fix(code-execution): every call a script makes faces its own permission decision (QA F7) - #246
Merged
Merged
Conversation
…ermission decision (F7, fail-before) QA finding F7 (composer-driven run on 7c96d79): in Manual mode, with `code_execution__execute_code` on the user's always-allow list, a script's `developer__shell` and `developer__analyze` calls ran with no approval card. The dispatched tool was `execute_code`, which was allowed; the calls inside the script were never judged. Four tests through the agent's real `dispatch_tool_call`, the real `developer` and `code_execution` extensions and a private permission table. Against this commit (no fix yet): manual_mode_asks_for_a_scripts_shell_call_under_the_inner_tools_name FAILED "ran with no approval card ... Result: \"SCRIPT-GATE-ALLOWED\n\"" a_denied_card_is_a_catchable_tool_error_and_the_script_goes_on FAILED "ran with no approval card: Result: { caught: null, continued: true }" always_deny_on_the_inner_tool_refuses_it_and_the_script_continues FAILED "must come back into the script as a tool error: { caught: null, ... }" auto_mode_runs_a_scripts_shell_call_with_no_card ok
…s (F7) QA finding F7: with `code_execution__execute_code` always-allowed, a script's inner calls ran with no card in Manual mode, because the only call the permission system judged was the one the agent loop dispatched. The script's own calls went straight from the JS sandbox to `ExtensionManager::dispatch_tool_call`, where no inspector runs. Now each call a script makes faces the decision a direct call would: - `Agent::dispatch_tool_call` builds a `ScriptCallGate` (the agent's own inspector stack, mode, session and hooks) for an `execute_code` call and runs the tool's BODY inside a task-local scope; `execute_code` reads it and hands it to the task that dispatches the script's calls. - Every call is inspected on its evaluated arguments by the same inspectors (repetition excluded: a script's loop is not the model repeating itself), on the capability `execute_code` was admitted on (threaded, never resampled). PreToolUse rewrites are applied and re-judged; staged hook context is dropped because a running script has no channel for it (the bridge's reasoning). - The permission inspector grades a script's call from the script's own catalogue (`inspect_graded`), because the agent's registry is graded from the model's roster, which in Code Execution mode holds none of these tools. - A denial throws into the script as a tool error it can catch; an ask parks the same card a direct call gets, naming the inner tool and its arguments, after PermissionRequest hooks; Always allow / Always deny are recorded under the inner tool's name. - The uninspected-boundary refusals still run first, so nothing they refuse can become a card; `execute_code` itself is judged exactly as before. - `no_human_surface` is carried into the spawned sub-call task, so an ask in a scheduled run is refused at once instead of parking for its TTL. `handle_denied_tools`' inline text match moves to `denied_response_text` so a refusal reads the same for a direct call and a script's call. Tests: the four F7 tests from the previous commit now pass, plus Smart-mode catalogue grading, always-allow-by-inner-name, Always Allow recorded under the inner name, nobody-to-ask refused at once, and a boundary refusal never becoming a card.
… (F7) Code Execution guide: replace the warning that implied per-tool controls already reached inside scripts with a section on how a script is judged twice - the script itself, then every call it makes under that tool's own name - with the table of what each mode, allow/deny entry and card answer does, what an always-allow entry for execute_code now means (and that it is never a shipped default), and the one route (POST /agent/call_tool) where a person drives the script and its calls run as before. Permission modes: a short section saying the same, linked to the table.
…he boundary refusals (F7) Self-review of the previous commit: the uninspected-boundary refusals (global memory store, transcript database, knowledge delete, first tier crossing) ran on the script's own arguments, and a PreToolUse hook may rewrite them. The inspectors re-judge a rewrite, but those only ASK about some of these shapes, where the boundary refuses outright - so a rewrite was a way to turn a boundary refusal into a card. When the judged arguments differ from the script's, the boundary now runs again on what will actually be dispatched. Pre-dispatch checks move into one admit_sub_call step with one refusal branch in run_tool_handler (which also keeps it under the too_many_lines baseline), and the boundary docs now say the inspector stack does reach a script's calls when the agent loop dispatched the script. Tests: a_hook_rewrite_cannot_carry_a_scripts_call_past_the_boundary_refusals (fails with the re-check disabled: the rewritten 'cat <global memory store>' was admitted), plus gate-level tests that a rewrite is what runs and is judged again (a rewritten 'rm -rf /' is refused despite an always-allow for developer__shell).
…ll's does (F7)
Measured in the running app: the provenance line the gate added to every
script ask ('This call was made by a Code Execution script...') was read
by the desktop as a security finding. ToolCallConfirmation draws any
prompt as a warning banner and withholds Always Allow, so an ordinary
Manual-mode ask for a script's shell call could only be answered once per
call - a loop of twenty meant twenty cards.
The card now carries exactly what a direct call's does: the inspectors'
reasons when one raised the ask, and no prompt otherwise. The script's
step row in the transcript already shows what the card belongs to. The
Manual-mode test now pins prompt == None for an ordinary ask, and the
docs table says the card and its buttons are the direct call's.
… (F7) a_hook_rewrite_cannot_carry_a_scripts_call_past_the_boundary_refusals failed once in a filtered run: it resolves the global memory store at the start of the test and the boundary resolves it again later, both through the process-global BIOROUTER_PATH_ROOT, and a guarded writer in another test landed between the two reads. The agent-level boundary test has the same shape. Both now hold env_lock's single global mutex for the whole test while writing nothing. Pinning the variable to its 'current' value (pinned_store_root's shape) was tried first and rejected: that value is read outside the lock, so it can capture another holder's transient root and republish it to every unguarded reader while the test runs. Measured after: the widened filter (script_call_gate code_execution permission inspector tool_inspection global_memory) 20/20 clean, and the full lib binary 3/3 at 3807 passed.
… (F7) Three of the gate's agent-path tests put a filesystem path inside a double-quoted JavaScript string. On Windows the path's backslashes are escape sequences there (\r becomes a carriage return, \U drops its backslash), so the script would name a different path from the one the test means. For a_boundary_refusal_stays_a_refusal_and_never_becomes_a_card that is a real failure, not a cosmetic one: the mangled path no longer names the global memory store, the boundary does not fire, and Manual mode raises a card the test forbids. The two deny tests only passed by luck. The commands are now spliced in as JSON string literals, which are valid JavaScript literals with every backslash escaped.
…in Auto mode (F7)
The brief names the sensitive-operations inspectors as part of the
decision a script's call must face, and no test pinned it. In Auto mode a
script's 'echo probe > /etc/...' must still raise a card, carrying the
sensitive-ops reason, and a Deny must come back into the script.
Fail-before (gate installation disabled in Agent::dispatch_tool_call):
the write actually ran - and failed harmlessly on permissions - with no
card: 'a sensitive write inside a script must ask even in Auto mode:
Result: { caught: "...[shell: command exited with status 1]" }'. The
same toggle fails the Manual-mode test too, which is what shows the
agent-path tests measure the real installation point rather than a
stand-in. Non-Windows, like sensitive_ops' own POSIX-fixture tests.
Two conflicts, both resolved keeping main's behaviour and this branch's structure: - agents/mod.rs: main made `schedule_tool` pub(crate) (F1's shared platform_approval); this branch adds `script_call_gate` beside it. Both kept. - agents/agent.rs `handle_denied_tools`: main (F4) added a `workspace_mutation` arm to the inline denial-text match; this branch had moved that match into `tool_execution::denied_response_text`, shared with the Code Execution script gate. The F4 arm is ported into the shared function, so a script's refused `workspace_set_tools` now reads the same sentence as a direct one. `DECLINED_RESPONSE` is re-imported for tests only (main's F4 test asserts against it). Checked against main's F4 WorkspaceMutationInspector: it honours a passed capability (`sampled = capability`) and samples only on None, so the gate's threaded CallCapability still reaches it with no second read.
…njudged `script_call_gate::current()` was `try_with(..).ok()`, so "no scope on this task" — which is what a `tokio::spawn` anywhere between `Agent::dispatch_tool_call` and `handle_execute_code` produces — collapsed into the same `None` a person-driven dispatch gives, and `judged_arguments` read that `None` as "nothing to judge". The whole control therefore rested on the shape of the call graph, and its failure mode was silent and permissive: the opposite polarity from `unjudged()` in the same module, whose stated principle is that no decision is not a yes. An absent gate is two situations. A person running a script through `POST /agent/call_tool`, an Agent Drafter app or the coding-agent bridge is benign — no agent loop dispatched it, every inspector was bypassed for the outer call too, and there is no model decision to gate. The agent loop's own dispatch arriving without its judge is a defect that would run every call inside the script past the permission system. Tell them apart with a record a spawn cannot lose: `DispatchedByAgentLoop`, a process-global count of the sessions the agent loop has an `execute_code` body in flight for, taken by `judging_script_calls` itself off the gate's own session, so the record and the scope it vouches for are created and released by one expression. `judge_for` is now the single place an absence is given a meaning: gate absent plus that record present is `JudgeLost`, and `handle_execute_code` refuses the whole script before dispatching a sub-call, loudly (a tool error the model sees plus `counter.biorouter.script_call_judge_lost`). `current()` returns `Result<_, NoGateOnThisTask>` so the access error is a named state rather than an absence, and `judged_arguments`' `None` is documented as a verdict already reached rather than a polarity to re-decide. Inverting the polarity outright was measured and rejected: `absent => refuse` would refuse `POST /agent/call_tool`, `routes/apps.rs:10274`, `coding_agent/bridge.rs:123` and ~15 integration-test dispatch sites, all legitimately ungated. The chosen shape errs the other way — its one false positive is a person dispatching a script for a session already inside one, and that gets a loud refusal rather than a silent grant. No change to `agents/agent.rs`.
…tch permit back `tool_dispatch_limits`' design note states the premise the whole lock ordering rests on: "a running tool only ever holds resources and runs to completion; it never waits on a resource a parked tool holds." F7 broke it. `execute_code` matches neither `is_spawn_tool_call` nor `is_parking_workspace_tool`, so it takes one of the eight permits in the process-global `TOOL_SEMAPHORE` — shared by every session in the daemon — and holds it across `judging_script_calls`, which now contains every approval card a script's sub-call parks on, for `approval_ttl()`: 3600 s by default, and `Duration::MAX` when `BIOROUTER_CONFIRMATION_TIMEOUT_SECS` is 0. Eight scripts parked on cards stall every other tool call in the process, including the user's own foreground conversation. Starvation rather than deadlock — answering a card takes no permit, and a script's sub-calls bypass the semaphore — but it is exactly the hazard the two workspace exemptions exist for. Adding `execute_code` to that list is the wrong fix. Those two are do-nothing wrappers, so exempting them widens no concurrency; `execute_code` does real work, and in the shipped Code Execution default it is very nearly the only tool the model can call, so an exemption would leave this semaphore bounding nothing at all. The permit is instead handed back for exactly the parked interval: `ToolDispatchGuard` keeps it in a cell, `parking_handle()` hands out a `Weak` reference to that cell, and `DispatchPermitHandle::while_parked` releases it, awaits, and queues for one again before the tool resumes. Waiting for it back starves nobody — the call holds nothing while it waits, which is the whole difference from the situation it replaces — and a cancelled park simply leaves it released. `ScriptCallGate` takes the handle from `Agent::dispatch_tool_call` (the permit is acquired inside the tool body, below where the gate is built) and wraps its `parked.wait(..)` in it. Everything else keeps the permit for its whole execution, unchanged.
…arks Two doc corrections the two review fixes make necessary. `workspace-control-tools.md` read "Two tools park, and are exempt from the dispatch permit". `execute_code` parks too now, and is deliberately not on that list — so the line was sending the next reader to the wrong mechanism. It now names both shapes and says a new parking tool has to choose between them. `code-execution.md` gains the limitation the review turned up and this PR cannot close without changing the approval protocol: a script call's card names the tool and shows the arguments exactly as a direct call's does, and says nothing about the script, so "Always allow" there records a grant for that tool everywhere from a card that did not say where the call came from. The alternative lever — a `prompt` — is also what draws the security banner and hides "Always allow", which this PR already measured and reverted.
This was referenced Sep 12, 2026
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
`./scripts/clippy-lint.sh` fails on `main` today, and this tree added a third violation to the two already there. All three are extractions, not rewrites: no statement moved relative to another, and each helper keeps the comments that say why its guards exist. - `commands/agent.rs` `run` (101/100) — the block this tree grew. The proof-of-user read, the level of its report and SD-12's launch-state pin move into `install_user_action_proof`, which keeps them beside the read they all depend on. The pin still lands before `AppState::new()` and before any route is mounted, because the one call site is where the inline block was. - `agents/code_execution_extension.rs` `handle_execute_code` (102/100, from #246) — the `_meta` assembly moves into `collected_meta`. Assembly only; every key there has a reader in the desktop artifact panel. - `session/session_manager.rs` `backfill_privacy_from_recorded_provenance` (105/100) — the shape guards move into `prepare_backfill_shape`, which answers `None` for "skip the backfill" and `Some(turn_ledger)" for "run it, and here is whether the second evidence source is readable". Each guard in it is a failed startup that happened; the comments saying so travel with them. `./scripts/clippy-lint.sh`: all baseline checks pass. `cargo fmt --check` clean. `cargo check --workspace --all-targets` clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Code Execution: every tool call a script makes now faces its own permission decision (QA F7)
QA finding F7 (composer-driven run on merged
main7c96d79,qa-f/report.mdlines 813-824): in Manual mode, withcode_execution__execute_codeonalways_allow, a script'secho APPROVAL-PROBE-9901ran with no card, and so diddeveloper.analyze, which was on no allow list. In Code Execution mode, which is the shipped default, the model can call 18 tools directly and reaches the other 76 only throughexecute_code. The permission system judged the one call the agent loop dispatched,execute_code. The script's own calls went from the JS sandbox straight toExtensionManager::dispatch_tool_call, where no inspector runs. One approval of a script, or onealways_allowentry for it, therefore covered every call the script made, in every mode.Shipped default or the operator's choice? The operator's choice.
PermissionManager::newstarts from an empty map whenpermission.yamlis absent (config/permission.rs:43-62). No seeding code exists in the repo: nothing in the server, CLI, desktop or scripts writes a defaultalways_allow.tool_execution.rs,update_permission_manager) and Settings → Permissions (POST /config/permissions,PermissionModal.tsx).~/biorouter-runs/seed-config/config/permission.yamlis a copy of the operator's own~/.config/biorouter/permission.yaml, which is byte-identical and dated Aug 24. Its entries are in click order (execute_code, shell, text_editor, read_module, search_modules), which is the orderupdate_permissionappends them.The granularity collapse itself was not something the sandbox caused. It affected every configuration: approving one script once had the same effect as the
always_allowentry.What changed
execute_codeitself is judged exactly as before, so nothing that asked before stops asking. The new part is that each call a script makes is judged as the same call made directly would be, under that tool's own name:agent.rs(Agent::dispatch_tool_call): for anexecute_codecall, in both name forms, this builds aScriptCallGatefrom the agent's ownToolInspectionManager(the sameArc), itsbiorouter_mode, the session and the hooks manager. It runs the tool's body, meaning the future it returns, inside a task-local scope (judging_script_calls). Wrapping the dispatch alone would have missed the body; see memory scope-the-run-not-the-dispatch.code_execution_extension.rs:execute_codereads the gate on the scoped task and hands it to the spawned sub-call handler. The spawn does not carry the task-local, so it is passed explicitly. Each call then goes throughadmit_sub_call, in order:kb_delete_base, first tier crossing), which are unchanged and still run first, so nothing they refuse can become a card;A refusal is thrown into the script as the usual attributed tool error (
Tool error from developer__shell: …). A script can catch it and carry on. If it does not, the error names the tool.script_call_gate.rs(new): the judge.execute_codewas admitted on: it is threaded, never resampled, and there is no second read of the privacy flag.process_inspection_results_with_permission_inspector. No decision at all counts as a refusal.allow, as inhandle_approval_tool_requests. Then aPendingUserActionscard is parked on the session. It names the inner tool and carries its arguments, risk grade and preview, and waits for the same TTL as a direct card. Always Allow and Always Deny are recorded under the inner tool's name.permission_inspector.rs/tool_inspection.rs:inspect_gradedandinspect_script_callslet a script's call be risk-graded from the script's own catalogue. The agent's registry is graded from the model's collapsed roster and has never seen these tools, so Smart mode would have confirmed a read-onlychatrecallas if it were a shell. The direct path now callsinspect_graded(…, &self.risks), so the two paths cannot drift. The agent's own registry is untouched: widening it would have changed the grade of off-roster direct calls in Smart mode, and some that ask today would have stopped asking.tool_execution.rs: the inline denial-text match inhandle_denied_toolsbecomesdenied_response_text, shared with the gate. This is a pure move, so a refusal reads the same whether the call came directly or from a script.no_human_surfacenow reaches the sub-call task. Before this, the spawn escaped the scheduler'swithout_human_surfacescope. A script call that parked, whether through this gate or an existing proof-backed approval such asinstallMarketplaceSkill, would have registered a card nobody could answer and blocked until its TTL. It is now refused at once, as every other ask in that run is.Deliberately different from a direct call (also in the module header):
Semantics, as documented
always_allowforcode_execution__execute_codenow means "don't ask me before running a script" and nothing more. It is not a grant for the tools the script calls. It stays allowed, following user-warns-agent-never: the script runs in a sandbox with no file, process or network access of its own, and every call it makes is now decided separately. Docs:docs/extensions/built-in/code-execution.md(new section and table) anddocs/security/permission-modes.md(new section).In Manual mode a script can now ask more than once: once to run at all (unless always-allowed), then once per call your settings do not already allow. Clicking Always Allow on a card for a tool used in a loop stops the rest of the loop asking.
One thing was measured and fixed during the runtime check. A provenance line on the card ("This call was made by a Code Execution script…") made the desktop treat every script ask as a security finding:
ToolCallConfirmation.tsxdraws anypromptas a warning and withholds Always Allow. The card now carries exactly what a direct call's card does, which is the inspectors' reasons or nothing (commit02c698dd). The test pinsprompt == Nonefor an ordinary ask.Tests
Fail-before. Commit
b9915a0eholds the tests only, run against the unfixed 7c96d79 code:a_hook_rewrite_cannot_carry_a_scripts_call_past_the_boundary_refusalsfails with the post-rewrite re-check disabled. The rewrittencat '<global memory store>/probe.txt'was admitted.auto_mode_still_asks_for_a_scripts_sensitive_write(non-Windows) fails with the gate installation disabled inAgent::dispatch_tool_call. The script'secho probe > /etc/…actually ran, with no card, and failed only because a user cannot write/etc:Result: { caught: "…[shell: command exited with status 1]" }. The same toggle fails the Manual-mode test as well. That shows the agent-path tests exercise the real installation point, not a stand-in for it.After. All of these go through the real
Agent::dispatch_tool_call, the realdeveloperandcode_executionextensions, and a private permission table.developer__shellcarrying the command. Allow runs it.never_allowondeveloper__shell: no card, a tool error, and the next call (text_editor) still runs.> /etc/…): a card carrying the sensitive-ops reason, and Deny comes back into the script.developer__shell, the script's second call runs with no card, and theexecute_codeentry is untouched.chatrecallpasses on its catalogue grade, andtodo_writeasks.without_human_surface): refused at once, with no card published.rm -rf /is refused despite always-allow for shell).execute_codeget a judge.cargo test -p biorouter --lib -- script_call_gate code_execution permission inspector tool_inspectionagents::code_execution_extension106, which includes the export-collision / GC-cycle forwarder tests (a_colliding_namespace_still_serialises_as_json,every_import_form_yields_a_callable_tool_when_the_server_shares_its_name);agents::workspace_inspector24;permission::permission_scope23;permission::tool_risk13;permission::permission_store13;agents::script_call_gate13 (12 at the time of that count, plus the sensitive-ops test added later);tool_inspection::tests11; and small groups elsewhere.cargo test -p biorouter --lib privacy::cargo test -p biorouter --libglobal_memory), loopedcode_execution_integration42,chatrecall_code_execution25,subagent_delegation8,global_memory_dispatch_boundary5,privacy_capability4,privacy_toggle4,nested_shell_cancellation3,session_store_dispatch_boundary3,workflow_capture_parity2,code_execution_module_invariants1,privacy_disclosure_toggle1subagent_delegation.cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warningstoo_many_linesbaselineOne flake I hit and fixed.
a_hook_rewrite_cannot_carry…failed once. It resolves the global memory store twice, through the process-globalBIOROUTER_PATH_ROOT, and a guarded writer in another test landed between the two reads. Both global-store tests now holdenv_lock's single global mutex while writing nothing (commit9f203b70). I first tried pinning the variable to its current value, the waypinned_store_rootdoes, and rejected it: that value is read outside the lock, so it can capture another holder's transient root and republish it to unguarded readers.Windows portability, found by reading the tests rather than by CI. Three agent-path tests used to put a filesystem path inside a double-quoted JS string. On Windows, backslashes there are escape sequences, so for the boundary test the mangled path would no longer name the store, and a card would appear. The commands are now spliced in as JSON string literals (commit
6e456618).A separate, pre-existing flake of the same family showed up once in 15 widened runs:
security::session_store::tests::the_boundary_and_the_inspector_say_exactly_the_same_thing. It re-readsPaths::data_dir()with no lock, and this PR does not touch that code.Every cargo test ran with
BIOROUTER_DISABLE_KEYRING=true.Runtime: my own sandboxed instance, not the QA ones
Setup:
f7-script-gateon CDP 9391, running this branch'starget/debug/biorouterd(seam build). The model wasversa_azure/gpt-5.5-2026-04-24.permission.yamlalways-allowedcode_execution__execute_code(the QA condition) but notdeveloper__shellordeveloper__text_editor. I switched Settings → Chat → Mode → Manual (configauto → approve) and started a new chat.1. "Run the shell command echo APPROVAL-PROBE-F7 and show me its exact output." The model called
code_execution__execute_codewithimport { shell } from "developer"; const result = shell({ command: "echo APPROVAL-PROBE-F7" }); record_result(result);. The card, read from the DOM under the script step "Working on Run the requested echo command":→ Deny. The card resolved to "Shell is denied", the step shows "Tool call failed", and the model replied:
The session store confirms the command never ran. The script saw
Error: Module error: Error: Tool error from developer__shell: The user has declined to run this tool. DO NOT attempt to call this tool again…, and its executed-calls record reads{"tool":"developer__shell","args":"{\"command\":\"echo APPROVAL-PROBE-F7\"}","status":"error","error":"Not run: you declined it"}.2. "Now use the developer analyze tool on the directory …/probe-dir and report exactly what it returns."
→ Allow Once. The card resolved to "Analyze is allowed once", and the model reported the tool's output:
The record reads
{"tool":"developer__analyze",…,"status":"ok","result_bytes":163}, andpermission.yamlwas unchanged, because Allow Once records nothing.3. I restored Autonomous (config back to
auto) and opened a new chat withecho AUTO-PROBE-F7. There was no card, the output wasAUTO-PROBE-F7, and the record readsdeveloper__shell · ok.Afterwards I stopped the instance and closed the browser session. The QA instances (CDP 9371-9376) were never touched, and the one orphaned forge Electron my own
startleft behind was identified by cwd and sandbox and killed by explicit PID.Known residuals, deliberately not in this PR
kb_delete_baseis still refused rather than put on a card, even though the agent path can now ask. Turning those refusals into asks is a product decision, not a side effect of this fix.execute_coderesolves{{vault:…}}into the script text before it runs, so a script's inner calls carry plaintext that its inspectors, hooks and approval card now see. That is the local user's own automation seeing the local user's own secret, which the script could alreadyrecord_result. It is unchanged in kind, but worth a follow-up.🤖 Generated with Claude Code
Update: merged with
main6455bc2 (commita594c209)There were two conflicts, and both resolutions keep
main's behaviour:agents/mod.rs: keptpub(crate) mod schedule_toolfrommain, withscript_call_gatebeside it.agent.rshandle_denied_tools:main's F4workspace_mutationarm is ported into the sharedtool_execution::denied_response_text, andmain's F4 test passes unchanged.The F4
WorkspaceMutationInspectoruses any capability it is handed (sampled = capability), so the threadedCallCapabilitystill reaches it.Results on the merged tree:
-- code_execution permission inspector script_call_gate: 231/0privacy::: 241/0subagent_delegation: 8/8, no stack overflowTwo failures are pre-existing on
main. The files involved are identical tomainin this PR:tests/privacy_capability.rscensus:bridge.rsnow has 2public_enforced(sites (2f61d274, QA-E F4) andworkspace_inspector.rshas 2sample(sites (PR fix(workspace): report an injected turn's verdict, and pre-flight set_tools before its approval card (F3, F4) #244 F4 pre-flight), against 1 expected in each.too_many_linesbaseline: a new entry,workspace_extension.rs:3406(frome60213c8).Update: two findings from an adversarial security review of this PR (both MEDIUM)
Both were raised against this branch, both are fixed here, one commit each, appended —
no history rewritten. Neither was exploitable as shipped; both are durability defects in
a permission control, where the failure mode was silent.
Finding 1 — the gate failed open when its task-local was absent
script_call_gate::current()wasSCRIPT_CALL_GATE.try_with(Arc::clone).ok(), soAccessError— "no scope on this task", which is what atokio::spawnanywhere betweenAgent::dispatch_tool_callandhandle_execute_codeproduces — collapsed into the sameNonea person-driven dispatch gives, andjudged_argumentsread thatNoneasOk(arguments): no permission decision at all, the opposite polarity fromunjudged()three functions away, whose stated principle is that no decision is not a yes. The safety
property rested entirely on the shape of the call graph.
Re-verified the review's "not exploitable as written", independently:
judging_script_callswraps the tool body (so spawning the returned future, which the tests do, is safe), the
one
tokio::spawnin the body is fed the gate by value after reading it, and thespawn_blockingruns the JS engine, which never reads the task-local.Fix. An absent gate is two situations and they are now told apart:
ScriptJudging::By(gate)ScriptJudging::PersonDrivenPOST /agent/call_tool, an Agent Drafter app, the coding-agent bridge (ownBridgeGrant). UnchangedScriptJudging::JudgeLostcurrent()returnsResult<_, NoGateOnThisTask>, andjudge_for(session_id)is now thesingle place an absence is given a meaning. The disambiguator is
DispatchedByAgentLoop: aprocess-global count of the sessions the agent loop currently has an
execute_codebody inflight for. A
tokio::spawnloses the task-local; it cannot lose this. It is taken byjudging_script_callsitself, off the gate's own session, so the record and the scope itvouches for are created and released by one expression and cannot be wired up one without
the other — which is why this finding needed no change to
agent.rsat all.On
JudgeLostthe handler returns a refusal before dispatching a single sub-call, plustracing::error!(counter.biorouter.script_call_judge_lost = 1). The refusal says it is adefect rather than a permission the user could grant.
Rejected: inverting the polarity outright ("absent ⇒ refuse"). Measured, that refuses
routes/agent.rs:2794(POST /agent/call_tool),routes/apps.rs:10274,providers/coding_agent/bridge.rs:123and ~15 integration-test dispatch sites, all of whichlegitimately have no gate. The chosen shape errs the other way: its one false positive is a
person dispatching a script for a session whose own turn is already inside one, and that gets
a loud refusal rather than a silent grant.
Fail-before evidence.
a_script_whose_judge_was_lost_is_refused_and_a_person_driven_one_still_runsdrives allthree states through
ExtensionManager::dispatch_tool_call(the only door that reaches thehandler with no scope): no record ⇒ the script runs and
echo SCRIPT-GATE-PERSON-DRIVENreaches the output; record held ⇒
is_error, the text names the missing permission judge,SCRIPT-GATE-UNJUDGEDnever appears and no card is published; record dropped ⇒ benignagain, so the refusal is not sticky. Phase 2 is the one that fails before the fix — the
script runs and the sentinel appears.
the_agent_loop_record_lives_exactly_as_long_as_the_scopepins the RAII and the nesting.Finding 2 —
execute_codecould park on a human while holding a process-global permittool_dispatch_limits' design note states the premise its whole lock ordering rests on:"a running tool only ever holds resources and runs to completion; it never waits on a
resource a parked tool holds." This PR broke it.
execute_codematches neitheris_spawn_tool_callnoris_parking_workspace_tool, so it takes one of the eight permits inthe process-global
TOOL_SEMAPHORE— shared by every session in the daemon — and holds itacross
judging_script_calls, which now contains every approval card a script's sub-callparks on, for
approval_ttl(): 3600 s by default andDuration::MAXwhenBIOROUTER_CONFIRMATION_TIMEOUT_SECS=0. Starvation, not deadlock (answering a card takes nopermit, and a script's sub-calls bypass the semaphore) — but eight scripts parked on cards
stall every other tool call in the process, the user's own foreground conversation included.
Before this PR
execute_codecould not park at all.Shape chosen: release for the parked interval, not a name-based exemption — and here is why.
The two tools on
is_parking_workspace_toolare do-nothing wrappers, so exempting them widensno concurrency.
execute_codedoes real work, and with the Code Execution capability on — theshipped default — it is very nearly the only tool the model calls, so adding it to that list
would leave this semaphore bounding nothing at all in the default mode. That would delete BR-58
rather than fix its premise.
So the permit is handed back for exactly the parked interval and queued for again afterwards:
ToolDispatchGuardkeeps its permit in a cell and remembers the semaphore it came from.ToolDispatchGuard::parking_handle() -> DispatchPermitHandlehands out aWeakreference to that cell, so a handle outliving its dispatch cannot keep a permit alive.
DispatchPermitHandle::while_parked(fut)takes the permit out, drops it, awaits, thenqueues for one again before the tool resumes. Waiting there starves nobody — the call
holds nothing while it waits, which is the whole difference from the situation it replaces.
A cancelled park simply leaves the permit released.
ScriptCallGatetakes the handle fromAgent::dispatch_tool_call(the permit is acquiredinside the tool body, below where the gate is built) and wraps only its
parked.wait(approval_ttl(), ..)in it. Everything else keeps its permit for its wholeexecution, unchanged. The path locks are not released —
execute_codetakes none.Fail-before evidence.
a_parked_scripts_ask_holds_no_global_dispatch_permitfills the real ceiling to exactly onefree permit, runs a Manual-mode script whose
developer__shellcall parks on a card, thenasserts an unrelated dispatch can still
acquirewithin 10 s. Before the fix thatacquisition never completes and the test fails on that assertion; after it, it acquires, and
answering the card still runs the call (
SCRIPT-GATE-PARKEDin the output), which exercisesthe re-acquire path too. Two exact unit tests against a private one-permit semaphore —
a_parked_tool_hands_its_permit_back_and_queues_for_it_againanda_handle_that_outlived_its_dispatch_is_inert— cover the mechanism without racing the restof the binary.
agent.rslines touched (for the PR #250 merge)Only two places, both additive:
1989-1996— eight comment lines appended tois_parking_workspace_tool's doc commentsaying why
execute_codeis deliberately not on that list. No code.7516-7526— eleven lines inside the existingSome(gate) =>arm of thelet inner_result = match script_gateexpression: thegate.hold_dispatch_permit(..)calland its comment. The arm's existing
judging_script_calls(gate, inner).awaitis unchanged.Finding 1 touched
agent.rsnot at all.The card still cannot say a call came from a script (LOW, from the same review)
Checked, and it cannot without changing the approval protocol, so it is recorded here as a
known limitation rather than left unstated.
ToolApprovalRequestcarries{tool_name, arguments, prompt, risk, preview, requires_user_proof}andToolCallConfirmation.tsxdestructures exactly{id, toolName, prompt, risk, preview}. Everyfield is spoken for:
promptis drawn as a warning banner and, at line 284,{!prompt && <Always Allow>}— itis the one lever that hides "Always allow". Using it for provenance turns every ordinary
script ask into an alarm; this PR already measured that regression in the running app and
reverted it.
riskis a typed badge;previewis a closed typed union with no free-text member;tool_nameis display-only but doctoring it is ruled out by this PR's own test that the cardnames the inner tool.
Provenance therefore needs a new optional field on
ToolApprovalRequestandActionRequiredData::ToolConfirmation, a regenerated OpenAPI spec and TS client, and a renderertreatment deliberately distinct from
promptso it does not suppress "Always allow". Out ofscope here. The consequence is documented for users in
docs/extensions/built-in/code-execution.md: on a script's card, Always allow records agrant for that tool everywhere, from a card that did not say where the call came from — use
Allow Once, or Settings → Permissions, if that is more than you meant. Withholding the
button is not available either, since
promptis its only gate.docs/agent-loop/workspace-control-tools.mdpreviously read "Two tools park, and are exemptfrom the dispatch permit"; it now says that list is not the list of everything that parks, and
names the two shapes a new parking tool must choose between.
Verification
All on
6a6748d1,BIOROUTER_DISABLE_KEYRING=true, in the worktree.cargo test -p biorouter --lib -- script_call_gate code_execution agents::agent tool_dispatch_limitscargo test -p biorouter --test subagent_delegationreply_internalgenerator gained no frames)cargo test -p biorouter --test privacy_capabilitycargo test -p biorouter --test privacy_guard_wiringcargo fmt --all && cargo fmt --all --checkNeither census needed a row: the needles are
CallCapability::sample(,CallCapability::public_enforced(,affiliation::gate_cross_affiliation,affiliation::refusing_mismatchandcrate::privacy_toggle::privacy_tiers_enabled(), andthese commits add none of them to
crates/*/src/. The new tests useCallCapability::for_test_restricted(), which the census excludes by design and whichscript_call_gate.rsalready spelled.Fail-before evidence (mandatory, and measured). Both production behaviours were reverted
—
judge_forback to the pre-fix polarity (absent gate ⇒ alwaysPersonDriven) andask_a_personback to a barewait.await— with the tests untouched. Exactly the two newhazard tests failed and nothing else did:
The second panic is the defect verbatim: pre-fix the script's
developer__shellcall ranand its output came back into the script. The two
tool_dispatch_limitsmechanism tests passeither way, as they should — they test
while_parkeddirectly, not the hazard.Not merged deliberately. This is security-sensitive permission code and the PR already
asks for a human review; these two commits do not change that.