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
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).
136 changes: 136 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,142 @@ 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


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