Merge pull request #179 from BaaRai/feat/target-gate-extract-resolve-gate

feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7)
This commit is contained in:
BaaRai
2026-11-11 14:42:45 +01:00
committed by GitHub
24 changed files with 4940 additions and 36 deletions
+29
View File
@@ -0,0 +1,29 @@
package agent
import (
"testing"
"github.com/Autumn-27/artex/intercept"
)
// The domain tools read and write the platform's own records (the exploration
// graph, assets, findings, goals). The target gate must find no target in
// their input: an intent or a finding that mentions a URL is not a connection.
// The gate scans every tool it does not know, so a domain tool added here has
// to be added to the gate's list of tools without a target.
func TestDomainToolsDeclareNoTarget(t *testing.T) {
seeds := BuiltinToolSeeds()
if len(seeds) < 20 {
t.Fatalf("only %d domain tools found", len(seeds))
}
input := []byte(`{"summary":"probe https://app.example.com/login","items":[{"url":"https://app.example.com:8443/","ip":"203.0.113.7"}],` +
`"poc":"curl -sk https://app.example.com/ -x http://127.0.0.1:8080","scope":"203.0.113.0/24"}`)
if targets := intercept.ExtractTargets("a_tool_the_gate_does_not_know", input); len(targets) == 0 {
t.Fatal("control: the input names targets, and an unknown tool is scanned")
}
for _, seed := range seeds {
if targets := intercept.ExtractTargets(seed.Key, input); len(targets) != 0 {
t.Errorf("%s: the gate reads targets %+v from a domain tool", seed.Key, targets)
}
}
}
+3
View File
@@ -320,6 +320,9 @@ ON CONFLICT (name) DO NOTHING`,
if err := d.seedDefaultAssetInterceptRules(); err != nil {
return fmt.Errorf("seed asset intercept rules: %w", err)
}
if err := d.seedTargetGateExemptions(); err != nil {
return fmt.Errorf("seed target-gate exemptions: %w", err)
}
return nil
}
+6 -2
View File
@@ -43,7 +43,8 @@ type InterceptAudit struct {
Output string `json:"output,omitempty"`
OutputTruncated bool `json:"output_truncated,omitempty"`
ExecutionEndedAt *time.Time `json:"execution_ended_at,omitempty"`
// Targets is set on target approval requests only (target_key IS NOT NULL).
// Targets is set on the rows of the target gate only (decision_source = target):
// approval requests, blocks, and the first use of an exemption or pin version.
Targets []InterceptTarget `json:"targets,omitempty"`
// MergedAsk marks a target request that also carries the ask of a rule or of
// the judge: one row, one decision for both.
@@ -68,10 +69,13 @@ type InterceptTarget struct {
PinVersion int `json:"pin_version,omitempty"`
PinnedIPs []string `json:"pinned_ips,omitempty"`
Reason string `json:"reason,omitempty"` // no_pin | new_ip | revoked
Outcome string `json:"outcome"` // requested | pinned | denied
Outcome string `json:"outcome"` // requested | pinned | denied | pin_match | exempt | rule_blocked | resolve_failed | too_many | gate_unavailable
// NameOnlyEffective: requested while the upstream egress proxy was set; a pin
// created from this request covers the name only.
NameOnlyEffective bool `json:"name_only_effective,omitempty"`
// ExemptVoid: a stored exemption names this target, and did not apply because
// the name resolved to a private, loopback or otherwise local address.
ExemptVoid bool `json:"exempt_void,omitempty"`
}
type InterceptDetail struct {
+161 -1
View File
@@ -334,6 +334,10 @@ WHERE id=$1 AND status='pending' AND target_key IS NOT NULL FOR UPDATE`, id).Sca
return pin, true, nil
}
// ErrTargetPinEmpty refuses an approval whose request displayed no address: a
// pin never holds zero addresses, whatever the previous pin of the key holds.
var ErrTargetPinEmpty = errors.New("the target request lists no resolved address; an approval never pins an empty address list")
// writeTargetPin inserts the pin an approved request creates and fills the
// request's target with what was pinned. The only writer of target_pins rows.
func writeTargetPin(tx *sql.Tx, scope TargetScopeRef, interceptID int64, t *InterceptTarget, approver, remoteAddr string) (*TargetPin, error) {
@@ -349,7 +353,12 @@ WHERE `+targetPinScope+` AND host=$3 AND port=$4`, scope.TaskID, scope.Conversat
}
ips := map[string]bool{}
for _, ip := range t.ResolvedIPs {
ips[ip] = true
if ip != "" {
ips[ip] = true
}
}
if len(ips) == 0 {
return nil, ErrTargetPinEmpty
}
prev, err := scanTargetPin(tx.QueryRow(`SELECT `+targetPinCols+` FROM target_pins
WHERE `+targetPinScope+` AND host=$3 AND port=$4 AND status='active' FOR UPDATE`, scope.TaskID, scope.ConversationID, t.Host, t.Port))
@@ -391,3 +400,154 @@ RETURNING `+targetPinCols,
t.Outcome, t.PinID, t.PinVersion, t.PinnedIPs = "pinned", pin.ID, pin.Version, pin.PinnedIPs
return pin, nil
}
// TargetDecision is an outcome of the target gate that no human took: a block,
// or the first use of an exemption or of a pin version.
type TargetDecision struct {
Scope TargetScopeRef
AgentName string
ToolName string
ToolInput []byte
Status string // allowed | denied
Reason string
Audit *InterceptAudit
}
// LogTargetDecision records a target-gate outcome as an already-decided row
// (decided_by = system, decision_source = target). It opens no request and
// never pins.
func (d *DB) LogTargetDecision(r TargetDecision) (int64, error) {
if err := r.Scope.check(); err != nil {
return 0, err
}
raw := json.RawMessage(r.ToolInput)
if len(raw) == 0 {
raw = json.RawMessage("{}")
}
taskID, convID := r.Scope.pendingScope()
var id int64
err := d.QueryRow(`
INSERT INTO intercept_pending(conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit, decided_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'target', $8, 'system') RETURNING id`,
convID, taskID, r.AgentName, r.ToolName, raw, r.Status, r.Reason, firstAudit([]*InterceptAudit{r.Audit})).Scan(&id)
return id, err
}
// SettingTargetExemptHosts is the settings key of the stored target-gate
// exemption list: a JSON array of TargetExemption.
const SettingTargetExemptHosts = "target_gate_exempt_hosts"
// TargetExemption is one entry of the target-gate exemption list: an exact
// host and port that needs no approval and no pin. It is configuration, not an
// approval.
type TargetExemption struct {
Host string `json:"host"`
Port int `json:"port"`
Note string `json:"note,omitempty"`
}
// TargetExemptions returns the stored exemption list and its raw settings
// value ("" when the list was never stored).
func (d *DB) TargetExemptions() (list []TargetExemption, raw string, err error) {
raw, ok, err := d.GetSetting(SettingTargetExemptHosts)
if err != nil || !ok {
return nil, "", err
}
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return nil, raw, fmt.Errorf("stored target-gate exemption list: %w", err)
}
return list, raw, nil
}
// ReplaceTargetExemptions stores list as the exemption list and appends the
// change to target_gate_exemption_log, in one transaction. digest identifies
// the new list in the log and in the audit of the calls that use it.
func (d *DB) ReplaceTargetExemptions(list []TargetExemption, changedBy, digest string) error {
if list == nil {
list = []TargetExemption{}
}
after, err := json.Marshal(list)
if err != nil {
return err
}
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var before *string
var stored string
switch err := tx.QueryRow(`SELECT value FROM settings WHERE key=$1 FOR UPDATE`, SettingTargetExemptHosts).Scan(&stored); {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return err
case json.Valid([]byte(stored)):
before = &stored
}
if _, err := tx.Exec(`
INSERT INTO settings(key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, SettingTargetExemptHosts, string(after)); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO target_gate_exemption_log(changed_by, before, after, digest)
VALUES ($1, $2::jsonb, $3::jsonb, $4)`, changedBy, before, string(after), digest); err != nil {
return err
}
return tx.Commit()
}
// TargetExemptionChange is one row of target_gate_exemption_log.
type TargetExemptionChange struct {
ID int64 `json:"id"`
ChangedAt time.Time `json:"changed_at"`
ChangedBy string `json:"changed_by"`
Before json.RawMessage `json:"before"`
After json.RawMessage `json:"after"`
Digest string `json:"digest"`
}
// ListTargetExemptionLog returns the most recent changes of the exemption
// list, newest first.
func (d *DB) ListTargetExemptionLog(limit int) ([]TargetExemptionChange, error) {
rows, err := d.Query(`SELECT id, changed_at, changed_by, before, after, digest
FROM target_gate_exemption_log ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TargetExemptionChange{}
for rows.Next() {
var c TargetExemptionChange
var before, after []byte
if err := rows.Scan(&c.ID, &c.ChangedAt, &c.ChangedBy, &before, &after, &c.Digest); err != nil {
return nil, err
}
c.Before, c.After = before, after
out = append(out, c)
}
return out, rows.Err()
}
// seedTargetGateExemptions stores the initial exemption list once: the package
// sources of the runtime image, so that a package install does not ask for an
// approval. Gated by a settings flag like the rule seeds: the list is owned by
// the operator afterwards, and an entry they remove stays removed.
func (d *DB) seedTargetGateExemptions() error {
if v, _, _ := d.GetSetting("target_gate_exempt_seed_v1"); v == "done" {
return nil
}
raw, err := json.Marshal([]TargetExemption{
{Host: "pypi.org", Port: 443, Note: "[built-in] Python package index"},
{Host: "files.pythonhosted.org", Port: 443, Note: "[built-in] Python package files"},
{Host: "registry.npmjs.org", Port: 443, Note: "[built-in] npm registry"},
{Host: "deb.debian.org", Port: 80, Note: "[built-in] Debian packages"},
{Host: "deb.debian.org", Port: 443, Note: "[built-in] Debian packages"},
})
if err != nil {
return err
}
if _, err := d.InsertSettingIfAbsent(SettingTargetExemptHosts, string(raw)); err != nil {
return err
}
return d.SetSetting("target_gate_exempt_seed_v1", "done")
}
+169
View File
@@ -922,3 +922,172 @@ func TestResolveInterceptByRecordsApprover(t *testing.T) {
t.Fatalf("a system decision recorded an approver: %s", raw)
}
}
// An approval never pins zero addresses. A request that displays none cannot
// be approved, whether the key has a pin or not: the row stays pending, the
// pin of the key stays as it is, and the request can still be denied.
func TestTargetPinRefusesEmptyAddressList(t *testing.T) {
d := openTargetDB(t)
scope := TargetScopeRef{TaskID: targetTask(t, d, TaskCreateOptions{}).ID}
const host = "no-address.example.com"
for name, ips := range map[string][]string{"nil": nil, "empty": {}, "blank": {"", ""}} {
id, created := openTargetRequest(t, d, scope, host, 443, ips...)
if !created {
t.Fatalf("%s: the request was not created", name)
}
pin, ok, err := d.ResolveTargetRequest(id, true, "ARTEX", "198.51.100.9")
if !errors.Is(err, ErrTargetPinEmpty) || ok || pin != nil {
t.Fatalf("%s: approval of an empty address list: pin=%+v ok=%v err=%v, want ErrTargetPinEmpty", name, pin, ok, err)
}
row, err := d.GetInterceptPending(id)
if err != nil || row == nil || row.Status != "pending" {
t.Fatalf("%s: row after the refused approval = %+v, %v; want still pending", name, row, err)
}
if pins, err := d.ListTargetPins(scope); err != nil || len(pins) != 0 {
t.Fatalf("%s: pins after the refused approval = %+v, %v; want none", name, pins, err)
}
if _, ok, err := d.ResolveTargetRequest(id, false, "ARTEX", "198.51.100.9"); err != nil || !ok {
t.Fatalf("%s: denying the request: ok=%v err=%v", name, ok, err)
}
}
// The key has a pin with addresses: an empty request does not re-issue it.
v1 := approveTarget(t, d, scope, host, 443, "203.0.113.7")
id, _ := openTargetRequest(t, d, scope, host, 443)
if pin, ok, err := d.ResolveTargetRequest(id, true, "ARTEX", "198.51.100.9"); !errors.Is(err, ErrTargetPinEmpty) || ok || pin != nil {
t.Fatalf("empty request on a pinned key: pin=%+v ok=%v err=%v, want ErrTargetPinEmpty", pin, ok, err)
}
pins, err := d.ListTargetPins(scope)
if err != nil || len(pins) != 1 || pins[0].ID != v1.ID || pins[0].Status != TargetPinActive || pins[0].Version != 1 {
t.Fatalf("pins = %+v, %v; want v1 active and untouched", pins, err)
}
// Blank entries are dropped from a list that has addresses.
if _, ok, err := d.ResolveTargetRequest(id, false, "ARTEX", "198.51.100.9"); err != nil || !ok {
t.Fatal(ok, err)
}
v2 := approveTarget(t, d, scope, host, 443, "", "203.0.113.8")
if v2.Version != 2 || !slices.Equal(v2.PinnedIPs, []string{"203.0.113.7", "203.0.113.8"}) {
t.Fatalf("v2 = %+v, want both addresses and no blank entry", v2)
}
}
// The exemption list is seeded once with the package sources of the runtime
// image, and belongs to the operator afterwards: a start never restores an
// entry they removed. Every change is appended to the log with its previous value.
func TestTargetGateExemptionSeedAndLog(t *testing.T) {
d := openTargetDB(t)
seeded := []TargetExemption{
{Host: "pypi.org", Port: 443, Note: "[built-in] Python package index"},
{Host: "files.pythonhosted.org", Port: 443, Note: "[built-in] Python package files"},
{Host: "registry.npmjs.org", Port: 443, Note: "[built-in] npm registry"},
{Host: "deb.debian.org", Port: 80, Note: "[built-in] Debian packages"},
{Host: "deb.debian.org", Port: 443, Note: "[built-in] Debian packages"},
}
// Start from the state of a new database, and put the list back afterwards.
original, _, err := d.TargetExemptions()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.ReplaceTargetExemptions(original, "test", "restore") })
if _, err := d.Exec(`DELETE FROM settings WHERE key IN ($1, 'target_gate_exempt_seed_v1')`, SettingTargetExemptHosts); err != nil {
t.Fatal(err)
}
if list, raw, err := d.TargetExemptions(); err != nil || list != nil || raw != "" {
t.Fatalf("unset list = %+v, %q, %v", list, raw, err)
}
if err := d.seedTargetGateExemptions(); err != nil {
t.Fatal(err)
}
list, raw, err := d.TargetExemptions()
if err != nil || !slices.Equal(list, seeded) || raw == "" {
t.Fatalf("seeded list = %+v, %v; want the package sources", list, err)
}
// The operator replaces the list; the next start does not seed again.
changesBefore, err := d.ListTargetExemptionLog(1000)
if err != nil {
t.Fatal(err)
}
own := []TargetExemption{{Host: "mirror.example.org", Port: 443, Note: "internal mirror"}}
if err := d.ReplaceTargetExemptions(own, "alice", "digest-1"); err != nil {
t.Fatal(err)
}
if err := d.seedTargetGateExemptions(); err != nil {
t.Fatal(err)
}
if list, _, err := d.TargetExemptions(); err != nil || !slices.Equal(list, own) {
t.Fatalf("list after a second seed = %+v, %v; want the list of the operator", list, err)
}
// An emptied list stays empty, and is stored as [] rather than null.
if err := d.ReplaceTargetExemptions(nil, "bob", "digest-2"); err != nil {
t.Fatal(err)
}
if err := d.seedTargetGateExemptions(); err != nil {
t.Fatal(err)
}
if list, raw, err := d.TargetExemptions(); err != nil || len(list) != 0 || raw != "[]" {
t.Fatalf("emptied list = %+v, %q, %v", list, raw, err)
}
changes, err := d.ListTargetExemptionLog(1000)
if err != nil || len(changes) != len(changesBefore)+2 {
t.Fatalf("%d log rows added (err=%v), want 2", len(changes)-len(changesBefore), err)
}
decode := func(raw json.RawMessage) []TargetExemption {
t.Helper()
var out []TargetExemption
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("log value %q: %v", raw, err)
}
return out
}
second, first := changes[0], changes[1]
if first.ChangedBy != "alice" || first.Digest != "digest-1" || !slices.Equal(decode(first.Before), seeded) || !slices.Equal(decode(first.After), own) {
t.Fatalf("first change = %+v", first)
}
if second.ChangedBy != "bob" || second.Digest != "digest-2" || !slices.Equal(decode(second.Before), own) || len(decode(second.After)) != 0 || second.ID <= first.ID {
t.Fatalf("second change = %+v", second)
}
// The log is append-only: a change never rewrites an earlier row.
if again, err := d.ListTargetExemptionLog(1000); err != nil || len(again) != len(changes) || again[1].ID != first.ID || string(again[1].After) != string(first.After) {
t.Fatalf("log changed between two reads: %+v, %v", again, err)
}
}
// A decision of the gate that no human took is a decided row of the scope:
// decided by the system, source target, never a request and never a pin.
func TestLogTargetDecision(t *testing.T) {
d := openTargetDB(t)
task := targetTask(t, d, TaskCreateOptions{})
conv := targetConversation(t, d)
for name, scope := range map[string]TargetScopeRef{"task": {TaskID: task.ID}, "conversation": {ConversationID: conv.ID}} {
id, err := d.LogTargetDecision(TargetDecision{Scope: scope, AgentName: "planner", ToolName: "WebFetch",
ToolInput: []byte(`{"url":"https://app.example.com/"}`), Status: "denied", Reason: "TARGET_RESOLVE_FAILED: no such host",
Audit: &InterceptAudit{Correlation: "unavailable", EffectiveAction: "deny", ExecutionStatus: "not_executed",
Targets: []InterceptTarget{{Host: "app.example.com", Port: 443, Kind: "domain", Outcome: "resolve_failed"}}}})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
row, err := d.GetInterceptDetail(id)
if err != nil || row == nil {
t.Fatalf("%s: row=%+v err=%v", name, row, err)
}
if row.Status != "denied" || row.DecidedBy != "system" || row.DecisionSource != "target" || row.DecidedAt == nil || row.RuleID != nil ||
row.AgentName != "planner" || row.Audit == nil || row.Audit.Targets[0].Outcome != "resolve_failed" {
t.Fatalf("%s: row = %+v audit=%+v", name, row.InterceptApprovalRow, row.Audit)
}
if (row.TaskID != nil) != (scope.TaskID != 0) || (row.ConversationID != nil) != (scope.ConversationID != 0) {
t.Fatalf("%s: row scope = task %v conversation %v", name, row.TaskID, row.ConversationID)
}
if key, err := d.TargetRequestKey(id); err != nil || key != "" {
t.Fatalf("%s: target key = %q, %v; a decided row is not a request", name, key, err)
}
if n := pendingTargetRows(t, d, scope); n != 0 {
t.Fatalf("%s: %d open requests, want none", name, n)
}
}
if _, err := d.LogTargetDecision(TargetDecision{Status: "denied"}); err == nil {
t.Fatal("a decision without a scope was accepted")
}
}
+129 -26
View File
@@ -10,7 +10,11 @@ package guard
import (
"context"
"encoding/json"
"fmt"
"net"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -35,6 +39,7 @@ type Guard struct {
reg *hook.Registry
interceptor *intercept.Interceptor // optional; nil disables user-configured rules
targetOnly bool // planner / main agent: target gate only, no rules, no judge, no audit ring
gate targetGate // the target gate; nil without an interceptor
}
// New creates a Guard without user-configured intercept rules (used for pentest
@@ -50,18 +55,28 @@ func NewWithInterceptor(ic *intercept.Interceptor) *Guard { return newGuard(ic)
// ring (which keeps the worker history of the task's main Guard).
func NewTargetOnly(ic *intercept.Interceptor) *Guard {
g := &Guard{attrib: map[string]int{}, interceptor: ic, targetOnly: true}
g.setGate(ic)
g.reg = hook.NewRegistry().On(hook.PreToolUse, g.preToolUse)
return g
}
func newGuard(ic *intercept.Interceptor) *Guard {
g := &Guard{attrib: map[string]int{}, interceptor: ic}
g.setGate(ic)
g.reg = hook.NewRegistry().
On(hook.PreToolUse, g.preToolUse).
On(hook.PostToolUse, g.postToolUse)
return g
}
// setGate wires the interceptor in as the target gate. A nil interceptor leaves
// the gate unset rather than set to an interface that holds a nil pointer.
func (g *Guard) setGate(ic *intercept.Interceptor) {
if ic != nil {
g.gate = ic
}
}
// Hooks returns the hook registry to attach to an agent session.
func (g *Guard) Hooks() *hook.Registry { return g.reg }
@@ -93,45 +108,68 @@ func (g *Guard) preToolUse(ctx context.Context, ev hook.Event) hook.Result {
return g.applyIntercept(ctx, ev)
}
// targetGate is the seam of the approval gate for asset targets. The gate is not
// wired in yet: every call is allowed, and nothing is extracted, resolved, read
// or written.
func (g *Guard) targetGate(_ context.Context, _ hook.Event) hook.Result {
return hook.Result{}
// targetGate is the approval gate for asset targets, as the guard uses it.
// *intercept.Interceptor implements it.
type targetGate interface {
CheckTargets(ctx context.Context, scope intercept.TargetScope, toolName string, input []byte) ([]intercept.TargetRequest, intercept.TargetResult)
RequestTargets(ctx context.Context, scope intercept.TargetScope, toolName string, input []byte, targets []intercept.TargetRequest, ask *intercept.Decision) intercept.TargetResult
}
// applyIntercept evaluates user-configured intercept rules against the tool call.
// Both rules and the fallback judge receive the complete tool input.
// targetPasses bounds the request-and-recheck loop of one call: a request that
// was decided in between, or addresses that changed again after an approval,
// send the call back to the pin lookup at most this many times.
const targetPasses = 3
// gateScope returns the target-gate scope of the session, and whether its gate
// is on. With the gate off (the default) no call reaches the gate.
func (g *Guard) gateScope(ctx context.Context) (intercept.TargetScope, bool) {
scope, ok := intercept.TargetScopeFromContext(ctx)
return scope, ok && g.gate != nil && scope.Enabled != nil && scope.Enabled()
}
// applyIntercept evaluates user-configured intercept rules against the tool call,
// then the target gate. Both rules and the fallback judge receive the complete
// tool input.
//
// The rules and the judge apply to the enabled tools of a full guard. The
// target gate applies to every tool call of a session whose gate is on: a deny
// blocks first, before any target request exists; an allow by a rule or by the
// judge does not satisfy the gate; an ask and an unpinned target share one
// approval request.
func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
if g.targetOnly {
return g.targetGate(ctx, ev)
}
if g.interceptor == nil {
return hook.Result{}
}
if !g.interceptor.IsToolEnabled(ev.ToolName) {
scope, gated := g.gateScope(ctx)
rules := !g.targetOnly && g.interceptor != nil && g.interceptor.IsToolEnabled(ev.ToolName)
if !rules && !gated {
return hook.Result{}
}
ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input)
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
if !matched {
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
// or unwired, keep current behavior and allow.
d, judged := g.interceptor.Judge(ctx, ev.ToolName, ev.Input)
if !judged {
return hook.Result{}
var dec intercept.Decision
if rules {
var matched bool
dec, matched = g.interceptor.Match(ev.ToolName, ev.Input)
if !matched {
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
// or unwired, keep current behavior and allow.
dec, _ = g.interceptor.Judge(ctx, ev.ToolName, ev.Input)
}
dec = d
}
switch dec.Action {
case "deny":
if dec.Action == "deny" {
// Note: a deny hit does not block for approval; it directly logs a denied entry (visible on the history / task interception pages).
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied")
return g.block(ev.ToolName, systemBlockMessage(dec.Message), "")
}
var unpinned []intercept.TargetRequest
if gated {
var res intercept.TargetResult
if unpinned, res = g.gate.CheckTargets(ctx, scope, ev.ToolName, ev.Input); res.Code != "" {
return g.block(ev.ToolName, targetBlockMessage(res), "")
}
}
var ask *intercept.Decision
switch dec.Action {
case "allow":
// Record explicit rule and model approvals so review details remain auditable.
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
return hook.Result{}
case "ask":
// If the worker context is already cancelled (task stopped / killed), block
// immediately without creating a pending record — avoids orphaned DB entries
@@ -139,15 +177,80 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
if ctx.Err() != nil {
return g.block(ev.ToolName, systemBlockMessage("work cancelled; platform safety controls blocked execution"), "")
}
ask = &dec
}
if len(unpinned) > 0 {
res, merged := g.requestTargets(ctx, scope, ev, unpinned, ask)
if res.Code != "" {
return g.block(ev.ToolName, targetBlockMessage(res), "")
}
if merged {
// The approval of the target request answered the ask as well.
ask = nil
}
}
if ask != nil {
convID := intercept.ConvIDFromContext(ctx)
if !g.interceptor.HandleAsk(ctx, convID, dec, ev.ToolName, ev.Input) {
return g.block(ev.ToolName, systemBlockMessage("manual approval not granted (user rejected or approval timed out)"), "")
}
return hook.Result{}
}
return hook.Result{}
}
// requestTargets asks for the approval of the targets no pin covers, waits for
// the decisions, and checks the call again: it runs only if every target is
// covered then. A code in the result blocks the call. merged reports that the
// ask was carried by a request this call created, so it needs no prompt of its
// own.
func (g *Guard) requestTargets(ctx context.Context, scope intercept.TargetScope, ev hook.Event, unpinned []intercept.TargetRequest, ask *intercept.Decision) (res intercept.TargetResult, merged bool) {
for pass := 0; pass < targetPasses; pass++ {
if res = g.gate.RequestTargets(ctx, scope, ev.ToolName, ev.Input, unpinned, ask); res.Code != "" {
return res, merged
}
if res.AskMerged {
merged, ask = true, nil
}
if unpinned, res = g.gate.CheckTargets(ctx, scope, ev.ToolName, ev.Input); res.Code != "" || len(unpinned) == 0 {
return res, merged
}
}
t := unpinned[0]
return intercept.TargetResult{Code: intercept.TargetApprovalPending, Host: t.Host, Port: t.Port,
Reason: fmt.Sprintf("the target is still not covered by a pin after %d approval rounds; its addresses changed again", targetPasses)}, merged
}
// targetGuidance tells the model what a target block means for its next step.
var targetGuidance = map[string]string{
intercept.TargetApprovalPending: "The call was not executed. An approval by the operator is needed and may still arrive: retry the same call later. " +
"Do not rewrite the command, switch tools or substitute an IP address.",
intercept.TargetDenied: "The call was not executed. The operator did not approve this target: do not retry it and do not reach it another way.",
intercept.TargetResolveFailed: "The call was not executed and no approval was requested. " +
"This is a resolver failure on the platform side, not a block by the target. " +
"Do not rewrite the command, switch tools or substitute an IP address. Retry the same call later, or report the failure.",
intercept.TargetRuleBlocked: "The call was not executed and no approval was requested. " +
"The operator's rules exclude this target: do not reach it another way.",
intercept.TargetTooMany: "The call was not executed and no approval was requested. Name fewer new targets per call.",
intercept.TargetGateUnavailable: "The call was not executed. This is a failure on the platform side, not a block by the target. " +
"Retry the same call later, or report the failure.",
}
// targetBlockMessage is systemBlockMessage for the target gate: the block code,
// the reason and the target, then what to do about it. A target block is not
// always final (an approval can still arrive, a lookup can succeed later), so
// it does not end with "This operation is forbidden."
func targetBlockMessage(res intercept.TargetResult) string {
reason := res.Reason
if res.Host != "" && !strings.Contains(reason, res.Host) {
target := res.Host
if res.Port != 0 {
target = net.JoinHostPort(res.Host, strconv.Itoa(res.Port))
}
reason = "target " + target + ": " + reason
}
return "[ARTEX platform control, not target defense] " + res.Code + ": " + reason + ". " + targetGuidance[res.Code]
}
// systemBlockMessage frames an intercept block as an ARTEX platform-governance
// decision so the agent does not mistake it for a target-side defense.
//
+5 -4
View File
@@ -37,10 +37,11 @@ func TestPreToolUsePassthrough(t *testing.T) {
}
}
// The target-only guard (planner, main agent) runs the target-gate seam and
// nothing else: no intercept rule, no fallback judge, no audit-ring entry. Its
// interceptor has no database behind it, so any read or write through it would
// panic — passing proves the seam allows every call without touching the store.
// The target-only guard (planner, main agent) runs the target gate and nothing
// else: no intercept rule, no fallback judge, no audit-ring entry. Here the gate
// is off (no scope, or a scope without the flag). Its interceptor has no database
// behind it, so any read or write through it would panic — passing proves that
// with the gate off every call is allowed without touching the store.
func TestTargetOnlyGuardSkipsRules(t *testing.T) {
calls := []struct {
tool string
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
package guard
import (
"os"
"testing"
"github.com/Autumn-27/artex/db"
)
func TestMain(m *testing.M) {
// Own database per package (#80), as in db, agent, server, evidence and intercept.
db.SuffixTestDatabase("guard")
os.Exit(m.Run())
}
+11 -1
View File
@@ -181,6 +181,15 @@ type Interceptor struct {
// upstreamProxy reports whether target traffic leaves through an upstream
// egress proxy (nil = no). Injected by the server layer.
upstreamProxy func() bool
// derivedExempt lists the endpoints of the platform's own services (nil =
// none). Injected by the server layer. See SetDerivedExemptions.
derivedExempt func() []string
// targets is what the target gate reads and writes; resolve resolves the
// names it checks. firstUse holds the exemptions and pin versions whose first
// use this process has logged.
targets targetStore
resolve *resolveCache
firstUse sync.Map
}
// SetUpstreamProxyCheck installs the function that reports whether an upstream
@@ -210,7 +219,8 @@ var defaultEnabledTools = []string{
// New creates an Interceptor backed by d. The rule cache is lazy-loaded on
// first use.
func New(d *db.DB) *Interceptor {
return &Interceptor{db: d, pending: newPendingManager()}
return &Interceptor{db: d, pending: newPendingManager(),
targets: dbTargetStore{d}, resolve: newResolveCache(systemResolver{})}
}
// Invalidate clears the in-memory rule cache and the enabled-tools cache.
+176
View File
@@ -0,0 +1,176 @@
package intercept
import (
"net/netip"
"net/url"
"strconv"
"strings"
"golang.org/x/net/idna"
)
// Kinds of target.
const (
TargetDomain = "domain"
TargetIP = "ip"
TargetCIDR = "cidr"
)
// Target is one network target a tool call declares, in normalised form. With
// its scope, Host and Port are the key of a pin and of an approval request;
// path, query and scheme are never part of it.
type Target struct {
Kind string // domain | ip | cidr
Host string // lower-case punycode name, canonical IP literal, or masked CIDR
Port int // 0 = port not declared
}
// schemePorts are the default ports of the schemes a target is usually written
// with. A URL of another scheme without an explicit port has port 0.
var schemePorts = map[string]int{"http": 80, "https": 443, "ws": 80, "wss": 443, "ftp": 21}
// hostProfile maps a name the way a lookup does (case, width, punycode) without
// the strict rules that would refuse an underscore label.
var hostProfile = idna.New(idna.MapForLookup(), idna.BidiRule())
// NormalizeTarget parses one target written as a URL with a scheme, or as a
// host, host:port, [IPv6]:port, IP literal or CIDR. The name is lower-cased, its
// trailing dot, userinfo and IPv6 brackets are removed, the host of a URL is
// percent-decoded, an IPv4-mapped IPv6 address is unmapped, the decimal, hex, octal and short forms of an IPv4
// address become dotted, and a Unicode name becomes punycode. The port is the
// explicit one, else the default of the scheme, else 0.
func NormalizeTarget(raw string) (Target, bool) {
raw = strings.TrimSpace(raw)
def, isURL := 0, false
if i := strings.Index(raw, "://"); i > 0 {
isURL = true
def = schemePorts[strings.ToLower(raw[:i])]
raw = raw[i+3:]
if end := strings.IndexAny(raw, "/?#"); end >= 0 {
raw = raw[:end]
}
raw = raw[strings.LastIndexByte(raw, '@')+1:]
}
host, port := raw, ""
switch {
case strings.HasPrefix(raw, "["):
end := strings.IndexByte(raw, ']')
if end < 0 {
return Target{}, false
}
host = raw[1:end]
if rest := raw[end+1:]; rest != "" {
if rest[0] != ':' {
return Target{}, false
}
port = rest[1:]
}
case strings.Count(raw, ":") == 1:
host, port, _ = strings.Cut(raw, ":")
}
if isURL && !strings.HasPrefix(raw, "[") {
// A client decodes a percent-encoded host before it resolves it.
if decoded, err := url.PathUnescape(host); err == nil {
host = decoded
}
}
t := Target{Port: def}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return Target{}, false
}
t.Port = n
}
var ok bool
t.Kind, t.Host, ok = normalizeHost(host, true)
return t, ok
}
// normalizeHost returns the kind and the canonical form of a host. numeric
// accepts the forms of an IPv4 address that are not dotted decimal
// (2130706433, 0x7f000001, 0177.0.0.1, 127.1), which a URL host may use; in
// free text they would turn every number into an address.
func normalizeHost(raw string, numeric bool) (kind, host string, ok bool) {
h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
if h == "" {
return "", "", false
}
if strings.Contains(h, "/") {
p, err := netip.ParsePrefix(h)
if err != nil {
return "", "", false
}
return TargetCIDR, p.Masked().String(), true
}
if a, err := netip.ParseAddr(h); err == nil {
return TargetIP, a.Unmap().WithZone("").String(), true
}
if numeric {
if a, ok := parseIPv4Forms(h); ok {
return TargetIP, a.String(), true
}
}
// A name the profile refuses is kept as written: it then fails to resolve and
// the call is blocked, instead of passing as a call without a target.
if name, err := hostProfile.ToASCII(h); err == nil && name != "" {
h = name
}
return TargetDomain, h, true
}
// parseIPv4Forms reads an IPv4 address the way inet_aton does: one to four
// parts, each decimal, hex (0x) or octal (leading 0), the last one filling the
// remaining bytes.
func parseIPv4Forms(s string) (netip.Addr, bool) {
parts := strings.Split(s, ".")
if len(parts) > 4 {
return netip.Addr{}, false
}
var v uint64
for n, p := range parts {
base := 10
switch {
case strings.HasPrefix(p, "0x"):
p, base = p[2:], 16
case len(p) > 1 && p[0] == '0':
p, base = p[1:], 8
}
x, err := strconv.ParseUint(p, base, 32)
if err != nil {
return netip.Addr{}, false
}
if n < len(parts)-1 {
if x > 255 {
return netip.Addr{}, false
}
v = v<<8 | x
continue
}
rest := uint(8 * (4 - n))
if x >= 1<<rest {
return netip.Addr{}, false
}
v = v<<rest | x
}
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}), true
}
// validHostname reports whether h is a plain DNS name: labels of letters,
// digits, hyphen and underscore. A wildcard or a pattern is not one.
func validHostname(h string) bool {
if len(h) > 253 {
return false
}
for _, label := range strings.Split(h, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, c := range []byte(label) {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
}
return true
}
+119
View File
@@ -1,8 +1,13 @@
package intercept
import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"strings"
"github.com/Autumn-27/artex/db"
)
// metadataAddrs are instance-metadata addresses that lie outside the link-local
@@ -51,3 +56,117 @@ func ValidateExemptionHost(host string) error {
}
return nil
}
// ErrInvalidExemption is wrapped by the reason an exemption entry is refused.
var ErrInvalidExemption = errors.New("invalid exemption")
// SetDerivedExemptions installs the function that lists the endpoints of the
// platform's own services, as URLs or host:port: the LLM gateways, the search
// backend in use and the platform proxies. They are exempt because the
// configuration names them. They are computed at check time and never stored,
// so they follow the configuration and cannot be set through the exemption
// list. A derived exemption is never resolved and never voided: an internal
// relay for a platform service is a normal deployment.
func (i *Interceptor) SetDerivedExemptions(f func() []string) {
i.mu.Lock()
i.derivedExempt = f
i.mu.Unlock()
}
// DerivedExemptions returns the exemptions derived from the configuration, as
// exact host and port. An endpoint without a port is not listed.
func (i *Interceptor) DerivedExemptions() []db.TargetExemption {
i.mu.RLock()
f := i.derivedExempt
i.mu.RUnlock()
out := []db.TargetExemption{}
if f == nil {
return out
}
seen := map[string]bool{}
for _, raw := range f() {
t, ok := NormalizeTarget(raw)
if !ok || t.Port == 0 || t.Kind == TargetCIDR || seen[db.TargetKey(t.Host, t.Port)] {
continue
}
seen[db.TargetKey(t.Host, t.Port)] = true
out = append(out, db.TargetExemption{Host: t.Host, Port: t.Port})
}
return out
}
// Exemptions returns the stored exemption list and its digest.
func (i *Interceptor) Exemptions() ([]db.TargetExemption, string, error) {
return i.targets.Exemptions()
}
// SetExemptions validates list and stores it as the exemption list; the change
// is appended to the exemption log with changedBy. An entry is an exact host
// and port: a name or an IP literal, no wildcard, no suffix, no CIDR, and a
// port between 1 and 65535. It returns the list as stored (hosts normalised,
// duplicates dropped) and its digest.
func (i *Interceptor) SetExemptions(list []db.TargetExemption, changedBy string) ([]db.TargetExemption, string, error) {
out := []db.TargetExemption{}
seen := map[string]bool{}
for _, e := range list {
kind, host, ok := normalizeHost(e.Host, true)
switch {
case !ok || kind == TargetCIDR || kind == TargetDomain && !validHostname(host):
return nil, "", fmt.Errorf("%w: %q is not an exact host name or IP address", ErrInvalidExemption, e.Host)
case e.Port < 1 || e.Port > 65535:
return nil, "", fmt.Errorf("%w: %s needs a port between 1 and 65535, got %d", ErrInvalidExemption, host, e.Port)
}
if err := ValidateExemptionHost(host); err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrInvalidExemption, err)
}
if key := db.TargetKey(host, e.Port); !seen[key] {
seen[key] = true
out = append(out, db.TargetExemption{Host: host, Port: e.Port, Note: strings.TrimSpace(e.Note)})
}
}
raw, err := json.Marshal(out)
if err != nil {
return nil, "", err
}
digest := digestInput(raw)
if err := i.db.ReplaceTargetExemptions(out, changedBy, digest); err != nil {
return nil, "", err
}
return out, digest, nil
}
// exemptions is the exemption list as one gated call sees it.
type exemptions struct {
derived map[string]bool // db.TargetKey(host, port)
stored map[string]bool
digest string // of the stored list
}
func (i *Interceptor) loadExemptions() (exemptions, error) {
stored, digest, err := i.targets.Exemptions()
if err != nil {
return exemptions{}, err
}
ex := exemptions{derived: map[string]bool{}, stored: map[string]bool{}, digest: digest}
for _, e := range i.DerivedExemptions() {
ex.derived[db.TargetKey(e.Host, e.Port)] = true
}
for _, e := range stored {
ex.stored[db.TargetKey(e.Host, e.Port)] = true
}
return ex, nil
}
// voidsExemption reports whether one resolved address voids a stored
// exemption on a name: a private address (10/8, 172.16/12, 192.168/16, the
// unique local range fc00::/7), or one that is never exempt as a literal
// either (loopback, link-local 169.254.0.0/16 and fe80::/10, unspecified,
// multicast, instance metadata). One such address is enough.
func voidsExemption(ip string) bool {
a, err := netip.ParseAddr(ip)
if err != nil {
return true
}
a = a.Unmap().WithZone("")
return a.IsPrivate() || ValidateExemptionHost(a.String()) != nil
}
+320
View File
@@ -0,0 +1,320 @@
package intercept
import (
"bytes"
"encoding/json"
"net/netip"
"regexp"
"strconv"
"strings"
)
// executeExtraTool is the tool that runs a deferred tool (every MCP tool, and
// the custom tools flagged deferred). The hook sees it, never the inner name.
const executeExtraTool = "ExecuteExtraTool"
// noTargetTools are the tools whose input names no network target: the search
// tool (its input is a query), the file and session tools, and the platform's
// domain tools, which read and write the platform's own records. Every name
// that is not listed here is scanned, so a tool added later is gated by default.
var noTargetTools = toolSet(
"web_search",
"Read", "Write", "Edit", "MultiEdit", "LS", "Glob", "Grep",
"TodoWrite", "sleep", "TaskOutput", "TaskStop", "TaskList",
// exploration graph, assets, findings, goals (agent.ToolSet)
"graph_overview", "list_findings", "list_facts", "node_detail", "expand_digest",
"get_worker_output", "get_worker_trace", "search_all_worker_traces",
"add_hint", "add_intent", "steer_work", "kill_work",
"set_goals", "set_constraints", "list_goals", "prove_goal", "goal_met",
"insert_assets", "add_company_scope", "add_task_scope", "list_assets",
"list_untested_assets", "list_companies", "report_finding", "record_fact",
"save_task_credential", "list_task_credentials",
// cross-task orchestration, findings and retests (server)
"list_tasks", "list_llm_profiles", "spawn_task", "pause_task", "get_task_graph",
"list_task_findings", "add_task_hint", "get_task_worker_trace",
"list_task_worker_traces", "search_task_worker_traces", "get_task_node_detail",
"update_finding_report", "get_finding_traffic", "bind_finding_traffic",
"get_finding_retest_context", "record_finding_retest_result",
"delete_assets_by_host", "create_skill", "update_skill_file",
// the operator runs the command, not the platform
"ask_operator",
// recorded traffic
"traffic_search", "traffic_get", "traffic_blob",
)
func toolSet(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
return m
}
// ExtractTargets returns the network targets a tool call declares in its
// input, normalised and without duplicates, in order of appearance.
//
// WebFetch names its target exactly. The command tools are read with the
// command-string rules: URLs with a scheme, IP literals, IP:port, host:port and
// CIDR. A bare host name is not a target there, because a file name such as
// x.py is a valid host name. Every other tool that is not known to have no
// target has each string of its input read with the same rules. This is what
// the model declared: a command can hide its target, and the process resolves
// names on its own. The gate detects a declared target; it does not prevent an
// undeclared connection.
func ExtractTargets(toolName string, input []byte) []Target {
if toolName == executeExtraTool {
var in struct {
ToolName string `json:"tool_name"`
Params json.RawMessage `json:"params"`
}
if json.Unmarshal(input, &in) == nil && in.ToolName != "" && in.ToolName != executeExtraTool {
toolName, input = in.ToolName, in.Params
}
}
if noTargetTools[toolName] {
return nil
}
var out targetList
switch toolName {
case "WebFetch":
if t, ok := NormalizeTarget(declaredFields(input).URL); ok {
out.add(t)
}
case "Bash", "shell_open", "Monitor":
out.scan(declaredFields(input).Command)
case "shell_send":
out.scan(declaredFields(input).Text)
default:
out.scanStrings(input)
}
return out.targets
}
// declaredInput holds the fields of the built-in tools that carry a target.
type declaredInput struct {
URL string `json:"url"`
Command string `json:"command"`
Text string `json:"text"`
}
// declaredFields decodes the input into a struct, as the tools themselves do:
// a key matches whatever its case and the last duplicate wins. The gate
// therefore reads the value the tool will run, not another spelling of the key.
func declaredFields(input []byte) declaredInput {
var in declaredInput
_ = json.Unmarshal(input, &in) // a field of another type is skipped, the others are kept
return in
}
type targetList struct {
targets []Target
seen map[Target]bool
}
func (l *targetList) add(t Target) {
if l.seen[t] {
return
}
if l.seen == nil {
l.seen = map[Target]bool{}
}
l.seen[t] = true
l.targets = append(l.targets, t)
}
// scanStrings reads every string of a JSON input, in document order. It walks
// the tokens instead of decoding into a map, so both values of a duplicated key
// are read: the gate cannot know which one the tool behind the call keeps.
func (l *targetList) scanStrings(input []byte) {
dec := json.NewDecoder(bytes.NewReader(input))
for {
tok, err := dec.Token()
if err != nil {
return
}
if s, ok := tok.(string); ok {
l.scan(s)
}
}
}
// shellMeta are the characters that end a word for an unquoted shell.
const shellMeta = `|;&(){},\`
// urlHosts returns the readings of the authority of a URL found in a command.
// Whether the URL is quoted decides how its authority is read when a shell
// character sits in it: 'http://a.example;@b.example/' names b.example to the
// client, and without the quotes the shell ends the URL at a.example. A
// backslash is dropped by the shell and kept inside quotes. The gate cannot
// tell which applies, so it returns every reading and each one is a target.
func urlHosts(authority string) []string {
host := func(s string) string {
s = s[strings.LastIndexByte(s, '@')+1:]
if i := strings.IndexAny(s, shellMeta); i >= 0 {
s = s[:i]
}
return s
}
out := []string{host(authority)}
if i := strings.IndexAny(authority, shellMeta); i >= 0 {
out = append(out, host(authority[:i]), host(strings.ReplaceAll(authority, `\`, "")))
}
return out
}
var (
// A URL with a scheme: the scheme, the authority, then the rest of the token.
// The rest is consumed so that what a path or a query contains is not read
// as a target.
reURL = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.\-]*://)([^\s/?#"'<>` + "`" + `]*)[^\s"'<>` + "`" + `]*`)
// [IPv6] and [IPv6]:port.
reBracketed = regexp.MustCompile(`\[([0-9A-Fa-f:.]+(?:%[\w.\-]+)?)\](?::(\d{1,5}))?`)
// IPv4, IPv4:port and IPv4/len.
reIPv4 = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2}|:\d{1,5})?`)
// IPv6 and IPv6/len: at least two colons, checked by the parser afterwards.
reIPv6 = regexp.MustCompile(`[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*(?:%[\w.\-]+)?(?:/\d{1,3})?`)
// name:port, for a name with a dot and an alphabetic last label, or localhost.
// A single label is not read as a host: user:1234 and image:22 are not targets.
reHostPort = regexp.MustCompile(`(?i)(?:localhost|[a-z0-9_](?:[a-z0-9_\-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_\-]*[a-z0-9_])?)*\.[a-z][a-z0-9\-]*[a-z0-9]):\d{1,5}`)
)
// scan reads the targets of one string with the command-string rules. Each
// form is blanked once it is read, so a later rule never reads part of it again.
func (l *targetList) scan(s string) {
if s == "" {
return
}
b := []byte(s)
blank := func(from, to int) {
for i := from; i < to; i++ {
b[i] = ' '
}
}
each := func(re *regexp.Regexp, read func(m []int) bool) {
for _, m := range re.FindAllSubmatchIndex(b, -1) {
if read(m) {
blank(m[0], m[1])
}
}
}
each(reURL, func(m []int) bool {
scheme, found := string(b[m[2]:m[3]]), false
for _, host := range urlHosts(string(b[m[4]:m[5]])) {
// A host that is not a name ($HOST, a glob) is what the shell or the
// client will make of it, which the gate cannot read.
if t, ok := NormalizeTarget(scheme + host); ok && (t.Kind != TargetDomain || validHostname(t.Host)) {
l.add(t)
found = true
}
}
// The scheme and what follows the authority are never read again. An
// authority that named no host is: an address written in it still counts.
blank(m[0], m[4])
blank(m[5], m[1])
return found
})
each(reBracketed, func(m []int) bool {
a, err := netip.ParseAddr(string(b[m[2]:m[3]]))
if err != nil {
return false
}
t := Target{Kind: TargetIP, Host: a.Unmap().WithZone("").String()}
if m[4] >= 0 {
t.Port = validPort(string(b[m[4]:m[5]]))
}
l.add(t)
return true
})
each(reIPv6, func(m []int) bool {
start, end := m[0], m[1]
if start > 0 && isWord(b[start-1]) || end < len(b) && (isWord(b[end]) || b[end] == ':') {
return false
}
kind, host, ok := normalizeHost(string(b[start:end]), false)
// "::" alone is punctuation (a slice, a scope operator), not an address.
if !ok || kind == TargetDomain || host == "::" {
return false
}
l.add(Target{Kind: kind, Host: host})
return true
})
each(reIPv4, func(m []int) bool {
start, end := m[0], m[1]
if start > 0 && (isWord(b[start-1]) || b[start-1] == '.') {
return false
}
// The address alone, when what follows it is not a port or a prefix length.
addrEnd := start + bytes.IndexAny(b[start:end], "/:")
if addrEnd < start {
addrEnd = end
}
if !endsToken(b, end) {
end = addrEnd
if !endsToken(b, end) {
return false
}
}
a, err := netip.ParseAddr(string(b[start:addrEnd]))
if err != nil {
return false
}
t := Target{Kind: TargetIP, Host: a.String()}
if end > addrEnd {
switch suffix := string(b[addrEnd+1 : end]); b[addrEnd] {
case '/':
if p, err := netip.ParsePrefix(string(b[start:end])); err == nil {
t = Target{Kind: TargetCIDR, Host: p.Masked().String()}
}
case ':':
t.Port = validPort(suffix)
}
}
l.add(t)
return true
})
each(reHostPort, func(m []int) bool {
start, end := m[0], m[1]
// Not the tail of a longer word, a path or an address, and not the head of
// a version number (name:1.25).
if start > 0 && (isWord(b[start-1]) || strings.IndexByte(`.-/\`, b[start-1]) >= 0) {
return false
}
if !endsToken(b, end) {
return false
}
host, port, _ := strings.Cut(string(b[start:end]), ":")
n := validPort(port)
_, host, ok := normalizeHost(host, false)
if n == 0 || !ok {
return false
}
l.add(Target{Kind: TargetDomain, Host: host, Port: n})
return true
})
}
// endsToken reports whether the token that ends before b[i] really ends there:
// what follows is not a word character and not a dot that continues a name or
// a number.
func endsToken(b []byte, i int) bool {
if i >= len(b) {
return true
}
if isWord(b[i]) {
return false
}
return b[i] != '.' || i+1 >= len(b) || !isWord(b[i+1])
}
func isWord(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
}
// validPort returns the port s names, 0 when it is not one.
func validPort(s string) int {
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 65535 {
return 0
}
return n
}
+363
View File
@@ -0,0 +1,363 @@
package intercept
import (
"context"
"fmt"
"net/netip"
"strings"
"time"
"github.com/Autumn-27/artex/db"
)
// Block codes of the gate decision. Like the codes of a target request they
// are carried in the model-facing text of the blocked call. None of them opens
// an approval request.
const (
// TargetResolveFailed: a name did not resolve, so there is no address to
// compare with a pin or to show to a human.
TargetResolveFailed = "TARGET_RESOLVE_FAILED"
// TargetRuleBlocked: an asset block rule matches a target.
TargetRuleBlocked = "TARGET_RULE_BLOCKED"
// TargetTooMany: the call names more unpinned targets than one call may request.
TargetTooMany = "TARGET_TOO_MANY"
)
// maxUnpinnedTargets is how many distinct targets without a covering pin one
// call may request approval for. Each approval is one click on one host.
const maxUnpinnedTargets = 5
// literalResolver is the resolver recorded for an IP literal or a CIDR: the
// address is what was written, nothing was resolved.
const literalResolver = "literal"
// targetStore is what the gate reads and writes. The database implements it.
type targetStore interface {
// BlockRules returns the asset block rules that apply to the scope: the
// global ones and, for a task, its own. Allow rules play no role in the gate.
BlockRules(scope db.TargetScopeRef) ([]db.AssetInterceptRule, error)
// ActivePins returns the active pins that may cover (host, port) in scope,
// the pin on the exact port first.
ActivePins(scope db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error)
// Exemptions returns the stored exemption list and its digest.
Exemptions() ([]db.TargetExemption, string, error)
// LogDecision records an outcome no human took.
LogDecision(db.TargetDecision) error
}
type dbTargetStore struct{ d *db.DB }
func (s dbTargetStore) BlockRules(scope db.TargetScopeRef) ([]db.AssetInterceptRule, error) {
rules, err := s.d.ListAssetInterceptRules()
if err != nil || scope.TaskID == 0 {
return rules, err
}
block, _, err := s.d.Assets().TaskInterceptRulesSplit(scope.TaskID)
if err != nil {
return nil, err
}
return append(rules, block...), nil
}
func (s dbTargetStore) ActivePins(scope db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error) {
return s.d.ActiveTargetPins(scope, host, port)
}
func (s dbTargetStore) Exemptions() ([]db.TargetExemption, string, error) {
list, raw, err := s.d.TargetExemptions()
if raw == "" {
raw = "[]"
}
return list, digestInput([]byte(raw)), err
}
func (s dbTargetStore) LogDecision(r db.TargetDecision) error {
_, err := s.d.LogTargetDecision(r)
return err
}
// CheckTargets decides, for one tool call of a gated scope, whether every
// network target the call declares may be reached: extraction, asset block
// rules, exemptions, resolution and the pin lookup. It creates no approval
// request and never waits.
//
// A block is reported in the result (Code, Reason and the target that caused
// it) and logged as a decided row. Otherwise the returned requests are the
// targets no pin covers, each with the addresses a human has to read; the
// caller passes them to RequestTargets and checks again after the decisions.
// No request and no code means the call may run.
//
// With the gate off the call returns at once: nothing is extracted, resolved,
// read or written.
func (i *Interceptor) CheckTargets(ctx context.Context, scope TargetScope, toolName string, input []byte) ([]TargetRequest, TargetResult) {
if scope.Enabled == nil || !scope.Enabled() {
return nil, TargetResult{}
}
targets := ExtractTargets(toolName, input)
if len(targets) == 0 {
return nil, TargetResult{}
}
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input}
return c.run(targets)
}
// targetCheck is one run of the gate decision.
type targetCheck struct {
i *Interceptor
ctx context.Context
scope TargetScope
ref db.TargetScopeRef
tool string
input []byte
}
// checkedTarget is one target of the call and what the gate learned about it.
type checkedTarget struct {
Target
ips []string // resolved addresses; the literal itself for an IP or a CIDR
resolver string
resolvedAt time.Time
err error // the name did not resolve
exemptVoid bool
}
func (t Target) String() string {
return targetName(TargetRequest{Host: t.Host, Port: t.Port})
}
func (t *checkedTarget) audit(outcome string) db.InterceptTarget {
return db.InterceptTarget{Host: t.Host, Port: t.Port, Kind: t.Kind, ResolvedIPs: t.ips,
Resolver: t.resolver, ResolvedAt: t.resolvedAt, Outcome: outcome, ExemptVoid: t.exemptVoid}
}
func (c *targetCheck) run(targets []Target) ([]TargetRequest, TargetResult) {
i := c.i
var err error
if c.ref, err = c.scope.ref(); err != nil {
return nil, TargetResult{Code: TargetGateUnavailable, Host: targets[0].Host, Port: targets[0].Port, Reason: err.Error()}
}
first := &checkedTarget{Target: targets[0]}
rules, err := i.targets.BlockRules(c.ref)
if err != nil {
return c.block(TargetGateUnavailable, "gate_unavailable", first, "the asset rules could not be read: "+err.Error())
}
ex, err := i.loadExemptions()
if err != nil {
return c.block(TargetGateUnavailable, "gate_unavailable", first, "the exemption list could not be read: "+err.Error())
}
// Block rules come first, on the name and on what it resolves to: an exempt
// or pinned host that matches one is blocked. A name the configuration
// exempts is never resolved.
checked := make([]*checkedTarget, len(targets))
for n, t := range targets {
ct := &checkedTarget{Target: t}
checked[n] = ct
switch {
case t.Kind != TargetDomain:
ct.ips, ct.resolver, ct.resolvedAt = []string{t.Host}, literalResolver, time.Now().UTC()
case !ex.derived[db.TargetKey(t.Host, t.Port)]:
c.resolve(ct, false)
}
if rule, hit := blockRule(rules, ct); hit {
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", t, rule.Reason()))
}
}
i.mu.RLock()
proxied := i.upstreamProxy
i.mu.RUnlock()
// Behind the upstream proxy the proxy chooses the address it connects to, so
// every pin is name-only for this call, whatever the pin row says: the proxy
// can be turned on after a pin was approved.
nameOnly := proxied != nil && proxied()
var unpinned []TargetRequest
var uses []func()
for _, ct := range checked {
key := db.TargetKey(ct.Host, ct.Port)
if ex.derived[key] {
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
continue
}
if ex.stored[key] {
if exemptionHolds(ct) {
uses = append(uses, func() { c.exemptUsed(ct, ex.digest) })
continue
}
// A void exemption is not a block: the target goes on like any other.
ct.exemptVoid = true
}
if ct.err != nil {
return c.block(TargetResolveFailed, "resolve_failed", ct, c.resolveFailure(ct))
}
pins, err := i.targets.ActivePins(c.ref, ct.Host, ct.Port)
if err != nil {
return c.block(TargetGateUnavailable, "gate_unavailable", ct, "the pins could not be read: "+err.Error())
}
pin := coveringPin(pins, ct.ips, nameOnly)
if pin == nil && ct.Kind == TargetDomain {
// A request is about to be raised: the addresses a human reads and
// pins are resolved now, never taken from the cache.
if c.resolve(ct, true); ct.err != nil {
return c.block(TargetResolveFailed, "resolve_failed", ct, c.resolveFailure(ct))
}
if rule, hit := blockRule(rules, ct); hit {
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", ct.Target, rule.Reason()))
}
pin = coveringPin(pins, ct.ips, nameOnly)
}
if pin != nil {
uses = append(uses, func() { c.pinUsed(ct, pin) })
continue
}
req := TargetRequest{Host: ct.Host, Port: ct.Port, Kind: ct.Kind, ResolvedIPs: ct.ips,
Resolver: ct.resolver, ResolvedAt: ct.resolvedAt, Reason: "no_pin", ExemptVoid: ct.exemptVoid}
if len(pins) > 0 {
// The request is raised on the key of the pin that exists (the one on
// the exact port when both exist), so that an approval extends that pin.
req.Port, req.Reason, req.Pin = pins[0].Port, "new_ip", pins[0]
}
if !hasRequest(unpinned, req) {
unpinned = append(unpinned, req)
}
}
if len(unpinned) > maxUnpinnedTargets {
names := make([]string, len(unpinned))
audit := make([]db.InterceptTarget, len(unpinned))
for n, r := range unpinned {
names[n] = targetName(r)
audit[n] = db.InterceptTarget{Host: r.Host, Port: r.Port, Kind: r.Kind, ResolvedIPs: r.ResolvedIPs,
Resolver: r.Resolver, ResolvedAt: r.ResolvedAt, Reason: r.Reason, Outcome: "too_many"}
}
reason := fmt.Sprintf("this call names %d targets that are not approved (%s); at most %d are requested per call",
len(unpinned), strings.Join(names, ", "), maxUnpinnedTargets)
c.log("denied", TargetTooMany+": "+reason, "", audit...)
return nil, TargetResult{Code: TargetTooMany, Host: unpinned[0].Host, Port: unpinned[0].Port, Reason: reason}
}
if len(unpinned) == 0 {
// The call passes: record what let it pass, once per process.
for _, use := range uses {
use()
}
}
return unpinned, TargetResult{}
}
// resolve fills the addresses of a name. fresh skips the cache.
func (c *targetCheck) resolve(ct *checkedTarget, fresh bool) {
ct.resolver = c.i.resolve.name()
res, err := c.i.resolve.lookup(c.ctx, ct.Host, fresh)
ct.ips, ct.resolvedAt, ct.err = res.ips, res.at, err
}
func (c *targetCheck) resolveFailure(ct *checkedTarget) string {
reason := fmt.Sprintf("the platform could not resolve %s (%v)", ct.Host, ct.err)
if pins, err := c.i.targets.ActivePins(c.ref, ct.Host, ct.Port); err == nil && len(pins) > 0 {
reason += fmt.Sprintf(", so it cannot compare the address with the approved pin (v%d: %s)",
pins[0].Version, strings.Join(pins[0].PinnedIPs, ", "))
}
return reason
}
// block logs a blocked call as a decided row and returns the block.
func (c *targetCheck) block(code, outcome string, ct *checkedTarget, reason string) ([]TargetRequest, TargetResult) {
c.log("denied", code+": "+reason, "", ct.audit(outcome))
return nil, TargetResult{Code: code, Host: ct.Host, Port: ct.Port, Reason: reason}
}
// log writes one decided row of the gate. Best effort: a failed write never
// changes the outcome of the call.
func (c *targetCheck) log(status, reason, configDigest string, targets ...db.InterceptTarget) {
action := "deny"
if status == "allowed" {
action = "allow"
}
audit := auditFor(c.ctx, Decision{Action: action, Message: reason, ConfigDigest: configDigest}, c.input, status)
if status == "allowed" {
// The row records that a pin or an exemption was used, not this one call.
audit.ExecutionStatus = "unknown"
}
audit.Targets = targets
_, agentName := taskInfoFromCtx(c.ctx)
_ = c.i.targets.LogDecision(db.TargetDecision{Scope: c.ref, AgentName: agentName, ToolName: c.tool,
ToolInput: c.input, Status: status, Reason: reason, Audit: audit})
}
// exemptUsed logs the first call of this process that passes through an
// exemption, per scope, host, port and list digest.
func (c *targetCheck) exemptUsed(ct *checkedTarget, digest string) {
key := "exempt|" + c.scope.scopeName() + "|" + db.TargetKey(ct.Host, ct.Port) + "|" + digest
if _, seen := c.i.firstUse.LoadOrStore(key, true); seen {
return
}
c.log("allowed", fmt.Sprintf("Target %s is exempt from target approval for %s.", ct.Target, c.scope.scopeName()),
digest, ct.audit("exempt"))
}
// pinUsed logs the first call of this process that a pin version covers.
func (c *targetCheck) pinUsed(ct *checkedTarget, pin *db.TargetPin) {
if _, seen := c.i.firstUse.LoadOrStore(fmt.Sprintf("pin|%d", pin.ID), true); seen {
return
}
t := ct.audit("pin_match")
t.PinID, t.PinVersion, t.PinnedIPs, t.NameOnlyEffective = pin.ID, pin.Version, pin.PinnedIPs, pin.NameOnlyEffective
c.log("allowed", fmt.Sprintf("Target %s is covered by pin v%d of %s.", ct.Target, pin.Version, c.scope.scopeName()), "", t)
}
// blockRule matches the asset block rules against a target: its name, and its
// addresses. A range is matched as written and by its network address.
func blockRule(rules []db.AssetInterceptRule, ct *checkedTarget) (db.AssetInterceptRule, bool) {
var domains []string
ips := ct.ips
switch ct.Kind {
case TargetDomain:
domains = []string{ct.Host}
case TargetCIDR:
if p, err := netip.ParsePrefix(ct.Host); err == nil {
ips = []string{ct.Host, p.Addr().String()}
}
}
rule, _, hit := db.MatchAssetInterceptRules(rules, domains, ips, nil)
return rule, hit
}
// exemptionHolds reports whether the stored exemption on a target applies. On a
// name it is void when the name does not resolve or one of its addresses is
// private, loopback or otherwise local. An IP literal is not resolved; it holds
// unless it is an address that can never be exempt.
func exemptionHolds(ct *checkedTarget) bool {
if ct.Kind == TargetIP {
return ValidateExemptionHost(ct.Host) == nil
}
if ct.Kind != TargetDomain || ct.err != nil || len(ct.ips) == 0 {
return false
}
for _, ip := range ct.ips {
if voidsExemption(ip) {
return false
}
}
return true
}
// coveringPin returns the pin that covers the resolved addresses: the first
// candidate that is name-only, or that holds every address. The address sets
// of two pins are never merged.
func coveringPin(pins []*db.TargetPin, resolved []string, nameOnly bool) *db.TargetPin {
for _, p := range pins {
if nameOnly || p.NameOnlyEffective || subset(resolved, p.PinnedIPs) {
return p
}
}
return nil
}
func hasRequest(list []TargetRequest, r TargetRequest) bool {
for _, x := range list {
if x.Host == r.Host && x.Port == r.Port {
return true
}
}
return false
}
+475
View File
@@ -0,0 +1,475 @@
package intercept
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/db"
)
// The tests below run the gate against PostgreSQL: the real store, the real
// request and decide path, and a fake resolver.
// pgGate returns an interceptor on d with a fake resolver.
func pgGate(t *testing.T, d *db.DB) (*Interceptor, *fakeResolver) {
t.Helper()
ic, res := New(d), newFakeResolver()
ic.SetResolver(res)
return ic, res
}
// storedExemptions sets the exemption list for one test and puts the previous
// one back afterwards. The list is global, so these tests do not run in parallel.
func storedExemptions(t *testing.T, d *db.DB, list ...db.TargetExemption) {
t.Helper()
previous, _, err := d.TargetExemptions()
if err != nil {
t.Fatal(err)
}
if err := d.ReplaceTargetExemptions(list, "test", "test"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.ReplaceTargetExemptions(previous, "test", "test") })
}
// decidedRows returns the rows of the gate that no human decided, oldest first.
func decidedRows(t *testing.T, d *db.DB, scope TargetScope) []*db.InterceptDetail {
t.Helper()
rows, err := d.Query(`SELECT id FROM intercept_pending
WHERE COALESCE(task_id,'')=$1 AND COALESCE(conversation_id,0)=$2 AND decision_source='target' AND decided_by='system' AND target_key IS NULL ORDER BY id`,
scope.TaskID, scope.ConversationID)
if err != nil {
t.Fatal(err)
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
rows.Close()
out := make([]*db.InterceptDetail, len(ids))
for n, id := range ids {
if out[n], err = d.GetInterceptDetail(id); err != nil || out[n] == nil || out[n].Audit == nil || len(out[n].Audit.Targets) == 0 {
t.Fatalf("row %d: detail=%+v err=%v", id, out[n], err)
}
}
return out
}
func fetchInput(url string) []byte { return []byte(`{"url":"` + url + `"}`) }
// approveAll approves every open request of scope.
func approveAll(t *testing.T, d *db.DB, ic *Interceptor, scope TargetScope) {
t.Helper()
ids := openRows(t, d, scope)
if len(ids) == 0 {
t.Fatal("no open request to approve")
}
for _, id := range ids {
if err := ic.Decide(id, true, "ARTEX", "192.0.2.1"); err != nil {
t.Fatal(err)
}
}
}
// pin opens the request for an unpinned WebFetch target and approves it.
func pinURL(t *testing.T, d *db.DB, ic *Interceptor, scope TargetScope, url string) {
t.Helper()
reqs, res := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput(url))
if res.Code != "" || len(reqs) != 1 {
t.Fatalf("%s: requests=%+v result=%+v, want one request", url, reqs, res)
}
done := request(context.Background(), ic, scope, nil, reqs...)
eventually(t, "the request row", func() bool { return len(openRows(t, d, scope)) == 1 })
approveAll(t, d, ic, scope)
if out := result(t, done); out.Code != "" {
t.Fatalf("%s: approved request ended with %+v", url, out)
}
}
func mustPass(t *testing.T, what string, ic *Interceptor, scope TargetScope, url string) {
t.Helper()
if reqs, res := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput(url)); res.Code != "" || len(reqs) != 0 {
t.Fatalf("%s: requests=%+v result=%+v, want the call to pass", what, reqs, res)
}
}
func TestExemptionChangeIsLogged(t *testing.T) {
d := targetTestDB(t)
ic := New(d)
original, _, err := ic.Exemptions()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.ReplaceTargetExemptions(original, "test", "test") })
logged := func() []db.TargetExemptionChange {
t.Helper()
changes, err := d.ListTargetExemptionLog(100)
if err != nil {
t.Fatal(err)
}
return changes
}
before := logged()
first := []db.TargetExemption{{Host: "PyPI.org.", Port: 443, Note: " index "}, {Host: "10.0.0.5", Port: 8080}, {Host: "pypi.org", Port: 443, Note: "duplicate"}}
stored, digest, err := ic.SetExemptions(first, "alice")
if err != nil {
t.Fatal(err)
}
want := []db.TargetExemption{{Host: "pypi.org", Port: 443, Note: "index"}, {Host: "10.0.0.5", Port: 8080}}
if !slices.Equal(stored, want) {
t.Fatalf("stored = %+v, want %+v (normalised, without the duplicate)", stored, want)
}
if list, got, err := ic.Exemptions(); err != nil || !slices.Equal(list, want) || got != digest || digest == "" {
t.Fatalf("read back: list=%+v digest=%q (want %q) err=%v", list, got, digest, err)
}
second, digest2, err := ic.SetExemptions(nil, "bob")
if err != nil || len(second) != 0 || digest2 == digest {
t.Fatalf("empty list: stored=%+v digest=%q err=%v", second, digest2, err)
}
// A refused list changes nothing and logs nothing.
if _, _, err := ic.SetExemptions([]db.TargetExemption{{Host: "169.254.169.254", Port: 80}}, "mallory"); !errors.Is(err, ErrInvalidExemption) {
t.Fatalf("metadata address: err=%v, want it refused", err)
}
if list, got, err := ic.Exemptions(); err != nil || len(list) != 0 || got != digest2 {
t.Fatalf("after the refused change: list=%+v digest=%q err=%v", list, got, err)
}
changes := logged()
if len(changes) != len(before)+2 {
t.Fatalf("%d log rows added, want 2", len(changes)-len(before))
}
newest, older := changes[0], changes[1]
var olderBefore, olderAfter, newestBefore, newestAfter []db.TargetExemption
for raw, into := range map[*json.RawMessage]*[]db.TargetExemption{&older.Before: &olderBefore, &older.After: &olderAfter, &newest.Before: &newestBefore, &newest.After: &newestAfter} {
if len(*raw) > 0 {
if err := json.Unmarshal(*raw, into); err != nil {
t.Fatal(err)
}
}
}
if older.ChangedBy != "alice" || older.Digest != digest || !slices.Equal(olderBefore, original) || !slices.Equal(olderAfter, want) || older.ChangedAt.IsZero() {
t.Fatalf("first change = %+v", older)
}
if newest.ChangedBy != "bob" || newest.Digest != digest2 || !slices.Equal(newestBefore, want) || len(newestAfter) != 0 || string(newest.After) != "[]" {
t.Fatalf("second change = %+v", newest)
}
}
// The first call that passes through an exemption is logged once per scope,
// host, port and list digest, and per process.
func TestExemptionFirstUseLoggedOnce(t *testing.T) {
d := targetTestDB(t)
storedExemptions(t, d, db.TargetExemption{Host: "pypi.org", Port: 443})
ic, res := pgGate(t, d)
ic.SetDerivedExemptions(func() []string { return []string{"https://llm.example.net/v1"} })
res.set("pypi.org", "151.101.0.223")
scope := taskScope(t, d, ic, time.Minute)
_, digest, err := ic.Exemptions()
if err != nil {
t.Fatal(err)
}
for n := 0; n < 3; n++ {
mustPass(t, "stored exemption", ic, scope, "https://pypi.org/simple/")
mustPass(t, "derived exemption", ic, scope, "https://llm.example.net/v1/models")
}
rows := decidedRows(t, d, scope)
if len(rows) != 2 {
t.Fatalf("%d first-use rows, want one per exemption", len(rows))
}
for n, host := range []string{"pypi.org", "llm.example.net"} {
row := rows[n]
target := row.Audit.Targets[0]
if row.Status != "allowed" || row.DecidedBy != "system" || row.DecisionSource != "target" || row.ToolName != "WebFetch" ||
target.Host != host || target.Port != 443 || target.Outcome != "exempt" || row.Audit.ConfigDigest != digest || row.DecidedAt == nil {
t.Fatalf("first-use row %d = %+v audit=%+v", n, row.InterceptApprovalRow, row.Audit)
}
}
if n := len(pins(t, d, scope)) + len(openRows(t, d, scope)); n != 0 {
t.Fatalf("an exemption created %d pins or requests", n)
}
// Another scope logs its own first use.
other := taskScope(t, d, ic, time.Minute)
mustPass(t, "other task", ic, other, "https://pypi.org/simple/")
if n := len(decidedRows(t, d, other)); n != 1 {
t.Fatalf("other task: %d first-use rows, want 1", n)
}
// A changed list has another digest: the use is logged again.
storedExemptions(t, d, db.TargetExemption{Host: "pypi.org", Port: 443}, db.TargetExemption{Host: "files.pythonhosted.org", Port: 443})
mustPass(t, "changed list", ic, scope, "https://pypi.org/simple/")
mustPass(t, "changed list, again", ic, scope, "https://pypi.org/simple/")
if n := len(decidedRows(t, d, scope)); n != 3 {
t.Fatalf("after the list change: %d rows, want 3", n)
}
// Another process (a second interceptor on the same database) logs again.
second, res2 := pgGate(t, d)
res2.set("pypi.org", "151.101.0.223")
scope.Interceptor = second
mustPass(t, "second process", second, scope, "https://pypi.org/simple/")
if n := len(decidedRows(t, d, scope)); n != 4 {
t.Fatalf("second process: %d rows, want 4", n)
}
}
// The seeded package sources are stored entries like the ones an operator
// adds: the void rule applies to them.
func TestSeededExemptionIsVoidOnPrivateAddress(t *testing.T) {
d := targetTestDB(t)
stored, _, err := d.TargetExemptions()
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(stored, func(e db.TargetExemption) bool { return e.Host == "pypi.org" && e.Port == 443 }) {
t.Skipf("the stored list no longer holds the seeded pypi.org:443 entry: %+v", stored)
}
ic, res := pgGate(t, d)
scope := taskScope(t, d, ic, time.Minute)
res.set("pypi.org", "151.101.0.223")
mustPass(t, "seeded entry, public address", ic, scope, "https://pypi.org/simple/")
second, res2 := pgGate(t, d)
res2.set("pypi.org", "151.101.0.223", "10.0.0.5")
reqs, out := second.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://pypi.org/simple/"))
if out.Code != "" || len(reqs) != 1 || !reqs[0].ExemptVoid {
t.Fatalf("seeded entry, one private address: requests=%+v result=%+v, want a request with a void exemption", reqs, out)
}
// The request row records that the exemption was void.
done := request(context.Background(), second, scope, nil, reqs...)
id := oneOpenRow(t, d, scope)
detail, err := d.GetInterceptDetail(id)
if err != nil || detail == nil || !detail.Audit.Targets[0].ExemptVoid {
t.Fatalf("request row audit = %+v err=%v, want exempt_void", detail, err)
}
if err := second.Decide(id, false, "ARTEX", "192.0.2.1"); err != nil {
t.Fatal(err)
}
if out := result(t, done); out.Code != TargetDenied {
t.Fatalf("denied request ended with %+v", out)
}
}
// Task allow rules play no role in the gate: they neither satisfy it nor
// restrict it. Block rules, global and of the task, always win.
func TestAllowRuleDoesNotSatisfyGate(t *testing.T) {
d := targetTestDB(t)
ic, res := pgGate(t, d)
scope := taskScope(t, d, ic, time.Minute)
ref := scope.dbRef(t)
res.set("app.example.com", "203.0.113.7")
res.set("other.example.com", "203.0.113.9")
rule := func(action, kind, pattern string) {
t.Helper()
if _, err := d.Assets().CreateTaskInterceptRule(ref.TaskID, action, kind, pattern, "target gate test", true); err != nil {
t.Fatal(err)
}
}
// An allow rule on the host does not replace the approval.
rule("allow", "exact_domain", "app.example.com")
reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://app.example.com/"))
if out.Code != "" || len(reqs) != 1 || reqs[0].Reason != "no_pin" {
t.Fatalf("allow rule on the host: requests=%+v result=%+v, want a request", reqs, out)
}
// A pinned host outside the allow list still passes: the whitelist is not
// applied at dispatch.
pinURL(t, d, ic, scope, "https://other.example.com/")
mustPass(t, "pinned host outside the allow list", ic, scope, "https://other.example.com/")
// A block rule of the task beats the pin.
rule("block", "exact_ip", "203.0.113.9")
if reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://other.example.com/")); out.Code != TargetRuleBlocked || len(reqs) != 0 {
t.Fatalf("task block rule: requests=%+v result=%+v", reqs, out)
}
// It is the task's rule: another task is not blocked by it.
other := taskScope(t, d, ic, time.Minute)
if reqs, out := ic.CheckTargets(context.Background(), other, "WebFetch", fetchInput("https://other.example.com/")); out.Code != "" || len(reqs) != 1 {
t.Fatalf("other task: requests=%+v result=%+v, want a request", reqs, out)
}
// A global block rule applies to every scope.
global, err := d.CreateAssetInterceptRule("exact_domain", "app.example.com", "target gate test", true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteAssetInterceptRule(global.ID) })
for name, s := range map[string]TargetScope{"task": scope, "other task": other} {
if reqs, out := ic.CheckTargets(context.Background(), s, "WebFetch", fetchInput("https://app.example.com/")); out.Code != TargetRuleBlocked || len(reqs) != 0 {
t.Fatalf("global block rule, %s: requests=%+v result=%+v", name, reqs, out)
}
}
// Each block is a decided row; none opened a request.
rows := decidedRows(t, d, scope)
blocks := 0
for _, row := range rows {
if row.Audit.Targets[0].Outcome == "rule_blocked" {
blocks++
if row.Status != "denied" || !strings.HasPrefix(row.Reason, TargetRuleBlocked+": ") || row.Audit.ExecutionStatus != "not_executed" {
t.Fatalf("block row = %+v audit=%+v", row.InterceptApprovalRow, row.Audit)
}
}
}
if blocks != 2 || len(openRows(t, d, scope)) != 0 {
t.Fatalf("%d block rows and %d open requests, want 2 and 0", blocks, len(openRows(t, d, scope)))
}
}
// The proxy can be turned on between the approval and a later call. The pin
// row then still says name_only_effective = false, and the gate treats it as
// name-only at call time: a changed address neither blocks nor asks.
func TestProxyTurnedOnAfterApprovalMakesPinNameOnly(t *testing.T) {
d := targetTestDB(t)
ic, res := pgGate(t, d)
proxied := false
ic.SetUpstreamProxyCheck(func() bool { return proxied })
scope := taskScope(t, d, ic, time.Minute)
res.set("app.example.com", "203.0.113.7")
pinURL(t, d, ic, scope, "https://app.example.com/")
approved := pins(t, d, scope)
if len(approved) != 1 || approved[0].NameOnlyEffective || !slices.Equal(approved[0].PinnedIPs, []string{"203.0.113.7"}) {
t.Fatalf("pins = %+v, want one pin that is not name-only", approved)
}
requestRows := allRows(t, d, scope)
// global_proxy is set, and the name answers another address. A new
// interceptor stands for a later call that has nothing cached.
proxied = true
res.set("app.example.com", "198.51.100.99")
ic.SetResolver(res)
mustPass(t, "changed address behind the proxy", ic, scope, "https://app.example.com/")
if n := len(openRows(t, d, scope)); n != 0 {
t.Fatalf("%d open requests, want none", n)
}
if n := allRows(t, d, scope); n != requestRows {
t.Fatalf("%d request rows, want %d: the call opened a request", n, requestRows)
}
after := pins(t, d, scope)
if len(after) != 1 || after[0].ID != approved[0].ID || after[0].NameOnlyEffective || after[0].Status != db.TargetPinActive ||
!slices.Equal(after[0].PinnedIPs, []string{"203.0.113.7"}) {
t.Fatalf("pins after the call = %+v, want the same row, unchanged", after)
}
// The use of the pin is on record, with the address the platform saw.
rows := decidedRows(t, d, scope)
last := rows[len(rows)-1]
if target := last.Audit.Targets[0]; last.Status != "allowed" || target.Outcome != "pin_match" || target.PinID != approved[0].ID ||
!slices.Equal(target.ResolvedIPs, []string{"198.51.100.99"}) {
t.Fatalf("last row = %+v audit=%+v", last.InterceptApprovalRow, last.Audit)
}
// Control: with the proxy off the same call asks.
proxied = false
if reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://app.example.com/")); out.Code != "" || len(reqs) != 1 || reqs[0].Reason != "new_ip" {
t.Fatalf("proxy off: requests=%+v result=%+v, want a new_ip request", reqs, out)
}
}
// An approval never pins zero addresses: not on a first approval, and not when
// the key already has a pin with addresses.
func TestApprovalNeverPinsEmptyAddressList(t *testing.T) {
d := targetTestDB(t)
ic, res := pgGate(t, d)
scope := taskScope(t, d, ic, time.Minute)
ref := scope.dbRef(t)
// RequestTargets refuses to open such a request.
for _, ips := range [][]string{nil, {}} {
out := ic.RequestTargets(context.Background(), scope, "WebFetch", webFetchInput,
[]TargetRequest{{Host: "app.example.com", Port: 443, Kind: TargetDomain, ResolvedIPs: ips, Reason: "no_pin"}}, nil)
if out.Code != TargetResolveFailed || out.PendingID != 0 {
t.Fatalf("request with %v: %+v, want %s", ips, out, TargetResolveFailed)
}
}
// One empty target refuses the whole call, before any row is written.
out := ic.RequestTargets(context.Background(), scope, "Bash", []byte(`{"command":"x"}`),
[]TargetRequest{target("a.example.com", 443, "203.0.113.7"), target("b.example.com", 443)}, nil)
if out.Code != TargetResolveFailed || out.Host != "b.example.com" || allRows(t, d, scope) != 0 {
t.Fatalf("mixed call: %+v with %d rows, want %s and no row", out, allRows(t, d, scope), TargetResolveFailed)
}
// A row that reached the table without an address cannot be approved.
open := func(host string, ips []string) int64 {
t.Helper()
id, created, err := d.FindOrCreateTargetRequest(db.TargetRequest{Scope: ref, Host: host, Port: 443, ToolName: "WebFetch", Reason: "test",
Audit: &db.InterceptAudit{Targets: []db.InterceptTarget{{Host: host, Port: 443, ResolvedIPs: ips, Outcome: "requested"}}}})
if err != nil || !created {
t.Fatalf("open request: id=%d created=%v err=%v", id, created, err)
}
return id
}
for _, ips := range [][]string{nil, {}, {""}} {
id := open("empty.example.com", ips)
if err := ic.Decide(id, true, "ARTEX", "192.0.2.1"); !errors.Is(err, db.ErrTargetPinEmpty) {
t.Fatalf("approve %v: err=%v, want ErrTargetPinEmpty", ips, err)
}
if row, err := d.GetInterceptPending(id); err != nil || row == nil || row.Status != "pending" {
t.Fatalf("refused approval: row=%+v err=%v, want it still pending", row, err)
}
if n := len(pins(t, d, scope)); n != 0 {
t.Fatalf("%d pins after a refused approval, want none", n)
}
// The request can still be denied.
if err := ic.Decide(id, false, "ARTEX", "192.0.2.1"); err != nil {
t.Fatal(err)
}
}
// With a pin on the key: the approval is refused all the same, and the
// pin is neither superseded nor re-issued with its old addresses.
res.set("app.example.com", "203.0.113.7")
pinURL(t, d, ic, scope, "https://app.example.com/")
id := open("app.example.com", nil)
if err := ic.Decide(id, true, "ARTEX", "192.0.2.1"); !errors.Is(err, db.ErrTargetPinEmpty) {
t.Fatalf("approve an empty new_ip request: err=%v, want ErrTargetPinEmpty", err)
}
if after := pins(t, d, scope); len(after) != 1 || after[0].Version != 1 || after[0].Status != db.TargetPinActive || !slices.Equal(after[0].PinnedIPs, []string{"203.0.113.7"}) {
t.Fatalf("pins = %+v, want version 1 untouched", after)
}
// A name that stops resolving opens no request, so there is nothing to approve.
res.errs["app.example.com"] = errors.New("no such host")
ic.SetResolver(res)
if reqs, out := ic.CheckTargets(context.Background(), scope, "WebFetch", fetchInput("https://app.example.com:8443/")); out.Code != TargetResolveFailed || len(reqs) != 0 {
t.Fatalf("unresolvable name: requests=%+v result=%+v", reqs, out)
}
}
// A block is logged for a conversation as it is for a task, on the
// conversation's own rows.
func TestTargetBlockIsLoggedForConversation(t *testing.T) {
d := targetTestDB(t)
ic, res := pgGate(t, d)
conv, err := d.CreateConversation("auto", "target gate block", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = d.DeleteConversation(conv.ID)
_, _ = d.Exec(`DELETE FROM target_pins WHERE conversation_id=$1`, conv.ID)
})
scope := TargetScope{ConversationID: conv.ID, Enabled: func() bool { return true }, WaitBound: time.Minute, Interceptor: ic}
res.errs["app.example.com"] = errors.New("no such host")
ctx := WithCall(WithConvID(context.Background(), conv.ID), "WebFetch", fetchInput("https://app.example.com/"))
if reqs, out := ic.CheckTargets(ctx, scope, "WebFetch", fetchInput("https://app.example.com/")); out.Code != TargetResolveFailed || len(reqs) != 0 {
t.Fatalf("requests=%+v result=%+v", reqs, out)
}
rows := decidedRows(t, d, scope)
if len(rows) != 1 {
t.Fatalf("%d rows, want 1", len(rows))
}
row := rows[0]
if row.Status != "denied" || row.ConversationID == nil || *row.ConversationID != conv.ID || row.TaskID != nil ||
row.Audit.Targets[0].Outcome != "resolve_failed" || row.Audit.InputDigest == "" {
t.Fatalf("row = %+v audit=%+v", row.InterceptApprovalRow, row.Audit)
}
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -40,6 +40,7 @@ type TargetRequest struct {
ResolvedAt time.Time
Reason string // no_pin | new_ip | revoked
Pin *db.TargetPin // the active pin an approval would extend (new_ip); nil otherwise
ExemptVoid bool // a stored exemption names the target and was void for this call
}
// TargetResult is the outcome of RequestTargets. Code is empty when every
@@ -105,6 +106,14 @@ func (i *Interceptor) RequestTargets(ctx context.Context, scope TargetScope, too
if err != nil {
return TargetResult{Code: TargetGateUnavailable, Reason: err.Error()}
}
// An approval pins the addresses the request displays, so a request without
// any is never opened: approving it would pin nothing.
for _, t := range targets {
if len(t.ResolvedIPs) == 0 {
return TargetResult{Code: TargetResolveFailed, Host: t.Host, Port: t.Port,
Reason: "no address was resolved for " + targetName(t) + ", and an approval never pins an empty address list"}
}
}
if scope.WaitBound <= 0 {
// A call that never waits cannot own a call-bound row.
ask = nil
@@ -238,7 +247,7 @@ func (i *Interceptor) openTargetRequest(ctx context.Context, ref db.TargetScopeR
target := db.InterceptTarget{
Host: t.Host, Port: t.Port, Kind: t.Kind,
ResolvedIPs: t.ResolvedIPs, Resolver: t.Resolver, ResolvedAt: t.ResolvedAt,
Reason: t.Reason, Outcome: "requested", NameOnlyEffective: nameOnly,
Reason: t.Reason, Outcome: "requested", NameOnlyEffective: nameOnly, ExemptVoid: t.ExemptVoid,
}
if t.Pin != nil {
target.PinID, target.PinVersion, target.PinnedIPs = t.Pin.ID, t.Pin.Version, t.Pin.PinnedIPs
+174
View File
@@ -0,0 +1,174 @@
package intercept
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"slices"
"sync"
"time"
)
// Resolver turns a host name into its addresses. The gate compares what it
// returns with the addresses a human pinned.
type Resolver interface {
// Name identifies the resolver in pins and in audit rows.
Name() string
// LookupIPs returns the A and AAAA answers for host.
LookupIPs(ctx context.Context, host string) ([]netip.Addr, error)
}
// SetResolver replaces the resolver of the target gate, which is the system
// resolver unless one is set.
func (i *Interceptor) SetResolver(r Resolver) { i.resolve.use(r) }
// systemResolver is the resolver of the ARTEX process. It reads the same
// resolver configuration as the tools that run in the same container.
type systemResolver struct{}
func (systemResolver) Name() string { return "system" }
func (systemResolver) LookupIPs(ctx context.Context, host string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
}
const (
// resolveTimeout bounds one lookup.
resolveTimeout = 3 * time.Second
// resolveCacheTTL is how long a positive answer is reused. It bounds latency
// and resolver load when several calls name one host, and adds at most this
// much to the time a changed address goes unnoticed.
resolveCacheTTL = 10 * time.Second
)
// resolution is one positive answer: canonical addresses, sorted, never empty.
type resolution struct {
ips []string
at time.Time
}
type resolveFlight struct {
done chan struct{}
res resolution
err error
}
// resolveCache resolves names through one Resolver. Positive answers are kept
// per host for resolveCacheTTL, concurrent lookups for one host collapse into
// one, and a failure or an empty answer is never kept: the next call tries
// again.
type resolveCache struct {
now func() time.Time
mu sync.Mutex
resolver Resolver
entries map[string]resolution
flights map[string]*resolveFlight
}
func newResolveCache(r Resolver) *resolveCache {
return &resolveCache{resolver: r, now: time.Now, entries: map[string]resolution{}, flights: map[string]*resolveFlight{}}
}
// use replaces the resolver and drops the answers of the previous one.
func (c *resolveCache) use(r Resolver) {
c.mu.Lock()
c.resolver, c.entries = r, map[string]resolution{}
c.mu.Unlock()
}
func (c *resolveCache) name() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.resolver.Name()
}
// lookup resolves host. fresh skips the cached answer: it is what a new
// approval request uses, because a human reads and pins those addresses.
func (c *resolveCache) lookup(ctx context.Context, host string, fresh bool) (resolution, error) {
c.mu.Lock()
if e, ok := c.entries[host]; ok && !fresh && c.now().Sub(e.at) < resolveCacheTTL {
c.mu.Unlock()
return e, nil
}
f := c.flights[host]
if f == nil {
f = &resolveFlight{done: make(chan struct{})}
c.flights[host] = f
go c.fly(c.resolver, host, f)
}
c.mu.Unlock()
select {
case <-f.done:
return f.res, f.err
case <-ctx.Done():
return resolution{}, ctx.Err()
}
}
// fly runs the one lookup the waiters of host share. It is not bound to the
// context of the call that started it: that call leaving must not fail the others.
func (c *resolveCache) fly(r Resolver, host string, f *resolveFlight) {
ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
defer cancel()
addrs, err := r.LookupIPs(ctx, host)
switch {
case ctx.Err() != nil:
f.err = fmt.Errorf("lookup timed out after %s", resolveTimeout)
case err != nil:
f.err = lookupError(err)
case len(addrs) == 0:
f.err = errors.New("the lookup returned no address")
default:
f.res = resolution{ips: canonicalIPs(addrs), at: c.now()}
}
c.mu.Lock()
delete(c.flights, host)
if f.err == nil {
c.entries[host] = f.res
}
c.mu.Unlock()
close(f.done)
}
// lookupError keeps the part of a resolver error that says what went wrong.
func lookupError(err error) error {
var dns *net.DNSError
if errors.As(err, &dns) {
if dns.IsTimeout {
return fmt.Errorf("lookup timed out after %s", resolveTimeout)
}
if dns.IsNotFound {
return errors.New("no such host")
}
return errors.New(dns.Err)
}
return err
}
// canonicalIPs returns the addresses as sorted, unique strings, IPv4-mapped
// addresses unmapped and zones dropped.
func canonicalIPs(addrs []netip.Addr) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.Unmap().WithZone("").String())
}
slices.Sort(out)
return slices.Compact(out)
}
// subset reports whether every resolved address is one of the pinned ones. An
// empty answer is never a subset: a lookup that returned nothing matches no pin.
func subset(resolved, pinned []string) bool {
if len(resolved) == 0 {
return false
}
for _, ip := range resolved {
if !slices.Contains(pinned, ip) {
return false
}
}
return true
}
+272
View File
@@ -0,0 +1,272 @@
package intercept
import (
"encoding/json"
"slices"
"testing"
)
func TestNormalizeTarget(t *testing.T) {
cases := []struct {
name string
raw string
want Target
}{
{"case", "HTTPS://App.Example.COM/Admin", Target{TargetDomain, "app.example.com", 443}},
{"trailing dot", "https://app.example.com./", Target{TargetDomain, "app.example.com", 443}},
{"userinfo", "https://user:p@ss@app.example.com/x", Target{TargetDomain, "app.example.com", 443}},
{"userinfo that looks like a host", "http://pinned.example@evil.example/", Target{TargetDomain, "evil.example", 80}},
{"ipv6 brackets", "http://[2001:DB8::1]/", Target{TargetIP, "2001:db8::1", 80}},
{"ipv6 brackets and port", "http://[2001:db8::1]:8443/x", Target{TargetIP, "2001:db8::1", 8443}},
{"ipv4-mapped ipv6", "http://[::ffff:203.0.113.7]/", Target{TargetIP, "203.0.113.7", 80}},
{"decimal ipv4", "http://2130706433/", Target{TargetIP, "127.0.0.1", 80}},
{"hex ipv4", "http://0x7f000001/", Target{TargetIP, "127.0.0.1", 80}},
{"octal ipv4", "http://0177.0.0.1/", Target{TargetIP, "127.0.0.1", 80}},
{"short ipv4", "http://127.1/", Target{TargetIP, "127.0.0.1", 80}},
{"mixed ipv4 forms", "http://0xa9.254.0xa9fe/", Target{TargetIP, "169.254.169.254", 80}},
{"punycode", "https://bücher.example/", Target{TargetDomain, "xn--bcher-kva.example", 443}},
{"punycode kept", "https://xn--bcher-kva.example/", Target{TargetDomain, "xn--bcher-kva.example", 443}},
{"default port http", "http://app.example.com", Target{TargetDomain, "app.example.com", 80}},
{"default port https", "https://app.example.com?x=1", Target{TargetDomain, "app.example.com", 443}},
{"default port ws", "ws://app.example.com/socket", Target{TargetDomain, "app.example.com", 80}},
{"default port wss", "wss://app.example.com/socket", Target{TargetDomain, "app.example.com", 443}},
{"default port ftp", "ftp://files.example.com/pub", Target{TargetDomain, "files.example.com", 21}},
{"explicit port wins", "https://app.example.com:8443/", Target{TargetDomain, "app.example.com", 8443}},
{"empty port is the default", "https://app.example.com:/", Target{TargetDomain, "app.example.com", 443}},
{"port 0: scheme without a default", "ssh://git.example.com/repo", Target{TargetDomain, "git.example.com", 0}},
{"port 0: bare host", "app.example.com", Target{TargetDomain, "app.example.com", 0}},
{"host and port", "App.Example.com:8080", Target{TargetDomain, "app.example.com", 8080}},
{"ip and port", "203.0.113.7:22", Target{TargetIP, "203.0.113.7", 22}},
{"bare ipv6", "2001:db8::1", Target{TargetIP, "2001:db8::1", 0}},
{"ipv6 zone dropped", "fe80::1%eth0", Target{TargetIP, "fe80::1", 0}},
{"cidr is masked", "10.1.2.3/8", Target{TargetCIDR, "10.0.0.0/8", 0}},
{"ipv6 cidr", "FD00::/8", Target{TargetCIDR, "fd00::/8", 0}},
{"underscore label", "http://my_host.internal/", Target{TargetDomain, "my_host.internal", 80}},
{"percent-encoded host", "http://%65vil.example/", Target{TargetDomain, "evil.example", 80}},
{"percent-encoded address", "http://127.0.0.1%2e/x", Target{TargetIP, "127.0.0.1", 80}},
{"a host no profile accepts is kept", "https://-odd-.example/", Target{TargetDomain, "-odd-.example", 443}},
}
for _, c := range cases {
got, ok := NormalizeTarget(c.raw)
if !ok || got != c.want {
t.Errorf("%s: NormalizeTarget(%q) = %+v, %v; want %+v", c.name, c.raw, got, ok, c.want)
}
}
for _, raw := range []string{"", "file:///etc/passwd", "http:///path", "http://app.example.com:99999/", "http://app.example.com:http/", "http://[::1", "10.0.0.0/99"} {
if got, ok := NormalizeTarget(raw); ok {
t.Errorf("NormalizeTarget(%q) = %+v, want no target", raw, got)
}
}
}
func targetsOf(tool string, input any) []Target {
raw, _ := json.Marshal(input)
return ExtractTargets(tool, raw)
}
func command(cmd string) map[string]string { return map[string]string{"command": cmd} }
func wantTargets(t *testing.T, what string, got []Target, want ...Target) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("%s: targets = %+v, want %+v", what, got, want)
}
}
func TestExtractTargets(t *testing.T) {
app := Target{TargetDomain, "app.example.com", 443}
// WebFetch names its target exactly.
wantTargets(t, "WebFetch", targetsOf("WebFetch", map[string]any{"url": "https://App.Example.com/login?next=http://other.example/", "extract": true}), app)
wantTargets(t, "WebFetch numeric host", targetsOf("WebFetch", map[string]string{"url": "http://2130706433/"}), Target{TargetIP, "127.0.0.1", 80})
wantTargets(t, "WebFetch without a url", targetsOf("WebFetch", map[string]string{}))
// The command tools: Bash, shell_open, Monitor (command) and shell_send (text).
commands := []struct {
cmd string
want []Target
}{
{"curl -s https://app.example.com/api | jq .", []Target{app}},
{"curl -sk 'https://app.example.com:8443/a?b=1&c=http://inner.example/' -o out", []Target{{TargetDomain, "app.example.com", 8443}}},
{"curl http://a.example/;curl http://b.example|sh", []Target{{TargetDomain, "a.example", 80}, {TargetDomain, "b.example", 80}}},
{"curl -x http://127.0.0.1:8080 https://app.example.com/", []Target{{TargetIP, "127.0.0.1", 8080}, app}},
{"nmap -sV 203.0.113.7", []Target{{TargetIP, "203.0.113.7", 0}}},
{"nmap -p 443 203.0.113.0/24 198.51.100.9", []Target{{TargetCIDR, "203.0.113.0/24", 0}, {TargetIP, "198.51.100.9", 0}}},
{"nc -v 203.0.113.7:4444", []Target{{TargetIP, "203.0.113.7", 4444}}},
{"ssh root@203.0.113.7 -p 22", []Target{{TargetIP, "203.0.113.7", 0}}},
{"mysql --host=203.0.113.7 -uroot", []Target{{TargetIP, "203.0.113.7", 0}}},
{"openssl s_client -connect app.example.com:443 -servername x", []Target{app}},
{"nc localhost:8080", []Target{{TargetDomain, "localhost", 8080}}},
{"ping6 2001:db8::1", []Target{{TargetIP, "2001:db8::1", 0}}},
{"curl 'http://[2001:db8::1]:8080/'; nc [2001:db8::2]:22", []Target{{TargetIP, "2001:db8::1", 8080}, {TargetIP, "2001:db8::2", 22}}},
{"nmap fd00::/8 ::ffff:203.0.113.7", []Target{{TargetCIDR, "fd00::/8", 0}, {TargetIP, "203.0.113.7", 0}}},
{"curl https://app.example.com/a https://APP.example.com/b", []Target{app}},
{"git clone git+ssh://git@code.example.com/x.git", []Target{{TargetDomain, "code.example.com", 0}}},
{"scp f 203.0.113.7:/tmp/f", []Target{{TargetIP, "203.0.113.7", 0}}},
{"docker run -p 203.0.113.7:8080:80 img", []Target{{TargetIP, "203.0.113.7", 8080}}},
{"curl 203.0.113.7/2024/report", []Target{{TargetIP, "203.0.113.7", 0}}},
{"dig @198.51.100.53 x", []Target{{TargetIP, "198.51.100.53", 0}}},
// The authority of a URL reads differently quoted and unquoted: both readings are targets.
{`curl 'http://pinned.example;@evil.example/'`, []Target{{TargetDomain, "evil.example", 80}, {TargetDomain, "pinned.example", 80}}},
{`curl http://pinned.example\@evil.example/`, []Target{{TargetDomain, "evil.example", 80}, {TargetDomain, "pinned.example", 80}}},
{`curl "http://pinned.example,x@evil.example:8443/"`, []Target{{TargetDomain, "evil.example", 8443}, {TargetDomain, "pinned.example", 80}}},
{"curl http://a.example;curl http://b.example&&id", []Target{{TargetDomain, "a.example", 80}, {TargetDomain, "b.example", 80}}},
{"curl http://user:pw@app.example.com:8443/ http://%65vil.example/", []Target{{TargetDomain, "app.example.com", 8443}, {TargetDomain, "evil.example", 80}}},
// A port the gate cannot read leaves the address a target.
{"for p in 80 443; do curl http://203.0.113.7:$p/; done", []Target{{TargetIP, "203.0.113.7", 0}}},
}
for _, c := range commands {
wantTargets(t, "Bash "+c.cmd, targetsOf("Bash", command(c.cmd)), c.want...)
wantTargets(t, "shell_open "+c.cmd, targetsOf("shell_open", command(c.cmd)), c.want...)
wantTargets(t, "Monitor "+c.cmd, targetsOf("Monitor", command(c.cmd)), c.want...)
wantTargets(t, "shell_send "+c.cmd, targetsOf("shell_send", map[string]string{"text": c.cmd + "\n"}), c.want...)
}
// Only the command field is read.
wantTargets(t, "Bash description", targetsOf("Bash", map[string]string{"command": "ls", "description": "see https://app.example.com/"}))
wantTargets(t, "shell_send session", targetsOf("shell_send", map[string]string{"text": "id\n", "session_id": "203.0.113.7"}))
// The field is read the way the tool decodes it: a key matches whatever its
// case, and the last duplicate wins. The gate reads the value that runs.
for _, c := range []struct{ tool, input string }{
{"Bash", `{"command":"ls","Command":"curl https://app.example.com/"}`},
{"Bash", `{"command":"ls","command":"curl https://app.example.com/"}`},
{"Bash", `{"COMMAND":"curl https://app.example.com/"}`},
{"Bash", `{"command":"curl https://app.example.com/","timeout":"not a number","text":5}`},
{"Monitor", `{"command":"ls","cOmMaNd":"curl https://app.example.com/"}`},
{"shell_send", `{"text":"id","Text":"curl https://app.example.com/\n"}`},
{"WebFetch", `{"url":"https://pinned.example/","URL":"https://app.example.com/"}`},
{"WebFetch", `{"url":"https://pinned.example/","url":"https://app.example.com/"}`},
{"ExecuteExtraTool", `{"tool_name":"list_assets","Tool_Name":"mcp__x__y","params":{"u":"https://pinned.example/"},"PARAMS":{"u":"https://app.example.com/"}}`},
} {
wantTargets(t, c.tool+" "+c.input, ExtractTargets(c.tool, []byte(c.input)), app)
}
wantTargets(t, "first duplicate does not run", ExtractTargets("Bash", []byte(`{"command":"curl https://app.example.com/","Command":"ls"}`)))
// A tool the gate does not know may keep either value of a duplicated key: both are read.
wantTargets(t, "duplicate key, unknown tool", ExtractTargets("mcp__scan__run", []byte(`{"url":"https://app.example.com/","url":"https://other.example.com/"}`)),
app, Target{TargetDomain, "other.example.com", 443})
// Things that look close to a target and are not one.
for _, cmd := range []string{
"sleep 300; echo 2130706433",
"pip install requests==2.31.0 && python3 --version",
"echo v1.2.3.4 > version; tar xf app-1.2.3.4.tgz",
"date +%H:%M:%S; echo 12:30:45",
"curl -u admin:1234 -H 'X-Id: 7' file:///etc/hosts",
"docker run redis:7 && docker pull python:3.12",
"chown user:1000 f; chmod 600 key.pem",
"ip link show; echo 00:11:22:33:44:55",
"echo 999.1.1.1 1.2.3 1.2.3.4.5",
"python3 -c 'print(a[::-1])'; g++ -std=c++17 x.cc && echo std::cout",
"cat ./src/main.go:42",
} {
wantTargets(t, "Bash "+cmd, targetsOf("Bash", command(cmd)))
}
}
// Every MCP tool and every deferred custom tool is called through
// ExecuteExtraTool: the gate reads the inner tool name and its parameters.
func TestExtractUnwrapsExecuteExtraTool(t *testing.T) {
app := Target{TargetDomain, "app.example.com", 443}
call := func(tool string, params any) []Target {
return targetsOf("ExecuteExtraTool", map[string]any{"tool_name": tool, "params": params})
}
wantTargets(t, "mcp tool", call("mcp__browser__navigate", map[string]string{"url": "https://app.example.com/"}), app)
wantTargets(t, "inner WebFetch", call("WebFetch", map[string]string{"url": "https://app.example.com/x"}), app)
wantTargets(t, "inner Bash", call("Bash", map[string]string{"command": "nmap 203.0.113.7", "description": "https://app.example.com/"}),
Target{TargetIP, "203.0.113.7", 0})
wantTargets(t, "inner tool without a target", call("list_assets", map[string]string{"q": "https://app.example.com/"}))
wantTargets(t, "no params", targetsOf("ExecuteExtraTool", map[string]any{"tool_name": "mcp__scan__run"}))
// A call that does not name its inner tool is read as a whole.
wantTargets(t, "no tool name", targetsOf("ExecuteExtraTool", map[string]any{"params": map[string]string{"url": "https://app.example.com/"}}), app)
// The wrapper is unwrapped once: a nested wrapper is read as an unknown tool.
wantTargets(t, "nested", call("ExecuteExtraTool", map[string]any{"tool_name": "list_assets", "params": map[string]string{"q": "https://app.example.com/"}}), app)
}
func TestExtractSkipsNoTargetTools(t *testing.T) {
input := map[string]any{
"query": "site:app.example.com 203.0.113.7", "file_path": "/tmp/203.0.113.7.txt", "content": "curl https://app.example.com/",
"url": "https://app.example.com/", "command": "nmap 203.0.113.7", "items": []any{map[string]any{"url": "https://app.example.com:8443/"}},
}
for _, tool := range []string{
"web_search", "Read", "Write", "Edit", "MultiEdit", "LS", "Glob", "Grep", "TodoWrite", "sleep",
"TaskOutput", "TaskStop", "TaskList",
"add_intent", "insert_assets", "report_finding", "record_fact", "add_task_scope", "graph_overview",
"spawn_task", "ask_operator", "traffic_search", "traffic_get", "traffic_blob",
} {
wantTargets(t, tool, targetsOf(tool, input))
}
}
// A tool the gate does not know is scanned, not skipped: a tool added later is
// gated by default.
func TestExtractUnknownToolScansStringLeaves(t *testing.T) {
input := map[string]any{
"target": "203.0.113.7",
"options": map[string]any{"proxy": "http://127.0.0.1:8080", "ports": []any{"443", 8443}, "deep": true},
"urls": []any{"https://app.example.com/a", map[string]any{"u": "https://app.example.com/b"}, "ftp://files.example.com"},
"note": "x.py and README.md are files",
}
// In the order of the sorted keys: options, target, urls.
want := []Target{
{TargetIP, "127.0.0.1", 8080}, {TargetIP, "203.0.113.7", 0},
{TargetDomain, "app.example.com", 443}, {TargetDomain, "files.example.com", 21},
}
for _, tool := range []string{"mcp__scan__run", "my_custom_http_tool", "SomeToolAddedLater", "create_mcp", "create_custom_tool"} {
wantTargets(t, tool, targetsOf(tool, input), want...)
}
wantTargets(t, "input that is not JSON", ExtractTargets("mcp__scan__run", []byte("https://app.example.com/")))
}
// A bare name is not a target in a command: a file name is a valid host name,
// so reading bare names would ask on every script run.
func TestExtractDoesNotTreatFileNamesAsHosts(t *testing.T) {
for _, cmd := range []string{
"python3 x.py", "node server.js --watch", "cat notes.txt README.md", "bash ./run.sh && go test ./...",
"tar czf out.tar.gz data.json", "ls app.example.com.har", "vim config.yaml", "./exploit.py --out result.io",
} {
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
}
}
// The tests below document what the gate does NOT see. A subprocess path is
// advisory: the gate detects a declared target and cannot prevent an undeclared
// connection. Each asserts that no target is extracted, which is the gap. They
// change only if that decision changes.
func TestBypass_BareHostnameNotExtracted(t *testing.T) {
for _, cmd := range []string{"nmap app.example.com", "ping -c1 app.example.com", "curl app.example.com/login", "ssh root@app.example.com", "nc db 5432"} {
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
}
}
func TestBypass_EncodedCommandHidesTarget(t *testing.T) {
for _, cmd := range []string{
"echo Y3VybCBodHRwczovL2FwcC5leGFtcGxlLmNvbS8= | base64 -d | sh",
`python3 -c "import urllib.request as u; u.urlopen('https:/'+'/app.example'+'.com/')"`,
"H=app.example; curl $H.com",
"curl http://$TARGET/login https://${HOST}:8443/",
"curl 'http://{app.example.com}/'",
} {
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
}
}
func TestBypass_ScriptFileHidesTarget(t *testing.T) {
// The target is in the file, or in a list the tool reads.
for _, cmd := range []string{"python3 exploit.py", "bash scan.sh", "nmap -iL targets.txt", "nuclei -l hosts.txt"} {
wantTargets(t, cmd, targetsOf("Bash", command(cmd)))
}
}
func TestBypass_SplitKeystrokesHideTarget(t *testing.T) {
// One address typed into an interactive shell in pieces: no call names it.
for _, text := range []string{"nc 203.0.", "113.7 44", "44\n"} {
wantTargets(t, text, targetsOf("shell_send", map[string]string{"text": text}))
}
}
func TestBypass_CustomScriptCodeNotScanned(t *testing.T) {
// A custom script tool is called with its parameters only; the target is in
// the stored code, which the hook never sees.
wantTargets(t, "custom script tool", targetsOf("my_script_tool", map[string]any{"path": "/login", "retries": 3}))
}
+60
View File
@@ -379,6 +379,11 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
writeErr(w, 409, err.Error())
return
}
if errors.Is(err, db.ErrTargetPinEmpty) {
// Refused, not failed: the request stays open and nothing was pinned.
writeErr(w, 422, err.Error())
return
}
writeErr(w, 500, err.Error())
return
}
@@ -463,6 +468,61 @@ func (s *Server) revokeTargetPin(w http.ResponseWriter, r *http.Request, scope d
writeJSON(w, 200, pin)
}
// --- target-gate exemptions (hosts that need no target approval, global) ---
// targetGateGetExemptions returns the stored exemption list with its digest,
// and the exemptions derived from the configuration (LLM gateways, search
// backend, platform proxies), which are computed and cannot be edited.
func (s *Server) targetGateGetExemptions(w http.ResponseWriter, r *http.Request) {
list, digest, err := s.m.interceptor.Exemptions()
if err != nil {
writeErr(w, 500, err.Error())
return
}
if list == nil {
list = []db.TargetExemption{}
}
writeJSON(w, 200, map[string]any{"exemptions": list, "digest": digest, "derived": s.m.interceptor.DerivedExemptions()})
}
// targetGateSetExemptions replaces the stored exemption list. Entries are an
// exact host and port. The change is appended to the exemption log with the
// approver of the session.
func (s *Server) targetGateSetExemptions(w http.ResponseWriter, r *http.Request) {
var req struct {
Exemptions []db.TargetExemption `json:"exemptions"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
list, digest, err := s.m.interceptor.SetExemptions(req.Exemptions, s.approver(r))
if err != nil {
if errors.Is(err, intercept.ErrInvalidExemption) {
writeErr(w, 400, err.Error())
return
}
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"exemptions": list, "digest": digest})
}
// targetGateExemptionHistory returns the most recent changes of the exemption
// list, newest first.
func (s *Server) targetGateExemptionHistory(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
changes, err := pg.ListTargetExemptionLog(100)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"changes": changes})
}
// --- tool-config (global tool interception scope) ---
// interceptGetToolConfig returns the list of tool names that are currently
+28
View File
@@ -404,6 +404,8 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
// Behind the global egress proxy a target approval is name-only.
m.interceptor.SetUpstreamProxyCheck(func() bool { return m.GlobalProxy() != "" })
// The platform's own endpoints need no target approval.
m.interceptor.SetDerivedExemptions(m.targetGateEndpoints)
if proxyAddr != "" {
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
if err != nil {
@@ -892,6 +894,32 @@ func (t *Task) targetScope(wait time.Duration) intercept.TargetScope {
return intercept.TargetScope{TaskID: t.ID, Enabled: t.targetGate.Load, WaitBound: wait, Interceptor: t.interceptor}
}
// searchBackendEndpoints are the endpoints of the search backends. The
// "deepseek" backend has none of its own: it uses the active LLM profile.
var searchBackendEndpoints = map[string]string{
"ddgs": "https://html.duckduckgo.com",
"brave-free": "https://api.search.brave.com",
"tavily": "https://api.tavily.com",
}
// targetGateEndpoints lists the endpoints of the platform's own services, which
// the target gate exempts from approval: every LLM gateway, the search backend
// in use, and the proxies an agent may name on a command line (the recording
// proxy, the global egress proxy, the search proxy and each profile's proxy).
// It is read on every gated call, so the exemptions follow the configuration.
func (m *Manager) targetGateEndpoints() []string {
var out []string
profiles, _ := m.pg.ListProfiles()
for _, p := range profiles {
out = append(out, p.BaseURL, p.Proxy)
}
on, backend, _, _, searchProxy := m.WebSearch()
if on {
out = append(out, searchBackendEndpoints[backend])
}
return append(out, searchProxy, m.ProxyAddr(), m.GlobalProxy())
}
// UpdateTaskMetadata changes list-only task metadata without interrupting any
// planner, main-agent, or worker call.
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
+3
View File
@@ -961,6 +961,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/intercept/task/{taskID}", s.interceptListTaskItems)
mux.HandleFunc("GET /api/intercept/tool-config", s.interceptGetToolConfig)
mux.HandleFunc("PUT /api/intercept/tool-config", s.interceptSetToolConfig)
mux.HandleFunc("GET /api/target-gate/exemptions", s.targetGateGetExemptions)
mux.HandleFunc("PUT /api/target-gate/exemptions", s.targetGateSetExemptions)
mux.HandleFunc("GET /api/target-gate/exemptions/history", s.targetGateExemptionHistory)
mux.HandleFunc("GET /api/intercept/judge", s.interceptGetJudgeConfig)
mux.HandleFunc("PUT /api/intercept/judge", s.interceptSetJudgeConfig)
mux.HandleFunc("GET /api/intercept/judge/usage", s.interceptJudgeUsage) // cumulative token usage of fallback approval
+1 -1
View File
@@ -382,7 +382,7 @@ func TestFinalPlannerRoundHasZeroWait(t *testing.T) {
}
}
// The gate seam is a no-op: through the task's target-only hooks a call is
// With the gate off (the task default) the target-only hooks are a no-op: a call is
// allowed and nothing is written — no intercept row, no audit-ring entry. The
// control runs the same call through the task's full Guard, where a deny rule
// blocks it and logs a row, so the rule and the row count are known to be live.
+368
View File
@@ -0,0 +1,368 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/artex/traffic"
actool "github.com/Autumn-27/norma/tool"
)
// The gate wired into the server: the exemption API, the exemptions derived
// from the configuration, and the hooks of a task's guards.
// exemptionsOf decodes the "exemptions" (or "derived") list of a response.
func exemptionsOf(t *testing.T, body map[string]any, field string) []db.TargetExemption {
t.Helper()
raw, err := json.Marshal(body[field])
if err != nil {
t.Fatal(err)
}
var out []db.TargetExemption
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("%s = %s: %v", field, raw, err)
}
return out
}
func TestTargetGateExemptionsAPI(t *testing.T) {
g := newGateServer(t)
pg := g.m.pg
original, _, err := pg.TargetExemptions()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pg.ReplaceTargetExemptions(original, "test", "restore") })
// The routes are behind the session, like the rest of the API.
for _, c := range []struct{ method, path string }{
{"GET", "/api/target-gate/exemptions"}, {"PUT", "/api/target-gate/exemptions"}, {"GET", "/api/target-gate/exemptions/history"},
} {
rec := httptest.NewRecorder()
g.s.Handler().ServeHTTP(rec, httptest.NewRequest(c.method, c.path, strings.NewReader(`{"exemptions":[]}`)))
if rec.Code != 401 {
t.Fatalf("%s %s without a token: %d, want 401", c.method, c.path, rec.Code)
}
}
code, body := g.do(t, "GET", "/api/target-gate/exemptions", "")
if code != 200 || !slices.Equal(exemptionsOf(t, body, "exemptions"), original) || body["digest"] == "" || body["derived"] == nil {
t.Fatalf("GET: %d %v, want the stored list %+v", code, body, original)
}
// PUT replaces the list. Hosts are normalised; host and port are exact.
code, body = g.do(t, "PUT", "/api/target-gate/exemptions",
`{"exemptions":[{"host":"Mirror.Example.ORG.","port":443,"note":"internal mirror"},{"host":"10.0.0.5","port":8080},{"host":"FD12:3456::5","port":8080}]}`)
want := []db.TargetExemption{{Host: "mirror.example.org", Port: 443, Note: "internal mirror"}, {Host: "10.0.0.5", Port: 8080}, {Host: "fd12:3456::5", Port: 8080}}
if code != 200 || !slices.Equal(exemptionsOf(t, body, "exemptions"), want) {
t.Fatalf("PUT: %d %v, want %+v", code, body, want)
}
digest, _ := body["digest"].(string)
code, body = g.do(t, "GET", "/api/target-gate/exemptions", "")
if code != 200 || !slices.Equal(exemptionsOf(t, body, "exemptions"), want) || body["digest"] != digest || digest == "" {
t.Fatalf("GET after PUT: %d %v", code, body)
}
// Refused entries: 400, and the stored list does not change.
for name, entry := range map[string]string{
"metadata address": `{"host":"169.254.169.254","port":80}`,
"metadata address, v6": `{"host":"fd00:ec2::254","port":80}`,
"metadata address, cloud": `{"host":"100.100.100.200","port":80}`,
"link-local v6": `{"host":"fe80::1","port":80}`,
"loopback": `{"host":"127.0.0.1","port":8080}`,
"loopback as a number": `{"host":"2130706433","port":8080}`,
"loopback v6": `{"host":"::1","port":8080}`,
"unspecified": `{"host":"0.0.0.0","port":80}`,
"multicast": `{"host":"224.0.0.1","port":80}`,
"wildcard": `{"host":"*.example.org","port":443}`,
"suffix": `{"host":".example.org","port":443}`,
"range": `{"host":"10.0.0.0/8","port":443}`,
"url": `{"host":"https://mirror.example.org/","port":443}`,
"host with a port": `{"host":"mirror.example.org:443","port":443}`,
"no port": `{"host":"mirror.example.org"}`,
"port out of range": `{"host":"mirror.example.org","port":70000}`,
"no host": `{"port":443}`,
"one bad entry among good": `{"host":"pypi.org","port":443},{"host":"169.254.169.254","port":80}`,
} {
code, body := g.do(t, "PUT", "/api/target-gate/exemptions", `{"exemptions":[`+entry+`]}`)
if code != 400 || body["error"] == nil {
t.Errorf("%s: %d %v, want 400", name, code, body)
}
}
if code, _ := g.do(t, "PUT", "/api/target-gate/exemptions", `not json`); code != 400 {
t.Fatalf("malformed body: %d, want 400", code)
}
if list, _, err := pg.TargetExemptions(); err != nil || !slices.Equal(list, want) {
t.Fatalf("stored list after the refused changes = %+v, %v; want it unchanged", list, err)
}
// The history holds the one change that was made, with who made it.
code, body = g.do(t, "GET", "/api/target-gate/exemptions/history", "")
changes, _ := body["changes"].([]any)
if code != 200 || len(changes) == 0 {
t.Fatalf("history: %d %v", code, body)
}
last, _ := changes[0].(map[string]any)
if last["changed_by"] != "ARTEX" || last["digest"] != digest || len(exemptionsOf(t, last, "after")) != 3 || !slices.Equal(exemptionsOf(t, last, "before"), original) {
t.Fatalf("last change = %v", last)
}
// An empty list is a valid list.
if code, body := g.do(t, "PUT", "/api/target-gate/exemptions", `{}`); code != 200 || len(exemptionsOf(t, body, "exemptions")) != 0 {
t.Fatalf("empty list: %d %v", code, body)
}
if code, body := g.do(t, "GET", "/api/target-gate/exemptions", ""); code != 200 || body["exemptions"] == nil || len(exemptionsOf(t, body, "exemptions")) != 0 {
t.Fatalf("GET of the empty list: %d %v", code, body)
}
}
// The exemptions of the platform's own endpoints are derived from the
// configuration on every call: LLM gateways, profile proxies, the search
// backend in use, the search proxy and the global egress proxy.
func TestTargetGateEndpointsFollowConfig(t *testing.T) {
g := newGateServer(t)
derived := func() []string {
var out []string
for _, e := range g.m.interceptor.DerivedExemptions() {
out = append(out, db.TargetKey(e.Host, e.Port))
}
return out
}
has := func(key string) bool { return slices.Contains(derived(), key) }
name := fmt.Sprintf("target-gate-endpoints-%d", time.Now().UnixNano())
id, err := g.m.pg.SaveProfile(&db.LLMProfile{Name: name, Format: "openai", BaseURL: "https://LLM-gateway.example.net:8443/v1",
Proxy: "http://user:secret@llm-proxy.example.net:3128", Model: "m", APIKey: "k"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = g.m.pg.DeleteProfile(id) })
for _, key := range []string{"llm-gateway.example.net:8443", "llm-proxy.example.net:3128"} {
if !has(key) {
t.Fatalf("derived = %v, want the profile endpoint %s", derived(), key)
}
}
for _, e := range derived() {
if strings.Contains(e, "secret") || strings.Contains(e, "user") {
t.Fatalf("a derived exemption carries credentials: %s", e)
}
}
on, backend, _, _, proxy := g.m.WebSearch()
globalProxy := g.m.GlobalProxy()
t.Cleanup(func() {
_ = g.m.SetWebSearch(on, backend, nil, nil, &proxy)
_ = g.m.SetGlobalProxy(globalProxy)
})
searchProxy := "http://search-proxy.example.net:8118"
if err := g.m.SetWebSearch(true, "tavily", nil, nil, &searchProxy); err != nil {
t.Fatal(err)
}
if err := g.m.SetGlobalProxy("socks5://egress.example.net:1080"); err != nil {
t.Fatal(err)
}
for _, key := range []string{"api.tavily.com:443", "search-proxy.example.net:8118", "egress.example.net:1080"} {
if !has(key) {
t.Fatalf("derived = %v, want %s", derived(), key)
}
}
// The configuration changes: so do the exemptions.
none := ""
if err := g.m.SetWebSearch(true, "ddgs", nil, nil, &none); err != nil {
t.Fatal(err)
}
if err := g.m.SetGlobalProxy(""); err != nil {
t.Fatal(err)
}
if err := g.m.pg.DeleteProfile(id); err != nil {
t.Fatal(err)
}
if !has("html.duckduckgo.com:443") {
t.Fatalf("derived = %v, want the keyless search backend", derived())
}
for _, key := range []string{"api.tavily.com:443", "search-proxy.example.net:8118", "egress.example.net:1080", "llm-gateway.example.net:8443", "llm-proxy.example.net:3128"} {
if has(key) {
t.Fatalf("derived = %v, still holds %s after the configuration changed", derived(), key)
}
}
// Search off: its backend is not exempt.
if err := g.m.SetWebSearch(false, "ddgs", nil, nil, nil); err != nil {
t.Fatal(err)
}
if has("html.duckduckgo.com:443") {
t.Fatalf("derived = %v, want no search backend while search is off", derived())
}
// The API shows them, read-only.
if code, body := g.do(t, "GET", "/api/target-gate/exemptions", ""); code != 200 || body["derived"] == nil {
t.Fatalf("GET: %d %v", code, body)
}
}
// An approval of a request that displays no address is refused with 422. The
// request stays open and nothing is pinned.
func TestInterceptDecideRefusesEmptyPinOverHTTP(t *testing.T) {
g := newGateServer(t)
task := g.newTask(t, db.TaskCreateOptions{})
scope := taskRef(t, task)
id := openTargetRow(t, g.m.pg, scope, "no-address.example.com")
code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`)
if msg, _ := body["error"].(string); code != 422 || !strings.Contains(msg, "never pins an empty address list") {
t.Fatalf("approve: %d %v, want 422", code, body)
}
if row, err := g.m.pg.GetInterceptPending(id); err != nil || row == nil || row.Status != "pending" {
t.Fatalf("row = %+v, %v; want still pending", row, err)
}
if pins, err := g.m.pg.ListTargetPins(scope); err != nil || len(pins) != 0 {
t.Fatalf("pins = %+v, %v; want none", pins, err)
}
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"denied"}`); code != 200 {
t.Fatalf("deny: %d", code)
}
}
// The hooks of a task run the gate with the task's own scope: the worker
// guard and the target-only guard of the planner and the main agent. The flag
// is the task's, read on every call.
func TestTaskGuardsRunTargetGate(t *testing.T) {
g := newGateServer(t)
task := g.newTask(t, db.TaskCreateOptions{})
scope := taskRef(t, task)
pg := g.m.pg
const fetch = `{"url":"http://203.0.113.7:8080/admin"}`
ctxOf := func(agentName string, wait time.Duration) context.Context {
return intercept.WithTargetScope(intercept.WithTaskContext(context.Background(), task.ID, agentName, nil), task.targetScope(wait))
}
targetRows := func() int {
t.Helper()
var n int
if err := pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND decision_source='target'`, task.ID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
hook := func(hooks interface {
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
}, ctx context.Context) (bool, string) {
block, msg, _ := hooks.PreToolUse(ctx, "WebFetch", []byte(fetch))
return block, msg
}
// Gate off, the default: both guards pass the call and write nothing.
for name, hooks := range map[string]interface {
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
}{"worker": task.Guard.Hooks(), "planner": task.TargetGuard.Hooks()} {
if block, msg := hook(hooks, ctxOf(name, 0)); block {
t.Fatalf("gate off, %s: blocked: %s", name, msg)
}
}
if n := targetRows(); n != 0 {
t.Fatalf("gate off: %d target rows, want none", n)
}
// Gate on through the API: the next call of each guard is stopped. With
// wait bound 0 it does not wait, and the one request stays open.
if code, _ := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"target_gate":true}`); code != 200 {
t.Fatalf("switch the gate on: %d", code)
}
for _, c := range []struct {
name string
hooks interface {
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
}
}{{"planner", task.TargetGuard.Hooks()}, {"worker", task.Guard.Hooks()}} {
block, msg := hook(c.hooks, ctxOf(c.name, 0))
if !block || !strings.Contains(msg, "TARGET_APPROVAL_PENDING: target 203.0.113.7:8080: ") {
t.Fatalf("gate on, %s: block=%v msg=%q", c.name, block, msg)
}
}
var id int64
var agentName string
var open int
if err := pg.QueryRow(`SELECT count(*), min(id), min(agent_name) FROM intercept_pending
WHERE task_id=$1 AND status='pending' AND target_key='203.0.113.7:8080'`, task.ID).Scan(&open, &id, &agentName); err != nil {
t.Fatal(err)
}
if open != 1 || agentName != "planner" {
t.Fatalf("%d open requests by %q, want one, opened by the planner's call", open, agentName)
}
// The planner's block is in the ring of its own guard, not in the task's.
if audit := task.TargetGuard.Audit(); len(audit) != 1 || audit[0].Action != "block" {
t.Fatalf("target-only ring = %+v, want the one block", audit)
}
for _, e := range task.Guard.Audit() {
if e.Action == "block" && !strings.Contains(e.Reason, "TARGET_APPROVAL_PENDING") {
t.Fatalf("unexpected entry in the task ring: %+v", e)
}
}
// One click through the API pins the address for the task; both guards pass.
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
t.Fatalf("decide: %d", code)
}
if pins, err := pg.ListTargetPins(scope); err != nil || len(pins) != 1 || pins[0].Host != "203.0.113.7" || pins[0].Port != 8080 ||
!slices.Equal(pins[0].PinnedIPs, []string{"203.0.113.7"}) || pins[0].DecidedBy != "ARTEX" {
t.Fatalf("pins = %+v, %v", pins, err)
}
ring := len(task.TargetGuard.Audit())
for name, hooks := range map[string]interface {
PreToolUse(context.Context, string, []byte) (bool, string, []byte)
}{"worker": task.Guard.Hooks(), "planner": task.TargetGuard.Hooks()} {
if block, msg := hook(hooks, ctxOf(name, 0)); block {
t.Fatalf("pinned, %s: blocked: %s", name, msg)
}
}
if n := len(task.TargetGuard.Audit()); n != ring {
t.Fatalf("a passing planner call added %d entries to the ring", n-ring)
}
// Gate off again: the gate is out of the way at once.
if code, _ := g.do(t, "PATCH", "/api/tasks/"+task.ID, `{"target_gate":false}`); code != 200 {
t.Fatal("switch the gate off")
}
rows := targetRows()
if block, _, _ := task.Guard.Hooks().PreToolUse(ctxOf("worker", 0), "WebFetch", []byte(`{"url":"http://198.51.100.200/"}`)); block || targetRows() != rows {
t.Fatalf("gate off again: block=%v, %d new target rows", block, targetRows()-rows)
}
}
// The platform's own tools read and write the platform's records: the gate
// must find no target in their input, or every finding and every intent that
// mentions a URL would ask for an approval. A tool added to these sets has to
// be added to the gate's list of tools without a target.
func TestHostToolsDeclareNoTarget(t *testing.T) {
s := &Server{}
var tools []actool.CoreTool
tools = append(tools, s.orchestrationTools()...)
tools = append(tools, s.findingRetestTools()...)
tools = append(tools, s.platformTools()...)
tools = append(tools, traffic.SeedToolMetas()...)
if len(tools) < 25 {
t.Fatalf("only %d host tools found", len(tools))
}
// The tools that set up a custom tool or an MCP server take an endpoint of
// the model's choosing. They are scanned like any unknown tool: that
// endpoint is a target.
scanned := map[string]bool{"create_custom_tool": true, "update_custom_tool": true, "create_mcp": true, "update_mcp": true, "mcp_status": true}
input := []byte(`{"task_id":1,"description":"test https://app.example.com/ and 203.0.113.7:8443","command":"curl https://app.example.com/","q":"host:app.example.com:443"}`)
for _, tool := range tools {
targets := intercept.ExtractTargets(tool.Name(), input)
if scanned[tool.Name()] {
if len(targets) == 0 {
t.Errorf("%s: no target read", tool.Name())
}
delete(scanned, tool.Name())
} else if len(targets) != 0 {
t.Errorf("%s: the gate reads targets %+v from a platform tool", tool.Name(), targets)
}
}
if len(scanned) != 0 {
t.Errorf("tools that no longer exist: %v", scanned)
}
}