From 78f92f44fced7bb6fdb0a1b1f7a76523b8ad8787 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:34:55 +0100 Subject: [PATCH] feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7) The approval gate for asset targets now decides. It is opt-in per task and per conversation (target_gate, default off). With the flag off nothing is extracted, resolved, read or written, and every branch of the hook behaves as before. No UI. Extraction and normalisation (1.5) - intercept.NormalizeTarget: lower case, trailing dot, userinfo, IPv6 brackets, IPv4-mapped IPv6, decimal / hex / octal / short IPv4, percent- encoded host, punycode; port explicit, else the scheme default, else 0. - intercept.ExtractTargets: WebFetch url exactly; Bash, shell_open, Monitor (command) and shell_send (text) with the command-string rules (URL with a scheme, IP, IP:port, host:port, CIDR; a bare name is not a target); every string of the input for MCP tools, custom tools and any tool the gate does not know. ExecuteExtraTool is unwrapped to the inner tool. The platform's domain tools, the file tools and web_search have no target. - The fields are decoded as the tools decode them (key case, last duplicate), and a URL authority that reads differently quoted and unquoted yields both readings. Resolver, exemptions, gate decision (1.6) - Resolver interface, system implementation (A and AAAA), 3 s per lookup, positive answers cached 10 s per host with collapsed lookups, failures and empty answers never cached, always fresh when a request is raised. - Exemptions are exact host + port. Derived from the configuration (LLM gateways, search backend in use, platform proxies): computed on every call, never resolved, never voided. Stored (settings key target_gate_exempt_hosts, seeded once with the package sources): resolved, and void when any address is private, loopback, link-local, unspecified, unique-local, multicast or a metadata address. An entry in both origins counts as derived. A stored IP literal that can never be exempt does not apply. - GET / PUT /api/target-gate/exemptions and GET .../history. A change is validated (ValidateExemptionHost on the normalised host) and appended to target_gate_exemption_log in the transaction that stores it. - Interceptor.CheckTargets: asset block rules (global and of the task) on the name and its addresses, exemptions, resolution, pin lookup. A (host, 0) pin covers every port; a (host, 443) pin covers 443 only and never a target without a declared port; two pins are never merged. At most 5 unpinned targets per call. Codes: TARGET_RULE_BLOCKED, TARGET_RESOLVE_FAILED, TARGET_TOO_MANY, TARGET_GATE_UNAVAILABLE; none opens a request. - With the upstream egress proxy set every pin is name-only at call time, whatever name_only_effective says in its row. - An approval never pins zero addresses: the pin writer refuses a request that displays none (db.ErrTargetPinEmpty, 422 on the decide endpoint, the row stays pending) and RequestTargets does not open one. - Every block, and the first use per process of each exemption and of each pin version, is a decided row (decided_by system, decision_source target). The gate in the hook (1.7) - guard.applyIntercept: a deny by a rule or by the judge blocks first, before any target request exists; then CheckTargets; an allow is logged as before and does not satisfy the gate; an ask and an unpinned target share one request, and the classic ask is raised only when it was not merged. The call is checked again after the decisions, at most three passes. - The gate applies to every tool of a gated session, enabled for rules or not, and to the target-only guard of the planner and the main agent, which still records blocks only. - Target blocks have their own model-facing text, built from the code and the reason of the result. Not in this change: rendered targets of custom tools (1.8) and redirect targets (1.9). Subprocess paths stay advisory; the TestBypass_* tests document what the gate does not see. --- agent/target_gate_tools_test.go | 29 + db/db.go | 3 + db/intercept_detail.go | 8 +- db/target_pins.go | 162 ++++- db/target_pins_test.go | 169 +++++ guard/guard.go | 155 ++++- guard/guard_test.go | 9 +- guard/target_gate_test.go | 1002 +++++++++++++++++++++++++++ guard/testmain_test.go | 14 + intercept/intercept.go | 12 +- intercept/target.go | 176 +++++ intercept/target_exempt.go | 119 ++++ intercept/target_extract.go | 320 +++++++++ intercept/target_gate.go | 363 ++++++++++ intercept/target_gate_pg_test.go | 475 +++++++++++++ intercept/target_gate_test.go | 1042 +++++++++++++++++++++++++++++ intercept/target_request.go | 11 +- intercept/target_resolve.go | 174 +++++ intercept/target_test.go | 272 ++++++++ server/intercept.go | 60 ++ server/manager.go | 28 + server/server.go | 3 + server/target_gate_scope_test.go | 2 +- server/target_gate_wiring_test.go | 368 ++++++++++ 24 files changed, 4940 insertions(+), 36 deletions(-) create mode 100644 agent/target_gate_tools_test.go create mode 100644 guard/target_gate_test.go create mode 100644 guard/testmain_test.go create mode 100644 intercept/target.go create mode 100644 intercept/target_extract.go create mode 100644 intercept/target_gate.go create mode 100644 intercept/target_gate_pg_test.go create mode 100644 intercept/target_gate_test.go create mode 100644 intercept/target_resolve.go create mode 100644 intercept/target_test.go create mode 100644 server/target_gate_wiring_test.go 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) + } +}