doHTTP now connects through intercept.PinnedDialer under the target scope of the request's context, so the probe uses the same pinned-address dial as the target gate. ProbeSite takes the context of the call that asks for the probe. A probe without a scope is refused; with the gate on, the probe bypasses the recording proxy so the dialer decides on the target itself. The path stays dormant: nothing calls ProbeSite on main.
add_task_scope is seeded through BuiltinToolSeeds, SeedTool inserts only
once, and ToolResolve serves the stored row, so existing installs kept the
old "this is the task's authorization boundary" text.
seedTaskScopeDescription runs from seedOrchestrationTools and updates the
description only:
UPDATE tools SET description=$new, updated_at=now()
WHERE key='add_task_scope' AND system AND description=$old
$old is the description as seeded at 0dc8bdf, kept verbatim; $new is the
code description from BuiltinToolSeeds. It is idempotent, a no-op when the
row is absent, and leaves an operator-edited description alone. It does not
use RefreshToolDefaults, which overwrites without condition.
TestTaskScopeDescriptionUpgrade (server, PG) covers the replacement, the
preserved custom text, an unchanged updated_at on a second run, and the
description ToolResolve serves to the planner.
declaredScopeFilter treats only root_domain, subdomain, ip and cidr rows as
roots. A declared company, icp or keyword row does not make an auto row
count, but the add_task_scope description said "a goal target or scope
added here or manually". The description and the code comments now say
"declared domain or IP root" and name the kinds that act as roots.
Auto task_scope rows (source='auto') come from insert_assets and intent
asset_ids, i.e. from target-derived content. They now count toward the
coverage denominator and list_untested_assets only when they sit under a
root the same task declared (a non-auto row: goal target registered via
add_task_scope, or manual scope): the same or a deeper domain of a declared
root_domain/subdomain, or a net inside a declared ip/cidr.
The direct-source coverage view also stops counting an asset from an intent
anchor alone; fact anchors still count, so tested evidence is unchanged.
Without this, insert_assets put a planted host back into the backlog
through its intent anchor.
task_scope is described as "coverage scope, not enforced" in the code
comments, the add_task_scope description, the goal-decomposer tail and the
task overview UI, instead of "authorization boundary".
Audit F8, 0.7.0.
The planner and worker user messages now carry worker output, finding
details, recent_facts, finding_list, cold_digests, related_tasks and hints
not written by the operator inside a code-owned data envelope whose nonce
comes from crypto/rand (16 bytes, hex) once per message. Embedded marker
stems are neutralised with a fixed-string replacement. The task goal, goal
nodes, constraints and the operator's own hints stay outside, labelled
operator. Overview fact summaries are capped at factSummaryMax (160 runes),
the cap list_facts already uses.
SeedTool is first-insert only and ToolResolve serves the stored tools row,
so on an existing database the record_fact schema never gained the source
property: the model never saw the enum, every fact was stored as inference,
and prove_goal's target_response refusal could not fire.
seedFactSourceSchema copies properties.source from the record_fact seed into
the stored schema, only when the stored schema lacks it (same pattern as
seedFindingEvidenceSchema). Operator edits to the description and to other
properties are preserved, and a second run changes nothing. It runs from
seedOrchestrationTools next to the other schema seeders.
The PG test edits the stored row (no source, custom description and
parameter text), runs seedOrchestrationTools twice, and checks the added
enum, the preserved operator text, the unchanged row on the second run,
and that ToolResolve serves the source property to the worker.
Refs #124 (F4).
record_fact takes a source field from a closed enum (target_response,
tool_output, third_party_intel, operator_input, inference). An empty value
is stored as inference; any other value is refused with the allowed list.
The source is stored in the fact payload.
The writer of a node is already recorded by the server in the origin
column. compactNode now surfaces origin and source, so recent_facts in the
overview, list_facts, expand_digest members and related-task facts carry
them. The cold-digest compressor input carries source and origin next to
confidence; the digest body is unchanged model text.
prove_goal refuses a fact whose source is target_response, in addition to
the existing confidence=observed rule. The goal stays open; findings are
still accepted.
The worker, pentest and planner default prompts ask for the source, and the
planner treats target_response facts as non-authoritative for goals.
Refs #124 (F4).
Add two optional free-text fields to findings so a reporter can record
what was tested and did not work (counterevidence) and why the
conclusion holds (confidence_rationale).
- db: idempotent ADD COLUMN IF NOT EXISTS, TEXT NOT NULL DEFAULT '',
no CHECK; RecordFindingInput, the INSERT, the shared select list and
all three scan sites carry them.
- agent: report_finding accepts both as optional strings; the worker
and pentest prompts say to fill them when confidence is not high.
- server: FindingDTO (list and detail) carries both, omitted when empty;
existing databases get the two schema properties added to the stored
report_finding row once, without overwriting user edits.
- web: the finding detail page shows each section only when non-empty.
Both fields stay optional: a missing field never refuses a finding.
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.
Phase B, part 1 of the agentic attack-class audit (issue #124).
- Planner and cold-digest compactor: a code-owned untrusted-data tail
after the rendered body. Worker summaries, facts, digests, traces and
tool outputs are data; they cannot add goals, targets or scope, end the
task, or be relayed through steer_work as instructions.
- The #49 untrusted-content block moves out of the editable section [A]
of the worker, mainagent, auto and pentest seeds into a code-owned tail,
also applied to the reporter. A body that already carries the block
verbatim (rows seeded before this change) does not get it twice.
- Planner user message: a code-owned goal anchor after the situational
block restates the task goal and the operation constraints and asks each
new intent to name the goal id it serves. The message builder is
factored into plannerRoundInput for pure tests.
- bbot-recon skill: every event field is untrusted; only scope_distance
== 0 reaches insert_assets; scope_distance > 0 stays an intel fact with
source=third_party_intel, never auto-scoped; FINDING text is a lead.
Tests: prompt_injection_seed_test.go checks that a PromptOverride without
the block still renders the tail for every role, planner and compactor
included, that the block is not duplicated, and that the planner input
ends with the goal anchor. shipped_skills_test.go checks the bbot markers.
Stage the recon binaries in a build-platform "tools" stage and verify each
release archive against the checksums file of its own GitHub release (exact
asset name, sha256), failing the build when the entry is missing or does not
match: nuclei v3.11.1, httpx v1.12.0, subfinder v2.17.0, katana v1.8.0,
ffuf v2.3.0, dalfox v3.2.4. sqlmap is cloned at tag 1.9.9 and checked
against commit cceda39208b5aad478de2c3f731df4239d9881f6 before .git is
removed. The runtime stage copies /out/ and /opt/sqlmap from that stage.
Install Node v22.23.4 from the official nodejs.org tarball verified against
SHASUMS256.txt instead of piping the NodeSource setup script into bash.
Pin @playwright/mcp@0.0.83, @playwright/cli@0.1.22 and playwright@1.64.1
instead of @latest.
amd64 and arm64 are mapped explicitly; any other architecture fails the
build. dalfox --version no longer ignores failures.
Carries over the Dockerfile part of #127 by stevenAthompson.
ProxyAddr returns a URL that already carries a scheme (the recording
proxy and the validated global proxy), but httpProxyTransport wrote
"http://" before it, so use_recording_proxy produced http://http://...
proxyURLString now adds http:// only to a bare host:port, for both the
operator proxy and ProxyAddr. httpToolTransport falls back to a fresh
http.Transport when http.DefaultTransport is not an *http.Transport.
In a gated scope, the two HTTP clients the platform owns connect only to
addresses that a pin or a stored exemption covers at the moment of the
connection, on every hop.
- intercept.PinnedDialer(tool, scoped): the DialContext of both clients.
It reads the target scope from the context net/http passes to the dial.
Gate off, or no scope in a client built without one: plain dial, as
today. A client built for a scope whose scope is missing at the dial
fails closed with TARGET_GATE_UNAVAILABLE.
- dialTarget runs the gate decision on the dialled host:port with a fresh
lookup (block rules first, exemptions, pins, port precedence) and
connects only to the addresses it compared, never to the name. Derived
exemptions and name-only pins dial the address as received. An address
no pin covers finds or opens the request (new_ip / no_pin, wait 0) and
fails at once with TARGET_APPROVAL_PENDING. Redirect hops are dialled
against the hop's own (host, port).
- WebFetch (withWebFetch, now given the run context): DialContext set on
every agent kind; DisableDirectRetry when the run's scope is gated.
- Custom http tool: a direct call in a gated scope gets a DefaultTransport
clone with the pinned dialer, no environment proxy and keep-alive off.
Proxied calls are unchanged (the recording proxy is step 2.2).
- Name-only: request reason says the platform does not bind the
connection; the first-use pin_match row records the effective state.
- Derived exemptions: a proxy URL without a port is listed with the port
net/http dials (1080 for socks5) (OPEN-9).
- go.mod: Norma v0.4.8 (DialContext, DisableDirectRetry).
WebFetch and the custom http tool followed redirects with net/http's
default policy, so a pinned host could send the client to a target nobody
approved. RedirectCheck is now the CheckRedirect policy of both clients.
- Gate off, or no scope (operator test endpoint): only the 10-hop limit,
same error text; nothing is parsed, resolved, read or written.
- Gate on: each hop runs through the call's decision code (URL rules on
the hop URL, block rules before any lookup, exemptions, resolution,
pins). A hop no pin covers is not followed and never waits: its request
row is found or created and the call fails with TARGET_APPROVAL_PENDING.
http -> https asks again for port 443 unless a (host, 0) pin exists. An
unreadable hop is blocked with TARGET_GATE_UNAVAILABLE.
- Rows raised by a hop record redirect_from in the audit targets.
- Norma v0.4.7 has WebFetchConfig.CheckRedirect but no agentcore option,
so the four agent kinds build WebFetch themselves (same proxy, CA and
position in the tool list) instead of EnableWebFetch. Norma carries the
policy to the direct retry client.
Point the Norma replace at v0.4.7, which brings the thinking replay fix
(#4) and the WebFetch CheckRedirect hook (#3). go.sum updated by
go mod tidy. No ARTEX code changes.
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.
spawn_task now reads the target-gate scope of the calling session from the
tool context. When the caller is gated, the child task is created with the
gate on (D23) and with a copy of the calling task's block rules (D24). Pins
and allow rules are never copied, parent_ref is not consulted, and spawn_task
gets no new parameter. A gate-off caller, or a call with no scope, creates the
child exactly as before.
A conversation started by a trigger of a gated task is created with the gate
on (D25). A merged run is gated if any of its fires came from a gated task.
Pins are never copied. Fires of a gate-off task and fires with no task are
unchanged.
TestSpawnedTaskStartsWithGateOff becomes TestSpawnedTaskInheritsGateFromCaller.
New: TestSpawnTaskSeesCallerScopeThroughHarness and
TestTriggeredConversationInheritsGate.
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.
Storage, flag and request path of the approval gate for asset targets. The
gate itself is not wired into the tool hook yet (steps 1.5 to 1.7): with this
change nothing is extracted, resolved or blocked, whatever the flag says.
Schema and store (1.2)
- tasks.target_gate, conversations.target_gate (default false).
- intercept_pending.target_key and a partial unique index: one open request
per scope (task or conversation) and host:port.
- target_pins (no foreign keys: a pin outlives its task, its conversation and
its request row) and target_gate_exemption_log. All DDL is idempotent; no
CHECK constraints; the cascade on intercept_pending.conversation_id stays.
- db/target_pins.go: pin lookup with port precedence, list, revoke,
find-or-create of a request, and ResolveTargetRequest, which settles the
row, supersedes the old pin and writes the new version in one transaction.
- Deleting a task or an archive stub revokes its pins and settles its open
target requests in the delete transaction. DeleteConversation and
DeleteConversations revoke the pins of the removed conversations in the
same transaction as the delete.
Opt-in flag (1.3)
- Per task through the create and metadata-patch paths, per conversation
through create and patch; exposed in the task DTO. A retest conversation
takes the flag of the finding's task, never its pins. A spawned task starts
with the gate off.
- The task flag is atomic and read on every call through TargetScope.Enabled.
TargetScope now carries the Interceptor that owns the gate.
Request, wait, decide (1.4)
- pendingManager keeps a list of waiters per request; resolve wakes all.
- RequestTargets: find-or-create per target, one card per request, wait on
channel, poll, context and wait bound. A plain request is never settled by
a call that leaves. A request merged with an ask is bound to its creating
call and a timeout never allows it.
- Decide records the approver (subject of the session token) and the remote
address for every human decision, classic asks included. On a target
request "allowed" pins the displayed addresses; that is the only writer of
pins.
- A request raised while the global egress proxy is set says the approval is
name-only and its pin is stored with name_only_effective.
- Routes: GET/POST .../target-pins and .../target-pins/{pid}/revoke under
/api/tasks/{id} and /api/conversations/{id}; decision_source=target filter.
- ValidateExemptionHost: an exemption written as an IP literal may be private
unicast, never loopback, link-local or metadata, unspecified or multicast.
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.
An intent claim was atomic but had no heartbeat, no expiry and no owner
token: startup reset every running intent to open unconditionally, so a
second instance on the same database stole live claims, and the worker
that lost its intent could still write the result.
- schema: last_heartbeat_at and unclaim_token on exploration_nodes
(idempotent ADD COLUMN IF NOT EXISTS, nullable, no CHECK, no backfill).
- db: every claim (open->running and the paused->running message path)
gets a fresh crypto/rand token and starts its lease in the same UPDATE.
HeartbeatIntentClaim renews it for that token only.
- TTL: db.IntentClaimTTL = 90s, heartbeat every 15s
(db.IntentClaimHeartbeatInterval), both on the database clock.
- takeover: ResetRunningIntents now reopens only claims whose heartbeat
is older than the TTL, or NULL (rows claimed before the columns
existed). The engine runs it per task every 15s as well as at startup.
- fencing: a heartbeat or result write whose token is not the current
one returns db.ErrIntentClaimLost and writes nothing. All intent state
writes in runIntent go through the fenced SettleIntentClaim /
SettleIntentClaimBlocked; a rejected heartbeat cancels the stale run
with the new work_claim_lost cause.
Behaviour change: after a restart, intents that were in flight are
resumed once their claim expires (at most 105s) instead of immediately.
Not in this change: fencing of the facts, findings and activity a worker
writes during its run; they are not guarded by the token.