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..1f2124da1 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("-"): @@ -425,6 +439,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 +450,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 +467,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: