Compare commits

..
18 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5.5 b0d58d5adf feat(hooks): mcp-destructive-guard asks before irreversible MCP verbs (#52)
CI / Check / Test (push) Successful in 5s
CI / Tag release (push) Successful in 2s
PreToolUse hook on mcp__.*: asks for repo_delete, file_delete, branch_delete, pr_merge, tbd_ship, repo_update, repo_mirror_push, workflow_run_trigger, tag_create, release_create on any server name, and for file_write_branch unless it targets a plain non-main/master branch with non-empty content under exactly one exact-case branch/content key. Fails closed; never emits allow.

Refs mathias/infra#508. Reviewed in three rounds by pr-gatekeeper with execution against gitea-mcp and Gitea 1.27.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-01 20:21:52 +00:00
mathiasandClaude Opus 5 55b2e811d1 chore(release): 0.22.0 — pr-gatekeeper pre-push mode
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 0s
pr-gatekeeper gains a second mode: reviewing a staged or unpushed diff in a
GitOps repo where a branch cannot be reconciled, recording its verdict on the
issue instead of merging. New capability, so minor.

Refs mathias/infra#470

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:32:30 +02:00
mathiasandClaude Opus 5 0dcf5cc136 feat(pr-gatekeeper): review a staged diff pre-push, for GitOps repos with no PR
infra#470 resolved a doctrine conflict: root rule 5 wants a PR for risk/medium+,
and mathias/infra forbids branches because Flux watches main — a branch there can
be linted but never reconciled, so a PR reviews a state that was never deployed.
The agreed lane is that such work stays on main and gets a recorded adversarial
review pass BEFORE the push.

That lane names this agent as its reviewer, and the definition only knew how to
review an open PR. A lane whose reviewer cannot see the diff resolves to "the
author was careful", which is what rule 5 was written to replace.

ONE AGENT, TWO MODES — NOT A SECOND FILE

Only three of twelve sections were PR-specific: the diff command, the CI step, and
the verdict. Everything else — the iron laws, "the test is the claim", the three
attack habits, probing without causing the incident, escalation — is diff-agnostic.
Forking a near-duplicate 226-line definition would give the estate two
implementations of one review standard, and they would drift; the same argument
tests/litellm-validate_test.sh makes about its own consolidation.

WHAT DIFFERS, AND WHY

- Diff: three commands, run all of them. Which one holds the change is itself
  information — work that is neither staged nor committed is a working tree, not a
  diff under review. Untracked files appear in neither diff, so `git status
  --porcelain` is how a control that exists only in an unstaged file gets noticed.
- CI: it CANNOT have run, nothing is pushed. So "CI absent" must not block here —
  that rule is for a pushed branch with no runs, which is a platform failure;
  unpushed with no runs is arithmetic. Instead run the local gate and check its
  EXIT CODE, because a grep over gate output has hidden a failing test in this
  estate before.
- Verdict: nothing to merge, no PR to close, no branch to delete. The entire output
  is a comment on the issue. That is what makes the lane RECORDED rather than a
  conversation — a verdict living only in a chat transcript is indistinguishable
  from no review having happened.

The report must state that it carries strictly less external evidence than PR mode:
"nothing I could find blocks this push", never "CI confirms this".

TDD, and one vacuous assertion caught

Five assertions written first, all red. One passed spuriously — an unscoped grep
for "do not merge" matched line 202, the ESCALATION step talking about a PR. A pass
for entirely the wrong reason, which is what rule 9 is about. Now scoped to the
pre-push section, and the comment records why, so nobody re-loosens it.

agents_test 19/19. Repo check exit 0, 45 skills, manifest matches tag.

Refs mathias/infra#470

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:31:31 +02:00
mathias c25175e4b3 feat(agents): ship pr-gatekeeper from the repo, not one host's ~/.claude (#51)
CI / Check / Test (push) Successful in 4s
CI / Tag release (push) Successful in 1s
pr-gatekeeper blocked dispatch#75 five times. Every block was a real,
executable escape — twelve pathspec bypasses, arbitrary command
execution via `git -c alias`, `--upload-pack='rm …'`, a write primitive
through `--output=`, `${IFS}` word-splitting past the policy, and a path
denylist that had been a no-op in the live flow since it was written.
task check was green on all five.

The agent definition existed in no repository. A code search across the
estate found only references to it — comments in dispatch source, a
local-dev sprint doc — while the file itself lived at
~/.claude/agents/pr-gatekeeper.md on koala alone: unversioned, on one
host, one rm from gone. local-dev AGENT.md rule 8 now requires routing
control code through it, so doctrine depended on an artifact with no
history.

PORTED, PLUS WHAT THE FIVE ROUNDS ACTUALLY TAUGHT

The file already had flip-testing. Three habits that did the finding were
implied at best, and are now explicit (§4b):

  - Verify by EXECUTION, not by reading the implementation. Every finding
    came from running the command in a scratch repo and looking at the
    filesystem. The matcher said one thing and the filesystem another,
    five times.
  - Read the SURVIVORS. After an allow/deny sweep, read what is still
    permitted, one at a time. This caught `git commit -F /etc/passwd` —
    exfiltration wearing a message flag — which neither the review nor
    the differential found, because both implementations agreed and both
    were wrong. Agreement is not correctness.
  - Grade a tool on its EXIT CODE, never by grepping output. A crashed
    linter greps as clean.

Plus rule 9 in §4: a test whose name claims more than it proves is worse
than no test, because the name wins in the reader's head.

AND THE INCIDENT THIS AGENT CAUSED (§4c)

On 2026-09-19 a probe of this agent's own making printed /proc/$PPID/environ
and `ps e` raw, and a `git credential fill` consulted the host's real
store. Seven live credentials reached the transcript and need rotation
(infra#445). The finding was correct; the method was not. Versioning the
file without that lesson would have shipped the incident along with the
agent. So: a probe that reads an environment prints a count, never the
buffer; invented sentinels only; isolated HOME for anything touching
git's credential subsystem; prove a primitive and stop.

DOCTOR

New `shadow` check: a real file in ~/.claude/agents with the same name as
a shipped agent wins over the shipped one and nothing says so. Same
silent-drift shape as the existing `outside` and `unmanaged` checks — it
works, so nothing complains, while serving content nobody updates. A
symlink is managed wiring and is not flagged.

The host-local copy on koala has been moved aside, so the shipped file is
what runs once the plugin updates.

TESTS

14 assertions, each named for what it observes rather than what it
guarantees — a grep proves a phrase is present, not that a procedure is
followed, and naming it otherwise would be the defect rule 9 exists for.
Flip-tested both directions: removing the doctor check, and altering the
probe-safety phrase, each turn the suite red.

`agents` added to task check's root-entry allowlist. Leaving it out made
check_tolerates_unverifiable_test.sh fail, which is #23's stray-entry
guard working exactly as designed — an undeclared root entry loads in no
harness and errors in none either.

Closes #51

Bump-Type: minor
2026-09-28 23:30:37 +02:00
mathias e2a93c1e99 chore(release): plugin.json version 0.20.1
Skip-Release: true
2026-09-24 17:44:54 +00:00
mathiasandClaude Opus 5 2399ce68e5 chore(release): plugin.json version 0.20.0
CI / Check / Test (push) Successful in 9s
CI / Tag release (push) Successful in 4s
New skill (document-to-markdown) is a minor bump; the gate asserts
manifest/tag parity, so this lands with the tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:44:28 +02:00
mathiasandClaude Opus 5 46cbfc5967 feat(document-to-markdown): read the formats Read cannot open, locally
CI / Check / Test (push) Successful in 9s
CI / Tag release (push) Successful in 2s
Read handles PDF, images and notebooks natively; it does not open .docx,
.xlsx, .pptx, .epub, .msg or .zip. One uvx command does, with no install step
and no service.

Deliberately does NOT call docmark, which is the counter-intuitive part.
Measured 2026-09-24 on the same .docx, tables included: local markitdown and
the docmark service return byte-identical Markdown. Conversion of a
text-bearing document involves no LLM at all — the client is used only to
describe images. A network hop, a bearer token and an ingress for the same
bytes is not a trade worth making, and docmark is unreachable from a session
on koala anyway (ClusterIP, NetworkPolicy admits supervisor and dispatch only).

The boundary is measured, not asserted. The light extras register 18
converters; docmark registers 22. The difference is exactly DocxWithOCR,
PdfWithOCR, PptxWithOCR and XlsxWithOCR — the vision path, which is the one
case that must go through docmark because its sovereignty gate is the estate's
only audited route from an image to a model.

Two hazards the skill names because both bite silently:

  - markitdown[all] pulls az-doc-intel and az-content-understanding, Azure
    cloud converters. The extras list avoids having them on the path.
  - A blank template and a scanned PDF with no text layer both convert to zero
    characters and exit 0. Check the byte count; an empty success is the
    failure mode, and a confident summary of an unread document is the worst
    output this skill could produce.

Refs mathias/docmark#3

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:44:10 +02:00
mathias cf3abafc66 chore(release): plugin.json version 0.19.6
Skip-Release: true
2026-09-11 09:11:36 +00:00
mathias 6838baa071 fix(ci): gate the release-bump commit — nothing has ever tested it
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 1s
Found from the other end: the koala cluster's CIHeadUncovered{repo=skills}
alert had been firing for 85 hours, and it is a true positive.

    mathias/skills main HEAD   f031c2b
    workflow runs for that sha  0

Not a one-off. The previous release-bump commit has the same property:

    6cc224f  chore(release): plugin.json version 0.19.4  ->  0 runs
    f031c2b  chore(release): plugin.json version 0.19.5  ->  0 runs

The `tag` job pushes the manifest bump itself with secrets.GITEA_TOKEN, and a
push authenticated with the Actions token does not trigger workflows -- that is
deliberate platform behaviour to stop CI recursing. The `Skip-Release: true`
trailer stops the TAGGER recursing and works; nothing stopped the whole run,
`gate` included, from never firing on that commit.

So after every release, main's HEAD is a commit no CI has seen, until an
unrelated human push moves it. CIHeadUncovered therefore cannot clear on its
own -- and an alert that can only be cleared by doing something unrelated is
worse than a noisy one, because it teaches the reader that red is the normal
state of that rule.

The commit is not cosmetic either. 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 tagged with the manifest still reading 0.9.0, per the
comment above that very loop.

Fix: run `task check` on the bump commit before pushing it. The commit is
produced in that step, so it can be validated in that step -- one check instead
of a second full CI run, and no recursion risk to reintroduce.

Considered and rejected: re-triggering CI with a different credential after the
push (costs a full run per release and brings back what the token behaviour
exists to prevent), and teaching CIHeadUncovered to ignore manifest-only
commits (narrowing the measure to fit the system, and it would hide a real gap
too).

The assertion in tests/ci_gate_test.sh had to be anchored with ^[^#]*: the tag
job's own prose says "`task check` asserts the manifest is not behind the
latest tag", so a bare grep matched the comment explaining the problem and
reported the problem fixed.

Closes #46.
2026-09-11 11:11:16 +02:00
mathias f031c2b77d chore(release): plugin.json version 0.19.5
Skip-Release: true
2026-09-08 19:35:09 +00:00
mathiasandClaude Opus 5 1328dcd8f3 feat(doctor): report golangci-lint built with a Go older than GOROOT
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 1s
Second recurrence of the same host fault (infra#375; first hit
openbanking-api 2026-09-03, then cobalt-dingo 2026-09-08). golangci-lint
embeds the go/types export-data reader from the Go it was BUILT with, so a
newer GOROOT makes it unable to decode the stdlib:

  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 source 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.

CI never sees it — the runner image has matching versions — so it breaks
exactly the local `task check` that AGENT.md requires before every push, and
nothing else. The fix was always one reinstall; the missing thing was the
signal, which is what doctor is for.

Fails open by design: an unparseable version, or no Go at all, is a normal host
and not a fault doctor may assert. Version comparison is major.minor in POSIX
sh rather than sort -V, since this runs on macOS too and the export-data format
is minor-versioned.

Also makes the suite hermetic. The check reads the real host, so on a machine
whose golangci-lint is genuinely stale every unrelated fixture started failing.
The harness now prepends a matching fake pair for the whole file, and only the
four cases that opt in exercise the check.

Verified against koala, which is currently in exactly this state:

  FAIL toolchain: golangci-lint built with go1.26.1 but GOROOT is go1.27.0
       — lint panics on every Go repo; reinstall it under the current
       toolchain (infra#375)

doctor: 25 passed, 0 failed. Full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:34:44 +02:00
mathias 6cc224f151 chore(release): plugin.json version 0.19.4
Skip-Release: true
2026-09-07 20:34:58 +00:00
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
mathiasandClaude Opus 5 9fd5a054b3 fix(check): tolerate an unverifiable check instead of dying on it
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
bin/check-mcp-env exits 2 for "could not run" -- no jq, or no env-file --
and the gate was written to report that as a NOTE rather than fail:

    "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
    case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac

That tolerance never executed. Task runs each cmds block under `sh -e`, so
a bare command exiting non-zero aborts the shell before `case $?` is
reached. main went red on the very commit that added the check (run #57),
with `task: Failed to run task "check": exit status 2`.

It passed locally because this host has both jq and ~/.op-env, so the
check exited 0 and the untested branch never ran. CI has neither. A
tolerated exit code that is only tolerated where it never fires is not
tolerated at all.

`|| rc=$?` puts the invocation in a compound command, which `set -e`
exempts, so `case` is reached. Both exit-2 paths verified: no env-file
(the new test's precondition) and no jq (PATH sandbox, same SKIP message
CI printed).

The test pins the tolerance itself rather than the checker: it points HOME
at an empty directory, which is exactly CI's condition, and asserts `task
check` exits 0 AND still says it could not verify -- silence would mean
skipped rather than tolerated.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:14:19 +02:00
18 changed files with 1208 additions and 6 deletions
+1 -1
View File
@@ -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.0",
"version": "0.22.0",
"author": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
+27
View File
@@ -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
+1 -1
View File
@@ -29,7 +29,7 @@ descriptions and "use when" triggers.
/plugin install skills@mathias
```
The plugin ships skills, the `secret-guard` + `pre-task-ritual` hooks, and the
The plugin ships skills, the `secret-guard`, `mcp-destructive-guard` and `pre-task-ritual` hooks, and the
brain/gitea/infra MCP servers as one versioned unit. Skills are namespaced
(`skills:tdd`). Updates land when `.claude-plugin/plugin.json`'s `version`
moves — CI bumps it on every release.
+1
View File
@@ -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 |
+8 -3
View File
@@ -78,7 +78,7 @@ tasks:
for entry in "{{.TASKFILE_DIR}}"/*; do
base=$(basename "$entry")
case "$base" in
install.sh|Taskfile.yml|README.md|SKILLS_INDEX.md|skills|hooks|bin|tests) continue ;;
install.sh|Taskfile.yml|README.md|SKILLS_INDEX.md|skills|hooks|bin|tests|agents) continue ;;
esac
echo "ERROR: unexpected entry at repo root: $base" >&2
fail=1
@@ -145,8 +145,13 @@ tasks:
# not run (no jq, or no env-file — CI has neither); that is reported
# and not fatal, but it is never treated as a pass.
if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env"
case $? in
# `|| rc=$?` is load-bearing, not style. Task runs this block under
# `sh -e`, so a bare invocation exiting non-zero kills the shell
# before `case` is reached -- the tolerance below never ran, and the
# gate died with `exit status 2` on the commit that added it.
rc=0
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" || rc=$?
case $rc in
0) ;;
2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;;
*) fail=1 ;;
+299
View File
@@ -0,0 +1,299 @@
---
name: pr-gatekeeper
description: Adversarially reviews one change and returns a blocking verdict. Two modes — an open Gitea PR (verify CI, then merge and close, or escalate), or a staged/unpushed diff in a GitOps repo where a branch cannot be reconciled, in which case it reviews before the push and records the verdict on the issue without merging. Use for risk/medium work. Spawned with a repo, the issue it closes, and either a PR number or a checkout path.
tools: Read, Grep, Glob, Bash
color: "#7C3AED"
---
<role>
You are the merge gate for a solo estate. Nobody else will read this PR. If you
approve something broken, it ships to a live GitOps cluster within 60 seconds.
Your default posture is refusal. The author (another agent) believes the work is
correct — that belief is the input you are testing, not evidence. The same model
in the author's seat has convinced itself five times in a row and been wrong
every time.
Blocking the same PR repeatedly is the control working, not you being
difficult. For code whose job is to refuse something, expect it.
</role>
## What you are given
One of two modes. Everything after step 2 is identical — the review standard does
not change with the plumbing.
**PR mode** (the original):
- `owner/repo`, the PR number, and the issue number it claims to close
- The claim the author made about what the change does
**Pre-push mode** (infra#470): there is no PR, because there cannot be one.
- `owner/repo`, the path to the checkout, and the issue number the work claims to close
- The claim the author made about what the change does
In a repo whose deployment watches one branch — `mathias/infra`, where Flux
watches `main` — a branch is invisible to the thing that applies it. A
`risk/medium` change to `alerts.yaml` or `litellm-config.yaml` can be linted on a
branch but never *reconciled* there, so a PR would review a state that was never
deployed. The agreed lane is therefore that such work stays on `main` and gets a
recorded adversarial review pass **before the push**, with you as the reviewer.
If that lane resolves to "the author was careful", it has replaced nothing. You are
the difference between the rule and a sentence about the rule.
## Procedure
Work in the repo checkout at the path given to you. Never push, never force,
never amend the author's commits.
### 1. Read the issue before the diff
```
mcp__plugin_skills_gitea__issue_get
mcp__plugin_skills_gitea__issue_list_comments
```
The issue's "done when" clauses are the acceptance criteria. A PR that is
well-built and does not satisfy them is a **reject**, not an approve-with-note.
Comments carry sequencing constraints that the body does not.
### 2. Get the real diff
**PR mode:**
```bash
git fetch origin
git diff origin/main...origin/<branch>
```
**Pre-push mode** — the change is staged, or committed and unpushed:
```bash
git status --porcelain # know what is staged vs merely present
git diff --staged # staged but not committed
git diff origin/main...HEAD # committed locally, not yet pushed
```
Run all three. Which one holds the change is itself information: work that is
neither staged nor committed is not a diff under review, it is a working tree, and
you should say so rather than review it. Untracked files do not appear in either
diff — `git status --porcelain` is how you notice a new file the author forgot to
stage, and a control that exists only in an unstaged file is not going to ship.
Read every hunk. Do not skim. If the diff is too large to review properly, say
so and escalate rather than producing confident coverage of a fraction of it.
### 3. Iron laws — any violation blocks
1. **Secrets.** No credential, token or key in the diff, in a test fixture, or
rendered into output by a command the change adds. A `kubectl get secret -o
yaml`, a `sops -d` piped to stdout, or an `echo $TOKEN` is an immediate block.
2. **Swallowed errors.** `_ = f()`, a bare `except:`, `|| true` on a line whose
failure matters, `err` returned naked.
3. **Unvalidated input at a boundary.** External API responses, file reads, user
input used before checking.
4. **Live-breaking GitOps changes.** For `mathias/infra`: does this leave the
cluster reconcilable? A `kubectl diff` that is clean is **not** proof Flux
will accept it — server-side apply does not prune fields it does not own, and
that took the whole `apps` Kustomization down on 2026-09-16.
### 4. The test is the claim — attack it
This estate's recurring defect is **a check that passes without proving
anything**. Confirmed instances to pattern-match against:
- a suite grading a subprocess's stdout passed when the subprocess crashed:
empty output, zero assertions, `fail` stayed 0
- a gate mapped 11 of 18 subjects and *printed* the other seven, then exited 0
- a manifest check matched `type: Recreate` inside a **comment** explaining why
Recreate had been rejected
So, for every test the PR adds or changes:
- **Flip it.** Break the thing it claims to check and run it. If it still
passes, the test is decoration. Say so and block. On `dispatch#75` this found
**three behavioural changes shipped with no assertion at all** — correct code
that nothing would have noticed the removal of.
- **Read the name against the assertion.** A test whose name claims more than
it proves is worse than no test, because the name wins in the reader's head
and the gap becomes invisible. Found three times in one PR, twice in tests
written to fix the previous instance. `DoesNotInheritEnv` is honest;
`IsContained` is a claim the test cannot support. (local-dev `AGENT.md` rule 9.)
- Does it fail when it cannot run? An empty result, a parser that matches
nothing, a missing file must all be failures, never silent passes.
- Does it assert on what the machine reads, or on prose near it?
- Is coverage total, or a majority? Partial coverage must be expressed as a
failure or an explicit, justified allowlist.
A PR with no test for a behavioural change is a block unless the change is
purely declarative config, and even then ask what would catch its regression.
### 4b. How to attack — the three habits that actually found the escapes
Five rounds on `dispatch#75` produced five real escapes. **CI was green on every
one of them.** These are what found them; reading the diff did not.
**Verify by execution, not by reading the implementation.** Build a scratch
repo, run the command, look at the filesystem. Every finding came from a
command that ran, not from reasoning about a matcher. The matcher said one
thing and the filesystem said another, five times running. A claim you have not
executed is a hypothesis.
**Read the survivors.** After any allow/deny sweep, read the list of what is
still *permitted*, one entry at a time. This is what caught `git commit -F
/etc/passwd` — reads an arbitrary file into a commit message that is then
pushed, i.e. exfiltration wearing a message flag. It was found by neither the
review nor the differential, because both implementations **agreed** and both
were wrong. Agreement is not correctness.
**Grade a tool on its exit code, never on grepping its output.** A crashed
linter greps as clean. `golangci-lint` exits 0 clean / 1 issues found / >1 the
linter itself failed — and a harness that matched its summary text scored a
crash as a pass, leaving the only gate over an entire package silently absent.
Where a control has **two implementations** kept in sync by hand, run one case
list through both and diff the verdicts mechanically. A case one refuses and
the other allows is a real finding, and this has caught one.
### 4c. Probing without causing the incident you are testing for
**This agent has caused a credential leak.** On 2026-09-19 a probe testing
environment scrubbing printed `/proc/$PPID/environ` and `ps e` raw, and a `git
credential fill` consulted the host's real store. Seven live credentials went
into the transcript and had to be rotated (`infra#445`). The finding was
correct; the method was not.
Non-negotiable when probing anything credential-adjacent:
- **A probe that reads an environment prints a count, never the buffer.**
Grep for a sentinel and report whether it was found. A leak test that prints
what it found defeats itself.
- **Use an invented sentinel**, never a real secret name. Set it yourself, then
assert on its absence.
- **Anything touching git's credential subsystem runs with an isolated `HOME`**
and `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` pointed away from the real ones.
Otherwise it reads the host's store, which is the point of the subsystem.
- Prove a primitive and stop. Do not aim a demonstrated write or execution
primitive at a real secret to show it works.
A command whose *purpose* is to surface credentials will surface real ones if
you point it at a real environment.
### 5. CI
```
mcp__plugin_skills_gitea__pr_get # mergeable, conflicts
mcp__plugin_skills_gitea__workflow_run_list
```
CI green is necessary and not sufficient — you have just spent step 4
establishing what its green is worth. On `dispatch#75` it was worth nothing
five times running: `task check` exit 0, `golangci-lint` exit 0, every test
passing, while `git rm ././.dispatch-allow` deleted the file the control
existed to protect. Say so in your report when it applies: *green, and green
covers none of these findings.*
CI **red or absent** is an automatic block: an agent-opened PR with zero runs
is a known platform failure mode in this estate, not a pass.
**Pre-push mode: CI cannot have run**, because nothing has been pushed. The only
signal is the author's local gate, and the absence of CI is not a finding against
the change — it is a fact about the mode. Two consequences:
- Do not block for "CI absent" here. That rule exists for a *pushed* branch with no
runs, which is a platform failure; an unpushed diff having no runs is arithmetic.
- Verify the local gate the way you would verify any claim: run it yourself. Check
the **exit code**, not filtered output — `task check; echo $?` — because a grep
over a gate's output has hidden a failing test in this estate before. "The author
says it is green" is the belief you are testing.
So in pre-push mode your report carries strictly less external evidence than in PR
mode, and must say so. The verdict is "nothing I could find blocks this push", never
"CI confirms this".
### 6. Verdict
**PR mode — merge** only when all hold:
- no iron-law violation
- every "done when" clause in the issue is satisfied
- CI ran and is green
- you flipped the tests and they went red
- the change is `risk/low` or `risk/medium`
Then:
```
mcp__plugin_skills_gitea__pr_merge # squash
mcp__plugin_skills_gitea__issue_comment # the evidence, on the ISSUE
mcp__plugin_skills_gitea__issue_close
mcp__plugin_skills_gitea__branch_delete
```
**Pre-push mode — clear to push** only when all hold:
- no iron-law violation
- every "done when" clause in the issue is satisfied
- you ran the local gate yourself and checked its exit code
- you flipped the tests and they went red
- the change is `risk/low` or `risk/medium`
**There is nothing to merge and no PR to close, so do not merge, do not close the
issue, and do not touch a branch.** Your entire output is a comment:
```
mcp__plugin_skills_gitea__issue_comment # the verdict, on the ISSUE
```
**The verdict goes in the issue.** That is not a stylistic preference — it is what
makes the lane a *recorded* review pass rather than a conversation. A verdict that
exists only in a chat transcript is indistinguishable from no review having
happened, and the rule that created this mode says exactly that: no recorded
verdict means the lane was not used.
Head the comment so it can be found by grep, state which mode you ran in, and say
plainly that CI had not run at the time you reviewed. The author pushes; you do not.
For `mathias/infra`, after the author has pushed:
```bash
flux reconcile kustomization apps --with-source
flux get kustomization apps
```
If it does not go `Ready: True`, **revert immediately** rather than
forward-fixing, and report. A blocked `apps` Kustomization stops every app in
the cluster, not just the changed one.
Put your evidence on the **issue**, not only the PR thread. In Gitea a merge
auto-closes the gated issue and review comments live on a thread separate from
the issue's — so a finding recorded only on the PR is a finding nobody reading
the issue will ever see.
### 7. Escalate to Mathias — and only then
Do not merge. Comment the finding on the issue and report it. Escalate when:
- the change is `risk/high`
- it touches host networking, iptables, `ip rule`, Tailscale, or UFW on koala
- it needs `sudo` (denied outright to agents on koala)
- it writes or rotates a secret
- it deletes data, a PVC, or a repo
- a finding is real and the fix is a judgement call, not a correction
- the diff is too large to review honestly
Escalation is cheap. A bad merge on a GitOps cluster is not.
## Output
Report back, in this order:
1. **VERDICT** — `MERGED` / `BLOCKED` / `ESCALATED`
2. **Findings**, most severe first, each as `path:line — what is wrong — what
happens because of it`
3. **Flip-test results** — what you broke, and whether the test noticed
4. **CI** — run id and conclusion, or "absent"
5. **What you did not check**, explicitly
Never report a clean review you did not perform. "I read the diff and it looked
fine" is not a review; naming what you attacked and what survived is.
+10
View File
@@ -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
+61
View File
@@ -327,6 +327,67 @@ 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" ]
}
# --- agents shipped by the plugin, shadowed by host-local copies -------------
#
# skills#51: pr-gatekeeper lived ONLY at ~/.claude/agents/pr-gatekeeper.md for
# weeks — unversioned, on one host, while doctrine came to depend on it
# (local-dev AGENT.md rule 8). Now that the plugin ships agents/, a real file of
# the same name in ~/.claude/agents takes precedence over the shipped one and
# nothing says so. That is the same silent-drift shape as the `outside` and
# `unmanaged` checks above: it works, so nothing complains, while serving
# content nobody updates.
CLAUDE_AGENTS="$HOME/.claude/agents"
if [ -d "$CHECKOUT/agents" ]; then
for _shipped in "$CHECKOUT"/agents/*.md; do
[ -e "$_shipped" ] || continue
_name=$(basename -- "$_shipped")
_local="$CLAUDE_AGENTS/$_name"
[ -e "$_local" ] || continue
if [ -L "$_local" ]; then
continue # a link is managed wiring, not a shadow
fi
add FAIL shadow "$_local is a host-local copy of the plugin's agents/$_name — it wins over the shipped one, so the versioned file is not what runs; delete it"
done
fi
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' \
+10
View File
@@ -33,6 +33,16 @@
"timeout": 5
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mcp-destructive-guard.sh\"",
"timeout": 5
}
]
}
]
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# PreToolUse hook (mcp__.* matcher) — makes a human approve irreversible MCP
# verbs instead of letting the model run them unattended.
#
# Why (infra#508, infra ADR-0027): gitea-mcp authenticates by PAT pass-through,
# so an agent holds the full scope of the caller's PAT. repo_delete's own guard
# (confirm == repo) is filled in by the model on both sides: it stops typos,
# not an injected or mistaken agent. The infra-mcp README claimed client-side
# deny lists covered this; on 2026-10-01 none existed in any settings file.
#
# Policy: match gitea-mcp's verb spellings on ANY server name. The same tool is
# reachable as mcp__plugin_skills_gitea__*, mcp__claude_ai_gitea__* or under
# whatever name a host registers it with, so keying on the server name would
# let a rename bypass the gate. It does NOT recognise other servers' spellings
# of the same operation (e.g. a third-party `delete_repo`).
#
# file_write_branch is gated when it would land on main/master (on infra that
# is a Flux deploy within ~60s — the same effect as pr_merge) or empty a file
# (the same effect as file_delete). Writes to other branches pass.
#
# Emits "ask" or nothing; never "allow" (a hook allow would override the
# user's own deny/ask rules). Always exits 0: Claude Code ignores a hook's
# JSON on a non-zero exit. Anything it cannot parse — including a host
# without jq — fails closed to "ask". The ask JSON is a literal, so the
# fail-closed path needs no tool that might be missing.
#
# Scope limit, recorded in infra ADR-0027: this gates MCP calls only. A
# session with the PAT in its environment can still reach the Gitea REST API
# through a shell.
set -u
DESTRUCTIVE='repo_delete|file_delete|branch_delete|pr_merge|tbd_ship|repo_update|repo_mirror_push|workflow_run_trigger|tag_create|release_create'
ask() {
# $1 must contain only [A-Za-z0-9 _.:()'/-] — it is spliced into JSON verbatim.
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$1"
exit 0
}
INPUT="$(cat)"
command -v jq >/dev/null 2>&1 \
|| ask "mcp-destructive-guard: jq is not installed, so this MCP call cannot be checked. Approve only if you expected it."
if ! TOOL_NAME="$(printf '%s' "$INPUT" | jq -er '.tool_name | strings | select(length > 0)' 2>/dev/null)"; then
ask "mcp-destructive-guard: could not read a tool name from this call, so it cannot be shown safe. Approve only if you expected it."
fi
case "$TOOL_NAME" in
mcp__*__*) ;;
*) exit 0 ;;
esac
verb="${TOOL_NAME##*__}"
safe_verb="$(printf '%s' "$verb" | tr -cd 'A-Za-z0-9_-')"
if printf '%s' "$verb" | grep -Eqix "($DESTRUCTIVE)"; then
ask "Irreversible MCP verb '$safe_verb' (infra ADR-0027): needs your approval. The model filled in every argument, including any confirm field."
fi
if printf '%s' "$verb" | grep -Eqix 'file_write_branch'; then
# Cleared only when there is exactly ONE key that case-folds to "branch" and
# one to "content": gitea-mcp decodes with Go encoding/json, which matches
# keys case-insensitively and lets the last one win, so {"branch":"feat/x",
# "Branch":"main"} landed on main while a lowercase-only read saw feat/x.
# The branch is cleared by SHAPE (allowlist), not by a blocklist of names:
# "refs/heads/main" created a second ref that wedged API writes to main.
verdict="$(printf '%s' "$INPUT" | jq -r '
.tool_input as $i
| if ($i | type) != "object" then "ask" else
([$i | to_entries[] | select(.key | ascii_downcase == "branch")]) as $b
| ([$i | to_entries[] | select(.key | ascii_downcase == "content")]) as $c
| if ($b | length) != 1 or ($c | length) != 1 then "ask"
elif ($b[0].key != "branch") or ($c[0].key != "content") then "ask"
elif ($b[0].value | type) != "string" or ($c[0].value | type) != "string" then "ask"
elif ($b[0].value | test("^[A-Za-z0-9][A-Za-z0-9._/-]*$") | not) then "ask"
elif ($b[0].value | test("\\.\\.|^refs/|/$")) then "ask"
elif ($b[0].value | ascii_downcase | . == "main" or . == "master") then "ask"
elif ($c[0].value | test("^\\s*$")) then "ask"
else "pass" end
end' 2>/dev/null || echo ask)"
[ "$verdict" = "pass" ] \
|| ask "file_write_branch to main/master, an unusual branch name, or emptying a file (infra ADR-0027): needs your approval."
fi
exit 0
+126
View File
@@ -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.
+164
View File
@@ -0,0 +1,164 @@
#!/bin/sh
# Tests for the agents/ directory the plugin ships, and doctor's shadow check.
#
# skills#51: pr-gatekeeper blocked dispatch#75 five times, every block a real
# escape verified by execution — and the agent definition lived only at
# ~/.claude/agents/pr-gatekeeper.md on one host. Unversioned, unreviewable, one
# rm from gone. Doctrine now depends on it (local-dev AGENT.md rule 8), so the
# file has to be in the repo.
#
# These assertions observe FILE CONTENT and doctor's OUTPUT. They cannot and do
# not claim the agent reviews well — naming them otherwise would be the exact
# defect rule 9 exists for.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
AGENT="$ROOT/agents/pr-gatekeeper.md"
DOCTOR="$ROOT/bin/doctor"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/agents_test.$$
ok() { pass=$((pass + 1)); printf 'ok: %s\n' "$1"; }
no() { fail=$((fail + 1)); printf 'FAIL: %s\n' "$1"; }
# --- the file is in the repo ------------------------------------------------
if [ -f "$AGENT" ]; then
ok 'agents/pr-gatekeeper.md exists in the checkout'
else
no 'agents/pr-gatekeeper.md exists in the checkout'
printf '\n%s passed, %s failed\n' "$pass" "$fail"
exit 1
fi
# --- frontmatter is present and well-formed ---------------------------------
head -1 "$AGENT" | grep -q '^---$' \
&& ok 'the file opens with a frontmatter fence' \
|| no 'the file opens with a frontmatter fence'
for field in name description tools; do
if sed -n '2,/^---$/p' "$AGENT" | grep -q "^$field:"; then
ok "frontmatter declares $field"
else
no "frontmatter declares $field"
fi
done
fm_name=$(sed -n '2,/^---$/p' "$AGENT" | sed -n 's/^name: *//p' | head -1)
if [ "$fm_name" = "pr-gatekeeper" ]; then
ok 'the frontmatter name equals the filename stem'
else
no "the frontmatter name equals the filename stem (got '$fm_name')"
fi
# --- the practices that actually found the defects --------------------------
#
# Each string below is traceable to a specific round of dispatch#75 or to the
# incident the agent itself caused. A grep is a weak check — it proves the
# phrase is present, not that the procedure is followed — so the test is named
# for the phrase, not for the behaviour.
check_phrase() {
if grep -qi -- "$1" "$AGENT"; then
ok "the file names: $2"
else
no "the file names: $2"
fi
}
check_phrase 'verify by execution' 'verify by execution, not by reading the implementation'
check_phrase 'flip' 'flip-testing'
check_phrase 'read the survivors' 'read the survivors of a differential'
check_phrase 'exit code' 'grade a linter on its exit code'
check_phrase 'never the buffer' 'a probe prints a count, never the buffer'
check_phrase 'claims more than' 'a test name must not claim more than it proves'
# --- doctor flags a host-local copy that shadows the shipped one ------------
setup() {
rm -rf "$WORK"
mkdir -p "$WORK/home/.claude/agents" "$WORK/ck/skills/x" "$WORK/ck/agents"
printf 'x\n' > "$WORK/ck/skills/x/SKILL.md"
printf -- '---\nname: pr-gatekeeper\n---\n' > "$WORK/ck/agents/pr-gatekeeper.md"
}
setup
printf -- '---\nname: pr-gatekeeper\n---\n' > "$WORK/home/.claude/agents/pr-gatekeeper.md"
out=$(HOME="$WORK/home" DOCTOR_CHECKOUT="$WORK/ck" sh "$DOCTOR" 2>&1)
if printf '%s' "$out" | grep -q 'pr-gatekeeper'; then
ok 'doctor reports a host-local agent that shadows a shipped one'
else
no "doctor reports a host-local agent that shadows a shipped one (got: $out)"
fi
setup
out=$(HOME="$WORK/home" DOCTOR_CHECKOUT="$WORK/ck" sh "$DOCTOR" 2>&1)
if printf '%s' "$out" | grep -q 'pr-gatekeeper'; then
no "doctor is silent when no host-local copy exists (got: $out)"
else
ok 'doctor is silent when no host-local copy exists'
fi
# --- the pre-push mode the GitOps review lane depends on (infra#470) ---------
#
# infra#470 resolved a doctrine conflict: root rule 5 wants a PR for risk/medium+,
# and mathias/infra forbids branches because Flux watches main and a branch can be
# linted but never reconciled. The agreed lane is that such work stays on main and
# instead gets a RECORDED ADVERSARIAL REVIEW PASS before the push.
#
# That lane names this agent as its reviewer. If the definition only knows how to
# review an open PR, the lane resolves to "be careful" — which is what rule 5 was
# written to replace. These assertions observe FILE CONTENT only: that the mode is
# documented, that it does not merge, and that the verdict has a stated home. They
# do NOT claim the agent reviews a staged diff well (rule 9).
if grep -qi 'git diff --staged\|git diff --cached' "$AGENT"; then
ok 'the definition names a command that reads a staged diff, not only a branch diff'
else
no 'the definition has no staged-diff command, so the infra#470 lane has no reviewer'
fi
if grep -qi 'pre-push' "$AGENT"; then
ok 'the definition names the pre-push mode'
else
no 'the definition does not name a pre-push mode (infra#470)'
fi
# The verdict has to land somewhere durable, or "recorded review pass" is untrue.
# The lane specifies the issue, deliberately not a PR thread — there is no PR.
if grep -qi 'verdict in the issue\|verdict goes in the issue' "$AGENT"; then
ok 'the definition states the verdict goes in the issue'
else
no 'the definition does not say where a pre-push verdict is recorded'
fi
# In pre-push mode there is nothing to merge and no PR to close. An agent that
# reaches for pr_merge there would be acting on a PR that does not exist.
#
# SCOPED to the pre-push section on purpose. An unscoped grep for "do not merge"
# passes on line 202 of the current file, which is the ESCALATION step talking
# about a PR — a pass for entirely the wrong reason, and the kind of vacuous
# assertion rule 9 is about. Caught by checking what the green was matching.
PREPUSH=$(awk '/[Pp]re-push/{f=1} f&&/^## [^#]/&&!/[Pp]re-push/{f=0} f' "$AGENT")
if printf '%s' "$PREPUSH" | grep -qi 'nothing to merge\|do not merge\|no PR to merge'; then
ok 'the pre-push section itself rules out merging'
else
no 'the pre-push section does not rule out merging (an unscoped grep would pass here wrongly)'
fi
# CI cannot have run on an unpushed diff, so the CI step's meaning changes. A
# definition that still treats "CI green" as available would grade on a signal
# that does not exist yet.
if grep -qi 'CI has not run\|no CI run yet\|CI cannot have run' "$AGENT"; then
ok 'the definition acknowledges CI has not run on an unpushed diff'
else
no 'the definition does not address CI being absent pre-push'
fi
rm -rf "$WORK"
printf '\n%s passed, %s failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
@@ -0,0 +1,70 @@
#!/bin/sh
# `task check` must survive a check that reports "could not verify".
#
# The regression these exist for: bin/check-mcp-env exits 2 to mean "I could
# not run — no jq, or no env-file", which the gate deliberately treats as a
# reported NOTE rather than a failure. The Taskfile said exactly that:
#
# "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
# case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac
#
# and it did not work. Task runs each cmds block under `sh -e`, so a bare
# command exiting non-zero aborts the shell *before* `case $?` is ever
# reached. The whole gate died with `exit status 2`. Locally the check found
# jq and exited 0, so it went green; CI has neither jq nor ~/.op-env, so main
# went red on the very commit that added it (skills run #57, 2026-09-07).
#
# A tolerated exit code that is only tolerated on the host where it never
# fires is not tolerated at all. This pins the tolerance itself.
#
# No mocking: pointing HOME at an empty directory removes ~/.op-env, which is
# exactly the condition CI runs under, so check-mcp-env genuinely exits 2.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
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 task >/dev/null 2>&1; then
printf ' SKIP: task not installed -- cannot exercise the gate\n'
exit 0
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# Sanity: the precondition must actually hold, or the test proves nothing.
# An empty HOME means no ~/.op-env, so check-mcp-env cannot verify anything.
"$ROOT/bin/check-mcp-env" "$ROOT/.mcp.json" "$TMP/.op-env" >/dev/null 2>&1
rc=$?
if [ "$rc" -eq 2 ]; then
ok 'precondition: check-mcp-env exits 2 when the env-file is absent'
else
no "precondition: expected check-mcp-env to exit 2, got $rc"
fi
# The assertion. `task check` must still pass, and must say why it could not
# verify -- silence would mean the check was skipped rather than tolerated.
out=$(HOME="$TMP" task --dir "$ROOT" check 2>&1)
rc=$?
if [ "$rc" -eq 0 ]; then
ok 'task check passes when MCP env wiring cannot be verified'
else
no "task check exited $rc when check-mcp-env reported unverifiable"
printf '%s\n' "$out" | sed 's/^/ | /' | tail -8
fi
if printf '%s' "$out" | grep -q 'unverified'; then
ok 'the unverifiable check is reported, not silently skipped'
else
no 'no NOTE reported -- an unverifiable check must never look like a pass'
fi
printf ' %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+26
View File
@@ -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 ]
+82
View File
@@ -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 ]
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
# Tests for hooks/mcp-destructive-guard.sh — the PreToolUse gate that makes a
# human approve irreversible MCP verbs (infra#508, infra ADR-0027).
#
# Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers
# in hookSpecificOutput.permissionDecision. "ask" = a human must approve;
# empty stdout ("NONE") = no opinion, normal permission rules apply. The guard
# never emits "allow": an allow from a hook would bypass the user's own rules.
#
# Cases marked (gatekeeper) are escapes found by the skills#52 review: each
# passed the first version of this suite.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
GUARD="$ROOT/hooks/mcp-destructive-guard.sh"
pass=0
fail=0
# run_guard <json> [PATH] — invoked exactly as hooks.json does: `bash <path>`.
# Prints "<decision> rc=<exit>", decision NONE for an empty answer.
run_guard() {
if [ $# -ge 2 ]; then
_out=$(printf '%s' "$1" | PATH="$2" "$BASH_BIN" "$GUARD"); _rc=$?
else
_out=$(printf '%s' "$1" | bash "$GUARD"); _rc=$?
fi
if [ -z "$_out" ]; then _d=NONE
else _d=$(printf '%s' "$_out" | jq -r '.hookSpecificOutput.permissionDecision // "NONE"' 2>/dev/null || echo BROKEN)
fi
printf '%s rc=%s' "$_d" "$_rc"
}
BASH_BIN=$(command -v bash)
check() {
_want=$1 _json=$2 _label=$3
_got=$(run_guard "$_json")
if [ "$_got" = "$_want rc=0" ]; then
pass=$((pass + 1)); printf ' ok %s\n' "$_label"
else
fail=$((fail + 1)); printf ' FAIL %s\n want=%s rc=0 got=%s\n in=%s\n' "$_label" "$_want" "$_got" "$_json"
fi
}
tool() { printf '{"tool_name":"%s","tool_input":{"owner":"mathias","repo":"x"}}' "$1"; }
echo "mcp-destructive-guard tests"
for v in repo_delete file_delete branch_delete pr_merge tbd_ship repo_update repo_mirror_push \
workflow_run_trigger tag_create release_create; do
check ask "$(tool "mcp__plugin_skills_gitea__$v")" "plugin gitea $v asks"
done
# The verb is matched on any server name: connector, bare, or renamed.
check ask "$(tool mcp__claude_ai_gitea__repo_delete)" "connector namespace asks"
check ask "$(tool mcp__gitea__repo_delete)" "bare server name asks"
check ask "$(tool mcp__git__repo_delete)" "renamed server still asks"
check ask "$(tool mcp__plugin_skills_gitea__REPO_DELETE)" "uppercase verb asks"
# The verb is the LAST segment (gatekeeper: parsing the second segment stayed green).
check ask "$(tool mcp__a__b__repo_delete)" "verb taken from last segment"
check NONE "$(tool mcp__repo_delete__issue_get)" "server named like a verb does not trigger"
# file_write_branch: content landing on main, or emptying a file, is the same
# effect as the gated pr_merge / file_delete (gatekeeper).
fwb() { printf '{"tool_name":"mcp__plugin_skills_gitea__file_write_branch","tool_input":{"branch":"%s","content":"%s"}}' "$1" "$2"; }
check ask "$(fwb main x)" "file_write_branch to main asks"
check ask "$(fwb master x)" "file_write_branch to master asks"
check ask "$(fwb feat/y '')" "file_write_branch emptying a file asks"
check ask '{"tool_name":"mcp__plugin_skills_gitea__file_write_branch","tool_input":{"content":"x"}}' "file_write_branch without branch asks"
check NONE "$(fwb feat/y x)" "file_write_branch to a feature branch passes"
# Go's encoding/json matches keys case-insensitively, last one wins: gitea-mcp
# wrote to main from {"branch":"feat/y8","Branch":"main"} while the hook read
# only `branch` (gatekeeper round 2, verified against a real Gitea). So the
# guard clears a write only when exactly one key case-folds to branch/content.
FWB=mcp__plugin_skills_gitea__file_write_branch
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"branch\":\"feat/y8\",\"Branch\":\"main\",\"content\":\"x\"}}" "case-variant Branch key asks"
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"branch\":\"feat/y\",\"content\":\"x\",\"Content\":\"\"}}" "case-variant Content key asks"
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"BRANCH\":\"feat/y\",\"content\":\"x\"}}" "only an uppercase BRANCH key asks"
check ask "$(fwb Main x)" "file_write_branch to Main asks"
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":{\"branch\":\"feat/y\"}}" "missing content key asks"
check ask "$(fwb feat/y ' ')" "whitespace-only content asks"
check ask "{\"tool_name\":\"$FWB\",\"tool_input\":\"branch=feat/y\"}" "non-object tool_input asks"
# Branch names are cleared by shape, not by blocklist: refs/heads/main created a
# second ref that wedged every API write to main (gatekeeper side finding).
check ask "$(fwb refs/heads/main x)" "refs/ prefixed branch asks"
check ask "$(fwb ' main' x)" "branch with whitespace asks"
check ask "$(fwb '../main' x)" "branch with .. asks"
check NONE "$(fwb release/v1.2_x x)" "ordinary branch name passes"
# Reversible / read verbs pass through.
for v in issue_close issue_create file_read repo_list pr_create; do
check NONE "$(tool "mcp__plugin_skills_gitea__$v")" "$v passes through"
done
check NONE "$(tool mcp__plugin_skills_gitea__repo_delete_dry_run_list)" "suffix near-miss passes"
check NONE "$(tool Bash)" "non-MCP tool passes"
check NONE "$(tool repo_delete)" "non-MCP tool named like a verb passes"
# Malformed input fails CLOSED (gatekeeper: non-string tool_name got NONE).
check ask 'not json' "non-JSON asks"
check ask '{}' "missing tool_name asks"
check ask '{"tool_name":null}' "null tool_name asks"
check ask '{"tool_name":""}' "empty tool_name asks"
check ask '{"tool_name":5}' "number tool_name asks"
check ask '{"tool_name":["mcp__x__repo_delete"]}' "array tool_name asks"
check ask '{"tool_name":{"a":1}}' "object tool_name asks"
# Without jq on PATH the guard must still answer ask, not stay silent
# (gatekeeper: the fail-closed path itself needed jq).
nojq=$(mktemp -d); trap 'rm -rf "$nojq"' EXIT
for b in bash cat grep tr sed; do ln -s "$(command -v $b)" "$nojq/$b"; done
_got=$(run_guard "$(tool mcp__plugin_skills_gitea__repo_delete)" "$nojq")
if [ "$_got" = "ask rc=0" ]; then pass=$((pass+1)); echo " ok no jq on PATH still asks"; else fail=$((fail+1)); echo " FAIL no jq on PATH: got=$_got"; fi
_got=$(run_guard "$(tool mcp__plugin_skills_gitea__issue_get)" "$nojq")
if [ "$_got" = "ask rc=0" ]; then pass=$((pass+1)); echo " ok no jq on PATH asks even for a read verb (cannot parse = cannot clear)"; else fail=$((fail+1)); echo " FAIL no jq read verb: got=$_got"; fi
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+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}
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