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

## 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.

Expand Down
22 changes: 15 additions & 7 deletions hooks/bash_guard.py
Original file line number Diff line number Diff line change
@@ -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/")
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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


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 = 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)
14 changes: 12 additions & 2 deletions tests/test_bash_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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 .",
Expand Down
Loading