Skip to content

Commit beaa871

Browse files
authored
fix(adapters): harden claude-code require_file_changes + bypassPermissions (#819)
Closes #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 had unrestricted Bash with no CodeFrame-side equivalent, and #565 turns externally-authored issue bodies into task prompts. Registers a PreToolUse hook (hooks fire even under bypassPermissions) reusing is_dangerous_command verbatim, so both engines block the same set from one source of truth. Patterns live in a stdlib-only leaf (core/dangerous_commands.py) and the guard outside the eagerly-importing adapters package, keeping the per-Bash-call hook cost at ~142ms rather than ~423ms. 2. Exit-0 clarifications route to a blocker instead of a hard failure. --print cannot prompt, so a genuine ambiguity is printed and the process exits clean; the zero-file guard hard-failed it with a misleading "likely lacked write permission". Tactical questions still fail, by design. 3. require_file_changes follows what the allowlist grants, not whether one was passed. An unrecognized tool is assumed write-capable so the unknown case fails loudly rather than silently disabling the #739 guard. 4. Git errors are logged; empty now means failed, so a transient hiccup was indistinguishable from "the agent changed nothing". Verified: guard proven live against the real claude CLI (denied dd if=/dev/zero of=/dev/null, allowed echo); 4275 tests pass; ruff clean; diff coverage 96.67%; test-mutation check passes on all 5 new behaviors; codex cross-family review (pre-PR + posted post-PR) and the GLM bug-hunter both found no defects. Known limitation: the guard is advisory-grade, not a sandbox — a regex matcher an agent that means to evade it can evade, same grade as ReAct's.
1 parent 1cad94f commit beaa871

10 files changed

Lines changed: 743 additions & 69 deletions

codeframe/core/adapters/claude_code.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,61 @@
22

33
from __future__ import annotations
44

5+
import json
6+
import shlex
7+
import sys
58
from pathlib import Path
69

710
from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter
811

12+
_GUARD_MODULE = "codeframe.core.claude_code_guard"
13+
14+
# Tools known to be incapable of changing the workspace. Everything *not* listed
15+
# here — Edit/Write/Bash, an MCP write tool, Task (whose subagent can write), a
16+
# tool that does not exist yet — is assumed write-capable.
17+
#
18+
# The polarity matters. Reading a write tool as read-only would silently switch
19+
# off the zero-file guard and re-open the #739 false completion; reading a
20+
# read-only tool as a write tool merely fails an analysis run loudly. So the
21+
# unknown case must land on "write". Keeping an allowlist of the safe names, not
22+
# a blocklist of the dangerous ones, is what makes that the default. (#819 review)
23+
_READ_ONLY_TOOLS = frozenset(
24+
{"read", "grep", "glob", "ls", "notebookread", "webfetch", "websearch"}
25+
)
26+
27+
28+
def _grants_write_access(allowlist: list[str]) -> bool:
29+
"""True unless every allowlist entry is a known read-only tool.
30+
31+
Entries may be bare tool names ("Edit") or rule-shaped ("Bash(git *)"), so
32+
match on the tool name preceding any rule parentheses.
33+
"""
34+
return not all(
35+
entry.split("(", 1)[0].strip().lower() in _READ_ONLY_TOOLS
36+
for entry in allowlist
37+
)
38+
39+
40+
def _guard_settings() -> str:
41+
"""Build the --settings payload registering the dangerous-command guard.
42+
43+
The hook runs under the interpreter already running CodeFrame, so the guard
44+
module is importable regardless of what is on the delegated CLI's PATH.
45+
"""
46+
hook_command = f"{shlex.quote(sys.executable)} -m {_GUARD_MODULE}"
47+
return json.dumps(
48+
{
49+
"hooks": {
50+
"PreToolUse": [
51+
{
52+
"matcher": "Bash",
53+
"hooks": [{"type": "command", "command": hook_command}],
54+
}
55+
]
56+
}
57+
}
58+
)
59+
960

1061
class ClaudeCodeAdapter(SubprocessAdapter):
1162
"""Adapter that delegates code execution to Claude Code CLI.
@@ -20,7 +71,7 @@ class ClaudeCodeAdapter(SubprocessAdapter):
2071
def __init__(
2172
self,
2273
allowlist: list[str] | None = None,
23-
require_file_changes: bool = True,
74+
require_file_changes: bool | None = None,
2475
) -> None:
2576
"""Initialize the Claude Code adapter.
2677
@@ -32,9 +83,16 @@ def __init__(
3283
are auto-approved in non-interactive ``--print`` mode.
3384
Without this, ``--print`` silently denies those tools and
3485
the delegated agent can analyze but never modify files. (#739)
35-
require_file_changes: If True (default), a run that exits 0 but touches
36-
no files is downgraded to ``failed`` — a coding task that
37-
writes nothing is a false completion.
86+
Either way a PreToolUse hook vets Bash commands against
87+
CodeFrame's dangerous-command patterns — the permission
88+
mode skips approval prompts, not hooks. (#819)
89+
require_file_changes: If True, a run that exits 0 but touches no files
90+
is downgraded to ``failed`` — a coding task that writes
91+
nothing is a false completion. Defaults to whether the
92+
adapter actually grants write access: on for the
93+
bypassPermissions default, and for an allowlist carrying a
94+
write tool; off for a read-only allowlist, whose whole
95+
point is a run that writes nothing. (#819)
3896
"""
3997
cli_args = ["--print"]
4098
if allowlist:
@@ -43,6 +101,15 @@ def __init__(
43101
else:
44102
cli_args.extend(["--permission-mode", "bypassPermissions"])
45103

104+
# Attached on both paths: an allowlist granting Bash has the same blast
105+
# radius as bypass mode, and a read-only one loses nothing by carrying it.
106+
cli_args.extend(["--settings", _guard_settings()])
107+
108+
if require_file_changes is None:
109+
require_file_changes = (
110+
_grants_write_access(allowlist) if allowlist else True
111+
)
112+
46113
super().__init__(
47114
binary="claude",
48115
cli_args=cli_args,

codeframe/core/adapters/git_utils.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@
22

33
from __future__ import annotations
44

5+
import logging
56
import subprocess
67
from pathlib import Path
78

9+
logger = logging.getLogger(__name__)
10+
811

912
def detect_modified_files(workspace_path: Path) -> list[str]:
1013
"""Detect files modified in a workspace via git diff.
1114
1215
Combines modified, staged, and untracked files. Returns an empty list
1316
if git is unavailable or the workspace is not a git repo.
17+
18+
Errors are logged rather than swallowed silently: an empty list means
19+
"failed" for ``require_file_changes`` adapters, so a transient git error
20+
(lock contention, missing binary, timeout) would otherwise be
21+
indistinguishable from "the agent changed nothing". (#819)
1422
"""
1523
try:
1624
result = subprocess.run(
@@ -21,6 +29,13 @@ def detect_modified_files(workspace_path: Path) -> list[str]:
2129
timeout=10,
2230
)
2331
if result.returncode != 0:
32+
logger.warning(
33+
"git diff failed in %s (exit %d): %s — reporting no modified "
34+
"files, which callers may read as 'agent did nothing'.",
35+
workspace_path,
36+
result.returncode,
37+
(result.stderr or "").strip() or "<no stderr>",
38+
)
2439
return []
2540

2641
files = [f for f in result.stdout.strip().splitlines() if f]
@@ -34,7 +49,16 @@ def detect_modified_files(workspace_path: Path) -> list[str]:
3449
)
3550
if untracked.returncode == 0:
3651
files.extend(f for f in untracked.stdout.strip().splitlines() if f)
52+
else:
53+
logger.warning(
54+
"git ls-files failed in %s (exit %d): %s — untracked files are "
55+
"missing from the result.",
56+
workspace_path,
57+
untracked.returncode,
58+
(untracked.stderr or "").strip() or "<no stderr>",
59+
)
3760

3861
return list(dict.fromkeys(files))
39-
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
62+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as e:
63+
logger.warning("git could not run in %s: %s", workspace_path, e)
4064
return []

codeframe/core/adapters/subprocess_adapter.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,27 @@ def _write_stdin() -> None:
242242
and head_after != head_before
243243
)
244244
if in_git_repo and not committed:
245-
result.status = "failed"
246-
result.error = (
247-
f"'{self._binary}' exited successfully but modified no files. "
248-
"A coding task must change at least one file; the agent likely "
249-
"lacked write permission or produced no edits."
250-
)
245+
# A zero-file exit-0 run may be the agent *asking* rather than
246+
# failing: `--print` has no way to prompt, so a genuine ambiguity
247+
# gets printed and the process exits clean. Route those to the
248+
# blocker flow a human can answer instead of hard-failing with a
249+
# misleading "lacked write permission". Tactical questions
250+
# classify as None by design — those are the agent's own job, so
251+
# they keep failing. (#819)
252+
category = classify_error_for_blocker(result.output or "")
253+
if category is not None:
254+
result.status = "blocked"
255+
result.error = None
256+
result.blocker_question = self._extract_blocker_question(
257+
result.output or ""
258+
)
259+
else:
260+
result.status = "failed"
261+
result.error = (
262+
f"'{self._binary}' exited successfully but modified no "
263+
"files. A coding task must change at least one file; the "
264+
"agent likely lacked write permission or produced no edits."
265+
)
251266

252267
return result
253268

@@ -296,6 +311,9 @@ def _git_head(self, workspace_path: Path) -> str | None:
296311
297312
None means "not a git repo, git unavailable, or an unborn HEAD" — i.e.
298313
a state where modified-file detection can't judge whether work happened.
314+
Errors are logged: since empty now means failed for require_file_changes
315+
adapters, a git hiccup would otherwise be indistinguishable from "the
316+
agent changed nothing" in the logs. (#819)
299317
"""
300318
try:
301319
result = subprocess.run(
@@ -306,9 +324,18 @@ def _git_head(self, workspace_path: Path) -> str | None:
306324
timeout=10,
307325
)
308326
if result.returncode != 0:
327+
logger.warning(
328+
"git rev-parse HEAD failed in %s (exit %d): %s",
329+
workspace_path,
330+
result.returncode,
331+
(result.stderr or "").strip() or "<no stderr>",
332+
)
309333
return None
310334
return result.stdout.strip() or None
311-
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
335+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as e:
336+
logger.warning(
337+
"git rev-parse HEAD could not run in %s: %s", workspace_path, e
338+
)
312339
return None
313340

314341
def _extract_blocker_question(self, output: str) -> str:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""PreToolUse hook: block dangerous Bash commands from a delegated claude CLI.
2+
3+
``ClaudeCodeAdapter`` runs ``claude`` with ``--permission-mode bypassPermissions``
4+
so ``--print`` mode does not silently deny Edit/Write/Bash (#739). That hands the
5+
delegated agent unrestricted Bash with none of the ``DANGEROUS_PATTERNS``
6+
filtering the built-in ReAct engine applies to every command it runs
7+
(``core/tools.py`` -> ``is_dangerous_command``). With GitHub Issues import (#565)
8+
turning externally-authored issue bodies into task prompts, that gap widens the
9+
prompt-injection blast radius.
10+
11+
``claude`` runs this module as a PreToolUse hook, which fires *even under*
12+
``bypassPermissions`` — the permission mode skips approval prompts, not hooks.
13+
Re-using ``is_dangerous_command`` keeps one source of truth for the patterns, so
14+
both engines block the same set. (#819)
15+
16+
Import cost is paid *per Bash call* (a fresh subprocess each time), so this module
17+
is kept deliberately cheap to import:
18+
19+
- The patterns live in the stdlib-only ``core.dangerous_commands`` leaf rather
20+
than in ``core.executor``, which drags in ``codeframe.adapters.llm`` and the
21+
openai SDK.
22+
- It sits in ``core`` rather than beside its adapter in ``core.adapters``, whose
23+
``__init__`` eagerly imports every adapter (and so the LLM SDKs again).
24+
25+
Together those took the guard from ~930 imported modules to ~300. (#819 review)
26+
27+
This is the same grade of protection ReAct has, no more: a regex matcher over the
28+
command string, evadable by an agent that means to evade it (``bash -c``, string
29+
splicing). It raises the floor for accidental and injected destructive commands;
30+
it is not a sandbox.
31+
32+
Contract (https://code.claude.com/docs/en/hooks): the hook JSON arrives on stdin;
33+
exit 0 with no output allows the call; exit 0 with a ``permissionDecision: deny``
34+
payload blocks it and feeds the reason back to the agent.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import json
40+
import sys
41+
42+
from codeframe.core.dangerous_commands import is_dangerous_command
43+
44+
45+
def main() -> int:
46+
"""Vet one PreToolUse payload. Returns the process exit code."""
47+
try:
48+
payload = json.load(sys.stdin)
49+
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
50+
# Not a payload we understand. Allow: a guard that wedges every run on a
51+
# parse hiccup is worse than one that misses a hook it can't read.
52+
return 0
53+
54+
if not isinstance(payload, dict) or payload.get("tool_name") != "Bash":
55+
return 0
56+
57+
tool_input = payload.get("tool_input")
58+
if not isinstance(tool_input, dict):
59+
return 0
60+
61+
command = tool_input.get("command")
62+
if not isinstance(command, str) or not command.strip():
63+
return 0
64+
65+
dangerous, reason = is_dangerous_command(command)
66+
if not dangerous:
67+
return 0
68+
69+
json.dump(
70+
{
71+
"hookSpecificOutput": {
72+
"hookEventName": "PreToolUse",
73+
"permissionDecision": "deny",
74+
"permissionDecisionReason": (
75+
f"CodeFrame blocked this command: {reason}. "
76+
"Destructive commands are refused for delegated runs; "
77+
"achieve the task another way."
78+
),
79+
}
80+
},
81+
sys.stdout,
82+
)
83+
return 0
84+
85+
86+
if __name__ == "__main__":
87+
sys.exit(main())
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Dangerous shell-command patterns — the shared filter for every engine.
2+
3+
Deliberately a **leaf module**: it imports nothing but the stdlib. Both consumers
4+
need it cheaply.
5+
6+
- The built-in ReAct engine imports it once per process (``core/tools.py``).
7+
- The claude-code guard (``core/claude_code_guard.py``) is spawned as a fresh
8+
subprocess for *every* Bash call the delegated CLI makes, so it pays this
9+
module's import cost per command. Living under ``core/executor.py`` — which
10+
pulls in ``codeframe.adapters.llm`` and the openai SDK, ~930 modules — cost
11+
~420ms per Bash call for what is a regex match. (#819 review)
12+
13+
``core/executor.py`` re-exports both names, so the historical import path keeps
14+
working and the patterns stay a single source of truth.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import re
20+
import shlex
21+
22+
# Module-level dangerous command patterns (importable by other modules like tools.py)
23+
DANGEROUS_PATTERNS: list[tuple[str, str]] = [
24+
# Recursive delete of root or home
25+
(r"\brm\s+(-[rf]+\s+)*[/~]", "recursive deletion of root or home"),
26+
(r"\brm\s+--no-preserve-root", "rm with --no-preserve-root"),
27+
# Writing to /dev/ devices
28+
(r">\s*/dev/", "redirect to /dev device"),
29+
(r"\bdd\s+.*of=/dev/", "dd writing to device"),
30+
# Filesystem destruction
31+
(r"\bmkfs\b", "filesystem format command"),
32+
(r"\bfdisk\b", "disk partition command"),
33+
# Fork bombs
34+
(r":\s*\(\s*\)\s*\{", "potential fork bomb"),
35+
(r"\bfork\s*while\s*true", "potential fork bomb"),
36+
# Dangerous dd operations
37+
(r"\bdd\s+if=/dev/", "dd reading from device"),
38+
# Dangerous chmod
39+
(r"\bchmod\s+(-[Rr]\s+)?777\s+/", "chmod 777 on root"),
40+
# Wget/curl piped to shell (potential malware download)
41+
(r"\b(wget|curl)\s+.*\|\s*(ba)?sh", "download piped to shell"),
42+
# Overwriting important system files
43+
(r">\s*/(etc|bin|usr|lib|sbin)/", "overwriting system directory"),
44+
]
45+
46+
47+
def is_dangerous_command(command: str) -> tuple[bool, str]:
48+
"""Check if a command matches dangerous patterns.
49+
50+
Uses regex-based patterns that are harder to bypass than substring matching.
51+
Normalizes whitespace and handles common shell escapes.
52+
53+
Args:
54+
command: The shell command to check
55+
56+
Returns:
57+
Tuple of (is_dangerous, description) where description explains the match
58+
"""
59+
# Normalize the command for comparison
60+
try:
61+
# Use shlex to handle escapes, then rejoin
62+
tokens = shlex.split(command)
63+
normalized = " ".join(tokens)
64+
except ValueError:
65+
# If shlex fails, use the original with normalized whitespace
66+
normalized = " ".join(command.split())
67+
68+
for pattern, description in DANGEROUS_PATTERNS:
69+
if re.search(pattern, normalized, re.IGNORECASE):
70+
return (True, description)
71+
# Also check original command in case normalization removed something
72+
if re.search(pattern, command, re.IGNORECASE):
73+
return (True, description)
74+
75+
return (False, "")

0 commit comments

Comments
 (0)