Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/dippy/cli/find.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -41,9 +45,16 @@ 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.
# 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:
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"):
Expand All @@ -64,4 +75,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)
39 changes: 39 additions & 0 deletions tests/cli/test_find.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from conftest import is_approved, needs_confirmation

from dippy.core.config import parse_config

# ==========================================================================
# find
# ==========================================================================
Expand Down Expand Up @@ -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"
Loading