Compare commits

...
10 Commits
Author SHA1 Message Date
mathias 0be3506799 chore(release): plugin.json version 0.5.0
Skip-Release: true
2026-08-24 16:01:14 +00:00
mathiasandClaude Opus 5 6151b973bc perf(mcp): reach brain and infra over tailnet NodePorts, not the public edge
release / tag (push) Successful in 1s
Every MCP call from Claude Code was going out through claude.ai's connector
infrastructure, across the public internet, through Cloudflare and NPM on
piguard, back into koala's k3s ingress — to reach a service on the same
machine the session is running on. For brain that means a two-cloud round trip
to read /var/lib/supervisor/brain, a file on koala's local disk.

Cloudflare rate-limits and size-caps tool results on that path, which is the
actual user-facing symptom. Measured from koala:

  brain  koala:30330    200 in  6.8ms
  infra  koala:30342    200 in  3.6ms
  brain  public edge    200 in 27.7ms

infra starts working again as a side effect: its only fault was an SNI/cert
gap at the public edge (TLS "unrecognized name"), which the NodePort path does
not traverse. No NPM change needed for local use.

Note this is not a relocation of existing config. The mcpServers block that
lived in ~/.claude/settings.json was inert — Claude Code reads server
definitions from ~/.claude.json, project .mcp.json, plugins and connectors,
not from settings.json. Verified: a uniquely-named server placed there is
invisible to both `claude mcp list` and `claude mcp get`. So before the plugin
existed, koala had no direct path at all and every call went via Cloudflare by
construction. That also explains how a stale GITEA_MCP_TOKEN went unnoticed
for five days: nothing consumed it.

