Gate nested projects in bash-guard instead of passing them ungated

The build-and-test gate searched for marker files only two levels below the
repo root. This repo keeps its python projects at
plugins/<name>/skills/<skill>/, four levels down, so every push reported "no
recognized project layout" and pushed without running a single test. A gate
that announces it did nothing is worse than no gate, because the announcement
scrolls past and the push still succeeds.

Walk to depth 4, and resolve python tooling properly. A bare `ruff` or
`pytest` is usually absent from PATH, and run() treats ENOENT as a pass, so an
unresolved tool would have skipped the check just as silently. Resolution now
tries the project's own .venv, then uv (which needs a [project] table that
audit-terraform does not have), then the interpreter's -m form.

A tool that is genuinely not installed is reported as an advisory note rather
than blocking. Its absence is a gap in coverage, not a defect in the change
being pushed. Silence is the one outcome that is never acceptable.

Verified by injecting a failing test into each skill and confirming the guard
blocks: audit-code resolves through its .venv, audit-terraform through
python -m pytest. The new regression tests fail against the old depth-2 walk
and pass against this one.

Tests: 8 passing (bash-guard), 197 (audit-code), 106 (audit-terraform).
This commit is contained in:
2026-07-21 11:49:48 -05:00
parent f5934181ec
commit fc4a939482
3 changed files with 69 additions and 7 deletions
+30 -2
View File
@@ -12,15 +12,16 @@ 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):
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
["node", GUARD], stdin=child.fileno(), stdout=subprocess.PIPE, close_fds=False, cwd=cwd
)
child.close()
parent.sendall(json.dumps(payload).encode())
@@ -68,4 +69,31 @@ check("unrelated command is allowed", decision(r) != "deny", f"got {decision(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}")
sys.exit(1 if failures else 0)