Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/cursor-workspace/.claude/settings.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)",
Expand Down
1 change: 1 addition & 0 deletions scripts/claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 116 additions & 3 deletions scripts/claude/hooks/compound-bash-allow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<<EOF`, whose body IS expanded), herestrings, $(...),
Expand All @@ -40,7 +47,10 @@
If any segment is unrecognized, or the command contains one of those bail-out
constructs, the hook stays silent and normal permission flow takes over.

Optional config: ~/.claude/hooks/compound-bash.json
Optional config, first of these that parses (a workspace copy beside this script
therefore overrides the per-user one):
<this script's directory>/compound-bash.json
~/.claude/hooks/compound-bash.json
{
"extraSafeCommands": ["my-tool", "another"],
// or to fully replace the default safe list:
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions scripts/claude/hooks/compound-bash.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
60 changes: 58 additions & 2 deletions scripts/claude/hooks/redirect-inline-eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(<workspace>/scripts/claude/bin/sbx *)) and auto-approves, so the user sees no
Expand Down Expand Up @@ -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"})


Expand All @@ -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:
Expand All @@ -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 <file>` 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


Expand All @@ -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 "
Expand Down
Loading