"""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 "')) 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)