Compare commits

..
5 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 8359d61e33 test(hooks): pin the pre-task-ritual hook to its registration
CI / Tag release (push) Successful in 1s
CI / Check / Test (push) Successful in 2s
Closes #44.

That issue overstated the risk and this corrects it. The hook is three lines
and echoes a string -- no bash-isms, so it cannot fail the way secret-guard
appeared to. The real exposure is narrower and duller: it can stop working
silently, and its entire job is to be visible.

THE ASSERTION THAT MATTERS IS REGISTRATION. A hook file that exists but is
absent from hooks.json does nothing at all and looks identical on disk to one
that works. So the test derives the command from hooks.json and runs it exactly
as registered, substituting CLAUDE_PLUGIN_ROOT the way the harness does, rather
than inventing its own invocation.

That choice is the lesson from #44's sibling. secret_guard_test.sh piped its
hook into `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash,
which has no `set -o pipefail`, so the suite failed 20/20 in CI against a hook
that was FINE in production. The test was wrong, not the hook -- and the
earlier commit message for that fix said the shebang decides the interpreter,
which hooks.json shows is not true. Deriving the invocation removes the whole
class rather than fixing one instance of it.

No red phase to report: the hook was already correct, so this is regression
pinning, not TDD. Teeth proven by mutation against a throwaway copy instead:

  - hook runs but prints nothing      -> 6 failures
  - hook removed from hooks.json      -> caught, suite stops early
  - ONE ritual step dropped, rule 0
    still named                       -> caught (9 passed, 1 failed)

The third is the one worth having. Losing the whole line is obvious; quietly
losing "failing test" from the ritual while the banner still reads correctly is
the drift that would actually happen.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:52 +02:00
mathias 400d2b9b6b chore(release): plugin.json version 0.19.3
Skip-Release: true
2026-09-07 20:23:31 +00:00
mathiasandClaude Opus 5 4edf42ce9f fix(test): run the secret guard via its own shebang, not through sh
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 0s
The suite piped hook input into `sh "$GUARD"`, which overrides the hook's
`#!/usr/bin/env bash` and tests an interpreter production never uses. Claude
Code executes the hook as a program.

On Debian /bin/sh is dash, which has no `set -o pipefail`, so the guard died
on line 23 before reading a byte of input:

    secret-guard.sh: 23: set: Illegal option -o pipefail
    secret_guard: 0 passed, 20 failed

while passing 20/20 on a host where /bin/sh happens to be bash. A security
gate that tests green and cannot start is the worst of both, and the test was
the only thing standing between those two states.

Found because baked-v16 added jq to the runner: this suite needs jq to build
its hook JSON, so it had been silently doing nothing in CI and started running
for the first time. Same shape as installing zip and immediately learning the
gitea backup check had never run -- a missing tool does not announce that it
is suppressing a whole suite.

Also asserts the hook is executable, since direct invocation depends on the
exec bit, and that assertion is what stops this regressing to `sh "$GUARD"`.

Checked the rest of the suite: every other `sh "$SUT"` targets a POSIX script,
which is correct. hooks/pre-task-ritual.sh is the other bash-shebang hook and
has no test at all -- filed separately rather than fixed here.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:23:06 +02:00
mathiasandClaude Opus 5 2f53346b8c docs(check): say plainly that CI can never verify the MCP env wiring
CI / Check / Test (push) Failing after 2s
CI / Tag release (push) Skipped
infra#368 added jq to the gitea-runner image on the stated grounds that
check-mcp-env would then verify in CI instead of skipping. Verified after
rolling baked-v16: jq IS present now, the `SKIP: jq not installed` branch is
gone -- and the check still reports unverified, because CI has no ~/.op-env.

The env-file branch was always the binding one. It holds live credentials, so
it is not on the runner and must not be, which means this check can only ever
verify on a host that has one. Putting ~/.op-env on the runner to turn the
NOTE green would be trading a real secret for a green tick.

So the message now says so, at the point someone reads it. The risk otherwise
is not the skip -- it is a future reader seeing a passing pipeline and
concluding the MCP wiring is covered. It is covered on developer hosts only.

