skilldrop-cli 0.16.27 → 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 +63 -2
- package/bin/skilldrop.js +65 -2
- package/contracts/policy.schema.json +59 -11
- package/package.json +1 -1
package/bin/governance.js
CHANGED
|
@@ -16,7 +16,7 @@ const { execFileSync } = require("child_process");
|
|
|
16
16
|
const SEVERITY = { "🟥": 3, "🟧": 2, "🟨": 1 }; // scan findings; ⚪ (fixtures) never count
|
|
17
17
|
const MAX = { none: 0, low: 1, medium: 2, high: 3 }; // policy scan.max: the worst finding allowed
|
|
18
18
|
const KEYS = {
|
|
19
|
-
"": ["version", "catalogues", "skills", "scan", "audit", "note"],
|
|
19
|
+
"": ["version", "catalogues", "skills", "scan", "audit", "require_containment", "note"],
|
|
20
20
|
catalogues: ["allow", "require_pinned"],
|
|
21
21
|
skills: ["allow", "block"],
|
|
22
22
|
scan: ["max"],
|
|
@@ -60,6 +60,8 @@ function load(flags) {
|
|
|
60
60
|
list(p.skills && p.skills.block, "skills.block");
|
|
61
61
|
if (p.catalogues && p.catalogues.require_pinned !== undefined && typeof p.catalogues.require_pinned !== "boolean")
|
|
62
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");
|
|
63
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(", ")}`);
|
|
64
66
|
if (p.audit) {
|
|
65
67
|
const a = p.audit.actor;
|
|
@@ -209,4 +211,63 @@ function verifyMirror(dir) {
|
|
|
209
211
|
return { source: m.source, created: m.created, files: Object.keys(want).length, missing, changed, extra };
|
|
210
212
|
}
|
|
211
213
|
|
|
212
|
-
|
|
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
|
@@ -105,6 +105,9 @@ Install/update/uninstall targets (pick one):
|
|
|
105
105
|
repo you don't own without touching its tracked files or .gitignore
|
|
106
106
|
|
|
107
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)
|
|
108
111
|
--policy <file> the policy to enforce; also SKILLDROP_POLICY, else ~/.config/skilldrop/policy.json.
|
|
109
112
|
It can allow only some catalogues (and require them pinned), allow or block skills,
|
|
110
113
|
cap the supply-chain scan's findings, and turn on an audit log. A policy that is
|
|
@@ -459,6 +462,62 @@ function enforcePolicy(flags, cat, names, kind) {
|
|
|
459
462
|
return policy;
|
|
460
463
|
}
|
|
461
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
|
+
|
|
462
521
|
const auditUnit = (cat, name, kind, version, dest) =>
|
|
463
522
|
({ kind, name, version: version || null, source: cat.source, commit: cat.commit || null, pinned: !!cat.pinned, target: dest });
|
|
464
523
|
|
|
@@ -1265,7 +1324,8 @@ function install(args) {
|
|
|
1265
1324
|
gate(cat, names);
|
|
1266
1325
|
const policy = enforcePolicy(args.flags, cat, names, "skill");
|
|
1267
1326
|
const { dest, ide } = target(args.flags);
|
|
1268
|
-
|
|
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; }
|
|
1269
1329
|
fs.mkdirSync(dest, { recursive: true });
|
|
1270
1330
|
const l = ledger(dest);
|
|
1271
1331
|
const pipDeps = [], suggestions = new Set(), notes = [];
|
|
@@ -1276,7 +1336,8 @@ function install(args) {
|
|
|
1276
1336
|
console.log(`installed ${s}@${m.version} -> ${dest}`);
|
|
1277
1337
|
}
|
|
1278
1338
|
saveLedger(l);
|
|
1279
|
-
|
|
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 } : {})));
|
|
1280
1341
|
console.log(`\n${names.length} skill(s) installed (${ide}).`);
|
|
1281
1342
|
if (notes.length) console.log(`\nCleanup:\n${notes.join("\n")}`);
|
|
1282
1343
|
const local = !!args.flags.local;
|
|
@@ -2253,6 +2314,8 @@ function inventory(args) {
|
|
|
2253
2314
|
installs.push({ target: gov.tilde(t.dest), scope: t.scope, ide: t.ide, kind, name, version: lver(e) || null,
|
|
2254
2315
|
source: gov.tilde(src), commit: (e && e.commit) || null, pinned,
|
|
2255
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,
|
|
2256
2319
|
policy: policy ? (refused.length ? refused.map((x) => x.why) : "ok") : null });
|
|
2257
2320
|
}
|
|
2258
2321
|
}
|
|
@@ -5,31 +5,67 @@
|
|
|
5
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
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
|
-
"required": [
|
|
8
|
+
"required": [
|
|
9
|
+
"version"
|
|
10
|
+
],
|
|
9
11
|
"properties": {
|
|
10
|
-
"version": {
|
|
11
|
-
|
|
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
|
+
},
|
|
12
19
|
"catalogues": {
|
|
13
20
|
"type": "object",
|
|
14
21
|
"additionalProperties": false,
|
|
15
22
|
"properties": {
|
|
16
|
-
"allow": {
|
|
17
|
-
|
|
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
|
+
}
|
|
18
34
|
}
|
|
19
35
|
},
|
|
20
36
|
"skills": {
|
|
21
37
|
"type": "object",
|
|
22
38
|
"additionalProperties": false,
|
|
23
39
|
"properties": {
|
|
24
|
-
"allow": {
|
|
25
|
-
|
|
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
|
+
}
|
|
26
54
|
}
|
|
27
55
|
},
|
|
28
56
|
"scan": {
|
|
29
57
|
"type": "object",
|
|
30
58
|
"additionalProperties": false,
|
|
31
59
|
"properties": {
|
|
32
|
-
"max": {
|
|
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
|
+
}
|
|
33
69
|
}
|
|
34
70
|
},
|
|
35
71
|
"audit": {
|
|
@@ -37,10 +73,22 @@
|
|
|
37
73
|
"additionalProperties": false,
|
|
38
74
|
"description": "Present: install, update and uninstall append to a tamper-evident log.",
|
|
39
75
|
"properties": {
|
|
40
|
-
"log": {
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
|
43
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."
|
|
44
92
|
}
|
|
45
93
|
}
|
|
46
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",
|