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 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
- module.exports = { load, check, prepare, audit, verifyAudit, verifyMirror, sourceKey, tilde, SEVERITY };
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
- if (args.flags["dry-run"]) return planInstall(cat, names, dest, ide, args.flags);
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
- gov.audit(policy, "install", names.map((s) => auditUnit(cat, s, "skill", lver(l.data[s]), dest)));
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": ["version"],
8
+ "required": [
9
+ "version"
10
+ ],
9
11
  "properties": {
10
- "version": { "const": 1 },
11
- "note": { "type": "string", "description": "Free text: who owns this policy and where to ask about it." },
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": { "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>)." }
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": { "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." }
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": { "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." }
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": { "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." }
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.27",
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",