A deny reason comes from a rule message, the fallback judge or the
target gate, and nothing bounds its length. systemBlockMessage and
TargetBlockMessage now cap the reason at 4000 runes (rune-safe, with a
" …(truncated)" marker) and keep the platform framing and the guidance
text intact. Interceptor.Log still stores the raw reason.
Audit findings F5, F6 and F11 for issue #124.
F5, goal guards:
- goal_met refuses while any goal node of the task is open, and when the
goals cannot be read. A task without goal nodes keeps the old behavior.
- prove_goal accepts a fact as evidence only when its confidence is
"observed"; otherwise it refuses and the goal stays open. Findings are
still accepted.
F6, hint origin:
- add_hint stores the calling ToolSet's worker as the hint origin instead
of "human", and passes it to the planner trigger (TriggerEvent.HintOrigin).
- renderTriggers names the writer: the human via the main agent, a
cross-task orchestration, or an agent, with a neutral fallback.
F11, built-in rules for the planner and the main agent:
- Interceptor.MatchBuiltin evaluates the enabled "[built-in]" deny rules on
the intercept-enabled tools.
- The target-only guard applies them before the target gate, whether the
gate is on or off. User rules and the judge stay out of it.
- SetRulesForTest is a test seam for the store-less guard tests; Log is a
no-op without a store.
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.
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).
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.
Step 1.1 of the S3b phase 1 plan. Wiring only: the approval gate for asset
targets is not enforced yet and no behaviour changes.
- Planner.Plan and MainAgent.Chat take a hooks parameter, like Worker.Execute.
Their sessions had no hooks, so nothing saw their tool calls before execution.
- guard.NewTargetOnly: the guard for those two agent kinds. It runs the
target-gate seam only: no intercept rule, no fallback judge, and no entry in
the audit ring (Guard.record is untouched and not called for these calls).
- Task.TargetGuard, built beside Task.Guard.
- intercept.WithTargetScope / TargetScopeFromContext: task id or conversation
id, an Enabled func and a wait bound, set at the five session entry points
(planner round, final planner round with wait bound 0, main-agent turn,
worker intent, conversation turn).
The seam allows every call and reads and writes nothing. No task or
conversation has an opt-in flag yet, so Enabled is nil (off) everywhere.
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.
Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
full text search) now use ASCII equivalents; go build passes and the
test suite matches the pre translation baseline (remaining failures
need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
matching the backend, chat mentions tests pass