diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9f2e4..b375c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to **@askalf/truecopy** are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.9.0] - 2026-07-17 + +### Added +- **`truecopy check-manifest `** — compare every installed marketplace + plugin skill against a watch manifest (name → skill hash). An installed skill + whose bytes differ from what the watch scanned is `drifted`, a watch-flagged + skill fails even byte-identical, and skills the manifest doesn't know are + `unlisted` (reported, never fatal). Exit 1 on any failure; takes `--json`. + Offline: you fetch the manifest, truecopy only reads it. +- **The weekly marketplace watch publishes `directory-manifest.json`** on the + `watch` branch — name → hash for every scanned skill in the official Claude + Code plugin directory, plus the currently-flagged names. This is the manifest + `check-manifest` consumes: our standing vetting of the directory, as a + drop-in check for any machine that installs from it. +- **Per-file acceptance granularity for the watch** (#68): accept entries can + key a reviewed-benign finding to the finding-bearing files instead of the + whole-skill hash, so upstream docs churn no longer lapses a review. Skill + dirs now expose `scanPieces` (per-file scan text) to support subset re-scans; + scan targets, verdicts, and hashes are unchanged. + ## [0.8.0] - 2026-07-11 ### Changed diff --git a/README.md b/README.md index c650920..30bfa97 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ A `--force` pin is an explicit accept: you read those bytes, truecopy records `v And the audit didn't end with the study: a **standing watch** re-scans the full official plugin directory every week — every catalog plugin, including the external vendor plugins fetched at their catalog-pinned SHAs — and publishes the snapshot — plugin and skill counts, verdicts, advisories, pin drift — to [`WATCH.md` on the `watch` branch](https://github.com/askalf/truecopy/blob/watch/WATCH.md) (that's the badge at the top of this page). A poisoned skill would turn the badge red and the scheduled run with it. +**The watch is consumable, not just a badge.** Each run also publishes [`directory-manifest.json`](https://github.com/askalf/truecopy/blob/watch/directory-manifest.json) — name → content hash for every skill it scanned, plus the currently-flagged names. Point `check-manifest` at it and every marketplace plugin skill **installed on your machine** is compared against exactly the bytes the watch vetted: + +```bash +curl -fsSLo directory-manifest.json https://raw.githubusercontent.com/askalf/truecopy/watch/directory-manifest.json +truecopy check-manifest directory-manifest.json +``` + +An installed skill whose bytes differ from what the watch scanned is `drifted`, a watch-flagged skill fails even byte-identical (a hash match is not an endorsement), and skills from other marketplaces — or your own — are `unlisted`, reported but never fatal. Exit 1 on any failure, `--json` for machines, and offline like everything else: you fetch the manifest, truecopy only reads it. + Every row above is verified **live**, not just unit-tested: each scenario ran in its own fresh headless Claude Code session against a real pinned skill. A skill silently edited after pinning physically cannot run — the invocation fails and the model is told why ("drifted from its pinned version") — and restoring the exact pinned bytes immediately un-blocks it. The check costs roughly a quarter-second per skill invocation. **Per-repo lockdown:** hook settings merge from the project too, so `truecopy hook install --strict` in a repo (committed next to `truecopy.lock`) makes *that repo* whitelist-strict for everyone who works in it, while machines keep the adoption-friendly default globally. And the same committed `truecopy.lock` gates CI (`truecopy verify`), `truecopy-mcp`, and every teammate's sessions. diff --git a/package-lock.json b/package-lock.json index ca59bcc..a2c1828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askalf/truecopy", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askalf/truecopy", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@askalf/redstamp": "github:askalf/redstamp#3da60b9d720cb3f152f3db906673be0a2f8116b7" diff --git a/package.json b/package.json index b541b17..de1d59a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askalf/truecopy", - "version": "0.8.0", + "version": "0.9.0", "description": "own your agent skills — vet, sign, and pin every skill & MCP server before it runs. The supply-chain gate for AI agents. Part of Own Your Stack.", "type": "module", "bin": { diff --git a/src/cli.mjs b/src/cli.mjs index fa562d0..8d91cce 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -86,6 +86,9 @@ function usage() { truecopy diff [--name ] show what changed since it was pinned truecopy list show the pinned set truecopy remove un-pin a skill — drop its ${lockPath} entry (alias: unpin) + truecopy check-manifest compare every INSTALLED marketplace plugin skill against a + watch manifest (directory-manifest.json on the watch branch): + drifted-from-watched or watch-flagged fails; takes --json truecopy guard [--lock ] [--require-signed] -- verify the lock, then run only if it's clean …scan / verify / list / diff take --json: machine-readable stdout, same exit codes @@ -243,6 +246,61 @@ function runRemove() { return 0; } +// A watch manifest maps `plugin:skill` names to skill hashes — the weekly +// marketplace watch publishes one for the official plugin directory +// (directory-manifest.json on the watch branch). check-manifest compares every +// INSTALLED marketplace plugin skill on this machine against it: same name + +// same bytes as the watch scanned → `match`; same name, different bytes → +// `drifted` (exit 1 — what's on disk is not what was scanned); a name the +// manifest flagged as poisoned → `flagged` (exit 1 even byte-identical: match +// is not endorsement); a name the manifest doesn't know → `unlisted` (reported, +// never fatal — your own plugins and other marketplaces are normal). Manifest +// skills that aren't installed here are ignored: the check is about what CAN +// run on this machine. Offline like everything else — you fetch the manifest, +// truecopy only reads it. +function runCheckManifest() { + const file = sources[0]; + if (!file) { out('usage: truecopy check-manifest (directory-manifest.json from the watch branch)'); return 2; } + let manifest; + try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (e) { out(`${c(C.red, '✗')} unreadable manifest ${file}: ${e.message}`); return 2; } + const skills = manifest && typeof manifest.skills === 'object' && manifest.skills !== null && !Array.isArray(manifest.skills) ? manifest.skills : null; + if (!skills) { out(`${c(C.red, '✗')} ${file}: not a watch manifest (no "skills" name→hash map)`); return 2; } + const flaggedSet = new Set(Array.isArray(manifest.flagged) ? manifest.flagged.filter((n) => typeof n === 'string') : []); + const installed = discoverClaudePluginSkills(); + let bad = 0; + const results = []; + for (const { name, dir, marketplace } of installed) { + let row; + try { + const hash = skillHash(loadSkill(dir)); + // Object.hasOwn, not `in` / direct read: a hostile manifest can carry + // `__proto__`/`toString` keys, and a skill named after a prototype member + // must not read the inherited value as its "expected hash". + const expected = Object.hasOwn(skills, name) && typeof skills[name] === 'string' ? skills[name] : null; + const status = flaggedSet.has(name) ? 'flagged' : expected === null ? 'unlisted' : expected === hash ? 'match' : 'drifted'; + row = { name, marketplace, status, hash, ...(expected && expected !== hash ? { expected } : {}) }; + } catch (e) { + row = { name, marketplace, status: 'error', error: e.message }; // unreadable installed skill fails the gate — same posture as scan + } + if (row.status !== 'match' && row.status !== 'unlisted') bad++; + results.push(row); + } + const summary = { manifest: { scannedAt: manifest.scannedAt, skills: Object.keys(skills).length }, installed: installed.length, failing: bad }; + if (jsonOut) out(JSON.stringify({ ...summary, results })); + else { + const markOf = { match: mark.ok, drifted: mark.drifted, flagged: mark.poisoned, unlisted: mark.unpinned, error: c(C.red, '✗') }; + for (const r of results) { + const detail = r.status === 'drifted' ? c(C.dim, ` installed ${r.hash.slice(0, 12)} ≠ watched ${r.expected.slice(0, 12)}`) + : r.status === 'flagged' ? c(C.red, ' flagged poisoned by the watch — do not run') + : r.status === 'error' ? c(C.red, ` ${r.error}`) : ''; + out(`${markOf[r.status]} ${c(C.bold, r.name)} ${c(C.dim, `(${r.marketplace})`)} ${r.status}${detail}`); + } + out(c(C.dim, `${installed.length} installed plugin skills checked against ${Object.keys(skills).length} watched (manifest ${manifest.scannedAt || 'undated'})`)); + } + return bad ? 1 : 0; +} + function runGuard() { if (!post.length) { out('usage: canon guard [--lock ] -- '); return 2; } const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) }); @@ -416,7 +474,7 @@ function runHook() { return 2; } -const table = { scan: runScan, add: runAdd, remove: runRemove, unpin: runRemove, verify: runVerify, diff: runDiff, list: runList, guard: runGuard, key: runKey, trust: runTrust, hook: runHook }; +const table = { scan: runScan, add: runAdd, remove: runRemove, unpin: runRemove, verify: runVerify, diff: runDiff, list: runList, 'check-manifest': runCheckManifest, guard: runGuard, key: runKey, trust: runTrust, hook: runHook }; if (!cmd || cmd === '-h' || cmd === '--help' || !table[cmd]) { usage(); process.exit(cmd && cmd !== '-h' && cmd !== '--help' ? 2 : 0); } try { process.exit(table[cmd]()); } catch (e) { process.stderr.write(`canon: ${e && e.message || e}\n`); process.exit(1); } diff --git a/support/marketplace-watch.mjs b/support/marketplace-watch.mjs index b2c4fb3..65bf43a 100644 --- a/support/marketplace-watch.mjs +++ b/support/marketplace-watch.mjs @@ -103,9 +103,15 @@ if (corpusMode) { const flaggedRows = []; const acceptedRows = []; const advisoryRows = []; +// name → skillHash for EVERY scanned skill, published as directory-manifest.json +// so `truecopy check-manifest` can compare a machine's installed plugin skills +// against exactly the bytes this watch scanned. Null-proto: catalog names are +// validated but 'constructor'-shaped ones must stay plain data keys. +const manifestSkills = Object.create(null); let advisoryCount = 0; for (const s of skills) { const r = scan(s.dir); + manifestSkills[s.name] = skillHash(r.skill); const advisories = (r.advisories || []).map((f) => `${f.tool}: ${f.flags.join('; ')}`); advisoryCount += advisories.length; if (r.verdict !== 'clean') { @@ -132,6 +138,18 @@ write('badge.json', JSON.stringify({ color: poisoned ? 'red' : (fetchErrors.length ? 'orange' : 'brightgreen'), }) + '\n'); +// The consumable artifact: what the watch scanned, as name → hash. `flagged` +// rides along so a byte-identical install of a poisoned skill still fails +// check-manifest (a hash match is not an endorsement). +write('directory-manifest.json', JSON.stringify({ + schemaVersion: 1, + source: 'anthropics/claude-plugins-official', + scannedAt, + plugins, + skills: { ...manifestSkills }, + flagged: flaggedRows.map((r) => r.name), +}, null, 2) + '\n'); + write('results.json', JSON.stringify({ ...summary, flagged: flaggedRows, diff --git a/test/check-manifest.test.mjs b/test/check-manifest.test.mjs new file mode 100644 index 0000000..97ad251 --- /dev/null +++ b/test/check-manifest.test.mjs @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +// Same isolation as the other suites: private mkdtemp base; CANON_CLAUDE_HOME +// stands in for the real home so plugin discovery never touches ~/.claude. +const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-manifest-test-')); +process.env.CANON_HOME = path.join(baseDir, 'home'); +process.env.CANON_CLAUDE_HOME = path.join(baseDir, 'chome'); +import { loadSkill, skillHash } from '../src/index.mjs'; + +const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); +const run = (args) => spawnSync(process.execPath, [CLI, ...args], { cwd: baseDir, encoding: 'utf8', env: process.env }); + +const mkPluginSkill = (plugin, skill, body) => { + const p = path.join(process.env.CANON_CLAUDE_HOME, '.claude', 'plugins', 'marketplaces', 'official', 'plugins', plugin); + fs.mkdirSync(path.join(p, 'skills', skill), { recursive: true }); + fs.writeFileSync(path.join(p, 'skills', skill, 'SKILL.md'), body); + return path.join(p, 'skills', skill); +}; +const helperDir = mkPluginSkill('toolkit', 'helper', '# helper\nBe helpful.\n'); +const extraDir = mkPluginSkill('toolkit', 'extra', '# extra\nDo extra things.\n'); +mkPluginSkill('constructor', 'skill', '# skill\nA plugin named after a prototype member.\n'); + +const hashOf = (dir) => skillHash(loadSkill(dir)); +const writeManifest = (name, m) => { + const f = path.join(baseDir, name); + fs.writeFileSync(f, JSON.stringify(m)); + return f; +}; + +test('check-manifest: byte-identical installs match, unlisted skills never fail', () => { + // 'constructor:skill' is deliberately NOT in the manifest — with a plain `in` + // lookup it would read Object.prototype.constructor as its expected hash. + const f = writeManifest('m-ok.json', { scannedAt: '2026-07-17', skills: { 'toolkit:helper': hashOf(helperDir), 'toolkit:extra': hashOf(extraDir) } }); + const r = run(['check-manifest', f, '--json']); + assert.equal(r.status, 0, r.stdout + r.stderr); + const j = JSON.parse(r.stdout); + assert.equal(j.failing, 0); + const byName = Object.fromEntries(j.results.map((x) => [x.name, x.status])); + assert.equal(byName['toolkit:helper'], 'match'); + assert.equal(byName['toolkit:extra'], 'match'); + assert.equal(byName['constructor:skill'], 'unlisted'); +}); + +test('check-manifest: an installed skill whose bytes differ from the watched hash fails', () => { + const f = writeManifest('m-drift.json', { skills: { 'toolkit:helper': 'a'.repeat(64), 'toolkit:extra': hashOf(extraDir) } }); + const r = run(['check-manifest', f, '--json']); + assert.equal(r.status, 1, r.stdout + r.stderr); + const j = JSON.parse(r.stdout); + assert.equal(j.failing, 1); + const helper = j.results.find((x) => x.name === 'toolkit:helper'); + assert.equal(helper.status, 'drifted'); + assert.equal(helper.expected, 'a'.repeat(64)); +}); + +test('check-manifest: a watch-flagged skill fails even byte-identical — match is not endorsement', () => { + const f = writeManifest('m-flagged.json', { skills: { 'toolkit:helper': hashOf(helperDir) }, flagged: ['toolkit:helper'] }); + const r = run(['check-manifest', f, '--json']); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.equal(JSON.parse(r.stdout).results.find((x) => x.name === 'toolkit:helper').status, 'flagged'); +}); + +test('check-manifest: unreadable or shapeless manifests exit 2, not 0', () => { + assert.equal(run(['check-manifest', path.join(baseDir, 'nope.json')]).status, 2); + assert.equal(run(['check-manifest', writeManifest('m-shapeless.json', { hello: 'world' })]).status, 2); + assert.equal(run(['check-manifest', writeManifest('m-array.json', { skills: ['not', 'a', 'map'] })]).status, 2); + assert.equal(run(['check-manifest']).status, 2); +}); diff --git a/test/marketplace-watch.test.mjs b/test/marketplace-watch.test.mjs index f6315dc..bcd9ac6 100644 --- a/test/marketplace-watch.test.mjs +++ b/test/marketplace-watch.test.mjs @@ -91,13 +91,18 @@ test('corpus mode: poisoned skill flags, drift and fetch errors are reported, ex assert.deepEqual(results.flagged.map((f) => f.name), ['p-evil:sneaky']); assert.equal(results.pinDriftDetail[0].name, 'p-drift'); assert.equal(results.fetchErrorDetail[0].name, 'p-gone'); + // the manifest names the poisoned skill so check-manifest fails it even byte-identical + const manifest = JSON.parse(fs.readFileSync(path.join(out, 'directory-manifest.json'), 'utf8')); + assert.deepEqual(manifest.flagged, ['p-evil:sneaky']); + assert.ok(manifest.skills['p-evil:sneaky']); const watchMd = fs.readFileSync(path.join(out, 'WATCH.md'), 'utf8'); assert.match(watchMd, /## ☠ Poisoned/); assert.match(watchMd, /## ⚠ Pin drift/); assert.match(watchMd, /## ✗ Not scanned/); }); -test('corpus mode: all-clean corpus exits 0 with a green badge', () => { +test('corpus mode: all-clean corpus exits 0 with a green badge and a consumable manifest', async () => { + const { scan, skillHash } = await import('../src/index.mjs'); const dir = path.join(baseDir, 'p-solo'); put(path.join(dir, 'skills', 'helper', 'SKILL.md'), CLEAN); const corpus = mkCorpus('corpus-clean', [{ name: 'p-solo', kind: 'local', dir, status: 'ok' }]); @@ -105,6 +110,10 @@ test('corpus mode: all-clean corpus exits 0 with a green badge', () => { const r = runWatch(corpus, out); assert.equal(r.status, 0, r.stdout + r.stderr); assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'badge.json'), 'utf8')).color, 'brightgreen'); + // directory-manifest.json carries name → the same hash the scan derived + const manifest = JSON.parse(fs.readFileSync(path.join(out, 'directory-manifest.json'), 'utf8')); + assert.equal(manifest.skills['p-solo:helper'], skillHash(scan(path.join(dir, 'skills', 'helper')).skill)); + assert.deepEqual(manifest.flagged, []); }); test('corpus mode: fetch failures alone keep exit 0 but turn the badge orange', () => {