Skip to content

fix(code-execution): every call a script makes faces its own permission decision (QA F7) - #246

Merged
Broccolito merged 14 commits into
mainfrom
fix/f7-script-call-permissions
Sep 12, 2026
Merged

fix(code-execution): every call a script makes faces its own permission decision (QA F7)#246
Broccolito merged 14 commits into
mainfrom
fix/f7-script-call-permissions

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Code Execution: every tool call a script makes now faces its own permission decision (QA F7)

QA finding F7 (composer-driven run on merged main 7c96d79, qa-f/report.md lines 813-824): in Manual mode, with code_execution__execute_code on always_allow, a script's echo APPROVAL-PROBE-9901 ran with no card, and so did developer.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 through execute_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 to ExtensionManager::dispatch_tool_call, where no inspector runs. One approval of a script, or one always_allow entry for it, therefore covered every call the script made, in every mode.

Shipped default or the operator's choice? The operator's choice.

  • PermissionManager::new starts from an empty map when permission.yaml is absent (config/permission.rs:43-62). No seeding code exists in the repo: nothing in the server, CLI, desktop or scripts writes a default always_allow.
  • Only two things write that list: an Always Allow click on a card (tool_execution.rs, update_permission_manager) and Settings → Permissions (POST /config/permissions, PermissionModal.tsx).
  • The QA sandbox file ~/biorouter-runs/seed-config/config/permission.yaml is 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 order update_permission appends 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_allow entry.

What changed

execute_code itself 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 an execute_code call, in both name forms, this builds a ScriptCallGate from the agent's own ToolInspectionManager (the same Arc), its biorouter_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_code reads 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 through admit_sub_call, in order:
    1. the existing uninspected-boundary refusals (global memory store, transcript DB, kb_delete_base, first tier crossing), which are unchanged and still run first, so nothing they refuse can become a card;
    2. the gate;
    3. the boundary refusals again, on the arguments that will actually be dispatched, if a PreToolUse hook rewrote them.
      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.
    • Every inspector the loop runs sees the call's evaluated arguments. The capability is the one execute_code was admitted on: it is threaded, never resampled, and there is no second read of the privacy flag.
    • PreToolUse rewrites are applied and judged again.
    • The permission verdict comes from process_inspection_results_with_permission_inspector. No decision at all counts as a refusal.
    • For an ask, PermissionRequest hooks are consulted first. Non-delegable asks ignore a hook's allow, as in handle_approval_tool_requests. Then a PendingUserActions card 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_graded and inspect_script_calls let 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-only chatrecall as if it were a shell. The direct path now calls inspect_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 in handle_denied_tools becomes denied_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_surface now reaches the sub-call task. Before this, the spawn escaped the scheduler's without_human_surface scope. A script call that parked, whether through this gate or an existing proof-backed approval such as installMarketplaceSkill, 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):

  • The repetition loop guard is skipped. A loop inside a script is not the model repeating itself.
  • The call is judged without conversation history. Only sensitive-ops' criterion-5 provenance reads history, and only to exempt things, so this errs toward asking.
  • Approvals are not delegated to an ancestor agent. A script's ask always goes to a person.
  • Hook context is dropped rather than injected, using the bridge's reasoning: a running script has no channel to receive it.

Semantics, as documented

always_allow for code_execution__execute_code now 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) and docs/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.tsx draws any prompt as 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 (commit 02c698dd). The test pins prompt == None for an ordinary ask.

Tests

Fail-before. Commit b9915a0e holds the tests only, run against the unfixed 7c96d79 code:

manual_mode_asks_for_a_scripts_shell_call_under_the_inner_tools_name  FAILED
  the script's developer__shell call ran with no approval card, because the always-allowed
  script vouched for it: Result: "SCRIPT-GATE-ALLOWED\n"
a_denied_card_is_a_catchable_tool_error_and_the_script_goes_on        FAILED
  the script's developer__shell call ran with no approval card: Result: { "caught": null, "continued": true }
always_deny_on_the_inner_tool_refuses_it_and_the_script_continues     FAILED
  the always-denied call must come back into the script as a tool error: Result: { "caught": null, "after": "[package]…" }
auto_mode_runs_a_scripts_shell_call_with_no_card                      ok

