From 3913c17a977b6f187d37ab98406a51ec7b70c21c Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Tue, 13 Oct 2026 06:50:15 +0000 Subject: [PATCH 1/9] feat(bash): add validation for sensitive file access and secret exfiltration --- agent/tools/bash.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index e571894..215a87a 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -24,6 +24,16 @@ "git clean -f", "git checkout .", ] +# Patterns that indicate attempts to read sensitive files or exfiltrate secrets +SENSITIVE_PATTERNS = [ + "cat /etc/passwd", "cat /etc/shadow", "cat ~/.ssh", + "cat ~/.aws", "cat ~/.config", "env | grep -i key", + "env | grep -i secret", "env | grep -i token", + "printenv", "declare -p", "set | grep -i", + "/proc/self/environ", "AWS_SECRET", "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", "exfiltrate", "ex-filtrate", +] + # Minimum timeout to prevent accidental no-timeout execution MIN_TIMEOUT = 1 DEFAULT_TIMEOUT = 120 @@ -71,6 +81,14 @@ def _is_dangerous(self, command: str) -> bool: cmd_lower = command.lower() return any(p in cmd_lower for p in DANGEROUS_PATTERNS) + def _is_sensitive(self, command: str) -> Optional[str]: + """Check if command attempts to read sensitive files or exfiltrate secrets.""" + cmd_lower = command.lower() + for pattern in SENSITIVE_PATTERNS: + if pattern.lower() in cmd_lower: + return f"Blocked: command attempts to access sensitive data or exfiltrate secrets" + return None + def _sanitize_timeout(self, timeout: Optional[int]) -> int: """Validate and enforce timeout bounds.""" if timeout is None: @@ -85,6 +103,10 @@ def execute(self, command: str, timeout: Optional[int] = None, **kw) -> ToolResu if blocked: return ToolResult(False, "", blocked) + sensitive = self._is_sensitive(command) + if sensitive: + return ToolResult(False, "", sensitive) + if self._is_dangerous(command) and not self.auto_approve: return ToolResult( False, "", From e2547ab94b1e7e0dba5b05a04f74a5e2ec55a3ec Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:57:05 +0000 Subject: [PATCH 2/9] fix(review): replace substring blocklist with argv-based sensitive command detection Rework _is_sensitive to parse each shell segment's argv (via shlex) and check the resolved/expanded path or variable name, instead of doing a raw substring match on the whole command text. This fixes the false positives the reviewer found (grep for a secret's name, `printenv PATH`, `cat ~/.config/...`) while still catching the bypasses they demonstrated (`head`/`less /etc/shadow`, `cat /etc//shadow`, `$HOME/.ssh/id_rsa`, `cd ~/.ssh && cat id_rsa`, `env|grep key` with no spaces). Dropped the `exfiltrate`/`ex-filtrate` word-match patterns, which matched English prose, not behavior. Also route the sensitive check through the same auto_approve gate used by _is_dangerous (previously a hard block with no override), and have the error message include the actual match instead of a generic string. Added regression tests for the block cases, the must-not-block cases, and the auto_approve override. --- agent/tools/bash.py | 90 +++++++++++++++++++++++++++++++++++++-------- tests/test_tools.py | 48 ++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 215a87a..7276fb5 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -1,6 +1,7 @@ """Bash/shell command execution tool.""" import os +import re import subprocess import shlex import platform @@ -24,16 +25,32 @@ "git clean -f", "git checkout .", ] -# Patterns that indicate attempts to read sensitive files or exfiltrate secrets -SENSITIVE_PATTERNS = [ - "cat /etc/passwd", "cat /etc/shadow", "cat ~/.ssh", - "cat ~/.aws", "cat ~/.config", "env | grep -i key", - "env | grep -i secret", "env | grep -i token", - "printenv", "declare -p", "set | grep -i", - "/proc/self/environ", "AWS_SECRET", "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", "exfiltrate", "ex-filtrate", +# Commands whose argv[0] reads file contents (checked against SENSITIVE_FILE_PATTERNS) +FILE_READ_COMMANDS = {"cat", "less", "more", "head", "tail", "vim", "vi", "nano", "strings"} + +# Path fragments that identify secret-bearing files, matched against the +# normalized/expanded path argument rather than the raw command text +SENSITIVE_FILE_PATTERNS = [ + re.compile(r"/etc/passwd$"), + re.compile(r"/etc/shadow$"), + re.compile(r"\.ssh(/|$)"), + re.compile(r"\.aws(/|$)"), + re.compile(r"\.netrc$"), + re.compile(r"id_rsa$"), + re.compile(r"id_ed25519$"), + re.compile(r"/proc/(?:self|\d+)/environ$"), ] +# Commands that print environment variables; sensitive only when dumping +# everything (no specific name given) or when the requested name looks secret +ENV_DUMP_COMMANDS = {"env", "printenv", "set", "declare", "export"} + +# argv[0] for commands that can print a variable's value directly +ENV_PRINT_COMMANDS = {"echo", "printf"} + +SECRET_NAME_RE = re.compile(r"(API[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) +VAR_REF_RE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?") + # Minimum timeout to prevent accidental no-timeout execution MIN_TIMEOUT = 1 DEFAULT_TIMEOUT = 120 @@ -82,11 +99,50 @@ def _is_dangerous(self, command: str) -> bool: return any(p in cmd_lower for p in DANGEROUS_PATTERNS) def _is_sensitive(self, command: str) -> Optional[str]: - """Check if command attempts to read sensitive files or exfiltrate secrets.""" - cmd_lower = command.lower() - for pattern in SENSITIVE_PATTERNS: - if pattern.lower() in cmd_lower: - return f"Blocked: command attempts to access sensitive data or exfiltrate secrets" + """Check if command reads a sensitive file or prints a secret-looking + environment variable. Operates on parsed argv per shell segment so + that commands merely mentioning a secret's name (e.g. grepping for + it in source) are not flagged, only commands structured to read or + print one. + """ + for segment in re.split(r"\|\||&&|[;|\n]", command): + segment = segment.strip() + if not segment: + continue + try: + tokens = shlex.split(segment) + except ValueError: + continue + if not tokens: + continue + + argv0 = os.path.basename(tokens[0]) + args = tokens[1:] + + if argv0 in FILE_READ_COMMANDS: + for arg in args: + if arg.startswith("-"): + continue + expanded = os.path.normpath( + os.path.expanduser(os.path.expandvars(arg)) + ) + for pat in SENSITIVE_FILE_PATTERNS: + if pat.search(expanded): + return f"Blocked: '{segment}' reads sensitive file '{arg}'" + + if argv0 in ENV_DUMP_COMMANDS: + named = [a for a in args if not a.startswith("-")] + if not named or any(SECRET_NAME_RE.search(a) for a in named): + return f"Blocked: '{segment}' dumps environment variables" + + if argv0 in ENV_PRINT_COMMANDS: + for arg in args: + for var_name in VAR_REF_RE.findall(arg): + if SECRET_NAME_RE.search(var_name): + return ( + f"Blocked: '{segment}' prints the value of " + f"'{var_name}'" + ) return None def _sanitize_timeout(self, timeout: Optional[int]) -> int: @@ -104,8 +160,12 @@ def execute(self, command: str, timeout: Optional[int] = None, **kw) -> ToolResu return ToolResult(False, "", blocked) sensitive = self._is_sensitive(command) - if sensitive: - return ToolResult(False, "", sensitive) + if sensitive and not self.auto_approve: + return ToolResult( + False, "", + f"{sensitive}\n" + "Set auto_approve_bash=true in config or confirm interactively." + ) if self._is_dangerous(command) and not self.auto_approve: return ToolResult( diff --git a/tests/test_tools.py b/tests/test_tools.py index fdf7f1a..1a1f779 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -239,6 +239,54 @@ def test_timeout(self): self.assertFalse(result.success) self.assertIn("timed out", result.error) + def test_sensitive_file_reads_blocked(self): + guarded_tool = BashTool(self.tmpdir, auto_approve=False) + for cmd in ( + "cat /etc/passwd", + "cat /etc/shadow", + "head /etc/shadow", + "less /etc/shadow", + "cat /etc//shadow", + "cat ~/.ssh/id_rsa", + "cat $HOME/.ssh/id_rsa", + "cd ~/.ssh && cat id_rsa", + "cat /proc/self/environ", + ): + result = guarded_tool.execute(command=cmd) + self.assertFalse(result.success, cmd) + self.assertIn("sensitive", result.error.lower(), cmd) + + def test_env_dumps_blocked(self): + guarded_tool = BashTool(self.tmpdir, auto_approve=False) + for cmd in ( + "env", + "env|grep key", + "printenv", + "declare -p", + "printenv ANTHROPIC_API_KEY", + "echo $ANTHROPIC_API_KEY", + ): + result = guarded_tool.execute(command=cmd) + self.assertFalse(result.success, cmd) + + def test_sensitive_checks_overridable_with_auto_approve(self): + auto_tool = BashTool(self.tmpdir, auto_approve=True) + result = auto_tool.execute(command="echo $ANTHROPIC_API_KEY") + self.assertTrue(result.success) + + def test_legitimate_commands_not_blocked(self): + guarded_tool = BashTool(self.tmpdir, auto_approve=False) + for cmd in ( + "grep -rn ANTHROPIC_API_KEY scripts/", + "printenv PATH", + "cat ~/.config/nvim/init.lua", + "echo hello", + "ls -la", + ): + result = guarded_tool.execute(command=cmd) + self.assertNotIn("sensitive", (result.error or "").lower(), cmd) + self.assertNotIn("dumps environment", (result.error or ""), cmd) + class TestConversation(unittest.TestCase): def test_basic_conversation(self): From 78a12a74e7f03f6afae17000b77162ff456f7be9 Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:21:23 -0500 Subject: [PATCH 3/9] fix(review): allow set option toggles and keep checking unparsable segments Only bare `set` prints shell variables. `set -e`, `set -x` and `set -o pipefail` toggle options and print nothing, so they no longer trip the environment-dump check. When shlex cannot parse a segment (for example after the split cuts inside a quoted "|"), fall back to whitespace tokens instead of skipping the segment, so `cat /etc/shadow "|"` is still caught. The block message no longer suggests interactive confirmation, which does not lift this check. The docstring now says the guard is best-effort and does not inspect command substitution or interpreter wrappers. --- agent/tools/bash.py | 22 +++++++++++++++++++--- tests/test_tools.py | 6 ++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 7276fb5..4a0c588 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -104,6 +104,11 @@ def _is_sensitive(self, command: str) -> Optional[str]: that commands merely mentioning a secret's name (e.g. grepping for it in source) are not flagged, only commands structured to read or print one. + + This is a best-effort guard against accidental or injected leakage, + not a sandbox: only top-level segments are inspected, so command + substitution, backticks, and interpreter wrappers such as + ``bash -c`` or ``python -c`` are not examined. """ for segment in re.split(r"\|\||&&|[;|\n]", command): segment = segment.strip() @@ -112,7 +117,10 @@ def _is_sensitive(self, command: str) -> Optional[str]: try: tokens = shlex.split(segment) except ValueError: - continue + # Unbalanced quotes (often from the split above cutting + # inside a quoted "|"). Fall back to whitespace tokens so + # the segment is still checked rather than skipped. + tokens = segment.split() if not tokens: continue @@ -132,7 +140,13 @@ def _is_sensitive(self, command: str) -> Optional[str]: if argv0 in ENV_DUMP_COMMANDS: named = [a for a in args if not a.startswith("-")] - if not named or any(SECRET_NAME_RE.search(a) for a in named): + # Only bare `set` prints variables; `set -e`, `set -x` and + # `set -o ...` toggle shell options and print nothing. + if argv0 == "set": + dumps = not args + else: + dumps = not named + if dumps or any(SECRET_NAME_RE.search(a) for a in named): return f"Blocked: '{segment}' dumps environment variables" if argv0 in ENV_PRINT_COMMANDS: @@ -164,7 +178,9 @@ def execute(self, command: str, timeout: Optional[int] = None, **kw) -> ToolResu return ToolResult( False, "", f"{sensitive}\n" - "Set auto_approve_bash=true in config or confirm interactively." + "Interactive confirmation does not lift this check; only " + "auto_approve_bash=true in config skips it, and that disables " + "confirmation for every bash command." ) if self._is_dangerous(command) and not self.auto_approve: diff --git a/tests/test_tools.py b/tests/test_tools.py index 1a1f779..18f0732 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -251,6 +251,7 @@ def test_sensitive_file_reads_blocked(self): "cat $HOME/.ssh/id_rsa", "cd ~/.ssh && cat id_rsa", "cat /proc/self/environ", + 'cat /etc/shadow "|"', ): result = guarded_tool.execute(command=cmd) self.assertFalse(result.success, cmd) @@ -262,6 +263,7 @@ def test_env_dumps_blocked(self): "env", "env|grep key", "printenv", + "set", "declare -p", "printenv ANTHROPIC_API_KEY", "echo $ANTHROPIC_API_KEY", @@ -282,6 +284,10 @@ def test_legitimate_commands_not_blocked(self): "cat ~/.config/nvim/init.lua", "echo hello", "ls -la", + "set -e; echo ok", + "set -x", + "set -eu", + "set -o pipefail", ): result = guarded_tool.execute(command=cmd) self.assertNotIn("sensitive", (result.error or "").lower(), cmd) From da365c56edbb6b6c663890f8beddc4e47fb2865b Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:24:07 -0500 Subject: [PATCH 4/9] fix: block xtrace when the command references a secret variable Allowing `set -x` opened a route around the echo/printenv checks: with tracing on, bash prints each command with its variables expanded, so `set -x; curl -H "Authorization: $ANTHROPIC_API_KEY" ...` writes the key to the output. Block `set -x`, `set -o xtrace` and `bash -x` (and other shells' -x) when a secret-looking variable appears anywhere in the command. Option toggles without a secret reference stay allowed. --- agent/tools/bash.py | 28 ++++++++++++++++++++++++++++ tests/test_tools.py | 14 ++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 4a0c588..869c1bd 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -51,6 +51,25 @@ SECRET_NAME_RE = re.compile(r"(API[_-]?KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) VAR_REF_RE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?") +# Shells whose -x flag traces commands with variables already expanded +TRACING_SHELLS = {"bash", "sh", "zsh", "dash", "ksh"} + + +def _enables_xtrace(argv0: str, args: list) -> bool: + """True if this command turns on xtrace (`set -x`, `set -o xtrace`, + `bash -x ...`), which prints every later command with its variables + expanded, so a secret referenced anywhere in the line reaches the + output.""" + if argv0 != "set" and argv0 not in TRACING_SHELLS: + return False + for i, arg in enumerate(args): + if arg.startswith("-") and not arg.startswith("--") and "x" in arg[1:]: + return True + if arg == "-o" and i + 1 < len(args) and args[i + 1] == "xtrace": + return True + return False + + # Minimum timeout to prevent accidental no-timeout execution MIN_TIMEOUT = 1 DEFAULT_TIMEOUT = 120 @@ -110,6 +129,9 @@ def _is_sensitive(self, command: str) -> Optional[str]: substitution, backticks, and interpreter wrappers such as ``bash -c`` or ``python -c`` are not examined. """ + secret_refs = [ + name for name in VAR_REF_RE.findall(command) if SECRET_NAME_RE.search(name) + ] for segment in re.split(r"\|\||&&|[;|\n]", command): segment = segment.strip() if not segment: @@ -127,6 +149,12 @@ def _is_sensitive(self, command: str) -> Optional[str]: argv0 = os.path.basename(tokens[0]) args = tokens[1:] + if secret_refs and _enables_xtrace(argv0, args): + return ( + f"Blocked: '{segment}' turns on command tracing, which " + f"would print the value of '{secret_refs[0]}'" + ) + if argv0 in FILE_READ_COMMANDS: for arg in args: if arg.startswith("-"): diff --git a/tests/test_tools.py b/tests/test_tools.py index 18f0732..ba95936 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -271,6 +271,20 @@ def test_env_dumps_blocked(self): result = guarded_tool.execute(command=cmd) self.assertFalse(result.success, cmd) + def test_xtrace_with_secret_reference_blocked(self): + # Tracing prints each command with variables expanded, so allowing + # `set -x` must not open a route around the echo/printenv checks. + guarded_tool = BashTool(self.tmpdir, auto_approve=False) + for cmd in ( + 'set -x; curl -s -H "Authorization: $ANTHROPIC_API_KEY" localhost', + 'set -eux; curl -s -H "x: ${OPENAI_API_KEY}" localhost', + 'set -o xtrace; curl -s -u "u:$GITHUB_TOKEN" localhost', + "bash -x -c 'curl -s -H \"k: $API_TOKEN\" localhost'", + ): + result = guarded_tool.execute(command=cmd) + self.assertFalse(result.success, cmd) + self.assertIn("command tracing", result.error, cmd) + def test_sensitive_checks_overridable_with_auto_approve(self): auto_tool = BashTool(self.tmpdir, auto_approve=True) result = auto_tool.execute(command="echo $ANTHROPIC_API_KEY") From 2c44174ef90273dee7e25852ebfee9d641ee2851 Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:27:08 -0500 Subject: [PATCH 5/9] fix(review): allow variable assignments and check more file readers `export TOKEN=...` and `env TOKEN=x ./run.sh` set a variable; they do not print one. Only bare names now count as reads in the env-dump check, so `printenv ANTHROPIC_API_KEY`, bare `env`/`export` and `declare -p` stay blocked while assignments pass. Add grep, sort, od, xxd and base64 to FILE_READ_COMMANDS so `grep . /etc/shadow` and `base64 ~/.ssh/id_rsa` are caught like `cat`. --- agent/tools/bash.py | 10 ++++++++-- tests/test_tools.py | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 869c1bd..936fda8 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -26,7 +26,10 @@ ] # Commands whose argv[0] reads file contents (checked against SENSITIVE_FILE_PATTERNS) -FILE_READ_COMMANDS = {"cat", "less", "more", "head", "tail", "vim", "vi", "nano", "strings"} +FILE_READ_COMMANDS = { + "cat", "less", "more", "head", "tail", "vim", "vi", "nano", "strings", + "grep", "sort", "od", "xxd", "base64", +} # Path fragments that identify secret-bearing files, matched against the # normalized/expanded path argument rather than the raw command text @@ -168,13 +171,16 @@ def _is_sensitive(self, command: str) -> Optional[str]: if argv0 in ENV_DUMP_COMMANDS: named = [a for a in args if not a.startswith("-")] + # NAME=value sets a variable (`export TOKEN=...`, + # `env TOKEN=x ./run.sh`); only bare names read one. + reads = [a for a in named if "=" not in a] # Only bare `set` prints variables; `set -e`, `set -x` and # `set -o ...` toggle shell options and print nothing. if argv0 == "set": dumps = not args else: dumps = not named - if dumps or any(SECRET_NAME_RE.search(a) for a in named): + if dumps or any(SECRET_NAME_RE.search(a) for a in reads): return f"Blocked: '{segment}' dumps environment variables" if argv0 in ENV_PRINT_COMMANDS: diff --git a/tests/test_tools.py b/tests/test_tools.py index ba95936..3402ac6 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -252,6 +252,8 @@ def test_sensitive_file_reads_blocked(self): "cd ~/.ssh && cat id_rsa", "cat /proc/self/environ", 'cat /etc/shadow "|"', + "grep . /etc/shadow", + "base64 ~/.ssh/id_rsa", ): result = guarded_tool.execute(command=cmd) self.assertFalse(result.success, cmd) @@ -302,6 +304,8 @@ def test_legitimate_commands_not_blocked(self): "set -x", "set -eu", "set -o pipefail", + "export ANTHROPIC_API_KEY=sk-test", + "env GITHUB_TOKEN=xxx true", ): result = guarded_tool.execute(command=cmd) self.assertNotIn("sensitive", (result.error or "").lower(), cmd) From 38063509e73f4aca095507a3561fcdbf0e65ac24 Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:28:54 -0500 Subject: [PATCH 6/9] fix: check the command wrapped by env and block env with no command `env [opts] NAME=value` with no command prints every variable, and `env NAME=value printenv` ran printenv unchecked because only argv[0] (env) was inspected. Strip env's options and assignments, block when no command is left, and run the usual checks on the wrapped command. --- agent/tools/bash.py | 30 ++++++++++++++++++++++++++++++ tests/test_tools.py | 5 +++++ 2 files changed, 35 insertions(+) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 936fda8..6d17336 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -73,6 +73,28 @@ def _enables_xtrace(argv0: str, args: list) -> bool: return False +# env options that take a separate argument (`env -u NAME`, `env -C DIR`) +ENV_OPTS_WITH_ARG = {"-u", "--unset", "-C", "--chdir", "-S", "--split-string"} + + +def _unwrap_env(tokens: list) -> Optional[list]: + """Strip `env`'s own options and NAME=value assignments. + + Returns the wrapped command's tokens, or None when nothing is left, + in which case `env` prints the whole environment. + """ + i = 1 + while i < len(tokens): + tok = tokens[i] + if tok in ENV_OPTS_WITH_ARG: + i += 2 + elif tok.startswith("-") or "=" in tok: + i += 1 + else: + return tokens[i:] + return None + + # Minimum timeout to prevent accidental no-timeout execution MIN_TIMEOUT = 1 DEFAULT_TIMEOUT = 120 @@ -149,6 +171,14 @@ def _is_sensitive(self, command: str) -> Optional[str]: if not tokens: continue + # `env [opts] [NAME=value ...] cmd` runs cmd, so check cmd + # itself; with no cmd it prints every variable. + while os.path.basename(tokens[0]) == "env": + wrapped = _unwrap_env(tokens) + if wrapped is None: + return f"Blocked: '{segment}' dumps environment variables" + tokens = wrapped + argv0 = os.path.basename(tokens[0]) args = tokens[1:] diff --git a/tests/test_tools.py b/tests/test_tools.py index 3402ac6..c2eabca 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -254,6 +254,7 @@ def test_sensitive_file_reads_blocked(self): 'cat /etc/shadow "|"', "grep . /etc/shadow", "base64 ~/.ssh/id_rsa", + "env FOO=1 cat ~/.ssh/id_rsa", ): result = guarded_tool.execute(command=cmd) self.assertFalse(result.success, cmd) @@ -266,6 +267,10 @@ def test_env_dumps_blocked(self): "env|grep key", "printenv", "set", + "env FOO=1", + "env -i FOO=1", + "env FOO=1 printenv", + "env -u HOME printenv ANTHROPIC_API_KEY", "declare -p", "printenv ANTHROPIC_API_KEY", "echo $ANTHROPIC_API_KEY", From c3262e56a14d16d370c44ec7e1fd8b4b200cf567 Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:30:31 -0500 Subject: [PATCH 7/9] fix: treat env -P and -a/--argv0 as taking an argument BSD/macOS env -P altpath and GNU env -a NAME consume the next token. Without them in ENV_OPTS_WITH_ARG, env -P /bin printenv took /bin for the wrapped command and printenv went unchecked. --- agent/tools/bash.py | 9 +++++++-- tests/test_tools.py | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 6d17336..98fa76f 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -73,8 +73,13 @@ def _enables_xtrace(argv0: str, args: list) -> bool: return False -# env options that take a separate argument (`env -u NAME`, `env -C DIR`) -ENV_OPTS_WITH_ARG = {"-u", "--unset", "-C", "--chdir", "-S", "--split-string"} +# env options that take a separate argument (`env -u NAME`, `env -C DIR`). +# Covers GNU and BSD/macOS env (-P altpath); missing one here would let +# the option's argument be taken for the wrapped command. +ENV_OPTS_WITH_ARG = { + "-u", "--unset", "-C", "--chdir", "-S", "--split-string", + "-a", "--argv0", "-P", +} def _unwrap_env(tokens: list) -> Optional[list]: diff --git a/tests/test_tools.py b/tests/test_tools.py index c2eabca..df1a34a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -271,6 +271,8 @@ def test_env_dumps_blocked(self): "env -i FOO=1", "env FOO=1 printenv", "env -u HOME printenv ANTHROPIC_API_KEY", + "env -P /bin printenv", + "env -a sh printenv", "declare -p", "printenv ANTHROPIC_API_KEY", "echo $ANTHROPIC_API_KEY", From 4af48f86cda192d0f2db2694cb05dab70246522d Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:31:33 -0500 Subject: [PATCH 8/9] fix: parse env options getopt-style and fail closed on unknown ones Skipping every dash token as a lone flag missed option clusters: `env -iu FOO printenv` took FOO for the command and never checked printenv. Walk short-option clusters character by character (an argument-taking option consumes the rest of the cluster or the next token), accept --opt=val long forms, re-parse the string given to -S/--split-string as further env arguments, and treat any option the parser does not know as a block rather than a pass. --- agent/tools/bash.py | 103 +++++++++++++++++++++++++++++++++----------- tests/test_tools.py | 12 ++++++ 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index 98fa76f..e406c52 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -73,31 +73,77 @@ def _enables_xtrace(argv0: str, args: list) -> bool: return False -# env options that take a separate argument (`env -u NAME`, `env -C DIR`). -# Covers GNU and BSD/macOS env (-P altpath); missing one here would let -# the option's argument be taken for the wrapped command. -ENV_OPTS_WITH_ARG = { - "-u", "--unset", "-C", "--chdir", "-S", "--split-string", - "-a", "--argv0", "-P", -} - - -def _unwrap_env(tokens: list) -> Optional[list]: - """Strip `env`'s own options and NAME=value assignments. - - Returns the wrapped command's tokens, or None when nothing is left, - in which case `env` prints the whole environment. +# env's own options, GNU and BSD/macOS. Short options in ENV_SHORT_WITH_ARG +# take an argument (the rest of the cluster, or the next token). +ENV_SHORT_FLAGS = set("iv0") +ENV_SHORT_WITH_ARG = set("uCSaPLU") +ENV_LONG_FLAGS = {"--ignore-environment", "--null", "--debug", "--list-signal-handling"} +ENV_LONG_WITH_ARG = {"--unset", "--chdir", "--split-string", "--argv0"} +# Signal options take an optional argument, only in the attached form. +ENV_LONG_OPTIONAL_ARG = {"--block-signal", "--default-signal", "--ignore-signal"} + + +def _unwrap_env(tokens: list) -> tuple: + """Parse `env [opts] [NAME=value ...] [cmd ...]` the way getopt does. + + Returns ("cmd", tokens) for the wrapped command, ("dump", None) when no + command is left (env then prints every variable), or ("unknown", None) + for an option this parser does not recognise. Callers block both of + the latter: failing closed is the point of the check. """ - i = 1 - while i < len(tokens): - tok = tokens[i] - if tok in ENV_OPTS_WITH_ARG: - i += 2 - elif tok.startswith("-") or "=" in tok: - i += 1 - else: - return tokens[i:] - return None + rest = list(tokens[1:]) + while rest: + tok = rest.pop(0) + if tok == "--": + break + if tok == "-": + continue # same as -i + if tok.startswith("--"): + name, eq, _ = tok.partition("=") + if name == "--split-string": + arg = tok[len(name) + 1:] if eq else (rest.pop(0) if rest else "") + try: + rest = shlex.split(arg) + rest + except ValueError: + return ("unknown", None) + elif name in ENV_LONG_WITH_ARG: + if not eq: + if not rest: + return ("unknown", None) + rest.pop(0) + elif name in ENV_LONG_FLAGS or name in ENV_LONG_OPTIONAL_ARG: + pass + else: + return ("unknown", None) + continue + if tok.startswith("-"): + for pos, ch in enumerate(tok[1:], start=1): + if ch in ENV_SHORT_FLAGS: + continue + if ch in ENV_SHORT_WITH_ARG: + attached = tok[pos + 1:] + if attached: + arg = attached + elif rest: + arg = rest.pop(0) + else: + return ("unknown", None) + if ch == "S": + # -S splits its string into further env arguments. + try: + rest = shlex.split(arg) + rest + except ValueError: + return ("unknown", None) + break + return ("unknown", None) + continue + if "=" in tok: + continue + return ("cmd", [tok] + rest) + # Arguments after "--" are the command, except leading assignments. + while rest and "=" in rest[0]: + rest.pop(0) + return ("cmd", rest) if rest else ("dump", None) # Minimum timeout to prevent accidental no-timeout execution @@ -179,9 +225,14 @@ def _is_sensitive(self, command: str) -> Optional[str]: # `env [opts] [NAME=value ...] cmd` runs cmd, so check cmd # itself; with no cmd it prints every variable. while os.path.basename(tokens[0]) == "env": - wrapped = _unwrap_env(tokens) - if wrapped is None: + kind, wrapped = _unwrap_env(tokens) + if kind == "dump": return f"Blocked: '{segment}' dumps environment variables" + if kind == "unknown": + return ( + f"Blocked: '{segment}' uses env options this guard " + "cannot parse, so it may dump environment variables" + ) tokens = wrapped argv0 = os.path.basename(tokens[0]) diff --git a/tests/test_tools.py b/tests/test_tools.py index df1a34a..05f62cf 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -273,6 +273,16 @@ def test_env_dumps_blocked(self): "env -u HOME printenv ANTHROPIC_API_KEY", "env -P /bin printenv", "env -a sh printenv", + "env -iu FOO printenv", + "env -uFOO printenv", + "env --unset FOO printenv", + "env --unset=FOO printenv", + "env -S 'printenv ANTHROPIC_API_KEY'", + "env -S'FOO=1 printenv'", + "env --split-string='printenv'", + "env -- FOO=1 printenv", + "env --made-up-option printenv", + "env -Z printenv", "declare -p", "printenv ANTHROPIC_API_KEY", "echo $ANTHROPIC_API_KEY", @@ -313,6 +323,8 @@ def test_legitimate_commands_not_blocked(self): "set -o pipefail", "export ANTHROPIC_API_KEY=sk-test", "env GITHUB_TOKEN=xxx true", + "env -i PATH=/usr/bin true", + "env -u FOO true", ): result = guarded_tool.execute(command=cmd) self.assertNotIn("sensitive", (result.error or "").lower(), cmd) From a8343dbb8c8cc84904b7746d51362cab3e3ea2c1 Mon Sep 17 00:00:00 2001 From: Scott Severance Date: Thu, 15 Oct 2026 00:33:27 -0500 Subject: [PATCH 9/9] fix: check commands run through wrappers such as timeout, nohup and sudo Only argv[0] was inspected, so `command printenv`, `timeout 5 printenv ANTHROPIC_API_KEY` and `nohup cat ~/.ssh/id_rsa` passed. For a known wrapper, check from the first later token that is itself a checked command. Wrapper options vary too much to parse, and the checked-command scan does not depend on them. --- agent/tools/bash.py | 28 +++++++++++++++++++++++++++- tests/test_tools.py | 8 ++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/agent/tools/bash.py b/agent/tools/bash.py index e406c52..2b444d7 100644 --- a/agent/tools/bash.py +++ b/agent/tools/bash.py @@ -146,6 +146,15 @@ def _unwrap_env(tokens: list) -> tuple: return ("cmd", rest) if rest else ("dump", None) +# Commands that run another command given later in their argv. Their own +# options vary too much to parse, so the guard checks from the first token +# that is itself a checked command. +WRAPPER_COMMANDS = { + "command", "exec", "builtin", "nice", "nohup", "time", "timeout", "stdbuf", + "sudo", "doas", "xargs", "ionice", "setsid", "chrt", "taskset", "unbuffer", +} + + # Minimum timeout to prevent accidental no-timeout execution MIN_TIMEOUT = 1 DEFAULT_TIMEOUT = 120 @@ -224,7 +233,24 @@ def _is_sensitive(self, command: str) -> Optional[str]: # `env [opts] [NAME=value ...] cmd` runs cmd, so check cmd # itself; with no cmd it prints every variable. - while os.path.basename(tokens[0]) == "env": + checked = ( + FILE_READ_COMMANDS | ENV_DUMP_COMMANDS | ENV_PRINT_COMMANDS + | TRACING_SHELLS | WRAPPER_COMMANDS | {"env"} + ) + while True: + head = os.path.basename(tokens[0]) + if head in WRAPPER_COMMANDS: + inner = next( + (i for i, t in enumerate(tokens[1:], start=1) + if os.path.basename(t) in checked), + None, + ) + if inner is None: + break + tokens = tokens[inner:] + continue + if head != "env": + break kind, wrapped = _unwrap_env(tokens) if kind == "dump": return f"Blocked: '{segment}' dumps environment variables" diff --git a/tests/test_tools.py b/tests/test_tools.py index 05f62cf..646e440 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -255,6 +255,8 @@ def test_sensitive_file_reads_blocked(self): "grep . /etc/shadow", "base64 ~/.ssh/id_rsa", "env FOO=1 cat ~/.ssh/id_rsa", + "nohup cat ~/.ssh/id_rsa", + "sudo -u root cat /etc/shadow", ): result = guarded_tool.execute(command=cmd) self.assertFalse(result.success, cmd) @@ -283,6 +285,10 @@ def test_env_dumps_blocked(self): "env -- FOO=1 printenv", "env --made-up-option printenv", "env -Z printenv", + "command printenv", + "exec env", + "timeout 5 printenv ANTHROPIC_API_KEY", + "nice -n 5 env FOO=1 printenv", "declare -p", "printenv ANTHROPIC_API_KEY", "echo $ANTHROPIC_API_KEY", @@ -325,6 +331,8 @@ def test_legitimate_commands_not_blocked(self): "env GITHUB_TOKEN=xxx true", "env -i PATH=/usr/bin true", "env -u FOO true", + "timeout 5 echo ok", + "time ls -la", ): result = guarded_tool.execute(command=cmd) self.assertNotIn("sensitive", (result.error or "").lower(), cmd)