Skip to content

fix(adapters): harden claude-code require_file_changes + bypassPermissions (#819)#865

Merged
frankbria merged 2 commits into
mainfrom
fix/819-claude-code-hardening
Jul 15, 2026
Merged

fix(adapters): harden claude-code require_file_changes + bypassPermissions (#819)#865
frankbria merged 2 commits into
mainfrom
fix/819-claude-code-hardening

Conversation

@frankbria

@frankbria frankbria commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Closes #819. Four follow-ups from the #739 / PR #818 review — none blocked that merge.

1. Dangerous-command guard for bypassPermissions (security)

The built-in ReAct engine runs every Bash command through is_dangerous_command() (core/executor.py:52, called at core/tools.py:802). ClaudeCodeAdapter's default --permission-mode bypassPermissions handed the delegated claude CLI unrestricted Bash with no CodeFrame-side equivalent — and #565 turns externally-authored issue bodies into task prompts.

Approach: a PreToolUse hook, not a narrower allowlist. Verified against the hooks docs: PreToolUse hooks fire even under bypassPermissions — the mode skips approval prompts, not hooks. The alternatives lose:

The hook re-uses is_dangerous_command() verbatim, so both engines block the same set from one source of truth. It's attached on the allowlist path too — an allowlist granting Bash carries the same blast radius.

2. Exit-0 clarifications route to a blocker, not a hard failure

--print cannot prompt, so a genuine ambiguity gets printed and the process exits clean. The zero-file guard then hard-failed it with a misleading "likely lacked write permission". Now stdout is classified first, and requirements/access/external-service ambiguity raises a blocker a human can answer.

Tactical questions ("which approach…") still hard-fail — classify_error_for_blocker returns None for those by design: they're the agent's own job, not a human's.

3. require_file_changes follows what the allowlist grants

Was coupled to whether an allowlist was passed. A read-only allowlist (["Read","Grep"]) means an analysis run that legitimately writes nothing, but still defaulted to hard-failing. Now derived from whether the allowlist grants a write tool (Edit/Write/MultiEdit/NotebookEdit/Bash, parsed off rule-shaped entries like Bash(git *)). Explicit True/False still wins. Not reachable today (engine_registry never passes allowlist) — this closes the trap before the next caller hits it.

4. Git errors are logged

detect_modified_files / _git_head swallowed every git error. Since empty→failed, a transient hiccup was indistinguishable from "the agent changed nothing".

Verification

  • Full CI gate: 4270 passed, 12 skipped, 0 failed. ruff check . clean.

  • Guard proven end-to-end against the real claude CLI — not just mocked stdin. With the adapter's actual --settings payload, dd if=/dev/zero of=/dev/null (matches a dangerous pattern; harmless if it ran) was denied before reaching the shell, and the reason surfaced to the delegated agent:

    CodeFrame blocked this command: dd writing to device. Destructive commands are refused for delegated runs; achieve the task another way.

    A safe command (echo SAFE-OK) passed through the same hook, confirming the guard discriminates rather than blanket-blocking.

  • codex review on the branch diff: no actionable correctness issues (it independently exercised the guard).

Known limitations

  • Advisory-grade, not a sandbox. The guard is a regex matcher over the command string — exactly the grade of protection ReAct already has, no more. In the demo above the delegated agent immediately proposed an evasion (head -c 512 /dev/zero > /dev/null) unprompted. It raises the floor for accidental and injected destructive commands; it does not stop an agent that means to evade it. Real containment needs the worktree/E2B isolation paths.
  • Fail-open on guard import failure. The hook runs sys.executable -m codeframe.core.claude_code_guard. sys.executable is the interpreter already running CodeFrame, so codeframe is importable in practice — but if the import did fail, Python exits 1, which Claude Code treats as a non-blocking hook error and the command proceeds. Exit code 2 (fail-closed) is unreachable from a module that failed to import.
  • bypassPermissions path not exercised live. The end-to-end demo ran under --allowedTools because this dev sandbox blocks spawning a nested claude --permission-mode bypassPermissions. The hook is registered identically on both paths and the docs are explicit that hooks fire under bypassPermissions; the mechanism itself is proven.
  • The issue asked to "soften any 'matches ReAct tool access' framing" in docs — grepped the tree, that phrasing exists nowhere. It was PR fix(adapters): grant claude-code write permissions + fail on zero-file completion (#739) #818 prose, not checked-in docs. Nothing to change; no docs describe the adapter's permission model at all.
  • A zero-file run whose stdout merely mentions an access term ("I reviewed the api key handling") can now classify as blocked rather than failed. Low-stakes (a human looks at it) and consistent with how the non-zero-exit path already behaves.

Review round (post-PR)

Full triage in this comment. Two fixes applied on top of the original commit:

  • Guard import weight (claude bot, verified + measured): the guard is a fresh subprocess per Bash call, and importing is_dangerous_command from core.executor pulled 930 modules incl. the openai SDK — ~423ms per Bash call. Patterns extracted to a stdlib-only leaf (core/dangerous_commands.py, re-exported by executor to keep one source of truth) and the guard moved out of the eagerly-importing core/adapters package. 423ms → 142ms.
  • require_file_changes polarity: inverted _WRITE_TOOLS_READ_ONLY_TOOLS so an unrecognized tool (MCP write tool, Task, future tools) is assumed write-capable and fails loudly, rather than silently disabling the zero-file guard.

Three findings rebutted in writing (stderr classification, last-line blocker extraction, git-logging dedupe). Cross-family review: codex (opencode timed out twice with zero output — noted per the invariants). Guard re-verified live against the real CLI after the module move; test-mutation check passes on all 5 new behaviors; diff coverage 96.67%.

🤖 Generated with Claude Code

…sions (#819)

Four follow-ups from the #739 / PR #818 review.

1. Dangerous-command guard for bypassPermissions (security).
   The built-in ReAct engine filters every Bash command through
   is_dangerous_command(); the delegated claude CLI got unrestricted Bash
   with no CodeFrame-side equivalent, and #565 turns externally-authored
   issue bodies into task prompts. Register a PreToolUse hook (which fires
   even under bypassPermissions — the mode skips approval prompts, not
   hooks) that reuses is_dangerous_command verbatim, so both engines block
   the same set from one source of truth. Attached on the allowlist path
   too: an allowlist granting Bash has the same blast radius.

2. Route exit-0 clarifications to a blocker instead of hard-failing.
   --print cannot prompt, so a genuine ambiguity gets printed and the
   process exits clean; the zero-file guard then hard-failed it with a
   misleading "likely lacked write permission". Classify stdout first and
   raise a blocker a human can answer. Tactical questions still fail — by
   design those are the agent's own job.

3. Derive require_file_changes from what the allowlist grants, not from
   whether one was passed. A read-only allowlist means an analysis run,
   which legitimately writes nothing; it no longer silently hard-fails.

4. Log git errors in detect_modified_files / _git_head. Empty now means
   failed, so a transient git hiccup was indistinguishable from "the agent
   changed nothing".
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 7m 41s —— View job


✅ Bug-hunting review: no defects found

Reviewed PR #865 for concrete defects (logic errors, security, data loss, race conditions). Focused only on the 4 source hunks and their integration points — style/coverage/test architecture are out of scope (covered elsewhere).

  • Gather PR diff and existing comments (none prior)
  • Read changed files + surrounding context (callers/callees)
  • Confirm candidate defects against real code
  • Post summary (no inline findings — nothing survived verification)

Integration points verified against the checked-out tree:

  • is_dangerous_command returns tuple[bool, str] (core/executor.py:52) → guard's dangerous, reason = ... unpacking is correct; reason is always non-empty when dangerous=True, so the deny message is well-formed.
  • AgentResult permits error=None alongside blocker_question (agent_adapter.py:60-70) → setting error=None on the new blocked path is valid.
  • Downstream consumer handles it correctly: runtime.py:826 gates blocker creation on result.blocker_question, and logging guards result.blocker_question or "Unknown" / (result.error or "")[:500] (runtime.py:837-843) → no None-deref or crash from the new failed→blocked transition.
  • The resolved require_file_changes is actually forwarded to the base class (claude_code.py:103-107), so the new None-derivation isn't inert.
  • _grants_write_access handles bare names, rule-shaped entries (Bash(git *)), case-insensitivity, and empty list consistently with the allowlist truthiness gate.

Candidate findings considered and rejected (no concrete failure scenario that isn't already an explicitly-disclosed limitation):

  • False blocker on a benign stdout mention of "api key"/"credentials" in a zero-file run — real, but only fires when the agent wrote nothing, routes to a human (the designed fallback), and is disclosed in the PR's "Known limitations". Not a defect.
  • external_service blocks immediately (no retry) in the zero-file path — consistent with _map_result's pre-existing behavior for non-zero exits (subprocess_adapter.py:290-297); the retry-aware should_create_blocker is only used in verification loops. Not introduced here.
  • Guard fail-open on malformed stdin / import failure — returns 0 (allow) by design; matches the "advisory-grade, not a sandbox" framing.

No inline comments posted. No files modified, no commits pushed.

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review

This PR closes #819 with four hardening follow-ups to ClaudeCodeAdapter: a PreToolUse hook that reuses is_dangerous_command() to filter the delegated claude CLI's Bash calls under bypassPermissions, a require_file_changes default that now follows what the allowlist actually grants, routing exit-0/zero-file runs with ambiguous stdout to a blocked status instead of a hard failure, and logging for previously-silent git errors. The approach (PreToolUse hook over a narrower --allowedTools deny-list, single source of truth for the dangerous-command patterns) is well-reasoned and the PR body is upfront about the advisory-grade nature of the guard and its known limitations. Test coverage for the new logic (guard behavior, require_file_changes inference, blocked-vs-failed routing, git-error logging) is thorough.

A few things worth a look:

  • codeframe/core/adapters/claude_code_guard.py:12is_dangerous_command is imported from codeframe.core.executor, which transitively imports codeframe.adapters.llmcodeframe/adapters/llm/openai.py (top-level import openai). Since the guard runs as a brand-new python -m codeframe.core.adapters.claude_code_guard subprocess for every Bash call the delegated CLI makes (not once per process, per core/tools.py's in-process usage), this pays SDK-import + interpreter-startup overhead on every single Bash invocation for what should be a lightweight regex check. Worth considering whether is_dangerous_command/DANGEROUS_PATTERNS could live in a leaner module the guard imports directly, bypassing the adapters.llm import chain.

  • codeframe/core/adapters/subprocess_adapter.py:252 — the new blocked-vs-failed classification only inspects result.output (stdout); on the exit-0 path stderr is discarded entirely (_map_result's exit_code == 0 branch never captures it). If a genuine ambiguity/access message lands on stderr rather than stdout, classify_error_for_blocker won't see it and the run falls through to the generic "lacked write permission" hard failure instead of the new blocker flow. Likely a narrow case for --print output, but worth confirming stdout is where clarifying text always lands.

  • codeframe/core/adapters/subprocess_adapter.py:256-258_extract_blocker_question (unchanged, but now reused on this new path) returns just the last non-empty stdout line. For free-form --print prose where the ambiguous question isn't the final line (e.g. the agent asks the question, then adds a trailing "let me check the schema" sentence), the blocker surfaced to the human would be that unrelated trailing line rather than the actual question.

  • codeframe/core/adapters/claude_code.py:16_WRITE_TOOLS is a hardcoded 5-name frozenset (edit, write, multiedit, notebookedit, bash) used to infer require_file_changes from an allowlist. Any other write-capable tool name not in that set (e.g. an MCP write tool) would be misclassified as read-only, silently defaulting require_file_changes to False and masking a real false-completion — reopening the exact problem this PR is fixing. Per the PR body this path is dead code today (engine_registry never passes allowlist), so low urgency now, but worth a comment noting the set needs to stay in sync with whatever tool names the next allowlist-passing caller uses.

  • Minor reuse nit: git_utils.py:detect_modified_files and subprocess_adapter.py:_git_head both add near-identical logger.warning(...) blocks around subprocess.run git calls in this PR. Since git_utils.py already exists as the shared git-helpers module, a small shared "run git, log on failure" helper could avoid the duplication — not blocking, just a small consolidation opportunity.

Nothing here blocks merging — the stdout-only classification and _WRITE_TOOLS gaps are edge cases the PR's own "known limitations" section is already in the spirit of, and the efficiency point is a latency/cost concern rather than a correctness one.

…#819)

Addresses post-PR review feedback on #865.

Guard import weight (verified + measured). The guard is spawned as a fresh
subprocess for every Bash call the delegated CLI makes, so its import cost
is paid per command. Importing is_dangerous_command from core.executor
pulled 930 modules including the openai SDK -- ~423ms per Bash call for
what is a regex match. Two causes, both fixed:

  - core/executor.py imports codeframe.adapters.llm -> openai. Patterns
    extracted to a stdlib-only leaf, core/dangerous_commands.py; executor
    re-exports both names so the import path and the single source of
    truth are preserved.
  - core/adapters/__init__.py eagerly imports every adapter (and so the
    SDKs again), so any module in that package pays it. Guard moved to
    core/claude_code_guard.py, outside the package.

423ms -> 142ms per Bash call. A regression test asserts the guard imports
no LLM SDK and that both call sites share one function object.

require_file_changes polarity. _WRITE_TOOLS was a hardcoded set of write
tools, so an unrecognized name (an MCP write tool, Task, anything added
later) read as read-only -- silently disabling the zero-file guard and
re-opening the #739 false completion. Inverted to _READ_ONLY_TOOLS: the
unknown case now means write-capable, which fails loudly instead. A
misread read-only tool only costs a noisy failure on an analysis run;
a misread write tool costs a silent false pass.

Re-verified live against the real claude CLI after the move: a stale hook
path would fail open silently, so the deny path was re-tested end-to-end
rather than trusted to the unit tests.
@frankbria

Copy link
Copy Markdown
Owner Author

Post-PR cross-family review + feedback triage

Reviewer chain: opencode (GLM) is the preferred primary, but it timed out twice at 420s/540s returning zero bytes — unavailable in practice. Fell back to codex review --base main (OpenAI/Codex family) per the review invariants. Run from a scratch directory with the diff passed inline, so the reviewer was strictly read-only (no --auto); repo tree verified unmodified after.

codex verdict: no actionable correctness issues.

The changes add a Claude Code Bash guard, improve file-change defaults, and route zero-change ambiguity outputs to blockers without introducing a clear functional regression in the reviewed diff.

review/review bug-hunter: no defects found; explicitly rejected 3 candidates as already-disclosed limitations.

claude bot: 5 findings, all self-marked non-blocking. Triaged below — verified before acting, per the verify-before-fix rule.

Fixed (2)

1. Guard import weight — confirmed, and worse than reported. Measured before assuming:

modules imported per-Bash-call cost
before 930 (incl. full openai SDK) ~423ms
after ~300 ~142ms

The guard is a fresh subprocess per Bash call, so this is paid every command — ~21s of pure overhead on a 50-command task. The bot identified core.executoradapters.llmopenai, but fixing that alone changed nothing: core/adapters/__init__.py eagerly imports every adapter and re-pulls the SDKs, so any module in that package pays it. Both fixed — patterns extracted to a stdlib-only leaf (core/dangerous_commands.py, re-exported by executor so the single-source-of-truth property the guard design depends on is preserved), and the guard moved to core/claude_code_guard.py. Pinned by a regression test asserting no LLM SDK import and that both call sites share one function object (so the patterns can't drift into a copy).

2. _WRITE_TOOLS sync risk — took a stronger fix than suggested. The bot proposed a comment to keep the set in sync. Instead the polarity is inverted to _READ_ONLY_TOOLS, so an unknown tool (MCP write tool, Task, anything future) is assumed write-capable. Same code size, but the failure mode flips to the safe side: misreading a write tool as read-only silently disables the zero-file guard and re-opens the #739 false completion, whereas misreading a read-only tool only costs a loud failure on an analysis run.

Rebutted (3)

3. stderr not classified on the exit-0 path. stdout is where a --print CLI emits the agent's prose; stderr carries diagnostics. Folding stderr into blocker classification would surface warning text as the human-facing question — I observed exactly such a warning on stderr during live testing (Permission allow rule ... is not matched). Net-negative, out of scope.

4. _extract_blocker_question returns the last line. Pre-existing and unchanged; the non-zero-exit path has used it identically since before this PR. A real limitation, but not a regression introduced here — separate concern.

5. Dedupe the two git logger.warning blocks. Nitpick. The call sites differ (2 git commands vs 1); a shared wrapper adds indirection to save ~4 lines.

Verification of the fixes

  • Re-verified the guard live against the real claude CLI on the new module path. Mandatory, not optional: the move changed the hook command, and a stale path fails open silently — unit tests would still pass while the guard did nothing. It still denies.
  • Test-mutation sanity check — all 5 new behaviors fail when deliberately broken (neutered pattern list → 7 failures; inverted allowlist polarity → 5; removed blocker routing → 2; downgraded git warnings → 4; guard re-importing executor → 1).
  • Diff coverage 96.67% on changed modules (claude_code.py and git_utils.py at 100%).
  • Full local suite + ruff re-run clean on uncontaminated source.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 9m 29s —— View job


🔍 Precision bug-hunting review

Scope: concrete defects only (logic errors, null derefs, security, data loss, races, breaking contract changes, resource leaks). Style/coverage/architecture are out of scope — covered by the general review.

  • Gather PR diff + existing comments (don't repeat prior findings)
  • Read changed files + callers/callees to confirm each candidate
  • Post inline comments for confirmed defects
  • Post summary

✅ GLM review: no defects found.

Read every source hunk (claude_code.py, git_utils.py, subprocess_adapter.py, claude_code_guard.py, dangerous_commands.py, executor.py) plus the integration points an inline diff can't show — the runtime consumer, the blocker classifier, the AgentResult model, and engine_registry's call site. No inline findings; nothing survived verification.

Candidate concerns checked and cleared (each has no concrete failure scenario that isn't already an explicitly-disclosed limitation or pre-existing behavior):

area verdict
_grants_write_access polarity + rule-shape parsing (entry.split("(",1)[0]) Correct. Unknown tool → write-capable (safe side); Bash(git *)"bash"→write; read-only Bash rules over-classify to write (loud, safe). Empty list guarded by if allowlist.
Zero-file blocked path → runtime.py:826 Consumed correctly: status_map["blocked"]→BLOCKED, blocker created when blocker_question set. result.error=None is safe (logging guards (result.error or "")[:500]). _extract_blocker_question always returns a non-empty string, so no "blocked with empty question" state gap.
dangerous_commands.py move Verbatim; regression test asserts claude_code_guard.is_dangerous_command is leaf_impl is executor_impl — one object, not a drifting copy. All callers (tools.py, executor._is_dangerous_command) resolve through the re-export.
Guard JSON contract (security-relevant — wrong shape = silent fail-open) Grounded against the live docs: --settings accepts "a settings JSON file or an inline JSON string" and merges (keys omitted keep file values), so the inline payload registers without clobbering user settings; PreToolUse deny is exactly hookSpecificOutput.permissionDecision:"deny" + permissionDecisionReason ("deny prevents the tool call… shown to Claude"); matcher "Bash" is exact-tool-name matching. Guard output matches verbatim.
Tactical-beats-access classifier priority Shared classify_error_for_blocker, identical to the pre-existing non-zero-exit path — not introduced here, and arguably correct (agent has a path forward).

The "false blocker on a benign stdout mention of an access term" case is real but only fires when the agent wrote nothing, routes to a human (the designed fallback), and is disclosed in the PR's Known Limitations — not a defect.

No files modified, no commits pushed.

@frankbria

Copy link
Copy Markdown
Owner Author

Demo verification — outcome evidence per acceptance criterion

Re-run against the final commit (a9d8606), not the original one. This mattered: item 3's logic changed in the review round (the _WRITE_TOOLS_READ_ONLY_TOOLS inversion), so the earlier demo was stale evidence for code that no longer existed.

Item 1 — dangerous-command guard (live, against the real claude CLI)

Not mocked. The adapter's actual --settings payload was handed to a real claude --print run. dd if=/dev/zero of=/dev/null matches a dangerous pattern but is harmless if it executes — so a guard failure would have been visible as success, not damage.

command outcome
dd if=/dev/zero of=/dev/null count=1 denied before reaching the shell — delegated agent received: CodeFrame blocked this command: dd writing to device. Destructive commands are refused for delegated runs; achieve the task another way.
echo SAFE-OK allowed → returned SAFE-OK (guard discriminates; it isn't blanket-blocking)

Re-verified on the new -m codeframe.core.claude_code_guard path after the module move — a stale hook path fails open silently, so unit tests passing would have proven nothing.

Item 2 — exit-0 clarification → blocker, not hard failure

requirements ambiguity -> blocked   q='The specification unclear: should deletes cascade?'
access issue           -> blocked   q='Cannot continue: the credentials missing for the repo'
no signal              -> failed    (hard fail, unchanged)
tactical question      -> failed    (agent's own job, by design)

Item 3 — require_file_changes derived from what the allowlist grants (post-inversion)

allowlist=None                    -> True      (bypass mode grants writes)
allowlist=['Read', 'Grep']        -> False     (analysis run writes nothing)
allowlist=['Read', 'Write']       -> True
allowlist=['Bash(git *)']         -> True      (rule-shaped entry parsed)
allowlist=['mcp__fs__write_file'] -> True      <- unknown tool, fails SAFE
allowlist=['Task']                -> True      <- unknown tool, fails SAFE
allowlist=['Read(src/**)']        -> False
explicit require_file_changes=True on read-only -> True  (caller still wins)

Item 4 — git errors distinguishable from "agent did nothing"

LOG WARNING: git diff failed in /tmp/x (exit 128): not a git repository —
reporting no modified files, which callers may read as 'agent did nothing'.

Gates

🤖 Generated with Claude Code

@frankbria
frankbria merged commit beaa871 into main Jul 15, 2026
11 checks passed
@frankbria
frankbria deleted the fix/819-claude-code-hardening branch July 15, 2026 06:21
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.

[P2.23] claude-code engine: harden require_file_changes + bypassPermissions follow-ups (from #739 review)

1 participant