a_hook_rewrite_cannot_carry_a_scripts_call_past_the_boundary_refusals fails with the post-rewrite re-check disabled. The rewritten cat '<global memory store>/probe.txt' was admitted.

auto_mode_still_asks_for_a_scripts_sensitive_write (non-Windows) fails with the gate installation disabled in Agent::dispatch_tool_call. The script's echo 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 real developer and code_execution extensions, and a private permission table.

  • Manual mode: a card for developer__shell carrying the command. Allow runs it.
  • Deny: a catchable tool error, the script goes on, and the command does not run.
  • never_allow on developer__shell: no card, a tool error, and the next call (text_editor) still runs.
  • Auto mode: no card.
  • Auto mode with a sensitive write (> /etc/…): a card carrying the sensitive-ops reason, and Deny comes back into the script.
  • Allowed by inner name: no card.
  • Always Allow on the card is recorded under developer__shell, the script's second call runs with no card, and the execute_code entry is untouched.
  • Smart mode: read-only chatrecall passes on its catalogue grade, and todo_write asks.
  • Nobody to ask (without_human_surface): refused at once, with no card published.
  • A boundary refusal never becomes a card.
  • A hook rewrite is what runs and is judged again (a rewritten rm -rf / is refused despite always-allow for shell).
  • Both name forms of execute_code get a judge.
Suite Result
cargo test -p biorouter --lib -- script_call_gate code_execution permission inspector tool_inspection 238 passed, 0 failed. Measured modules: agents::code_execution_extension 106, 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_inspector 24; permission::permission_scope 23; permission::tool_risk 13; permission::permission_store 13; agents::script_call_gate 13 (12 at the time of that count, plus the sensitive-ops test added later); tool_inspection::tests 11; and small groups elsewhere.
cargo test -p biorouter --lib privacy:: 232 passed, 0 failed
cargo test -p biorouter --lib 3807 passed, 0 failed, 2 ignored. The same on each of 3 final runs.
Widened filter (+ global_memory), looped 20/20 clean
11 integration binaries: code_execution_integration 42, chatrecall_code_execution 25, subagent_delegation 8, global_memory_dispatch_boundary 5, privacy_capability 4, privacy_toggle 4, nested_shell_cancellation 3, session_store_dispatch_boundary 3, workflow_capture_parity 2, code_execution_module_invariants 1, privacy_disclosure_toggle 1 98 passed, 0 failed. No stack overflow in subagent_delegation.
cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings clean
too_many_lines baseline no new entries

