diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 134e7c870..70057953d 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -642,6 +642,37 @@ class TestSensitiveRedirectPattern: assert key is None assert desc is None + def test_redirect_to_dotenv_with_trailing_arg_requires_approval(self): + # The redirection target is still `.env`; the trailing token is just an + # extra argument to `echo`, so the file is overwritten. The old + # _COMMAND_TAIL anchor required the rest of the line to be empty/a + # separator and let this slip past the deny. + dangerous, key, desc = detect_dangerous_command("echo secret > .env extra") + assert dangerous is True + assert key is not None + assert "project env/config" in desc.lower() + + def test_redirect_to_dotenv_with_trailing_comment_requires_approval(self): + # A trailing `#` comment does not change the redirection target. + dangerous, key, desc = detect_dangerous_command("echo secret > .env # note") + assert dangerous is True + assert key is not None + assert "project env/config" in desc.lower() + + def test_append_to_config_yaml_with_trailing_arg_requires_approval(self): + dangerous, key, desc = detect_dangerous_command("echo mode: prod >> config.yaml foo") + assert dangerous is True + assert key is not None + assert "project env/config" in desc.lower() + + def test_redirect_to_config_yaml_backup_is_safe(self): + # `config.yaml.bak` is a different file; the boundary must end the path + # token at a word boundary so backup writes stay out of the deny. + dangerous, key, desc = detect_dangerous_command("echo x > config.yaml.bak") + assert dangerous is False + assert key is None + assert desc is None + class TestProjectSensitiveCopyPattern: def test_cp_to_local_dotenv_requires_approval(self): @@ -825,6 +856,14 @@ class TestProjectSensitiveTeePattern: assert key is not None assert "project env/config" in desc.lower() + def test_tee_to_dotenv_with_trailing_file_arg_requires_approval(self): + # tee writes to every file argument, so `.env` is overwritten even when + # another file follows it. The old _COMMAND_TAIL anchor missed this. + dangerous, key, desc = detect_dangerous_command("printenv | tee .env backup") + assert dangerous is True + assert key is not None + assert "project env/config" in desc.lower() + class TestPatternKeyUniqueness: """Bug: pattern_key is derived by splitting on \\b and taking [1], so diff --git a/tools/approval.py b/tools/approval.py index bc1a607a3..11c33f9f7 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -258,7 +258,21 @@ _USER_SENSITIVE_WRITE_TARGET = ( rf'{_CREDENTIAL_FILES})' ) _PROJECT_SENSITIVE_WRITE_TARGET = rf'(?:{_PROJECT_ENV_PATH}|{_PROJECT_CONFIG_PATH})' +# Anchor for the cp/mv/install rule, where the sensitive path is only a write +# target when it is the LAST argument (the destination). Requiring end-of-line +# (or a command separator) keeps `cp config.yaml backup.yaml` — config.yaml as +# the SOURCE — out of the deny. _COMMAND_TAIL = r'(?:\s*(?:&&|\|\||;).*)?$' +# Boundary for stream-write rules (`>`/`>>` redirection and `tee`), where the +# sensitive path is ALWAYS a write target no matter what follows it. We only +# need the path token to END at a shell word boundary — whitespace, a quote, a +# command separator, a redirection operator, a `#` comment, or end-of-line. +# Using _COMMAND_TAIL here was too strict: it required the rest of the line to +# be empty or a command separator, so `echo x > .env extra` (extra arg to echo) +# and `echo x > .env # note` (trailing comment) slipped past the deny even +# though the shell still overwrites `.env`. Mirrors the looser system-path +# redirection rule, which never had this restriction. +_WRITE_TARGET_BOUNDARY = r'(?=[\s;&|<>#"\']|$)' # ========================================================================= # Hardline (unconditional) blocklist @@ -487,8 +501,8 @@ DANGEROUS_PATTERNS = [ (r'\b(bash|sh|zsh|ksh)\s+<\s*>?\s*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via redirection"), - (rf'\btee\b.*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config via tee"), - (rf'>>?\s*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config via redirection"), + (rf'\btee\b.*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_WRITE_TARGET_BOUNDARY}', "overwrite project env/config via tee"), + (rf'>>?\s*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_WRITE_TARGET_BOUNDARY}', "overwrite project env/config via redirection"), (r'\bxargs\s+.*\brm\b', "xargs with rm"), # find -exec rm / -execdir rm — the -execdir variant (same semantics, # runs in the directory of each match) was previously missed. Claude