From 47c6d428acd70501e12f281dbd4ab0db1b70ad9a Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Mon, 8 Jun 2026 15:56:37 +0300 Subject: [PATCH 1/2] Route find -fprint/-fprintf/-fls targets through redirect rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #151 made these file-writing actions ask, which closed the allow->ask hole but treated them as a blanket prompt. They write to a file just like a `> file` redirect, so surface the file argument as a redirect_target (the sort -o pattern) instead. Now allow-redirect pre-approves and deny-redirect hard-blocks the target, exactly matching `>` — and an unmatched write still defaults to ask. --- src/dippy/cli/find.py | 25 ++++++++++++++++++++----- tests/cli/test_find.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/dippy/cli/find.py b/src/dippy/cli/find.py index 1493968..15aeea1 100644 --- a/src/dippy/cli/find.py +++ b/src/dippy/cli/find.py @@ -5,6 +5,8 @@ - -exec, -execdir: Execute arbitrary commands (delegates to inner command) - -ok, -okdir: Interactive execution (always ask) - -delete: Delete found files (always ask) +- -fprint, -fprint0, -fprintf, -fls: Write output to a file (checked against + redirect rules, like a `> file` redirect) """ from __future__ import annotations @@ -14,9 +16,10 @@ COMMANDS = ["find"] -# Actions that write find's output to a file (truncating it) — same effect as a -# `> file` redirect, which Dippy already guards. The `f` prefix means "file" -# (vs. -print/-printf/-ls which write to stdout and are safe). +# Actions that write find's output to a file (truncating it), same effect as a +# `> file` redirect. The `f` prefix means "file" (vs. -print/-printf/-ls which +# write to stdout and are safe). The file argument follows the flag; we surface +# it as a redirect target so the usual allow-redirect/deny-redirect rules apply. FILE_WRITE_ACTIONS = frozenset({"-fprint", "-fprint0", "-fprintf", "-fls"}) # Context for flags that aren't self-explanatory @@ -30,6 +33,7 @@ def classify(ctx: HandlerContext) -> Classification: """Classify find command by examining exec flags and delegating inner commands.""" tokens = ctx.tokens base = tokens[0] if tokens else "find" + write_targets: list[str] = [] for i, token in enumerate(tokens): # -ok/-okdir are interactive - always ask @@ -41,9 +45,13 @@ def classify(ctx: HandlerContext) -> Classification: if token == "-delete": return Classification("ask", description=f"{base} -delete") - # -fprint/-fprintf/-fls write output to a file (truncating it) + # -fprint/-fprintf/-fls write output to the following file; gate the + # target through redirect rules, exactly like a `> file` redirect. if token in FILE_WRITE_ACTIONS: - return Classification("ask", description=f"{base} {token}") + if i + 1 >= len(tokens): + return Classification("ask", description=f"{base} {token}") + write_targets.append(tokens[i + 1]) + continue # -exec/-execdir - extract inner command and delegate if token in ("-exec", "-execdir"): @@ -64,4 +72,11 @@ def classify(ctx: HandlerContext) -> Classification: description=f"{base} {token} {inner_name}", ) + if write_targets: + return Classification( + "allow", + description=f"{base} (write to file)", + redirect_targets=tuple(write_targets), + ) + return Classification("allow", description=base) diff --git a/tests/cli/test_find.py b/tests/cli/test_find.py index edaad3f..8a7b963 100644 --- a/tests/cli/test_find.py +++ b/tests/cli/test_find.py @@ -6,6 +6,8 @@ from conftest import is_approved, needs_confirmation +from dippy.core.config import parse_config + # ========================================================================== # find # ========================================================================== @@ -300,3 +302,40 @@ def test_find_command(check, command: str, expected: bool): assert is_approved(result), f"Expected approved for: {command}" else: assert needs_confirmation(result), f"Expected confirmation for: {command}" + + +class TestFindFileWriteRedirectRules: + """find -fprint/-fls/-fprintf surface their file target as a redirect, so the + same allow-redirect/deny-redirect rules apply as for a `> file` redirect.""" + + def test_default_config_asks(self, check_single): + """No redirect rule -> ask (default for an unmatched file write).""" + decision, _ = check_single("find . -fprint /tmp/out.txt") + assert decision is None # ask + + def test_deny_redirect_denies(self, check_single): + """deny-redirect hard-blocks the -fprint target.""" + cfg = parse_config('deny-redirect /etc/** "protected"') + decision, reason = check_single("find . -fprint /etc/hosts", config=cfg) + assert decision == "deny" + assert "protected" in reason + + def test_allow_redirect_approves(self, check_single): + """allow-redirect pre-approves the -fprint target (no prompt).""" + cfg = parse_config("allow-redirect /tmp/**") + decision, _ = check_single("find . -fprint /tmp/out.txt", config=cfg) + assert decision == "approve" + + def test_fls_and_fprintf_also_gated(self, check_single): + """-fls and -fprintf targets go through the same gating.""" + cfg = parse_config('deny-redirect /etc/** "protected"') + for cmd in ["find . -fls /etc/x", "find . -fprintf /etc/x '%p'"]: + decision, _ = check_single(cmd, config=cfg) + assert decision == "deny", cmd + + def test_matches_redirect_equivalent(self, check_single): + """`find . -fprint FILE` resolves the same as `find . > FILE`.""" + cfg = parse_config("allow-redirect /tmp/**") + fprint, _ = check_single("find . -fprint /tmp/x", config=cfg) + redirect, _ = check_single("find . > /tmp/x", config=cfg) + assert fprint == redirect == "approve" From 402face6cd6e49d59d06eceb9d587904cba8cc71 Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Mon, 8 Jun 2026 16:18:47 +0300 Subject: [PATCH 2/2] Document the write+delete/exec combine limitation in find handler --- src/dippy/cli/find.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dippy/cli/find.py b/src/dippy/cli/find.py index 15aeea1..83428a3 100644 --- a/src/dippy/cli/find.py +++ b/src/dippy/cli/find.py @@ -47,6 +47,9 @@ def classify(ctx: HandlerContext) -> Classification: # -fprint/-fprintf/-fls write output to the following file; gate the # target through redirect rules, exactly like a `> file` redirect. + # A preceding -delete/-exec returns first, so when a write is combined + # with one of those the target isn't separately gated — the command + # degrades to that branch's ask, never to something weaker. if token in FILE_WRITE_ACTIONS: if i + 1 >= len(tokens): return Classification("ask", description=f"{base} {token}")