Compare commits

..
Author SHA1 Message Date
BaaRai ca15063db7 fix(intercept): match block rules on the declared targets before any lookup
The target gate resolved each name through the cache before it matched the
asset block rules, so a call to a name that a name rule blocks still cost a
DNS lookup, and that query reached the name servers of the blocked domain.
URL rules (exact_url, fuzzy_url) were not evaluated at dispatch at all: the
gate passed no URL to the matcher, so they only applied to asset and intent
writes.

targetCheck.run now decides in this order, for a gated call:

1. URL rules, once per decision, on the URLs the call declares, as written.
2. Every block rule on the declared form of every target: the name as
   written, the IP literal, the range.
3. Only then resolution (names a derived exemption covers are still never
   resolved), and the block rules again on the resolved addresses.
4. Exemptions, pins and requests, unchanged.

A hit in 1 or 2 blocks with TARGET_RULE_BLOCKED and the same decided row as
any block, costs no lookup for any target of the call, and opens no request.

- intercept.extractDeclared returns the targets (ExtractTargets is now a thin
  wrapper, unchanged for its callers) and the URLs that name a target: the
  WebFetch url, and every URL token the command-string rules read in Bash,
  shell_open, Monitor, shell_send and the string leaves of other tools,
  including ExecuteExtraTool's inner call.
- The custom http tool's rendered URL and the URLs of a rendered command go to
  the same URL rules (gateRendered takes the URLs).
- Known limit, stated in the comments next to the matcher (db.matchOne) and
  at the dispatch match: URL rules match the declared string. exact_url misses
  any other spelling (case, percent-encoding, trailing slash, added query,
  /./), fuzzy_url misses an encoded substring, and a command can build its URL
  at run time. For a host, a domain, IP or CIDR rule is the one to write.

Unchanged: a tool-rule or judge deny still blocks before the gate is reached
(no extraction, no lookup, no request); asset block rules still win over an
allow and an ask; the gate off path does nothing; scripts stay advisory.

Tests:
- intercept: TestBlockRuleOnDeclaredNameNeedsNoLookup (exact_domain and
  fuzzy_domain; zero lookups, also for an unblocked name earlier in the same
  call), TestExtractDeclaredURLs, TestURLBlockRuleAtDispatch (WebFetch, Bash,
  MCP, ExecuteExtraTool, a pinned and an exempt host, a disabled rule, the
  rendered URL and command; zero lookups), TestExactURLRuleMatchesOnlyAsWritten
  (documents the limit).
- guard: TestBlockRuleWinsOverAllowAndAsk (name rule and URL rule, each over a
  rule allow, judge allow, rule ask and judge ask, real interceptor: blocked,
  no allow row, nobody asked, zero lookups). TestDenyRuleWinsBeforeTargetRequest
  now also covers a judge deny and asserts zero lookups with the real gate.
- server: TestBypass_CustomScriptToolTargetNotGated documents that a script
  tool's target, written in its stored code, is not visible to the gate: in a
  gated task the call is allowed and reaches the target with no lookup, no
  request and no row.
2026-11-11 15:47:58 +01:00
BaaRai a77851db7d Merge pull request #180 from BaaRai/feat/target-gate-custom-tools
feat(intercept): gate the rendered targets of custom tools (S3b 1.8)
2026-11-11 15:32:57 +01:00
BaaRai c0b741bcf6 feat(intercept): gate the rendered targets of custom tools (S3b 1.8)
The hook reads the parameters of a custom tool call, not the URL or the
command they render, and not a target written in the template itself. The
custom command and http tools now run the target gate inside the tool, on what
they are about to run, before anything is sent or started. Gate off (the
default) is unchanged: with no scope or the flag off nothing is parsed,
resolved, read or written.

- intercept.GateRenderedURL: the target is read as net/http dials it (host of
  the parsed URL, explicit port, else the scheme default), then goes through
  the same decision as at the hook (asset block rules, exemptions, resolution,
  pin lookup) and the same request-and-wait, at most three passes. A URL whose
  target cannot be read is blocked with TARGET_GATE_UNAVAILABLE and logged as
  a decided row; no request, no lookup.
- intercept.GateRenderedCommand: the rendered command is read with the
  command-string rules of Bash, and so is the rendering without the shell
  quoting of the parameters (a quoted parameter inside a host name hides the
  name from the rules). A command that names no target passes, as in Bash.
- A gated scope without the interceptor that owns the gate blocks
  (TARGET_GATE_UNAVAILABLE), it never lets the call through.
- runCommandTool and runHTTPTool take the tool key, which names the request
  rows. The gate runs before the tool's own timeout starts.
- guard.TargetBlockMessage (was targetBlockMessage) is exported so the tools
  return the same model-facing text as the hook.
- Unchanged: the script tool (its target is in stored code; advisory, D9), the
  custom-tool test endpoint (no scope: the operator's own action, D14), the
  hook order (a rule or judge deny blocks first; the tool then never runs, so
  its rendered target is never read), the evaluation of asset rules.

Tests: TestRendered* in intercept (no database); in server, against
PostgreSQL: TestCustomHTTPRenderedTargetGated,
TestCustomCommandRenderedTargetGated, TestCustomToolTestEndpointUngated,
TestTargetGateEndToEnd (a scripted worker's WebFetch waits, a click pins, the
next call passes, revoke, it asks again), TestDenyRuleWinsBeforeRenderedTarget
and TestGateOffTaskIsUnchanged.

Not in this change: redirect targets of WebFetch and of the custom http tool
(1.9).
2026-11-11 15:04:42 +01:00
BaaRai ec3db7fdec Merge pull request #179 from BaaRai/feat/target-gate-extract-resolve-gate
feat(intercept): target extraction, resolver, exemptions and the gate in the hook (S3b 1.5-1.7)
2026-11-11 14:42:45 +01:00
11 changed files with 1357 additions and 33 deletions
+6
View File
@@ -45,6 +45,12 @@ func (r AssetInterceptRule) Reason() string {
// matchOne checks whether a single enabled rule matches the given domain/IP/URL
// candidates, and returns the concrete matched value.
//
// URL rules match the URL string as given, with no normalisation. Known limit:
// exact_url misses any other spelling of the same URL (case, percent-encoding
// such as %2F, a trailing slash, an added query, /./), and fuzzy_url misses an
// encoded form of its substring. At tool dispatch the string is the URL the
// call declares (intercept, targetCheck.run).
func matchOne(r AssetInterceptRule, domains, ips, urls []string) (string, bool) {
p := strings.TrimSpace(r.Pattern)
if p == "" {
+4 -4
View File
@@ -162,7 +162,7 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
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), "")
return g.block(ev.ToolName, TargetBlockMessage(res), "")
}
}
var ask *intercept.Decision
@@ -182,7 +182,7 @@ func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
if len(unpinned) > 0 {
res, merged := g.requestTargets(ctx, scope, ev, unpinned, ask)
if res.Code != "" {
return g.block(ev.ToolName, targetBlockMessage(res), "")
return g.block(ev.ToolName, TargetBlockMessage(res), "")
}
if merged {
// The approval of the target request answered the ask as well.
@@ -235,11 +235,11 @@ var targetGuidance = map[string]string{
"Retry the same call later, or report the failure.",
}
// targetBlockMessage is systemBlockMessage for the target gate: the block code,
// 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 {
func TargetBlockMessage(res intercept.TargetResult) string {
reason := res.Reason
if res.Host != "" && !strings.Contains(reason, res.Host) {
target := res.Host
+84 -6
View File
@@ -257,7 +257,7 @@ func TestTargetBlockMessage(t *testing.T) {
[]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)
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)
}
@@ -282,18 +282,27 @@ type gateDB struct {
task string // task id
}
// stubResolver answers every name with the addresses it holds.
// stubResolver answers every name with the addresses it holds, and counts the
// lookups.
type stubResolver struct {
mu sync.Mutex
ips []string
err error
n int
}
func (r *stubResolver) Name() string { return "stub" }
func (r *stubResolver) lookups() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.n
}
func (r *stubResolver) LookupIPs(context.Context, string) ([]netip.Addr, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.n++
if r.err != nil {
return nil, r.err
}
@@ -576,8 +585,8 @@ func TestGateOffLeavesEveryBranchUnchanged(t *testing.T) {
}
}
// A deny by a rule blocks first, as before: the gate is not consulted and no
// target request exists.
// A deny by a rule or by the judge blocks first, as before: the gate is not
// consulted, nothing is resolved and no target request exists.
func TestDenyRuleWinsBeforeTargetRequest(t *testing.T) {
g := newGateDB(t)
m := marker(t)
@@ -601,8 +610,77 @@ func TestDenyRuleWinsBeforeTargetRequest(t *testing.T) {
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())
if len(g.pending()) != 0 || len(g.pins()) != 0 || g.res.lookups() != 0 {
t.Fatalf("pending=%+v pins=%+v lookups=%d, want none", g.pending(), g.pins(), g.res.lookups())
}
// A deny by the judge takes the same branch.
g.judge("deny")
input := `{"command":"curl https://judged.example.com/"}`
gate = &fakeGate{t: t, check: unpinnedOnce}
guard = NewWithInterceptor(g.ic)
guard.gate = gate
before := len(g.rows())
if block, msg := pre(guard, g.ctx(true, time.Minute), "Bash", input); !block || !strings.Contains(msg, "judge says deny") || strings.Contains(msg, "TARGET_") {
t.Fatalf("judge deny: block=%v msg=%q", block, msg)
}
if gate.checks != 0 || len(gate.asked) != 0 {
t.Fatalf("judge deny: the gate was reached: %d checks, %d requests", gate.checks, len(gate.asked))
}
if rows := g.rows()[before:]; len(rows) != 1 || rows[0].status != "denied" || rows[0].decidedBy != "model" || rows[0].targetKey != "" {
t.Fatalf("judge deny: rows = %+v, want the denied judge row only", rows)
}
guard = NewWithInterceptor(g.ic)
if block, _ := pre(guard, g.ctx(true, time.Minute), "Bash", input); !block {
t.Fatal("judge deny did not block")
}
if len(g.pending()) != 0 || len(g.pins()) != 0 || g.res.lookups() != 0 {
t.Fatalf("judge deny: pending=%+v pins=%+v lookups=%d, want none", g.pending(), g.pins(), g.res.lookups())
}
}
// An asset block rule wins over an allow and an ask, of a rule or of the
// judge: the call is blocked, the allow is not logged, no human is asked, and
// the rule hits on what the call declares, so nothing is resolved. A name rule
// and a URL rule behave the same.
func TestBlockRuleWinsOverAllowAndAsk(t *testing.T) {
const input = `{"command":"curl https://app.example.com/blocked-path # %s"}`
for _, block := range []struct{ name, kind, pattern string }{
{"name rule", "exact_domain", "app.example.com"},
{"URL rule", "fuzzy_url", "/blocked-path"},
} {
for _, v := range []struct{ name, by, action string }{
{"rule allow", "rule", "allow"},
{"judge allow", "judge", "allow"},
{"rule ask", "rule", "ask"},
{"judge ask", "judge", "ask"},
} {
t.Run(block.name+" over "+v.name, func(t *testing.T) {
g := newGateDB(t)
id, _ := strconv.ParseInt(g.task, 10, 64)
if _, err := g.d.Assets().CreateTaskInterceptRule(id, "block", block.kind, block.pattern, "guard test", true); err != nil {
t.Fatal(err)
}
m := marker(t)
if v.by == "rule" {
g.rule(v.action, m)
} else {
g.judge(v.action)
}
guard := NewWithInterceptor(g.ic)
r := g.result(call(guard, g.ctx(true, time.Minute), "Bash", fmt.Sprintf(input, m)))
if !r.block || !strings.Contains(r.msg, intercept.TargetRuleBlocked+": ") || !strings.Contains(r.msg, block.pattern) {
t.Fatalf("block=%v msg=%q, want %s", r.block, r.msg, intercept.TargetRuleBlocked)
}
rows := g.rows()
if len(rows) != 1 || rows[0].status != "denied" || rows[0].decidedBy != "system" || rows[0].ruleID != 0 {
t.Fatalf("rows = %+v, want the one block row of the gate: no allow logged, nobody asked", rows)
}
if n := g.res.lookups(); n != 0 || len(g.pins()) != 0 {
t.Fatalf("%d lookups, pins %+v; want none", n, g.pins())
}
})
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package intercept
import (
"encoding/json"
"slices"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
)
// The declared forms of a call decide first: the URL block rules on the URLs
// the call writes, then the block rules on every name, IP literal and range as
// written. Nothing is resolved before that, so a hit costs no lookup.
func TestExtractDeclaredURLs(t *testing.T) {
raw := func(v any) []byte {
b, _ := json.Marshal(v)
return b
}
for _, c := range []struct {
name string
tool string
input []byte
want []string
}{
{"WebFetch", "WebFetch", raw(map[string]string{"url": " https://app.example.com/admin?x=1 "}), []string{"https://app.example.com/admin?x=1"}},
{"WebFetch without a target", "WebFetch", raw(map[string]string{"url": "http:///path"}), nil},
{"Bash, query kept", "Bash", raw(command("curl -s 'https://a.example.com/admin?x=1'")), []string{"https://a.example.com/admin?x=1"}},
{"Bash, two URLs", "Bash", raw(command("curl http://a.example.com/x && curl https://b.example.com:8443/y")),
[]string{"http://a.example.com/x", "https://b.example.com:8443/y"}},
{"shell_send", "shell_send", raw(map[string]string{"text": "curl http://a.example.com/x\n"}), []string{"http://a.example.com/x"}},
{"Monitor", "Monitor", raw(command("watch curl http://198.51.100.9:8080/status")), []string{"http://198.51.100.9:8080/status"}},
{"MCP string leaves", "mcp__scan__run", []byte(`{"target":"http://a.example.com/p","opts":{"list":["https://b.example.com/q"]}}`),
[]string{"http://a.example.com/p", "https://b.example.com/q"}},
{"ExecuteExtraTool unwrap", executeExtraTool, raw(map[string]any{"tool_name": "WebFetch", "params": map[string]string{"url": "https://a.example.com/z"}}),
[]string{"https://a.example.com/z"}},
{"tool without a target", "web_search", raw(map[string]string{"query": "https://a.example.com/admin"}), nil},
{"wrapped tool without a target", executeExtraTool, raw(map[string]any{"tool_name": "web_search", "params": map[string]string{"query": "https://a.example.com/admin"}}), nil},
{"authority that names no host", "Bash", raw(command("curl http://$HOST/x https://${H}:8443/admin")), nil},
} {
d := extractDeclared(c.tool, c.input)
if !slices.Equal(d.urls, c.want) {
t.Errorf("%s: urls = %q, want %q", c.name, d.urls, c.want)
}
// The targets are those ExtractTargets returns.
if !slices.Equal(d.targets, ExtractTargets(c.tool, c.input)) {
t.Errorf("%s: targets differ from ExtractTargets", c.name)
}
if len(d.urls) > 0 && len(d.targets) == 0 {
t.Errorf("%s: a URL without a target", c.name)
}
}
}
// A name that a name rule blocks is blocked on the name as written: no lookup
// is made for it, nor for another name of the same call that no rule blocks.
func TestBlockRuleOnDeclaredNameNeedsNoLookup(t *testing.T) {
for _, rule := range []db.AssetInterceptRule{
{Enabled: true, Kind: "exact_domain", Pattern: "blocked.example.com"},
{Enabled: true, Kind: "fuzzy_domain", Pattern: "blocked.example"},
} {
g := newGate(t)
g.res.set("ok.example.com", "203.0.113.7")
g.res.set("blocked.example.com", "203.0.113.8")
g.store.rules = []db.AssetInterceptRule{rule}
for name, v := range map[string]verdict{
"WebFetch": g.fetch("https://blocked.example.com/"),
// The unblocked name comes first in the call.
"two names": g.check("Bash", `{"command":"curl https://ok.example.com/ https://blocked.example.com/"}`),
} {
res := g.blocked(rule.Kind+", "+name, TargetRuleBlocked, v)
if res.Host != "blocked.example.com" || !strings.Contains(res.Reason, rule.Pattern) {
t.Fatalf("%s, %s: result = %+v", rule.Kind, name, res)
}
}
if n := g.res.total(); n != 0 {
t.Fatalf("%s: %d lookups, want none", rule.Kind, n)
}
for _, row := range g.store.logged {
if a := row.Audit.Targets[0]; a.Outcome != "rule_blocked" || len(a.ResolvedIPs) != 0 {
t.Fatalf("%s: logged target = %+v, want rule_blocked with no address", rule.Kind, a)
}
}
}
}
// URL rules apply at dispatch, on the URLs the call declares, before anything
// is resolved. A hit blocks with the code of every block rule, opens no
// request, and wins over a pin and an exemption.
func TestURLBlockRuleAtDispatch(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(TargetRequest{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}})
g.passes("pinned", g.fetch("https://app.example.com/admin"))
g.passes("exempt", g.fetch("https://pypi.org/admin"))
rule := func(kind, pattern string, enabled bool) []db.AssetInterceptRule {
return []db.AssetInterceptRule{{Enabled: enabled, Kind: kind, Pattern: pattern}}
}
// A disabled URL rule blocks nothing.
g.store.rules = rule("exact_url", "https://app.example.com/admin", false)
g.passes("disabled rule", g.fetch("https://app.example.com/admin"))
lookups, rows := g.res.total(), len(g.store.logged)
for _, c := range []struct {
name string
rules []db.AssetInterceptRule
v func() verdict
host string
port int
matchedURL string
}{
{"exact_url, WebFetch of a pinned host", rule("exact_url", "https://app.example.com/admin", true),
func() verdict { return g.fetch("https://app.example.com/admin") }, "app.example.com", 443, "https://app.example.com/admin"},
{"fuzzy_url, exempt host", rule("fuzzy_url", "/admin", true),
func() verdict { return g.fetch("https://pypi.org/admin") }, "pypi.org", 443, "https://pypi.org/admin"},
{"fuzzy_url, Bash with a query", rule("fuzzy_url", "/admin", true),
func() verdict { return g.check("Bash", `{"command":"curl -s 'https://app.example.com/admin?x=1'"}`) }, "app.example.com", 443, "https://app.example.com/admin?x=1"},
{"fuzzy_url, second URL of the call", rule("fuzzy_url", "/admin", true),
func() verdict {
return g.check("Bash", `{"command":"curl https://app.example.com/ && curl http://198.51.100.9:8080/admin"}`)
}, "198.51.100.9", 8080, "http://198.51.100.9:8080/admin"},
{"fuzzy_url, MCP input", rule("fuzzy_url", "/ADMIN", true),
func() verdict { return g.check("mcp__scan__run", `{"target":"https://app.example.com/admin/users"}`) }, "app.example.com", 443, "https://app.example.com/admin/users"},
{"exact_url, wrapped WebFetch", rule("exact_url", "https://app.example.com/admin", true),
func() verdict {
return g.check(executeExtraTool, `{"tool_name":"WebFetch","params":{"url":"https://app.example.com/admin"}}`)
}, "app.example.com", 443, "https://app.example.com/admin"},
} {
g.store.rules = c.rules
g.res.set("app.example.com", "198.51.100.200") // a lookup would make a pinned host ask
res := g.blocked(c.name, TargetRuleBlocked, c.v())
if res.Host != c.host || res.Port != c.port || !strings.Contains(res.Reason, c.matchedURL+" is blocked by an asset rule") ||
!strings.Contains(res.Reason, c.rules[0].Pattern) {
t.Errorf("%s: result = %+v", c.name, res)
}
}
if n := g.res.total() - lookups; n != 0 {
t.Fatalf("%d lookups for calls a URL rule blocks, want none", n)
}
if got := g.store.outcomes()[rows:]; len(got) != 6 || slices.ContainsFunc(got, func(o string) bool { return o != "denied/rule_blocked" }) {
t.Fatalf("logged rows = %v, want 6 rule_blocked rows", got)
}
// The rendered URL of a custom http tool and the URLs of a rendered command
// meet the same rules, with no lookup.
g.store.rules = rule("exact_url", "https://app.example.com/admin", true)
if res := GateRenderedURL(g.ctx(), "probe_http", "https://app.example.com/admin"); res.Code != TargetRuleBlocked || res.Host != "app.example.com" {
t.Fatalf("rendered URL: %+v", res)
}
g.store.rules = rule("fuzzy_url", "/admin", true)
if res := GateRenderedCommand(g.ctx(), "probe_cmd", "curl 'https://app.example.com/admin'"); res.Code != TargetRuleBlocked {
t.Fatalf("rendered command: %+v", res)
}
if n := g.res.total() - lookups; n != 0 {
t.Fatalf("%d lookups for rendered calls a URL rule blocks, want none", n)
}
}
// Documents the known limit of URL rules: they match the URL as the call
// writes it. Another spelling of the same URL is not blocked. For a host, a
// domain, IP or CIDR rule is the one to write.
func TestExactURLRuleMatchesOnlyAsWritten(t *testing.T) {
g := newGate(t)
g.res.set("app.example.com", "203.0.113.7")
g.store.approve(TargetRequest{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}})
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_url", Pattern: "https://app.example.com/admin"}}
g.blocked("as written", TargetRuleBlocked, g.fetch("https://app.example.com/admin"))
for _, u := range []string{
"https://APP.example.com/admin", // case of the host
"https://app.example.com/Admin", // case of the path
"https://app.example.com/admin/", // trailing slash
"https://app.example.com/admin?x=1", // added query
"https://app.example.com/%61dmin", // percent-encoding
"https://app.example.com/./admin", // dot segment
"https://app.example.com:443/admin", // default port written out
} {
g.passes("exact_url, "+u, g.fetch(u))
}
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "fuzzy_url", Pattern: "/admin"}}
g.blocked("fuzzy, as written", TargetRuleBlocked, g.fetch("https://app.example.com/x/admin"))
for _, u := range []string{"https://app.example.com/%61dmin", "https://app.example.com/adm%69n", "https://app.example.com//%2fadmin"} {
if strings.Contains(strings.ToLower(u), "/admin") {
t.Fatalf("%s contains the pattern; the case documents an encoded form", u)
}
g.passes("fuzzy_url, "+u, g.fetch(u))
}
}
+17 -3
View File
@@ -62,6 +62,13 @@ func toolSet(names ...string) map[string]bool {
// names on its own. The gate detects a declared target; it does not prevent an
// undeclared connection.
func ExtractTargets(toolName string, input []byte) []Target {
return extractDeclared(toolName, input).targets
}
// extractDeclared reads what a tool call declares: its targets, as
// ExtractTargets returns them, and the URLs it writes, as written, for the URL
// block rules. A URL is kept only when it names a target.
func extractDeclared(toolName string, input []byte) targetList {
if toolName == executeExtraTool {
var in struct {
ToolName string `json:"tool_name"`
@@ -72,13 +79,15 @@ func ExtractTargets(toolName string, input []byte) []Target {
}
}
if noTargetTools[toolName] {
return nil
return targetList{}
}
var out targetList
switch toolName {
case "WebFetch":
if t, ok := NormalizeTarget(declaredFields(input).URL); ok {
u := declaredFields(input).URL
if t, ok := NormalizeTarget(u); ok {
out.add(t)
out.urls = append(out.urls, strings.TrimSpace(u))
}
case "Bash", "shell_open", "Monitor":
out.scan(declaredFields(input).Command)
@@ -87,7 +96,7 @@ func ExtractTargets(toolName string, input []byte) []Target {
default:
out.scanStrings(input)
}
return out.targets
return out
}
// declaredInput holds the fields of the built-in tools that carry a target.
@@ -109,6 +118,7 @@ func declaredFields(input []byte) declaredInput {
type targetList struct {
targets []Target
seen map[Target]bool
urls []string // the URLs that name a target, as written
}
func (l *targetList) add(t Target) {
@@ -207,6 +217,10 @@ func (l *targetList) scan(s string) {
found = true
}
}
if found {
// The whole token, path and query included, from the original string.
l.urls = append(l.urls, s[m[0]:m[1]])
}
// 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])
+38 -12
View File
@@ -93,12 +93,12 @@ func (i *Interceptor) CheckTargets(ctx context.Context, scope TargetScope, toolN
if scope.Enabled == nil || !scope.Enabled() {
return nil, TargetResult{}
}
targets := ExtractTargets(toolName, input)
if len(targets) == 0 {
declared := extractDeclared(toolName, input)
if len(declared.targets) == 0 {
return nil, TargetResult{}
}
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input}
return c.run(targets)
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input, urls: declared.urls}
return c.run(declared.targets)
}
// targetCheck is one run of the gate decision.
@@ -109,6 +109,7 @@ type targetCheck struct {
ref db.TargetScopeRef
tool string
input []byte
urls []string // the URLs the call declares, as written, for the URL block rules
}
// checkedTarget is one target of the call and what the gate learned about it.
@@ -146,23 +147,47 @@ func (c *targetCheck) run(targets []Target) ([]TargetRequest, TargetResult) {
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.
// Block rules come first, and an exempt or pinned host that matches one is
// blocked. They are matched on what the call declares before anything is
// resolved: a hit there costs no lookup, for any target of the call.
//
// URL rules (exact_url, fuzzy_url) are matched once per decision, on the
// URLs as the call writes them. That is a known limit: exact_url compares
// the string as written, so another case, encoding (%2F), a trailing slash,
// an added query or /./ does not match, and fuzzy_url is evaded by encoding
// the substring; a command can also build its URL at run time. To keep an
// agent away from a host, a domain, IP or CIDR rule is the one to write.
if rule, u, hit := db.MatchAssetInterceptRules(rules, nil, nil, c.urls); hit {
ct := &checkedTarget{Target: targets[0]}
if t, ok := NormalizeTarget(u); ok {
ct.Target = t
}
return c.block(TargetRuleBlocked, "rule_blocked", ct, fmt.Sprintf("%s is blocked by an asset rule. %s", u, rule.Reason()))
}
// Then the declared form of every target: the name as written, the IP
// literal, the range.
checked := make([]*checkedTarget, len(targets))
for n, t := range targets {
ct := &checkedTarget{Target: t}
checked[n] = ct
switch {
case t.Kind != TargetDomain:
if 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()))
}
}
// Only then are the names resolved, and the rules matched on the addresses.
// A name the configuration exempts is never resolved.
for _, ct := range checked {
if ct.Kind != TargetDomain || ex.derived[db.TargetKey(ct.Host, ct.Port)] {
continue
}
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", ct.Target, rule.Reason()))
}
}
i.mu.RLock()
proxied := i.upstreamProxy
@@ -306,7 +331,8 @@ func (c *targetCheck) pinUsed(ct *checkedTarget, pin *db.TargetPin) {
}
// 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.
// addresses. A range is matched as written and by its network address. URL
// rules are not matched here: run matches them once, on the declared URLs.
func blockRule(rules []db.AssetInterceptRule, ct *checkedTarget) (db.AssetInterceptRule, bool) {
var domains []string
ips := ct.ips
+155
View File
@@ -0,0 +1,155 @@
package intercept
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
)
// The custom tools render their target from a template and the parameters of
// the call, after the hook has seen the call: the hook reads the parameters, not
// the URL or the command they produce, and not a target written in the
// template itself. These functions run the gate inside the tool, on what the
// tool is about to run, before anything is sent or started.
// renderedPasses bounds the request-and-recheck loop of a rendered target, as
// the hook bounds its own: 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 renderedPasses = 3
// GateRenderedURL is the target gate for the URL a custom http tool renders. It
// reads the target as the HTTP client will: the host of the parsed URL and its
// port, else the default port of the scheme. A URL whose target cannot be read
// is blocked with TARGET_GATE_UNAVAILABLE: the call is not executed and no
// approval is requested.
//
// The result is empty when the tool may run. With the gate off, or with no
// scope in ctx (the operator's test endpoint), it returns at once: nothing is
// parsed, resolved, read or written.
func GateRenderedURL(ctx context.Context, toolName, rawURL string) TargetResult {
scope, on := renderedScope(ctx)
if !on {
return TargetResult{}
}
input := renderedInput("url", rawURL)
t, err := urlTarget(rawURL)
if err != nil {
return undeterminedTarget(ctx, scope, toolName, input, "the target of the rendered URL could not be read ("+err.Error()+")")
}
return gateRendered(ctx, scope, toolName, input, []Target{t}, []string{rawURL})
}
// GateRenderedCommand is the target gate for the command a custom command tool
// renders, read with the command-string rules of Bash. readings are other
// renderings of the same command that are read as well: the shell quoting of a
// parameter placed inside a host name hides that name from the rules, and the
// shell removes the quotes before the command runs. A command that names no
// target passes, as it does in Bash: what a command hides is advisory (D9).
//
// The result is empty when the tool may run. With the gate off, or with no
// scope in ctx, it returns at once.
func GateRenderedCommand(ctx context.Context, toolName, command string, readings ...string) TargetResult {
scope, on := renderedScope(ctx)
if !on {
return TargetResult{}
}
var l targetList
l.scan(command)
for _, r := range readings {
l.scan(r)
}
return gateRendered(ctx, scope, toolName, renderedInput("command", command), l.targets, l.urls)
}
func renderedScope(ctx context.Context) (TargetScope, bool) {
scope, ok := TargetScopeFromContext(ctx)
return scope, ok && scope.Enabled != nil && scope.Enabled()
}
// renderedInput is the tool input recorded with a rendered target: what the
// tool runs, which is what the human reads on the approval.
func renderedInput(field, value string) []byte {
b, _ := json.Marshal(map[string]string{field: value})
return b
}
// gateRendered runs the gate decision on the targets of a rendered call, asks
// for the approval of those no pin covers, and checks again after the
// decisions. urls are the URLs the call renders, for the URL block rules. It
// never merges an ask: the rules and the judge decided at the hook, before the
// tool ran.
func gateRendered(ctx context.Context, scope TargetScope, toolName string, input []byte, targets []Target, urls []string) TargetResult {
if len(targets) == 0 {
return TargetResult{}
}
i := scope.Interceptor
if i == nil {
return TargetResult{Code: TargetGateUnavailable, Host: targets[0].Host, Port: targets[0].Port,
Reason: "the session's target scope carries no gate"}
}
ctx = WithCall(ctx, toolName, input)
check := func() ([]TargetRequest, TargetResult) {
if !scope.Enabled() {
return nil, TargetResult{}
}
c := &targetCheck{i: i, ctx: ctx, scope: scope, tool: toolName, input: input, urls: urls}
return c.run(targets)
}
unpinned, res := check()
for pass := 0; res.Code == "" && len(unpinned) > 0; pass++ {
if pass == renderedPasses {
t := unpinned[0]
return TargetResult{Code: 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", renderedPasses)}
}
if res = i.RequestTargets(ctx, scope, toolName, input, unpinned, nil); res.Code != "" {
return res
}
unpinned, res = check()
}
return res
}
// undeterminedTarget blocks a call whose target cannot be read, and logs the
// block as a decided row.
func undeterminedTarget(ctx context.Context, scope TargetScope, toolName string, input []byte, reason string) TargetResult {
res := TargetResult{Code: TargetGateUnavailable, Reason: reason}
if scope.Interceptor == nil {
return res
}
ref, err := scope.ref()
if err != nil {
return res
}
c := &targetCheck{i: scope.Interceptor, ctx: WithCall(ctx, toolName, input), scope: scope, ref: ref, tool: toolName, input: input}
_, res = c.block(TargetGateUnavailable, "gate_unavailable", &checkedTarget{}, reason)
return res
}
// urlTarget reads the target of a URL the way net/http dials it: the host of
// the parsed URL, and its port, else the default port of the scheme.
func urlTarget(raw string) (Target, error) {
u, err := url.Parse(raw)
if err != nil {
return Target{}, err
}
name := u.Hostname()
if name == "" {
return Target{}, errors.New("no host")
}
kind, host, ok := normalizeHost(name, true)
if !ok {
return Target{}, fmt.Errorf("host %q", name)
}
t := Target{Kind: kind, Host: host, Port: schemePorts[strings.ToLower(u.Scheme)]}
if p := u.Port(); p != "" {
if t.Port = validPort(p); t.Port == 0 {
return Target{}, fmt.Errorf("port %q", p)
}
}
return t, nil
}
+177
View File
@@ -0,0 +1,177 @@
package intercept
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/db"
)
// The gate inside the custom tools: GateRenderedURL and GateRenderedCommand
// read what the tool is about to run. Without a database: the cases that reach
// a request are covered by the server tests.
func (g *gateFixture) ctx() context.Context {
return WithTargetScope(context.Background(), g.scope)
}
// With the gate off, or without a scope (the operator's test endpoint), the
// rendered target is not even parsed: nothing is read, resolved or logged, and
// a URL the gate could not read passes too, as it did before the gate.
func TestRenderedGateOffTouchesNothing(t *testing.T) {
g := newGate(t)
g.res.set("app.example.com", "203.0.113.7")
for name, ctx := range map[string]context.Context{
"no scope": context.Background(),
"no flag": WithTargetScope(context.Background(), TargetScope{TaskID: "7", WaitBound: time.Minute, Interceptor: g.ic}),
"flag off": WithTargetScope(context.Background(), TargetScope{TaskID: "7", Enabled: func() bool { return false }, Interceptor: g.ic}),
} {
for _, res := range []TargetResult{
GateRenderedURL(ctx, "probe", appURL),
GateRenderedURL(ctx, "probe", "http://a b/"),
GateRenderedCommand(ctx, "probe", "curl "+appURL),
} {
if res != (TargetResult{}) {
t.Errorf("%s: %+v, want nothing", name, 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())
}
}
// The target of a rendered URL is read as net/http dials it: the host of the
// parsed URL, the explicit port, else the default of the scheme.
func TestRenderedURLTarget(t *testing.T) {
for _, c := range []struct {
raw string
want Target
}{
{"https://App.Example.COM./x?next=http://other.example/", Target{TargetDomain, "app.example.com", 443}},
{"http://user:pw@203.0.113.7:8080/a", Target{TargetIP, "203.0.113.7", 8080}},
{"http://[::ffff:203.0.113.7]/", Target{TargetIP, "203.0.113.7", 80}},
{"http://[2001:db8::1]:8443/", Target{TargetIP, "2001:db8::1", 8443}},
{"http://2130706433/", Target{TargetIP, "127.0.0.1", 80}},
{"HTTPS://bücher.example/", Target{TargetDomain, "xn--bcher-kva.example", 443}},
// The client's reading of the authority, not the shell's: a.example; is userinfo.
{"http://a.example;@b.example/", Target{TargetDomain, "b.example", 80}},
} {
got, err := urlTarget(c.raw)
if err != nil || got != c.want {
t.Errorf("%q: %+v, %v; want %+v", c.raw, got, err, c.want)
}
}
for _, raw := range []string{
"http://a b/", // not a URL the client accepts
`http://a.example\@b.example/`, // idem
"http://[::1/", // idem
"http:///path", // no host
"app.example.com/path", // no scheme: no host
"http://app.example.com:99999/x", // no valid port
} {
if got, err := urlTarget(raw); err == nil {
t.Errorf("%q: %+v, want no target", raw, got)
}
}
}
// A rendered URL whose target cannot be read is blocked: no lookup, no
// request, one decided row that records the URL.
func TestRenderedURLWithoutTargetFailsClosed(t *testing.T) {
g := newGate(t)
for n, raw := range []string{"http://a b/", "http:///path", "app.example.com/path", "http://app.example.com:99999/"} {
res := GateRenderedURL(g.ctx(), "probe_http", raw)
if res.Code != TargetGateUnavailable || !strings.Contains(res.Reason, "could not be read") {
t.Fatalf("%q: %+v, want %s", raw, res, TargetGateUnavailable)
}
if len(g.store.logged) != n+1 {
t.Fatalf("%q: %d rows, want %d", raw, len(g.store.logged), n+1)
}
row := g.store.logged[n]
var input map[string]string
_ = json.Unmarshal(row.ToolInput, &input)
if row.Status != "denied" || row.ToolName != "probe_http" || input["url"] != raw || row.Audit.Targets[0].Outcome != "gate_unavailable" {
t.Fatalf("%q: row = %+v (input %s)", raw, row, row.ToolInput)
}
}
if g.res.total() != 0 {
t.Fatalf("%d lookups, want none", g.res.total())
}
}
// A gated scope without the interceptor that owns the gate cannot decide: the
// call is blocked, never let through.
func TestRenderedGateWithoutInterceptorFailsClosed(t *testing.T) {
ctx := WithTargetScope(context.Background(), TargetScope{TaskID: "7", Enabled: func() bool { return true }})
for name, res := range map[string]TargetResult{
"url": GateRenderedURL(ctx, "probe", appURL),
"unreadable url": GateRenderedURL(ctx, "probe", "http://a b/"),
"command": GateRenderedCommand(ctx, "probe", "curl "+appURL),
} {
if res.Code != TargetGateUnavailable {
t.Errorf("%s: %+v, want %s", name, res, TargetGateUnavailable)
}
}
// A command that names no target needs no gate.
if res := GateRenderedCommand(ctx, "probe", "ls -la"); res != (TargetResult{}) {
t.Errorf("no target: %+v, want nothing", res)
}
}
// The rendered target goes through the same decision as a target at the hook:
// block rules, resolution, pins. A pinned target passes, a blocked one is
// blocked without a request.
func TestRenderedTargetUsesTheGateDecision(t *testing.T) {
g := newGate(t)
g.res.set("app.example.com", "203.0.113.7")
g.store.approve(TargetRequest{Host: "app.example.com", Port: 443, ResolvedIPs: []string{"203.0.113.7"}})
g.store.approve(TargetRequest{Host: "198.51.100.9", Port: 8080, ResolvedIPs: []string{"198.51.100.9"}})
for name, res := range map[string]TargetResult{
"url": GateRenderedURL(g.ctx(), "probe", appURL),
"url, ip and port": GateRenderedURL(g.ctx(), "probe", "http://198.51.100.9:8080/x"),
"command": GateRenderedCommand(g.ctx(), "probe", "curl -s '"+appURL+"'"),
"command, no target": GateRenderedCommand(g.ctx(), "probe", "nmap -sV app.example.com"),
"command, ip literal": GateRenderedCommand(g.ctx(), "probe", "nc 198.51.100.9:8080"),
} {
if res != (TargetResult{}) {
t.Errorf("%s: %+v, want the call to pass", name, res)
}
}
g.res.errs["down.example"] = context.DeadlineExceeded
if res := GateRenderedURL(g.ctx(), "probe", "https://down.example/"); res.Code != TargetResolveFailed || res.Host != "down.example" {
t.Fatalf("unresolvable: %+v", res)
}
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_domain", Pattern: "app.example.com"}}
for name, res := range map[string]TargetResult{
"url": GateRenderedURL(g.ctx(), "probe", appURL),
"command": GateRenderedCommand(g.ctx(), "probe", "curl "+appURL),
} {
if res.Code != TargetRuleBlocked || res.Host != "app.example.com" || res.Port != 443 {
t.Errorf("%s: %+v, want %s on the pinned host", name, res, TargetRuleBlocked)
}
}
if !strings.Contains(strings.Join(g.store.outcomes(), " "), "denied/rule_blocked") {
t.Fatalf("rows = %v, want the block logged", g.store.outcomes())
}
}
// The shell quoting of a parameter inside a host name hides the name from the
// command-string rules; the reading without the quoting is read as well.
func TestRenderedCommandReadsTheUnquotedRendering(t *testing.T) {
g := newGate(t)
g.store.rules = []db.AssetInterceptRule{{Enabled: true, Kind: "exact_domain", Pattern: "api.example.com"}}
quoted := "curl http://'api'.example.com:8080/"
if res := GateRenderedCommand(g.ctx(), "probe", quoted); res != (TargetResult{}) {
t.Fatalf("quoted only: %+v; the rules were expected to miss the name", res)
}
res := GateRenderedCommand(g.ctx(), "probe", quoted, "curl http://api.example.com:8080/")
if res.Code != TargetRuleBlocked || res.Host != "api.example.com" || res.Port != 8080 {
t.Fatalf("with the unquoted reading: %+v, want %s on api.example.com:8080", res, TargetRuleBlocked)
}
}
+19 -6
View File
@@ -18,6 +18,8 @@ import (
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/guard"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
@@ -163,11 +165,11 @@ func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
var res actool.Result
switch req.Kind {
case "command":
res, _ = s.runCommandTool(ctx, req.Exec, params, tc)
res, _ = s.runCommandTool(ctx, "test", req.Exec, params, tc)
case "script":
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
case "http":
res, _ = s.runHTTPTool(ctx, req.Exec, params, tc)
res, _ = s.runHTTPTool(ctx, "test", req.Exec, params, tc)
case "shell":
writeErr(w, 400, "shell-kind tools are bash environment declarations with no executable content")
return
@@ -275,11 +277,11 @@ func (s *Server) buildCustomTool(t *db.Tool) actool.CoreTool {
}
switch kind {
case "command":
return s.runCommandTool(ctx, execRaw, params, tc)
return s.runCommandTool(ctx, key, execRaw, params, tc)
case "script":
return s.runScriptTool(ctx, key, execRaw, params, tc)
case "http":
return s.runHTTPTool(ctx, execRaw, params, tc)
return s.runHTTPTool(ctx, key, execRaw, params, tc)
default:
return actool.Errorf("unknown custom tool kind: " + kind), nil
}
@@ -328,13 +330,18 @@ func ensureSchema(raw json.RawMessage) map[string]any {
// ---------- command: render the command → reuse the Bash low-level run ----------
func (s *Server) runCommandTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
func (s *Server) runCommandTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
var spec commandExec
_ = json.Unmarshal(execRaw, &spec)
if strings.TrimSpace(spec.Command) == "" {
return actool.Errorf("command is empty"), nil
}
cmd := renderTemplate(spec.Command, params, shellQuote)
// The hook saw the parameters, not the command they render: the target gate
// reads the rendered command here, also without the quoting of the parameters.
if res := intercept.GateRenderedCommand(ctx, key, cmd, renderTemplate(spec.Command, params, identity)); res.Code != "" {
return actool.Errorf(guard.TargetBlockMessage(res)), nil
}
// Reuse the low-level run Bash also uses (via the Bash CoreTool.Call): it
// automatically inherits the safety floor/timeout/proxy env/output overflow. The tool is a peer of Bash sharing that low level, not routed through the Bash tool for the model to call.
bashIn, _ := json.Marshal(map[string]any{"command": cmd})
@@ -417,7 +424,7 @@ func execPython(ctx context.Context, interp, key, code string, params map[string
// ---------- http: native request + proxy ----------
func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
func (s *Server) runHTTPTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
var spec httpExec
_ = json.Unmarshal(execRaw, &spec)
method := strings.ToUpper(strings.TrimSpace(spec.Method))
@@ -428,6 +435,12 @@ func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, param
if strings.TrimSpace(rawURL) == "" {
return actool.Errorf("http url is empty"), nil
}
// The hook saw the parameters, not the URL they render (nor a URL written in
// the template): the target gate reads the rendered URL here, before the
// tool's own timeout starts, so a wait for an approval does not consume it.
if res := intercept.GateRenderedURL(ctx, key, rawURL); res.Code != "" {
return actool.Errorf(guard.TargetBlockMessage(res)), nil
}
var bodyReader io.Reader
if spec.Body != "" {
bodyReader = strings.NewReader(renderTemplate(spec.Body, params, identity))
+662
View File
@@ -0,0 +1,662 @@
package server
import (
"context"
"encoding/json"
"fmt"
"iter"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/llm"
actool "github.com/Autumn-27/norma/tool"
)
// Step 1.8: the targets the custom tools render. The hook reads the parameters
// of a custom tool, not the URL or the command they produce: the tool runs the
// gate on what it is about to run. Plus the end-to-end run of a gated worker
// and the run of a task with the gate off.
// countingServer is an HTTP server on 127.0.0.1 that counts the requests it
// receives. Its host:port is an IP literal: the gate never resolves it.
func countingServer(t *testing.T) (srv *httptest.Server, hits *atomic.Int64, host, port string) {
t.Helper()
hits = &atomic.Int64{}
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("probe-reached"))
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
host, port, err = net.SplitHostPort(u.Host)
if err != nil {
t.Fatal(err)
}
return srv, hits, host, port
}
// gatedCtx is the context of a worker tool call of task: its approval scope
// with the given wait bound.
func gatedCtx(task *Task, wait time.Duration) context.Context {
ctx := intercept.WithTaskContext(context.Background(), task.ID, "worker", nil)
return intercept.WithTargetScope(ctx, task.targetScope(wait))
}
func execJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
// callTool runs a custom tool the way the harness does and returns its text.
func callTool(t *testing.T, tool actool.CoreTool, ctx context.Context, input string, tc *actool.ToolContext) (string, bool) {
t.Helper()
res, err := tool.Call(ctx, json.RawMessage(input), tc)
if err != nil {
t.Fatalf("%s: %v", tool.Name(), err)
}
return res.Flatten(), res.IsError
}
// openTargetRequest returns the open target request of the task on key.
func openTargetRequest(t *testing.T, pg *db.DB, taskID, key string) (id int64, toolName, toolInput string, ok bool) {
t.Helper()
err := pg.QueryRow(`SELECT id, tool_name, tool_input::text FROM intercept_pending
WHERE task_id=$1 AND status='pending' AND target_key=$2`, taskID, key).Scan(&id, &toolName, &toolInput)
if err != nil {
return 0, "", "", false
}
return id, toolName, toolInput, true
}
func targetRowCount(t *testing.T, pg *db.DB, taskID string) int {
t.Helper()
var n int
if err := pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND (decision_source='target' OR target_key IS NOT NULL)`, taskID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// A custom http tool is gated on the URL it renders, whether the target comes
// from a parameter (here split over two parameters, so the hook reads no
// target in them) or is written in the template. Nothing is sent before an
// approval; an approval lets both through; a revoked pin asks again.
func TestCustomHTTPRenderedTargetGated(t *testing.T) {
g := newGateServer(t)
on := true
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
pg := g.m.pg
srv, hits, host, port := countingServer(t)
key := net.JoinHostPort(host, port)
dot := strings.LastIndexByte(host, '.')
rendered := g.s.buildCustomTool(&db.Tool{Key: "probe_http", Kind: "http",
Schema: json.RawMessage(`{"type":"object","properties":{"last":{"type":"string"},"port":{"type":"string"}}}`),
Exec: execJSON(t, map[string]any{"method": "GET", "url": "http://" + host[:dot+1] + "{last}:{port}/rendered"})})
params := fmt.Sprintf(`{"last":%q,"port":%q}`, host[dot+1:], port)
fixed := g.s.buildCustomTool(&db.Tool{Key: "probe_fixed", Kind: "http",
Exec: execJSON(t, map[string]any{"method": "GET", "url": srv.URL + "/fixed"})})
// The hook cannot see either target: neither input names one.
for _, in := range []struct{ tool, input string }{{"probe_http", params}, {"probe_fixed", `{}`}} {
if got := intercept.ExtractTargets(in.tool, []byte(in.input)); len(got) != 0 {
t.Fatalf("%s: the hook reads %+v; the test needs a target only the rendering shows", in.tool, got)
}
}
// The template's own URL, wait bound 0: blocked at once, nothing sent, the
// request open with the rendered URL as its input.
out, isErr := callTool(t, fixed, gatedCtx(task, 0), `{}`, nil)
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || !strings.Contains(out, key) || hits.Load() != 0 {
t.Fatalf("hard-coded URL: error=%v out=%q hits=%d", isErr, out, hits.Load())
}
id, toolName, toolInput, ok := openTargetRequest(t, pg, task.ID, key)
if !ok || toolName != "probe_fixed" || !strings.Contains(toolInput, srv.URL+"/fixed") {
t.Fatalf("request row: ok=%v tool=%q input=%s", ok, toolName, toolInput)
}
// The rendered URL names the same host and port: the call attaches to the
// open request and waits. One click lets it through.
type result struct {
out string
isErr bool
}
done := make(chan result, 1)
go func() {
res, err := rendered.Call(gatedCtx(task, 30*time.Second), json.RawMessage(params), nil)
if err != nil {
done <- result{err.Error(), true}
return
}
done <- result{res.Flatten(), res.IsError}
}()
time.Sleep(300 * time.Millisecond)
if hits.Load() != 0 {
t.Fatal("the rendered URL was fetched before the approval")
}
if n := targetRowCount(t, pg, task.ID); n != 1 {
t.Fatalf("%d target rows, want the one request", n)
}
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
t.Fatalf("decide: %d %v", code, body)
}
select {
case r := <-done:
if r.isErr || !strings.Contains(r.out, "probe-reached") || hits.Load() != 1 {
t.Fatalf("after the approval: error=%v out=%q hits=%d", r.isErr, r.out, hits.Load())
}
case <-time.After(30 * time.Second):
t.Fatal("the waiting call did not end after the approval")
}
if out, isErr := callTool(t, fixed, gatedCtx(task, 0), `{}`, nil); isErr || hits.Load() != 2 {
t.Fatalf("pinned, hard-coded URL: error=%v out=%q hits=%d", isErr, out, hits.Load())
}
// Revoked: the next call asks again and sends nothing.
pins, err := pg.ListTargetPins(taskRef(t, task))
if err != nil || len(pins) != 1 || pins[0].Host != host || fmt.Sprint(pins[0].Port) != port {
t.Fatalf("pins = %+v, %v", pins, err)
}
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/tasks/%s/target-pins/%d/revoke", task.ID, pins[0].ID), ""); code != 200 {
t.Fatalf("revoke: %d", code)
}
out, isErr = callTool(t, rendered, gatedCtx(task, 0), params, nil)
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || hits.Load() != 2 {
t.Fatalf("revoked: error=%v out=%q hits=%d", isErr, out, hits.Load())
}
if again, toolName, _, ok := openTargetRequest(t, pg, task.ID, key); !ok || again == id || toolName != "probe_http" {
t.Fatalf("after the revoke: open request %d (ok=%v, tool %q), want a new one", again, ok, toolName)
}
// A URL whose target cannot be read is blocked, never sent.
broken := g.s.buildCustomTool(&db.Tool{Key: "probe_broken", Kind: "http",
Schema: json.RawMessage(`{"type":"object","properties":{"host":{"type":"string"}}}`),
Exec: execJSON(t, map[string]any{"url": "http://{host}/x"})})
out, isErr = callTool(t, broken, gatedCtx(task, 0), `{"host":"a b"}`, nil)
if !isErr || !strings.Contains(out, "TARGET_GATE_UNAVAILABLE") {
t.Fatalf("unreadable URL: error=%v out=%q", isErr, out)
}
}
// A custom command tool is gated on the command it renders, with a target
// written in the template or formed by a parameter inside a host name. The
// command does not run before an approval.
func TestCustomCommandRenderedTargetGated(t *testing.T) {
g := newGateServer(t)
on := true
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
pg := g.m.pg
res := &countingResolver{answers: map[string][]string{"api.example.com": {"198.51.100.7"}}}
g.m.interceptor.SetResolver(res)
dir := t.TempDir()
marker := filepath.Join(dir, "marker")
ran := func() bool {
_, err := os.Stat(marker)
return err == nil
}
fixed := g.s.buildCustomTool(&db.Tool{Key: "probe_cmd_fixed", Kind: "command",
Exec: execJSON(t, map[string]any{"command": "echo reached > '" + marker + "' && : http://198.51.100.7:8080/"})})
glued := g.s.buildCustomTool(&db.Tool{Key: "probe_cmd", Kind: "command",
Schema: json.RawMessage(`{"type":"object","properties":{"sub":{"type":"string"}}}`),
Exec: execJSON(t, map[string]any{"command": "echo reached > '" + marker + "' && : http://{sub}.example.com:8080/"})})
if got := intercept.ExtractTargets("probe_cmd", []byte(`{"sub":"api"}`)); len(got) != 0 {
t.Fatalf("the hook reads %+v; the test needs a target only the rendering shows", got)
}
tc := &actool.ToolContext{WorkingDir: dir}
for _, c := range []struct {
name string
tool actool.CoreTool
input string
key string
}{
{"template", fixed, `{}`, "198.51.100.7:8080"},
{"parameter inside the host", glued, `{"sub":"api"}`, "api.example.com:8080"},
} {
out, isErr := callTool(t, c.tool, gatedCtx(task, 0), c.input, tc)
if !isErr || !strings.Contains(out, "TARGET_APPROVAL_PENDING") || ran() {
t.Fatalf("%s: error=%v out=%q ran=%v, want a block before the command runs", c.name, isErr, out, ran())
}
id, toolName, _, ok := openTargetRequest(t, pg, task.ID, c.key)
if !ok || toolName != c.tool.Name() {
t.Fatalf("%s: no open request on %s for %s", c.name, c.key, c.tool.Name())
}
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
t.Fatalf("%s: decide: %d %v", c.name, code, body)
}
if out, isErr := callTool(t, c.tool, gatedCtx(task, 0), c.input, tc); isErr || !ran() {
t.Fatalf("%s, approved: error=%v out=%q ran=%v", c.name, isErr, out, ran())
}
_ = os.Remove(marker)
}
if res.lookups() == 0 {
t.Fatal("the name formed by the parameter was never resolved")
}
}
// The custom-tool test endpoint is the operator's own action (D14): it has no
// approval scope, so the gate is not on there, whatever the tasks say.
func TestCustomToolTestEndpointUngated(t *testing.T) {
g := newGateServer(t)
on := true
g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
_, hits, host, port := countingServer(t)
before := 0
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&before); err != nil {
t.Fatal(err)
}
body := fmt.Sprintf(`{"kind":"http","exec":{"url":"http://%s:{port}/test"},"params":{"port":%q}}`, host, port)
code, out := g.do(t, "POST", "/api/tools/custom/test", body)
if code != 200 || out["is_error"] != false || !strings.Contains(fmt.Sprint(out["output"]), "probe-reached") || hits.Load() != 1 {
t.Fatalf("http: %d %v hits=%d", code, out, hits.Load())
}
code, out = g.do(t, "POST", "/api/tools/custom/test", `{"kind":"command","exec":{"command":": http://198.51.100.7:8080/ && echo ran-ungated"}}`)
if code != 200 || !strings.Contains(fmt.Sprint(out["output"]), "ran-ungated") {
t.Fatalf("command: %d %v", code, out)
}
after := 0
if err := g.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE decision_source='target' OR target_key IS NOT NULL`).Scan(&after); err != nil {
t.Fatal(err)
}
if after != before {
t.Fatalf("the test endpoint wrote %d target rows", after-before)
}
}
// countingResolver answers from a table and counts every lookup.
type countingResolver struct {
mu sync.Mutex
answers map[string][]string
n int
}
func (r *countingResolver) Name() string { return "counting" }
func (r *countingResolver) LookupIPs(_ context.Context, host string) ([]netip.Addr, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.n++
var out []netip.Addr
for _, ip := range r.answers[host] {
out = append(out, netip.MustParseAddr(ip))
}
if len(out) == 0 {
return nil, fmt.Errorf("no answer for %s", host)
}
return out, nil
}
func (r *countingResolver) lookups() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.n
}
// scriptedCall is one tool call of a toolScript.
type scriptedCall struct{ tool, input string }
func fetches(urls ...string) []scriptedCall {
calls := make([]scriptedCall, len(urls))
for n, u := range urls {
calls[n] = scriptedCall{"WebFetch", fmt.Sprintf(`{"url":%q}`, u)}
}
return calls
}
// toolScript is a model that makes each of its calls in turn, one per turn,
// then ends the run. It hands the result of every call to the test, and waits
// before turn n while hold[n] is open.
type toolScript struct {
calls []scriptedCall
hold map[int64]chan struct{}
results chan string
turn atomic.Int64
}
func (p *toolScript) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
n := p.turn.Add(1)
if n > 1 {
select {
case p.results <- lastToolResult(req.Messages):
default:
}
}
if h := p.hold[n]; h != nil {
select {
case <-h:
case <-ctx.Done():
}
}
events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
if int(n) <= len(p.calls) {
call := p.calls[n-1]
events = []llm.StreamEvent{
{Type: llm.SEToolUseStart, ToolID: fmt.Sprintf("call-%d", n), ToolName: call.tool},
{Type: llm.SEToolInputJSON, Text: call.input},
{Type: llm.SEMessageDelta, StopReason: "tool_use"},
}
}
return func(yield func(llm.StreamEvent, error) bool) {
for _, event := range events {
if !yield(event, nil) {
return
}
}
}
}
func (p *toolScript) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
return accumulateStreamForTest(ctx, p.Stream, req)
}
func (p *toolScript) next(t *testing.T) string {
t.Helper()
select {
case r := <-p.results:
return r
case <-time.After(60 * time.Second):
t.Fatal("the worker did not hand back a tool result")
return ""
}
}
// lastToolResult is the text of the last tool result the model is shown.
func lastToolResult(msgs []llm.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
for _, b := range msgs[i].Content {
if b.Type != llm.BlockToolResult {
continue
}
var s strings.Builder
if b.IsError {
s.WriteString("[error] ")
}
for _, c := range b.Content {
s.WriteString(c.Text)
}
return s.String()
}
}
return ""
}
// runScriptedWorker claims one intent of task and runs it with a worker driven
// by p, in the background. extra are tools the worker has besides its own.
func runScriptedWorker(t *testing.T, g *gateServer, task *Task, p llm.Provider, extra ...actool.CoreTool) <-chan struct{} {
t.Helper()
if _, err := task.Store.AddIntent(map[string]any{"summary": "fetch the probe"}, 1, nil, "planner"); err != nil {
t.Fatal(err)
}
intent, token := g.s.engine.claimNext(task, "work#1")
if intent == nil {
t.Fatal("no intent claimed")
}
worker := agent.NewWorker(p, "test-model", g.m.dir, nil, 0, 8, extra...)
done := make(chan struct{})
go func() {
defer close(done)
g.s.engine.runIntent(context.Background(), task, "work#1", worker, intent, token, "", "")
}()
t.Cleanup(func() {
select {
case <-done:
case <-time.After(60 * time.Second):
t.Error("the worker run did not end")
}
})
return done
}
// A gated task end to end: a worker's WebFetch waits on a request, a click
// through the API pins the address and the call is made, the next call passes
// on the pin, the pin is listed and revoked, and the call after asks again.
func TestTargetGateEndToEnd(t *testing.T) {
g := newGateServer(t)
on := true
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
pg := g.m.pg
srv, hits, host, port := countingServer(t)
key := net.JoinHostPort(host, port)
target := srv.URL + "/e2e"
p := &toolScript{calls: fetches(target, target, target), results: make(chan string, 16),
hold: map[int64]chan struct{}{3: make(chan struct{})}}
done := runScriptedWorker(t, g, task, p)
var id int64
waitUntil(t, "the first request", func() bool {
var ok bool
id, _, _, ok = openTargetRequest(t, pg, task.ID, key)
return ok
})
if hits.Load() != 0 {
t.Fatal("fetched before the approval")
}
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"allowed"}`); code != 200 {
t.Fatalf("decide: %d %v", code, body)
}
if r := p.next(t); !strings.Contains(r, "probe-reached") || hits.Load() != 1 {
t.Fatalf("first call after the approval: %q hits=%d", r, hits.Load())
}
if r := p.next(t); !strings.Contains(r, "probe-reached") || hits.Load() != 2 {
t.Fatalf("second call, pinned: %q hits=%d", r, hits.Load())
}
var requests int
if err := pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND target_key IS NOT NULL`, task.ID).Scan(&requests); err != nil || requests != 1 {
t.Fatalf("%d requests, %v; want the one that was approved", requests, err)
}
code, body := g.do(t, "GET", "/api/tasks/"+task.ID+"/target-pins", "")
pins, _ := body["pins"].([]any)
if code != 200 || len(pins) != 1 {
t.Fatalf("pins: %d %v", code, body)
}
pin := pins[0].(map[string]any)
if pin["host"] != host || fmt.Sprint(pin["port"]) != port || pin["status"] != "active" || fmt.Sprint(pin["pinned_ips"]) != "["+host+"]" {
t.Fatalf("listed pin = %v", pin)
}
pinID, _ := pin["id"].(float64)
if code, _ := g.do(t, "POST", fmt.Sprintf("/api/tasks/%s/target-pins/%d/revoke", task.ID, int64(pinID)), ""); code != 200 {
t.Fatalf("revoke: %d", code)
}
close(p.hold[3])
var again int64
waitUntil(t, "the request after the revoke", func() bool {
var ok bool
again, _, _, ok = openTargetRequest(t, pg, task.ID, key)
return ok && again != id
})
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", again), `{"decision":"denied"}`); code != 200 {
t.Fatalf("deny: %d %v", code, body)
}
if r := p.next(t); !strings.Contains(r, "TARGET_DENIED") || hits.Load() != 2 {
t.Fatalf("third call, revoked then denied: %q hits=%d", r, hits.Load())
}
select {
case <-done:
case <-time.After(60 * time.Second):
t.Fatal("the worker run did not end")
}
}
// A custom tool in a gated worker session. A deny rule on the call decides
// first, at the hook: the tool never runs, so its rendered target is never
// read, resolved or requested. The next call, which no rule refuses, is gated
// inside the tool on the URL it renders.
func TestDenyRuleWinsBeforeRenderedTarget(t *testing.T) {
g := newGateServer(t)
on := true
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
pg, ic := g.m.pg, g.m.interceptor
_, hits, _, port := countingServer(t)
res := &countingResolver{answers: map[string][]string{"api.example.com": {"127.0.0.1"}}}
ic.SetResolver(res)
prior, err := ic.GetEnabledTools()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ic.SetEnabledTools(prior) })
if err := ic.SetEnabledTools(append(slices.Clone(prior), "probe_http")); err != nil {
t.Fatal(err)
}
const sentinel = "ARTEX_RENDERED_DENY_PROBE"
rule, err := pg.CreateInterceptRule("rendered deny probe", "tool_input", "string", sentinel, "deny", "", 1000000, true, false, 0, "deny")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = pg.DeleteInterceptRule(rule.ID)
ic.Invalidate()
})
ic.Invalidate()
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_http", Kind: "http",
Schema: json.RawMessage(`{"type":"object","properties":{"sub":{"type":"string"},"note":{"type":"string"}}}`),
Exec: execJSON(t, map[string]any{"url": "http://{sub}.example.com:" + port + "/deny"})})
p := &toolScript{calls: []scriptedCall{
{"probe_http", `{"sub":"api","note":"` + sentinel + `"}`},
{"probe_http", `{"sub":"api"}`},
}, results: make(chan string, 16), hold: map[int64]chan struct{}{2: make(chan struct{})}}
done := runScriptedWorker(t, g, task, p, tool)
if r := p.next(t); !strings.Contains(r, "intercepted by the platform") || strings.Contains(r, "TARGET_") {
t.Fatalf("call refused by the rule: %q", r)
}
if n, rows := res.lookups(), targetRowCount(t, pg, task.ID); n != 0 || rows != 0 || hits.Load() != 0 {
t.Fatalf("after the deny: %d lookups, %d target rows, %d requests sent; want none", n, rows, hits.Load())
}
close(p.hold[2])
key := "api.example.com:" + port
var id int64
waitUntil(t, "the request for the rendered target", func() bool {
var ok bool
var tool string
id, tool, _, ok = openTargetRequest(t, pg, task.ID, key)
return ok && tool == "probe_http"
})
if code, body := g.do(t, "POST", fmt.Sprintf("/api/intercept/pending/%d/decide", id), `{"decision":"denied"}`); code != 200 {
t.Fatalf("deny: %d %v", code, body)
}
if r := p.next(t); !strings.Contains(r, "TARGET_DENIED") || hits.Load() != 0 || res.lookups() == 0 {
t.Fatalf("rendered target: %q, %d requests sent, %d lookups", r, hits.Load(), res.lookups())
}
select {
case <-done:
case <-time.After(60 * time.Second):
t.Fatal("the worker run did not end")
}
}
// Documents a known limit: script tools stay advisory (D9). A custom script
// tool's target is in its stored code, which neither the hook nor the tool
// reads, so in a gated task the call is allowed and the script reaches a target
// nobody approved: no lookup, no request, no row. This test changes only if
// that decision changes.
func TestBypass_CustomScriptToolTargetNotGated(t *testing.T) {
if detectPython() == "" {
t.Skip("python3 unavailable")
}
g := newGateServer(t)
on := true
task := g.newTask(t, db.TaskCreateOptions{TargetGate: &on})
res := &countingResolver{answers: map[string][]string{"localhost": {"127.0.0.1"}}}
g.m.interceptor.SetResolver(res)
_, hits, _, port := countingServer(t)
code := "import urllib.request\n" +
"opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))\n" +
"print(opener.open('http://localhost:" + port + "/script', timeout=20).read().decode())\n"
tool := g.s.buildCustomTool(&db.Tool{Key: "probe_script", Kind: "script", Exec: execJSON(t, map[string]any{"code": code})})
// The gate sees no target in the call: only the parameters reach the hook.
if got := intercept.ExtractTargets("probe_script", []byte(`{}`)); len(got) != 0 {
t.Fatalf("the hook reads %+v; the test needs a target only the stored code names", got)
}
p := &toolScript{calls: []scriptedCall{{"probe_script", `{}`}}, results: make(chan string, 16)}
done := runScriptedWorker(t, g, task, p, tool)
if r := p.next(t); !strings.Contains(r, "probe-reached") || strings.Contains(r, "TARGET_") {
t.Fatalf("script tool in a gated task: %q, want it allowed and the target reached", r)
}
select {
case <-done:
case <-time.After(60 * time.Second):
t.Fatal("the worker run did not end")
}
if hits.Load() != 1 {
t.Fatalf("%d requests reached the unapproved target, want 1 (the gap)", hits.Load())
}
if n, rows := res.lookups(), targetRowCount(t, g.m.pg, task.ID); n != 0 || rows != 0 {
t.Fatalf("%d lookups, %d target rows; the gate did see the script's target", n, rows)
}
}
// A task with the gate off (the default) runs as before: a worker's WebFetch
// and the custom tools reach their targets, and the gate writes no row and
// resolves no name.
func TestGateOffTaskIsUnchanged(t *testing.T) {
g := newGateServer(t)
task := g.newTask(t, db.TaskCreateOptions{})
res := &countingResolver{answers: map[string][]string{"localhost": {"127.0.0.1"}}}
g.m.interceptor.SetResolver(res)
_, hits, _, port := countingServer(t)
byName := "http://localhost:" + port
p := &toolScript{calls: fetches(byName+"/a", byName+"/b"), results: make(chan string, 16)}
done := runScriptedWorker(t, g, task, p)
for n := 1; n <= 2; n++ {
if r := p.next(t); !strings.Contains(r, "probe-reached") {
t.Fatalf("WebFetch %d: %q", n, r)
}
}
select {
case <-done:
case <-time.After(60 * time.Second):
t.Fatal("the worker run did not end")
}
httpTool := g.s.buildCustomTool(&db.Tool{Key: "probe_http", Kind: "http",
Schema: json.RawMessage(`{"type":"object","properties":{"port":{"type":"string"}}}`),
Exec: execJSON(t, map[string]any{"url": "http://localhost:{port}/c"})})
if out, isErr := callTool(t, httpTool, gatedCtx(task, intercept.DefaultTargetWaitBound), fmt.Sprintf(`{"port":%q}`, port), nil); isErr || !strings.Contains(out, "probe-reached") {
t.Fatalf("custom http tool: error=%v out=%q", isErr, out)
}
cmdTool := g.s.buildCustomTool(&db.Tool{Key: "probe_cmd", Kind: "command",
Exec: execJSON(t, map[string]any{"command": ": http://localhost:" + port + "/ && echo ran-gate-off"})})
if out, isErr := callTool(t, cmdTool, gatedCtx(task, intercept.DefaultTargetWaitBound), `{}`, &actool.ToolContext{WorkingDir: t.TempDir()}); isErr || !strings.Contains(out, "ran-gate-off") {
t.Fatalf("custom command tool: error=%v out=%q", isErr, out)
}
if hits.Load() != 3 {
t.Fatalf("%d requests reached the server, want 3", hits.Load())
}
if n := targetRowCount(t, g.m.pg, task.ID); n != 0 {
t.Fatalf("%d target rows, want none", n)
}
if n := res.lookups(); n != 0 {
t.Fatalf("%d lookups, want none", n)
}
}
+2 -2
View File
@@ -128,7 +128,7 @@ func TestRunHTTPTool(t *testing.T) {
"headers": map[string]string{"Authorization": "Bearer {token}"},
"body": `{"flag":"{flag}"}`,
})
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw,
res, err := (&Server{}).runHTTPTool(context.Background(), "probe", execRaw,
map[string]any{"flag": "CTF{x}", "token": "sekret"}, nil)
if err != nil {
t.Fatalf("runHTTPTool: %v", err)
@@ -176,7 +176,7 @@ func TestHTTPToolTruncatesLargeBody(t *testing.T) {
defer srv.Close()
execRaw, _ := json.Marshal(map[string]any{"method": "GET", "url": srv.URL})
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw, map[string]any{}, nil)
res, err := (&Server{}).runHTTPTool(context.Background(), "probe", execRaw, map[string]any{}, nil)
if err != nil {
t.Fatalf("runHTTPTool: %v", err)
}