Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46cbfc5967 | ||
|
|
cf3abafc66 | ||
|
|
6838baa071 | ||
|
|
f031c2b77d | ||
|
|
1328dcd8f3 | ||
|
|
6cc224f151 | ||
|
|
8359d61e33 | ||
|
|
400d2b9b6b | ||
|
|
4edf42ce9f | ||
|
|
2f53346b8c | ||
|
|
dcc0677270 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "0.19.1",
|
||||
"version": "0.19.6",
|
||||
"author": {
|
||||
"name": "Mathias",
|
||||
"url": "https://git.d-ma.be/mathias"
|
||||
|
||||
@@ -133,6 +133,33 @@ jobs:
|
||||
git add "$manifest"
|
||||
# The trailer below stops this push from re-triggering the tagger.
|
||||
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
|
||||
|
||||
# skills#46. This commit is pushed with secrets.GITEA_TOKEN, and a
|
||||
# push authenticated with the Actions token does NOT trigger
|
||||
# workflows -- deliberate platform behaviour to stop CI recursing.
|
||||
# `Skip-Release: true` stops the tagger recursing; nothing stopped
|
||||
# the whole run, `gate` included, from never firing on this commit.
|
||||
#
|
||||
# So every release left main's HEAD with zero CI runs. Measured:
|
||||
# 6cc224f (0.19.4) and f031c2b (0.19.5) each have none, and the
|
||||
# koala cluster's CIHeadUncovered{repo=skills} had been firing on it
|
||||
# for 85 hours -- an alert that can only be cleared by an unrelated
|
||||
# human push.
|
||||
#
|
||||
# The commit is not cosmetic: plugin.json's version is what Claude
|
||||
# Code gates plugin updates on, `task check` asserts it is not
|
||||
# behind the latest tag, and the sed-and-retry logic above already
|
||||
# shipped a mismatched manifest once (v0.10.0 reading 0.9.0).
|
||||
#
|
||||
# Gating it HERE rather than re-triggering CI afterwards: the commit
|
||||
# is produced here, so it can be validated here, at the cost of one
|
||||
# `task check` instead of a second full run -- and with no recursion
|
||||
# risk to reintroduce.
|
||||
if ! task check; then
|
||||
echo "::error::the release-bump commit fails task check -- not pushing it" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git push origin HEAD:main; then
|
||||
echo "bumped $manifest to $version"
|
||||
exit 0
|
||||
|
||||
@@ -17,6 +17,7 @@ the SKILL.md, not the table.
|
||||
| `cognitive-load` | Cognitive complexity analysis (CLI score) | Identifying hotspots, before refactoring | claude-code, library |
|
||||
| `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious — or a bug report with no failure text, which this skill refuses rather than guesses at | claude-code, library |
|
||||
| `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing | claude-code, library |
|
||||
| `document-to-markdown` | Read .docx/.xlsx/.pptx/.epub/.msg and other formats the Read tool cannot open | You need the contents of a binary document — Word, Excel, PowerPoint, EPUB, Outlook .msg, a zip of documents — and Read returns nothing useful | claude-code, library |
|
||||
| `dream` | Reflective consolidation pass over an agent's memory directory | Pruning/merging memory files, rebuilding a memory index | claude-code, library, chat |
|
||||
| `experiment-spec` | Rigorous experiment spec — falsifiable hypothesis, quantitative acceptance, null-result protocol | Scoping a research/ML phase instead of a feature | claude-code, library |
|
||||
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project | claude-code, library |
|
||||
|
||||
@@ -41,7 +41,17 @@ if [ ! -f "$CONFIG" ]; then
|
||||
fi
|
||||
|
||||
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 " (expected in CI: this check only verifies on a host with ~/.op-env)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
||||
+38
@@ -327,6 +327,44 @@ if [ "$ONLINE" -eq 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
|
||||
# infra#375, second recurrence. golangci-lint embeds the go/types export-data
|
||||
# reader from the Go it was BUILT with. When GOROOT is newer, that reader
|
||||
# cannot decode the stdlib and the linter dies:
|
||||
#
|
||||
# panic: file requires newer Go version go1.27 (application built with go1.26)
|
||||
#
|
||||
# An earlier form printed a typecheck error naming the user's own file, which
|
||||
# sends people to debug code that is fine. It cost time in two repos before
|
||||
# anyone recognised it as a host fault rather than a repo fault.
|
||||
#
|
||||
# It is invisible to CI -- the runner image has matching versions -- so it
|
||||
# breaks exactly the local `task check` that AGENT.md requires before a push,
|
||||
# and nothing else. That is why it belongs in doctor: the signal was missing,
|
||||
# not the fix.
|
||||
#
|
||||
# Fails open on anything unparseable. A host with no Go toolchain is a normal
|
||||
# host, not a broken one, and doctor runs in a SessionStart hook everywhere.
|
||||
ver_lt() {
|
||||
# 0 when $1 < $2 on major.minor. Deliberately not sort -V: this is POSIX sh
|
||||
# that also runs on macOS, and the export-data format is minor-versioned, so
|
||||
# patch level does not enter into it.
|
||||
_a=${1%%.*}; _ar=${1#*.}; _am=${_ar%%.*}
|
||||
_b=${2%%.*}; _br=${2#*.}; _bm=${_br%%.*}
|
||||
case "$_a$_am$_b$_bm" in *[!0-9]*) return 1 ;; esac
|
||||
[ "$_a" -lt "$_b" ] && return 0
|
||||
[ "$_a" -gt "$_b" ] && return 1
|
||||
[ "$_am" -lt "$_bm" ]
|
||||
}
|
||||
|
||||
if command -v golangci-lint >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then
|
||||
lint_go=$(golangci-lint --version 2>/dev/null | sed -n 's/.*built with go\([0-9][0-9.]*\).*/\1/p' | head -1)
|
||||
root_go=$(go version 2>/dev/null | sed -n 's/.*go version go\([0-9][0-9.]*\).*/\1/p' | head -1)
|
||||
if [ -n "$lint_go" ] && [ -n "$root_go" ] && ver_lt "$lint_go" "$root_go"; then
|
||||
add FAIL toolchain "golangci-lint built with go$lint_go but GOROOT is go$root_go — lint panics on every Go repo; reinstall it under the current toolchain (infra#375)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- output -----------------------------------------------------------------
|
||||
if [ "$JSON" -eq 1 ]; then
|
||||
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: document-to-markdown
|
||||
surfaces: [claude-code, library]
|
||||
summary: "Read .docx/.xlsx/.pptx/.epub/.msg and other formats the Read tool cannot open"
|
||||
use_when: "You need the contents of a binary document — Word, Excel, PowerPoint, EPUB, Outlook .msg, a zip of documents — and Read returns nothing useful"
|
||||
description: Convert a binary document to Markdown locally with markitdown. No network, no LLM, safe for confidential material. Says when to use the docmark service instead.
|
||||
---
|
||||
|
||||
# Document → Markdown
|
||||
|
||||
## Overview
|
||||
|
||||
`Read` opens PDFs, images and notebooks natively. It does **not** open `.docx`,
|
||||
`.xlsx`, `.pptx`, `.epub`, `.msg` or `.zip`. This skill converts those locally.
|
||||
|
||||
One command, no install step, no service:
|
||||
|
||||
```bash
|
||||
uvx --from 'markitdown[docx,pdf,pptx,xls,xlsx,outlook]' markitdown FILE > /tmp/out.md
|
||||
```
|
||||
|
||||
First run downloads 38 packages (~10s); afterwards it is cached and instant.
|
||||
|
||||
## When to use this
|
||||
|
||||
You have a file on disk whose contents you need. That is the whole trigger.
|
||||
|
||||
## When NOT to use this — use `docmark` instead
|
||||
|
||||
Three cases, and only three:
|
||||
|
||||
1. **A scanned PDF or an image whose content is pixels, not text.** Extracting
|
||||
that needs a vision model. The estate's only audited path from an image to a
|
||||
model is `docmark`, whose `sovereignty.py` constructs an LLM client *only*
|
||||
when `DOCMARK_VISION_MODEL` matches `koala/*` or `iguana/*`, and silently
|
||||
degrades to metadata-only otherwise. `tests/test_sovereignty.py` is the
|
||||
load-bearing guard. Do not reach for a cloud vision model directly; that is
|
||||
the boundary the gate exists to hold.
|
||||
2. **You are writing an in-cluster service** (something in `dispatch`,
|
||||
`ingestion`, `cobalt-dingo`). Call `docmark` over MCP rather than bundling a
|
||||
converter into each service. Its NetworkPolicy already admits `supervisor`
|
||||
and `dispatch`.
|
||||
3. **The file is larger than ~25 MiB** or you want the input/output caps
|
||||
enforced centrally.
|
||||
|
||||
For a text-bearing document, the service returns **byte-identical output** to
|
||||
the local command — measured 2026-09-24 on the same `.docx`, tables included.
|
||||
A network hop, a bearer token and an ingress for the same bytes is not a
|
||||
trade worth making.
|
||||
|
||||
## Confidential material
|
||||
|
||||
**Conversion is local and involves no LLM.** `markitdown` uses an LLM client
|
||||
only to describe images; with none configured it does not call anything. No
|
||||
bytes leave the machine.
|
||||
|
||||
That makes this safe for client material, unlike anything that routes through a
|
||||
model. Two cautions:
|
||||
|
||||
- **Do not install `markitdown[all]`.** It pulls `az-doc-intel` and
|
||||
`az-content-understanding` — Azure cloud document-intelligence converters.
|
||||
They need explicit endpoint configuration to activate, but the extras list
|
||||
above avoids having them on the path at all.
|
||||
- The output is a file like any other. If the source was confidential, so is
|
||||
`/tmp/out.md` — and the usual rule applies: it must not reach an open-egress
|
||||
sink (ADR-0014).
|
||||
|
||||
## Verify the conversion, do not assume it
|
||||
|
||||
The failure mode is **a successful exit with empty output**. A `.docx` template
|
||||
with no body content converts to zero characters and exits 0; so does a scanned
|
||||
PDF with no text layer. Both look like success.
|
||||
|
||||
```bash
|
||||
uvx --from 'markitdown[docx,pdf,pptx,xls,xlsx,outlook]' markitdown FILE > /tmp/out.md
|
||||
wc -c /tmp/out.md
|
||||
```
|
||||
|
||||
If the byte count is 0 or implausibly small for the document's size, the
|
||||
converter found no text layer — that is case 1 above, and it needs `docmark`
|
||||
with a vision model, not a retry.
|
||||
|
||||
## What it converts
|
||||
|
||||
18 converters with the extras above:
|
||||
|
||||
```
|
||||
Audio, BingSerp, Csv, Docx, Epub, Html, Image, Ipynb, OutlookMsg, Pdf,
|
||||
PlainText, Pptx, Rss, Wikipedia, Xls, Xlsx, YouTube, Zip
|
||||
```
|
||||
|
||||
`docmark` registers 22 — the same set plus `DocxWithOCR`, `PdfWithOCR`,
|
||||
`PptxWithOCR`, `XlsxWithOCR`. **Those four are the entire difference**, and
|
||||
they are exactly the vision path from case 1. Everything else is identical code
|
||||
running in a different place.
|
||||
|
||||
## Reading the output
|
||||
|
||||
Convert to a file and `Read` it, rather than piping into the transcript —
|
||||
a 200-page document produces a lot of Markdown and most of it will not be
|
||||
what you needed. Convert, then grep or read the section you want.
|
||||
|
||||
## Provenance
|
||||
|
||||
`docmark` ADR-0013 (`mathias/infra docs/decisions/`) owns the service; this
|
||||
skill is the local path. The split was decided in `mathias/docmark#3` on
|
||||
measurement, not preference: three of docmark's four plausible consumers are
|
||||
better served without it, and the fourth — vision under a sovereignty gate — is
|
||||
why it exists.
|
||||
|
||||
## Escalation
|
||||
|
||||
Local, read-only, single command. Nothing here is handed back mid-task.
|
||||
|
||||
Stop and hand back when:
|
||||
|
||||
- **The output is empty or near-empty.** Do not retry, and do not guess at the
|
||||
contents. Say which file, what the byte count was, and that it needs a vision
|
||||
path — then either use `docmark` with a local vision model, or ask. A
|
||||
confident summary of a document you could not read is the worst possible
|
||||
output of this skill.
|
||||
- **The document is client material and the task would send its contents
|
||||
anywhere** — a commit message, an issue, a PR body, a cloud model. That is
|
||||
ADR-0014's refusal, and it is not this skill's call to make.
|
||||
- **The file is encrypted or password-protected.** markitdown will fail; asking
|
||||
for the password is a human's decision, not a retry.
|
||||
@@ -53,5 +53,31 @@ printf '%s' "$tag_job" | grep -qE '^ +needs: *gate' \
|
||||
&& ok 'ungated release.yml removed' \
|
||||
|| no 'ungated release.yml removed'
|
||||
|
||||
# skills#46. The manifest-bump commit is created and pushed by the `tag` job
|
||||
# using secrets.GITEA_TOKEN, and a push authenticated with the Actions token
|
||||
# does not trigger workflows -- deliberate platform behaviour to stop CI
|
||||
# recursing. So the ONE commit in this repo that no CI run has ever seen is the
|
||||
# one the release job writes, every release:
|
||||
#
|
||||
# 6cc224f chore(release): plugin.json version 0.19.4 -> 0 runs
|
||||
# f031c2b chore(release): plugin.json version 0.19.5 -> 0 runs
|
||||
#
|
||||
# The koala cluster's CIHeadUncovered{repo=skills} alert had been firing on it
|
||||
# for 85 hours when this was found, and it cannot clear on its own -- only an
|
||||
# unrelated human push moves main's HEAD to a tested commit.
|
||||
#
|
||||
# The commit is not nothing: plugin.json's version is what Claude Code gates
|
||||
# plugin updates on, `task check` asserts it is not behind the latest tag, and
|
||||
# the sed-and-retry logic that writes it already shipped a mismatched manifest
|
||||
# once (v0.10.0 with the manifest reading 0.9.0).
|
||||
#
|
||||
# Anchored past comments: the tag job's prose already SAYS "`task check`
|
||||
# asserts the manifest is not behind the latest tag", and a bare grep matches
|
||||
# that and passes. A grep for a concept matches the rationale, and the more
|
||||
# carefully a thing is documented the more likely that is.
|
||||
printf '%s' "$tag_job" | grep -qE '^[^#]*task check' \
|
||||
&& ok 'the release-bump commit is gated before it is pushed' \
|
||||
|| no 'the release-bump commit is pushed with no gate -- nothing ever tests it (#46)'
|
||||
|
||||
printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -24,6 +24,18 @@ if [ ! -f "$DOCTOR" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The toolchain check (golangci-lint build-Go vs GOROOT) reads the real host
|
||||
# unless we say otherwise, which made every unrelated fixture fail on a machine
|
||||
# whose golangci-lint is genuinely stale. Give the whole suite a matching fake
|
||||
# pair so only the cases that OPT IN exercise that check.
|
||||
STUB_BIN="$WORK/_stub_bin"
|
||||
mkdir -p "$STUB_BIN"
|
||||
printf '#!/bin/sh\nprintf "golangci-lint has version 2.0.0 built with go1.27.0 from abc\\n"\n' > "$STUB_BIN/golangci-lint"
|
||||
printf '#!/bin/sh\ncase "$1" in version) printf "go version go1.27.0 linux/amd64\\n";; *) printf "\\n";; esac\n' > "$STUB_BIN/go"
|
||||
chmod +x "$STUB_BIN/golangci-lint" "$STUB_BIN/go"
|
||||
PATH="$STUB_BIN:$PATH"
|
||||
export PATH
|
||||
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
@@ -291,5 +303,75 @@ else
|
||||
fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||
fi
|
||||
|
||||
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
|
||||
# Measured on koala 2026-09-08 (infra#375, second recurrence): golangci-lint
|
||||
# 2.11.4 built with go1.26.1 against a go1.27.0 GOROOT PANICS on any Go repo --
|
||||
#
|
||||
# panic: file requires newer Go version go1.27 (application built with go1.26)
|
||||
#
|
||||
# and the earlier form of the same fault printed a typecheck error naming YOUR
|
||||
# file, so the natural response is to debug code that is fine. It cost time in
|
||||
# two repos. CI never sees it (the runner image matches), so it breaks only the
|
||||
# local `task check` that AGENT.md requires before every push.
|
||||
#
|
||||
# The fixtures inject fake `go` and `golangci-lint` on PATH; nothing here reads
|
||||
# the real host toolchain, so the suite still passes on a machine without Go.
|
||||
mk_toolchain() { # $1=fixture $2=lint-build-go $3=goroot-go ("" = absent)
|
||||
_b="$1/bin"; mkdir -p "$_b"
|
||||
if [ -n "$2" ]; then
|
||||
printf '#!/bin/sh\nprintf "golangci-lint has version 2.11.4 built with %s from abc on 2026-01-01\\n"\n' "$2" > "$_b/golangci-lint"
|
||||
chmod +x "$_b/golangci-lint"
|
||||
fi
|
||||
if [ -n "$3" ]; then
|
||||
printf '#!/bin/sh\ncase "$1" in version) printf "go version %s linux/amd64\\n";; *) printf "\\n";; esac\n' "$3" > "$_b/go"
|
||||
chmod +x "$_b/go"
|
||||
fi
|
||||
printf '%s' "$_b"
|
||||
}
|
||||
|
||||
f=$(new_fixture golangci_old); wire_clean "$f"
|
||||
b=$(mk_toolchain "$f" go1.26.1 go1.27.0)
|
||||
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||
if [ "$_rc" -ne 0 ] && printf '%s' "$_out" | grep -qi 'golangci'; then
|
||||
pass=$((pass + 1)); printf ' ok golangci-lint older than GOROOT is reported\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL golangci-lint older than GOROOT is reported\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||
fi
|
||||
|
||||
# Matching versions are the normal case and must stay silent.
|
||||
f=$(new_fixture golangci_ok); wire_clean "$f"
|
||||
b=$(mk_toolchain "$f" go1.27.0 go1.27.0)
|
||||
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
pass=$((pass + 1)); printf ' ok golangci-lint matching GOROOT is silent\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL golangci-lint matching GOROOT is silent\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||
fi
|
||||
|
||||
# A NEWER lint than GOROOT is fine -- the constraint is one-directional.
|
||||
f=$(new_fixture golangci_newer); wire_clean "$f"
|
||||
b=$(mk_toolchain "$f" go1.28.0 go1.27.0)
|
||||
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
pass=$((pass + 1)); printf ' ok golangci-lint newer than GOROOT is not a fault\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL golangci-lint newer than GOROOT is not a fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||
fi
|
||||
|
||||
# Fail OPEN on anything unparseable. doctor runs in a SessionStart hook on every
|
||||
# machine; a toolchain it cannot read is not a fault it may assert. (A host with
|
||||
# no Go at all takes the same path -- command -v simply misses.)
|
||||
f=$(new_fixture golangci_garbled); wire_clean "$f"
|
||||
_b="$f/bin"; mkdir -p "$_b"
|
||||
printf '#!/bin/sh\nprintf "golangci-lint dev build\\n"\n' > "$_b/golangci-lint"
|
||||
printf '#!/bin/sh\nprintf "go version devel\\n"\n' > "$_b/go"
|
||||
chmod +x "$_b/golangci-lint" "$_b/go"
|
||||
_out=$(PATH="$_b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
pass=$((pass + 1)); printf ' ok an unreadable toolchain version is not asserted as a fault\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL an unreadable toolchain version is not asserted as a fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||
fi
|
||||
|
||||
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -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 ]
|
||||
@@ -24,7 +24,7 @@ decision_for() {
|
||||
_tool=${2:-Bash}
|
||||
jq -n --arg t "$_tool" --arg c "$_cmd" \
|
||||
'{tool_name: $t, tool_input: {command: $c}}' \
|
||||
| sh "$GUARD" \
|
||||
| "$GUARD" \
|
||||
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ expect() {
|
||||
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
|
||||
printf 'FAIL: %s does not exist\n' "$GUARD"
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user