skilldrop-cli 0.16.25 → 0.16.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ /* Organisation governance (RFC-0050, OWASP AST09): a policy the installer enforces, an audit
2
+ log that shows when its history was edited, and a check that a mirror still matches what was
3
+ packaged. Node's standard library only, like the rest of the CLI.
4
+
5
+ The policy is a JSON file an organisation hands out (MDM, a dotfiles repo, an image):
6
+ --policy <file> > SKILLDROP_POLICY > ~/.config/skilldrop/policy.json
7
+ A policy that is named but missing, unreadable, or carries a key this version doesn't know
8
+ stops the command: a policy that fails open is not a policy. */
9
+ "use strict";
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const os = require("os");
13
+ const crypto = require("crypto");
14
+ const { execFileSync } = require("child_process");
15
+
16
+ const SEVERITY = { "🟥": 3, "🟧": 2, "🟨": 1 }; // scan findings; ⚪ (fixtures) never count
17
+ const MAX = { none: 0, low: 1, medium: 2, high: 3 }; // policy scan.max: the worst finding allowed
18
+ const KEYS = {
19
+ "": ["version", "catalogues", "skills", "scan", "audit", "note"],
20
+ catalogues: ["allow", "require_pinned"],
21
+ skills: ["allow", "block"],
22
+ scan: ["max"],
23
+ audit: ["log", "actor", "salt_env"],
24
+ };
25
+
26
+ function die(msg) { console.error(`error: ${msg}`); process.exit(1); }
27
+ const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
28
+ const tilde = (p) => (p.startsWith(os.homedir()) ? "~" + p.slice(os.homedir().length) : p);
29
+ const untilde = (p) => (p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
30
+
31
+ function policyPath(flags) {
32
+ if (flags.policy && flags.policy !== true) return { file: path.resolve(String(flags.policy)), named: true };
33
+ if (process.env.SKILLDROP_POLICY) return { file: path.resolve(untilde(process.env.SKILLDROP_POLICY)), named: true };
34
+ const dflt = path.join(os.homedir(), ".config", "skilldrop", "policy.json");
35
+ return { file: dflt, named: false };
36
+ }
37
+
38
+ /* The policy in force, or null when none is set. */
39
+ function load(flags) {
40
+ const { file, named } = policyPath(flags || {});
41
+ if (!fs.existsSync(file)) {
42
+ if (named) die(`policy ${file} does not exist — nothing was changed`);
43
+ return null;
44
+ }
45
+ let raw, p;
46
+ try { raw = fs.readFileSync(file, "utf8"); p = JSON.parse(raw); }
47
+ catch (e) { die(`policy ${file} is not valid JSON (${e.message}) — nothing was changed`); }
48
+ const problems = [];
49
+ const closed = (obj, where) => {
50
+ if (obj === undefined) return;
51
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return problems.push(`${where || "the policy"} must be an object`);
52
+ for (const k of Object.keys(obj)) if (!KEYS[where].includes(k)) problems.push(`unknown key '${where ? where + "." : ""}${k}'`);
53
+ };
54
+ closed(p, "");
55
+ for (const k of ["catalogues", "skills", "scan", "audit"]) closed(p[k], k);
56
+ if (p.version !== 1) problems.push("version must be 1");
57
+ const list = (v, where) => { if (v !== undefined && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) problems.push(`${where} must be a list of strings`); };
58
+ list(p.catalogues && p.catalogues.allow, "catalogues.allow");
59
+ list(p.skills && p.skills.allow, "skills.allow");
60
+ list(p.skills && p.skills.block, "skills.block");
61
+ if (p.catalogues && p.catalogues.require_pinned !== undefined && typeof p.catalogues.require_pinned !== "boolean")
62
+ problems.push("catalogues.require_pinned must be true or false");
63
+ if (p.scan && p.scan.max !== undefined && !(p.scan.max in MAX)) problems.push(`scan.max must be one of ${Object.keys(MAX).join(", ")}`);
64
+ if (p.audit) {
65
+ const a = p.audit.actor;
66
+ const ok = a === undefined || ["none", "pseudonym", "os-user"].includes(a) ||
67
+ (a && typeof a === "object" && Object.keys(a).length === 1 && typeof a.fixed === "string" && a.fixed.trim());
68
+ if (!ok) problems.push('audit.actor must be "none", "pseudonym", "os-user" or {"fixed": "<name>"}');
69
+ if (p.audit.log !== undefined && typeof p.audit.log !== "string") problems.push("audit.log must be a path");
70
+ }
71
+ if (problems.length) die(`policy ${file} is not usable — nothing was changed:\n ${problems.join("\n ")}`);
72
+ return Object.assign(p, { file, hash: sha256(raw).slice(0, 16) });
73
+ }
74
+
75
+ const glob = (pat) => new RegExp("^" + pat.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
76
+ const matches = (name, pats) => (pats || []).some((p) => glob(p).test(name));
77
+
78
+ /* A catalogue source as the policy compares it: "bundled", an absolute path, or a git URL
79
+ without its #ref, trailing slash or .git. */
80
+ function sourceKey(source) {
81
+ if (!source || source === "bundled") return "bundled";
82
+ const i = source.lastIndexOf("#");
83
+ const url = i > 0 ? source.slice(0, i) : source;
84
+ if (fs.existsSync(url)) return path.resolve(url);
85
+ return url.replace(/\/+$/, "").replace(/\.git$/i, "");
86
+ }
87
+
88
+ /* Why the policy refuses these units, as [{name, why}]; an empty list means allowed.
89
+ worst: {name: "🟥" | "🟧" | "🟨" | undefined}, the worst scan finding per unit. */
90
+ function check(policy, { source, pinned, names, worst = {}, kind = "skill" }) {
91
+ if (!policy) return [];
92
+ const out = [];
93
+ const key = sourceKey(source);
94
+ const cats = policy.catalogues || {};
95
+ if (cats.allow && !cats.allow.some((a) => (a === "bundled" ? key === "bundled" : matches(key, [sourceKey(a)]))))
96
+ for (const n of names) out.push({ name: n, why: `catalogue '${key}' is not in the policy's allowed list` });
97
+ else if (cats.require_pinned && key !== "bundled" && !pinned)
98
+ for (const n of names) out.push({ name: n, why: `catalogue '${key}' must be pinned to a commit (--from <url>#<commit>)` });
99
+ const sk = policy.skills || {};
100
+ for (const n of names) {
101
+ if (matches(n, sk.block)) out.push({ name: n, why: `${kind} '${n}' is blocked by the policy` });
102
+ else if (sk.allow && !matches(n, sk.allow)) out.push({ name: n, why: `${kind} '${n}' is not in the policy's allowed list` });
103
+ const max = policy.scan && policy.scan.max;
104
+ if (max !== undefined && worst[n] && SEVERITY[worst[n]] > MAX[max])
105
+ out.push({ name: n, why: `the supply-chain scan found a ${worst[n]} pattern, above the policy's limit (${max})` });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /* ---------- audit log ----------
111
+ One JSON object per line. Each line carries the SHA-256 of the line before it, so a line
112
+ edited or removed afterwards breaks the chain, and `skilldrop audit verify` says where. */
113
+ function auditFile(policy) {
114
+ const env = process.env.SKILLDROP_AUDIT_LOG;
115
+ if (!(policy && policy.audit) && !env) return null;
116
+ const f = path.resolve(untilde(env || (policy.audit.log || "~/.config/skilldrop/audit.jsonl")));
117
+ // Never inside a git working tree, where it could be committed.
118
+ try {
119
+ const dir = path.dirname(f);
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
122
+ if (top) die(`the audit log ${tilde(f)} is inside a git working tree (${tilde(top)}); keep it outside any repository`);
123
+ } catch (e) { /* not inside a repository (or no git): good */ }
124
+ return f;
125
+ }
126
+
127
+ function actor(policy) {
128
+ const mode = (policy && policy.audit && policy.audit.actor) || "pseudonym";
129
+ if (mode === "none") return null;
130
+ if (typeof mode === "object") return mode.fixed;
131
+ const user = (() => { try { return os.userInfo().username; } catch (e) { return "unknown"; } })();
132
+ if (mode === "os-user") return user;
133
+ const saltVar = (policy && policy.audit && policy.audit.salt_env) || "SKILLDROP_AUDIT_SALT";
134
+ const salt = process.env[saltVar];
135
+ if (!salt) die(`the audit log names people by pseudonym, which needs a secret salt: set ${saltVar} (the organisation keeps it), or set audit.actor to "none"`);
136
+ return "p:" + crypto.createHmac("sha256", salt).update(`${user}@${os.hostname()}`).digest("hex").slice(0, 16);
137
+ }
138
+
139
+ function lastLine(f) {
140
+ if (!fs.existsSync(f)) return null;
141
+ const lines = fs.readFileSync(f, "utf8").split("\n").filter(Boolean);
142
+ return lines.length ? lines[lines.length - 1] : null;
143
+ }
144
+
145
+ /* Everything the audit log needs (where it goes, who is acting), worked out before a command
146
+ changes anything: an install must not land and then fail to be recorded. */
147
+ const ready = new Map();
148
+ function prepare(policy) {
149
+ const key = policy || "env";
150
+ if (ready.has(key)) return ready.get(key);
151
+ const f = auditFile(policy);
152
+ const r = f ? { f, who: actor(policy) } : null;
153
+ ready.set(key, r);
154
+ return r;
155
+ }
156
+
157
+ /* Append one event per unit. Does nothing when no audit log is configured. */
158
+ function audit(policy, action, units, extra) {
159
+ const r = prepare(policy);
160
+ if (!r || !units.length) return;
161
+ const { f, who } = r;
162
+ for (const u of units) {
163
+ const prev = lastLine(f);
164
+ const entry = Object.assign({ ts: new Date().toISOString(), action }, u, extra || {},
165
+ { actor: who, policy: policy ? policy.hash : null, prev: prev ? sha256(prev) : "0".repeat(64) });
166
+ if (entry.target) entry.target = tilde(entry.target);
167
+ if (entry.source) entry.source = tilde(entry.source); // a catalogue on local disk names no home directory
168
+ fs.appendFileSync(f, JSON.stringify(entry) + "\n", { mode: 0o600 });
169
+ }
170
+ }
171
+
172
+ function verifyAudit(file) {
173
+ const f = path.resolve(untilde(file));
174
+ if (!fs.existsSync(f)) die(`no audit log at ${tilde(f)}`);
175
+ const lines = fs.readFileSync(f, "utf8").split("\n").filter(Boolean);
176
+ let prev = "0".repeat(64);
177
+ for (let i = 0; i < lines.length; i++) {
178
+ let e;
179
+ try { e = JSON.parse(lines[i]); } catch (err) { return { ok: false, line: i + 1, why: "not valid JSON", count: lines.length }; }
180
+ if (e.prev !== prev) return { ok: false, line: i + 1, why: "does not follow the line before it: a line was edited, removed or inserted", count: lines.length };
181
+ prev = sha256(lines[i]);
182
+ }
183
+ return { ok: true, count: lines.length };
184
+ }
185
+
186
+ /* ---------- mirrors ----------
187
+ MIRROR.json (skilldrop package) records a SHA-256 for every file it copied. */
188
+ function verifyMirror(dir) {
189
+ const root = path.resolve(dir);
190
+ const mf = path.join(root, "MIRROR.json");
191
+ if (!fs.existsSync(mf)) die(`${root} has no MIRROR.json — it wasn't made by skilldrop package`);
192
+ let m;
193
+ try { m = JSON.parse(fs.readFileSync(mf, "utf8")); } catch (e) { die(`${mf} is not valid JSON`); }
194
+ const want = m.files || {};
195
+ const found = {};
196
+ const walk = (d) => {
197
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
198
+ if (e.name === ".git") continue;
199
+ const p = path.join(d, e.name);
200
+ if (e.isDirectory()) walk(p);
201
+ else found[path.relative(root, p).split(path.sep).join("/")] = p;
202
+ }
203
+ };
204
+ walk(root);
205
+ const own = new Set(["MIRROR.json", "catalogue.json"]);
206
+ const missing = Object.keys(want).filter((f) => !found[f]);
207
+ const changed = Object.keys(want).filter((f) => found[f] && sha256(fs.readFileSync(found[f])) !== want[f]);
208
+ const extra = Object.keys(found).filter((f) => !(f in want) && !own.has(f) && !/^packs\/[^/]+\/pack\.json$/.test(f));
209
+ return { source: m.source, created: m.created, files: Object.keys(want).length, missing, changed, extra };
210
+ }
211
+
212
+ module.exports = { load, check, prepare, audit, verifyAudit, verifyMirror, sourceKey, tilde, SEVERITY };
package/bin/skilldrop.js CHANGED
@@ -15,6 +15,7 @@ const path = require("path");
15
15
  const os = require("os");
16
16
  const { execFileSync } = require("child_process");
17
17
  const crypto = require("crypto");
18
+ const gov = require("./governance"); // org policy, audit log, mirror check (RFC-0050)
18
19
 
19
20
  const ROOT = path.resolve(__dirname, "..");
20
21
  const LEDGER = ".skilldrop.json";
@@ -75,6 +76,11 @@ Usage:
75
76
  skilldrop init-catalogue <dir> [--pack <name>] start a private catalogue for your team's skills
76
77
  skilldrop package <dir> [--pack a,b] [--skills x,y] [--agents] copy a vetted subset of a catalogue
77
78
  into a standalone one to host internally (writes MIRROR.json)
79
+ skilldrop inventory [--json] [--label <machine>] every skilldrop install on this machine, with
80
+ what the policy thinks of each (RFC-0050)
81
+ skilldrop inventory --merge <files...> combine inventory reports from many machines into one view
82
+ skilldrop audit verify|show [<file>] check the audit log's chain, or show its last entries
83
+ skilldrop verify-mirror <dir> check a mirror against the SHA-256s its MIRROR.json recorded
78
84
  skilldrop --version print the CLI version
79
85
  skilldrop bootstrap add the skilldrop marketplace to ~/.claude/settings.json
80
86
  (idempotent — safe to run in onboarding scripts)
@@ -98,6 +104,12 @@ Install/update/uninstall targets (pick one):
98
104
  --local project scope, hidden from git via .git/info/exclude — try skills in a
99
105
  repo you don't own without touching its tracked files or .gitignore
100
106
 
107
+ Organisation policy (RFC-0050):
108
+ --policy <file> the policy to enforce; also SKILLDROP_POLICY, else ~/.config/skilldrop/policy.json.
109
+ It can allow only some catalogues (and require them pinned), allow or block skills,
110
+ cap the supply-chain scan's findings, and turn on an audit log. A policy that is
111
+ named but missing or unreadable stops the command.
112
+
101
113
  Options:
102
114
  --dry-run install | update | uninstall | new-skill: print what would change, change nothing
103
115
  --yes skip the confirmation uninstall and update --force ask for at a terminal
@@ -131,7 +143,8 @@ function parseArgs(argv) {
131
143
  const out = { _: [], flags: {} };
132
144
  for (let i = 0; i < argv.length; i++) {
133
145
  const a = argv[i];
134
- if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile" || a === "--tier" || a === "--skills" || a === "--days") out.flags[a.slice(2)] = argv[++i];
146
+ if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile" || a === "--tier" || a === "--skills" || a === "--days" ||
147
+ a === "--policy" || a === "--merge" || a === "--label" || a === "--last") out.flags[a.slice(2)] = argv[++i];
135
148
  else if (a.startsWith("--")) out.flags[a.slice(2)] = true;
136
149
  else out._.push(a);
137
150
  }
@@ -420,6 +433,35 @@ function gate(cat, names) {
420
433
  if (bad) die(`${bad} skill(s) failed the structural check — nothing was installed`);
421
434
  }
422
435
 
436
+ /* RFC-0050: the worst scan finding per skill, for a policy's scan.max. Eval fixtures and
437
+ example inputs (⚪) never count. */
438
+ function worstFindings(cat, names) {
439
+ const out = {};
440
+ for (const s of names) {
441
+ let w;
442
+ for (const f of scanSkill(cat, s)) if (!f.fixture && (!w || gov.SEVERITY[f.sev] > gov.SEVERITY[w])) w = f.sev;
443
+ if (w) out[s] = w;
444
+ }
445
+ return out;
446
+ }
447
+
448
+ /* Refuse the whole install when the policy in force forbids any part of it. */
449
+ function enforcePolicy(flags, cat, names, kind) {
450
+ const policy = gov.load(flags);
451
+ gov.prepare(policy); // an audit log that can't be written stops the install before it starts
452
+ if (!policy || !names.length) return policy;
453
+ const worst = kind === "skill" && policy.scan && policy.scan.max !== undefined ? worstFindings(cat, names) : {};
454
+ const problems = gov.check(policy, { source: cat.source, pinned: cat.pinned, names, worst, kind });
455
+ if (problems.length) {
456
+ for (const pr of problems) console.error(`refused ${pr.name}: ${pr.why}`);
457
+ die(`the policy in ${gov.tilde(policy.file)} refuses ${new Set(problems.map((x) => x.name)).size} ${kind}(s) — nothing was installed`);
458
+ }
459
+ return policy;
460
+ }
461
+
462
+ const auditUnit = (cat, name, kind, version, dest) =>
463
+ ({ kind, name, version: version || null, source: cat.source, commit: cat.commit || null, pinned: !!cat.pinned, target: dest });
464
+
423
465
  /* ---------- supply-chain scan (RFC-0022) ----------
424
466
  A skill is instruction prose an agent will obey, and its scripts are code that runs on the
425
467
  user's machine — so installing a third-party catalog is a supply-chain event. Anthropic's own
@@ -1221,6 +1263,7 @@ function install(args) {
1221
1263
  }
1222
1264
  }
1223
1265
  gate(cat, names);
1266
+ const policy = enforcePolicy(args.flags, cat, names, "skill");
1224
1267
  const { dest, ide } = target(args.flags);
1225
1268
  if (args.flags["dry-run"]) return planInstall(cat, names, dest, ide, args.flags);
1226
1269
  fs.mkdirSync(dest, { recursive: true });
@@ -1233,6 +1276,7 @@ function install(args) {
1233
1276
  console.log(`installed ${s}@${m.version} -> ${dest}`);
1234
1277
  }
1235
1278
  saveLedger(l);
1279
+ gov.audit(policy, "install", names.map((s) => auditUnit(cat, s, "skill", lver(l.data[s]), dest)));
1236
1280
  console.log(`\n${names.length} skill(s) installed (${ide}).`);
1237
1281
  if (notes.length) console.log(`\nCleanup:\n${notes.join("\n")}`);
1238
1282
  const local = !!args.flags.local;
@@ -1354,7 +1398,10 @@ function update(args) {
1354
1398
  }
1355
1399
  }
1356
1400
  if (dry) return planUpdate(dest, ide, l, rows, args.flags);
1357
- let n = 0, conflicts = 0;
1401
+ const policy = gov.load(args.flags);
1402
+ gov.prepare(policy);
1403
+ const done = [];
1404
+ let n = 0, conflicts = 0, refusedByPolicy = 0;
1358
1405
  const silent = rows.filter((r) => r.current === r.installed && r.changed.length && !args.flags.changed);
1359
1406
  for (const r of rows) {
1360
1407
  if (r.retired) { console.log(`skip ${r.retired}\n Remove it with: skilldrop uninstall ${r.s}${targetHint(args.flags)}`); continue; }
@@ -1362,6 +1409,9 @@ function update(args) {
1362
1409
  if (!isDue(r)) continue;
1363
1410
  const problems = unitCheck(r.cat, r.s, r.loop);
1364
1411
  if (problems.length) { console.log(`skip ${unitLabel(r)}: fails structural check in '${r.src}' (${problems[0]})`); continue; }
1412
+ const refused = gov.check(policy, { source: r.src, pinned: r.pinned, names: [r.s], kind: r.loop ? "loop" : "skill",
1413
+ worst: !r.loop && policy && policy.scan && policy.scan.max !== undefined ? worstFindings(r.cat, [r.s]) : {} });
1414
+ if (refused.length) { console.log(`skip ${unitLabel(r)}: the policy refuses it (${refused[0].why})`); refusedByPolicy++; continue; }
1365
1415
  // No baseline (installed before RFC-0032, or a loop installed before 0.16.11) or --force:
1366
1416
  // overwrite as before, and record one.
1367
1417
  const entry = l.data[r.s];
@@ -1377,9 +1427,12 @@ function update(args) {
1377
1427
  conflicts += kept.length;
1378
1428
  }
1379
1429
  n++;
1430
+ done.push(auditUnit(r.cat, r.s, r.loop ? "loop" : "skill", r.current, dest));
1380
1431
  }
1381
1432
  saveLedger(l);
1382
- console.log(n ? `\n${n} skill(s) or loop(s) updated.` : (silent.length ? "no version changes." : rows.some((r) => r.retired) ? "nothing else to update." : "everything up to date."));
1433
+ gov.audit(policy, "update", done);
1434
+ if (refusedByPolicy) console.log(`\n${refusedByPolicy} not updated because the policy refuses them; they stay as they are. Remove them with skilldrop uninstall, or change the policy.`);
1435
+ else console.log(n ? `\n${n} skill(s) or loop(s) updated.` : (silent.length ? "no version changes." : rows.some((r) => r.retired) ? "nothing else to update." : "everything up to date."));
1383
1436
  if (silent.length)
1384
1437
  console.log(`\nNot taken: ${silent.map((r) => r.s).join(", ")} changed upstream without a version bump.` +
1385
1438
  `\n Read the change (skilldrop diff <skill>), then take it with: skilldrop update --changed`);
@@ -1484,11 +1537,14 @@ function uninstall(args) {
1484
1537
  const what = `Remove ${present.length} skill(s) from ${dest}: ${present.join(", ")}` +
1485
1538
  (edits.length ? `\n (${edits.length} of the files have your edits: ${edits.join(", ")})` : "") + "?";
1486
1539
  if (!confirm(what, args.flags)) return console.log("cancelled — nothing removed.");
1540
+ gov.prepare(gov.load(args.flags));
1487
1541
  for (const s of present) {
1488
1542
  fs.rmSync(path.join(dest, s), { recursive: true, force: true });
1489
1543
  const w = wiringPath(ide, dest, s);
1490
1544
  if (w) fs.rmSync(w, { force: true });
1491
1545
  removeHooksFor(s, dest, ide);
1546
+ gov.audit(gov.load(args.flags), "uninstall", [{ kind: "skill", name: s, version: l.data[s] ? lver(l.data[s]) : null,
1547
+ source: l.data[s] ? lsrc(l.data[s]) : null, target: dest }]);
1492
1548
  delete l.data[s];
1493
1549
  console.log(`removed ${s} from ${dest}`);
1494
1550
  }
@@ -1919,6 +1975,12 @@ function installLoops(args) {
1919
1975
  if (problems.length) bad++;
1920
1976
  }
1921
1977
  if (bad) die(`${bad} loop(s) failed the structural check — nothing was installed`);
1978
+ const policy = enforcePolicy(args.flags, cat, names, "loop");
1979
+ if (!args.flags["no-skills"]) {
1980
+ const stage = [];
1981
+ for (const n of names) for (const sk of loopSkills(cat, n)) if (!stage.includes(sk)) stage.push(sk);
1982
+ enforcePolicy(args.flags, cat, stage, "skill");
1983
+ }
1922
1984
 
1923
1985
  const { dest, ide } = target(args.flags);
1924
1986
  fs.mkdirSync(dest, { recursive: true });
@@ -1929,6 +1991,7 @@ function installLoops(args) {
1929
1991
  console.log(`installed loop ${n} -> ${path.join(dest, n)}`);
1930
1992
  }
1931
1993
  saveLedger(l);
1994
+ gov.audit(policy, "install", names.map((n) => auditUnit(cat, n, "loop", lver(l.data[n]), dest)));
1932
1995
  if (args.flags.local) addLocal(dest, ide, names);
1933
1996
 
1934
1997
  const wanted = [];
@@ -1959,6 +2022,8 @@ function uninstallLoops(args) {
1959
2022
  fs.rmSync(path.join(dest, n), { recursive: true, force: true });
1960
2023
  const w = wiringPath(ide, dest, n);
1961
2024
  if (w) fs.rmSync(w, { force: true });
2025
+ gov.audit(gov.load(args.flags), "uninstall", [{ kind: "loop", name: n, version: l.data[n] ? lver(l.data[n]) : null,
2026
+ source: l.data[n] ? lsrc(l.data[n]) : null, target: dest }]);
1962
2027
  delete l.data[n];
1963
2028
  console.log(`removed loop ${n} from ${dest} (stage skills left in place)`);
1964
2029
  }
@@ -1981,6 +2046,7 @@ function installAgents(args) {
1981
2046
  if (problems.length) bad++;
1982
2047
  }
1983
2048
  if (bad) die(`${bad} agent(s) failed the structural check — nothing was installed`);
2049
+ const policy = enforcePolicy(args.flags, cat, names, "agent");
1984
2050
 
1985
2051
  const { dest, ide } = agentTarget(args.flags);
1986
2052
  fs.mkdirSync(dest, { recursive: true });
@@ -1993,6 +2059,7 @@ function installAgents(args) {
1993
2059
  console.log(`installed ${a} -> ${path.join(dest, file)}`);
1994
2060
  }
1995
2061
  saveLedger(l);
2062
+ gov.audit(policy, "install", names.map((a) => auditUnit(cat, a, "agent", null, dest)));
1996
2063
  console.log(`\n${names.length} agent(s) installed (${ide}).`);
1997
2064
  if (warns.length) console.log(`\nTool mapping:\n${warns.join("\n")}`);
1998
2065
  if (ide === "claude") console.log("Delegate by name, e.g. \"use the devils-advocate agent on this diff\".");
@@ -2125,6 +2192,10 @@ function validateCmd(args) {
2125
2192
  const cat = resolveCatalog(args.flags.from);
2126
2193
  const names = skillsIn(cat);
2127
2194
  let bad = 0;
2195
+ // RFC-0050: this CLI accepts the policy example the contract describes (validate.py checks the
2196
+ // other half), so the schema and the enforcement can't drift apart unnoticed.
2197
+ const example = path.join(cat.dir || ROOT, "contracts", "policy.example.json");
2198
+ if (!args.flags.from && fs.existsSync(example)) gov.load({ policy: example });
2128
2199
  for (const s of names) {
2129
2200
  const problems = checkSkill(cat, s);
2130
2201
  for (const f of scanSkill(cat, s).filter((x) => x.id.startsWith("undeclared-")))
@@ -2149,6 +2220,118 @@ const BOOTSTRAP_MARKETPLACE_KEY = "skilldrop";
2149
2220
  const BOOTSTRAP_GITHUB_OWNER = "sananthanarayan";
2150
2221
  const BOOTSTRAP_GITHUB_REPO = "skilldrop";
2151
2222
 
2223
+ /* ---------- inventory (RFC-0050) ----------
2224
+ Every skilldrop install on this machine, from each target's ledger: one report an
2225
+ organisation can collect, and --merge to combine many into one view. Paths under the home
2226
+ directory are written as ~ so a report names no one. */
2227
+ function inventoryTargets(flags) {
2228
+ if (flags.dest) return [{ dest: path.resolve(flags.dest), ide: "generic", scope: "dest" }];
2229
+ const out = [];
2230
+ for (const [ide, [proj, user]] of Object.entries(SKILL_DIRS)) {
2231
+ out.push({ dest: path.resolve(...proj), ide, scope: "project" });
2232
+ if (user) out.push({ dest: path.join(os.homedir(), ...user), ide, scope: "user" });
2233
+ }
2234
+ for (const ide of ["claude", "codex"]) for (const project of [true, false]) {
2235
+ try { const t = agentTarget({ ide, project }); out.push({ dest: t.dest, ide, scope: project ? "project" : "user" }); } catch (e) { /* no target */ }
2236
+ }
2237
+ const seen = new Set();
2238
+ return out.filter((t) => !seen.has(t.dest) && seen.add(t.dest));
2239
+ }
2240
+
2241
+ function inventory(args) {
2242
+ if (args.flags.merge) return mergeInventories([String(args.flags.merge), ...args._], args.flags);
2243
+ const policy = gov.load(args.flags);
2244
+ const installs = [];
2245
+ for (const t of inventoryTargets(args.flags)) {
2246
+ const l = ledger(t.dest);
2247
+ for (const name of Object.keys(l.data).sort()) {
2248
+ const e = l.data[name];
2249
+ const kind = isLoopEntry(e) ? "loop" : e && e.file ? "agent" : "skill";
2250
+ const src = lsrc(e);
2251
+ const pinned = isPinned(src);
2252
+ const refused = gov.check(policy, { source: src, pinned, names: [name], kind });
2253
+ installs.push({ target: gov.tilde(t.dest), scope: t.scope, ide: t.ide, kind, name, version: lver(e) || null,
2254
+ source: gov.tilde(src), commit: (e && e.commit) || null, pinned,
2255
+ edited: kind === "agent" ? 0 : editedFiles(t.dest, name, e).length,
2256
+ policy: policy ? (refused.length ? refused.map((x) => x.why) : "ok") : null });
2257
+ }
2258
+ }
2259
+ const report = { inventory: 1, created: new Date().toISOString(), label: args.flags.label && args.flags.label !== true ? String(args.flags.label) : null,
2260
+ cli: readJSON(path.join(ROOT, "package.json")).version, policy: policy ? policy.hash : null, installs };
2261
+ if (args.flags.json) return emitJSON(report);
2262
+ if (!installs.length) return console.log("no skilldrop installs found on this machine (looked in every --ide target, user and project scope)");
2263
+ for (const i of installs)
2264
+ console.log(`${i.target} ${i.kind} ${i.name}@${i.version || "-"} ${i.source}${i.pinned ? " (pinned)" : ""}` +
2265
+ (i.edited ? ` ${i.edited} edited file(s)` : "") + (Array.isArray(i.policy) ? ` REFUSED: ${i.policy[0]}` : ""));
2266
+ const bad = installs.filter((i) => Array.isArray(i.policy));
2267
+ console.log(`\n${installs.length} install(s)` + (policy ? `, ${bad.length} the policy refuses` : "") +
2268
+ ". For an organisation-wide view: skilldrop inventory --json --label <machine> > <file>, then skilldrop inventory --merge <files...>");
2269
+ if (bad.length) process.exitCode = 1;
2270
+ }
2271
+
2272
+ function mergeInventories(files, flags) {
2273
+ const by = {};
2274
+ let reports = 0;
2275
+ for (const f of files) {
2276
+ let r;
2277
+ try { r = readJSON(f); } catch (e) { die(`${f} is not an inventory report`); }
2278
+ if (r.inventory !== 1 || !Array.isArray(r.installs)) die(`${f} is not an inventory report (skilldrop inventory --json)`);
2279
+ reports++;
2280
+ const who = r.label || path.basename(f);
2281
+ for (const i of r.installs) {
2282
+ const k = `${i.kind}\u0000${i.name}\u0000${i.source}`;
2283
+ const a = (by[k] = by[k] || { kind: i.kind, name: i.name, source: i.source, machines: new Set(), versions: new Set(), unpinned: 0, edited: 0, refused: 0 });
2284
+ a.machines.add(who);
2285
+ a.versions.add(i.version || "-");
2286
+ if (i.source !== "bundled" && !i.pinned) a.unpinned++;
2287
+ if (i.edited) a.edited++;
2288
+ if (Array.isArray(i.policy)) a.refused++;
2289
+ }
2290
+ }
2291
+ const rows = Object.values(by).map((a) => Object.assign(a, { machines: [...a.machines].sort(), versions: [...a.versions].sort() }))
2292
+ .sort((x, y) => x.name.localeCompare(y.name));
2293
+ if (flags.json) return emitJSON({ reports, units: rows });
2294
+ for (const a of rows)
2295
+ console.log(`${a.kind} ${a.name} ${a.source} on ${a.machines.length} machine(s) version(s) ${a.versions.join(", ")}` +
2296
+ (a.unpinned ? ` ${a.unpinned} unpinned` : "") + (a.edited ? ` ${a.edited} edited` : "") + (a.refused ? ` ${a.refused} REFUSED by policy` : ""));
2297
+ console.log(`\n${rows.length} distinct install(s) across ${reports} report(s).`);
2298
+ }
2299
+
2300
+ /* skilldrop audit verify|show [<file>] — the append-only log install, update and uninstall
2301
+ write when a policy turns it on (or SKILLDROP_AUDIT_LOG names a file). */
2302
+ function auditCmd(args) {
2303
+ const sub = args._.shift();
2304
+ const policy = gov.load(args.flags);
2305
+ const file = args._[0] || process.env.SKILLDROP_AUDIT_LOG || (policy && policy.audit && (policy.audit.log || "~/.config/skilldrop/audit.jsonl"));
2306
+ if (!file) die("no audit log: pass a file, set SKILLDROP_AUDIT_LOG, or turn it on in the policy (audit.log)");
2307
+ if (sub === "verify") {
2308
+ const r = gov.verifyAudit(file);
2309
+ if (r.ok) return console.log(`ok: ${r.count} entr${r.count === 1 ? "y" : "ies"}, each following the one before it`);
2310
+ console.log(`BROKEN at line ${r.line} of ${r.count}: ${r.why}`);
2311
+ process.exitCode = 1;
2312
+ } else if (sub === "show") {
2313
+ const lines = fs.readFileSync(path.resolve(file.replace(/^~(?=\/)/, os.homedir())), "utf8").split("\n").filter(Boolean);
2314
+ const n = args.flags.last && args.flags.last !== true ? Math.max(1, parseInt(args.flags.last, 10) || 20) : 20;
2315
+ for (const line of lines.slice(-n)) {
2316
+ const e = JSON.parse(line);
2317
+ console.log(`${e.ts} ${e.action} ${e.kind} ${e.name}${e.version ? "@" + e.version : ""} ${e.source || ""}${e.pinned ? " (pinned)" : ""} ${e.target || ""}${e.actor ? " by " + e.actor : ""}`);
2318
+ }
2319
+ } else die("usage: skilldrop audit verify|show [<file>] [--last N]");
2320
+ }
2321
+
2322
+ /* skilldrop verify-mirror <dir> — is an internal mirror still what skilldrop package made? */
2323
+ function verifyMirrorCmd(args) {
2324
+ const r = gov.verifyMirror(args._[0] || die("usage: skilldrop verify-mirror <dir>"));
2325
+ if (args.flags.json) { emitJSON(r); if (r.missing.length || r.changed.length || r.extra.length) process.exitCode = 1; return; }
2326
+ for (const f of r.changed) console.log(`changed ${f}`);
2327
+ for (const f of r.missing) console.log(`missing ${f}`);
2328
+ for (const f of r.extra) console.log(`added ${f} (not in MIRROR.json)`);
2329
+ const bad = r.changed.length + r.missing.length + r.extra.length;
2330
+ console.log(bad ? `\n${bad} file(s) differ from what was packaged from '${r.source}' on ${r.created}.`
2331
+ : `ok: all ${r.files} file(s) match what was packaged from '${r.source}' on ${r.created}.`);
2332
+ if (bad) process.exitCode = 1;
2333
+ }
2334
+
2152
2335
  function bootstrap() {
2153
2336
  const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
2154
2337
  let data = {};
@@ -2178,7 +2361,8 @@ function bootstrap() {
2178
2361
 
2179
2362
  const args = parseArgs(process.argv.slice(2));
2180
2363
  const cmd = args._.shift();
2181
- const commands = { list, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, diff, doctor, "new-skill": newSkill, "loop-stats": loopStats, "init-catalogue": initCatalogue, package: packageCatalogue, validate: validateCmd, scan, bootstrap };
2364
+ const commands = { list, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, diff, doctor, "new-skill": newSkill, "loop-stats": loopStats, "init-catalogue": initCatalogue, package: packageCatalogue, validate: validateCmd, scan, bootstrap,
2365
+ inventory, audit: auditCmd, "verify-mirror": verifyMirrorCmd };
2182
2366
  if (args.flags.version || cmd === "version") console.log(readJSON(path.join(ROOT, "package.json")).version);
2183
2367
  else if (!cmd || cmd === "help" || args.flags.help) console.log(HELP);
2184
2368
  else if (commands[cmd]) commands[cmd](args);
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "note": "Example: Platform Security owns this policy. Ask in #ai-tools before adding a catalogue.",
4
+ "catalogues": {
5
+ "allow": ["bundled", "https://git.example.org/ai/skills"],
6
+ "require_pinned": true
7
+ },
8
+ "skills": {
9
+ "allow": ["*"],
10
+ "block": ["figma-diagrams"]
11
+ },
12
+ "scan": { "max": "medium" },
13
+ "audit": { "actor": "pseudonym", "salt_env": "SKILLDROP_AUDIT_SALT" }
14
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/sananthanarayan/skilldrop/contracts/policy.schema.json",
4
+ "title": "skilldrop organisation policy",
5
+ "$comment": "RFC-0050. The file an organisation hands out (--policy, SKILLDROP_POLICY, or ~/.config/skilldrop/policy.json). Closed at every level: an unknown key stops the command, because a misspelt rule that is silently ignored is a rule that isn't there. bin/governance.js enforces the same shape; contracts/policy.example.json must satisfy both.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version"],
9
+ "properties": {
10
+ "version": { "const": 1 },
11
+ "note": { "type": "string", "description": "Free text: who owns this policy and where to ask about it." },
12
+ "catalogues": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "allow": { "type": "array", "items": { "type": "string" }, "description": "Catalogue sources allowed: \"bundled\", a git URL (a trailing * matches a prefix, e.g. https://git.example.org/skills/*), or a path. Absent: any." },
17
+ "require_pinned": { "type": "boolean", "description": "Every catalogue other than the bundled one must be pinned to a commit (--from <url>#<commit>)." }
18
+ }
19
+ },
20
+ "skills": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "allow": { "type": "array", "items": { "type": "string" }, "description": "Skill, loop and agent names allowed; * matches anything. Absent: any." },
25
+ "block": { "type": "array", "items": { "type": "string" }, "description": "Names refused even when allowed." }
26
+ }
27
+ },
28
+ "scan": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "properties": {
32
+ "max": { "enum": ["none", "low", "medium", "high"], "description": "The worst supply-chain scan finding a skill may carry: none, low (🟨), medium (🟧), high (🟥). Findings in eval fixtures and example inputs never count." }
33
+ }
34
+ },
35
+ "audit": {
36
+ "type": "object",
37
+ "additionalProperties": false,
38
+ "description": "Present: install, update and uninstall append to a tamper-evident log.",
39
+ "properties": {
40
+ "log": { "type": "string", "description": "Where the log goes. Default ~/.config/skilldrop/audit.jsonl. Refused inside a git working tree." },
41
+ "actor": { "description": "Who each entry names: \"pseudonym\" (default; a keyed hash of user and machine, needs the salt), \"none\", \"os-user\", or {\"fixed\": \"<name>\"} such as a team or machine id." },
42
+ "salt_env": { "type": "string", "description": "The environment variable holding the organisation's secret salt for pseudonyms. Default SKILLDROP_AUDIT_SALT." }
43
+ }
44
+ }
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.16.25",
3
+ "version": "0.16.27",
4
4
  "description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
