From bda9eaeea5991d64a777c11d6649b0f540559cae Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 29 Jul 2026 07:50:41 +0100 Subject: [PATCH] chore(claude): sync vendored hooks with the live baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged rather than overwritten: the vendored compound-bash-allow.py had diverged from the live copy in BOTH directions. It was already ahead on control-flow keywords (regex prefix stripping, `for` headers including the arithmetic form, redirection tails on `done`, `break 2`), while the live copy had gained subshell-group unwrapping. Copying either way would have regressed the other. compound-bash-allow.py - strip_group_wrapper(): split_compound() tracks paren depth, so a subshell arrives intact and `cat f || (echo missing; find . -name f)` was judged on the binary `(echo`, matching nothing. The body is now re-split and each inner segment rule-checked. Unwrapping is the safe direction, as with keyword prefixes: `(rm -rf /)` reduces to `rm -rf /`, which DENY_BINARIES rejects; safe-listing `(` would have allowed it outright. - Brace groups: `{` joins _KW_PREFIX_RE, where the whitespace lookahead is what keeps `${VAR}` and brace expansion `{a,b}` out. `}` is matched ahead of _KW_BARE_RE because a trailing \b could never fire there — `}` and end-of-segment are both non-word, so no boundary exists. Like `done`, `}` may carry the group's redirections, so `{ echo a; } > out` is covered. - The trailing-redirection check after `)` reuses _REDIR_TAIL_RE rather than introducing a second regex for the same idea. compound-bash.json (new) - Eight stdout-only binaries: strings, diff, cmp, od, comm, join, sha256sum, md5sum. Deploys via the existing `cp -a` of the tree, and is read in preference to ~/.claude/hooks/compound-bash.json. - xxd is deliberately excluded, with the reason recorded in the file: `xxd [infile [outfile]]` takes an output path as a bare positional and `-r` patches binary in place, so it writes with no redirection operator. Every entry in the list has to be reads-only for the safe list to mean anything. redirect-inline-eval.py - Ported _reads_program_from_stdin() from the live copy: `python3 - <<'EOF'`, `python3 <<'EOF'`, `cat x.py | node` and `deno run -` are inline eval by another spelling, and were falling through to a prompt instead of being redirected to sbx. Now byte-identical to the live hook. settings.template.json - Added `git worktree remove __HOME__/projects/*/.worktrees/*`. Worktrees under the project itself were unreachable — only `/tmp` targets had a rule. Written WITHOUT `-C` so normalize_git_prefix() covers both invocation forms from one rule. Note the trailing `*` means `--force` written after the path is also matched; the rule syntax cannot express "this subcommand but not that flag", and the existing `/tmp` rules already allow --force. Verified: 50/50 behavioural cases against the merged hook — subshells, brace groups, the keyword features that were already vendored, the new safe commands, and refusal of sudo/rm/mv/chmod/ln, curl|sh, $(), backticks and process substitution; 11/11 on the redirect hook; 7/7 against template rules rendered the way deploy-cursor-workspace.sh renders them. Diffing the merged hook against origin/main confirms that `while read` — the one uncovered case found — prompts in both, so it is pre-existing rather than a regression. Co-Authored-By: Claude Opus 5 (1M context) --- .../.claude/settings.template.json | 1 + scripts/claude/README.md | 1 + scripts/claude/hooks/compound-bash-allow.py | 119 +++++++++++++++++- scripts/claude/hooks/compound-bash.json | 13 ++ scripts/claude/hooks/redirect-inline-eval.py | 60 ++++++++- 5 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 scripts/claude/hooks/compound-bash.json diff --git a/examples/cursor-workspace/.claude/settings.template.json b/examples/cursor-workspace/.claude/settings.template.json index 25a810636..439286463 100644 --- a/examples/cursor-workspace/.claude/settings.template.json +++ b/examples/cursor-workspace/.claude/settings.template.json @@ -86,6 +86,7 @@ "Bash(git verify-tag *)", "Bash(git worktree add *)", "Bash(git worktree list *)", + "Bash(git worktree remove __HOME__/projects/*/.worktrees/*)", "Bash(gpg --list-secret-keys *)", "Bash(gpg --list-keys *)", "Bash(gpg --list-public-keys *)", diff --git a/scripts/claude/README.md b/scripts/claude/README.md index 2b94b2e3e..f25575937 100644 --- a/scripts/claude/README.md +++ b/scripts/claude/README.md @@ -18,6 +18,7 @@ scripts/claude/ ├── block-dynamic-shell.py # PreToolUse: deny $() / backticks / $VAR dynamic shell ├── block-gh-api-writes.py # PreToolUse: ask on mutating gh api (keep GET/HEAD free) ├── compound-bash-allow.py # PreToolUse: auto-allow safe compounds + normalized singles + ├── compound-bash.json # extra read-only safe commands for compound-bash-allow.py ├── curl-allow.json # host/path allowlist config for curl-read-allow.py ├── curl-read-allow.py # PreToolUse: auto-allow read-only curl to trusted hosts ├── redirect-inline-eval.py # PreToolUse: deny bare python -c / node -e; redirect to sbx diff --git a/scripts/claude/hooks/compound-bash-allow.py b/scripts/claude/hooks/compound-bash-allow.py index 16cfe4fb4..17e382981 100755 --- a/scripts/claude/hooks/compound-bash-allow.py +++ b/scripts/claude/hooks/compound-bash-allow.py @@ -32,6 +32,13 @@ `touch x` and `sleep 1` rather than on `while`/`do`/`done`. Keywords are never safe-listed: `do rm -rf /` reduces to `rm -rf /` and is rejected. +Grouped commands are unwrapped the same way. The splitter deliberately tracks +paren depth, so a subshell arrives intact as ONE segment whose apparent binary is +`(echo` — `cat f || (echo missing; find . -name f)` would never match a rule. +strip_group_wrapper() returns the body for re-splitting. Brace groups need no +special case: `{` and `}` are handled as control-flow keywords, since the +splitter cuts `{ echo a; }` into `{ echo a` and `}` before either is judged. + Heredocs with a quoted delimiter (`<<'EOF'` / `<<"EOF"`) are stripped before analysis: bash performs no expansion on such bodies, so they are inert stdin data. Unquoted heredocs (`</compound-bash.json + ~/.claude/hooks/compound-bash.json { "extraSafeCommands": ["my-tool", "another"], // or to fully replace the default safe list: @@ -509,7 +519,11 @@ def unwrap_runner(seg: str) -> str | None: # safe-listing `do` would auto-approve `do rm -rf /`, whereas stripping reduces # that segment to `rm -rf /`, which DENY_BINARIES rejects. Stripping can only # expose the real command to the existing checks; it can never grant more. -_KW_PREFIX_RE = re.compile(r"^(?:!|if|then|elif|else|while|until|do)(?=\s|$)\s*") +# +# `{` is included: a brace group's `{` is a keyword, not a binary, and the +# lookahead for whitespace is what keeps `${VAR}` and brace expansion `{a,b}` +# out — neither is followed by a space, so neither is ever read as a group. +_KW_PREFIX_RE = re.compile(r"^(?:!|if|then|elif|else|while|until|do|\{)(?=\s|$)\s*") # Keywords that carry no command of their own — allowed only as a whole segment. _KW_BARE_RE = re.compile(r"^(fi|done|esac|else|do|then|break|continue)\b(.*)$", re.DOTALL) @@ -530,7 +544,15 @@ def unwrap_runner(seg: str) -> str | None: def bare_keyword(seg: str) -> str | None: """The keyword, if `seg` is a standalone control-flow keyword; else None.""" - m = _KW_BARE_RE.match(seg.strip()) + s = seg.strip() + # `}` is matched ahead of _KW_BARE_RE because a `\b` after it could never + # fire: `}` and the end of the segment are both non-word, so there is no + # word boundary between them. Like `done`, a closing brace may carry the + # group's redirections (`{ echo a; } > out`). + if s.startswith("}"): + tail = s[1:].strip() + return "}" if not tail or _REDIR_TAIL_RE.match(tail) else None + m = _KW_BARE_RE.match(s) if not m: return None kw, tail = m.group(1), m.group(2).strip() @@ -557,6 +579,84 @@ def strip_keyword_prefix(seg: str) -> str | None: stripped = True +def _iter_unquoted(cmd: str): + """Yield (index, char) for each character outside single/double quotes.""" + in_s = in_d = False + i, n = 0, len(cmd) + while i < n: + c = cmd[i] + if in_s: + if c == "'": + in_s = False + i += 1 + continue + if in_d: + if c == "\\" and i + 1 < n: + i += 2 + continue + if c == '"': + in_d = False + i += 1 + continue + if c == "\\" and i + 1 < n: + i += 2 + continue + if c == "'": + in_s = True + i += 1 + continue + if c == '"': + in_d = True + i += 1 + continue + yield i, c + i += 1 + + +def strip_group_wrapper(seg: str) -> str | None: + """Unwrap a segment that is wholly a subshell `( … )`, returning its body. + + split_compound() tracks paren depth and so keeps a subshell intact, which + means a fallback like `cat f || (echo missing; find . -name f)` reaches + is_segment_allowed() as ONE segment whose first token is `(echo` — a binary + that matches nothing. Unwrapping lets the commands inside be split and + rule-checked individually. + + Unwrapping is the safe direction, exactly as for keyword prefixes: `(rm -rf + /)` reduces to `rm -rf /`, which DENY_BINARIES rejects. Safe-listing `(` + would have allowed it outright. + + Brace groups are not handled here — the splitter does not track `{`, so + `{ echo a; }` is already cut into `{ echo a` and `}`, which _KW_PREFIX_RE + and bare_keyword() judge. + + Returns None when seg is not a subshell, when the group is unterminated, or + when anything other than redirections follows the closing paren. + """ + s = seg.strip() + if not s or s[0] != "(": + return None + depth = 0 + end = -1 + for i, c in _iter_unquoted(s): + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + end = i + break + if end == -1: + return None # unterminated, or the closing paren was quoted + # A group may carry redirections (`(cd x && make) > log 2>&1`). Targets are + # not inspected: no rule in this file inspects them (`Bash(cat:*)` already + # matches `cat f > anywhere`), so vetting them only here would be theatre. + rest = s[end + 1:].strip() + if rest and not _REDIR_TAIL_RE.match(rest): + return None + return s[1:end].strip() or None + + def matches_rule(seg: str, rule: str) -> bool: if rule == seg: return True @@ -608,6 +708,18 @@ def is_segment_allowed(seg: str, rules: list[str], safe: set[str], base_cwd: str return True, "shell-keywords-only" ok, why = is_segment_allowed(kw_rest, rules, safe, base_cwd) return ok, f"keyword-stripped -> {why}" + body = strip_group_wrapper(seg) + if body is not None: + inner = split_compound(body) + if inner is None: + return False, "group body has risky tokens" + if not inner: + return False, "group body empty" + for s in inner: + ok, why = is_segment_allowed(s, rules, safe, base_cwd) + if not ok: + return False, f"group body segment rejected: {s!r} ({why})" + return True, f"group-unwrapped ({len(inner)} inner segment(s) allowed)" binary = first_binary(seg) if binary in DENY_BINARIES: return False, f"deny-binary:{binary}" @@ -685,6 +797,7 @@ def analyze(cmd: str, rules: list[str], safe: set[str], cwd: str | None = None) debug(f" single {seg!r} -> {ok} ({why})") if ok and ("env-stripped" in why or "git-normalized" in why or "-wrapped" in why or "glob-rule" in why + or "group-unwrapped" in why or "project-local-script" in why): return True, f"single (normalized) — {why}" return False, "single command — let normal flow decide" diff --git a/scripts/claude/hooks/compound-bash.json b/scripts/claude/hooks/compound-bash.json new file mode 100644 index 000000000..8205665b0 --- /dev/null +++ b/scripts/claude/hooks/compound-bash.json @@ -0,0 +1,13 @@ +{ + "_comment": "Read by compound-bash-allow.py, which prefers this copy over ~/.claude/hooks/compound-bash.json. 'extraSafeCommands' ADDS to the hook's DEFAULT_SAFE_COMMANDS; use 'safeCommands' instead to replace that list outright. A safe-listed binary auto-approves with ANY arguments as one segment of a compound command, so the bar for adding one is: reads only, writes nothing, executes nothing, opens no socket. Every entry below is stdout-only. Counter-example worth keeping in mind: xxd is deliberately ABSENT, because 'xxd [infile [outfile]]' takes an output path as a bare positional and '-r' patches binary in place — it writes without a redirection operator, which breaks the invariant even though it looks like a dump tool.", + "extraSafeCommands": [ + "strings", + "diff", + "cmp", + "od", + "comm", + "join", + "sha256sum", + "md5sum" + ] +} diff --git a/scripts/claude/hooks/redirect-inline-eval.py b/scripts/claude/hooks/redirect-inline-eval.py index 2516955f6..44a67926f 100755 --- a/scripts/claude/hooks/redirect-inline-eval.py +++ b/scripts/claude/hooks/redirect-inline-eval.py @@ -16,6 +16,10 @@ Rscript -e deno eval (subcommand form) +Also caught: the same interpreters reading their program from STDIN, which is +inline eval by another spelling — `python3 - <<'EOF'`, `python3 <<'EOF'`, +`cat x.py | node`, `deno run -`. See _reads_program_from_stdin(). + For a matching bare command the hook returns `deny` with a message telling the agent to re-issue it prefixed with `sbx`. The prefixed form is allowlisted (Bash(/scripts/claude/bin/sbx *)) and auto-approves, so the user sees no @@ -77,6 +81,21 @@ def _sbx_path() -> str: # Subcommand-style eval: basename -> first-arg subcommand that means eval. EVAL_SUBCOMMANDS = {"deno": ("eval",)} +# Interpreters that take the program itself from stdin, given a bare `-` in the +# program position or no program argument at all. +STDIN_PROGRAM = frozenset({ + "python", "python2", "python3", + "node", "nodejs", + "perl", "ruby", "php", "bun", +}) + +# Flag-only invocations that print and exit without reading a program. Excluded +# so version/help probes fall through to the normal flow instead of a redirect. +INFO_FLAGS = frozenset({ + "-V", "--version", "-h", "--help", "-v", + "--v8-options", "--print-config", +}) + ALREADY_WRAPPED = frozenset({"sbx", "bwrap"}) @@ -101,6 +120,35 @@ def _has_eval_flag(tokens: list[str], flags: tuple[str, ...]) -> bool: return False +def _reads_program_from_stdin(tokens: list[str], binary: str) -> bool: + """True when this interpreter will read its program text from stdin. + + Two spellings, both equivalent to -c/-e inline eval: + * bare `-` in the program position — `python3 - <<'EOF'`, `php -f -` + * no program argument at all — `python3 <<'EOF'`, `cat x.py | node` + + The allow hook excises a quoted heredoc body and splits on `|` before rule + matching, so the redirection itself is already gone by the time a segment + reaches this hook — the shape of interpreter+args is all there is to judge. + + Position handling is deliberately coarse, erring toward the sandbox on the + dash form and toward the prompt on the no-program form: + * a bare `-` ANYWHERE counts, so a `-` that is really a script's own + argument (`python3 tool.py -`) gets redirected to sbx, where it runs + unchanged; + * an option consuming a SEPARATE value (`python3 -W ignore`) reads as + "has a program" and falls open to the normal prompt. + """ + if binary not in STDIN_PROGRAM: + return False + args = tokens[1:] + if any(t == "-" for t in args): + return True + if any(t in INFO_FLAGS for t in args): + return False + return all(t.startswith("-") for t in args) + + def segment_needs_sandbox(seg: str, cba) -> bool: seg = cba.strip_env_prefix(seg) try: @@ -114,9 +162,14 @@ def segment_needs_sandbox(seg: str, cba) -> bool: return False if binary in EVAL_FLAGS and _has_eval_flag(tokens, EVAL_FLAGS[binary]): return True + if _reads_program_from_stdin(tokens, binary): + return True subs = EVAL_SUBCOMMANDS.get(binary) if subs and len(tokens) > 1 and tokens[1] in subs: return True + # `deno run -` is stdin eval; `deno run ` is a file the location hook vets. + if binary == "deno" and len(tokens) > 1 and tokens[1] == "run" and "-" in tokens[2:]: + return True return False @@ -134,10 +187,13 @@ def find_needs_sandbox(cmd: str) -> bool: def deny() -> None: reason = ( "Blocked before the permission prompt: this runs an un-sandboxed inline " - "interpreter (python -c / node -e / perl -e ...), which is not " + "interpreter — either an eval flag (python -c / node -e / perl -e ...) " + "or a program read from stdin (`python3 - <<'EOF'`, `python3 <<'EOF'`, " + "`cat x.py | node`) — which is not " "allowlisted and has no safe un-sandboxed use. Re-issue the command " "prefixed with the sandbox launcher " + SBX + " — e.g. `" + SBX + " " - "node -e '...'` or `" + SBX + " python3 -c '...'`. It runs under " + "node -e '...'`, `" + SBX + " python3 -c '...'`, or `" + SBX + " " + "python3 - <<'EOF'`. It runs under " "bubblewrap with the active project and /tmp " "read-write, the rest of the filesystem read-only, and no network, and " "it auto-approves with no prompt. If the code needs the NETWORK, the "