#!/usr/bin/env python3 """Tests for allow_safe_pipelines.is_allowed. Run with: python -m unittest test_allow_safe_pipelines (from this directory) The hook fails closed, so every case here is really a claim about one of two things: a command it must auto-approve (a false prompt is friction), or a command it must NOT approve (a false approve is a safety hole). The second group is the one that matters -- when in doubt about a new case, put it there. """ import unittest import allow_safe_pipelines as hook class Allowed(unittest.TestCase): """Commands that must be auto-approved.""" def check(self, command): self.assertTrue(hook.is_allowed(command), command) def test_single_command(self): self.check("ls") self.check("ls -la Scripts/") self.check("pwd") def test_exact_prefix_no_args(self): self.check("pwd") self.check("ls") def test_leading_whitespace(self): self.check(" ls Scripts/") def test_pipe_of_safe_stages(self): self.check("cat foo.txt | head -5") self.check("grep x foo.cs | wc -l") self.check("cat a | grep b | sort | uniq") def test_and_chain_of_safe_stages(self): self.check("ls Scripts/ && cat foo && head -3 bar") def test_and_chain_mixed_with_pipe(self): self.check("ls && cat foo | head -5") def test_semicolon_chain_of_safe_stages(self): self.check("ls; ls") self.check("ls -la Scripts/; echo done; cat foo") self.check("ls; cat foo | head -5 && wc -l bar") def test_trailing_semicolon(self): self.check("ls -la;") self.check("ls; cat foo; ") def test_reported_settings_inspection_command(self): self.check('ls -la /c/ElectricRoot/.claude/*.json 2>/dev/null; echo "=== settings.json ==="; ' 'cat /c/ElectricRoot/.claude/settings.json 2>/dev/null; echo "=== user settings ==="; ' 'cat /c/Users/numsg/.claude/settings.json 2>/dev/null | head -40') def test_original_reported_command(self): self.check('ls Scripts/ && echo "--- backlog ---" ' '&& cat /c/Users/numsg/ElectricRoot-Backlog.md 2>/dev/null | head -60') def test_safe_redirections(self): self.check("grep -n x foo.cs 2>&1 | wc -l") self.check("cat foo >/dev/null") self.check("cat foo 2>/dev/null") self.check("python -u build.py --project X 2>&1 | grep error") def test_metacharacters_inside_double_quotes_are_literal(self): self.check('echo "a > b"') self.check('echo "a; rm b"') self.check('echo "a & b"') self.check('echo "a && b"') self.check('grep "a|b" foo.txt') self.check('grep "a && b" foo.txt | wc -l') def test_metacharacters_inside_single_quotes_are_literal(self): self.check("echo 'a | b'") self.check("echo '$(whoami)'") self.check("echo '`whoami`'") self.check("grep 'foo;bar' file") def test_awk_sandbox_is_safe(self): # --sandbox neutralises the side effects at runtime, so the script body can be anything -- the # prefix approval is trusting gawk to honour the flag, exactly as `sed -n` trusts `-n`. self.check("awk --sandbox '{print $1}' foo.txt") self.check("cat foo | awk --sandbox '{print $2}'") self.check("awk --sandbox 'BEGIN{system(\"rm -rf /\")}' foo") def test_two_token_prefixes(self): self.check('python -m json.tool "C:/ElectricRoot/.claude/settings.local.json"') self.check("python -u Scripts/build.py --no-interactive") def test_read_only_svn_subcommands(self): self.check("svn diff Scripts/build.py") self.check("svn status") self.check("svn status Scripts/") self.check("svn log -l 5") self.check("svn info") self.check("svn list ^/trunk") self.check("svn cat Scripts/build.py") self.check("svn diff Scripts/ | head -40") self.check("svn status && svn diff Scripts/build.py") def test_ordinary_svn_diff_flags_stay_safe(self): self.check("svn diff --summarize") self.check("svn diff -r 1578:1579 Scripts/") self.check("svn diff -x -b Scripts/build.py") self.check("svn diff --internal-diff Scripts/build.py") self.check("svn log --diff") # --diff must not be swallowed by the --diff-cmd guard. def test_ordinary_modes_of_dual_use_commands_stay_safe(self): # rg -o is --only-matching (safe); only rg's exec flags are guarded, not every short -o. self.check("rg -o -n 'foo' src/") self.check("cat x | rg -i pattern") self.check("sort foo.txt") self.check("cat x | sort -n | uniq -c") self.check("uniq -c foo.txt") self.check("uniq -f2 foo.txt") # Attached value keeps a single operand. self.check("python -m json.tool foo.json") self.check("file foo.bin") self.check("sort -u - out.txt") # sort's positionals are all inputs; only -o writes. self.check("cat foo 2>/dev/null") self.check("cat foo 2>&1 | head -5") self.check("ls 2>/dev/null; cat foo 2>/dev/null") # Separator ends the redirect, not space. self.check("ls 2>/dev/null && cat foo 2>&1") class Refused(unittest.TestCase): """Commands that must NOT be auto-approved (the safety-critical half).""" def check(self, command): self.assertFalse(hook.is_allowed(command), command) def test_empty(self): self.check("") def test_unsafe_command(self): self.check("rm -rf /") self.check("python evil.py") def test_unsafe_stage_in_otherwise_safe_chain(self): self.check("ls && rm -rf foo") self.check("cat foo | python evil.py") self.check("ls && python evil.py") def test_backgrounding(self): self.check("sleep 10 &") self.check("cat foo & echo done") def test_file_write_redirection(self): self.check("cat foo > out.txt") self.check("echo hi > /etc/passwd") self.check("cat foo >> log.txt") def test_input_redirection(self): self.check("cat < /etc/passwd") def test_semicolon_chain_with_unsafe_stage(self): self.check("echo hi; rm foo") self.check("ls; python evil.py") self.check("ls -la; sed -i s/a/b/ foo") def test_double_semicolon_fails_closed(self): self.check("ls;; ls") def test_leading_semicolon_fails_closed(self): self.check("; rm foo") def test_command_substitution(self): self.check("echo $(whoami)") self.check("echo `whoami`") self.check('echo "$(whoami)"') # Active inside double quotes. self.check('cat "$(malicious)"') def test_escaped_dollar_paren_still_refused(self): # `\$` breaks the substitution, but the raw `$(` is still suspicious enough to defer to a # prompt -- and matches the pre-quote-aware behaviour, so it's not a regression. self.check("echo \\$(x)") def test_or_chain(self): self.check("ls || rm foo") self.check("cat foo || cat bar") def test_pipe_stderr_operator(self): self.check("cat foo |& grep x") def test_unterminated_quote(self): self.check('echo "unterminated') self.check("echo 'unterminated") def test_trailing_line_continuation(self): self.check("ls \\") def test_sed_not_safe(self): # sed is dropped wholesale: even `sed -n` can write files or, in GNU sed, run shell commands, # and there's no sandbox flag to lean on. All of these must prompt. self.check("sed -n 1,10p foo.txt") self.check("sed -n 'w /etc/passwd' foo") self.check("sed -n 's/a/b/w outfile' foo") self.check("sed -n '1e rm -rf /' foo") self.check("sed -i s/a/b/ foo") self.check("sed s/a/b/ foo") def test_bare_awk_not_safe(self): self.check("awk '{print}' foo") self.check("awk -F, '{print $1}' foo") self.check("awk --version") def test_rg_exec_flags_not_safe(self): self.check("rg --pre /bin/sh pattern") self.check("rg --pre=sh pattern src/") self.check("rg --hostname-bin evil pattern") self.check("rg -z pattern") self.check("rg --search-zip pattern") self.check("cat x | rg --pre sh pattern") # Hidden effect anywhere in the pipeline. def test_sort_write_and_exec_flags_not_safe(self): self.check("sort -o out.txt in.txt") self.check("sort -oout.txt in.txt") # Attached value. self.check("sort --output=out.txt in.txt") self.check("sort --compress-program=/bin/sh foo") self.check("cat x | sort -o out.txt") def test_positional_output_writes_not_safe(self): self.check("uniq foo bar") # bar is an output file. self.check("uniq -c foo bar") self.check("python -m json.tool in.json out.json") def test_stdin_dash_counts_as_an_operand(self): # `-` is stdin, not an option, so the file after it is still an output file. self.check("uniq - out.txt") self.check("cat x | uniq - out.txt") self.check("python -m json.tool - out.json") self.check("uniq -- - out.txt") def test_redirection_scrub_stops_at_a_token_boundary(self): # `2>/dev/nullx` redirects to a writable path; only an exact /dev/null may be scrubbed away. self.check("cat foo 2>/dev/nullx") self.check("cat foo 2>/dev/null.txt") self.check("cat foo 2>/dev/null/../../tmp/evil") self.check("cat foo 2>&1x") def test_file_compile_flag_not_safe(self): self.check("file -C -m foo.magic") self.check("file --compile -m foo.magic") def test_guarded_letter_inside_a_short_flag_cluster_not_safe(self): self.check("sort -uo out.txt in.txt") self.check("sort -nro out.txt in.txt") self.check("rg -iz pattern") self.check("file -mC foo.magic") self.check("cat x | sort -uo out.txt") def test_writing_svn_subcommands_not_safe(self): self.check("svn commit -m x") self.check("svn ci -m x") self.check("svn add foo.cs") self.check("svn revert Scripts/build.py") self.check("svn update") self.check("svn delete foo.cs") self.check("svn patch foo.diff") self.check("svn export ^/trunk out/") self.check("svn propset svn:ignore x .") self.check("svn status && svn commit -m x") def test_bare_svn_not_safe(self): # The allow list names subcommands, so `svn` alone must never satisfy a prefix. self.check("svn") self.check("svn --version") def test_svn_subcommand_aliases_not_safe(self): # Not a safety hole (these are read-only) -- a tripwire that the match is on the full # subcommand token, so no alias can ever shadow a spelled-out one. self.check("svn st") self.check("svn di Scripts/build.py") def test_svn_exec_flags_not_safe(self): self.check("svn diff --diff-cmd /bin/sh Scripts/") self.check("svn diff --diff-cmd=sh Scripts/") self.check("svn log --diff --diff-cmd sh") self.check("svn diff --config-option config:helpers:diff-cmd=/bin/sh Scripts/") self.check("svn diff --config-dir /tmp/evil Scripts/") self.check("svn log --editor-cmd /bin/sh") self.check("svn status | svn diff --diff-cmd sh") # Hidden effect anywhere in the pipeline. def test_svn_option_before_subcommand_not_safe(self): # svn permutes its argv, so these run the same as the guarded spelling -- they miss the # "svn diff" prefix instead, which is the other half of why both checks are needed. self.check("svn --config-dir /tmp/evil diff Scripts/") self.check("svn --diff-cmd /bin/sh diff Scripts/") def test_prefix_is_not_a_substring_match(self): # A safe prefix must be the whole command name, not just a leading substring of it. self.check("catt foo") self.check("lsof") class Masking(unittest.TestCase): """Direct checks on the quote scanner, so a split/guard regression can be localised.""" def test_unquoted_passthrough(self): single, full = hook._mask("cat foo | head") self.assertEqual(single, "cat foo | head") self.assertEqual(full, "cat foo | head") def test_single_quote_blanked_in_both(self): single, full = hook._mask("echo 'a|b'") self.assertNotIn("|", single) self.assertNotIn("|", full) def test_double_quote_blanked_only_in_full(self): single, full = hook._mask('echo "a|b"') self.assertIn("|", single) # Substitution view still sees inside double quotes. self.assertNotIn("|", full) # Structural view treats it as data. def test_unterminated_returns_none(self): single, full = hook._mask('echo "oops') self.assertIsNone(single) self.assertIsNone(full) def test_length_preserved(self): command = 'grep "a|b" foo && ls' single, full = hook._mask(command) self.assertEqual(len(single), len(command)) self.assertEqual(len(full), len(command)) class AbsoluteBuildScriptPrefixes(unittest.TestCase): """The absolute build.py spellings follow the checkout path instead of being pinned to one.""" def test_windows_root_yields_both_spellings(self): self.assertEqual( ("python -u C:/Repo/Scripts/build.py", "python -u /c/Repo/Scripts/build.py"), hook.absolute_build_script_prefixes(r"C:\Repo")) def test_nested_windows_root(self): self.assertEqual( ("python -u D:/work/Trunk/Scripts/build.py", "python -u /d/work/Trunk/Scripts/build.py"), hook.absolute_build_script_prefixes(r"D:\work\Trunk")) def test_posix_root_yields_one_spelling(self): self.assertEqual( ("python -u /home/me/repo/Scripts/build.py",), hook.absolute_build_script_prefixes("/home/me/repo")) def test_this_checkouts_own_absolute_invocation_is_allowed(self): for prefix in hook.absolute_build_script_prefixes(hook.REPO_ROOT): self.check_allowed(prefix + " --project Azimuth") def test_build_script_outside_this_checkout_is_not_allowed(self): # The derivation must not degrade into "any path ending in Scripts/build.py". self.assertFalse(hook.is_allowed("python -u /somewhere/else/Scripts/build.py")) def check_allowed(self, command): self.assertTrue(hook.is_allowed(command), command) if __name__ == "__main__": unittest.main()