Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`** — 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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
60 changes: 59 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ function usage() {
truecopy diff <source> [--name <n>] show what changed since it was pinned
truecopy list show the pinned set
truecopy remove <name...> un-pin a skill — drop its ${lockPath} entry (alias: unpin)
truecopy check-manifest <file> 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 <file>] [--require-signed] -- <cmd...> verify the lock, then run <cmd> only if it's clean
…scan / verify / list / diff take --json: machine-readable stdout, same exit codes

Expand Down Expand Up @@ -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 <manifest.json> (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 <file>] -- <command...>'); return 2; }
const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) });
Expand Down Expand Up @@ -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); }
18 changes: 18 additions & 0 deletions support/marketplace-watch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions test/check-manifest.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 10 additions & 1 deletion test/marketplace-watch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,29 @@ 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' }]);
const out = path.join(baseDir, 'out-clean');
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', () => {
Expand Down