From 492b99e564fd1a25584d98bfaae7ac528a03452d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sat, 24 Oct 2026 16:58:01 +0000 Subject: [PATCH 1/2] tsk-ewkvsx: tag time -o/--output and tee writes as output-redirect RED ``` $ python -m pytest -q tests/test_claude_code_shell_profile.py E E Right contains one more item: 'output-redirect' E Use -v to get more diff tests/test_claude_code_shell_profile.py:1110: AssertionError =========================== short test summary info ============================ FAILED tests/test_claude_code_shell_profile.py::test_time_output_option_tags_output_redirect FAILED tests/test_claude_code_shell_profile.py::test_time_tee_output_redirect_profile_refusal FAILED tests/test_claude_code_shell_profile.py::test_tee_file_operand_tags_output_redirect 3 failed, 65 passed in 11.12s ``` GREEN ``` $ python -m pytest -q tests/test_claude_code_shell_profile.py .................................................................... [100%] 68 passed in 10.39s ``` --- ...sx-shell-guard-time-tee-output-redirect.md | 2 ++ tests/test_claude_code_shell_profile.py | 35 +++++++++++++++++++ tinyagentos/claude_code_guard.py | 26 ++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 changelog.d/tsk-ewkvsx-shell-guard-time-tee-output-redirect.md diff --git a/changelog.d/tsk-ewkvsx-shell-guard-time-tee-output-redirect.md b/changelog.d/tsk-ewkvsx-shell-guard-time-tee-output-redirect.md new file mode 100644 index 000000000..2f490cb6a --- /dev/null +++ b/changelog.d/tsk-ewkvsx-shell-guard-time-tee-output-redirect.md @@ -0,0 +1,2 @@ +### Fixed +- Tag `time -o`/`--output` and `tee` file operands as `output-redirect` so profile deny rules catch program-level writes. diff --git a/tests/test_claude_code_shell_profile.py b/tests/test_claude_code_shell_profile.py index 57a523c37..56a66395f 100644 --- a/tests/test_claude_code_shell_profile.py +++ b/tests/test_claude_code_shell_profile.py @@ -1085,3 +1085,38 @@ def test_command_position_parameter_expansion_refused(): assert keys == ['echo'], f"expected ['echo'], got {keys!r}" payload = {"tool_name": "Bash", "tool_input": {"command": 'echo ${HOME}'}} assert refusal(payload) is None, "echo ${HOME} should be allowed" + + +# --------------------------------------------------------------------------- +# tsk-ewkvsx: time -o/--output and tee file operands tag output-redirect +# --------------------------------------------------------------------------- + + +def test_time_output_option_tags_output_redirect(): + from tinyagentos.claude_code_guard import commands + for cmd in ("time -o /tmp/x echo ok", "time -o/tmp/x echo ok", + "time --output=/tmp/x echo ok", "time --output /tmp/x echo ok"): + c = commands(cmd) + assert c is not None + assert "output-redirect" in c + assert "echo" in c + c = commands("time echo ok") + assert c is not None + assert "output-redirect" not in c + + +def test_tee_file_operand_tags_output_redirect(): + from tinyagentos.claude_code_guard import commands + assert commands("tee /tmp/x") == ["tee", "output-redirect"] + assert commands("tee -a /tmp/x") == ["tee", "output-redirect"] + assert commands("tee") == ["tee"] + + +def test_time_tee_output_redirect_profile_refusal(): + from tinyagentos.claude_code_guard import profile_refusal + msg, _ = profile_refusal("time -o /tmp/x echo ok", allow={"time", "echo", "tee"}, deny={"output-redirect"}) + assert msg is not None + msg, _ = profile_refusal("tee /tmp/x", allow={"time", "echo", "tee"}, deny={"output-redirect"}) + assert msg is not None + msg, _ = profile_refusal("time echo ok", allow={"time", "echo", "tee"}, deny={"output-redirect"}) + assert msg is None diff --git a/tinyagentos/claude_code_guard.py b/tinyagentos/claude_code_guard.py index f03f70017..4aa5e9b60 100644 --- a/tinyagentos/claude_code_guard.py +++ b/tinyagentos/claude_code_guard.py @@ -176,6 +176,7 @@ def add(key: str) -> None: wrapper_skip_duration = False # for timeout: skip the first non-option (duration) wrapper_skip_root = False # for chroot: skip the first non-option (root dir) env_split_string_pending = False # for env -S/--split-string: next token is the split string + tee_end_opts = False for tok in tokens: if tok and set(tok) <= _OPERATOR_CHARS: if set(tok) & set("<>") and not set(tok) & set(";|()"): @@ -197,6 +198,7 @@ def add(key: str) -> None: prog = None uniq_pos = 0 uniq_end_opts = False + tee_end_opts = False skip_next = False redirect_target = False continue @@ -259,6 +261,18 @@ def add(key: str) -> None: add(OUTPUT_REDIRECT) skip_next = True continue + # tee: any non-option argument (file operand) is a write + if prog == "tee": + if tee_end_opts or tok == "-": + add(OUTPUT_REDIRECT) + continue + if tok == "--": + tee_end_opts = True + continue + if tok.startswith("-"): + continue + add(OUTPUT_REDIRECT) + continue # uniq positional argument counting: 2nd positional is output file if prog == "uniq": if uniq_end_opts or tok == "-" or not tok.startswith("-"): @@ -286,6 +300,12 @@ def add(key: str) -> None: # else the rest of tok is the attached value break continue + if prog == "tee": + if tok == "--": + # everything after is positional operands + # but we need to track differently - simpler: just treat following as operands + pass # fall through to operand handling below + # handle tee options that take values? just skip options starting with - except -- # general --output handling (git diff, etc.) if tok == "--output" or tok.startswith("--output="): add(OUTPUT_REDIRECT) @@ -425,6 +445,9 @@ def add(key: str) -> None: # Wrapper-specific options that take a separate value if wrapper in _WRAPPER_OPTS_WITH_VALUE: if tok in _WRAPPER_OPTS_WITH_VALUE[wrapper]: + if wrapper == "time" and tok in ("-o", "--output", "-f", "--format"): + if tok in ("-o", "--output"): + add(OUTPUT_REDIRECT) skip_next = True continue # Handle attached forms like --userspec=VALUE, --groups=VALUE @@ -433,6 +456,8 @@ def add(key: str) -> None: # Check for attached value form (--opt=value) for opt in _WRAPPER_OPTS_WITH_VALUE[wrapper]: if opt.startswith("-") and tok.startswith(opt): + if wrapper == "time" and opt in ("-o", "--output"): + add(OUTPUT_REDIRECT) # This is an attached form, don't skip_next break # Other wrappers: skip options (words starting with -) @@ -448,6 +473,7 @@ def add(key: str) -> None: prog = name uniq_pos = 0 uniq_end_opts = False + tee_end_opts = False if name in SUBCOMMAND_PROGRAMS: sub_of = name else: From cdb01000be814abf2057e7b5e696fac128b5ba80 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sat, 24 Oct 2026 17:09:10 +0000 Subject: [PATCH 2/2] tsk-ewkvsx: drop unreachable tee option block left in commands() The first tee branch above it ends every arm in continue, so this block never ran; its comments were working notes, not code. Docs-Reviewed: dead code removal, no behaviour change; the tee/time tagging is already covered by changelog.d/tsk-ewkvsx-shell-guard-time-tee-output-redirect.md in this PR --- tinyagentos/claude_code_guard.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tinyagentos/claude_code_guard.py b/tinyagentos/claude_code_guard.py index 4aa5e9b60..1f2124da1 100644 --- a/tinyagentos/claude_code_guard.py +++ b/tinyagentos/claude_code_guard.py @@ -300,12 +300,6 @@ def add(key: str) -> None: # else the rest of tok is the attached value break continue - if prog == "tee": - if tok == "--": - # everything after is positional operands - # but we need to track differently - simpler: just treat following as operands - pass # fall through to operand handling below - # handle tee options that take values? just skip options starting with - except -- # general --output handling (git diff, etc.) if tok == "--output" or tok.startswith("--output="): add(OUTPUT_REDIRECT)