bash-guard mapped 20 mutating git verbs to their jj equivalents but not `worktree`, so `git worktree add` passed the hook untouched. Claude Code's built-in EnterWorktree/ExitWorktree tools were a second hole: they create a git worktree directly, never going through Bash, so the guard never saw them. A git worktree in a jj repo is not a jj workspace. jj does not manage it, it never appears in `jj workspace list`, and none of jj's workspace bookkeeping applies to it -- the isolated checkout ends up outside the VCS that owns the repo. Add the `worktree` entry to the git->jj map and a PreToolUse matcher on EnterWorktree|ExitWorktree that exits 2 with the jj workspace commands on stderr. The tool matcher replaces a `permissions.deny` entry in user settings.json: it travels with the plugin and names the replacement command instead of failing silently. Read-only `git worktree list` is blocked along with the rest of the verb. It cannot see jj workspaces, so its empty output reads as "no isolated checkouts exist" when several do -- worse than a denial. Verified by running the guard against `git worktree add ../feature` over socket stdin and confirming both the denial and that the reason names `jj workspace add`. The new checks fail against the 1.1.1 map. Tests: 12 passing (bash-guard).
111 lines
3.9 KiB
Python
111 lines
3.9 KiB
Python
"""Regression test for bash-guard.mjs.
|
|
|
|
The payload MUST be delivered over a socketpair, not a pipe. Claude Code hands hook children
|
|
their stdin as a socket, where opening '/dev/stdin' fails ENXIO -- that failure mode is the
|
|
entire point of this test and a pipe would not reproduce it.
|
|
|
|
Run: python3 test-bash-guard.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
GUARD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bash-guard.mjs")
|
|
|
|
|
|
def run_guard(payload, cwd=None):
|
|
"""Invoke the guard with fd 0 as a socket. Returns its parsed stdout, or None if silent."""
|
|
parent, child = socket.socketpair()
|
|
proc = subprocess.Popen(
|
|
["node", GUARD], stdin=child.fileno(), stdout=subprocess.PIPE, close_fds=False, cwd=cwd
|
|
)
|
|
child.close()
|
|
parent.sendall(json.dumps(payload).encode())
|
|
parent.shutdown(socket.SHUT_WR)
|
|
out, _ = proc.communicate(timeout=30)
|
|
parent.close()
|
|
return json.loads(out) if out.strip() else None
|
|
|
|
|
|
def bash(command):
|
|
return {"tool_name": "Bash", "tool_input": {"command": command}}
|
|
|
|
|
|
def decision(result):
|
|
return (result or {}).get("hookSpecificOutput", {}).get("permissionDecision")
|
|
|
|
|
|
def reason(result):
|
|
return (result or {}).get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
|
|
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, condition, detail=""):
|
|
if condition:
|
|
print(f" PASS {name}")
|
|
else:
|
|
print(f" FAIL {name} {detail}")
|
|
failures.append(name)
|
|
|
|
|
|
print("bash-guard over socket stdin:")
|
|
|
|
r = run_guard(bash('git commit -m "hook test"'))
|
|
check("git commit is denied", decision(r) == "deny", f"got {decision(r)!r}")
|
|
check("denial names the jj equivalent", "jj describe" in reason(r), f"got {reason(r)!r}")
|
|
|
|
r = run_guard(bash("git worktree add ../feature"))
|
|
check("git worktree is denied", decision(r) == "deny", f"got {decision(r)!r}")
|
|
check("denial names jj workspace", "jj workspace add" in reason(r), f"got {reason(r)!r}")
|
|
|
|
r = run_guard(bash("git status"))
|
|
check("read-only git is allowed", decision(r) != "deny", f"got {decision(r)!r}")
|
|
|
|
r = run_guard(bash("ls -la"))
|
|
check("unrelated command is allowed", decision(r) != "deny", f"got {decision(r)!r}")
|
|
|
|
r = run_guard(bash('git commit -m "x\n\nCo-Authored-By: Claude <[email protected]>"'))
|
|
check("Claude attribution is denied", decision(r) == "deny", f"got {decision(r)!r}")
|
|
|
|
|
|
def nested_repo(tmp, test_body):
|
|
skill = os.path.join(tmp, "plugins", "reviews", "skills", "audit-x")
|
|
os.makedirs(os.path.join(skill, "tests"))
|
|
subprocess.run(["git", "init", "-q", tmp], check=True)
|
|
open(os.path.join(skill, "pyproject.toml"), "w").write("[tool.pytest.ini_options]\n")
|
|
open(os.path.join(skill, "tests", "test_nested.py"), "w").write(test_body)
|
|
return skill
|
|
|
|
|
|
print("\nnested project detection (depth 4):")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
nested_repo(tmp, "def test_fails():\n assert False\n")
|
|
r = run_guard(bash("jj git push --bookmark main"), cwd=tmp)
|
|
check("failing nested test blocks the push", decision(r) == "deny", f"got {decision(r)!r}")
|
|
check(
|
|
"denial names the nested project path",
|
|
"plugins/reviews/skills/audit-x" in reason(r),
|
|
f"got {reason(r)!r}",
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
nested_repo(tmp, "def test_passes():\n assert True\n")
|
|
r = run_guard(bash("jj git push --bookmark main"), cwd=tmp)
|
|
check("passing nested test allows the push", decision(r) != "deny", f"got {decision(r)!r}")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
skill = nested_repo(tmp, "def test_passes():\n assert True\n")
|
|
open(os.path.join(skill, "scripts.py"), "w").write("import os\n")
|
|
r = run_guard(bash("jj git push --bookmark main"), cwd=tmp)
|
|
check("lint failure blocks the push", decision(r) == "deny", f"got {decision(r)!r}")
|
|
check("ruff is resolved off PATH, not skipped", "ruff" in reason(r), f"got {reason(r)!r}")
|
|
|
|
sys.exit(1 if failures else 0)
|