diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3cff1..918c18f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/hooks/bash_guard.py b/hooks/bash_guard.py index f6fafd1..6a7ca32 100644 --- a/hooks/bash_guard.py +++ b/hooks/bash_guard.py @@ -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 @@ -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 @@ -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 diff --git a/lib/dpcli/__init__.py b/lib/dpcli/__init__.py index 9cddfd4..d6c49f9 100644 --- a/lib/dpcli/__init__.py +++ b/lib/dpcli/__init__.py @@ -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) diff --git a/tests/test_bash_guard.py b/tests/test_bash_guard.py index 52d58e5..51390c1 100644 --- a/tests/test_bash_guard.py +++ b/tests/test_bash_guard.py @@ -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 \"< 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) @@ -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",