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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "guards",
"version": "1.0.2",
"version": "1.1.0",
"description": "Personal enforcement hooks: jj-only version control, no Claude attribution, build+test gate on push, and secret scrubbing on file writes.",
"author": {
"name": "Malcolm Roberts"
+38 -4
View File
@@ -175,8 +175,9 @@ const SKIP = new Set([
'.venv', 'venv', '__pycache__', 'vendor', '.next', 'out', 'packages',
]);
// Marker files are searched to depth 2 so monorepo layouts (back-end/, front-end/,
// services/*) are found without hardcoding any particular directory naming.
// Marker files are searched to depth 4 so monorepo layouts (back-end/, front-end/,
// services/*) and plugin trees (plugins/<name>/skills/<skill>/) are found without
// hardcoding any particular directory naming. SKIP keeps the walk cheap.
function findProjects(root, depth = 0) {
let found = [];
let entries;
@@ -193,7 +194,7 @@ function findProjects(root, depth = 0) {
if (names.includes('CMakeLists.txt')) found.push({ kind: 'cmake', dir: root });
else if (names.includes('Makefile') || names.includes('makefile')) found.push({ kind: 'make', dir: root });
if (depth < 2) {
if (depth < 4) {
for (const e of entries) {
if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.')) {
found = found.concat(findProjects(join(root, e.name), depth + 1));
@@ -208,6 +209,34 @@ function npmScripts(dir) {
catch { return []; }
}
// A bare `ruff`/`pytest` is usually absent from PATH, and run() treats ENOENT as a pass —
// so an unresolved tool would silently skip the gate. Resolve against the project's own
// venv first, then uv (which needs a [project] table), then the interpreter's -m fallback.
// An absent tool is reported rather than blocking: it is a gap in coverage, not a defect
// in the change being pushed. Silence is the one outcome that is never acceptable.
const skipped = [];
function pyTool(dir, tool, args) {
const venv = join(dir, '.venv', 'bin', tool);
if (existsSync(venv)) return [venv, args];
if (existsSync(join(dir, 'uv.lock')) && pyprojectHasProject(dir)) return ['uv', ['run', tool, ...args]];
if (pyModuleAvailable(tool)) return ['python', ['-m', tool, ...args]];
skipped.push({ dir, tool });
return null;
}
function pyprojectHasProject(dir) {
try { return /^\[project\]/m.test(readFileSync(join(dir, 'pyproject.toml'), 'utf8')); }
catch { return false; }
}
function pyModuleAvailable(tool) {
try {
execFileSync('python', ['-c', `import ${tool}`], { stdio: 'ignore' });
return true;
} catch { return false; }
}
// `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors.
function checksFor(p) {
switch (p.kind) {
@@ -226,7 +255,7 @@ function checksFor(p) {
case 'rust':
return [['cargo', ['clippy', '--all-targets', '--', '-D', 'warnings']], ['cargo', ['test']]];
case 'python':
return [['ruff', ['check', '.']], ['pytest', ['-q']]];
return [pyTool(p.dir, 'ruff', ['check', '.']), pyTool(p.dir, 'pytest', ['-q'])].filter(Boolean);
case 'cmake':
// cmake needs a configured build dir; without one there is nothing safe to drive.
return existsSync(join(p.dir, 'build', 'CMakeCache.txt'))
@@ -273,6 +302,11 @@ for (const p of projects) {
}
}
for (const s of skipped) {
const rel = s.dir === ROOT ? '.' : s.dir.slice(ROOT.length + 1);
notes.push(`${s.tool} is not installed for ${rel}, so that check did not run.`);
}
// Secrets: assume every repo is public.
const leaks = run('gitleaks', ['dir', ROOT, '--no-banner', '--redact', '-v'], ROOT);
if (leaks !== null) {
+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)