jq was still worth adding: it is a genuine dependency of this checker, the
runner's tool-contract.txt now asserts it, and a future image built without it
is a red pod at roll time rather than a silently degraded check.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:21:10 +02:00
mathias dcc0677270 chore(release): plugin.json version 0.19.2
Skip-Release: true
2026-09-07 18:14:53 +00:00
4 changed files with 129 additions and 2 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "skills", "name": "skills",
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.", "description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
"version": "0.19.1", "version": "0.19.3",
"author": { "author": {
"name": "Mathias", "name": "Mathias",
"url": "https://git.d-ma.be/mathias" "url": "https://git.d-ma.be/mathias"
+10
View File
@@ -41,7 +41,17 @@ if [ ! -f "$CONFIG" ]; then
fi fi
if [ ! -f "$ENVFILE" ]; then if [ ! -f "$ENVFILE" ]; then
# EXPECTED IN CI, and permanently so. The env-file holds live credentials, so
# it is not on the runner and must not be -- which means this check can only
# ever verify on a host that has one. infra#368 added jq to the runner image
# believing that would let CI verify; it removed the jq branch below and
# changed nothing else, because this branch is the binding one.
#
# Said plainly so nobody concludes CI covers this. It does not, it cannot,
# and putting ~/.op-env on the runner to make it would be trading a real
# secret for a green tick.
echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2 echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2
echo " (expected in CI: this check only verifies on a host with ~/.op-env)" >&2
exit 2 exit 2
fi fi
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
# Tests for hooks/pre-task-ritual.sh — the UserPromptSubmit hook that surfaces
# rule 0 on every prompt (#44).
#
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT CLAIM
# This hook is three lines and echoes a string. It has no bash-isms, so it
# cannot fail the way secret-guard appeared to; that risk was overstated when
# this test was requested. What it CAN do is stop working silently, and its
# whole job is to be visible -- if the text stops appearing, rule 0 stops being
# surfaced on every prompt and nothing announces that.
#
# THE ASSERTION THAT MATTERS IS REGISTRATION.
# A hook file that exists but is not in hooks.json does absolutely nothing, and
# looks identical on disk to one that works. So this derives the command from
# hooks.json rather than assuming one: the registration is the source of truth
# for how the hook is invoked, and a test that invokes it its own way is
# testing something the harness never runs.
#
# That distinction is not hypothetical. secret_guard_test.sh piped its hook into
# `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash, which
# has no `set -o pipefail`, and the suite failed 20/20 in CI against a hook that
# was fine in production. The test was wrong, not the hook. Deriving the
# invocation removes the opportunity for that class of mistake entirely.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
HOOK="$ROOT/hooks/pre-task-ritual.sh"
REG="$ROOT/hooks/hooks.json"
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
if ! command -v jq >/dev/null 2>&1; then
printf ' SKIP: jq not installed -- cannot read the hook registration\n'
exit 0
fi
[ -f "$HOOK" ] || { printf ' FAIL %s does not exist\n' "$HOOK"; exit 1; }
ok 'hook file exists'
[ -f "$REG" ] || { printf ' FAIL %s does not exist\n' "$REG"; exit 1; }
# 1. Registered, and on the right event. UserPromptSubmit is what makes it fire
# on every prompt; registered under any other event it would still "exist"
# and still do nothing useful.
CMD=$(jq -r '
.hooks.UserPromptSubmit[]?.hooks[]?
| select(.command | test("pre-task-ritual"))
| .command
' "$REG" 2>/dev/null | head -1)
if [ -n "$CMD" ]; then
ok 'registered on UserPromptSubmit in hooks.json'
else
no 'not registered on UserPromptSubmit -- the file would exist and never run'
printf ' %d passed, %d failed\n' "$pass" "$fail"
exit 1
fi
# 2. Run it EXACTLY as registered, with the harness variable substituted the way
# the harness substitutes it.
OUT=$(CLAUDE_PLUGIN_ROOT="$ROOT" sh -c "$CMD" 2>&1)
rc=$?
[ "$rc" -eq 0 ] && ok 'exits 0 as registered' \
|| no "exited $rc -- a non-zero UserPromptSubmit hook can block the prompt"
# 3. The content is the point. A hook that runs and says nothing is the same as
# no hook, and that is the failure this pins.
if [ -n "$OUT" ]; then
ok 'produces output'
else
no 'produced no output -- rule 0 would silently stop being surfaced'
fi
case "$OUT" in
*"Rule 0"*) ok 'names rule 0' ;;
*) no "does not name rule 0: '$OUT'" ;;
esac
# Each of the four steps, individually. Naming rule 0 while having quietly lost
# a step is the plausible drift, not losing the whole line.
for step in brain_query skill 'failing test' 'success criterion'; do
case "$OUT" in
*"$step"*) ok "states the '$step' step" ;;
*) no "lost the '$step' step from the ritual" ;;
esac
done
# 4. The interpreter named in the registration must actually exist, or the hook
# fails on that host with a message nobody reads.
INTERP=${CMD%% *}
if command -v "$INTERP" >/dev/null 2>&1; then
ok "registered interpreter '$INTERP' is available"
else
no "registered interpreter '$INTERP' not found on this host"
fi
printf ' %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+15 -1
View File
@@ -24,7 +24,7 @@ decision_for() {
_tool=${2:-Bash} _tool=${2:-Bash}
jq -n --arg t "$_tool" --arg c "$_cmd" \ jq -n --arg t "$_tool" --arg c "$_cmd" \
'{tool_name: $t, tool_input: {command: $c}}' \ '{tool_name: $t, tool_input: {command: $c}}' \
| sh "$GUARD" \ | "$GUARD" \
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"' | jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
} }
@@ -42,6 +42,20 @@ expect() {
fi fi
} }
# Invoked as a PROGRAM above, never as `sh "$GUARD"`. Claude Code executes the
# hook directly, so its `#!/usr/bin/env bash` shebang decides the interpreter;
# piping it into `sh` overrides that and tests something production never runs.
#
# It is not academic. On Debian /bin/sh is dash, which has no `set -o
# pipefail`, so the guard died on line 23 before reading a byte of input and
# every deny case returned empty -- 20 of 20 assertions failed in CI while
# passing on a host where /bin/sh happens to be bash. A security gate that
# tests green and cannot start is the worst of both.
if [ ! -x "$GUARD" ]; then
printf 'FAIL: %s is not executable -- it must run via its own shebang\n' "$GUARD"
exit 1
fi
if [ ! -f "$GUARD" ]; then if [ ! -f "$GUARD" ]; then
printf 'FAIL: %s does not exist\n' "$GUARD" printf 'FAIL: %s does not exist\n' "$GUARD"
exit 1 exit 1