One flake I hit and fixed. a_hook_rewrite_cannot_carry… failed once. It resolves the global memory store twice, through the process-global BIOROUTER_PATH_ROOT, and a guarded writer in another test landed between the two reads. Both global-store tests now hold env_lock's single global mutex while writing nothing (commit 9f203b70). I first tried pinning the variable to its current value, the way pinned_store_root does, 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-reads Paths::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-gate on CDP 9391, running this branch's target/debug/biorouterd (seam build). The model was versa_azure / gpt-5.5-2026-04-24. permission.yaml always-allowed code_execution__execute_code (the QA condition) but not developer__shell or developer__text_editor. I switched Settings → Chat → Mode → Manual (config auto → 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_code with import { 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":

Run Shell?  Unverified
Command
echo APPROVAL-PROBE-F7
[Allow Once] [Always Allow] [Deny]

Deny. The card resolved to "Shell is denied", the step shows "Tool call failed", and the model replied:

The shell command was not run because the environment/user declined permission for the shell tool call.

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."

Run Analyze?  Unverified
Arguments
{ "path": "/Users/wgu/biorouter-runs/f7-script-gate/probe-dir" }
[Allow Once] [Always Allow] [Deny]

Allow Once. The card resolved to "Analyze is allowed once", and the model reported the tool's output:

SUMMARY:
Shown: 2 files, 6L, 1F, 0C (max_depth=3)
Languages: python (66%), markdown (33%)

PATH [LOC, FUNCTIONS, CLASSES] <FLAGS>
README.md [2L]
hello.py [4L, 1F]

The record reads {"tool":"developer__analyze",…,"status":"ok","result_bytes":163}, and permission.yaml was unchanged, because Allow Once records nothing.

3. I restored Autonomous (config back to auto) and opened a new chat with echo AUTO-PROBE-F7. There was no card, the output was AUTO-PROBE-F7, and the record reads developer__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 start left behind was identified by cwd and sandbox and killed by explicit PID.

Known residuals, deliberately not in this PR

  • Boundary refusals versus cards. A script's call that names the global memory store, the transcript DB or kb_delete_base is 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.
  • BRSDK apps with a vault. The outer execute_code resolves {{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 already record_result. It is unchanged in kind, but worth a follow-up.
  • The coding-agent bridge's approval card has the same prompt problem measured above ("The coding agent asked to run this through Biorouter." hides Always Allow), and it does not record Always Allow either. It is out of scope here.

🤖 Generated with Claude Code

Update: merged with main 6455bc2 (commit a594c209)

There were two conflicts, and both resolutions keep main's behaviour:

  • agents/mod.rs: kept pub(crate) mod schedule_tool from main, with script_call_gate beside it.
  • agent.rs handle_denied_tools: main's F4 workspace_mutation arm is ported into the shared tool_execution::denied_response_text, and main's F4 test passes unchanged.

The F4 WorkspaceMutationInspector uses any capability it is handed (sampled = capability), so the threaded CallCapability still reaches it.

Results on the merged tree:

  • -- code_execution permission inspector script_call_gate: 231/0
  • privacy::: 241/0
  • full lib: 3897/0, run twice
  • fmt and workspace clippy: clean
  • subagent_delegation: 8/8, no stack overflow
  • 9 other integration binaries: all pass

Two failures are pre-existing on main. The files involved are identical to main in this PR:


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() was SCRIPT_CALL_GATE.try_with(Arc::clone).ok(), so
AccessError — "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
Ok(arguments): no permission decision at all, the opposite polarity from unjudged()
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_calls
wraps the tool body (so spawning the returned future, which the tests do, is safe), the
one tokio::spawn in the body is fed the gate by value after reading it, and the
spawn_blocking runs 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) the agent loop's judge is here — judge every call
ScriptJudging::PersonDriven nothing in the agent loop dispatched this: POST /agent/call_tool, an Agent Drafter app, the coding-agent bridge (own BridgeGrant). Unchanged
ScriptJudging::JudgeLost the agent loop is running a script for this session and this task cannot see its judge — refuse the whole script

current() returns Result<_, NoGateOnThisTask>, and judge_for(session_id) is now the
single place an absence is given a meaning. The disambiguator is DispatchedByAgentLoop: a
process-global count of the sessions the agent loop currently has an execute_code body in
flight for. A tokio::spawn loses the task-local; it cannot lose this. It is 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 and cannot be wired up one without
the other — which is why this finding needed no change to agent.rs at all.

On JudgeLost the handler returns a refusal before dispatching a single sub-call, plus
tracing::error!(counter.biorouter.script_call_judge_lost = 1). The refusal says it is a
defect 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:123 and ~15 integration-test dispatch sites, all of which
legitimately 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_runs drives all
three states through ExtensionManager::dispatch_tool_call (the only door that reaches the
handler with no scope): no record ⇒ the script runs and echo SCRIPT-GATE-PERSON-DRIVEN
reaches the output; record held ⇒ is_error, the text names the missing permission judge,
SCRIPT-GATE-UNJUDGED never appears and no card is published; record dropped ⇒ benign
again, 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_scope pins the RAII and the nesting.

Finding 2 — execute_code could park on a human while holding a process-global permit

tool_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_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=0. Starvation, not deadlock (answering a card takes no
permit, 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_code could 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_tool are do-nothing wrappers, so exempting them widens
no concurrency. execute_code does real work, and with the Code Execution capability on — the
shipped 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:

  • ToolDispatchGuard keeps its permit in a cell and remembers the semaphore it came from.
  • ToolDispatchGuard::parking_handle() -> DispatchPermitHandle hands out a Weak
    reference 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, then
    queues 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.
  • 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 only its
    parked.wait(approval_ttl(), ..) in it. Everything else keeps its permit for its whole
    execution, unchanged. The path locks are not released — execute_code takes none.

Fail-before evidence.
a_parked_scripts_ask_holds_no_global_dispatch_permit fills the real ceiling to exactly one
free permit, runs a Manual-mode script whose developer__shell call parks on a card, then
asserts an unrelated dispatch can still acquire within 10 s. Before the fix that
acquisition never completes and the test fails on that assertion; after it, it acquires, and
answering the card still runs the call (SCRIPT-GATE-PARKED in the output), which exercises
the 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_again and
a_handle_that_outlived_its_dispatch_is_inert — cover the mechanism without racing the rest
of the binary.

agent.rs lines touched (for the PR #250 merge)

Only two places, both additive:

  • 1989-1996 — eight comment lines appended to is_parking_workspace_tool's doc comment
    saying why execute_code is deliberately not on that list. No code.
  • 7516-7526 — eleven lines inside the existing Some(gate) => arm of the
    let inner_result = match script_gate expression: the gate.hold_dispatch_permit(..) call
    and its comment. The arm's existing judging_script_calls(gate, inner).await is unchanged.

Finding 1 touched agent.rs not 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. ToolApprovalRequest carries
{tool_name, arguments, prompt, risk, preview, requires_user_proof} and
ToolCallConfirmation.tsx destructures exactly {id, toolName, prompt, risk, preview}. Every
field is spoken for:

  • prompt is drawn as a warning banner and, at line 284, {!prompt && <Always Allow>} — it
    is 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.
  • risk is a typed badge; preview is a closed typed union with no free-text member;
    tool_name is display-only but doctoring it is ruled out by this PR's own test that the card
    names the inner tool.

Provenance therefore needs a new optional field on ToolApprovalRequest and
ActionRequiredData::ToolConfirmation, a regenerated OpenAPI spec and TS client, and a renderer
treatment deliberately distinct from prompt so it does not suppress "Always allow". Out of
scope here. The consequence is documented for users in
docs/extensions/built-in/code-execution.md: on a script's card, Always allow records a
grant 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 prompt is its only gate.

docs/agent-loop/workspace-control-tools.md previously read "Two tools park, and are exempt
from 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.

Command Result
cargo test -p biorouter --lib -- script_call_gate code_execution agents::agent tool_dispatch_limits 302 passed, 0 failed
cargo test -p biorouter --test subagent_delegation 8 passed, 0 failed (no stack overflow — the reply_internal generator gained no frames)
cargo test -p biorouter --test privacy_capability 4 passed, 0 failed (the two censuses are green again after #258/#259)
cargo test -p biorouter --test privacy_guard_wiring 3 passed, 0 failed
cargo fmt --all && cargo fmt --all --check clean

Neither census needed a row: the needles are CallCapability::sample(,
CallCapability::public_enforced(, affiliation::gate_cross_affiliation,
affiliation::refusing_mismatch and crate::privacy_toggle::privacy_tiers_enabled(), and
these commits add none of them to crates/*/src/. The new tests use
CallCapability::for_test_restricted(), which the census excludes by design and which
script_call_gate.rs already spelled.

Fail-before evidence (mandatory, and measured). Both production behaviours were reverted
judge_for back to the pre-fix polarity (absent gate ⇒ always PersonDriven) and
ask_a_person back to a bare wait.await — with the tests untouched. Exactly the two new
hazard tests failed and nothing else did:

test agents::script_call_gate::tests::a_parked_scripts_ask_holds_no_global_dispatch_permit ... FAILED
test agents::script_call_gate::tests::a_script_whose_judge_was_lost_is_refused_and_a_person_driven_one_still_runs ... FAILED

panicked at script_call_gate.rs:1748: a script parked on an approval card held its dispatch
  permit; eight of those stall every other tool call in the daemon
panicked at script_call_gate.rs:1663: a script the agent loop dispatched with no judge must be
  refused: Result: "SCRIPT-GATE-UNJUDGED\n"

test result: FAILED. 18 passed; 2 failed; 3904 filtered out; finished in 10.88s

The second panic is the defect verbatim: pre-fix the script's developer__shell call ran
and its output came back into the script. The two tool_dispatch_limits mechanism tests pass
either way, as they should — they test while_parked directly, 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.

…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.
@Broccolito
Broccolito merged commit ab23df4 into main Sep 12, 2026
16 checks passed
@Broccolito
Broccolito deleted the fix/f7-script-call-permissions branch September 12, 2026 06:50
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant