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
11 changes: 11 additions & 0 deletions .github/workflows/test-cross-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ name: Cross-Plugin Guards
# plugin.json (bumped on every release) and CLAUDE-APPEND are negated: the
# contract never reads core's manifest or template, so those edits can't
# change its outcome.
# The hooks/** glob plus the three dev guard scripts cover the hook stdin
# contract: every fleet hook keeps its own sealed stdin copy, so a drifted drain
# or fail direction is only caught by re-running that corpus here.
on:
push:
branches: [main]
Expand All @@ -25,6 +28,10 @@ on:
- 'plugins/*/state-templates/CLAUDE-APPEND.md'
- '!plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md'
- 'plugins/claude-code-hermit/scripts/**'
- 'plugins/*/hooks/**'
- 'plugins/claude-code-dev-hermit/scripts/git-push-guard.ts'
- 'plugins/claude-code-dev-hermit/scripts/worktree-boundary-guard.ts'
- 'plugins/claude-code-dev-hermit/scripts/record-test-result.ts'
- 'tests/cross-plugin/**'
- 'tests/lib/**'
- '.github/workflows/test-cross-plugin.yml'
Expand All @@ -41,6 +48,10 @@ on:
- 'plugins/*/state-templates/CLAUDE-APPEND.md'
- '!plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md'
- 'plugins/claude-code-hermit/scripts/**'
- 'plugins/*/hooks/**'
- 'plugins/claude-code-dev-hermit/scripts/git-push-guard.ts'
- 'plugins/claude-code-dev-hermit/scripts/worktree-boundary-guard.ts'
- 'plugins/claude-code-dev-hermit/scripts/record-test-result.ts'
- 'tests/cross-plugin/**'
- 'tests/lib/**'
- '.github/workflows/test-cross-plugin.yml'
Expand Down
8 changes: 8 additions & 0 deletions plugins/claude-code-dev-hermit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [Unreleased]

### Fixed
- `git-push-guard`, `worktree-boundary-guard`, and `record-test-result` exited mid-stream on stdin past their 1MB cap, leaving the pipe half-read; they now stop buffering but keep consuming to EOF before failing open.
- `record-test-result` crashed with exit 1 on a valid-JSON payload that is not an object (`null`), instead of failing open.
- `worktree-boundary-guard` exited on `WORKTREE_GUARD=off` before reading stdin, so disabling the guard broke the pipe on any payload larger than the pipe buffer; the drain now runs before the switch is honored.
- All three hooks exited 1 on an unhandled stdin stream error; `main()` now catches and exits 0.

## [0.4.8] - 2026-07-26

### Fixed
Expand Down
8 changes: 6 additions & 2 deletions plugins/claude-code-dev-hermit/scripts/git-push-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,17 @@ function block(msg: string): void {
async function main() {
// Read stdin first (every hook must consume stdin), then gate on profile —
// so a non-strict session can still surface a one-line "guard inactive" notice.
// Past the cap we stop buffering but keep consuming: exiting mid-stream would
// leave the pipe half-read (broken-pipe errors on the writer's side).
const chunks: Buffer[] = [];
let total = 0;
let oversize = false;
for await (const chunk of process.stdin) {
total += chunk.length;
if (total > MAX_STDIN) process.exit(0);
if (total > MAX_STDIN) { oversize = true; continue; }
chunks.push(chunk);
}
if (oversize) process.exit(0);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) process.exit(0);

Expand Down Expand Up @@ -159,4 +163,4 @@ async function main() {
process.exit(0);
}

main();
main().catch(() => process.exit(0));
13 changes: 11 additions & 2 deletions plugins/claude-code-dev-hermit/scripts/record-test-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,28 @@ if (argv[2] === 'write') {
}

async function main() {
// Past the cap we stop buffering but keep consuming to EOF — exiting
// mid-stream would leave the pipe half-read.
const chunks: Buffer[] = [];
let total = 0;
let oversize = false;
for await (const chunk of process.stdin) {
total += chunk.length;
if (total > MAX_STDIN) process.exit(0);
if (total > MAX_STDIN) { oversize = true; continue; }
chunks.push(chunk);
}
if (oversize) process.exit(0);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) process.exit(0);

let data: Json;
try { data = JSON.parse(raw); } catch { process.exit(0); }

// Valid JSON that is not an object (`null`, `[]`, `"x"`) is still not a hook
// payload — without this the `null` case throws past the catch above and the
// hook exits 1 instead of failing open.
if (typeof data !== 'object' || data === null || Array.isArray(data)) process.exit(0);

if (data.tool_response?.interrupted === true) process.exit(0);

const command = data.tool_input?.command || '';
Expand All @@ -146,4 +155,4 @@ async function main() {
process.exit(0);
}

main();
main().catch(() => process.exit(0));
16 changes: 12 additions & 4 deletions plugins/claude-code-dev-hermit/scripts/worktree-boundary-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,24 @@ function isUnder(child: string, parent: string): boolean {
}

async function main() {
if ((process.env.WORKTREE_GUARD || '').trim().toLowerCase() === 'off') process.exit(0);
const guardOff = (process.env.WORKTREE_GUARD || '').trim().toLowerCase() === 'off';

// Drain stdin to completion (avoids broken-pipe errors) with a size cap.
// Drain stdin to completion (avoids broken-pipe errors) with a size cap: past
// the cap we stop buffering but keep consuming to EOF. This runs BEFORE the
// WORKTREE_GUARD gate — exiting on the env switch without reading would leave
// the pipe half-read for any payload larger than the pipe buffer. When the
// guard is off the payload is never parsed, so consume without buffering.
const chunks: Buffer[] = [];
let total = 0;
let oversize = false;
for await (const chunk of process.stdin) {
if (guardOff) continue;
total += chunk.length;
if (total > MAX_STDIN) process.exit(0);
if (total > MAX_STDIN) { oversize = true; continue; }
chunks.push(chunk);
}
if (guardOff) process.exit(0);
if (oversize) process.exit(0);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) process.exit(0);

Expand Down Expand Up @@ -70,4 +78,4 @@ async function main() {
process.exit(0);
}

main();
main().catch(() => process.exit(0));
20 changes: 20 additions & 0 deletions scripts/test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,24 @@ for slug in "${BUN_TEST_SLUGS[@]}" "${RUN_ALL_SLUGS[@]}"; do
fi
done

# Repo-root guards spanning more than one plugin — nothing above runs them, since
# they live outside every plugin's own discovery. Deliberately serial, after the
# plugin suites: they spawn a hook subprocess per corpus case, and the parallel
# phase is already saturated enough that HA's CPU-bound gate-corpus tests sit
# near their 5s per-test timeout. Keeping this out of that phase leaves the
# tuned parallelism untouched, at ~10s of extra wall time.
cp_start=$(now)
( cd "$ROOT" && bun test tests/cross-plugin ) >"$LOGDIR/cross-plugin.log" 2>&1
cp_rc=$?
cp_elapsed=$(( $(now) - cp_start ))
if [ "$cp_rc" -eq 0 ]; then
printf "%-32s %-6s %5ss\n" "cross-plugin" "PASS" "$cp_elapsed"
else
printf "%-32s %-6s %5ss\n" "cross-plugin" "FAIL" "$cp_elapsed"
overall_rc=1
echo "--- cross-plugin (last 20 lines) ---"
tail -20 "$LOGDIR/cross-plugin.log"
echo "---"
fi

exit "$overall_rc"
Loading
Loading