Skip to content
6 changes: 6 additions & 0 deletions changelog.d/tsk-lzyw3v-claude-code-guard-wrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Fixed
- Wrapper programs (`env`, `nice`, `nohup`, `timeout`, `xargs`, `sudo`, `doas`, `exec`, `command`, `builtin`, `time`, `stdbuf`, `setsid`, `ionice`, `chroot`, `busybox`) no longer hide the real program in the shell profile: the wrapper's key is recorded and the wrapped program is also checked.
- Hard refusal for `gh` and `git push` now also applies to parsed command keys, catching escapes like `g""h pr list` and `'gh' pr list`.
- The hook's `main()` now fails closed on any exception: it prints the refusal to stderr and exits with code 2.
- Wrapper options that take a separate value (`nice -n/--adjustment`, `ionice -c/-n/-p/--class/--classdata/--pid`, `stdbuf -i/-o/-e/--input/--output/--error`, `xargs -n/-P/-I/-L/-s/-d/-a/-E/--max-args/--max-procs/--max-lines/--max-chars/--delimiter/--arg-file`, `timeout -s/--signal/-k/--kill-after`, `env -u/--unset/-C/--chdir/-S/--split-string`) no longer hide the real program; `chroot` skips its root directory operand before finding the program.
- Fixed three hard-refusal bypasses: `chroot` root operand mistaken for a wrapper (e.g. `/tmp/timeout`), `exec -a NAME` and `exec -aNAME` not skipping the alias value, and `env -S/--split-string` not parsing the split string for nested commands (fail closed on `$`, backslash, or unparseable).
3 changes: 3 additions & 0 deletions changelog.d/tsk-r3knpi-claude-code-guard-flag-clusters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Fixed bypass of hard refusal for clustered short flags (e.g., `exec -la NAME`, `sudo -Eu USER`, `chroot --userspec/--groups`) by enhancing the wrapper option parsing in `tinyagentos/claude_code_guard.py`. Added short option letter sets and extended chroot wrapper options to properly handle value-taking clustered flags and GNU long options with separate values.
233 changes: 233 additions & 0 deletions tests/test_claude_code_shell_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,239 @@ def test_control_commands_are_allowed():
assert refusal is None, cmd


# ---------------------------------------------------------------------------
# Wrapper programs, gh escaping, and fail-closed main() (tsk-lzyw3v)
# ---------------------------------------------------------------------------

def test_wrapper_programs_expose_real_program():
from tinyagentos.claude_code_guard import commands
# env
cmds = commands("env curl x")
assert "env" in cmds and "curl" in cmds, cmds
# timeout: first non-option is duration, then program
cmds = commands("timeout 60 curl x")
assert "curl" in cmds, cmds
# sudo -u bob curl x
cmds = commands("sudo -u bob curl x")
assert "curl" in cmds, cmds
# xargs curl
cmds = commands("xargs curl")
assert "curl" in cmds, cmds


def test_wrapper_option_values_do_not_hide_program():
from tinyagentos.claude_code_guard import commands
cases = [
("nice -n 10 curl x", "10"),
("ionice -c 3 curl x", "3"),
("stdbuf -o 0 curl x", "0"),
("xargs -n 1 curl", "1"),
("timeout -s KILL 60 curl x", "60"),
("chroot /newroot curl x", "newroot"),
("chroot / curl x", "/"),
]
for cmd, bad in cases:
cmds = commands(cmd)
assert "curl" in cmds, f"curl missing in {cmd!r}: {cmds}"
assert bad not in cmds, f"option value {bad!r} leaked as key in {cmd!r}: {cmds}"


def test_hard_refusal_sees_program_behind_wrapper_values():
from tinyagentos.claude_code_guard import refusal
cases = [
'chroot / g""h pr merge',
'chroot /newroot g""h pr merge',
'nice -n 10 g""h pr merge',
'timeout -s KILL 60 g""h pr merge',
'ionice -c 3 g""h pr merge',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
msg = refusal(payload)
assert msg is not None, f"should refuse but got None for {cmd!r}"
# positive control: sudo -u already fixed
payload = {"tool_name": "Bash", "tool_input": {"command": 'sudo -u x g""h pr merge'}}
assert refusal(payload) is not None


def test_hard_refusal_chroot_root_operand_bypass():
from tinyagentos.claude_code_guard import refusal
# chroot root operand parsed as a wrapper: /tmp/timeout has basename "timeout"
payload = {"tool_name": "Bash", "tool_input": {"command": 'chroot /tmp/timeout g""h pr merge'}}
assert refusal(payload) is not None


def test_hard_refusal_exec_a_option_bypass():
from tinyagentos.claude_code_guard import refusal
# exec -a NAME: -a takes a value but exec not in _WRAPPER_OPTS_WITH_VALUE
payload = {"tool_name": "Bash", "tool_input": {"command": 'exec -a masked g""h pr merge'}}
assert refusal(payload) is not None
# exec -aNAME attached form
payload = {"tool_name": "Bash", "tool_input": {"command": 'exec -amasked g""h pr merge'}}
assert refusal(payload) is not None


def test_hard_refusal_env_split_string_bypass():
from tinyagentos.claude_code_guard import refusal
# env -S 'g""h pr merge' (separate value)
payload = {"tool_name": "Bash", "tool_input": {"command": 'env -S \'g""h pr merge\''}}
assert refusal(payload) is not None
# env -Sg""h pr merge (attached value)
payload = {"tool_name": "Bash", "tool_input": {"command": 'env -Sg""h pr merge'}}
assert refusal(payload) is not None
# env --split-string='g""h pr merge' (long form attached)
payload = {"tool_name": "Bash", "tool_input": {"command": 'env --split-string=\'g""h pr merge\''}}
assert refusal(payload) is not None
# env -S with env var expansion (fail closed)
payload = {"tool_name": "Bash", "tool_input": {"command": r"env -S '${X} pr merge'"}}
assert refusal(payload) is not None


def test_hard_refusal_wrapper_bypass_positive_controls():
from tinyagentos.claude_code_guard import refusal
# chroot with normal root dir should still allow ls
payload = {"tool_name": "Bash", "tool_input": {"command": "chroot /srv/root ls -la"}}
assert refusal(payload) is None
# env -S with normal command should still allow ls
payload = {"tool_name": "Bash", "tool_input": {"command": "env -S 'ls -la'"}}
assert refusal(payload) is None


def test_hard_gh_refusal_on_parsed_keys():
from tinyagentos.claude_code_guard import refusal
# g""h and 'gh' should be refused same as gh
payload_gh = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "gh pr list"}}
payload_g_empty = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": 'g""h pr list'}}
payload_g_single = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "'gh' pr list"}}
msg_gh = refusal(payload_gh)
msg_g_empty = refusal(payload_g_empty)
msg_g_single = refusal(payload_g_single)
assert msg_gh is not None
assert msg_g_empty == msg_gh
assert msg_g_single == msg_gh
# git push also
payload_git = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "git push origin main"}}
assert refusal(payload_git) == msg_gh


