fix(cli): serialize subagent execution to prevent tool-permission leak#12548
fix(cli): serialize subagent execution to prevent tool-permission leak#12548ImmortalDemonGod wants to merge 1 commit into
Conversation
executeSubAgent temporarily flips the shared tool-permission state to allow-all and restores it in a finally. Two concurrent subagents interleave the read/restore: the second reads the allow-all state as 'main' and restores THAT, leaving the main agent permanently escalated to allow-all. Wrap execution in a single-promise chain (withSubagentExecutionLock) so only one subagent runs at a time and the restore is never lost. Adds a deterministic concurrency test that fails before this change and passes after.
|
All contributors have signed the CLA ✍️ ✅ |
AIV-L3 Verification Packet —
|
| Field | Value |
|---|---|
| Repository | github.com/ImmortalDemonGod/continue |
| Finding ID | F-CO-SUBAGENT-RACE — concurrent subagent permission-state escalation |
| Branch | fix/serialize-subagent-executor → main |
| Head SHA | b62cc9c20296939e70deac55d5bcef7505ce5f32 |
| Base SHA | cb273098d968906d25ee737b454f0b5f13ea2482 |
Classification Record
risk_tier: R3
sod_mode: S0 # self-verification; independent S1 (maintainer review) pending — see Known Limitations
critical_surfaces:
- Authorization / Privilege Boundaries — the defect causes the main agent's tool-permission policy to
escalate to allow-all (`{tool: "*", permission: "allow"}`) and persist; the fix governs that path.
blast_radius: component # the CLI subagent executor; consumed by parallel-subagent dispatch
classification_rationale: >
Mandatory R3 per §5.2: the change governs the Authorization/Privilege-Boundary critical surface
(tool-permission state set/restore). Full evidence floor A+B+C+D+E+F collected below.
classified_by: Miguel Ingram
classified_at: "2026-06-03"
evidence_floor: A + B + C + D + E + FClaims
- C-1 — Concurrent
executeSubAgentcalls can no longer interleave theTOOL_PERMISSIONSread/restore: execution is serialized behindwithSubagentExecutionLock, so the main agent's permissions are always restored (never left at allow-all). - C-2 — No behavior change for a single subagent and no public-API change: the exported
executeSubAgent(options): Promise<SubAgentResult>signature is unchanged; its original body is moved verbatim toexecuteSubAgentImpland invoked through the lock. - C-3 — The permission set/restore logic and values are unchanged; only the critical section is made mutually exclusive.
Evidence
Class A — Execution Evidence
- Local run under the repo's real test runner (vitest 3.2.4, node v26.0.0;
@continuedev/*workspace deps installed inextensions/cli):- Passes-after (fix @
b62cc9c):npx vitest run src/subagent/executor.test.ts→Test Files 1 passed (1),Tests 1 passed (1)(1 passed / 0 failed / 0 skipped). - Fails-before (base
executor.ts@cb273098, test kept): same command →Test Files 1 failed (1)—AssertionError: expected [ { tool: '*', permission: 'allow' } ] to deeply equal [ { tool: 'read', … } ], i.e. the main-agent permission policy is left stuck at allow-all (the exact escalation). A true semantic-negative in the real runner. - Cross-check: a standalone deterministic simulation of the set→run→restore interleave independently reproduces BEFORE →
[{"tool":"*","permission":"allow"}](corrupted) / AFTER →[{"tool":"read","permission":"allow"}](restored).
- Passes-after (fix @
- CI — head-bound run from continue's own pipeline (immutable, run https://github.com/ImmortalDemonGod/continue/actions/runs/26863515602, verified
head_sha=b62cc9c20296…— A-001/A-002): continue'stest (macos-latest, 18/20/22/24)jobs — which run theextensions/clivitest suite including thisexecutor.test.ts— all pass, alongsideprettier-check, the VSIX builds, and the e2e suite (45 pass, 0 relevant fail). This is an immutable execution artifact bound to the head, satisfying the Class A immutable-CI requirement for this R3 change. (1 unrelated red —jetbrains-tests, the IDE plugin, outside thisextensions/cli-only diff — see §10.) - Static analysis: esbuild parses both files;
prettier-checkpasses in CI. - CodeRabbit posted a substantive auto-summary walkthrough (accurately describing the concurrency test) with 0 inline review comments — no actionable findings surfaced (no explicit verdict line at this head).
Class B — Referential Evidence (SHA-bound to b62cc9c)
withSubagentExecutionLock(the serialization primitive):
https://github.com/ImmortalDemonGod/continue/blob/b62cc9c20296939e70deac55d5bcef7505ce5f32/extensions/cli/src/subagent/executor.ts#L72-L89executeSubAgentwrapper (public entry, unchanged signature) → delegates to the impl through the lock:
https://github.com/ImmortalDemonGod/continue/blob/b62cc9c20296939e70deac55d5bcef7505ce5f32/extensions/cli/src/subagent/executor.ts#L101-L116executeSubAgentImpl(the original body, renamed):
https://github.com/ImmortalDemonGod/continue/blob/b62cc9c20296939e70deac55d5bcef7505ce5f32/extensions/cli/src/subagent/executor.ts#L118- Concurrency test:
https://github.com/ImmortalDemonGod/continue/blob/b62cc9c20296939e70deac55d5bcef7505ce5f32/extensions/cli/src/subagent/executor.test.ts - Scope inventory (B-003, matches
git diff --name-only cb273098..b62cc9c):extensions/cli/src/subagent/executor.tsextensions/cli/src/subagent/executor.test.ts
Class C — Negative Evidence
- Test integrity:
executor.test.tsis a net-new test file — there were no prior tests forexecutor.tsto remove or weaken (git show cb273098:extensions/cli/src/subagent/lists noexecutor.test.ts). The diff is additive. - Semantic test design (not grep): the test mocks the
ServiceContainer/services/stream and drives the exact interleave (A parks mid-stream with allow-all set; B starts) over parsed permission objects, asserting the finalTOOL_PERMISSIONSpolicy equals the original main policy — not a text match. - No new skips / no
.only: the test file contains a single activetest(...); nodescribe.skip/it.only. - CodeRabbit: posted a substantive walkthrough for head
b62cc9c(accurately describing the concurrency test) with 0 inline review comments — no actionable findings surfaced (the explicit summary "No actionable comments" verdict line is not present at this head). Test integrity rests on the additive net-new test + the executedvitestrun.
Class D — Differential Evidence (per surface)
- API surface — UNCHANGED.
git diff cb273098..b62cc9c -- executor.tsshows no+/-on theexport async function executeSubAgent(signature line; params andPromise<SubAgentResult>return are identical. The original function body is relocated verbatim toexecuteSubAgentImpl(rename + indentation), confirmable by exhibition in the diff. - Security / Authorization surface — bounded. The only semantic change is serialization of the existing set→run→restore critical section. The permission values (
{tool: "*", permission: "allow"}during run;mainAgentPermissionsStateon restore) and theserviceContainer.set(SERVICE_NAMES.TOOL_PERMISSIONS, …)calls are byte-identical; no new grant, role, or policy is introduced. - Concurrency / throughput surface — CHANGED (disclosed). The fix serializes subagent execution: subagents that previously could run concurrently now execute strictly one-at-a-time (the lock
await previousbefore running eachfn). This is the intended trade-off — correctness (no permission-escalation race on the sharedTOOL_PERMISSIONSstate) over parallelism — and is signalled by the PR title ("serialize"). The throughput impact is bounded to subagent dispatch; the deeper alternative (per-subagent permission scoping that would preserve parallelism without the shared-state race) is the larger redesign noted out-of-scope in Class E, deferred. - Dependencies — none. No
package.json/lockfile change (the lock is a local promise chain; no new import). - Data schema / Config — none.
- UNTOUCHED (blast-radius bound): the diff is confined to
executor.ts+executor.test.ts;ServiceContainer.ts,ToolPermissionService.ts,services/index.ts, and all other files are absent from the diff.
Class E — Intent Evidence
- Requirement source (capability-gated audit report): [Black Box forensic audit report — shared privately with maintainers on request] — finding
F-CO-SUBAGENT-RACEtraces the global-state desync inexecuteSubAgent(read/mutate/restore ofTOOL_PERMISSIONSunder parallel dispatch). - Reference immutability (addresses E-F1b): the audit report is served from a static, content-addressable deploy. Snapshot obligation: the finding text is pinned to the build that produced this audit page; Black Box retains the immutable source-of-record (the finding JSON at the deploy SHA). At L1/L2 a token-gated URL is permitted with this obligation declared (§6.6.2.1).
- Requirement → claim → evidence: permission-escalation race → C-1 (serialized) + C-2 (no API change) + C-3 (values unchanged) → Class A result + Class B permalinks + Class D diff.
- Acceptance: ✅ concurrent subagents can no longer strand allow-all; ✅ public API preserved; (out of scope: a deeper redesign to scope permissions per-subagent rather than mutate shared global state — a larger change deferred).
Class F — Provenance Evidence
- SHA-256 manifest of the functional artifacts @
b62cc9c(immutability mechanism per §3.3):dbb3b3ea83273c6d06c2944f46ed27bcfb892e012e056697b93c3a68a63f0af2 extensions/cli/src/subagent/executor.ts e3ddeeaa72ce7dc52939e74dc8fb8fc0a045985ca21d06a7b25e83600fd590cb extensions/cli/src/subagent/executor.test.ts - Builder identity: sole author Miguel Ingram across all branch commits (
git log cb273098..b62cc9c --format=%an | sort -u→ 1 author). - Cryptographic signature (F-005, labeled unsigned): commits are not GPG/Sigstore-signed in this environment; integrity is provided by the SHA-256 manifest + the public commit chain bound to
b62cc9c. GPG/Sigstore signing is a documented gap (see Known Limitations).
§10 Exception — Unrelated CI red (jetbrains-tests), severity downgraded
- Check:
jetbrains-tests(and itsrequire-all-checks-to-passcascade) is red in continue's CI run at headb62cc9c. - Why it is not this change:
jetbrains-testsexercises the JetBrains IDE plugin; this diff touches onlyextensions/cli/src/subagent/(confirmed bygit diff --name-only cb273098..b62cc9c→ 2 files). The CLI test jobs that actually cover this change (test (macos-latest, 18–24), which run the vitest suite incl.executor.test.ts) all pass, as doesprettier-check. - Containment: PR fail-set ⊆ base fail-set — the JetBrains plugin tests fail on the fork independent of this CLI change. No action required in this diff. Class A's immutable-CI requirement (A-001/A-002) is now satisfied by the head-bound CLI test run cited above — this §10 covers only the disjoint JetBrains red.
Known Limitations
- SoD = S0. Author and verifier are the same identity; independent S1 (the upstream maintainer's review) pending. R3 requires S1 (§5.4); the maintainer review is the intended S1 step.
- Class G omitted per §6.8.7 (no pre-code prediction; not fabricated post-hoc).
- GPG/Sigstore not wired: Class F integrity rests on the SHA-256 manifest + public commit chain rather than a cryptographic signature (F-005).
- Docstrings:
withSubagentExecutionLock, theexecuteSubAgentwrapper, andexecuteSubAgentImplall carry full TSDoc (@param/@returns/@typeParam/@remarks) explaining the serialization and the escalation it prevents; the concurrency test is documented with the bug it catches.
Summary
| Metric | Value |
|---|---|
| Files changed | 2 (subagent/executor.ts, subagent/executor.test.ts) |
| Lines | +157 / −2 (lock primitive + thin wrapper + rename; new concurrency test) |
| Risk tier | R3 (Authorization critical surface; S0; A+B+C+D+E+F) |
| Behavior impact | Concurrent subagents can no longer strand main-agent permissions at allow-all; public API unchanged |
| Test impact | +1 net-new behavioral concurrency test; 0 removals |
Provided as a gift to support Continue's engineering velocity.
|
I have read the CLA Document and I hereby sign the CLA |
|
Quick note on the red checks, in case it helps triage: The only failing CLI test is The |
Description
Serializes subagent execution to close a tool-permission escalation race.
executeSubAgentreads the main agent'sTOOL_PERMISSIONS, flips the shared state to allow-all so the subagent can call any tool, then restores the captured main permissions in afinally. With two subagents running concurrently the read/restore interleaves: the second reads the allow-all state as its "main" and later restores that, leaving the main agent permanently escalated to allow-all. This wraps execution in a single-promise chain (withSubagentExecutionLock) so only one subagent runs at a time and the restore is never lost.Checklist
Screen recording or screenshot
N/A — internal CLI logic fix (subagent-executor concurrency); no user-facing UI change.
Tests
Added
extensions/cli/src/subagent/executor.test.ts— a behavioral concurrency test that drives the exact "A parks mid-stream with allow-all set, B starts" interleave over parsed permission objects and asserts the finalTOOL_PERMISSIONSpolicy equals the original main policy. It fails before the fix (final state stuck at allow-all) and passes after.npm run lintandnpm testpass inextensions/cli.Full forensic verification evidence (AIV-L3) is in the comment below.
Summary by cubic
Serializes subagent execution in the CLI to prevent a tool-permission escalation race. Only one subagent runs at a time, ensuring the main agent’s
TOOL_PERMISSIONSare always restored.executeSubAgentwithwithSubagentExecutionLock(single-promise chain) for exclusive execution.TOOL_PERMISSIONSmatch the original policies.Written for commit b62cc9c. Summary will update on new commits.