diff --git a/CHANGELOG.md b/CHANGELOG.md index c80c062..427f334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 37 +- Bash write guard: the refusal says up front to Read the file before Edit (an unread Edit cost one more round), and shell writes to git-ignored files (build output, artifacts) pass like `/tmp`. + ## 36 - `gc --remove` removes copies with a submodule (git refused without `--force`) and copies whose status shows changes but content equals HEAD (a hook restored the files); a change, a stash or a commit on no remote inside a submodule still blocks removal, and so does a failed submodule check. diff --git a/hooks/bash_guard.py b/hooks/bash_guard.py index 18391f6..f6fafd1 100644 --- a/hooks/bash_guard.py +++ b/hooks/bash_guard.py @@ -1,9 +1,10 @@ #!/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 files pass; echo/tee > file are -not covered on purpose.""" +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.""" import json import re +import subprocess import sys TEMP = ("/tmp/", "$TMPDIR", "${TMPDIR", "/dev/") @@ -16,6 +17,12 @@ PY_WRITES = re.compile(r"\.write_(?:text|bytes)\(|\bopen\([^)]*['\"][wax]b?\+?['\"]") +def scratch(path): + """Not a project file: temporary or ignored by git (build output, artifacts).""" + return path.startswith(TEMP) or subprocess.run(["git", "check-ignore", "-q", "--", path], + capture_output=True).returncode == 0 + + def split(cmd): """(command lines with quoted strings removed, heredoc bodies).""" lines, bodies, end = [], [], None @@ -41,7 +48,7 @@ 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 not target.startswith(TEMP): # temporary files are not project files + if not scratch(target): return what return None @@ -53,14 +60,15 @@ def main(): return 0 lines, body = split(cmd) hits = [w for w in map(edits, lines) if w] - writes = [ln for ln in body.split("\n") if PY_WRITES.search(ln) and not any(t.strip("/") in ln for t in TEMP)] + writes = [ln for ln in body.split("\n") if PY_WRITES.search(ln) and not any(t.strip("/") in ln for t in TEMP) + and not any(scratch(p) for p in re.findall(r"['\"]([^'\"\s]*/[^'\"\s]*)['\"]", ln))] if any(PY_HEREDOC.search(ln) for ln in lines) and writes: hits.append("python3 - writing files") if not hits: return 0 - print(f"dpcli: {hits[0]} edits project files through the shell — use the Edit tool (exact replacement) or Write " - f"(new file); read with Read; bulk renames: ast-grep run -p OLD -r NEW -U. Temporary files (/tmp) are fine.", - file=sys.stderr) + 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"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 2118742..2f29e3e 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 = 36 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec) +VERSION = 37 # 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 aab565e..52d58e5 100644 --- a/tests/test_bash_guard.py +++ b/tests/test_bash_guard.py @@ -9,9 +9,9 @@ GUARD = DIST / "hooks" / "bash_guard.py" -def run(cmd): +def run(cmd, cwd=None): r = subprocess.run([sys.executable, str(GUARD)], input=json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}), - capture_output=True, text=True) + capture_output=True, text=True, cwd=cwd) return r.returncode, r.stderr @@ -30,6 +30,16 @@ def test_blocks_shell_edits(self): code, err = run(cmd) self.assertEqual(code, 2, cmd) self.assertIn("Edit", err) + self.assertIn("Read the file first", err) # Edit refuses an unread file: say so now, not one round later + + def test_allows_git_ignored_targets(self): + p = Project() + self.addCleanup(p.cleanup) + (p.root / ".gitignore").write_text("build/\n") + for cmd in ("cat > build/r.json <<'EOF'\n{}\nEOF", "sed -i s/a/b/ build/x.md", + "python3 - <<'EOF'\nopen('build/a.txt', 'w').write('x')\nEOF"): + 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_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 .",