fix(engines): vet delegated tool approvals against is_dangerous_command (#916) - #1016
Conversation
…nd (#916) claude-code got a PreToolUse guard in #819 precisely because GitHub Issues import (#565) turns externally-authored issue bodies into task prompts. The same prompts went unguarded to the other three engines. codex — `_answer_server_request` approved every tool call sight-unseen under the default `approval_policy="auto"`. Command approvals now run their `command` through `core.dangerous_commands.is_dangerous_command` and decline on a match, emitting an error event so a block is not mistaken for the model declining to act. File-change approvals carry no command; the sandbox bounds those, and its default is `workspace-write` (verified against a live handshake, not asserted from the constructor alone). opencode — `--auto` approves anything "not explicitly denied", so its native permission config is a deny-list, the one mechanism that composes with the flag. When and only when auto_approve is set, OPENCODE_CONFIG points at a generated config denying the DANGEROUS_PATTERNS families. Without --auto the operator's own config governs untouched. Required a get_env hook on SubprocessAdapter, which layers over the inherited environment rather than replacing it (confining what is inherited is #996). kilocode — the issue's premise does not hold here, and the test says so rather than adding a guard that guards nothing: 0.22.0's `--auto` is "Run in autonomous mode (non-interactive)"; `--yolo` is the permission bypass and the adapter never passes it. Pinned so it stays true. kilocode 7.x redefines `--auto` as "auto-approve all permissions", which would make the existing flag a blanket bypass on a current install — part of the migration tracked in #1015. Known limitation: OPENCODE_CONFIG loads between the global and project configs, so a repo's own opencode.json still wins. That is the repo-supplied-config trust problem of #903/#905; this raises the floor, it is not a containment boundary.
…#916) codex review [P2]: the glob list omitted variants the shared regex list covers — `rm --no-preserve-root /` (my `rm * --no-preserve-root*` could not match the direct form) and the fork bomb entirely, so those shapes stayed auto-approvable under --auto. Added the missing families and widened the lossy translations (bare -r/-f recursive deletes, spaced fork-bomb variants, dd in either direction, curl|sh without a space before the pipe). Backed by a parity test that asserts each of the 14 DANGEROUS_PATTERNS families *matches* a deny glob under fnmatch, rather than asserting rules merely exist — a deny list nobody checked against real commands is how you get a mitigation that mitigates nothing. Plus the converse: ordinary commands (git status, npm test, rm build/artifact.o) must not be denied, since a deny list that blocks normal work just gets switched off.
|
Claude finished @frankbria's task in 5m 28s —— View job Precision bug-hunting review — done
Result: 1 finding (minor), 0 critical/major.
Verified clean: codex approval guard (correct tuple unpack + benign |
GLM precision review — bug huntScope: concrete defects only (logic errors, error/null paths, security, data loss, contract breaks, resource leaks). Style, naming, architecture, and test coverage are covered by the general review and deliberately not repeated here.
Verified clean (no defect):
1 finding, 0 critical/major. |
GLM review [minor]: the config was written with delete=False per adapter instance and never unlinked. Adapters are constructed per task, so once --auto is wired in that leaks one /tmp entry per task, unbounded. The deny-list is a constant, so it is now a process-level singleton with atexit cleanup. Verified: 25 adapters -> 1 distinct config file, 1 new /tmp entry (was 25). Regression test pins it.
|
Claude finished @frankbria's task in 3m 49s —— View job GLM precision review — re-review after
|
| area | verdict |
|---|---|
opencode singleton fix (opencode.py:50-67) |
Clean. _permission_config_path() writes once per process, registers atexit.register(path.unlink, missing_ok=True), and the if … is not None and .exists() guard short-circuits subsequent callers. with handle: flushes/closes before the path is published, so opencode never reads a half-written file. The only theoretical concern — two concurrent first-callers each creating a temp file — is benign: identical content, both register atexit, both unlinked at exit, bounded by concurrency (not unbounded). And it is dormant in production today: engine_registry.get_external_adapter calls OpenCodeAdapter() with no args, so auto_approve is never set and the path is never taken. |
codex guard (codex.py:471-477) |
Clean. is_dangerous_command returns tuple[bool, str], unpacked correctly. Fires only under approval_policy == "auto" (the default) on item/commandExecution/requestApproval; file-change approvals carry no command and are skipped by design. isinstance(command, str) guards non-string payloads; is_dangerous_command catches its own shlex.ValueError internally, so no exception can escape into _stream_turn. |
error-event on block (codex.py:488) |
Clean. Confirmed against runtime.on_agent_event (runtime.py:755): an event.type == "error" is logged/emitted as AGENT_STEP_COMPLETED — it does not fail the task or trip blocker detection. Informational only, as intended ("a silent block looks like the model choosing not to act"). |
subprocess env layering (subprocess_adapter.py:130-142) |
Clean. child_env = {**os.environ, **extra_env} if extra_env else None; None → Popen(env=None) inherits unchanged, so claude-code/codex/kilocode behave exactly as before. test_the_deny_config_reaches_the_subprocess asserts both OPENCODE_CONFIG and PATH reach Popen. |
opencode OPENCODE_CONFIG contract |
Verified against https://opencode.ai/docs/permissions: the config is a single JSON file, schema {"permission": {"bash": {<glob>: "deny"}}}, */? glob semantics matching fnmatch, "last matching rule wins" (moot — every rule is deny). The adapter writes exactly that. The deny-list has no catch-all "*" rule, which is correct under --auto (auto-approve anything not explicitly denied). |
kilocode (kilocode.py:103-104) |
Clean / test-only. Confirmed the production command is […, "--auto", "--workspace", …] with --yolo absent. |
No inline comments — nothing to surface.
Closes #916.
Problem
claude-code got a PreToolUse guard in #819 precisely because GitHub Issues import (#565) turns externally-authored issue bodies into task prompts. The same prompts went unguarded to the other three engines — so the raised floor against injected destructive commands existed for exactly one of four.
What each engine actually needed
I checked each CLI's real permission surface rather than assuming the issue's premise held uniformly — and for one engine it didn't.
codex — the real gap, now closed
_answer_server_requestapproved every tool call sight-unseen under the defaultapproval_policy="auto". Command approvals now run theircommandthroughcore.dangerous_commands.is_dangerous_commandand decline on a match, keeping one source of truth for the patterns across ReAct, claude-code's hook, and codex.A block emits an
errorevent — a silent decline is indistinguishable from the model choosing not to act.File-change approvals carry no command, so the sandbox is what bounds those. Its default is
workspace-write(set in #914), and the test asserts it on the wire, not from the constructor.opencode — paired
--autowith its native deny-list--autoapproves anything "not explicitly denied", so opencode's permission config is a deny-list — the one mechanism that composes with the flag rather than fighting it. When and only whenauto_approveis set,OPENCODE_CONFIGpoints at a generated config denying theDANGEROUS_PATTERNSfamilies. Without--auto, the operator's own config governs untouched; overriding it would be the adapter quietly changing their settings.This needed a
get_envhook onSubprocessAdapter, which layers over the inherited environment rather than replacing it. (Confining what a delegated agent inherits at all is #996, deliberately not conflated here.)kilocode — the issue's premise does not hold, and the test says so
The issue states "OpenCode and Kilocode's
--autoattach no equivalent guard". Verified against kilocode 0.22.0's own help, that is not the case:--autois the headless-execution flag, not a permission bypass, and the adapter never passes--yolo. So there is nothing to fix here today — and rather than add a guard that guards nothing, the test pins that--yolois never passed, so it stays true.--autoas "auto-approve all permissions", which would make the adapter's existing flag a blanket bypass on a current install. That is part of the migration tracked in #1015, and is called out in the test.Demo — real binary where it matters
Sandbox value captured from a live
codex app-serverhandshake, not read off the constructor:Approval decisions through the real handler:
opencode and kilocode:
Acceptance criteria
is_dangerous_commandand reject on match_answer_server_request; 5-command parametrized rejection test + the live demo abovesandbox_modeis the codex defaultworkspace-write, asserted on the wire intest_the_sandbox_default_is_restrictivetest_a_dangerous_command_is_declined_even_under_auto_approval+test_destructive_commands_are_declinedOPENCODE_CONFIGdeny-list, verified to reachPopen. kilocode:--yolonever passed, pinned — its native mechanism already grants nothingReview
codex review --base main(cross-family, pre-PR) raised one [P2]: my glob list omitted variants the shared regex list covers —rm --no-preserve-root /(myrm * --no-preserve-root*could not match the direct form) and the fork bomb entirely.Confirmed and fixed in ec86aac. The lasting fix is the parity test: each of the 14
DANGEROUS_PATTERNSfamilies now has a representative command asserted to match a deny glob underfnmatch, rather than asserting rules merely exist — a deny list nobody checked against real commands is how you get a mitigation that mitigates nothing. Plus the converse:git status,npm test,rm build/artifact.omust not be denied, because a deny list that blocks normal work just gets switched off.Tests
tests/core/adapters/— 243 passed, 13 skipped.ruff checkclean.Known limitations
OPENCODE_CONFIGloads between the global and project configs, so a repository's ownopencode.jsonstill takes precedence and can re-allow a denied command. That is the repo-supplied-config trust problem [P0.9] Treat a repo-supplied llm.base_url as untrusted #903/[P0.11] Close the untrusted-repo execution boundary: repo-committed hooks and agent-readable credential store #905 address elsewhere. This raises the floor for the ordinary case; it is not a containment boundary.bash -c, string splicing). Only OS-level isolation (worktree/E2B/container) actually contains a hostile command.