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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 47
- `bash_guard` lets `cat > file <<` create a new file: Write costs the same there, so blocking only cost agents a retry (15 reports after the previous fix). Rewriting or appending to an existing project file is still refused.

## 46
- `accept` scope check matches a scope path literally before treating `[` `*` `?` as glob: router directories like `[slug]` are names, and the check reported files under them as outside scope (4 reports).

Expand Down
7 changes: 5 additions & 2 deletions hooks/bash_guard.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""PreToolUse hook (Bash): refuse editing project files through the shell (sed -i, perl -i, cat > file <<, python3 -
heredocs that write); Edit/Write pay only the change. Exit 2 = blocked. Temporary and git-ignored files (build
output) pass; echo/tee > file are not covered on purpose."""
output) and new files from cat heredocs pass; echo/tee > file are not covered on purpose."""
import json
import os
import re
import subprocess
import sys
Expand Down Expand Up @@ -48,6 +49,8 @@ def edits(ln):
continue
seg = re.split(r"[|;&]", ln[m.end():], maxsplit=1)[0] # this command only, not `; rm /tmp/x` after it
target = m.group(1) if m.groups() else (seg.split() or [""])[-1]
if what == "cat > file <<" and not os.path.exists(target): # a new file: Write would cost the same
continue
if not scratch(target):
return what
return None
Expand All @@ -67,7 +70,7 @@ def main():
if not hits:
return 0
print(f"dpcli: {hits[0]} edits project files through the shell. Read the file first (cat in Bash does not count), "
f"then Edit (exact replacement); a new file — Write; bulk renames: ast-grep run -p OLD -r NEW -U. "
f"then Edit (exact replacement); a new file — Write or cat > file <<; bulk renames: ast-grep run -p OLD -r NEW -U. "
f"Temporary (/tmp) and git-ignored files (build/) are fine.", file=sys.stderr)
return 2

Expand Down
2 changes: 1 addition & 1 deletion lib/dpcli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
else in the current copy. Exit codes: 0 ok, 1 error/check failed, 2 arguments.
"""

VERSION = 46 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
VERSION = 47# bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
13 changes: 12 additions & 1 deletion tests/test_bash_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_blocks_shell_edits(self):
"sed -n -i 's/a/b/' f", "sed -e 's/a/b/' -i f", "perl -e 's/a/b/' -pi f", # -i anywhere
"echo \"<<EOF\"\nsed -i 's/a/b/' f", # a quoted "<<EOF" opens no heredoc
"sed -i s/a/b/ src/x.ts; rm /tmp/y", "sed -i s/a/b/ src/x.ts && rm /tmp/z", # target of its own segment
"cat > src/x.ts <<'EOF'\nx\nEOF", "python3 - <<'EOF'\nfrom pathlib import Path\nPath('a').write_text('x')\nEOF",
"python3 - <<'EOF'\nfrom pathlib import Path\nPath('a').write_text('x')\nEOF",
"python3 - <<'EOF'\nopen('a', 'w').write('x')\nEOF"):
code, err = run(cmd)
self.assertEqual(code, 2, cmd)
Expand All @@ -41,6 +41,17 @@ def test_allows_git_ignored_targets(self):
self.assertEqual(run(cmd, p.root)[0], 0, cmd)
self.assertEqual(run("sed -i s/a/b/ src/x.ts", p.root)[0], 2)

def test_cat_heredoc_blocks_existing_files_only(self): # a new file costs Write the same: nothing to save
p = Project()
self.addCleanup(p.cleanup)
(p.root / "src").mkdir(exist_ok=True)
(p.root / "src/x.ts").write_text("x")
for cmd in ("cat > src/x.ts <<'EOF'\ny\nEOF", "cat >> src/x.ts <<'EOF'\ny\nEOF"):
code, err = run(cmd, p.root)
self.assertEqual(code, 2, cmd)
self.assertIn("Edit", err)
self.assertEqual(run("cat > src/new.ts <<'EOF'\ny\nEOF", p.root)[0], 0)

def test_allows_programs_reads_and_quoted_text(self):
for cmd in ("rg -n foo src", "sed -n '1,20p' f", "python3 - <<'EOF'\nprint(1)\nEOF", "cat f | jq .",
"pnpm exec vitest run", ".dpcli/dpcli review X --from - <<'JSON'\n{}\nJSON",
Expand Down
Loading