diff --git a/agent/target_gate_tools_test.go b/agent/target_gate_tools_test.go new file mode 100644 index 0000000..d936dcd --- /dev/null +++ b/agent/target_gate_tools_test.go @@ -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) + } + } +} diff --git a/db/db.go b/db/db.go index 7f01a08..9348d86 100644 --- a/db/db.go +++ b/db/db.go @@ -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 } diff --git a/db/intercept_detail.go b/db/intercept_detail.go index 3bf7366..9712190 100644 --- a/db/intercept_detail.go +++ b/db/intercept_detail.go @@ -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 { diff --git a/db/target_pins.go b/db/target_pins.go index d11644b..064b9c3 100644 --- a/db/target_pins.go +++ b/db/target_pins.go @@ -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") +} diff --git a/db/target_pins_test.go b/db/target_pins_test.go index 9cc0a36..53f5397 100644 --- a/db/target_pins_test.go +++ b/db/target_pins_test.go @@ -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") + } +} diff --git a/guard/guard.go b/guard/guard.go index 137db03..c524bab 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -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. // diff --git a/guard/guard_test.go b/guard/guard_test.go index 850a727..b484369 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -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 diff --git a/guard/target_gate_test.go b/guard/target_gate_test.go new file mode 100644 index 0000000..1859c1a --- /dev/null +++ b/guard/target_gate_test.go @@ -0,0 +1,1002 @@ +package guard + +import ( + "context" + "fmt" + "net/netip" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/intercept" +) + +// fakeGate scripts the target gate. check and request receive the number of +// the call (from 1) and decide what the gate answers. +type fakeGate struct { + t *testing.T + mu sync.Mutex + check func(n int) ([]intercept.TargetRequest, intercept.TargetResult) + request func(n int, targets []intercept.TargetRequest, ask *intercept.Decision) intercept.TargetResult + checks int + asked [][]intercept.TargetRequest // targets of each RequestTargets call + asks []*intercept.Decision // ask of each RequestTargets call +} + +func (f *fakeGate) CheckTargets(_ context.Context, scope intercept.TargetScope, _ string, _ []byte) ([]intercept.TargetRequest, intercept.TargetResult) { + f.mu.Lock() + f.checks++ + n := f.checks + f.mu.Unlock() + if scope.Enabled == nil || !scope.Enabled() { + f.t.Errorf("the gate was reached with the gate off") + } + if f.check == nil { + return nil, intercept.TargetResult{} + } + return f.check(n) +} + +func (f *fakeGate) RequestTargets(_ context.Context, _ intercept.TargetScope, _ string, _ []byte, targets []intercept.TargetRequest, ask *intercept.Decision) intercept.TargetResult { + f.mu.Lock() + f.asked = append(f.asked, targets) + f.asks = append(f.asks, ask) + n := len(f.asked) + f.mu.Unlock() + if f.request == nil { + return intercept.TargetResult{} + } + return f.request(n, targets, ask) +} + +// unpinnedOnce answers the first check with one unpinned target and every +// later check with none: the target was approved in between. +func unpinnedOnce(n int) ([]intercept.TargetRequest, intercept.TargetResult) { + if n == 1 { + return []intercept.TargetRequest{appTarget}, intercept.TargetResult{} + } + return nil, intercept.TargetResult{} +} + +var appTarget = intercept.TargetRequest{Host: "app.example.com", Port: 443, Kind: intercept.TargetDomain, + ResolvedIPs: []string{"203.0.113.7"}, Resolver: "fake", ResolvedAt: time.Now().UTC(), Reason: "no_pin"} + +const fetchApp = `{"url":"https://app.example.com/login"}` + +func gateOn(ctx context.Context, taskID string, wait time.Duration) context.Context { + return intercept.WithTargetScope(intercept.WithTaskContext(ctx, taskID, "worker", nil), + intercept.TargetScope{TaskID: taskID, Enabled: func() bool { return true }, WaitBound: wait}) +} + +func pre(g *Guard, ctx context.Context, tool, input string) (bool, string) { + block, msg, _ := g.Hooks().PreToolUse(ctx, tool, []byte(input)) + return block, msg +} + +// The target gate applies to every tool of a gated session, not only to the +// tools that enter the rule system. +func TestTargetGateRunsOutsideEnabledToolSet(t *testing.T) { + calls := []struct{ tool, input string }{ + {"Monitor", `{"command":"tail -f /dev/null | nc 203.0.113.7 4444"}`}, + {"mcp__scan__run", `{"target":"203.0.113.7"}`}, + {"ExecuteExtraTool", `{"tool_name":"mcp__scan__run","params":{"target":"203.0.113.7"}}`}, + {"my_custom_tool", `{"url":"https://app.example.com/"}`}, + {"WebFetch", fetchApp}, + } + ctx := gateOn(context.Background(), "7", time.Minute) + for name, g := range map[string]*Guard{"guard without rules": New(), "target-only guard": NewTargetOnly(nil)} { + gate := &fakeGate{t: t} + g.gate = gate + for _, c := range calls { + if block, msg := pre(g, ctx, c.tool, c.input); block { + t.Errorf("%s: %s blocked (%s), want it passed by the gate", name, c.tool, msg) + } + } + if gate.checks != len(calls) || len(gate.asked) != 0 { + t.Errorf("%s: %d checks and %d requests for %d calls", name, gate.checks, len(gate.asked), len(calls)) + } + + // A block of the gate blocks the call, with the gate's own text. + gate.check = func(int) ([]intercept.TargetRequest, intercept.TargetResult) { + return nil, intercept.TargetResult{Code: intercept.TargetRuleBlocked, Host: "203.0.113.7", Reason: "203.0.113.7 is blocked by an asset rule. Matched asset interception rule [CIDR range: 203.0.113.0/24]"} + } + for _, c := range calls { + block, msg := pre(g, ctx, c.tool, c.input) + if !block || !strings.HasPrefix(msg, "[ARTEX platform control, not target defense] TARGET_RULE_BLOCKED: 203.0.113.7 is blocked by an asset rule.") { + t.Errorf("%s: %s block=%v msg=%q", name, c.tool, block, msg) + } + } + if len(gate.asked) != 0 { + t.Errorf("%s: a blocked call requested an approval", name) + } + + // An unpinned target is requested, and the call runs once it is approved. + gate.checks, gate.check = 0, unpinnedOnce + if block, msg := pre(g, ctx, "Monitor", calls[0].input); block { + t.Errorf("%s: approved target still blocked: %s", name, msg) + } + if len(gate.asked) != 1 || len(gate.asked[0]) != 1 || gate.asked[0][0].Host != "app.example.com" || gate.asks[0] != nil || gate.checks != 2 { + t.Errorf("%s: requests=%+v asks=%v checks=%d, want one request without an ask and a second check", name, gate.asked, gate.asks, gate.checks) + } + } +} + +// The target-only guard (planner, main agent) never records a call that +// passes. It records a block, through block(), in its own ring. +func TestTargetOnlyGuardRecordsBlocksOnly(t *testing.T) { + task := New() // the task's main guard, with its own ring + pre(task, context.Background(), "Bash", `{"command":"id"}`) + before := task.Audit() + + g := NewTargetOnly(nil) + gate := &fakeGate{t: t} + g.gate = gate + ctx := gateOn(context.Background(), "7", time.Minute) + calls := []struct{ tool, input string }{ + {"Bash", `{"command":"curl https://app.example.com/"}`}, {"WebFetch", fetchApp}, {"TodoWrite", `{"todos":[]}`}, + {"add_intent", `{"summary":"probe https://app.example.com/"}`}, {"shell_send", `{"text":"nc 203.0.113.7 22\n"}`}, + {"ExecuteExtraTool", `{"tool_name":"mcp__scan__run","params":{"target":"203.0.113.7"}}`}, + } + for round := 0; round < 50; round++ { + for _, c := range calls { + if block, msg := pre(g, ctx, c.tool, c.input); block { + t.Fatalf("%s blocked: %s", c.tool, msg) + } + } + } + // A call that waited for an approval and then ran is a passing call too. + gate.checks, gate.check = 0, unpinnedOnce + if block, msg := pre(g, ctx, "WebFetch", fetchApp); block || len(gate.asked) != 1 { + t.Fatalf("approved call: block=%v msg=%q requests=%d", block, msg, len(gate.asked)) + } + // Gate off: nothing is recorded either. + if block, _ := pre(g, context.Background(), "WebFetch", fetchApp); block { + t.Fatal("gate off: blocked") + } + if n := len(g.Audit()); n != 0 { + t.Fatalf("%d audit entries after passing calls, want none", n) + } + + // One block: one entry, written by block(). + gate.check = func(int) ([]intercept.TargetRequest, intercept.TargetResult) { + return nil, intercept.TargetResult{Code: intercept.TargetResolveFailed, Host: "app.example.com", Port: 443, Reason: "the platform could not resolve app.example.com (no such host)"} + } + block, msg := pre(g, ctx, "WebFetch", fetchApp) + if !block { + t.Fatal("the gate's block did not block the call") + } + audit := g.Audit() + if len(audit) != 1 || audit[0].Tool != "WebFetch" || audit[0].Action != "block" || audit[0].Reason != msg || !strings.Contains(msg, intercept.TargetResolveFailed) { + t.Fatalf("audit = %+v, want one block entry carrying the model-facing text", audit) + } + // A block after a request is one entry as well. + gate.checks, gate.check = 0, unpinnedOnce + gate.request = func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult { + return intercept.TargetResult{Code: intercept.TargetDenied, Host: "app.example.com", Port: 443, PendingID: 41, Reason: "the request was denied"} + } + if block, _ := pre(g, ctx, "WebFetch", fetchApp); !block || len(g.Audit()) != 2 || g.Audit()[1].Action != "block" { + t.Fatalf("denied request: block=%v audit=%+v", block, g.Audit()) + } + // It never counts attributions, and the ring of the task's guard is untouched. + g.Hooks().PostToolUse(ctx, "Bash", []byte(`{}`), []byte(`"403 forbidden"`), true) + if a := g.Attributions(); len(a) != 0 { + t.Fatalf("attributions = %v, want none", a) + } + if after := task.Audit(); !slices.Equal(after, before) { + t.Fatalf("the task guard's ring changed: %+v, was %+v", after, before) + } +} + +// One call goes back to the pin lookup at most three times. +func TestTargetRequestLoopIsBoundedToThreePasses(t *testing.T) { + g := NewTargetOnly(nil) + // Every approval is followed by a check that finds the target uncovered + // again (its addresses keep changing). + gate := &fakeGate{t: t, check: func(int) ([]intercept.TargetRequest, intercept.TargetResult) { + return []intercept.TargetRequest{appTarget}, intercept.TargetResult{} + }} + g.gate = gate + done := make(chan string, 1) + go func() { + _, msg := pre(g, gateOn(context.Background(), "7", time.Minute), "WebFetch", fetchApp) + done <- msg + }() + var msg string + select { + case msg = <-done: + case <-time.After(10 * time.Second): + t.Fatal("the request loop did not end") + } + if len(gate.asked) != 3 || gate.checks != 4 { + t.Fatalf("%d requests and %d checks, want 3 and 4", len(gate.asked), gate.checks) + } + if !strings.Contains(msg, "TARGET_APPROVAL_PENDING: target app.example.com:443: the target is still not covered by a pin after 3 approval rounds") { + t.Fatalf("message = %q", msg) + } + + // A block found by a recheck ends the loop at once. + gate = &fakeGate{t: t, check: func(n int) ([]intercept.TargetRequest, intercept.TargetResult) { + if n == 1 { + return []intercept.TargetRequest{appTarget}, intercept.TargetResult{} + } + return nil, intercept.TargetResult{Code: intercept.TargetResolveFailed, Host: "app.example.com", Port: 443, Reason: "the platform could not resolve app.example.com (no such host)"} + }} + g.gate = gate + if block, msg := pre(g, gateOn(context.Background(), "7", time.Minute), "WebFetch", fetchApp); !block || !strings.Contains(msg, intercept.TargetResolveFailed) || len(gate.asked) != 1 { + t.Fatalf("block=%v msg=%q requests=%d", block, msg, len(gate.asked)) + } +} + +// The model-facing text of a target block is built from the code and the +// reason of the result. It says the block is the platform's, and it does not +// call a transient block forbidden. +func TestTargetBlockMessage(t *testing.T) { + cases := []struct { + res intercept.TargetResult + want []string + }{ + {intercept.TargetResult{Code: intercept.TargetResolveFailed, Host: "app.example.com", Port: 443, + Reason: "the platform could not resolve app.example.com (lookup timed out after 3s), so it cannot compare the address with the approved pin (v2: 203.0.113.7)"}, + []string{"TARGET_RESOLVE_FAILED: the platform could not resolve app.example.com (lookup timed out after 3s), so it cannot compare the address with the approved pin (v2: 203.0.113.7). The call was not executed and no approval was requested.", + "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.TargetResult{Code: intercept.TargetApprovalPending, Host: "app.example.com", Port: 443, PendingID: 9, Reason: "no approval within the wait bound; the request stays open"}, + []string{"TARGET_APPROVAL_PENDING: target app.example.com:443: no approval within the wait bound; the request stays open. The call was not executed.", "retry the same call later"}}, + {intercept.TargetResult{Code: intercept.TargetDenied, Host: "2001:db8::1", Port: 8443, Reason: "the request was denied"}, + []string{"TARGET_DENIED: target [2001:db8::1]:8443: the request was denied. The call was not executed.", "do not retry it"}}, + {intercept.TargetResult{Code: intercept.TargetDenied, Host: "203.0.113.0/24", Reason: "the request was denied"}, + []string{"TARGET_DENIED: target 203.0.113.0/24: the request was denied."}}, + {intercept.TargetResult{Code: intercept.TargetRuleBlocked, Host: "app.example.gov", Port: 443, Reason: "app.example.gov:443 is blocked by an asset rule. Matched asset interception rule [Domain (fuzzy): .gov]"}, + []string{"TARGET_RULE_BLOCKED: app.example.gov:443 is blocked by an asset rule.", "no approval was requested", "do not reach it another way"}}, + {intercept.TargetResult{Code: intercept.TargetTooMany, Host: "198.51.100.1", Reason: "this call names 6 targets that are not approved (198.51.100.1, ...); at most 5 are requested per call"}, + []string{"TARGET_TOO_MANY: this call names 6 targets", "Name fewer new targets per call."}}, + {intercept.TargetResult{Code: intercept.TargetGateUnavailable, Host: "app.example.com", Port: 443, Reason: "the pins could not be read: connection refused"}, + []string{"TARGET_GATE_UNAVAILABLE: target app.example.com:443: the pins could not be read: connection refused.", "failure on the platform side"}}, + } + for _, c := range cases { + msg := targetBlockMessage(c.res) + if !strings.HasPrefix(msg, "[ARTEX platform control, not target defense] "+c.res.Code+": ") { + t.Errorf("%s: message = %q", c.res.Code, msg) + } + for _, want := range c.want { + if !strings.Contains(msg, want) { + t.Errorf("%s: message %q does not contain %q", c.res.Code, msg, want) + } + } + if strings.Contains(msg, "This operation is forbidden") || strings.HasSuffix(msg, " ") { + t.Errorf("%s: message = %q", c.res.Code, msg) + } + } +} + +// ---- With PostgreSQL: a real interceptor, for the rules and the judge ---- + +type gateDB struct { + t *testing.T + d *db.DB + ic *intercept.Interceptor + res *stubResolver + task string // task id +} + +// stubResolver answers every name with the addresses it holds. +type stubResolver struct { + mu sync.Mutex + ips []string + err error +} + +func (r *stubResolver) Name() string { return "stub" } + +func (r *stubResolver) LookupIPs(context.Context, string) ([]netip.Addr, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.err != nil { + return nil, r.err + } + out := make([]netip.Addr, len(r.ips)) + for n, ip := range r.ips { + out[n] = netip.MustParseAddr(ip) + } + return out, nil +} + +// answer changes what every name resolves to; the gate's cache is dropped. +func (g *gateDB) answer(err error, ips ...string) { + g.res.mu.Lock() + g.res.ips, g.res.err = ips, err + g.res.mu.Unlock() + g.ic.SetResolver(g.res) +} + +func newGateDB(t *testing.T) *gateDB { + t.Helper() + dsn, _, err := db.DSN() + if err != nil { + t.Skipf("no database config (%v) - skipping", err) + } + d, err := db.Open(dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + task, err := d.CreateTaskWithOptions("guard target gate", "the gate in the hook", db.TaskCreateOptions{}) + if err != nil { + t.Fatal(err) + } + id := strconv.FormatInt(task.ID, 10) + t.Cleanup(func() { + _ = d.DeleteTask(task.ID) + _, _ = d.Exec(`DELETE FROM target_pins WHERE task_id=$1`, task.ID) + _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, id) + }) + g := &gateDB{t: t, d: d, ic: intercept.New(d), res: &stubResolver{ips: []string{"203.0.113.7"}}, task: id} + g.ic.SetResolver(g.res) + return g +} + +// rule adds an intercept rule that matches marker in the tool input. +func (g *gateDB) rule(action, marker string) db.InterceptRule { + g.t.Helper() + r, err := g.d.CreateInterceptRule("guard test "+marker, "tool_input", "string", marker, action, action+" by the guard test", 100000, true, false, 0, "deny") + if err != nil { + g.t.Fatal(err) + } + g.t.Cleanup(func() { _ = g.d.DeleteInterceptRule(r.ID) }) + g.ic.Invalidate() + return r +} + +// judge turns the fallback judge on with a fixed verdict. +func (g *gateDB) judge(action string) { + g.t.Helper() + cfg := g.ic.GetJudgeConfig() + on := cfg + on.Enabled = true + if err := g.ic.SetJudgeConfig(on); err != nil { + g.t.Fatal(err) + } + g.t.Cleanup(func() { _ = g.ic.SetJudgeConfig(cfg) }) + g.ic.SetReviewer(func(context.Context, int64, string, intercept.ReviewInput) (intercept.Decision, error) { + return intercept.Decision{Action: action, Message: "judge says " + action}, nil + }) +} + +// ctx is the context of a worker call of the task, gate on or off. +func (g *gateDB) ctx(gate bool, wait time.Duration) context.Context { + ctx := intercept.WithTaskContext(context.Background(), g.task, "worker", nil) + if !gate { + return ctx + } + return intercept.WithTargetScope(ctx, intercept.TargetScope{TaskID: g.task, Enabled: func() bool { return true }, WaitBound: wait, Interceptor: g.ic}) +} + +type approvalRow struct { + id int64 + status string + source string + decidedBy string + ruleID int64 + targetKey string +} + +// rows returns the approval rows of the task, oldest first. +func (g *gateDB) rows() []approvalRow { + g.t.Helper() + rows, err := g.d.Query(`SELECT id, status, decision_source, COALESCE(decided_by,''), COALESCE(rule_id,0), COALESCE(target_key,'') +FROM intercept_pending WHERE task_id=$1 ORDER BY id`, g.task) + if err != nil { + g.t.Fatal(err) + } + defer rows.Close() + var out []approvalRow + for rows.Next() { + var r approvalRow + if err := rows.Scan(&r.id, &r.status, &r.source, &r.decidedBy, &r.ruleID, &r.targetKey); err != nil { + g.t.Fatal(err) + } + out = append(out, r) + } + return out +} + +func (g *gateDB) pending() []approvalRow { + var out []approvalRow + for _, r := range g.rows() { + if r.status == "pending" { + out = append(out, r) + } + } + return out +} + +func (g *gateDB) pins() []*db.TargetPin { + g.t.Helper() + id, _ := strconv.ParseInt(g.task, 10, 64) + pins, err := g.d.ListTargetPins(db.TargetScopeRef{TaskID: id}) + if err != nil { + g.t.Fatal(err) + } + return pins +} + +type hookResult struct { + block bool + msg string +} + +// call runs the hook in the background: it may wait for a human. +func call(g *Guard, ctx context.Context, tool, input string) <-chan hookResult { + out := make(chan hookResult, 1) + go func() { + block, msg := pre(g, ctx, tool, input) + out <- hookResult{block, msg} + }() + return out +} + +func (g *gateDB) result(ch <-chan hookResult) hookResult { + g.t.Helper() + select { + case r := <-ch: + return r + case <-time.After(20 * time.Second): + g.t.Fatal("the hook is still waiting") + return hookResult{} + } +} + +// onePending waits until the task has exactly one open row and returns it. +func (g *gateDB) onePending(ch <-chan hookResult) approvalRow { + g.t.Helper() + deadline := time.Now().Add(20 * time.Second) + for { + select { + case r := <-ch: + g.t.Fatalf("the hook returned %+v, want it waiting for a decision", r) + default: + } + if open := g.pending(); len(open) == 1 { + return open[0] + } else if len(open) > 1 { + g.t.Fatalf("%d open rows, want one: %+v", len(open), open) + } + if time.Now().After(deadline) { + g.t.Fatal("no open approval row") + } + time.Sleep(5 * time.Millisecond) + } +} + +func (g *gateDB) decide(id int64, allowed bool) { + g.t.Helper() + if err := g.ic.Decide(id, allowed, "ARTEX", "192.0.2.1"); err != nil { + g.t.Fatal(err) + } +} + +// approve lets one gated WebFetch through: it waits for the request of the +// call and approves it. +func (g *gateDB) approve(guard *Guard, url string) { + g.t.Helper() + ch := call(guard, g.ctx(true, time.Minute), "WebFetch", `{"url":"`+url+`"}`) + g.decide(g.onePending(ch).id, true) + if r := g.result(ch); r.block { + g.t.Fatalf("approved call blocked: %s", r.msg) + } +} + +func marker(t *testing.T) string { + return fmt.Sprintf("guard-gate-%s-%d", strings.ToLower(t.Name()), time.Now().UnixNano()) +} + +// With the gate off, which is the default, every branch of the hook behaves as +// it did before the gate existed, and the gate is never reached. +func TestGateOffLeavesEveryBranchUnchanged(t *testing.T) { + g := newGateDB(t) + deny, allow, ask := marker(t)+"-deny", marker(t)+"-allow", marker(t)+"-ask" + g.rule("deny", deny) + g.rule("allow", allow) + askRule := g.rule("ask", ask) + guard := NewWithInterceptor(g.ic) + guard.gate = &fakeGate{t: t, check: func(int) ([]intercept.TargetRequest, intercept.TargetResult) { + t.Error("CheckTargets was called with the gate off") + return nil, intercept.TargetResult{} + }, request: func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult { + t.Error("RequestTargets was called with the gate off") + return intercept.TargetResult{} + }} + base := intercept.WithTaskContext(context.Background(), g.task, "worker", nil) + off := map[string]context.Context{ + "no scope": base, + "scope, no flag": intercept.WithTargetScope(base, intercept.TargetScope{TaskID: g.task, WaitBound: time.Minute, Interceptor: g.ic}), + "scope, flag off": intercept.WithTargetScope(base, intercept.TargetScope{TaskID: g.task, Enabled: func() bool { return false }, WaitBound: time.Minute, Interceptor: g.ic}), + "no scope, no ctx": context.Background(), + } + // Every input names a target that has no pin. + curl := func(m string) string { return `{"command":"curl https://app.example.com/ # ` + m + `"}` } + for name, ctx := range off { + before := len(g.rows()) + // 1. A tool outside the enabled set: passes, nothing is written. + for _, c := range []struct{ tool, input string }{{"Monitor", curl(deny)}, {"mcp__scan__run", `{"target":"203.0.113.7","note":"` + deny + `"}`}} { + if block, msg := pre(guard, ctx, c.tool, c.input); block { + t.Fatalf("%s: %s blocked: %s", name, c.tool, msg) + } + } + // 2. An enabled tool, no rule, judge off: passes, nothing is written. + if block, msg := pre(guard, ctx, "Bash", curl("no rule")); block { + t.Fatalf("%s: no rule: blocked: %s", name, msg) + } + if block, msg := pre(guard, ctx, "WebFetch", fetchApp); block { + t.Fatalf("%s: WebFetch, no rule: blocked: %s", name, msg) + } + if n := len(g.rows()); name != "no scope, no ctx" && n != before { + t.Fatalf("%s: %d rows written by passing calls", name, n-before) + } + // 3. deny: blocked with the classic text, one denied row. + block, msg := pre(guard, ctx, "Bash", curl(deny)) + if !block || msg != systemBlockMessage("deny by the guard test") { + t.Fatalf("%s: deny: block=%v msg=%q", name, block, msg) + } + // 4. allow: passes, one allowed row. + if block, msg := pre(guard, ctx, "Bash", curl(allow)); block { + t.Fatalf("%s: allow: blocked: %s", name, msg) + } + if name == "no scope, no ctx" { + continue // its rows carry no task id + } + rows := g.rows()[before:] + if len(rows) != 2 || rows[0].status != "denied" || rows[0].decidedBy != "rule" || rows[1].status != "allowed" || rows[1].decidedBy != "rule" || + rows[0].source != "rule" || rows[0].targetKey != "" || rows[1].targetKey != "" { + t.Fatalf("%s: rows = %+v, want one denied and one allowed rule row", name, rows) + } + // 5. ask: one classic pending row; the decision of the human decides. + for _, allowed := range []bool{true, false} { + ch := call(guard, ctx, "Bash", curl(ask)) + row := g.onePending(ch) + if row.ruleID != askRule.ID || row.targetKey != "" || row.source != "rule" { + t.Fatalf("%s: ask row = %+v, want a classic ask of the rule", name, row) + } + g.decide(row.id, allowed) + r := g.result(ch) + if r.block == allowed || (r.block && r.msg != systemBlockMessage("manual approval not granted (user rejected or approval timed out)")) { + t.Fatalf("%s: ask decided %v: %+v", name, allowed, r) + } + } + } + if n := len(g.pins()); n != 0 { + t.Fatalf("%d pins with the gate off, want none", n) + } + // The audit ring still holds one allow entry per call, plus the blocks. + if audit := guard.Audit(); len(audit) == 0 || audit[0].Action != "allow" { + t.Fatalf("audit ring = %+v", audit) + } +} + +// A deny by a rule blocks first, as before: the gate is not consulted and no +// target request exists. +func TestDenyRuleWinsBeforeTargetRequest(t *testing.T) { + g := newGateDB(t) + m := marker(t) + g.rule("deny", m) + guard := NewWithInterceptor(g.ic) + gate := &fakeGate{t: t, check: unpinnedOnce} + guard.gate = gate + block, msg := pre(guard, g.ctx(true, time.Minute), "Bash", `{"command":"curl https://app.example.com/ # `+m+`"}`) + if !block || msg != systemBlockMessage("deny by the guard test") { + t.Fatalf("block=%v msg=%q", block, msg) + } + if gate.checks != 0 || len(gate.asked) != 0 { + t.Fatalf("the gate was reached: %d checks, %d requests", gate.checks, len(gate.asked)) + } + if rows := g.rows(); len(rows) != 1 || rows[0].status != "denied" || rows[0].targetKey != "" { + t.Fatalf("rows = %+v, want the denied rule row only", rows) + } + + // The same with the real gate: no request, no pin. + guard = NewWithInterceptor(g.ic) + if block, _ := pre(guard, g.ctx(true, time.Minute), "Bash", `{"command":"curl https://app.example.com/ # `+m+`"}`); !block { + t.Fatal("deny did not block") + } + if len(g.pending()) != 0 || len(g.pins()) != 0 { + t.Fatalf("pending=%+v pins=%+v, want none", g.pending(), g.pins()) + } +} + +// An allow by a rule is logged as before and does not satisfy the gate: the +// target still needs a human. +func TestRuleAllowDoesNotSatisfyGate(t *testing.T) { + g := newGateDB(t) + m := marker(t) + g.rule("allow", m) + input := `{"command":"curl https://app.example.com/ # ` + m + `"}` + allowDoesNotSatisfyGate(t, g, input, "rule") +} + +func TestJudgeAllowDoesNotSatisfyGate(t *testing.T) { + g := newGateDB(t) + g.judge("allow") + allowDoesNotSatisfyGate(t, g, `{"command":"curl https://app.example.com/"}`, "model") +} + +func allowDoesNotSatisfyGate(t *testing.T, g *gateDB, input, decidedBy string) { + t.Helper() + guard := NewWithInterceptor(g.ic) + // Scripted gate: the request is denied. + gate := &fakeGate{t: t, check: unpinnedOnce, request: func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult { + return intercept.TargetResult{Code: intercept.TargetDenied, Host: "app.example.com", Port: 443, Reason: "the request was denied"} + }} + guard.gate = gate + block, msg := pre(guard, g.ctx(true, time.Minute), "Bash", input) + if !block || !strings.Contains(msg, "TARGET_DENIED: target app.example.com:443: the request was denied") { + t.Fatalf("block=%v msg=%q, want the target denial", block, msg) + } + if len(gate.asked) != 1 || gate.asks[0] != nil { + t.Fatalf("requests=%+v asks=%v, want one request without an ask", gate.asked, gate.asks) + } + if rows := g.rows(); len(rows) != 1 || rows[0].status != "allowed" || rows[0].decidedBy != decidedBy { + t.Fatalf("rows = %+v, want the allow logged by the %s", rows, decidedBy) + } + + // Real gate: the call waits for a human although the allow was logged. + guard = NewWithInterceptor(g.ic) + ch := call(guard, g.ctx(true, time.Minute), "Bash", input) + row := g.onePending(ch) + if row.targetKey != "app.example.com:443" || row.source != "target" || row.ruleID != 0 { + t.Fatalf("open row = %+v, want a plain target request", row) + } + if len(g.pins()) != 0 { + t.Fatalf("pins = %+v: an allow must never pin", g.pins()) + } + g.decide(row.id, true) + if r := g.result(ch); r.block { + t.Fatalf("approved call blocked: %s", r.msg) + } + if pins := g.pins(); len(pins) != 1 || pins[0].Host != "app.example.com" || pins[0].Port != 443 || !slices.Equal(pins[0].PinnedIPs, []string{"203.0.113.7"}) { + t.Fatalf("pins = %+v", pins) + } +} + +// An ask and an unpinned target make one request: one row that carries the +// rule and the target, one click for both. +func TestAskAndUnpinnedTargetCreateOneRequest(t *testing.T) { + g := newGateDB(t) + m := marker(t) + askRule := g.rule("ask", m) + input := `{"command":"curl https://app.example.com/ # ` + m + `"}` + guard := NewWithInterceptor(g.ic) + + ch := call(guard, g.ctx(true, time.Minute), "Bash", input) + row := g.onePending(ch) + if row.ruleID != askRule.ID || row.targetKey != "app.example.com:443" || row.source != "target" { + t.Fatalf("open row = %+v, want one row with the rule and the target", row) + } + detail, err := g.d.GetInterceptDetail(row.id) + if err != nil || detail == nil || !detail.Audit.MergedAsk || len(detail.Audit.Targets) != 1 || + !strings.Contains(detail.Reason, "ask by the guard test") || !strings.Contains(detail.Reason, "app.example.com:443") { + t.Fatalf("merged row = %+v err=%v", detail, err) + } + g.decide(row.id, true) + if r := g.result(ch); r.block { + t.Fatalf("approved call blocked: %s", r.msg) + } + // One click: no second prompt was raised, and the target is pinned. + asked := 0 + for _, r := range g.rows() { + if r.decidedBy != "system" { + asked++ + } + } + if asked != 1 || len(g.pending()) != 0 || len(g.pins()) != 1 { + t.Fatalf("rows=%+v pins=%+v, want one human-decided row and one pin", g.rows(), g.pins()) + } + + // Denied: the call is refused and nothing is pinned. + g.answer(nil, "203.0.113.99") + ch = call(guard, g.ctx(true, time.Minute), "Bash", input) + row = g.onePending(ch) + g.decide(row.id, false) + if r := g.result(ch); !r.block || !strings.Contains(r.msg, "TARGET_DENIED") { + t.Fatalf("denied merged request: %+v", r) + } + if pins := g.pins(); len(pins) != 1 || !slices.Equal(pins[0].PinnedIPs, []string{"203.0.113.7"}) { + t.Fatalf("pins after the denial = %+v, want the first pin only", pins) + } + + // When the ask was not merged (the call attached to a request another call + // opened), the classic ask is raised afterwards, as its own row. + gate := &fakeGate{t: t, check: unpinnedOnce, request: func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult { + return intercept.TargetResult{AskMerged: false} + }} + guard.gate = gate + ch = call(guard, g.ctx(true, time.Minute), "Bash", input) + row = g.onePending(ch) + if row.ruleID != askRule.ID || row.targetKey != "" || row.source != "rule" { + t.Fatalf("row = %+v, want the classic ask of the rule", row) + } + if len(gate.asked) != 1 || gate.asks[0] == nil || gate.asks[0].RuleID != askRule.ID { + t.Fatalf("the ask was not offered to the target request: %+v", gate.asks) + } + g.decide(row.id, false) + if r := g.result(ch); !r.block || r.msg != systemBlockMessage("manual approval not granted (user rejected or approval timed out)") { + t.Fatalf("classic ask denied: %+v", r) + } + // Merged by the request: no classic ask. + gate = &fakeGate{t: t, check: unpinnedOnce, request: func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult { + return intercept.TargetResult{AskMerged: true} + }} + guard.gate = gate + if block, msg := pre(guard, g.ctx(true, time.Minute), "Bash", input); block || len(g.pending()) != 0 { + t.Fatalf("merged ask: block=%v msg=%q pending=%+v", block, msg, g.pending()) + } +} + +// An ask without an unpinned target is the classic ask, unchanged. +func TestAskAloneIsUnchanged(t *testing.T) { + g := newGateDB(t) + m := marker(t) + askRule := g.rule("ask", m) + guard := NewWithInterceptor(g.ic) + g.approve(guard, "https://app.example.com/") + settled := len(g.rows()) + + for name, input := range map[string]string{ + "no target": `{"command":"id # ` + m + `"}`, + "pinned target": `{"command":"curl https://app.example.com/ # ` + m + `"}`, + } { + for _, allowed := range []bool{true, false} { + ch := call(guard, g.ctx(true, time.Minute), "Bash", input) + row := g.onePending(ch) + if row.ruleID != askRule.ID || row.targetKey != "" || row.source != "rule" { + t.Fatalf("%s: row = %+v, want a classic ask", name, row) + } + g.decide(row.id, allowed) + r := g.result(ch) + if r.block == allowed || (r.block && r.msg != systemBlockMessage("manual approval not granted (user rejected or approval timed out)")) { + t.Fatalf("%s decided %v: %+v", name, allowed, r) + } + } + } + // Four classic asks, and no further target request or pin. + classic := 0 + for _, r := range g.rows()[settled:] { + if r.targetKey != "" { + t.Fatalf("a target request was opened: %+v", r) + } + if r.source == "rule" { + classic++ + } + } + if classic != 4 || len(g.pins()) != 1 { + t.Fatalf("%d classic asks and %d pins, want 4 and 1", classic, len(g.pins())) + } + // A cancelled call is blocked before any row is written, as before. + ctx, cancel := context.WithCancel(g.ctx(true, time.Minute)) + cancel() + before := len(g.rows()) + if block, msg := pre(guard, ctx, "Bash", `{"command":"id # `+m+`"}`); !block || !strings.Contains(msg, "work cancelled") || len(g.rows()) != before { + t.Fatalf("cancelled ask: block=%v msg=%q rows=%d (was %d)", block, msg, len(g.rows()), before) + } +} + +// The gate in the hook, end to end against the database: request, approval, +// pin, changed address, denial, resolver failure, and the round that never waits. +func TestTargetGateThroughHook(t *testing.T) { + g := newGateDB(t) + guard := NewWithInterceptor(g.ic) + ctx := g.ctx(true, time.Minute) + + // Unpinned: the call waits on one request; the approval pins and lets it run. + ch := call(guard, ctx, "WebFetch", fetchApp) + row := g.onePending(ch) + if row.targetKey != "app.example.com:443" || row.source != "target" { + t.Fatalf("request row = %+v", row) + } + if len(g.pins()) != 0 { + t.Fatal("a pin exists before any human decision") + } + g.decide(row.id, true) + if r := g.result(ch); r.block { + t.Fatalf("approved call blocked: %s", r.msg) + } + // Pinned: later calls pass at once, on any tool, and open nothing. + requests := func() int { + n := 0 + for _, r := range g.rows() { + if r.targetKey != "" { + n++ + } + } + return n + } + for _, c := range []struct{ tool, input string }{ + {"WebFetch", fetchApp}, {"Bash", `{"command":"curl -s https://app.example.com/api"}`}, + {"Monitor", `{"command":"curl -N https://app.example.com/stream"}`}, + {"ExecuteExtraTool", `{"tool_name":"mcp__browser__navigate","params":{"url":"https://app.example.com/"}}`}, + } { + if block, msg := pre(guard, ctx, c.tool, c.input); block { + t.Fatalf("pinned target, %s: blocked: %s", c.tool, msg) + } + } + if requests() != 1 || len(g.pending()) != 0 { + t.Fatalf("%d request rows and %d open, want the first request only", requests(), len(g.pending())) + } + + // A changed address asks again; a denial blocks and pins nothing. + g.answer(nil, "203.0.113.7", "203.0.113.8") + ch = call(guard, ctx, "WebFetch", fetchApp) + row = g.onePending(ch) + g.decide(row.id, false) + r := g.result(ch) + if !r.block || !strings.HasPrefix(r.msg, "[ARTEX platform control, not target defense] TARGET_DENIED: target app.example.com:443: ") { + t.Fatalf("denied: %+v", r) + } + if pins := g.pins(); len(pins) != 1 || pins[0].Version != 1 { + t.Fatalf("pins after the denial = %+v", pins) + } + + // A name that does not resolve blocks at once, without a request. + g.answer(fmt.Errorf("no such host")) + before := requests() + block, msg := pre(guard, ctx, "WebFetch", fetchApp) + if !block || !strings.Contains(msg, "TARGET_RESOLVE_FAILED: the platform could not resolve app.example.com (no such host), so it cannot compare the address with the approved pin (v1: 203.0.113.7)") { + t.Fatalf("resolver failure: block=%v msg=%q", block, msg) + } + if requests() != before || len(g.pending()) != 0 { + t.Fatalf("a resolver failure opened a request: %+v", g.pending()) + } + + // Wait bound 0 (the final settlement round): the call never waits. It is + // blocked at once and the request stays open for a later approval. + g.answer(nil, "203.0.113.50") + start := time.Now() + block, msg = pre(guard, g.ctx(true, 0), "WebFetch", `{"url":"https://other.example.com/"}`) + if !block || !strings.Contains(msg, "TARGET_APPROVAL_PENDING: target other.example.com:443: ") || time.Since(start) > 5*time.Second { + t.Fatalf("wait bound 0: block=%v msg=%q after %s", block, msg, time.Since(start)) + } + open := g.pending() + if len(open) != 1 || open[0].targetKey != "other.example.com:443" { + t.Fatalf("open rows = %+v, want the request left open", open) + } + // Approved later: the next call passes. + g.decide(open[0].id, true) + if block, msg := pre(guard, g.ctx(true, 0), "WebFetch", `{"url":"https://other.example.com/"}`); block { + t.Fatalf("after the late approval: blocked: %s", msg) + } + + // More than five unpinned targets in one call: blocked, nothing requested. + block, msg = pre(guard, ctx, "Bash", `{"command":"nmap 198.51.100.1 198.51.100.2 198.51.100.3 198.51.100.4 198.51.100.5 198.51.100.6"}`) + if !block || !strings.Contains(msg, "TARGET_TOO_MANY: ") || len(g.pending()) != 0 { + t.Fatalf("six targets: block=%v msg=%q pending=%+v", block, msg, g.pending()) + } +} + +// Owner condition. An approved pin with name_only_effective = false; then +// global_proxy is set; then a call whose name resolves to another address. The +// call must not block and must not open a request. +func TestProxySetAfterApprovalNeitherBlocksNorAsks(t *testing.T) { + g := newGateDB(t) + proxy := "" + g.ic.SetUpstreamProxyCheck(func() bool { return proxy != "" }) + guard := NewWithInterceptor(g.ic) + g.approve(guard, "https://app.example.com/") + pins := g.pins() + if len(pins) != 1 || pins[0].NameOnlyEffective || !slices.Equal(pins[0].PinnedIPs, []string{"203.0.113.7"}) { + t.Fatalf("pins = %+v, want one pin with name_only_effective = false", pins) + } + rows := len(g.rows()) + + proxy = "http://127.0.0.1:3128" + g.answer(nil, "198.51.100.99") + for _, c := range []struct{ tool, input string }{ + {"WebFetch", fetchApp}, {"Bash", `{"command":"curl https://app.example.com/"}`}, + } { + r := g.result(call(guard, g.ctx(true, time.Minute), c.tool, c.input)) + if r.block { + t.Fatalf("%s behind the proxy: blocked: %s", c.tool, r.msg) + } + } + if open := g.pending(); len(open) != 0 { + t.Fatalf("the call opened a request: %+v", open) + } + for _, r := range g.rows()[rows:] { + if r.targetKey != "" || r.status != "allowed" || r.decidedBy != "system" { + t.Fatalf("row written by the call = %+v, want only the first use of the pin", r) + } + } + after := g.pins() + if len(after) != 1 || after[0].ID != pins[0].ID || after[0].NameOnlyEffective || after[0].Version != 1 || !slices.Equal(after[0].PinnedIPs, []string{"203.0.113.7"}) { + t.Fatalf("pins after the call = %+v, want the same row, unchanged", after) + } + + // Control: the same call without the proxy waits for a human. + proxy = "" + ch := call(guard, g.ctx(true, time.Minute), "WebFetch", fetchApp) + row := g.onePending(ch) + g.decide(row.id, false) + if r := g.result(ch); !r.block { + t.Fatal("control: the changed address was let through without the proxy") + } +} + +// Chat and retester sessions are scoped to their conversation. Gate off (the +// default): the hook behaves as before. Gate on: the request, the pin and the +// rows of the gate belong to the conversation. +func TestTargetGateConversationScope(t *testing.T) { + g := newGateDB(t) + conv, err := g.d.CreateConversation("assistant", "guard target gate", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = g.d.DeleteConversation(conv.ID) + _, _ = g.d.Exec(`DELETE FROM target_pins WHERE conversation_id=$1`, conv.ID) + }) + m := marker(t) + g.rule("deny", m) + guard := NewWithInterceptor(g.ic) + chat := func(on bool) context.Context { + return intercept.WithTargetScope(intercept.WithConvID(context.Background(), conv.ID), + intercept.TargetScope{ConversationID: conv.ID, Enabled: func() bool { return on }, WaitBound: time.Minute, Interceptor: g.ic}) + } + rows := func(where string) int { + t.Helper() + var n int + if err := g.d.QueryRow(`SELECT count(*) FROM intercept_pending WHERE conversation_id=$1 AND `+where, conv.ID).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + + // Gate off: an unpinned target passes and writes nothing; a deny rule blocks as before. + for _, c := range []struct{ tool, input string }{{"WebFetch", fetchApp}, {"Bash", `{"command":"curl https://app.example.com/"}`}, {"mcp__scan__run", `{"target":"203.0.113.7"}`}} { + if block, msg := pre(guard, chat(false), c.tool, c.input); block { + t.Fatalf("gate off, %s: blocked: %s", c.tool, msg) + } + } + if n := rows("true"); n != 0 { + t.Fatalf("gate off: %d rows, want none", n) + } + if block, msg := pre(guard, chat(false), "Bash", `{"command":"curl https://app.example.com/ # `+m+`"}`); !block || msg != systemBlockMessage("deny by the guard test") { + t.Fatalf("gate off, deny: block=%v msg=%q", block, msg) + } + if rows("decision_source='target'") != 0 || rows("status='denied' AND decided_by='rule'") != 1 { + t.Fatal("gate off: want the one denied rule row and no row of the gate") + } + + // Gate on: the call waits on a request of the conversation. + ch := call(guard, chat(true), "WebFetch", fetchApp) + var id int64 + deadline := time.Now().Add(20 * time.Second) + for rows("status='pending' AND target_key='app.example.com:443'") != 1 { + if time.Now().After(deadline) { + t.Fatal("no open target request for the conversation") + } + time.Sleep(5 * time.Millisecond) + } + if err := g.d.QueryRow(`SELECT id FROM intercept_pending WHERE conversation_id=$1 AND status='pending'`, conv.ID).Scan(&id); err != nil { + t.Fatal(err) + } + g.decide(id, true) + if r := g.result(ch); r.block { + t.Fatalf("approved call blocked: %s", r.msg) + } + pins, err := g.d.ListTargetPins(db.TargetScopeRef{ConversationID: conv.ID}) + if err != nil || len(pins) != 1 || pins[0].ConversationID == nil || *pins[0].ConversationID != conv.ID || pins[0].TaskID != nil { + t.Fatalf("pins = %+v, %v; want one pin of the conversation", pins, err) + } + if block, msg := pre(guard, chat(true), "Bash", `{"command":"curl https://app.example.com/"}`); block { + t.Fatalf("pinned: blocked: %s", msg) + } + // The pin is the conversation's: the task of this test still has to ask. + if len(g.pins()) != 0 { + t.Fatalf("the task got pins: %+v", g.pins()) + } + block, msg := pre(guard, g.ctx(true, 0), "WebFetch", fetchApp) + if !block || !strings.Contains(msg, "TARGET_APPROVAL_PENDING") { + t.Fatalf("the task passed on the conversation's pin: block=%v msg=%q", block, msg) + } +} diff --git a/guard/testmain_test.go b/guard/testmain_test.go new file mode 100644 index 0000000..847a288 --- /dev/null +++ b/guard/testmain_test.go @@ -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()) +} diff --git a/intercept/intercept.go b/intercept/intercept.go index 036d598..4dd72dd 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -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. diff --git a/intercept/target.go b/intercept/target.go new file mode 100644 index 0000000..1f1a374 --- /dev/null +++ b/intercept/target.go @@ -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<> 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 +} diff --git a/intercept/target_exempt.go b/intercept/target_exempt.go index 4df287e..9f365f1 100644 --- a/intercept/target_exempt.go +++ b/intercept/target_exempt.go @@ -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 +} diff --git a/intercept/target_extract.go b/intercept/target_extract.go new file mode 100644 index 0000000..315ae43 --- /dev/null +++ b/intercept/target_extract.go @@ -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 +} diff --git a/intercept/target_gate.go b/intercept/target_gate.go new file mode 100644 index 0000000..1c43f22 --- /dev/null +++ b/intercept/target_gate.go @@ -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 +} diff --git a/intercept/target_gate_pg_test.go b/intercept/target_gate_pg_test.go new file mode 100644 index 0000000..2b8590a --- /dev/null +++ b/intercept/target_gate_pg_test.go @@ -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) + } +} diff --git a/intercept/target_gate_test.go b/intercept/target_gate_test.go new file mode 100644 index 0000000..a4d4376 --- /dev/null +++ b/intercept/target_gate_test.go @@ -0,0 +1,1042 @@ +package intercept + +import ( + "context" + "errors" + "fmt" + "net/netip" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/Autumn-27/artex/db" +) + +// fakeStore is the gate's store without a database. approve and revoke do what +// the decide endpoint and the revoke route do to the pins. +type fakeStore struct { + mu sync.Mutex + rules []db.AssetInterceptRule + pins []*db.TargetPin + exempt []db.TargetExemption + fail map[string]error // by method name + reads int + logged []db.TargetDecision +} + +func (s *fakeStore) read(method string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.reads++ + return s.fail[method] +} + +func (s *fakeStore) BlockRules(db.TargetScopeRef) ([]db.AssetInterceptRule, error) { + return s.rules, s.read("BlockRules") +} + +func (s *fakeStore) ActivePins(_ db.TargetScopeRef, host string, port int) ([]*db.TargetPin, error) { + if err := s.read("ActivePins"); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + var out []*db.TargetPin + for _, p := range s.pins { + if p.Status == db.TargetPinActive && p.Host == host && (p.Port == port || p.Port == 0) { + out = append(out, p) + } + } + slices.SortFunc(out, func(a, b *db.TargetPin) int { return b.Port - a.Port }) + return out, nil +} + +func (s *fakeStore) Exemptions() ([]db.TargetExemption, string, error) { + return s.exempt, fmt.Sprintf("digest-%d", len(s.exempt)), s.read("Exemptions") +} + +func (s *fakeStore) LogDecision(r db.TargetDecision) error { + s.mu.Lock() + defer s.mu.Unlock() + s.logged = append(s.logged, r) + return nil +} + +// approve pins what the request displays: a new version that holds the +// addresses of the active pin of the key plus the displayed ones. +func (s *fakeStore) approve(r TargetRequest) *db.TargetPin { + s.mu.Lock() + defer s.mu.Unlock() + pin := &db.TargetPin{ID: int64(len(s.pins) + 1), Host: r.Host, Port: r.Port, Version: 1, Status: db.TargetPinActive, + PinnedIPs: slices.Clone(r.ResolvedIPs)} + for _, p := range s.pins { + if p.Host != r.Host || p.Port != r.Port { + continue + } + pin.Version++ + if p.Status == db.TargetPinActive { + p.Status = db.TargetPinSuperseded + pin.PinnedIPs = append(pin.PinnedIPs, p.PinnedIPs...) + } + } + slices.Sort(pin.PinnedIPs) + pin.PinnedIPs = slices.Compact(pin.PinnedIPs) + s.pins = append(s.pins, pin) + return pin +} + +func (s *fakeStore) revoke(host string, port int) { + s.mu.Lock() + defer s.mu.Unlock() + for _, p := range s.pins { + if p.Host == host && p.Port == port && p.Status == db.TargetPinActive { + p.Status = db.TargetPinRevoked + } + } +} + +func (s *fakeStore) active(host string, port int) *db.TargetPin { + pins, _ := s.ActivePins(db.TargetScopeRef{}, host, port) + for _, p := range pins { + if p.Port == port { + return p + } + } + return nil +} + +// outcomes returns the outcome of the first target of every logged row. +func (s *fakeStore) outcomes() []string { + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for _, r := range s.logged { + out = append(out, r.Status+"/"+r.Audit.Targets[0].Outcome) + } + return out +} + +// fakeResolver answers from a table and counts the lookups per host. +type fakeResolver struct { + mu sync.Mutex + answers map[string][]string + errs map[string]error + calls map[string]int + hold chan struct{} // when set, a lookup waits until it is closed +} + +func newFakeResolver() *fakeResolver { + return &fakeResolver{answers: map[string][]string{}, errs: map[string]error{}, calls: map[string]int{}} +} + +func (r *fakeResolver) Name() string { return "fake" } + +func (r *fakeResolver) LookupIPs(ctx context.Context, host string) ([]netip.Addr, error) { + r.mu.Lock() + r.calls[host]++ + hold, err := r.hold, r.errs[host] + ips := r.answers[host] + r.mu.Unlock() + if hold != nil { + select { + case <-hold: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if err != nil { + return nil, err + } + out := make([]netip.Addr, len(ips)) + for n, ip := range ips { + out[n] = netip.MustParseAddr(ip) + } + return out, nil +} + +func (r *fakeResolver) set(host string, ips ...string) { + r.mu.Lock() + r.answers[host] = ips + delete(r.errs, host) + r.mu.Unlock() +} + +func (r *fakeResolver) lookups(host string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.calls[host] +} + +func (r *fakeResolver) total() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.calls { + n += c + } + return n +} + +// gateFixture is a gate with a fake store, a fake resolver and a clock. +type gateFixture struct { + t *testing.T + ic *Interceptor + store *fakeStore + res *fakeResolver + clock time.Time + scope TargetScope +} + +func newGate(t *testing.T) *gateFixture { + t.Helper() + g := &gateFixture{t: t, ic: New(nil), store: &fakeStore{fail: map[string]error{}}, res: newFakeResolver(), clock: time.Now().UTC()} + g.ic.targets = g.store + g.ic.SetResolver(g.res) + g.ic.resolve.now = func() time.Time { return g.clock } + g.scope = TargetScope{TaskID: "7", Enabled: func() bool { return true }, WaitBound: time.Minute, Interceptor: g.ic} + return g +} + +// expire lets every cached answer run out. +func (g *gateFixture) expire() { g.clock = g.clock.Add(resolveCacheTTL + time.Second) } + +// verdict is what CheckTargets returned for one call. +type verdict struct { + reqs []TargetRequest + res TargetResult +} + +func (g *gateFixture) check(tool, input string) verdict { + reqs, res := g.ic.CheckTargets(context.Background(), g.scope, tool, []byte(input)) + return verdict{reqs, res} +} + +func (g *gateFixture) fetch(url string) verdict { + return g.check("WebFetch", `{"url":"`+url+`"}`) +} + +// passes asserts that the call may run: no block and nothing to request. +func (g *gateFixture) passes(what string, v verdict) { + g.t.Helper() + reqs, res := v.reqs, v.res + if res.Code != "" || len(reqs) != 0 { + g.t.Fatalf("%s: requests=%+v result=%+v, want the call to pass", what, reqs, res) + } +} + +// requests asserts that the call is not blocked and needs exactly one approval. +func (g *gateFixture) requests(what string, v verdict) TargetRequest { + g.t.Helper() + reqs, res := v.reqs, v.res + if res.Code != "" || len(reqs) != 1 { + g.t.Fatalf("%s: requests=%+v result=%+v, want one request and no block", what, reqs, res) + } + return reqs[0] +} + +// blocked asserts a block with code that opens no request. +func (g *gateFixture) blocked(what, code string, v verdict) TargetResult { + g.t.Helper() + reqs, res := v.reqs, v.res + if res.Code != code || len(reqs) != 0 { + g.t.Fatalf("%s: requests=%+v result=%+v, want a block with %s and no request", what, reqs, res, code) + } + return res +} + +const appURL = "https://app.example.com/login" + +// With the gate off nothing is looked up, read or written. The interceptor of +// the second half has no database: a read through it would panic. +func TestGateOffTouchesNothing(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + bare := New(nil) + for name, scope := range map[string]TargetScope{ + "no flag": {TaskID: "7", WaitBound: time.Minute}, + "flag off": {TaskID: "7", Enabled: func() bool { return false }, WaitBound: time.Minute}, + "no scope": {}, + } { + for _, ic := range []*Interceptor{g.ic, bare} { + reqs, res := ic.CheckTargets(context.Background(), scope, "WebFetch", []byte(`{"url":"`+appURL+`"}`)) + if len(reqs) != 0 || res != (TargetResult{}) { + t.Errorf("%s: requests=%+v result=%+v, want nothing", name, reqs, res) + } + } + } + if g.store.reads != 0 || len(g.store.logged) != 0 || g.res.total() != 0 { + t.Errorf("gate off: %d store reads, %d rows, %d lookups, want none", g.store.reads, len(g.store.logged), g.res.total()) + } + // A gated call without a target stops after the extraction. + g.passes("no target", g.check("Bash", `{"command":"ls -la"}`)) + if g.store.reads != 0 || g.res.total() != 0 { + t.Errorf("call without a target: %d store reads, %d lookups, want none", g.store.reads, g.res.total()) + } +} + +func TestGateStateMachine(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + + // No pin: a request for the addresses that were resolved. + req := g.requests("no pin", g.fetch(appURL)) + if req.Host != "app.example.com" || req.Port != 443 || req.Kind != TargetDomain || req.Reason != "no_pin" || req.Pin != nil || + !slices.Equal(req.ResolvedIPs, []string{"203.0.113.7"}) || req.Resolver != "fake" || req.ResolvedAt.IsZero() { + t.Fatalf("first request = %+v", req) + } + // A request alone pins nothing: the next call asks again. + g.requests("still no pin", g.fetch(appURL)) + + // Approved: version 1, and the call passes. + if pin := g.store.approve(req); pin.Version != 1 { + t.Fatalf("pin = %+v, want version 1", pin) + } + g.passes("pinned", g.fetch(appURL)) + + // A new address: a request with reason new_ip that names the pin to extend. + g.res.set("app.example.com", "203.0.113.7", "203.0.113.8") + g.expire() + req = g.requests("new address", g.fetch(appURL)) + if req.Reason != "new_ip" || req.Pin == nil || req.Pin.Version != 1 || !slices.Equal(req.ResolvedIPs, []string{"203.0.113.7", "203.0.113.8"}) { + t.Fatalf("new_ip request = %+v", req) + } + + // Approved: version 2 holds the old addresses and the new one. + if pin := g.store.approve(req); pin.Version != 2 || !slices.Equal(pin.PinnedIPs, []string{"203.0.113.7", "203.0.113.8"}) { + t.Fatalf("pin = %+v, want version 2 with both addresses", pin) + } + g.passes("extended pin", g.fetch(appURL)) + + // A subset of the pinned addresses passes. + g.res.set("app.example.com", "203.0.113.8") + g.expire() + g.passes("subset", g.fetch(appURL)) + + // An IP literal is never resolved: the address is the literal. + req = g.requests("IP literal", g.fetch("http://198.51.100.9:8080/")) + if req.Kind != TargetIP || req.Host != "198.51.100.9" || req.Port != 8080 || !slices.Equal(req.ResolvedIPs, []string{"198.51.100.9"}) || req.Resolver != literalResolver { + t.Fatalf("literal request = %+v", req) + } + g.store.approve(req) + g.passes("pinned literal", g.fetch("http://198.51.100.9:8080/")) + req = g.requests("CIDR", g.check("Bash", `{"command":"nmap 198.51.100.0/24"}`)) + if req.Kind != TargetCIDR || req.Host != "198.51.100.0/24" || !slices.Equal(req.ResolvedIPs, []string{"198.51.100.0/24"}) { + t.Fatalf("CIDR request = %+v", req) + } + if n := g.res.lookups("198.51.100.9") + g.res.lookups("198.51.100.0/24"); n != 0 { + t.Fatalf("%d lookups for an IP literal and a CIDR, want none", n) + } + + // Revoked: the host asks again, from version 1's successor. + g.store.revoke("app.example.com", 443) + req = g.requests("revoked", g.fetch(appURL)) + if req.Reason != "no_pin" || req.Pin != nil { + t.Fatalf("request after the revocation = %+v", req) + } + if pin := g.store.approve(req); pin.Version != 3 || !slices.Equal(pin.PinnedIPs, []string{"203.0.113.8"}) { + t.Fatalf("pin after the revocation = %+v, want version 3 with the current address only", pin) + } +} + +func TestSubset(t *testing.T) { + cases := []struct { + name string + resolved, pinned []string + want bool + }{ + {"equal", []string{"203.0.113.7"}, []string{"203.0.113.7"}, true}, + {"subset", []string{"203.0.113.7"}, []string{"203.0.113.7", "203.0.113.8"}, true}, + {"superset", []string{"203.0.113.7", "203.0.113.8"}, []string{"203.0.113.7"}, false}, + {"disjoint", []string{"203.0.113.9"}, []string{"203.0.113.7"}, false}, + {"mixed families, all pinned", []string{"203.0.113.7", "2001:db8::7"}, []string{"2001:db8::7", "203.0.113.7"}, true}, + {"mixed families, one family pinned", []string{"203.0.113.7", "2001:db8::7"}, []string{"203.0.113.7"}, false}, + {"empty answer", nil, []string{"203.0.113.7"}, false}, + {"empty answer, empty pin", nil, nil, false}, + {"empty pin", []string{"203.0.113.7"}, nil, false}, + } + for _, c := range cases { + if got := subset(c.resolved, c.pinned); got != c.want { + t.Errorf("%s: subset(%v, %v) = %v, want %v", c.name, c.resolved, c.pinned, got, c.want) + } + } +} + +// A name that does not resolve blocks the call, pinned or not, and opens no +// request: there is no address to show, and nothing an approval could pin. +func TestResolveFailureBlocksWithoutRequest(t *testing.T) { + g := newGate(t) + g.res.errs["app.example.com"] = errors.New("no such host") + res := g.blocked("lookup error", TargetResolveFailed, g.fetch(appURL)) + if res.Host != "app.example.com" || res.Port != 443 || !strings.Contains(res.Reason, "could not resolve app.example.com (no such host)") { + t.Fatalf("result = %+v", res) + } + + // An empty answer is a failure too. + g.res.set("app.example.com") + res = g.blocked("empty answer", TargetResolveFailed, g.fetch(appURL)) + if !strings.Contains(res.Reason, "no address") { + t.Fatalf("empty answer: reason = %q", res.Reason) + } + + // With a pin the failure still blocks, and the text names the pin. + g.res.set("app.example.com", "203.0.113.7") + g.store.approve(g.requests("resolves again", g.fetch(appURL))) + g.passes("pinned", g.fetch(appURL)) + g.res.errs["app.example.com"] = context.DeadlineExceeded + g.expire() + res = g.blocked("pinned host", TargetResolveFailed, g.fetch(appURL)) + if !strings.Contains(res.Reason, "approved pin (v1: 203.0.113.7)") { + t.Fatalf("pinned host: reason = %q", res.Reason) + } + + // Every block is a decided row: denied by the system, outcome resolve_failed. + want := []string{"denied/resolve_failed", "denied/resolve_failed", "allowed/pin_match", "denied/resolve_failed"} + if got := g.store.outcomes(); !slices.Equal(got, want) { + t.Fatalf("logged rows = %v, want %v", got, want) + } + if row := g.store.logged[0]; !strings.HasPrefix(row.Reason, TargetResolveFailed+": ") || row.Audit.EffectiveAction != "deny" || + row.Audit.ExecutionStatus != "not_executed" || row.ToolName != "WebFetch" || row.Scope.TaskID != 7 { + t.Fatalf("logged row = %+v audit=%+v", row, row.Audit) + } + // A request without an address is refused before any row is written (the + // interceptor has no database: a write would panic). + out := g.ic.RequestTargets(context.Background(), g.scope, "WebFetch", []byte(`{"url":"`+appURL+`"}`), + []TargetRequest{{Host: "app.example.com", Port: 443, Kind: TargetDomain, Reason: "no_pin"}}, nil) + if out.Code != TargetResolveFailed || out.PendingID != 0 { + t.Fatalf("request without an address: %+v, want %s and no row", out, TargetResolveFailed) + } +} + +func TestResolverCacheTTLAndCollapse(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + g.res.set("other.example.com", "203.0.113.9") + cache := g.ic.resolve + + // Concurrent lookups for one host collapse into one. + g.res.hold = make(chan struct{}) + var wg sync.WaitGroup + for n := 0; n < 8; n++ { + wg.Add(1) + go func() { + defer wg.Done() + if res, err := cache.lookup(context.Background(), "app.example.com", false); err != nil || !slices.Equal(res.ips, []string{"203.0.113.7"}) { + t.Errorf("lookup = %+v, %v", res, err) + } + }() + } + eventually(t, "the shared lookup", func() bool { return g.res.lookups("app.example.com") == 1 }) + time.Sleep(20 * time.Millisecond) + close(g.res.hold) + wg.Wait() + g.res.mu.Lock() + g.res.hold = nil + g.res.mu.Unlock() + if n := g.res.lookups("app.example.com"); n != 1 { + t.Fatalf("%d lookups for 8 concurrent calls, want 1", n) + } + + // Within the TTL the answer is reused, per host. + g.res.set("app.example.com", "203.0.113.8") + g.clock = g.clock.Add(resolveCacheTTL - time.Second) + if res, _ := cache.lookup(context.Background(), "app.example.com", false); !slices.Equal(res.ips, []string{"203.0.113.7"}) { + t.Fatalf("within the TTL: %v, want the cached answer", res.ips) + } + if _, err := cache.lookup(context.Background(), "other.example.com", false); err != nil || g.res.lookups("other.example.com") != 1 { + t.Fatalf("another host must have its own lookup: err=%v lookups=%d", err, g.res.lookups("other.example.com")) + } + // After the TTL it is resolved again. + g.clock = g.clock.Add(2 * time.Second) + if res, _ := cache.lookup(context.Background(), "app.example.com", false); !slices.Equal(res.ips, []string{"203.0.113.8"}) || g.res.lookups("app.example.com") != 2 { + t.Fatalf("after the TTL: %v after %d lookups, want the new answer from a second lookup", res.ips, g.res.lookups("app.example.com")) + } + // Answers are canonical: unmapped, sorted, unique. + g.res.set("mapped.example.com", "::ffff:203.0.113.7", "2001:db8::1", "203.0.113.7") + if res, _ := cache.lookup(context.Background(), "mapped.example.com", false); !slices.Equal(res.ips, []string{"2001:db8::1", "203.0.113.7"}) { + t.Fatalf("canonical answer = %v", res.ips) + } +} + +func TestResolverNeverCachesFailure(t *testing.T) { + g := newGate(t) + cache := g.ic.resolve + g.res.errs["app.example.com"] = errors.New("server misbehaving") + for n := 1; n <= 2; n++ { + if _, err := cache.lookup(context.Background(), "app.example.com", false); err == nil || g.res.lookups("app.example.com") != n { + t.Fatalf("failing lookup %d: err=%v lookups=%d", n, err, g.res.lookups("app.example.com")) + } + } + // An empty answer is not kept either. + g.res.set("app.example.com") + for n := 3; n <= 4; n++ { + if _, err := cache.lookup(context.Background(), "app.example.com", false); err == nil || g.res.lookups("app.example.com") != n { + t.Fatalf("empty lookup %d: err=%v lookups=%d", n, err, g.res.lookups("app.example.com")) + } + } + // The next call gets the answer as soon as there is one. + g.res.set("app.example.com", "203.0.113.7") + if res, err := cache.lookup(context.Background(), "app.example.com", false); err != nil || !slices.Equal(res.ips, []string{"203.0.113.7"}) { + t.Fatalf("after the failure: %v, %v", res.ips, err) + } + // A lookup that does not answer in time is a failure, and is not kept. + g.res.hold = make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if _, err := cache.lookup(ctx, "slow.example.com", false); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("slow lookup: err=%v, want the caller's deadline", err) + } + close(g.res.hold) +} + +// The addresses of a new request are resolved when it is raised, never taken +// from the cache: a human reads and pins them. +func TestRequestCreationResolvesFresh(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + g.store.approve(g.requests("first", g.fetch(appURL))) + g.passes("pinned", g.fetch(appURL)) + before := g.res.lookups("app.example.com") + + // Within the TTL the pinned host is checked against the cached answer. + g.res.set("app.example.com", "203.0.113.7") + g.passes("cached", g.fetch(appURL)) + if n := g.res.lookups("app.example.com"); n != before { + t.Fatalf("%d lookups, want the cached answer (%d)", n, before) + } + + // The cache still says .7; the name now answers .9. Another port of the host + // has no pin, so a request is raised, and it shows what the name answers now. + g.res.set("app.example.com", "203.0.113.9") + req := g.requests("unpinned port", g.fetch("https://app.example.com:8443/")) + if !slices.Equal(req.ResolvedIPs, []string{"203.0.113.9"}) { + t.Fatalf("request shows %v, want the fresh answer 203.0.113.9", req.ResolvedIPs) + } + if n := g.res.lookups("app.example.com"); n != before+1 { + t.Fatalf("%d lookups, want one fresh lookup for the request (%d)", n, before+1) + } + // A cached answer that no pin covers is checked again before asking: when + // the fresh answer is pinned, nothing is requested. + g.res.set("app.example.com", "203.0.113.7") + g.passes("fresh answer is pinned", g.fetch(appURL)) +} + +func TestExemptHostNeedsNoPin(t *testing.T) { + g := newGate(t) + g.store.exempt = []db.TargetExemption{{Host: "pypi.org", Port: 443}} + g.res.set("pypi.org", "151.101.0.223") + g.passes("exempt", g.check("Bash", `{"command":"pip download -i https://pypi.org/simple requests"}`)) + g.passes("exempt again", g.fetch("https://pypi.org/simple/")) + if len(g.store.pins) != 0 { + t.Fatalf("an exemption created pins: %+v", g.store.pins) + } + // The first use is logged once per process, with the digest of the list. + if got := g.store.outcomes(); !slices.Equal(got, []string{"allowed/exempt"}) { + t.Fatalf("logged rows = %v, want one first-use row", got) + } + if row := g.store.logged[0]; row.Audit.ConfigDigest != "digest-1" || row.Audit.EffectiveAction != "allow" || row.Audit.ExecutionStatus != "unknown" { + t.Fatalf("first-use row audit = %+v", row.Audit) + } + // Exact host and port: another port, a subdomain and a parent are not exempt. + for _, url := range []string{"http://pypi.org/", "https://files.pypi.org/", "https://org/"} { + host := strings.TrimSuffix(strings.SplitN(url, "//", 2)[1], "/") + g.res.set(host, "151.101.0.223") + g.requests(url, g.fetch(url)) + } +} + +// Block rules come before exemptions and pins, and match the name and the +// addresses it resolves to. +func TestBlockRuleBeatsExemptionAndPin(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + g.res.set("pypi.org", "151.101.0.223") + g.store.exempt = []db.TargetExemption{{Host: "pypi.org", Port: 443}} + g.store.approve(g.requests("pin", g.fetch(appURL))) + g.passes("pinned", g.fetch(appURL)) + g.passes("exempt", g.fetch("https://pypi.org/")) + rows := len(g.store.logged) + + rule := func(kind, pattern string, enabled bool) db.AssetInterceptRule { + return db.AssetInterceptRule{Enabled: enabled, Kind: kind, Pattern: pattern} + } + // A disabled rule blocks nothing. + g.store.rules = []db.AssetInterceptRule{rule("exact_domain", "pypi.org", false)} + g.passes("disabled rule", g.fetch("https://pypi.org/")) + + cases := []struct { + name string + rule db.AssetInterceptRule + url string + }{ + {"exempt host, rule on the name", rule("exact_domain", "pypi.org", true), "https://pypi.org/"}, + {"exempt host, rule on its address", rule("exact_ip", "151.101.0.223", true), "https://pypi.org/"}, + {"pinned host, rule on the name", rule("fuzzy_domain", ".example.com", true), appURL}, + {"pinned host, rule on its address range", rule("cidr", "203.0.113.0/24", true), appURL}, + {"IP literal", rule("cidr", "203.0.113.0/24", true), "http://203.0.113.50/"}, + } + for _, c := range cases { + g.store.rules = []db.AssetInterceptRule{c.rule} + res := g.blocked(c.name, TargetRuleBlocked, g.fetch(c.url)) + if !strings.Contains(res.Reason, "blocked by an asset rule") || !strings.Contains(res.Reason, c.rule.Pattern) { + t.Errorf("%s: reason = %q", c.name, res.Reason) + } + } + // A range is matched as written and by its network address. + g.store.rules = []db.AssetInterceptRule{rule("cidr", "10.0.0.0/8", true)} + g.blocked("range inside a blocked range", TargetRuleBlocked, g.check("Bash", `{"command":"nmap 10.20.0.0/16"}`)) + + if got := g.store.outcomes()[rows:]; len(got) != len(cases)+1 || slices.ContainsFunc(got, func(o string) bool { return o != "denied/rule_blocked" }) { + t.Fatalf("logged rows = %v, want %d rule_blocked rows", got, len(cases)+1) + } +} + +// Port precedence. A (host, 0) pin covers every port. A (host, 443) pin covers +// port 443 only, and never a target whose port is not declared. The address +// sets of two pins are never merged. +func TestPinPortPrecedence(t *testing.T) { + const a, b = "203.0.113.7", "203.0.113.8" + urls := map[int]string{0: "ssh://app.example.com/", 80: "http://app.example.com/", 443: "https://app.example.com/", 8443: "https://app.example.com:8443/"} + pin := func(g *gateFixture, port int, ips ...string) *db.TargetPin { + return g.store.approve(TargetRequest{Host: "app.example.com", Port: port, ResolvedIPs: ips}) + } + + t.Run("a (host, 0) pin covers every port", func(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", a) + pin(g, 0, a) + for port, url := range urls { + g.passes(fmt.Sprintf("port %d", port), g.fetch(url)) + } + }) + + t.Run("a (host, 443) pin covers port 443 only", func(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", a) + pin443 := pin(g, 443, a) + g.passes("port 443", g.fetch(urls[443])) + for _, port := range []int{0, 80, 8443} { + req := g.requests(fmt.Sprintf("port %d", port), g.fetch(urls[port])) + if req.Host != "app.example.com" || req.Port != port || req.Reason != "no_pin" || req.Pin != nil { + t.Fatalf("port %d: request = %+v, want it keyed on the target's own port with no pin to extend", port, req) + } + } + // The request of the (host, 0) target is keyed (host, 0); approving it + // leaves the (host, 443) pin active and untouched. + req := g.requests("port 0", g.fetch(urls[0])) + pin0 := g.store.approve(req) + if pin0.Port != 0 || pin0.Version != 1 { + t.Fatalf("pin of the port-0 request = %+v", pin0) + } + if got := g.store.active("app.example.com", 443); got != pin443 || got.Version != 1 || !slices.Equal(got.PinnedIPs, []string{a}) { + t.Fatalf("the (host, 443) pin changed: %+v", got) + } + g.passes("port 0, pinned", g.fetch(urls[0])) + }) + + t.Run("two pins are never merged", func(t *testing.T) { + g := newGate(t) + pin443, pin0 := pin(g, 443, a), pin(g, 0, b) + + g.res.set("app.example.com", a) + g.passes("covered by the (host, 443) pin alone", g.fetch(urls[443])) + g.res.set("app.example.com", b) + g.expire() + g.passes("covered by the (host, 0) pin alone", g.fetch(urls[443])) + + // Each address is in one pin, both are in neither: not covered. + g.res.set("app.example.com", a, b) + g.expire() + req := g.requests("union of the two pins", g.fetch(urls[443])) + if req.Port != 443 || req.Reason != "new_ip" || req.Pin != pin443 { + t.Fatalf("request = %+v, want it on the exact-port pin (host, 443)", req) + } + // On another port only the (host, 0) pin is a candidate. + req = g.requests("port 80", g.fetch(urls[80])) + if req.Port != 0 || req.Reason != "new_ip" || req.Pin != pin0 { + t.Fatalf("request = %+v, want it on the (host, 0) pin", req) + } + // A port-0 target never looks at the (host, 443) pin. + g.res.set("app.example.com", a) + g.expire() + req = g.requests("port 0", g.fetch(urls[0])) + if req.Port != 0 || req.Pin != pin0 { + t.Fatalf("request = %+v, want it on the (host, 0) pin", req) + } + }) +} + +func TestTooManyTargetsBlocked(t *testing.T) { + g := newGate(t) + ips := func(n int) string { + var b strings.Builder + for i := 1; i <= n; i++ { + fmt.Fprintf(&b, " 198.51.100.%d", i) + } + return `{"command":"nmap -sn` + b.String() + `"}` + } + v := g.check("Bash", ips(maxUnpinnedTargets)) + reqs, res := v.reqs, v.res + if res.Code != "" || len(reqs) != maxUnpinnedTargets { + t.Fatalf("%d targets: requests=%d result=%+v, want %d requests", maxUnpinnedTargets, len(reqs), res, maxUnpinnedTargets) + } + res = g.blocked("six targets", TargetTooMany, g.check("Bash", ips(maxUnpinnedTargets+1))) + if !strings.Contains(res.Reason, "6 targets") || !strings.Contains(res.Reason, "198.51.100.6") { + t.Fatalf("reason = %q", res.Reason) + } + if got := g.store.outcomes(); !slices.Equal(got, []string{"denied/too_many"}) || len(g.store.logged[0].Audit.Targets) != 6 { + t.Fatalf("logged rows = %v", got) + } + // Only the targets no pin covers count, and each key counts once. + g.store.approve(TargetRequest{Host: "198.51.100.1", Port: 0, ResolvedIPs: []string{"198.51.100.1"}}) + v = g.check("Bash", `{"command":"nmap 198.51.100.1 198.51.100.2 198.51.100.3 198.51.100.4 198.51.100.5 198.51.100.2:80 198.51.100.2:80"}`) + reqs, res = v.reqs, v.res + if res.Code != "" || len(reqs) != maxUnpinnedTargets { + t.Fatalf("seven targets, one pinned, one repeated: requests=%d result=%+v, want %d requests", len(reqs), res, maxUnpinnedTargets) + } +} + +// What the gate cannot read, it does not allow. +func TestStoreFailureBlocks(t *testing.T) { + for _, method := range []string{"BlockRules", "Exemptions", "ActivePins"} { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + g.store.fail[method] = errors.New("connection refused") + res := g.blocked(method, TargetGateUnavailable, g.fetch(appURL)) + if !strings.Contains(res.Reason, "could not be read: connection refused") { + t.Errorf("%s: reason = %q", method, res.Reason) + } + } + // A scope that names no valid task cannot be checked either. + g := newGate(t) + g.scope.TaskID = "not-a-number" + g.blocked("bad scope", TargetGateUnavailable, g.fetch(appURL)) +} + +// The platform's own endpoints are exempt because the configuration names +// them. They are computed on every call, so they follow the configuration. +func TestDerivedExemptionsFollowConfig(t *testing.T) { + g := newGate(t) + config := []string{"https://llm.example.net/v1", "http://user:secret@proxy.example.net:3128", "", "not a url", "https://html.duckduckgo.com"} + g.ic.SetDerivedExemptions(func() []string { return config }) + + want := []db.TargetExemption{{Host: "llm.example.net", Port: 443}, {Host: "proxy.example.net", Port: 3128}, {Host: "html.duckduckgo.com", Port: 443}} + if got := g.ic.DerivedExemptions(); !slices.Equal(got, want) { + t.Fatalf("derived = %+v, want %+v", got, want) + } + g.passes("gateway", g.check("Bash", `{"command":"curl https://llm.example.net/v1/models"}`)) + g.passes("proxy on a command line", g.check("Bash", `{"command":"curl -x http://proxy.example.net:3128 https://html.duckduckgo.com/html/"}`)) + if n := g.res.total(); n != 0 { + t.Fatalf("%d lookups for derived exemptions, want none", n) + } + if got := g.store.outcomes(); !slices.Equal(got, []string{"allowed/exempt", "allowed/exempt", "allowed/exempt"}) { + t.Fatalf("logged rows = %v, want one first-use row per endpoint", got) + } + + // The configuration changes: the old gateway is a target like any other. + config = []string{"https://llm.other.example/v1"} + g.res.set("llm.example.net", "203.0.113.20") + g.requests("old gateway", g.check("Bash", `{"command":"curl https://llm.example.net/v1/models"}`)) + g.passes("new gateway", g.fetch("https://llm.other.example/v1/models")) + + // Exact host and port, as for a stored entry. + g.res.set("llm.other.example", "203.0.113.21") + g.requests("other port of the gateway", g.fetch("http://llm.other.example/")) +} + +// A stored exemption (a seeded package source, or one an operator added) is +// void when any address of the name is private, loopback, link-local, +// unspecified, unique-local or a metadata address. One such address is enough. +func TestStoredExemptionVoidOnPrivateOrLoopback(t *testing.T) { + seeded := []db.TargetExemption{{Host: "pypi.org", Port: 443}, {Host: "deb.debian.org", Port: 80}} + void := map[string][]string{ + "loopback": {"127.0.0.1"}, + "loopback, other": {"127.8.8.8"}, + "loopback v6": {"::1"}, + "private 10/8": {"10.0.0.5"}, + "private 172.16/12": {"172.16.0.1"}, + "private 192.168/16": {"192.168.1.1"}, + "unique local fd00::/8": {"fd00::1"}, + "unique local fc00::/8": {"fc00::1"}, + "link-local, metadata": {"169.254.169.254"}, + "link-local": {"169.254.0.1"}, + "link-local v6": {"fe80::1"}, + "unspecified": {"0.0.0.0"}, + "unspecified v6": {"::"}, + "mapped loopback": {"::ffff:127.0.0.1"}, + "metadata outside": {"100.100.100.200"}, + "metadata v6": {"fd00:ec2::254"}, + "one public one private": {"151.101.0.223", "10.0.0.5"}, + "one public one v6 ULA": {"151.101.0.223", "2a04:4e42::223", "fd12:3456::1"}, + } + for name, ips := range void { + for _, e := range seeded { + g := newGate(t) + g.store.exempt = seeded + g.res.set(e.Host, ips...) + url := fmt.Sprintf("http://%s:%d/", e.Host, e.Port) + req := g.requests(name+" "+e.Host, g.fetch(url)) + if !req.ExemptVoid || req.Reason != "no_pin" { + t.Errorf("%s %s: request = %+v, want a void exemption", name, e.Host, req) + } + if len(g.store.logged) != 0 { + t.Errorf("%s %s: a void exemption logged %v", name, e.Host, g.store.outcomes()) + } + } + } + for name, ips := range map[string][]string{ + "public": {"151.101.0.223"}, + "public, dual": {"151.101.0.223", "2a04:4e42::223"}, + "shared space": {"100.64.0.1"}, + "documentation net": {"203.0.113.7"}, + } { + g := newGate(t) + g.store.exempt = seeded + g.res.set("pypi.org", ips...) + g.passes(name, g.fetch("https://pypi.org/simple/")) + } + // A stored name that does not resolve is void as well; the call then fails + // on the resolution like any other target. + g := newGate(t) + g.store.exempt = seeded + g.res.errs["pypi.org"] = errors.New("no such host") + g.blocked("unresolvable", TargetResolveFailed, g.fetch("https://pypi.org/simple/")) + if t0 := g.store.logged[0].Audit.Targets[0]; !t0.ExemptVoid { + t.Errorf("unresolvable exempt name: audit target = %+v, want exempt_void", t0) + } +} + +// A stored exemption written as an IP literal is not resolved. A private +// unicast literal holds. A literal that can never be exempt does not, even if +// it reached the stored list without passing the API. +func TestStoredExemptionOnIPLiteral(t *testing.T) { + g := newGate(t) + never := []string{"169.254.169.254", "fe80::1", "fd00:ec2::254", "100.100.100.200", "127.0.0.1", "::1", "0.0.0.0", "::", "224.0.0.1", "ff02::1"} + g.store.exempt = []db.TargetExemption{{Host: "10.0.0.5", Port: 8080}, {Host: "fd12:3456::5", Port: 8080}, {Host: "203.0.113.7", Port: 8080}} + for _, ip := range never { + g.store.exempt = append(g.store.exempt, db.TargetExemption{Host: ip, Port: 8080}) + } + for _, url := range []string{"http://10.0.0.5:8080/", "http://[fd12:3456::5]:8080/", "http://203.0.113.7:8080/"} { + g.passes(url, g.fetch(url)) + } + for _, ip := range never { + host := ip + if strings.Contains(ip, ":") { + host = "[" + ip + "]" + } + req := g.requests(ip, g.fetch("http://"+host+":8080/")) + if !req.ExemptVoid { + t.Errorf("%s: request = %+v, want a void exemption", ip, req) + } + } + if n := g.res.total(); n != 0 { + t.Fatalf("%d lookups for IP literals, want none", n) + } + // The API refuses them before anything is stored (no database behind g.ic). + for _, ip := range never { + if _, _, err := g.ic.SetExemptions([]db.TargetExemption{{Host: ip, Port: 8080}}, "ARTEX"); !errors.Is(err, ErrInvalidExemption) { + t.Errorf("SetExemptions(%s) = %v, want it refused", ip, err) + } + } + for _, bad := range []db.TargetExemption{ + {Host: "*.example.com", Port: 443}, {Host: ".example.com", Port: 443}, {Host: "10.0.0.0/8", Port: 443}, {Host: "", Port: 443}, + {Host: "https://pypi.org", Port: 443}, {Host: "pypi.org:443", Port: 443}, {Host: "pypi.org/simple", Port: 443}, + {Host: "pypi.org", Port: 0}, {Host: "pypi.org", Port: 65536}, {Host: "pypi.org", Port: -1}, + {Host: "2130706433", Port: 80}, {Host: "0x7f.1", Port: 80}, {Host: "::ffff:127.0.0.1", Port: 80}, + } { + if _, _, err := g.ic.SetExemptions([]db.TargetExemption{{Host: "pypi.org", Port: 443}, bad}, "ARTEX"); !errors.Is(err, ErrInvalidExemption) { + t.Errorf("SetExemptions(%+v) = %v, want it refused", bad, err) + } + } +} + +// An exemption derived from the configuration is never voided: an internal +// relay for a platform service is a normal deployment. It is not resolved. +func TestDerivedExemptionNotVoidedByPrivateAddress(t *testing.T) { + g := newGate(t) + g.ic.SetDerivedExemptions(func() []string { + return []string{"http://10.0.0.5:8080/v1", "https://gateway.internal/v1", "http://127.0.0.1:8080", "http://localhost:3128", "http://169.254.10.10:8888"} + }) + // If the gate resolved the names, these answers would void a stored entry. + g.res.set("gateway.internal", "10.0.0.5") + g.res.set("localhost", "127.0.0.1") + for _, cmd := range []string{ + "curl http://10.0.0.5:8080/v1/models", + "curl https://gateway.internal/v1/models", + "curl -x http://127.0.0.1:8080 -x http://localhost:3128 https://gateway.internal/", + "curl http://169.254.10.10:8888/", + } { + g.passes(cmd, g.check("Bash", `{"command":"`+cmd+`"}`)) + } + if n := g.res.total(); n != 0 { + t.Fatalf("%d lookups, want none: a derived exemption is never resolved", n) + } +} + +// A void exemption is not a block: the target asks for approval like any other +// and passes once it is pinned. +func TestVoidExemptionFallsThroughToRequest(t *testing.T) { + g := newGate(t) + g.store.exempt = []db.TargetExemption{{Host: "mirror.example.org", Port: 443}} + g.res.set("mirror.example.org", "192.168.1.10") + req := g.requests("void", g.fetch("https://mirror.example.org/pkg")) + if !req.ExemptVoid || !slices.Equal(req.ResolvedIPs, []string{"192.168.1.10"}) { + t.Fatalf("request = %+v", req) + } + g.store.approve(req) + g.passes("pinned by a human", g.fetch("https://mirror.example.org/pkg")) + if got := g.store.outcomes(); !slices.Equal(got, []string{"allowed/pin_match"}) { + t.Fatalf("logged rows = %v, want the pin match and no exemption use", got) + } + // The name answers a public address again: the exemption applies again. + g.res.set("mirror.example.org", "151.101.0.223") + g.expire() + g.passes("exempt again", g.fetch("https://mirror.example.org/pkg")) + if got := g.store.outcomes(); !slices.Equal(got, []string{"allowed/pin_match", "allowed/exempt"}) { + t.Fatalf("logged rows = %v", got) + } +} + +// An entry that is both stored and derived counts as derived, so adding a +// platform host to the list can never break a platform service. +func TestEntryInBothOriginsIsDerived(t *testing.T) { + g := newGate(t) + g.store.exempt = []db.TargetExemption{{Host: "gateway.internal", Port: 443}} + g.res.set("gateway.internal", "10.0.0.5") + g.requests("stored only: void", g.fetch("https://gateway.internal/v1")) + lookups := g.res.total() + + g.ic.SetDerivedExemptions(func() []string { return []string{"https://gateway.internal/v1"} }) + g.passes("stored and derived", g.fetch("https://gateway.internal/v1")) + if n := g.res.total(); n != lookups { + t.Fatalf("%d more lookups, want none once the entry is derived", n-lookups) + } +} + +// A pin covers the host as written. Approving an IP does not cover a name that +// resolves to it: the name selects the tenant. +func TestIPPinDoesNotCoverNameOnSameIP(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + g.store.approve(g.requests("the IP", g.fetch("https://203.0.113.7/"))) + g.passes("the IP, pinned", g.fetch("https://203.0.113.7/")) + req := g.requests("a name on the same IP", g.fetch(appURL)) + if req.Host != "app.example.com" || req.Reason != "no_pin" || req.Pin != nil { + t.Fatalf("request = %+v", req) + } + // And the other way round. + g.store.approve(req) + g.requests("another IP port", g.fetch("http://203.0.113.7/")) +} + +// Behind the upstream egress proxy the proxy chooses the address. Every pin is +// name-only at call time, whatever its row says: a changed address neither +// blocks nor asks. A pin approved under the proxy stays name-only without it. +func TestUpstreamProxyMakesPinsNameOnlyAtCallTime(t *testing.T) { + g := newGate(t) + proxied := false + g.ic.SetUpstreamProxyCheck(func() bool { return proxied }) + g.res.set("app.example.com", "203.0.113.7") + pin := g.store.approve(g.requests("first", g.fetch(appURL))) + if pin.NameOnlyEffective { + t.Fatal("the pin was approved without the proxy and must not be name-only") + } + + // The address changes. Without the proxy that asks. + g.res.set("app.example.com", "198.51.100.99") + g.expire() + g.requests("changed address, no proxy", g.fetch(appURL)) + + // With the proxy on, the same pin row covers the name. + proxied = true + g.passes("changed address, proxy on", g.fetch(appURL)) + if pin.NameOnlyEffective || !slices.Equal(pin.PinnedIPs, []string{"203.0.113.7"}) { + t.Fatalf("the pin row changed: %+v", pin) + } + // The name still has to resolve, and block rules still apply. + g.res.errs["app.example.com"] = errors.New("no such host") + g.expire() + g.blocked("unresolvable under the proxy", TargetResolveFailed, g.fetch(appURL)) + g.res.set("app.example.com", "198.51.100.99") + g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_ip", Pattern: "198.51.100.99"}} + g.blocked("block rule under the proxy", TargetRuleBlocked, g.fetch(appURL)) + g.store.rules = nil + // A host without a pin still asks under the proxy. + g.res.set("other.example.com", "203.0.113.50") + g.requests("no pin under the proxy", g.fetch("https://other.example.com/")) + + // Proxy off again: the address comparison is back. + proxied = false + g.requests("changed address, proxy off again", g.fetch(appURL)) + + // A pin whose row is name-only stays name-only without the proxy. + pin.NameOnlyEffective = true + g.passes("name-only pin, no proxy", g.fetch(appURL)) +} + +// The first call that a pin version covers is logged once per process. +func TestPinFirstUseLoggedOncePerVersion(t *testing.T) { + g := newGate(t) + g.res.set("app.example.com", "203.0.113.7") + g.store.approve(g.requests("first", g.fetch(appURL))) + for n := 0; n < 3; n++ { + g.passes("pinned", g.fetch(appURL)) + } + if got := g.store.outcomes(); !slices.Equal(got, []string{"allowed/pin_match"}) { + t.Fatalf("logged rows = %v, want one pin_match row", got) + } + if t0 := g.store.logged[0].Audit.Targets[0]; t0.PinVersion != 1 || !slices.Equal(t0.PinnedIPs, []string{"203.0.113.7"}) { + t.Fatalf("pin_match target = %+v", t0) + } + g.res.set("app.example.com", "203.0.113.7", "203.0.113.8") + g.expire() + g.store.approve(g.requests("new address", g.fetch(appURL))) + g.passes("version 2", g.fetch(appURL)) + g.passes("version 2 again", g.fetch(appURL)) + if got := g.store.outcomes(); !slices.Equal(got, []string{"allowed/pin_match", "allowed/pin_match"}) || g.store.logged[1].Audit.Targets[0].PinVersion != 2 { + t.Fatalf("logged rows = %v, want one row per pin version", got) + } + // A call that still needs an approval logs no use: it has not passed. + g.res.set("other.example.com", "203.0.113.50") + g.requests("mixed", g.check("Bash", `{"command":"curl https://app.example.com/ https://other.example.com/"}`)) + if n := len(g.store.logged); n != 2 { + t.Fatalf("%d rows after a call that did not pass, want 2", n) + } +} + +// The gate resolves through the system resolver unless a test injects another +// one: A and AAAA, one lookup bounded to 3 s, positive answers kept 10 s. +func TestSystemResolverAndLimits(t *testing.T) { + if resolveTimeout != 3*time.Second || resolveCacheTTL != 10*time.Second || maxUnpinnedTargets != 5 { + t.Fatalf("limits: lookup timeout %s, cache %s, %d targets per call; want 3s, 10s and 5", resolveTimeout, resolveCacheTTL, maxUnpinnedTargets) + } + if DefaultTargetWaitBound != 300*time.Second { + t.Fatalf("wait bound = %s, want 300s", DefaultTargetWaitBound) + } + ic := New(nil) + if name := ic.resolve.name(); name != "system" { + t.Fatalf("default resolver = %q, want the system resolver", name) + } + // A resolver that does not answer is cut off after the timeout, whatever the + // caller's own deadline, and the failure says so. + g := newGate(t) + g.res.hold = make(chan struct{}) + defer close(g.res.hold) + start := time.Now() + out := g.blocked("silent resolver", TargetResolveFailed, g.fetch(appURL)) + if took := time.Since(start); took < resolveTimeout || took > resolveTimeout+10*time.Second { + t.Fatalf("the lookup was cut off after %s, want about %s", took, resolveTimeout) + } + if !strings.Contains(out.Reason, "could not resolve app.example.com (lookup timed out after 3s)") { + t.Fatalf("reason = %q", out.Reason) + } + // localhost needs no network: it proves the lookup returns canonical addresses. + res, err := ic.resolve.lookup(context.Background(), "localhost", true) + if err != nil { + t.Skipf("the system resolver does not know localhost here: %v", err) + } + if !slices.Contains(res.ips, "127.0.0.1") && !slices.Contains(res.ips, "::1") { + t.Fatalf("localhost = %v, want a loopback address", res.ips) + } +} diff --git a/intercept/target_request.go b/intercept/target_request.go index 559e71a..dde6e68 100644 --- a/intercept/target_request.go +++ b/intercept/target_request.go @@ -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 diff --git a/intercept/target_resolve.go b/intercept/target_resolve.go new file mode 100644 index 0000000..6d15d7a --- /dev/null +++ b/intercept/target_resolve.go @@ -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 +} diff --git a/intercept/target_test.go b/intercept/target_test.go new file mode 100644 index 0000000..5577e96 --- /dev/null +++ b/intercept/target_test.go @@ -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})) +} diff --git a/server/intercept.go b/server/intercept.go index 20d8cd7..b3f74e0 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -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 diff --git a/server/manager.go b/server/manager.go index 433d237..24ec67e 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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) { diff --git a/server/server.go b/server/server.go index 0a3f3fe..0896dd0 100644 --- a/server/server.go +++ b/server/server.go @@ -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 diff --git a/server/target_gate_scope_test.go b/server/target_gate_scope_test.go index a8d0552..ae07bf0 100644 --- a/server/target_gate_scope_test.go +++ b/server/target_gate_scope_test.go @@ -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. diff --git a/server/target_gate_wiring_test.go b/server/target_gate_wiring_test.go new file mode 100644 index 0000000..cb48f6b --- /dev/null +++ b/server/target_gate_wiring_test.go @@ -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) + } +}