# ---------------------------------------------------------------------------
# Wrapper bypass vulnerabilities that must be REFUSED (tsk-r3knpi)
# ---------------------------------------------------------------------------

def test_bypass_exec_clustered_short_flag():
from tinyagentos.claude_code_guard import refusal
# These clustered short flags should be REFUSED but are currently ALLOWED
cases = [
'exec -la masked g""h pr merge',
'exec -ca masked g""h pr merge',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
msg = refusal(payload)
assert msg is not None, f"should refuse but got None for {cmd!r}"


def test_bypass_sudo_clustered_short_flag():
from tinyagentos.claude_code_guard import refusal
# These clustered short flags should be REFUSED but are currently ALLOWED
cases = [
'sudo -Eu root g""h pr merge',
'sudo -nu root g""h pr merge',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
msg = refusal(payload)
assert msg is not None, f"should refuse but got None for {cmd!r}"


def test_bypass_chroot_attached_forms():
from tinyagentos.claude_code_guard import refusal
# These attached forms should be REFUSED but are currently ALLOWED
cases = [
'chroot --userspec=root:root /tmp/x g""h pr merge',
'chroot --groups=g /tmp/x g""h pr merge',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
msg = refusal(payload)
assert msg is not None, f"should refuse but got None for {cmd!r}"


# ---------------------------------------------------------------------------
# Positive controls that must stay ALLOWED (benign)
# ---------------------------------------------------------------------------

def test_benign_exec_clustered_short_flag():
from tinyagentos.claude_code_guard import refusal
# These benign cases should stay ALLOWED
cases = [
'exec -la foo ls',
'exec -ca foo ls',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
assert refusal(payload) is None, f"should allow but got refusal for {cmd!r}"


def test_benign_sudo_clustered_short_flag():
from tinyagentos.claude_code_guard import refusal
# These benign cases should stay ALLOWED
cases = [
'sudo -Eu root ls',
'sudo -nu root ls',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
assert refusal(payload) is None, f"should allow but got refusal for {cmd!r}"


def test_benign_chroot_attached_forms():
from tinyagentos.claude_code_guard import refusal
# These benign attached forms should stay ALLOWED
cases = [
'chroot --userspec=root:root /tmp/x ls',
'chroot --groups=g /tmp/x ls',
]
for cmd in cases:
payload = {"tool_name": "Bash", "tool_input": {"command": cmd}}
assert refusal(payload) is None, f"should allow but got refusal for {cmd!r}"


def test_benign_env_split_string():
from tinyagentos.claude_code_guard import refusal
# env -S 'ls -la' should stay ALLOWED
payload = {"tool_name": "Bash", "tool_input": {"command": "env -S 'ls -la'"}}
assert refusal(payload) is None


def test_benign_ls():
from tinyagentos.claude_code_guard import refusal
# ls -la should stay ALLOWED
payload = {"tool_name": "Bash", "tool_input": {"command": "ls -la"}}
assert refusal(payload) is None


def test_main_fails_closed_on_exception(tmp_path, monkeypatch):
from tinyagentos.claude_code_guard import main
import json
import sys
from io import StringIO
# Monkeypatch the rules loader path to raise
def raise_runtime(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("builtins.open", raise_runtime)
# Prepare stdin with a payload that triggers profile check
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
old_stdin = sys.stdin
sys.stdin = StringIO(payload)
try:
# main with --rules pointing to a file (will hit the patched open)
rc = main(["--rules", "/nonexistent/rules.json"])
finally:
sys.stdin = old_stdin
assert rc == 2


# ---------------------------------------------------------------------------
# Profile store, asks -> Decisions, answers -> rules, settings routes
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading