skilldrop-cli 0.16.26 → 0.16.28
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.
- package/bin/governance.js +273 -0
- package/bin/skilldrop.js +252 -5
- package/contracts/policy.example.json +14 -0
- package/contracts/policy.schema.json +94 -0
- package/package.json +1 -1
|
@@ -0,0 +1,273 @@
|
|
|
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", "require_containment", "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.require_containment !== undefined && typeof p.require_containment !== "boolean")
|
|
64
|
+
problems.push("require_containment must be true or false");
|
|
65
|
+
if (p.scan && p.scan.max !== undefined && !(p.scan.max in MAX)) problems.push(`scan.max must be one of ${Object.keys(MAX).join(", ")}`);
|
|
66
|
+
if (p.audit) {
|
|
67
|
+
const a = p.audit.actor;
|
|
68
|
+
const ok = a === undefined || ["none", "pseudonym", "os-user"].includes(a) ||
|
|
69
|
+
(a && typeof a === "object" && Object.keys(a).length === 1 && typeof a.fixed === "string" && a.fixed.trim());
|
|
70
|
+
if (!ok) problems.push('audit.actor must be "none", "pseudonym", "os-user" or {"fixed": "<name>"}');
|
|
71
|
+
if (p.audit.log !== undefined && typeof p.audit.log !== "string") problems.push("audit.log must be a path");
|
|
72
|
+
}
|
|
73
|
+
if (problems.length) die(`policy ${file} is not usable — nothing was changed:\n ${problems.join("\n ")}`);
|
|
74
|
+
return Object.assign(p, { file, hash: sha256(raw).slice(0, 16) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const glob = (pat) => new RegExp("^" + pat.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
|
|
78
|
+
const matches = (name, pats) => (pats || []).some((p) => glob(p).test(name));
|
|
79
|
+
|
|
80
|
+
/* A catalogue source as the policy compares it: "bundled", an absolute path, or a git URL
|
|
81
|
+
without its #ref, trailing slash or .git. */
|
|
82
|
+
function sourceKey(source) {
|
|
83
|
+
if (!source || source === "bundled") return "bundled";
|
|
84
|
+
const i = source.lastIndexOf("#");
|
|
85
|
+
const url = i > 0 ? source.slice(0, i) : source;
|
|
86
|
+
if (fs.existsSync(url)) return path.resolve(url);
|
|
87
|
+
return url.replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Why the policy refuses these units, as [{name, why}]; an empty list means allowed.
|
|
91
|
+
worst: {name: "🟥" | "🟧" | "🟨" | undefined}, the worst scan finding per unit. */
|
|
92
|
+
function check(policy, { source, pinned, names, worst = {}, kind = "skill" }) {
|
|
93
|
+
if (!policy) return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
const key = sourceKey(source);
|
|
96
|
+
const cats = policy.catalogues || {};
|
|
97
|
+
if (cats.allow && !cats.allow.some((a) => (a === "bundled" ? key === "bundled" : matches(key, [sourceKey(a)]))))
|
|
98
|
+
for (const n of names) out.push({ name: n, why: `catalogue '${key}' is not in the policy's allowed list` });
|
|
99
|
+
else if (cats.require_pinned && key !== "bundled" && !pinned)
|
|
100
|
+
for (const n of names) out.push({ name: n, why: `catalogue '${key}' must be pinned to a commit (--from <url>#<commit>)` });
|
|
101
|
+
const sk = policy.skills || {};
|
|
102
|
+
for (const n of names) {
|
|
103
|
+
if (matches(n, sk.block)) out.push({ name: n, why: `${kind} '${n}' is blocked by the policy` });
|
|
104
|
+
else if (sk.allow && !matches(n, sk.allow)) out.push({ name: n, why: `${kind} '${n}' is not in the policy's allowed list` });
|
|
105
|
+
const max = policy.scan && policy.scan.max;
|
|
106
|
+
if (max !== undefined && worst[n] && SEVERITY[worst[n]] > MAX[max])
|
|
107
|
+
out.push({ name: n, why: `the supply-chain scan found a ${worst[n]} pattern, above the policy's limit (${max})` });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ---------- audit log ----------
|
|
113
|
+
One JSON object per line. Each line carries the SHA-256 of the line before it, so a line
|
|
114
|
+
edited or removed afterwards breaks the chain, and `skilldrop audit verify` says where. */
|
|
115
|
+
function auditFile(policy) {
|
|
116
|
+
const env = process.env.SKILLDROP_AUDIT_LOG;
|
|
117
|
+
if (!(policy && policy.audit) && !env) return null;
|
|
118
|
+
const f = path.resolve(untilde(env || (policy.audit.log || "~/.config/skilldrop/audit.jsonl")));
|
|
119
|
+
// Never inside a git working tree, where it could be committed.
|
|
120
|
+
try {
|
|
121
|
+
const dir = path.dirname(f);
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
123
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
124
|
+
if (top) die(`the audit log ${tilde(f)} is inside a git working tree (${tilde(top)}); keep it outside any repository`);
|
|
125
|
+
} catch (e) { /* not inside a repository (or no git): good */ }
|
|
126
|
+
return f;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function actor(policy) {
|
|
130
|
+
const mode = (policy && policy.audit && policy.audit.actor) || "pseudonym";
|
|
131
|
+
if (mode === "none") return null;
|
|
132
|
+
if (typeof mode === "object") return mode.fixed;
|
|
133
|
+
const user = (() => { try { return os.userInfo().username; } catch (e) { return "unknown"; } })();
|
|
134
|
+
if (mode === "os-user") return user;
|
|
135
|
+
const saltVar = (policy && policy.audit && policy.audit.salt_env) || "SKILLDROP_AUDIT_SALT";
|
|
136
|
+
const salt = process.env[saltVar];
|
|
137
|
+
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"`);
|
|
138
|
+
return "p:" + crypto.createHmac("sha256", salt).update(`${user}@${os.hostname()}`).digest("hex").slice(0, 16);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function lastLine(f) {
|
|
142
|
+
if (!fs.existsSync(f)) return null;
|
|
143
|
+
const lines = fs.readFileSync(f, "utf8").split("\n").filter(Boolean);
|
|
144
|
+
return lines.length ? lines[lines.length - 1] : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Everything the audit log needs (where it goes, who is acting), worked out before a command
|
|
148
|
+
changes anything: an install must not land and then fail to be recorded. */
|
|
149
|
+
const ready = new Map();
|
|
150
|
+
function prepare(policy) {
|
|
151
|
+
const key = policy || "env";
|
|
152
|
+
if (ready.has(key)) return ready.get(key);
|
|
153
|
+
const f = auditFile(policy);
|
|
154
|
+
const r = f ? { f, who: actor(policy) } : null;
|
|
155
|
+
ready.set(key, r);
|
|
156
|
+
return r;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Append one event per unit. Does nothing when no audit log is configured. */
|
|
160
|
+
function audit(policy, action, units, extra) {
|
|
161
|
+
const r = prepare(policy);
|
|
162
|
+
if (!r || !units.length) return;
|
|
163
|
+
const { f, who } = r;
|
|
164
|
+
for (const u of units) {
|
|
165
|
+
const prev = lastLine(f);
|
|
166
|
+
const entry = Object.assign({ ts: new Date().toISOString(), action }, u, extra || {},
|
|
167
|
+
{ actor: who, policy: policy ? policy.hash : null, prev: prev ? sha256(prev) : "0".repeat(64) });
|
|
168
|
+
if (entry.target) entry.target = tilde(entry.target);
|
|
169
|
+
if (entry.source) entry.source = tilde(entry.source); // a catalogue on local disk names no home directory
|
|
170
|
+
fs.appendFileSync(f, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function verifyAudit(file) {
|
|
175
|
+
const f = path.resolve(untilde(file));
|
|
176
|
+
if (!fs.existsSync(f)) die(`no audit log at ${tilde(f)}`);
|
|
177
|
+
const lines = fs.readFileSync(f, "utf8").split("\n").filter(Boolean);
|
|
178
|
+
let prev = "0".repeat(64);
|
|
179
|
+
for (let i = 0; i < lines.length; i++) {
|
|
180
|
+
let e;
|
|
181
|
+
try { e = JSON.parse(lines[i]); } catch (err) { return { ok: false, line: i + 1, why: "not valid JSON", count: lines.length }; }
|
|
182
|
+
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 };
|
|
183
|
+
prev = sha256(lines[i]);
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, count: lines.length };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---------- mirrors ----------
|
|
189
|
+
MIRROR.json (skilldrop package) records a SHA-256 for every file it copied. */
|
|
190
|
+
function verifyMirror(dir) {
|
|
191
|
+
const root = path.resolve(dir);
|
|
192
|
+
const mf = path.join(root, "MIRROR.json");
|
|
193
|
+
if (!fs.existsSync(mf)) die(`${root} has no MIRROR.json — it wasn't made by skilldrop package`);
|
|
194
|
+
let m;
|
|
195
|
+
try { m = JSON.parse(fs.readFileSync(mf, "utf8")); } catch (e) { die(`${mf} is not valid JSON`); }
|
|
196
|
+
const want = m.files || {};
|
|
197
|
+
const found = {};
|
|
198
|
+
const walk = (d) => {
|
|
199
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
200
|
+
if (e.name === ".git") continue;
|
|
201
|
+
const p = path.join(d, e.name);
|
|
202
|
+
if (e.isDirectory()) walk(p);
|
|
203
|
+
else found[path.relative(root, p).split(path.sep).join("/")] = p;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
walk(root);
|
|
207
|
+
const own = new Set(["MIRROR.json", "catalogue.json"]);
|
|
208
|
+
const missing = Object.keys(want).filter((f) => !found[f]);
|
|
209
|
+
const changed = Object.keys(want).filter((f) => found[f] && sha256(fs.readFileSync(found[f])) !== want[f]);
|
|
210
|
+
const extra = Object.keys(found).filter((f) => !(f in want) && !own.has(f) && !/^packs\/[^/]+\/pack\.json$/.test(f));
|
|
211
|
+
return { source: m.source, created: m.created, files: Object.keys(want).length, missing, changed, extra };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ---------- containment (RFC-0051, OWASP AST06) ----------
|
|
215
|
+
Claude Code's sandbox confines the shell commands a session runs, and every process they
|
|
216
|
+
start: writes to the working directory and temp, network only to allowed hosts. It applies
|
|
217
|
+
to a whole project session, not to one skill, so containing a skill means turning the strict
|
|
218
|
+
sandbox on for the project it is installed into, sized to what its manifest declares
|
|
219
|
+
(permissions.network, permissions.files). It only ever adds restrictions: it never removes
|
|
220
|
+
a setting, never sets one to something looser, and never widens a sandbox that is already
|
|
221
|
+
on. Facts from code.claude.com/docs/en/sandboxing and /permissions, checked 2026-10-19. */
|
|
222
|
+
function readSettings(f) {
|
|
223
|
+
if (!f || !fs.existsSync(f)) return {};
|
|
224
|
+
try { return JSON.parse(fs.readFileSync(f, "utf8")); }
|
|
225
|
+
catch (e) { die(`${tilde(f)} is not valid JSON — fix it before containment changes it; nothing was installed`); }
|
|
226
|
+
}
|
|
227
|
+
const strictIn = (d) => !!(d && d.sandbox && d.sandbox.enabled === true && d.sandbox.allowUnsandboxedCommands === false);
|
|
228
|
+
|
|
229
|
+
/* units: [{name, perms}]; files: {local, project, user}; opts: {require}.
|
|
230
|
+
Returns what --contain would change, without writing anything. */
|
|
231
|
+
function containmentPlan(units, files, opts = {}) {
|
|
232
|
+
const uncontainable = units.filter((u) => u.perms && u.perms.files === "anywhere").map((u) => u.name);
|
|
233
|
+
const hosts = [...new Set(units.flatMap((u) => (u.perms && u.perms.network) || []))].sort();
|
|
234
|
+
const local = readSettings(files.local);
|
|
235
|
+
const others = [files.project, files.user].filter((f) => f && f !== files.local).map(readSettings);
|
|
236
|
+
const before = JSON.stringify(local);
|
|
237
|
+
const next = JSON.parse(before);
|
|
238
|
+
const sb = (next.sandbox = next.sandbox && typeof next.sandbox === "object" ? next.sandbox : {});
|
|
239
|
+
const alreadyOn = sb.enabled === true || others.some((d) => d.sandbox && d.sandbox.enabled === true);
|
|
240
|
+
const changes = [], notes = [];
|
|
241
|
+
if (sb.enabled !== true) { sb.enabled = true; changes.push("sandbox.enabled: true — shell commands and what they start run in the OS sandbox"); }
|
|
242
|
+
if (sb.allowUnsandboxedCommands !== false) {
|
|
243
|
+
sb.allowUnsandboxedCommands = false;
|
|
244
|
+
changes.push("sandbox.allowUnsandboxedCommands: false — a command that fails in the sandbox can't retry outside it");
|
|
245
|
+
}
|
|
246
|
+
if (opts.require && sb.failIfUnavailable !== true) {
|
|
247
|
+
sb.failIfUnavailable = true;
|
|
248
|
+
changes.push("sandbox.failIfUnavailable: true — Claude Code won't start here if the sandbox can't (the policy requires containment)");
|
|
249
|
+
}
|
|
250
|
+
if (hosts.length && alreadyOn)
|
|
251
|
+
notes.push(`these skills declare network hosts (${hosts.join(", ")}). The sandbox was already on, so they were not added: ` +
|
|
252
|
+
"adding hosts would widen it. Add them to sandbox.network.allowedDomains yourself if the skills should reach them.");
|
|
253
|
+
else if (hosts.length) {
|
|
254
|
+
sb.network = sb.network && typeof sb.network === "object" ? sb.network : {};
|
|
255
|
+
const have = new Set(sb.network.allowedDomains || []);
|
|
256
|
+
const add = hosts.filter((h) => !have.has(h));
|
|
257
|
+
if (add.length) {
|
|
258
|
+
sb.network.allowedDomains = [...(sb.network.allowedDomains || []), ...add];
|
|
259
|
+
changes.push(`sandbox.network.allowedDomains: ${add.join(", ")} — the hosts these skills declare; shell commands reach no other host without asking`);
|
|
260
|
+
}
|
|
261
|
+
} else if (!alreadyOn) notes.push("these skills declare no network hosts, so shell commands reach none without asking");
|
|
262
|
+
if ((sb.excludedCommands || []).length)
|
|
263
|
+
notes.push(`this file's sandbox.excludedCommands (${sb.excludedCommands.join(", ")}) still run outside the sandbox`);
|
|
264
|
+
notes.push('the sandbox covers shell commands only: Claude Code\'s file tools, web tools, MCP servers and hooks follow permission rules instead. ' +
|
|
265
|
+
'To keep these skills off the web entirely, add "permissions": {"deny": ["WebFetch", "WebSearch"]} to this file.');
|
|
266
|
+
return { uncontainable, hosts, changes, notes, next, changed: JSON.stringify(next) !== before };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Is a project already contained (strict sandbox on in its own settings)? For inventory and
|
|
270
|
+
for a policy that requires containment. */
|
|
271
|
+
function contained(files) { return strictIn(readSettings(files.local)) || strictIn(readSettings(files.project)); }
|
|
272
|
+
|
|
273
|
+
module.exports = { containmentPlan, contained, 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,15 @@ 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
|
+
--contain Claude Code project installs: turn on the strict sandbox for this project in
|
|
109
|
+
.claude/settings.local.json, allowing only the network hosts the skills declare.
|
|
110
|
+
Only adds restrictions; refuses a skill that declares writing anywhere (RFC-0051)
|
|
111
|
+
--policy <file> the policy to enforce; also SKILLDROP_POLICY, else ~/.config/skilldrop/policy.json.
|
|
112
|
+
It can allow only some catalogues (and require them pinned), allow or block skills,
|
|
113
|
+
cap the supply-chain scan's findings, and turn on an audit log. A policy that is
|
|
114
|
+
named but missing or unreadable stops the command.
|
|
115
|
+
|
|
101
116
|
Options:
|
|
102
117
|
--dry-run install | update | uninstall | new-skill: print what would change, change nothing
|
|
103
118
|
--yes skip the confirmation uninstall and update --force ask for at a terminal
|
|
@@ -131,7 +146,8 @@ function parseArgs(argv) {
|
|
|
131
146
|
const out = { _: [], flags: {} };
|
|
132
147
|
for (let i = 0; i < argv.length; i++) {
|
|
133
148
|
const a = argv[i];
|
|
134
|
-
if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile" || a === "--tier" || a === "--skills" || a === "--days"
|
|
149
|
+
if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile" || a === "--tier" || a === "--skills" || a === "--days" ||
|
|
150
|
+
a === "--policy" || a === "--merge" || a === "--label" || a === "--last") out.flags[a.slice(2)] = argv[++i];
|
|
135
151
|
else if (a.startsWith("--")) out.flags[a.slice(2)] = true;
|
|
136
152
|
else out._.push(a);
|
|
137
153
|
}
|
|
@@ -420,6 +436,91 @@ function gate(cat, names) {
|
|
|
420
436
|
if (bad) die(`${bad} skill(s) failed the structural check — nothing was installed`);
|
|
421
437
|
}
|
|
422
438
|
|
|
439
|
+
/* RFC-0050: the worst scan finding per skill, for a policy's scan.max. Eval fixtures and
|
|
440
|
+
example inputs (⚪) never count. */
|
|
441
|
+
function worstFindings(cat, names) {
|
|
442
|
+
const out = {};
|
|
443
|
+
for (const s of names) {
|
|
444
|
+
let w;
|
|
445
|
+
for (const f of scanSkill(cat, s)) if (!f.fixture && (!w || gov.SEVERITY[f.sev] > gov.SEVERITY[w])) w = f.sev;
|
|
446
|
+
if (w) out[s] = w;
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* Refuse the whole install when the policy in force forbids any part of it. */
|
|
452
|
+
function enforcePolicy(flags, cat, names, kind) {
|
|
453
|
+
const policy = gov.load(flags);
|
|
454
|
+
gov.prepare(policy); // an audit log that can't be written stops the install before it starts
|
|
455
|
+
if (!policy || !names.length) return policy;
|
|
456
|
+
const worst = kind === "skill" && policy.scan && policy.scan.max !== undefined ? worstFindings(cat, names) : {};
|
|
457
|
+
const problems = gov.check(policy, { source: cat.source, pinned: cat.pinned, names, worst, kind });
|
|
458
|
+
if (problems.length) {
|
|
459
|
+
for (const pr of problems) console.error(`refused ${pr.name}: ${pr.why}`);
|
|
460
|
+
die(`the policy in ${gov.tilde(policy.file)} refuses ${new Set(problems.map((x) => x.name)).size} ${kind}(s) — nothing was installed`);
|
|
461
|
+
}
|
|
462
|
+
return policy;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* RFC-0051: --contain turns on Claude Code's strict sandbox for the project a skill is installed
|
|
466
|
+
into, sized to what the skills declare. Planned (and refused) before anything is copied;
|
|
467
|
+
applied after. Only ever adds restrictions; see gov.containmentPlan. */
|
|
468
|
+
function containmentFor(flags, cat, names, dest, ide, policy) {
|
|
469
|
+
const require = !!(policy && policy.require_containment);
|
|
470
|
+
if (!flags.contain && !require) return null;
|
|
471
|
+
if (ide !== "claude") {
|
|
472
|
+
if (require) die(`the policy requires containment, which skilldrop sets up for Claude Code project installs; ` +
|
|
473
|
+
`${ide === "generic" ? "--dest" : "--ide " + ide} has no sandbox it can configure — nothing was installed`);
|
|
474
|
+
const want = names.map((s) => { const pm = manifestOf(cat, s).permissions; return pm ? `${s}: network ${(pm.network || []).join(", ") || "none"}, writes ${pm.files}` : `${s}: no scripts`; });
|
|
475
|
+
console.log(`note: ${ide === "generic" ? "this target" : ide} has no sandbox skilldrop can configure. What the skills expect:\n ${want.join("\n ")}`);
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const userDir = path.join(os.homedir(), ".claude", "skills");
|
|
479
|
+
if (path.resolve(dest) === userDir)
|
|
480
|
+
die("containment applies to one project: install with --project --contain (or --local --contain). A user-scope " +
|
|
481
|
+
"install reaches every project, and skilldrop won't change your user settings — nothing was installed");
|
|
482
|
+
const base = path.dirname(dest);
|
|
483
|
+
const files = { local: path.join(base, "settings.local.json"), project: path.join(base, "settings.json"),
|
|
484
|
+
user: path.join(os.homedir(), ".claude", "settings.json") };
|
|
485
|
+
if (!flags.contain) {
|
|
486
|
+
if (gov.contained(files)) return null;
|
|
487
|
+
die("the policy requires containment: re-run with --contain, which turns on Claude Code's strict sandbox for this " +
|
|
488
|
+
"project — nothing was installed");
|
|
489
|
+
}
|
|
490
|
+
const plan = gov.containmentPlan(names.map((s) => ({ name: s, perms: manifestOf(cat, s).permissions })), files, { require });
|
|
491
|
+
if (plan.uncontainable.length)
|
|
492
|
+
die(`${plan.uncontainable.join(", ")} declare${plan.uncontainable.length === 1 ? "s" : ""} writing anywhere on disk ` +
|
|
493
|
+
"(permissions.files), so it can't run inside the sandbox. Install without --contain, or leave it out — nothing was installed");
|
|
494
|
+
return Object.assign(plan, { files });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function applyContainment(plan, dry) {
|
|
498
|
+
const f = plan.files.local;
|
|
499
|
+
const where = path.relative(process.cwd(), f) || f;
|
|
500
|
+
console.log(`\n${dry ? "would contain" : "Contained"} (RFC-0051): ${where}`);
|
|
501
|
+
if (!plan.changed) console.log(" already contained: nothing to change");
|
|
502
|
+
for (const c of plan.changes) console.log(` ${dry ? "would set" : "set"} ${c}`);
|
|
503
|
+
for (const n of plan.notes) console.log(` note: ${n}`);
|
|
504
|
+
if (dry) return;
|
|
505
|
+
if (plan.changed) {
|
|
506
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
507
|
+
fs.writeFileSync(f, JSON.stringify(plan.next, null, 2) + "\n");
|
|
508
|
+
}
|
|
509
|
+
// settings.local.json is personal: keep it out of git if nothing ignores it already
|
|
510
|
+
const root = gitRoot(process.cwd());
|
|
511
|
+
if (root && path.resolve(f).startsWith(path.resolve(root) + path.sep)) {
|
|
512
|
+
let ignored = false;
|
|
513
|
+
try { execFileSync("git", ["check-ignore", "-q", f], { cwd: root, stdio: "ignore" }); ignored = true; } catch (e) { /* not ignored */ }
|
|
514
|
+
if (!ignored) {
|
|
515
|
+
writeExclude(root, readExclude(root).entries.concat(excludeEntry(root, f, false)));
|
|
516
|
+
console.log(` listed it in ${path.relative(process.cwd(), excludePath(root)) || excludePath(root)} so git won't see it`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const auditUnit = (cat, name, kind, version, dest) =>
|
|
522
|
+
({ kind, name, version: version || null, source: cat.source, commit: cat.commit || null, pinned: !!cat.pinned, target: dest });
|
|
523
|
+
|
|
423
524
|
/* ---------- supply-chain scan (RFC-0022) ----------
|
|
424
525
|
A skill is instruction prose an agent will obey, and its scripts are code that runs on the
|
|
425
526
|
user's machine — so installing a third-party catalog is a supply-chain event. Anthropic's own
|
|
@@ -1221,8 +1322,10 @@ function install(args) {
|
|
|
1221
1322
|
}
|
|
1222
1323
|
}
|
|
1223
1324
|
gate(cat, names);
|
|
1325
|
+
const policy = enforcePolicy(args.flags, cat, names, "skill");
|
|
1224
1326
|
const { dest, ide } = target(args.flags);
|
|
1225
|
-
|
|
1327
|
+
const contain = containmentFor(args.flags, cat, names, dest, ide, policy);
|
|
1328
|
+
if (args.flags["dry-run"]) { planInstall(cat, names, dest, ide, args.flags); if (contain) applyContainment(contain, true); return; }
|
|
1226
1329
|
fs.mkdirSync(dest, { recursive: true });
|
|
1227
1330
|
const l = ledger(dest);
|
|
1228
1331
|
const pipDeps = [], suggestions = new Set(), notes = [];
|
|
@@ -1233,6 +1336,8 @@ function install(args) {
|
|
|
1233
1336
|
console.log(`installed ${s}@${m.version} -> ${dest}`);
|
|
1234
1337
|
}
|
|
1235
1338
|
saveLedger(l);
|
|
1339
|
+
if (contain) applyContainment(contain, false);
|
|
1340
|
+
gov.audit(policy, "install", names.map((s) => Object.assign(auditUnit(cat, s, "skill", lver(l.data[s]), dest), contain ? { contained: true } : {})));
|
|
1236
1341
|
console.log(`\n${names.length} skill(s) installed (${ide}).`);
|
|
1237
1342
|
if (notes.length) console.log(`\nCleanup:\n${notes.join("\n")}`);
|
|
1238
1343
|
const local = !!args.flags.local;
|
|
@@ -1354,7 +1459,10 @@ function update(args) {
|
|
|
1354
1459
|
}
|
|
1355
1460
|
}
|
|
1356
1461
|
if (dry) return planUpdate(dest, ide, l, rows, args.flags);
|
|
1357
|
-
|
|
1462
|
+
const policy = gov.load(args.flags);
|
|
1463
|
+
gov.prepare(policy);
|
|
1464
|
+
const done = [];
|
|
1465
|
+
let n = 0, conflicts = 0, refusedByPolicy = 0;
|
|
1358
1466
|
const silent = rows.filter((r) => r.current === r.installed && r.changed.length && !args.flags.changed);
|
|
1359
1467
|
for (const r of rows) {
|
|
1360
1468
|
if (r.retired) { console.log(`skip ${r.retired}\n Remove it with: skilldrop uninstall ${r.s}${targetHint(args.flags)}`); continue; }
|
|
@@ -1362,6 +1470,9 @@ function update(args) {
|
|
|
1362
1470
|
if (!isDue(r)) continue;
|
|
1363
1471
|
const problems = unitCheck(r.cat, r.s, r.loop);
|
|
1364
1472
|
if (problems.length) { console.log(`skip ${unitLabel(r)}: fails structural check in '${r.src}' (${problems[0]})`); continue; }
|
|
1473
|
+
const refused = gov.check(policy, { source: r.src, pinned: r.pinned, names: [r.s], kind: r.loop ? "loop" : "skill",
|
|
1474
|
+
worst: !r.loop && policy && policy.scan && policy.scan.max !== undefined ? worstFindings(r.cat, [r.s]) : {} });
|
|
1475
|
+
if (refused.length) { console.log(`skip ${unitLabel(r)}: the policy refuses it (${refused[0].why})`); refusedByPolicy++; continue; }
|
|
1365
1476
|
// No baseline (installed before RFC-0032, or a loop installed before 0.16.11) or --force:
|
|
1366
1477
|
// overwrite as before, and record one.
|
|
1367
1478
|
const entry = l.data[r.s];
|
|
@@ -1377,9 +1488,12 @@ function update(args) {
|
|
|
1377
1488
|
conflicts += kept.length;
|
|
1378
1489
|
}
|
|
1379
1490
|
n++;
|
|
1491
|
+
done.push(auditUnit(r.cat, r.s, r.loop ? "loop" : "skill", r.current, dest));
|
|
1380
1492
|
}
|
|
1381
1493
|
saveLedger(l);
|
|
1382
|
-
|
|
1494
|
+
gov.audit(policy, "update", done);
|
|
1495
|
+
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.`);
|
|
1496
|
+
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
1497
|
if (silent.length)
|
|
1384
1498
|
console.log(`\nNot taken: ${silent.map((r) => r.s).join(", ")} changed upstream without a version bump.` +
|
|
1385
1499
|
`\n Read the change (skilldrop diff <skill>), then take it with: skilldrop update --changed`);
|
|
@@ -1484,11 +1598,14 @@ function uninstall(args) {
|
|
|
1484
1598
|
const what = `Remove ${present.length} skill(s) from ${dest}: ${present.join(", ")}` +
|
|
1485
1599
|
(edits.length ? `\n (${edits.length} of the files have your edits: ${edits.join(", ")})` : "") + "?";
|
|
1486
1600
|
if (!confirm(what, args.flags)) return console.log("cancelled — nothing removed.");
|
|
1601
|
+
gov.prepare(gov.load(args.flags));
|
|
1487
1602
|
for (const s of present) {
|
|
1488
1603
|
fs.rmSync(path.join(dest, s), { recursive: true, force: true });
|
|
1489
1604
|
const w = wiringPath(ide, dest, s);
|
|
1490
1605
|
if (w) fs.rmSync(w, { force: true });
|
|
1491
1606
|
removeHooksFor(s, dest, ide);
|
|
1607
|
+
gov.audit(gov.load(args.flags), "uninstall", [{ kind: "skill", name: s, version: l.data[s] ? lver(l.data[s]) : null,
|
|
1608
|
+
source: l.data[s] ? lsrc(l.data[s]) : null, target: dest }]);
|
|
1492
1609
|
delete l.data[s];
|
|
1493
1610
|
console.log(`removed ${s} from ${dest}`);
|
|
1494
1611
|
}
|
|
@@ -1919,6 +2036,12 @@ function installLoops(args) {
|
|
|
1919
2036
|
if (problems.length) bad++;
|
|
1920
2037
|
}
|
|
1921
2038
|
if (bad) die(`${bad} loop(s) failed the structural check — nothing was installed`);
|
|
2039
|
+
const policy = enforcePolicy(args.flags, cat, names, "loop");
|
|
2040
|
+
if (!args.flags["no-skills"]) {
|
|
2041
|
+
const stage = [];
|
|
2042
|
+
for (const n of names) for (const sk of loopSkills(cat, n)) if (!stage.includes(sk)) stage.push(sk);
|
|
2043
|
+
enforcePolicy(args.flags, cat, stage, "skill");
|
|
2044
|
+
}
|
|
1922
2045
|
|
|
1923
2046
|
const { dest, ide } = target(args.flags);
|
|
1924
2047
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -1929,6 +2052,7 @@ function installLoops(args) {
|
|
|
1929
2052
|
console.log(`installed loop ${n} -> ${path.join(dest, n)}`);
|
|
1930
2053
|
}
|
|
1931
2054
|
saveLedger(l);
|
|
2055
|
+
gov.audit(policy, "install", names.map((n) => auditUnit(cat, n, "loop", lver(l.data[n]), dest)));
|
|
1932
2056
|
if (args.flags.local) addLocal(dest, ide, names);
|
|
1933
2057
|
|
|
1934
2058
|
const wanted = [];
|
|
@@ -1959,6 +2083,8 @@ function uninstallLoops(args) {
|
|
|
1959
2083
|
fs.rmSync(path.join(dest, n), { recursive: true, force: true });
|
|
1960
2084
|
const w = wiringPath(ide, dest, n);
|
|
1961
2085
|
if (w) fs.rmSync(w, { force: true });
|
|
2086
|
+
gov.audit(gov.load(args.flags), "uninstall", [{ kind: "loop", name: n, version: l.data[n] ? lver(l.data[n]) : null,
|
|
2087
|
+
source: l.data[n] ? lsrc(l.data[n]) : null, target: dest }]);
|
|
1962
2088
|
delete l.data[n];
|
|
1963
2089
|
console.log(`removed loop ${n} from ${dest} (stage skills left in place)`);
|
|
1964
2090
|
}
|
|
@@ -1981,6 +2107,7 @@ function installAgents(args) {
|
|
|
1981
2107
|
if (problems.length) bad++;
|
|
1982
2108
|
}
|
|
1983
2109
|
if (bad) die(`${bad} agent(s) failed the structural check — nothing was installed`);
|
|
2110
|
+
const policy = enforcePolicy(args.flags, cat, names, "agent");
|
|
1984
2111
|
|
|
1985
2112
|
const { dest, ide } = agentTarget(args.flags);
|
|
1986
2113
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -1993,6 +2120,7 @@ function installAgents(args) {
|
|
|
1993
2120
|
console.log(`installed ${a} -> ${path.join(dest, file)}`);
|
|
1994
2121
|
}
|
|
1995
2122
|
saveLedger(l);
|
|
2123
|
+
gov.audit(policy, "install", names.map((a) => auditUnit(cat, a, "agent", null, dest)));
|
|
1996
2124
|
console.log(`\n${names.length} agent(s) installed (${ide}).`);
|
|
1997
2125
|
if (warns.length) console.log(`\nTool mapping:\n${warns.join("\n")}`);
|
|
1998
2126
|
if (ide === "claude") console.log("Delegate by name, e.g. \"use the devils-advocate agent on this diff\".");
|
|
@@ -2125,6 +2253,10 @@ function validateCmd(args) {
|
|
|
2125
2253
|
const cat = resolveCatalog(args.flags.from);
|
|
2126
2254
|
const names = skillsIn(cat);
|
|
2127
2255
|
let bad = 0;
|
|
2256
|
+
// RFC-0050: this CLI accepts the policy example the contract describes (validate.py checks the
|
|
2257
|
+
// other half), so the schema and the enforcement can't drift apart unnoticed.
|
|
2258
|
+
const example = path.join(cat.dir || ROOT, "contracts", "policy.example.json");
|
|
2259
|
+
if (!args.flags.from && fs.existsSync(example)) gov.load({ policy: example });
|
|
2128
2260
|
for (const s of names) {
|
|
2129
2261
|
const problems = checkSkill(cat, s);
|
|
2130
2262
|
for (const f of scanSkill(cat, s).filter((x) => x.id.startsWith("undeclared-")))
|
|
@@ -2149,6 +2281,120 @@ const BOOTSTRAP_MARKETPLACE_KEY = "skilldrop";
|
|
|
2149
2281
|
const BOOTSTRAP_GITHUB_OWNER = "sananthanarayan";
|
|
2150
2282
|
const BOOTSTRAP_GITHUB_REPO = "skilldrop";
|
|
2151
2283
|
|
|
2284
|
+
/* ---------- inventory (RFC-0050) ----------
|
|
2285
|
+
Every skilldrop install on this machine, from each target's ledger: one report an
|
|
2286
|
+
organisation can collect, and --merge to combine many into one view. Paths under the home
|
|
2287
|
+
directory are written as ~ so a report names no one. */
|
|
2288
|
+
function inventoryTargets(flags) {
|
|
2289
|
+
if (flags.dest) return [{ dest: path.resolve(flags.dest), ide: "generic", scope: "dest" }];
|
|
2290
|
+
const out = [];
|
|
2291
|
+
for (const [ide, [proj, user]] of Object.entries(SKILL_DIRS)) {
|
|
2292
|
+
out.push({ dest: path.resolve(...proj), ide, scope: "project" });
|
|
2293
|
+
if (user) out.push({ dest: path.join(os.homedir(), ...user), ide, scope: "user" });
|
|
2294
|
+
}
|
|
2295
|
+
for (const ide of ["claude", "codex"]) for (const project of [true, false]) {
|
|
2296
|
+
try { const t = agentTarget({ ide, project }); out.push({ dest: t.dest, ide, scope: project ? "project" : "user" }); } catch (e) { /* no target */ }
|
|
2297
|
+
}
|
|
2298
|
+
const seen = new Set();
|
|
2299
|
+
return out.filter((t) => !seen.has(t.dest) && seen.add(t.dest));
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function inventory(args) {
|
|
2303
|
+
if (args.flags.merge) return mergeInventories([String(args.flags.merge), ...args._], args.flags);
|
|
2304
|
+
const policy = gov.load(args.flags);
|
|
2305
|
+
const installs = [];
|
|
2306
|
+
for (const t of inventoryTargets(args.flags)) {
|
|
2307
|
+
const l = ledger(t.dest);
|
|
2308
|
+
for (const name of Object.keys(l.data).sort()) {
|
|
2309
|
+
const e = l.data[name];
|
|
2310
|
+
const kind = isLoopEntry(e) ? "loop" : e && e.file ? "agent" : "skill";
|
|
2311
|
+
const src = lsrc(e);
|
|
2312
|
+
const pinned = isPinned(src);
|
|
2313
|
+
const refused = gov.check(policy, { source: src, pinned, names: [name], kind });
|
|
2314
|
+
installs.push({ target: gov.tilde(t.dest), scope: t.scope, ide: t.ide, kind, name, version: lver(e) || null,
|
|
2315
|
+
source: gov.tilde(src), commit: (e && e.commit) || null, pinned,
|
|
2316
|
+
edited: kind === "agent" ? 0 : editedFiles(t.dest, name, e).length,
|
|
2317
|
+
contained: t.ide === "claude" && t.scope === "project" && kind !== "agent"
|
|
2318
|
+
? gov.contained({ local: path.join(path.dirname(t.dest), "settings.local.json"), project: path.join(path.dirname(t.dest), "settings.json") }) : null,
|
|
2319
|
+
policy: policy ? (refused.length ? refused.map((x) => x.why) : "ok") : null });
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
const report = { inventory: 1, created: new Date().toISOString(), label: args.flags.label && args.flags.label !== true ? String(args.flags.label) : null,
|
|
2323
|
+
cli: readJSON(path.join(ROOT, "package.json")).version, policy: policy ? policy.hash : null, installs };
|
|
2324
|
+
if (args.flags.json) return emitJSON(report);
|
|
2325
|
+
if (!installs.length) return console.log("no skilldrop installs found on this machine (looked in every --ide target, user and project scope)");
|
|
2326
|
+
for (const i of installs)
|
|
2327
|
+
console.log(`${i.target} ${i.kind} ${i.name}@${i.version || "-"} ${i.source}${i.pinned ? " (pinned)" : ""}` +
|
|
2328
|
+
(i.edited ? ` ${i.edited} edited file(s)` : "") + (Array.isArray(i.policy) ? ` REFUSED: ${i.policy[0]}` : ""));
|
|
2329
|
+
const bad = installs.filter((i) => Array.isArray(i.policy));
|
|
2330
|
+
console.log(`\n${installs.length} install(s)` + (policy ? `, ${bad.length} the policy refuses` : "") +
|
|
2331
|
+
". For an organisation-wide view: skilldrop inventory --json --label <machine> > <file>, then skilldrop inventory --merge <files...>");
|
|
2332
|
+
if (bad.length) process.exitCode = 1;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function mergeInventories(files, flags) {
|
|
2336
|
+
const by = {};
|
|
2337
|
+
let reports = 0;
|
|
2338
|
+
for (const f of files) {
|
|
2339
|
+
let r;
|
|
2340
|
+
try { r = readJSON(f); } catch (e) { die(`${f} is not an inventory report`); }
|
|
2341
|
+
if (r.inventory !== 1 || !Array.isArray(r.installs)) die(`${f} is not an inventory report (skilldrop inventory --json)`);
|
|
2342
|
+
reports++;
|
|
2343
|
+
const who = r.label || path.basename(f);
|
|
2344
|
+
for (const i of r.installs) {
|
|
2345
|
+
const k = `${i.kind}\u0000${i.name}\u0000${i.source}`;
|
|
2346
|
+
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 });
|
|
2347
|
+
a.machines.add(who);
|
|
2348
|
+
a.versions.add(i.version || "-");
|
|
2349
|
+
if (i.source !== "bundled" && !i.pinned) a.unpinned++;
|
|
2350
|
+
if (i.edited) a.edited++;
|
|
2351
|
+
if (Array.isArray(i.policy)) a.refused++;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
const rows = Object.values(by).map((a) => Object.assign(a, { machines: [...a.machines].sort(), versions: [...a.versions].sort() }))
|
|
2355
|
+
.sort((x, y) => x.name.localeCompare(y.name));
|
|
2356
|
+
if (flags.json) return emitJSON({ reports, units: rows });
|
|
2357
|
+
for (const a of rows)
|
|
2358
|
+
console.log(`${a.kind} ${a.name} ${a.source} on ${a.machines.length} machine(s) version(s) ${a.versions.join(", ")}` +
|
|
2359
|
+
(a.unpinned ? ` ${a.unpinned} unpinned` : "") + (a.edited ? ` ${a.edited} edited` : "") + (a.refused ? ` ${a.refused} REFUSED by policy` : ""));
|
|
2360
|
+
console.log(`\n${rows.length} distinct install(s) across ${reports} report(s).`);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/* skilldrop audit verify|show [<file>] — the append-only log install, update and uninstall
|
|
2364
|
+
write when a policy turns it on (or SKILLDROP_AUDIT_LOG names a file). */
|
|
2365
|
+
function auditCmd(args) {
|
|
2366
|
+
const sub = args._.shift();
|
|
2367
|
+
const policy = gov.load(args.flags);
|
|
2368
|
+
const file = args._[0] || process.env.SKILLDROP_AUDIT_LOG || (policy && policy.audit && (policy.audit.log || "~/.config/skilldrop/audit.jsonl"));
|
|
2369
|
+
if (!file) die("no audit log: pass a file, set SKILLDROP_AUDIT_LOG, or turn it on in the policy (audit.log)");
|
|
2370
|
+
if (sub === "verify") {
|
|
2371
|
+
const r = gov.verifyAudit(file);
|
|
2372
|
+
if (r.ok) return console.log(`ok: ${r.count} entr${r.count === 1 ? "y" : "ies"}, each following the one before it`);
|
|
2373
|
+
console.log(`BROKEN at line ${r.line} of ${r.count}: ${r.why}`);
|
|
2374
|
+
process.exitCode = 1;
|
|
2375
|
+
} else if (sub === "show") {
|
|
2376
|
+
const lines = fs.readFileSync(path.resolve(file.replace(/^~(?=\/)/, os.homedir())), "utf8").split("\n").filter(Boolean);
|
|
2377
|
+
const n = args.flags.last && args.flags.last !== true ? Math.max(1, parseInt(args.flags.last, 10) || 20) : 20;
|
|
2378
|
+
for (const line of lines.slice(-n)) {
|
|
2379
|
+
const e = JSON.parse(line);
|
|
2380
|
+
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 : ""}`);
|
|
2381
|
+
}
|
|
2382
|
+
} else die("usage: skilldrop audit verify|show [<file>] [--last N]");
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
/* skilldrop verify-mirror <dir> — is an internal mirror still what skilldrop package made? */
|
|
2386
|
+
function verifyMirrorCmd(args) {
|
|
2387
|
+
const r = gov.verifyMirror(args._[0] || die("usage: skilldrop verify-mirror <dir>"));
|
|
2388
|
+
if (args.flags.json) { emitJSON(r); if (r.missing.length || r.changed.length || r.extra.length) process.exitCode = 1; return; }
|
|
2389
|
+
for (const f of r.changed) console.log(`changed ${f}`);
|
|
2390
|
+
for (const f of r.missing) console.log(`missing ${f}`);
|
|
2391
|
+
for (const f of r.extra) console.log(`added ${f} (not in MIRROR.json)`);
|
|
2392
|
+
const bad = r.changed.length + r.missing.length + r.extra.length;
|
|
2393
|
+
console.log(bad ? `\n${bad} file(s) differ from what was packaged from '${r.source}' on ${r.created}.`
|
|
2394
|
+
: `ok: all ${r.files} file(s) match what was packaged from '${r.source}' on ${r.created}.`);
|
|
2395
|
+
if (bad) process.exitCode = 1;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2152
2398
|
function bootstrap() {
|
|
2153
2399
|
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
2154
2400
|
let data = {};
|
|
@@ -2178,7 +2424,8 @@ function bootstrap() {
|
|
|
2178
2424
|
|
|
2179
2425
|
const args = parseArgs(process.argv.slice(2));
|
|
2180
2426
|
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
|
|
2427
|
+
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,
|
|
2428
|
+
inventory, audit: auditCmd, "verify-mirror": verifyMirrorCmd };
|
|
2182
2429
|
if (args.flags.version || cmd === "version") console.log(readJSON(path.join(ROOT, "package.json")).version);
|
|
2183
2430
|
else if (!cmd || cmd === "help" || args.flags.help) console.log(HELP);
|
|
2184
2431
|
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,94 @@
|
|
|
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": [
|
|
9
|
+
"version"
|
|
10
|
+
],
|
|
11
|
+
"properties": {
|
|
12
|
+
"version": {
|
|
13
|
+
"const": 1
|
|
14
|
+
},
|
|
15
|
+
"note": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Free text: who owns this policy and where to ask about it."
|
|
18
|
+
},
|
|
19
|
+
"catalogues": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"allow": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "string"
|
|
27
|
+
},
|
|
28
|
+
"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."
|
|
29
|
+
},
|
|
30
|
+
"require_pinned": {
|
|
31
|
+
"type": "boolean",
|
|
32
|
+
"description": "Every catalogue other than the bundled one must be pinned to a commit (--from <url>#<commit>)."
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"skills": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"additionalProperties": false,
|
|
39
|
+
"properties": {
|
|
40
|
+
"allow": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": {
|
|
43
|
+
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"description": "Skill, loop and agent names allowed; * matches anything. Absent: any."
|
|
46
|
+
},
|
|
47
|
+
"block": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "string"
|
|
51
|
+
},
|
|
52
|
+
"description": "Names refused even when allowed."
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"scan": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"properties": {
|
|
60
|
+
"max": {
|
|
61
|
+
"enum": [
|
|
62
|
+
"none",
|
|
63
|
+
"low",
|
|
64
|
+
"medium",
|
|
65
|
+
"high"
|
|
66
|
+
],
|
|
67
|
+
"description": "The worst supply-chain scan finding a skill may carry: none, low (🟨), medium (🟧), high (🟥). Findings in eval fixtures and example inputs never count."
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"audit": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"additionalProperties": false,
|
|
74
|
+
"description": "Present: install, update and uninstall append to a tamper-evident log.",
|
|
75
|
+
"properties": {
|
|
76
|
+
"log": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"description": "Where the log goes. Default ~/.config/skilldrop/audit.jsonl. Refused inside a git working tree."
|
|
79
|
+
},
|
|
80
|
+
"actor": {
|
|
81
|
+
"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."
|
|
82
|
+
},
|
|
83
|
+
"salt_env": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "The environment variable holding the organisation's secret salt for pseudonyms. Default SKILLDROP_AUDIT_SALT."
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"require_containment": {
|
|
90
|
+
"type": "boolean",
|
|
91
|
+
"description": "RFC-0051. true: an install must be contained (--contain, or a Claude Code project whose own settings already run the strict sandbox); targets that can't be are refused, and --contain also sets sandbox.failIfUnavailable."
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skilldrop-cli",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.28",
|
|
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",
|