Safe to land now because koala is the only host with the plugin installed
(#24). The koala firewall must allow 30330/30342 on the Tailscale interface —
NOT on the LAN broadly, so tailnet membership stays the perimeter — before the
Macs get the plugin. Tracked as a prerequisite in #24. Measured today: those
ports are refused from iguana over both LAN and tailnet while :22 answers, so
the rule does not exist yet.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-24 18:00:17 +02:00
mathias 6c1b6ce9db chore(release): plugin.json version 0.4.2
Skip-Release: true
2026-08-24 15:31:15 +00:00
mathiasandClaude Opus 5 5c8341c797 fix(mcp): drop the gitea server — it was a redundant, silently stale copy
release / tag (push) Successful in 2s
Every gitea__* call from Claude Code on koala already resolves through the
claude.ai connector, not through this env-var-authenticated HTTP server. The
entry was a second path to the same service that nothing actually used.

That redundancy is exactly why it rotted unnoticed. GITEA_MCP_TOKEN on koala
resolves ~/.op-env -> op://HomeLab/GITEA_MCP_TOKEN, an item orphaned by the
2026-07-30 consolidation that moved gitea-mcp, agentsquad-serve and
crush-runner onto a single shared item in the AgentSecrets vault. The host
env-file was never migrated and cannot be: koala's 1Password service account
has no access to AgentSecrets, while onepassword-connect in-cluster maps both
vaults. So the host copy diverged and stayed diverged for five days.

The cluster was never affected — zero 401s from the pod CIDR in 30 days, every
rejection came from the gateway. Deleting the divergent copy beats maintaining
a second source of truth for a token this host does not need.

brain stays: it authenticates, and it is genuinely used from the plugin.
infra stays but is currently unreachable for an unrelated reason —
infra-mcp.d-ma.be fails TLS with "unrecognized name", an SNI/cert gap at the
NPM edge, tracked separately.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-24 17:31:11 +02:00
mathias 5ca44c9c6b chore(release): plugin.json version 0.4.1
Skip-Release: true
2026-08-23 12:39:55 +00:00
mathiasandClaude Opus 5 e2a312d05e fix(ci): anchor the release-skip marker to a trailer, and test it
release / tag (push) Successful in 1s
The tagger searched the entire commit message for a bracketed marker as a
substring. The commit that introduced the manifest-bump step described that
marker in its own body — so the tagger read its own documentation as an
instruction and skipped the release. Run 15 reported success with no tag, no
manifest bump and no error. Same failure family as everything else in this
sweep: the wrong thing happened silently.

The rule is now a git trailer matched anchored to its own line, consistent
with Bump-Type, and it lives in bin/should-skip-release rather than untested
inside YAML. tests/release_skip_test.sh pins six cases, including the exact
regression: a message that spells the marker out in prose must still tag.

A marker you cannot mention without triggering is a footgun, not a mechanism.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:39:31 +02:00
mathiasandClaude Opus 5 78b9b59eca docs+ci: document the plugin path, bump plugin.json version on release
release / tag (push) Successful in 1s
CI now writes the computed semver into .claude-plugin/plugin.json before
tagging, committed with [skip-release] so it does not re-trigger the tagger.
Claude Code gates plugin updates on that field: without the bump, installed
hosts never see a new tag no matter how many releases go out. `task check`
enforces manifest/tag equality so the two cannot silently diverge.

Docs rewritten around the two delivery paths, and the checkout location is now
stated as load-bearing rather than incidental: SKILLS_CHECKOUT_DIR defaults to
~/.local/share/skills because that is host-local and outside any synced
directory. The previous docs told you to clone into ~/dev, which on this fleet
is an iCloud folder shared by two Macs spanning two path roots — a git working
tree with concurrent writers, and absolute links that are broken on one side by
construction. That advice is what #23 is about; it is now inverted with the
reason attached.

Antigravity and Vibe removed from the docs along with their targets.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:35:38 +02:00
mathiasandClaude Opus 5 88c54399fe feat(doctor): fail loud on skills drift, wired to SessionStart
Every failure this fleet actually hit was silent. 243 dangling symlinks were
invisible to git because **/.claude/skills/ is in the global gitignore. A
checkout 20 skills behind still resolved, so nothing complained. A loose
secret-hygiene.md loaded in no harness and errored in none. Two machines had
the library missing entirely. Exit status is the signal that was absent.

bin/doctor reports, with a non-zero exit:
  - dangling links, anywhere under the harness dirs
  - links that RESOLVE but land outside the active checkout — the dangerous
    case, because it works while serving stale content
  - skill-count mismatch between a harness and the checkout
  - stray entries where <name>/SKILL.md was expected
  - leftover per-skill Claude symlinks (plugin skills do not override
    same-named user skills, so leftovers mean everything loads twice)
  - divergence between the plugin copy and the copy the other harnesses link to

Wired as a SessionStart hook with --quiet, so a healthy host prints nothing and
a drifting one says so at the top of the next session. That beats a timer:
no launchd/systemd wiring, and it reaches every host that installs the plugin.
27ms on koala, no network, no git fetch.

Two design points found by the tests, both worth keeping:
  - The canonical checkout is whatever ~/.agents/skills resolves to, NOT the
    copy doctor runs from. As a plugin hook $0 lives in the plugin cache, which
    is legitimately a second copy; judging harness links against it flagged
    every one of them as "outside".
  - "cd X/.." resolves ".." lexically against the link path and lands back in
    ~/.agents. Resolve the link first, then take the parent.

tests/doctor_test.sh drives seven fixtures through $HOME and $DOCTOR_CHECKOUT,
so no test touches the real host. Each asserts both the non-zero exit and that
the report names the fault — an exit-code-only assertion would pass while the
message said nothing useful.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:34:32 +02:00
mathiasandClaude Opus 5 747c6c7f4c feat(plugin): ship the secret-guard and pre-task-ritual hooks + MCP servers
The plugin now carries the two hooks Mathias owns and the brain/gitea/infra
MCP server definitions, not just skills.

Why this matters more than tidiness: both hooks existed ONLY in koala's
~/.claude/, which is host-local and unsynced. flamingo and iguana have been
running with no secret-guard at all — that is the control which blocks the
decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, the op-item-get
shape behind 2026-07-30, and the base64-bypass shape behind 2026-06-11. Its
absence on two of three machines is a security gap, not a config gap. Shipping
it in the plugin means installing the plugin installs the guard.

.mcp.json references tokens as ${BRAIN_MCP_TOKEN} / ${GITEA_MCP_TOKEN} only —
env indirection, never literal values, so the manifest is safe to commit.

tests/secret_guard_test.sh pins 14 cases. It asserts on
hookSpecificOutput.permissionDecision, NOT exit status: the guard always exits
0 by contract, so an exit-status assertion would pass vacuously while the guard
silently allowed everything. Each deny case names the incident that produced it.

gsd-* hooks are deliberately NOT migrated — GSD owns and updates those in place.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:31:04 +02:00
mathiasandClaude Opus 5 56a947b2a0 refactor(plugin)!: ship as a Claude Code plugin, one dir link per harness
Skills move to skills/<name>/SKILL.md and the repo gains both manifests, so
it is now a marketplace hosting exactly one plugin whose root is the repo
root (source: "."). Claude Code installs it from git; no symlinks involved.

Why: install.sh wired the library into every consuming repo as an ABSOLUTE
symlink to $CHECKOUT_DIR. The checkout moved three times, most recently when
local-dev@1966d5a dropped the embedded .skills/ copy, and nothing migrated
the links — 243 dangling links across 12 repos, hidden from git by the
**/.claude/skills/ ignore rule. Worse, ~/dev spans two path roots
(/Users/mathias on the iCloud-shared Macs, /home/mathias on koala), so an
absolute link stored there is broken on one side by construction. Re-running
the installer cannot fix that; only removing the mechanism can.

install.sh keeps the harnesses with no plugin system and drops from 42 links
per harness to one directory link, via the convergent ~/.agents/skills
convention that Pi and OpenCode both read:

  ~/.agents/skills          -> $CHECKOUT_DIR/skills
  ~/.config/crush/skills    -> ~/.agents/skills
  ~/.config/opencode/skills -> ~/.agents/skills

wire_claude_repo is deleted outright rather than gated behind a flag — on a
mixed-root fleet it cannot be made correct. Antigravity and Vibe targets go
too; both unused. CHECKOUT_DIR keeps its host-local default deliberately:
pointing it into ~/dev would put a git working tree inside iCloud with two
concurrent writers.

Taskfile drops five near-identical wiring loops (they had already drifted)
and delegates to install.sh. `task check` gains frontmatter validation, a
stray-root-entry gate, and manifest/tag version parity — the loose
secret-hygiene.md sat unloadable for months because nothing checked layout.

Verified: claude plugin validate . passes clean; a probe skill loaded as
skills:zzprobe through --plugin-dir; task check green at 42 skills.

Refs: #23
Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:29:51 +02:00
100 changed files with 960 additions and 311 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "mathias",
"description": "Mathias's private plugin marketplace, hosted on gitea.d-ma.be. Self-hosted by design — nothing here transits GitHub.",
"owner": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
},
"plugins": [
{
"name": "skills",
"source": ".",
"description": "Engineering skills, the pre-task-ritual and secret-guard hooks, and the brain/gitea/infra MCP servers as one versioned unit."
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"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.5.0",
"author": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
},
"homepage": "https://git.d-ma.be/mathias/skills",
"repository": "https://git.d-ma.be/mathias/skills.git"
}
+21 -2
View File
@@ -30,8 +30,10 @@ jobs:
run: |
set -euo pipefail
last_commit=$(git log -1 --pretty=%B)
if printf '%s' "$last_commit" | grep -qi '\[skip-release\]'; then
echo "skipping — commit marked [skip-release]"
# Delegated to bin/should-skip-release so the rule is covered by
# tests/release_skip_test.sh rather than living untested in YAML.
if printf '%s\n' "$last_commit" | sh bin/should-skip-release; then
echo "skipping — commit carries the Skip-Release trailer"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -62,6 +64,23 @@ jobs:
set -euo pipefail
git config user.name "${GITEA_ACTOR}"
git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be"
# Claude Code gates plugin updates on plugin.json's version field:
# if it does not move, installed hosts never see the new tag. Bump
# it in the same commit train so the tag and the manifest agree —
# `task check` enforces that equality on every run.
manifest=.claude-plugin/plugin.json
version="${TAG#v}"
current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
if [ "$current" != "$version" ]; then
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
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"
git push origin HEAD:main
echo "bumped $manifest to $version"
fi
git tag -a "$TAG" -m "release $TAG"
# gitea-actions checkout uses an x-access-token URL; reuse it.
git push origin "$TAG"
+16
View File
@@ -0,0 +1,16 @@
{
"_comment": "Tailnet-direct NodePorts, not the public d-ma.be hostnames. The public path goes out through Cloudflare and NPM to reach a k3s service on koala, which imposes rate limits and message-size caps on tool results and adds a full internet round trip - for a service on the same machine most sessions run on. Requires koala to allow these ports on the Tailscale interface; see mathias/skills#24.",
"mcpServers": {
"brain": {
"type": "http",
"url": "http://koala:30330/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
},
"infra": {
"type": "http",
"url": "http://koala:30342/mcp"
}
}
}
+53 -41
View File
@@ -1,8 +1,12 @@
# skills
Engineering skills library — markdown `SKILL.md` files that plug into any
project or AI-coding harness. Installed via `task skills:install` in
consumer projects, or directly by `install.sh` on hosts without Task.
AI-coding harness.
For **Claude Code** this repo is a plugin, installed from the marketplace it
also hosts; that path carries the hooks and MCP servers too. For **Crush, Pi
and OpenCode** it is a plain library wired by `install.sh` as one directory
symlink per harness. See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md#wiring-into-tools).
## What is a skill?
@@ -16,65 +20,73 @@ references.
See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md) for the full catalogue with
descriptions and "use when" triggers.
## Install (consumer project)
## Install
The expected pattern: each consumer project's `Taskfile.yml` has a
`skills:install` target that delegates here. From a project root:
### Claude Code — the plugin
```bash
task skills:install
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Bare-shell fallback for hosts without Task installed:
The plugin ships skills, the `secret-guard` + `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.
### Crush, Pi, OpenCode — `install.sh`
```bash
curl -fsSL https://gitea.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
```
Both forms are idempotent and re-runnable. On every run they:
Idempotent. It clones or fast-forwards the checkout, then creates **three**
symlinks — one directory link per harness, never one per skill:
1. `git clone` (or `git pull`) this repo into `~/.local/share/skills/` —
the **canonical local checkout** on every host.
2. Wire per-skill symlinks into the harnesses found on the host (see
[Wired harnesses](#wired-harnesses)).
| Link | Target |
|---|---|
| `~/.agents/skills` | `$SKILLS_CHECKOUT_DIR/skills` (Pi reads this path directly) |
| `~/.config/crush/skills` | `~/.agents/skills` |
| `~/.config/opencode/skills` | `~/.agents/skills` |
Re-run after `git pull` upstream to pick up new skills or version
changes. CI tags every change as a `vX.Y.Z` release (see
[Versioning](#versioning)).
Adding a skill wires it everywhere with no re-run.
## Wired harnesses
### Where the checkout must live
| Harness | Target path | Notes |
|---|---|---|
| **Claude Code (global)** | `~/.claude/skills/<name>` | Visible in every Claude Code session on the host. |
| **Claude Code (per-repo)** | `<repo>/.claude/skills/<name>` | Created when `install` is invoked inside a git repo (not the skills repo itself). Gitignored globally via `**/.claude/skills/`. |
| **Crush** | `~/.config/crush/skills/<name>` | Charmbracelet Crush — successor to `opencode-ai/opencode` (archived). |
| **Antigravity** | `~/.gemini/antigravity/skills/<name>` | Google Antigravity (VS Code extension). Our `SKILL.md` frontmatter (`name` + `description`) is already in the format Antigravity expects, so symlinks suffice — no manifest translation. |
`SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills`, and that default is
load-bearing: it is **host-local, outside any synced or shared directory**.
### Not wired (and why)
- **opencode** (`opencode-ai/opencode`): archive notice; succeeded by Crush.
Its only "skill"-like surface — Custom Commands at `~/.config/opencode/commands/` —
is for user-facing prompt templates, not system-level instructions.
Misaligned with how we use skills. Skipped.
- **gitea-resident agents** (cobalt-dingo, agentsquad): they consume
skills via their containing project's `.claude/skills` directory
(populated by the per-repo wirer when run from the host), or via the
brain MCP. No special target needed.
On this fleet `~/dev` is an iCloud folder shared by flamingo and iguana, and it
spans two path roots — `/Users/mathias` on the Macs, `/home/mathias` on koala.
A checkout there is a git working tree with two concurrent writers, and any
absolute symlink stored there is broken on one side by construction. That is
what produced 243 dangling links across 12 repos (#23). Do not "fix" the
default to point into `~/dev`.
### Per-host env-var overrides
Each target path can be overridden by setting the matching env var before
running `install.sh`:
| Env var | Default |
|---|---|
| `SKILLS_REPO_URL` | `https://gitea.d-ma.be/mathias/skills.git` |
| `SKILLS_REF` | `main` (set to e.g. `v0.1.0` to pin a release) |
| `SKILLS_REPO_URL` | `https://git.d-ma.be/mathias/skills.git` |
| `SKILLS_REF` | `main` (set to e.g. `v0.4.0` to pin a release) |
| `SKILLS_CHECKOUT_DIR` | `$HOME/.local/share/skills` |
| `CLAUDE_SKILLS_DIR` | `$HOME/.claude/skills` |
| `SKILLS_SKIP_FETCH` | unset (`1` wires an existing checkout without touching HEAD) |
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
| `ANTIGRAVITY_SKILLS_DIR` | `$HOME/.gemini/antigravity/skills` |
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
Antigravity and Mistral Vibe targets were removed — unused.
## Checking a host
```bash
task doctor # or bin/doctor
```
Reports dangling links, links resolving into a foreign checkout, skill-count
mismatches, stray entries, and leftover per-skill Claude symlinks. Non-zero
exit on drift. It also runs at session start via the plugin, so a drifting
host says so rather than waiting to be discovered.
## Versioning
+47 -15
View File
@@ -47,23 +47,55 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
## Wiring into tools
This repo **is** the canonical home. Wire every skill into each harness with the
**`install.sh`** script (idempotent, per-skill symlinks) — either the one-shot
`curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash`,
or, from a clone, `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh`. (The `task
install` targets are currently broken — see issue — use `install.sh`.)
This repo is the canonical home, and it is delivered two different ways.
- **Claude Code (global)** — `~/.claude/skills/<name>` → `<repo>/<name>`.
- **Claude Code (per-repo)** — `<repo>/.claude/skills/<name>` when `task install`
is run inside another git repo (excluded via `~/.config/git/ignore`).
- **Crush** — `~/.config/crush/skills/<name>`.
- **Antigravity** — `~/.gemini/antigravity/skills/<name>`.
- **Mistral Vibe** — `~/.vibe/skills/<name>.md` → `<repo>/<name>/SKILL.md`
(flat `.md` layout).
### Claude Code — install the plugin
Individual targets are also runnable: `task install:claude:global`,
`install:crush`, `install:antigravity`, `install:vibe`. `task update` pulls the
canonical checkout then re-wires.
This repo hosts a marketplace containing exactly one plugin: itself. The plugin
carries the skills **plus** the `secret-guard` and `pre-task-ritual` hooks and
the brain/gitea/infra MCP servers — none of which a symlink can deliver.
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are namespaced: `tdd` becomes `skills:tdd`. Updates arrive when
`.claude-plugin/plugin.json`'s `version` moves, which CI does on every release.
Remove any leftover `~/.claude/skills/<name>` symlinks from the pre-plugin era —
plugin skills do **not** override same-named user skills, so leftovers make
everything load twice.
### Crush, Pi, OpenCode — run `install.sh`
These have no plugin system, so they get symlinks — but **one directory link
each**, never one per skill:
```
~/.agents/skills -> $SKILLS_CHECKOUT_DIR/skills (Pi reads this directly)
~/.config/crush/skills -> ~/.agents/skills
~/.config/opencode/skills -> ~/.agents/skills
```
Adding a skill therefore wires it everywhere with no re-run, and a broken link
is one broken link instead of 42.
```
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
```
`SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills` and **must stay
outside `~/dev`**. On this fleet `~/dev` is an iCloud folder shared by both
Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala);
a checkout there means a git working tree with two concurrent writers, and any
absolute link stored there is broken on one side by construction. See #23.
### Check a host
`task doctor` (or `bin/doctor`) reports dangling links, links resolving into a
foreign checkout, count mismatches, stray entries, and leftover Claude
symlinks. It also runs automatically at session start via the plugin, so drift
announces itself instead of waiting to be found.
## Skill Relationships
+89 -154
View File
@@ -2,22 +2,19 @@ version: '3'
# Skills library Taskfile.
#
# Primary entry point for project + host installation. Consumer projects
# do NOT depend on this file directly — they ship their own
# `skills:install` target that delegates here via `install.sh`. This
# Taskfile is invoked by maintainers of the skills repo itself, plus by
# install.sh during the post-clone wire step.
# This repo is two things at once:
# 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
# the marketplace this same repo hosts at .claude-plugin/marketplace.json.
# 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
# OpenCode — wired by install.sh as ONE directory symlink per harness.
#
# The wiring loops that used to live here were five near-identical copies that
# drifted; install.sh owns wiring now and this file delegates to it.
vars:
REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
CHECKOUT_DIR: '{{.HOME}}/.local/share/skills'
CLAUDE_GLOBAL_DIR: '{{.HOME}}/.claude/skills'
CRUSH_DIR: '{{.HOME}}/.config/crush/skills'
ANTIGRAVITY_DIR: '{{.HOME}}/.gemini/antigravity/skills'
VIBE_DIR: '{{.HOME}}/.vibe/skills'
# Anything at the repo root that is NOT a skill directory. Both
# Taskfile + install.sh share this exclusion regex.
NON_SKILL_ENTRIES: '^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$'
SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
tasks:
@@ -28,134 +25,95 @@ tasks:
list:
desc: 'Print every skill in this repo'
cmds:
- 'ls -1 {{.TASKFILE_DIR}} | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$" || true'
- 'ls -1 {{.SKILLS_DIR}}'
silent: true
install:
desc: 'Wire skills into every supported harness (Claude Code, Crush, Antigravity, Mistral Vibe).'
desc: 'Wire THIS checkout into Crush / Pi / OpenCode. Claude Code uses the plugin instead.'
cmds:
- task: install:claude:global
- task: install:claude:repo
- task: install:crush
- task: install:antigravity
- task: install:vibe
install:claude:global:
desc: 'Per-skill symlinks under ~/.claude/skills/<name> — visible in every Claude Code project on this host.'
cmds:
- mkdir -p {{.CLAUDE_GLOBAL_DIR}}
- |
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
target="{{.TASKFILE_DIR}}/${skill}"
link="{{.CLAUDE_GLOBAL_DIR}}/${skill}"
if [ -L "$link" ] || [ -e "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
echo "linked $link → $target"
done
install:claude:repo:
desc: 'Per-skill symlinks under $(pwd)/.claude/skills/<name> when invoked inside a git repo.'
cmds:
- |
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
echo "not in a git repo — skipping per-repo wire"; exit 0
fi
repo=$(git rev-parse --show-toplevel)
if [ "$repo" = "{{.TASKFILE_DIR}}" ]; then
echo "running from skills repo itself — skipping per-repo wire"; exit 0
fi
target_dir="$repo/.claude/skills"
mkdir -p "$target_dir"
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
target="{{.TASKFILE_DIR}}/${skill}"
link="${target_dir}/${skill}"
if [ -L "$link" ] || [ -e "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
echo "linked $link → $target"
done
install:crush:
desc: 'Per-skill symlinks under ~/.config/crush/skills/<name> — visible in every Crush session on this host.'
cmds:
- mkdir -p {{.CRUSH_DIR}}
- |
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
target="{{.TASKFILE_DIR}}/${skill}"
link="{{.CRUSH_DIR}}/${skill}"
if [ -L "$link" ] || [ -e "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
echo "linked $link → $target"
done
install:antigravity:
desc: 'Per-skill symlinks under ~/.gemini/antigravity/skills/<name>. SKILL.md frontmatter (name + description) is already antigravity-compatible.'
cmds:
- mkdir -p {{.ANTIGRAVITY_DIR}}
- |
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
target="{{.TASKFILE_DIR}}/${skill}"
link="{{.ANTIGRAVITY_DIR}}/${skill}"
if [ -L "$link" ] || [ -e "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
echo "linked $link → $target"
done
install:vibe:
desc: 'Per-skill file symlinks under ~/.vibe/skills/<name>.md → <skill>/SKILL.md for Mistral Vibe (flat .md layout, not a directory).'
cmds:
- mkdir -p {{.VIBE_DIR}}
- |
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
target="{{.TASKFILE_DIR}}/${skill}/SKILL.md"
[ -f "$target" ] || continue
link="{{.VIBE_DIR}}/${skill}.md"
if [ -L "$link" ] || [ -e "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
echo "linked $link → $target"
done
- SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
update:
desc: 'git pull the canonical checkout then re-run install.'
desc: 'Fast-forward this checkout and re-wire.'
cmds:
- |
if [ ! -d "{{.CHECKOUT_DIR}}/.git" ]; then
echo "no canonical checkout at {{.CHECKOUT_DIR}} — run install.sh first"; exit 1
fi
git -C "{{.CHECKOUT_DIR}}" pull --ff-only
- git -C '{{.TASKFILE_DIR}}' pull --ff-only
- task: install
doctor:
desc: 'Report drift across every harness on this host. Non-zero exit means something is wrong.'
cmds:
- '{{.TASKFILE_DIR}}/bin/doctor'
validate:
desc: 'Validate the plugin + marketplace manifests with the Claude Code CLI.'
cmds:
- claude plugin validate '{{.TASKFILE_DIR}}'
test:
desc: 'Run the shell test suite (hooks + doctor).'
cmds:
- |
fail=0
for t in '{{.TASKFILE_DIR}}'/tests/*_test.sh; do
[ -f "$t" ] || continue
echo "--- $(basename "$t")"
sh "$t" || fail=1
done
exit $fail
check:
desc: 'Structure gate — layout, frontmatter, manifest/tag parity. Runs in CI.'
cmds:
- |
fail=0
# 1. No stray entries at the repo root. A loose foo.md where
# skills/foo/SKILL.md was meant loads in NO harness and errors in
# none either — it just silently does nothing. (See #23.)
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 ;;
esac
echo "ERROR: unexpected entry at repo root: $base" >&2
fail=1
done
# 2. Every skill is a directory with a SKILL.md carrying name + description.
count=0
for dir in "{{.SKILLS_DIR}}"/*; do
name=$(basename "$dir")
if [ ! -d "$dir" ]; then
echo "ERROR: skills/$name is not a directory" >&2; fail=1; continue
fi
if [ ! -f "$dir/SKILL.md" ]; then
echo "ERROR: skills/$name missing SKILL.md" >&2; fail=1; continue
fi
head -n 20 "$dir/SKILL.md" | grep -q '^name:' || { echo "ERROR: skills/$name SKILL.md has no 'name:' frontmatter" >&2; fail=1; }
head -n 20 "$dir/SKILL.md" | grep -q '^description:' || { echo "ERROR: skills/$name SKILL.md has no 'description:' frontmatter" >&2; fail=1; }
count=$((count + 1))
done
# 3. Plugin manifest version must match the latest tag, or installers
# never see the update (Claude Code gates updates on this field).
if [ -f '{{.PLUGIN_MANIFEST}}' ]; then
manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1)
tag_v=$(git -C '{{.TASKFILE_DIR}}' describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo '')
if [ -n "$tag_v" ] && [ "$manifest_v" != "$tag_v" ]; then
echo "ERROR: plugin.json version ($manifest_v) != latest tag ($tag_v)" >&2
fail=1
fi
else
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
fi
if [ $fail -ne 0 ]; then
echo "check FAILED" >&2; exit 1
fi
echo "check OK — $count skills, manifest matches tag"
release:
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this — run locally only when CI is unavailable.'
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this.'
cmds:
- |
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
@@ -172,26 +130,3 @@ tasks:
echo "tagging $next (previous $latest, bump=${bump:-patch})"
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
echo "now: git push origin $next"
check:
desc: 'Sanity check — every skill dir has a SKILL.md, no stray files at root.'
cmds:
- |
missing=0
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$"); do
dir="{{.TASKFILE_DIR}}/${skill}"
if [ ! -d "$dir" ]; then
echo "ERROR: $skill is not a directory" >&2
missing=$((missing + 1))
continue
fi
if [ ! -f "$dir/SKILL.md" ]; then
echo "ERROR: $dir missing SKILL.md" >&2
missing=$((missing + 1))
fi
done
if [ $missing -gt 0 ]; then
echo "$missing skill(s) failed structure check" >&2
exit 1
fi
echo "all skills have SKILL.md"
Executable
+185
View File
@@ -0,0 +1,185 @@
#!/bin/sh
# doctor — report skills-library drift on this host.
#
# Written because every failure this fleet actually hit was SILENT: 243
# dangling symlinks hidden from git by the **/.claude/skills/ ignore rule, a
# checkout that still resolved but was 20 skills behind, a loose .md that
# loaded in no harness, and two machines where the library was simply absent.
# None of it errored. Exit status here is the signal that was missing.
#
# doctor human report, non-zero exit on drift
# doctor --quiet print only when something is wrong (SessionStart hook)
# doctor --json machine-readable findings
#
# POSIX sh on purpose: it runs on macOS and Linux, before any toolchain is
# guaranteed present, and inside a session-start hook where latency shows.
# It does no network I/O and never shells out to git for a fetch.
#
# Test seams: $HOME drives harness paths, $DOCTOR_CHECKOUT names the checkout.
set -u
QUIET=0
JSON=0
for arg in "$@"; do
case "$arg" in
--quiet) QUIET=1 ;;
--json) JSON=1 ;;
-h|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) printf 'doctor: unknown option %s\n' "$arg" >&2; exit 2 ;;
esac
done
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SELF_CHECKOUT=$(CDPATH= cd -- "$SELF_DIR/.." && pwd)
# The canonical checkout is whatever ~/.agents/skills resolves to — NOT the
# copy doctor happens to be running from. When invoked as a plugin hook, $0
# lives in the plugin cache, which is legitimately a second copy: judging the
# harness links against it would flag every one of them as "outside".
if [ -n "${DOCTOR_CHECKOUT:-}" ]; then
CHECKOUT="$DOCTOR_CHECKOUT"
elif [ -d "$HOME/.agents/skills" ]; then
# Resolve the link FIRST, then take the parent. "cd X/.." resolves ".."
# lexically against the link path, which lands back in ~/.agents.
_resolved=$(CDPATH= cd -- "$HOME/.agents/skills" 2>/dev/null && pwd -P)
CHECKOUT=$(dirname -- "$_resolved")
else
CHECKOUT="$SELF_CHECKOUT"
fi
SKILLS_DIR="$CHECKOUT/skills"
findings=0
report=""
# add <severity> <check> <detail>
add() {
findings=$((findings + 1))
if [ "$JSON" -eq 1 ]; then
report="${report}{\"severity\":\"$1\",\"check\":\"$2\",\"detail\":\"$(printf '%s' "$3" | sed 's/"/\\"/g')\"},"
else
report="${report}$1 $2: $3
"
fi
}
# count_skills <dir> — directories containing a SKILL.md, one level down.
count_skills() {
[ -d "$1" ] || { printf '0'; return; }
find "$1"/ -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
}
# --- the checkout itself ----------------------------------------------------
if [ ! -d "$SKILLS_DIR" ]; then
add FAIL checkout "no skills/ directory at $CHECKOUT"
# Nothing downstream can be judged without it.
printf 'FAIL checkout: no skills/ directory at %s\n' "$CHECKOUT" >&2
exit 1
fi
EXPECTED=$(count_skills "$SKILLS_DIR")
[ "$EXPECTED" -gt 0 ] || add FAIL checkout "skills/ holds no SKILL.md at all"
# --- stray entries in the checkout -----------------------------------------
# A loose foo.md where foo/SKILL.md was meant loads in NO harness and errors
# in none either. That is exactly how secret-hygiene.md sat dead for months.
for entry in "$SKILLS_DIR"/*; do
[ -e "$entry" ] || continue
name=$(basename "$entry")
if [ ! -d "$entry" ]; then
add FAIL stray "$SKILLS_DIR/$name is not a directory — it loads nowhere"
elif [ ! -f "$entry/SKILL.md" ]; then
add FAIL stray "$SKILLS_DIR/$name has no SKILL.md"
fi
done
# --- harness wiring ---------------------------------------------------------
# Canonical location first; Pi reads it directly and the others link to it.
for dir in \
"$HOME/.agents/skills" \
"$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills"
do
[ -L "$dir" ] || [ -d "$dir" ] || continue
if [ -L "$dir" ] && [ ! -e "$dir" ]; then
add FAIL dangling "$dir -> $(readlink "$dir") (target does not exist)"
continue
fi
# Resolve to a physical path and confirm it lands inside THIS checkout.
# A link that resolves to a different checkout is the dangerous case: it
# works, so nothing complains, while serving stale content.
resolved=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)
canonical=$(CDPATH= cd -- "$SKILLS_DIR" 2>/dev/null && pwd -P)
if [ -n "$resolved" ] && [ "$resolved" != "$canonical" ]; then
add FAIL outside "$dir resolves to $resolved, outside the active checkout $canonical"
continue
fi
got=$(count_skills "$dir")
if [ "$got" != "$EXPECTED" ]; then
add FAIL count "$dir sees $got skills, checkout has $EXPECTED"
fi
done
# --- dangling links anywhere under the harness dirs -------------------------
for dir in "$HOME/.agents/skills" "$HOME/.config/crush/skills" \
"$HOME/.config/opencode/skills" "$HOME/.claude/skills"
do
[ -d "$dir" ] || continue
# Plain glob, not find|while: a while loop on the right of a pipe runs in a
# subshell, so add() would increment a counter that dies with it and doctor
# would exit 0 having found faults.
for link in "$dir"/*; do
[ -L "$link" ] || continue
[ -e "$link" ] && continue
add FAIL dangling "$link -> $(readlink "$link") (target does not exist)"
done
done
# --- Claude Code: plugin vs leftover per-skill symlinks ---------------------
# Plugin skills do NOT override same-named user skills — both stay visible. So
# leftover links from the pre-plugin era mean every skill appears twice.
CLAUDE_SKILLS="$HOME/.claude/skills"
if [ -d "$CLAUDE_SKILLS" ]; then
legacy=$(find "$CLAUDE_SKILLS"/ -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
if [ "$legacy" -gt 0 ]; then
add FAIL legacy "$CLAUDE_SKILLS holds $legacy per-skill symlink(s) from the pre-plugin wiring — remove them or every skill loads twice"
fi
fi
# Two delivery mechanisms means two copies that can drift apart: the plugin
# (Claude Code) and the checkout the other harnesses link to. Same host,
# different skill sets, no error — compare them.
# Skipped when DOCTOR_CHECKOUT is set: that scopes doctor to an explicit
# target, so comparing it against whatever copy the script lives in is
# meaningless rather than informative.
if [ -z "${DOCTOR_CHECKOUT:-}" ] && [ -f "$SELF_DIR/../.claude-plugin/plugin.json" ] \
&& [ "$SELF_CHECKOUT" != "$CHECKOUT" ]; then
plugin_count=$(count_skills "$SELF_CHECKOUT/skills")
if [ "$plugin_count" != "$EXPECTED" ]; then
add FAIL drift "Claude Code plugin serves $plugin_count skills, the other harnesses see $EXPECTED — the two copies have diverged"
fi
fi
INSTALLED="$HOME/.claude/plugins/installed_plugins.json"
if [ -f "$INSTALLED" ]; then
grep -q '"skills@mathias"' "$INSTALLED" 2>/dev/null \
|| add WARN plugin "skills@mathias is not installed for Claude Code — /plugin marketplace add https://git.d-ma.be/mathias/skills.git"
fi
# --- output -----------------------------------------------------------------
if [ "$JSON" -eq 1 ]; then
printf '{"checkout":"%s","expected":%s,"findings":[%s]}\n' \
"$CHECKOUT" "$EXPECTED" "${report%,}"
elif [ "$findings" -gt 0 ]; then
printf 'skills doctor — %s finding(s), checkout %s\n\n%s\n' \
"$findings" "$CHECKOUT" "$report"
elif [ "$QUIET" -eq 0 ]; then
printf 'skills doctor: clean — %s skills, checkout %s\n' "$EXPECTED" "$CHECKOUT"
fi
[ "$findings" -eq 0 ]
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# should-skip-release — reads a commit message on stdin. Exits 0 when the
# release tagger should skip this commit, 1 when it should tag.
#
# Skipping is opt-in via a git trailer:
#
# Skip-Release: true
#
# It is a TRAILER, matched anchored to its own line, deliberately. The first
# version searched the whole message for a bracketed marker as a substring,
# and the very commit that introduced the manifest-bump step described that
# marker in its body — so the tagger read its own documentation as an
# instruction and skipped the release. Success, no tag, no bump, no error
# (run 15, 2026-08-23). A marker you cannot mention without triggering is a
# footgun, not a mechanism.
set -u
msg=$(cat)
if printf '%s\n' "$msg" | grep -qiE '^[[:space:]]*Skip-Release:[[:space:]]*true[[:space:]]*$'; then
exit 0
fi
exit 1
+39
View File
@@ -0,0 +1,39 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/doctor\" --quiet",
"timeout": 5,
"statusMessage": "Checking skills wiring..."
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task-ritual.sh\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard.sh\"",
"timeout": 5
}
]
}
]
}
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Surfaces rule 0 on every prompt so the pre-task ritual is visible in context.
echo "Rule 0 (non-negotiable): brain_query → load skill → failing test → state success criterion"
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# PreToolUse hook (Bash matcher) — hard-blocks command patterns that reveal
# secret values into the transcript, rather than relying on remembering an
# existence-only check every time.
#
# Triggered by two real leaks in one session (2026-07-30):
# 1. `op item get <name> --format json` — JSON mode has NO masking, unlike
# the human-readable table view (`op item get <name>` alone masks
# concealed fields with bullets by default). Printed a live Gitea MCP
# token into the transcript.
# 2. `env | grep -i gitea` (infra#228, 2026-07-27) — same shape: an
# inspection command that dumps values instead of checking existence.
#
# Policy: block ALL direct reads of secret material via these command
# shapes, unconditionally, regardless of flags. Existence/rotation must go
# through a wrapper script (e.g. infra's scripts/op-safe.sh, scripts/rotate-
# *.sh) that strips values before anything reaches stdout — those scripts
# call the same underlying commands internally, but as a subprocess of the
# script, never as a literal Bash tool call the model composes and can typo.
#
# Reads PreToolUse hook JSON on stdin, decides allow/deny for the Bash tool.
set -euo pipefail
INPUT="$(cat)"
TOOL_NAME="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')"
[ "$TOOL_NAME" = "Bash" ] || { printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'; exit 0; }
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')"
deny() {
local reason="$1"
jq -n --arg reason "$reason" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}, systemMessage: $reason}'
exit 0
}
# 1Password CLI raw reads — block unconditionally, any flags.
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+get\b'; then
deny "Blocked: 'op item get' prints secret values to stdout (JSON mode has no masking at all; even table mode can leak via --fields/--reveal). Use a wrapper that strips .fields[].value before output (e.g. scripts/op-safe.sh exists|meta), or op item edit to WRITE without ever printing the value."
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+list\b'; then
deny "Blocked: 'op item list' can include concealed field previews. Use a wrapper that filters to titles only (e.g. scripts/op-safe.sh list)."
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+read\b'; then
deny "Blocked: 'op read' prints the raw secret value directly. Consume it inside a subprocess only (op run -- <cmd>, or a script that never echoes/captures it to stdout)."
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+document\s+get\b'; then
deny "Blocked: 'op document get' prints document contents (may contain secrets) to stdout. Use a wrapper script instead."
fi
# Raw env/secret-file dumps (infra#228/#229 root cause).
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)env(\s|$).*\|.*grep'; then
deny "Blocked: 'env | grep' prints matching values in full. Use an existence-only check: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eq '(^|[;&|])[[:space:]]*env[[:space:]]*($|[;&|])'; then
deny "Blocked: bare 'env' prints every variable's value, including secrets. Use an existence-only check: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then
deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*\.env'; then
deny "Blocked: cat of a .env file prints secret values. Use: grep -c '=' file.env (count only) or [ -s file.env ] && echo present"
fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*(\.llmkeys|\.op-env|\.op-agent-write\.env)\b'; then
deny "Blocked: cat of a known secrets file prints raw values. Use: grep -o '^[A-Z_]*=' file (key names only, never the value)."
fi
# kubectl secrets — -o yaml/json prints base64 (trivially reversible).
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)kubectl\b[^;&|]*\bget\s+secrets?\b[^;&|]*(-o|--output)[[:space:]=]*(yaml|json)'; then
deny "Blocked: 'kubectl get secret -o yaml/json' prints base64-encoded values (trivially reversible, same as plaintext). Use 'kubectl describe secret <name>' (shows keys + byte lengths only, never values) for existence checks."
fi
# Gitea app.ini — mixes plain config ([queue]/[indexer]/[repository]) with
# live secrets ([security].INTERNAL_TOKEN/SECRET_KEY, [oauth2].JWT_SECRET,
# [server].LFS_JWT_SECRET) in the SAME file, auto-generated on install and
# not git/SOPS-tracked. A context-range grep (-A/-B/-C) doesn't respect
# section boundaries and silently pulls a secret line into stdout even when
# targeting an unrelated section. Root cause of a real leak, infra sprint
# 2026-08-04 (`kubectl exec ... cat app.ini | grep -A15 "^\[queue\]"` pulled
# through into [oauth2].JWT_SECRET).
# Tight on purpose: requires "cat" immediately followed by a bare path-shaped
# token ending in app.ini (no intervening whitespace) so this only fires on
# an actual invocation ('cat /data/gitea/conf/app.ini', 'cat app.ini') and
# never on prose that merely mentions both words, e.g. a commit message
# describing this exact guard (that false-positived once while writing the
# fix — commands are matched as raw strings, heredoc bodies included).
if printf '%s' "$CMD" | grep -Eq '\bcat[[:space:]]+[A-Za-z0-9_./-]*app\.ini\b'; then
deny "Blocked: cat of Gitea's app.ini prints secrets mixed into the same file ([security]/[oauth2]/[server].LFS_JWT_SECRET). Use exact-key checks: grep -c '^KEY = ' app.ini (existence only) — never cat the whole file."
fi
if printf '%s' "$CMD" | grep -Eq '\bgrep\b[^;&|]*-[A-Za-z]*[ABC][A-Za-z]*[[:space:]]*[0-9][^;&|]*app\.ini\b'; then
deny "Blocked: grep -A/-B/-C context flags against app.ini can bleed past section boundaries into [security]/[oauth2]/[server] secrets, even when targeting an unrelated section (this is exactly how the 2026-08-04 leak happened). Use an exact anchor instead: grep '^KEY = ' app.ini, or bound to one section with awk '/^\\[section\\]/,/^\\[/' and confirm the section has no secret keys first."
fi
# sops decrypt output inspected directly — infra sweep-sprint incident,
# 2026-08-05: `sops -d ntfy-tokens.enc.yaml > /tmp/plain.yaml` (safe,
# redirected to a file) immediately followed by `cat /tmp/plain.yaml | head`
# in the SAME command block printed a live ntfy token into the transcript.
# Re-encrypting a secret (e.g. switching a file to data-only encryption per
# infra#177's pattern) requires decrypting it locally at some point — that
# redirect-to-file step alone is fine, the leak is any print command touching
# the plaintext afterward. Block the combination unconditionally rather than
# trying to track which file a decrypt wrote to across separate tool calls.
if printf '%s' "$CMD" | grep -Eq '\bsops\b[^;&|]*(-d\b|--decrypt\b|[[:space:]]decrypt\b)' \
&& printf '%s' "$CMD" | grep -Eq '\b(cat|head|tail|less|more|bat|od|xxd)\b'; then
deny "Blocked: this command decrypts a sops-encrypted file AND runs a print command (cat/head/tail/etc) in the same invocation — that's exactly the 2026-08-05 ntfy-token leak shape. Decrypt to a temp file (sops -d file > /tmp/x, fine on its own), do whatever transform is needed WITHOUT printing it (e.g. sops -e --encrypted-regex '^(data|stringData)\$' /tmp/x > newfile), then shred -u the temp file. Never cat/head/tail a sops-decrypted file, even to 'just check a few lines'."
fi
# base64 transform of a secret-shaped env var — the documented 2026-06-11
# 'defeats op run masking' bypass.
if printf '%s' "$CMD" | grep -Eqi '\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)[A-Za-z0-9_]*\}?[^;&|]*\|[^;&|]*base64'; then
deny "Blocked: piping a secret-shaped variable through base64 to inspect it. base64 is an encoding, not masking — op run's own masking only recognizes the raw value, so this is a known bypass (2026-06-11 incident). Never transform a secret to look at it."
fi
if printf '%s' "$CMD" | grep -Eqi 'base64[^;&|]*<<<\s*"?\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)'; then
deny "Blocked: base64 <<< of a secret-shaped variable. Same bypass as above — never transform a secret to inspect it."
fi
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}'
+67 -99
View File
@@ -5,25 +5,55 @@
# curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
#
# Pinned to a specific tag:
# SKILLS_REF=v0.1.0 bash install.sh
# SKILLS_REF=v0.4.0 bash install.sh
#
# Idempotent: re-running pulls the latest changes for the configured ref
# (default: main) and re-wires symlinks. Existing correct links are
# left alone; mismatched ones are replaced.
# (default: main) and re-wires the links.
#
# Claude Code is NOT wired here. It installs this repo as a plugin from the
# marketplace this repo also hosts:
#
# /plugin marketplace add https://git.d-ma.be/mathias/skills.git
# /plugin install skills@mathias
#
# That path carries the hooks and MCP server definitions too, which symlinks
# cannot. This script covers the harnesses with no plugin system: Crush, Pi
# and OpenCode.
#
# Wiring model — one directory link per harness, never one per skill:
#
# ~/.agents/skills -> $CHECKOUT_DIR/skills (canonical; Pi reads it directly)
# ~/.config/crush/skills -> ~/.agents/skills
# ~/.config/opencode/skills -> ~/.agents/skills
#
# Adding a skill to the repo therefore wires it everywhere with no re-run,
# and a broken link is one broken link rather than 42.
#
# CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or
# cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a
# fleet whose workspace is shared between macOS (/Users/...) and Linux
# (/home/...), an absolute link stored there is broken on one side by
# construction. See mathias/skills#23.
set -euo pipefail
REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}"
REF="${SKILLS_REF:-main}"
CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
CLAUDE_GLOBAL_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
ANTIGRAVITY_DIR="${ANTIGRAVITY_SKILLS_DIR:-$HOME/.gemini/antigravity/skills}"
VIBE_SKILLS_DIR="${VIBE_SKILLS_DIR:-$HOME/.vibe/skills}"
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
log() { printf '[skills] %s\n' "$*"; }
ensure_checkout() {
# Wiring an existing working checkout (task install) must not fetch or
# move HEAD — that would discard in-progress work.
if [ "${SKILLS_SKIP_FETCH:-}" = "1" ]; then
[ -d "$CHECKOUT_DIR/skills" ] || { log "no skills/ under $CHECKOUT_DIR"; exit 1; }
log "using $CHECKOUT_DIR as-is (SKILLS_SKIP_FETCH=1)"
return
fi
if [ -d "$CHECKOUT_DIR/.git" ]; then
log "updating $CHECKOUT_DIR"
git -C "$CHECKOUT_DIR" fetch --tags --quiet
@@ -40,109 +70,47 @@ ensure_checkout() {
fi
}
list_skills() {
# Every top-level entry that isn't a known meta file.
(cd "$CHECKOUT_DIR" && ls -1) | grep -Ev '^(Taskfile\.yml|install\.sh|README\.md|SKILLS_INDEX\.md|\.gitea|\.git)$' || true
count_skills() {
find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
}
link_skill() {
# link_skill <target_dir> <skill_name>
local target_dir="$1"
local skill="$2"
local target="$CHECKOUT_DIR/$skill"
local link="$target_dir/$skill"
if [ -L "$link" ] || [ -e "$link" ]; then
local current
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
return
# link_dir <link_path> <target_path>
# Replaces whatever is there — including a directory left behind by the old
# per-skill wiring — with a single symlink.
link_dir() {
local link="$1" target="$2"
if [ -L "$link" ]; then
[ "$(readlink "$link")" = "$target" ] && return
rm -f "$link"
elif [ -d "$link" ]; then
# Refuse to discard a directory holding anything but symlinks: that
# would be someone's real skills, not our old wiring.
if find "$link" -mindepth 1 -maxdepth 1 ! -type l | grep -q .; then
log "REFUSING to replace $link — it holds non-symlink entries; move them and re-run"
return 1
fi
rm -rf "$link"
find "$link" -mindepth 1 -maxdepth 1 -type l -delete
rmdir "$link"
elif [ -e "$link" ]; then
rm -f "$link"
fi
mkdir -p "$(dirname "$link")"
ln -s "$target" "$link"
log "linked $link → $target"
}
wire_claude_global() {
mkdir -p "$CLAUDE_GLOBAL_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$CLAUDE_GLOBAL_DIR" "$skill"
done < <(list_skills)
}
wire_crush() {
# Crush reads skills from ~/.config/crush/skills/<name>/SKILL.md.
# Pre-creating the dir is cheap even when Crush isn't installed.
mkdir -p "$CRUSH_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$CRUSH_DIR" "$skill"
done < <(list_skills)
}
wire_antigravity() {
# Antigravity (Google's VS Code extension) reads global skills from
# ~/.gemini/antigravity/skills/<name>/SKILL.md. Our SKILL.md files
# already carry the required `name` + `description` YAML frontmatter,
# so symlinks are sufficient — no manifest translation step.
mkdir -p "$ANTIGRAVITY_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$ANTIGRAVITY_DIR" "$skill"
done < <(list_skills)
}
wire_claude_repo() {
# Only wire per-repo when invoked from inside a git repo and it isn't
# the skills repo itself.
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
return
fi
local repo
repo=$(git rev-parse --show-toplevel)
if [ "$repo" = "$CHECKOUT_DIR" ]; then
return
fi
local target_dir="$repo/.claude/skills"
mkdir -p "$target_dir"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
link_skill "$target_dir" "$skill"
done < <(list_skills)
}
wire_vibe() {
# Mistral Vibe reads a flat ~/.vibe/skills/<name>.md — link the SKILL.md
# file directly (not the skill directory, as the other harnesses do).
mkdir -p "$VIBE_SKILLS_DIR"
while IFS= read -r skill; do
[ -n "$skill" ] || continue
local target="$CHECKOUT_DIR/$skill/SKILL.md"
[ -f "$target" ] || continue
local link="$VIBE_SKILLS_DIR/$skill.md"
if [ -L "$link" ] || [ -e "$link" ]; then
local current
current=$(readlink "$link" 2>/dev/null || true)
if [ "$current" = "$target" ]; then
continue
fi
rm -rf "$link"
fi
ln -s "$target" "$link"
log "linked $link → $target"
done < <(list_skills)
}
main() {
ensure_checkout
wire_claude_global
wire_claude_repo
wire_crush
wire_antigravity
wire_vibe
log "done — $(list_skills | wc -l | tr -d ' ') skill(s) wired at ref=$REF"
# Canonical location. Pi reads ~/.agents/skills directly, so it needs no
# harness-specific link of its own.
link_dir "$AGENTS_DIR" "$CHECKOUT_DIR/skills"
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
log "done — $(count_skills) skill(s) at ref=$REF, 3 links total"
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
}
main "$@"
+124
View File
@@ -0,0 +1,124 @@
#!/bin/sh
# Tests for bin/doctor.
#
# Every case here is a real state this fleet was found in on 2026-08-23, and
# every one of them was SILENT — nothing errored, git status was clean, the
# harness simply ran with the wrong skills or none at all. The point of doctor
# is that none of these can be silent again, so each fixture asserts both a
# non-zero exit AND that the report names the fault.
#
# Fixtures drive doctor through $HOME and $DOCTOR_CHECKOUT, so no test touches
# the real host.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
DOCTOR="$ROOT/bin/doctor"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/doctor_test.$$
if [ ! -f "$DOCTOR" ]; then
printf 'FAIL: %s does not exist\n' "$DOCTOR"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
# new_fixture <name> — builds $WORK/<name>/{ck,home} with a 2-skill checkout
# and an empty fake HOME. Echoes the fixture root.
new_fixture() {
_f="$WORK/$1"
mkdir -p "$_f/ck/skills/alpha" "$_f/ck/skills/beta" "$_f/home"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$_f/ck/skills/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$_f/ck/skills/beta/SKILL.md"
printf '%s' "$_f"
}
# wire_clean <fixture> — the correct wiring: one canonical link, harnesses
# pointing at it.
wire_clean() {
mkdir -p "$1/home/.agents" "$1/home/.config/crush" "$1/home/.config/opencode"
ln -sfn "$1/ck/skills" "$1/home/.agents/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/crush/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/opencode/skills"
}
# assert_fails <fixture> <substring> <label>
# Note: doctor must be invoked directly here, not via a helper called in a
# command substitution — $? from inside $( ) belongs to the subshell and the
# exit status, which is the whole point of these assertions, would be lost.
assert_fails() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" sh "$DOCTOR" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
fail=$((fail + 1))
printf ' FAIL %s\n doctor exited 0; drift went undetected\n' "$3"
elif ! printf '%s' "$_out" | grep -qi -- "$2"; then
fail=$((fail + 1))
printf ' FAIL %s\n exited %s but report never mentions "%s"\n got: %s\n' "$3" "$rc" "$2" "$_out"
else
pass=$((pass + 1))
printf ' ok %s\n' "$3"
fi
}
assert_clean() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" sh "$DOCTOR" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok %s\n' "$2"
else
fail=$((fail + 1)); printf ' FAIL %s\n exited %s on a healthy fixture\n got: %s\n' "$2" "$rc" "$_out"
fi
}
# --- 1. healthy host stays silent -------------------------------------------
f=$(new_fixture clean); wire_clean "$f"
assert_clean "$f" 'clean fixture exits 0'
# --- 2. dangling link (the 243-link case, gitignored and invisible) ----------
f=$(new_fixture dangling); wire_clean "$f"
ln -sfn "$f/ck/skills-that-moved" "$f/home/.agents/skills"
assert_fails "$f" 'dangling' 'dangling canonical link is reported'
# --- 3. resolves, but to a DIFFERENT checkout (the silent-stale case) --------
# Worse than dangling: it works, so nothing complains, while serving content
# from a checkout that is months behind.
f=$(new_fixture stale)
mkdir -p "$f/other/skills/alpha" "$f/other/skills/beta"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/other/skills/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$f/other/skills/beta/SKILL.md"
wire_clean "$f"
ln -sfn "$f/other/skills" "$f/home/.agents/skills"
assert_fails "$f" 'outside' 'link into a foreign checkout is reported'
# --- 4. count mismatch (a harness seeing fewer skills than the checkout) -----
f=$(new_fixture count)
mkdir -p "$f/home/.config/crush/skills/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md"
mkdir -p "$f/home/.agents"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
assert_fails "$f" 'count' 'skill-count mismatch is reported'
# --- 5. stray entry (the loose secret-hygiene.md that loaded nowhere) --------
f=$(new_fixture stray); wire_clean "$f"
printf 'orphan\n' > "$f/ck/skills/loose-note.md"
assert_fails "$f" 'stray' 'stray non-directory entry is reported'
# --- 6. legacy Claude symlinks alongside the plugin (duplicate skills) -------
f=$(new_fixture dupe); wire_clean "$f"
mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/alpha" "$f/home/.claude/skills/alpha"
assert_fails "$f" 'legacy' 'leftover per-skill Claude symlinks are reported'
# --- 7. dangling link inside a harness dir (not the canonical link itself) --
# Exercises the per-entry scan rather than the canonical-link check: this is
# the shape the 243 dead links actually had.
f=$(new_fixture ghost); wire_clean "$f"
mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/vanished" "$f/home/.claude/skills/vanished"
assert_fails "$f" 'dangling' 'dangling link inside a harness dir is reported'
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
# Tests for bin/should-skip-release.
#
# The regression these exist for: an unanchored substring match let a commit
# that merely *described* the skip marker skip its own release.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/bin/should-skip-release"
pass=0
fail=0
if [ ! -f "$SUT" ]; then
printf 'FAIL: %s does not exist\n' "$SUT"
exit 1
fi
# expect <skip|tag> <label> <<message on stdin>>
expect() {
_want=$1
_label=$2
_msg=$3
printf '%s\n' "$_msg" | sh "$SUT"
_rc=$?
if [ "$_want" = skip ]; then _wrc=0; else _wrc=1; fi
if [ "$_rc" -eq "$_wrc" ]; then
pass=$((pass + 1)); printf ' ok %s\n' "$_label"
else
fail=$((fail + 1)); printf ' FAIL %s (want %s, rc=%s)\n' "$_label" "$_want" "$_rc"
fi
}
expect skip 'trailer on its own line' 'chore(release): bump
Skip-Release: true'
expect skip 'trailer, case-insensitive' 'chore: x
skip-release: TRUE'
expect tag 'ordinary commit' 'feat: add a thing
Bump-Type: minor'
# --- the actual regression -------------------------------------------------
expect tag 'prose MENTIONING the marker must still tag' 'docs+ci: document the release flow
CI commits the manifest bump with a Skip-Release: true trailer so it does
not re-trigger the tagger. The old [skip-release] marker was a substring
match, which is exactly why this commit would have skipped itself.
Bump-Type: patch'
expect tag 'legacy bracketed marker no longer skips' 'chore: something [skip-release]'
expect tag 'empty message' ''
printf '\nrelease_skip: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
# Tests for hooks/secret-guard.sh — the PreToolUse gate that blocks command
# shapes which render secret values into the transcript.
#
# Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers
# in the JSON body as hookSpecificOutput.permissionDecision = allow | deny.
# Asserting on exit status would pass vacuously — assert on the decision.
#
# Every deny case below is a shape that caused a real leak; the comment names
# the incident so nobody "simplifies" the pattern away later.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
GUARD="$ROOT/hooks/secret-guard.sh"
pass=0
fail=0
# decision_for <command-string> [tool_name]
# Emits the guard's permissionDecision for a synthetic Bash tool call.
decision_for() {
_cmd=$1
_tool=${2:-Bash}
jq -n --arg t "$_tool" --arg c "$_cmd" \
'{tool_name: $t, tool_input: {command: $c}}' \
| sh "$GUARD" \
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
}
expect() {
_want=$1
_cmd=$2
_label=$3
_got=$(decision_for "$_cmd")
if [ "$_got" = "$_want" ]; then
pass=$((pass + 1))
printf ' ok %s\n' "$_label"
else
fail=$((fail + 1))
printf ' FAIL %s\n want=%s got=%s\n cmd=%s\n' "$_label" "$_want" "$_got" "$_cmd"
fi
}
if [ ! -f "$GUARD" ]; then
printf 'FAIL: %s does not exist\n' "$GUARD"
exit 1
fi
command -v jq >/dev/null 2>&1 || { printf 'SKIP: jq not installed\n'; exit 0; }
# --- deny: decrypt-and-print in one invocation (2026-08-05 ntfy-token leak) ---
expect deny 'sops -d secrets.enc.yaml | tail -20' 'sops decrypt piped to tail'
expect deny 'sops -d ingress.enc.yaml > /tmp/x; cat /tmp/x' 'sops decrypt then cat'
# --- deny: 1Password raw reads (2026-07-30) ---
expect deny 'op item get "gitea mcp" --format json' 'op item get'
expect deny 'op read "op://vault/item/field"' 'op read'
# --- deny: env dumps (infra#228, 2026-07-27) ---
expect deny 'env | grep -i gitea' 'env piped to grep'
expect deny 'env' 'bare env'
expect deny 'printenv' 'printenv'
# --- deny: kubernetes secret material ---
expect deny 'kubectl get secret brain-app -o yaml' 'kubectl get secret -o yaml'
# --- deny: base64 as a masking bypass (2026-06-11) ---
expect deny 'echo "$GITEA_MCP_TOKEN" | base64' 'token piped through base64'
# --- allow: ordinary commands must not be blocked ---
expect allow 'ls -la /tmp' 'plain ls'
expect allow 'git status --porcelain' 'git status'
expect allow 'kubectl describe secret brain-app' 'kubectl describe secret (values redacted by k8s)'
expect allow '[ -n "$GITEA_MCP_TOKEN" ] && echo set' 'existence-only check'
# --- allow: the guard only judges Bash ---
_got=$(decision_for 'env' 'Read')
if [ "$_got" = "allow" ]; then
pass=$((pass + 1)); printf ' ok non-Bash tool passes through\n'
else
fail=$((fail + 1)); printf ' FAIL non-Bash tool passes through\n want=allow got=%s\n' "$_got"
fi
printf '\nsecret_guard: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]