@@ -99,10 +99,9 @@ Two things follow:
99
99
  right lines or mark the item assumed. Add `--mermaid diagram.mmd` for a version to paste
100
100
  into a README, and `--compare old.json` to show what a change adds, removes and alters.
101
101
 
102
- 5. **Reply with the map first.** The script prints the line to open with: a link to the page,
103
- titled. Use it as the first line, then the takeaway in one or two sentences, then one line
104
- each for what is drawn as an assumption and what was left out, and last how many citations
105
- were checked:
102
+ 5. **Lead the reply with the map.** The script prints a titled link to the page. Put it at the
103
+ top, then the takeaway in one or two sentences, then one line each for what is drawn as an
104
+ assumption and what was left out, and last how many citations were checked:
106
105
 
107
106
  > **What a checkout request touches** (the script's link line: the title, linked to the page)
108
107
  >
@@ -110,8 +109,8 @@ Two things follow:
110
109
  > Assumed: the shopper's browser (no client in this repo). Left out: the products table's
111
110
  > other readers. 22 of 22 citations checked.
112
111
 
113
- Keep the spec beside the page so the map can be rebuilt after the code changes. No recap of
114
- commands or layout, and nothing before the link.
112
+ Keep the spec beside the page so the map can be rebuilt after the code changes. At most one
113
+ short line about the build may come before the link; no recap of commands or layout.
115
114
 
116
115
  6. **When the script can't run** (no `python3`), give the Mermaid version in the reply, with
117
116
  assumptions marked, and say that the interactive page needs Python 3.
@@ -131,7 +130,8 @@ don't cite them in the first place, and read the page before it leaves the team.
131
130
  - **Nothing on the map comes only from a README, a plan or what such systems usually have.**
132
131
  - **The map answers one stated question,** and what was left out is said.
133
132
  - **One HTML file that works offline:** no CDN, no external fonts, no network requests.
134
- - **The reply opens with the link and the takeaway,** not with how it was made.
133
+ - **The link and the takeaway lead the reply,** with at most one short line about the build
134
+ before them and no account of how it was made.
135
135
 
136
136
  ## When to use this skill
137
137
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "interactive-diagram",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Turn a codebase, a system description, a process or a database schema into one self-contained interactive HTML diagram (architecture, workflow, data flow, lifecycle, ERD, sequence, class, tree, timeline or waterfall) where every box and arrow cites the file and line or the words it came from, anything inferred is drawn dashed as an assumption, and each cited line is checked against the repository before the page is written. Laid out automatically, opens offline with no CDN, and exports SVG, PNG and Mermaid. Use when the user asks to \"visualize\", \"map\" or \"make an interactive diagram of\" a repo, a request path, a pipeline, an approval process, a set of tables, an incident's events or a slow trace, or wants a diagram they can click through and share as one file.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -2591,7 +2591,7 @@ def main(argv=None):
2591
2591
  else:
2592
2592
  item, link = {"sequence": ("participants", "messages"), "tree": ("items", "links"), "timeline": ("events", "links"),
2593
2593
  "waterfall": ("spans", "parent links"), "class": ("types", "relations")}.get(spec["type"], ("nodes", "edges"))
2594
- print("Your reply starts with this line, before anything else:\n" + receipt["reply_first_line"] + "\n")
2594
+ print("Lead the reply with this line, then the takeaway:\n" + receipt["reply_first_line"] + "\n")
2595
2595
  print("For the end of the reply: %d of %d file citations checked; %d drawn as assumptions. (%s, %d KB: %d %s, %d %s.)" % (
2596
2596
  receipt["verified"], receipt["file_citations"], receipt["assumed"], a.out, receipt["bytes"] // 1024,
2597
2597
  receipt["nodes"], item, receipt["edges"], link))