vet-skills 0.3.0 → 0.5.0

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.
Files changed (2) hide show
  1. package/dist/index.js +124 -19
  2. package/package.json +10 -11
package/dist/index.js CHANGED
@@ -7614,8 +7614,8 @@ var init_dist = __esm({
7614
7614
  init_dist();
7615
7615
  import { parseArgs } from "node:util";
7616
7616
  import { spawnSync } from "node:child_process";
7617
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
7618
- import { resolve, join as join3, dirname, basename } from "node:path";
7617
+ import { readFileSync as readFileSync3, existsSync as existsSync4 } from "node:fs";
7618
+ import { resolve, join as join4, dirname, basename } from "node:path";
7619
7619
  import { hostname, platform, userInfo } from "node:os";
7620
7620
  import { createHash } from "node:crypto";
7621
7621
  import { createInterface } from "node:readline";
@@ -7629,7 +7629,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync, chm
7629
7629
  import { homedir } from "node:os";
7630
7630
  import { join } from "node:path";
7631
7631
  import { randomUUID } from "node:crypto";
7632
- var VERSION = "0.3.0";
7632
+ var VERSION = "0.5.0";
7633
7633
  var HOME = process.env.VET_SKILLS_HOME ?? join(homedir(), ".vet-skills");
7634
7634
  var API = (process.env.KILLERSKILLS_API ?? "https://killerskills.ai").replace(/\/$/, "");
7635
7635
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -7778,6 +7778,11 @@ function report(body) {
7778
7778
  if (!org) return Promise.resolve({ ok: false, error: "not joined", offline: false });
7779
7779
  return call(`/api/v1/orgs/${org.slug}/agents/${org.agentId}/report`, { method: "POST", body: JSON.stringify(body), headers: orgHeaders() }, 8e3);
7780
7780
  }
7781
+ function confirmRemoval(removalId, body) {
7782
+ const org = readOrg();
7783
+ if (!org) return Promise.resolve({ ok: false, error: "not joined", offline: false });
7784
+ return call(`/api/v1/orgs/${org.slug}/agents/${org.agentId}/removals/${removalId}/confirm`, { method: "POST", body: JSON.stringify(body), headers: orgHeaders() });
7785
+ }
7781
7786
  function publish(tgz) {
7782
7787
  const org = readOrg();
7783
7788
  if (!org) return Promise.resolve({ ok: false, status: 0, error: "not enrolled in an organization (vet-skills join <org> --token \u2026)", offline: false });
@@ -7854,7 +7859,7 @@ async function check(q, opts = {}) {
7854
7859
  let source = "api";
7855
7860
  let offline = false;
7856
7861
  let cacheAge;
7857
- let res = await resolveSkill(q);
7862
+ let res = await resolveSkill(q, opts.registry);
7858
7863
  if (!res.ok && !res.offline && org) {
7859
7864
  const bare = q.replace(/^[^/]+\//, "");
7860
7865
  const priv = await resolveSkill(`${org.slug}/${bare}`, "private");
@@ -7986,9 +7991,59 @@ function restrictionText(r) {
7986
7991
  }
7987
7992
 
7988
7993
  // src/hook.ts
7989
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
7994
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
7995
+ import { homedir as homedir3 } from "node:os";
7996
+ import { join as join3 } from "node:path";
7997
+
7998
+ // src/sync.ts
7999
+ import { existsSync as existsSync2, rmSync } from "node:fs";
7990
8000
  import { homedir as homedir2 } from "node:os";
7991
8001
  import { join as join2 } from "node:path";
8002
+ function skillDirs(name) {
8003
+ const bare = name.replace(/^.*\//, "").replace(/@.*$/, "");
8004
+ return [join2(homedir2(), ".claude", "skills", bare), join2(process.cwd(), ".claude", "skills", bare), join2(homedir2(), ".claude", "plugins", bare)];
8005
+ }
8006
+ async function applyRemovals(pending, log = () => {
8007
+ }) {
8008
+ let done = 0;
8009
+ const byRequest = /* @__PURE__ */ new Map();
8010
+ for (const r of pending) byRequest.set(r.removal_id, [...byRequest.get(r.removal_id) ?? [], r]);
8011
+ for (const [removalId, items] of byRequest) {
8012
+ const detail = [];
8013
+ let failed = false;
8014
+ for (const it of items) {
8015
+ const dirs = skillDirs(it.skill).filter((d) => existsSync2(d));
8016
+ if (!dirs.length) {
8017
+ detail.push(`${it.skill}: not on disk`);
8018
+ continue;
8019
+ }
8020
+ try {
8021
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
8022
+ detail.push(`${it.skill}: removed ${dirs.join(", ")}`);
8023
+ } catch (e) {
8024
+ failed = true;
8025
+ detail.push(`${it.skill}: ${e.message}`);
8026
+ }
8027
+ }
8028
+ const res = await confirmRemoval(removalId, { status: failed ? "failed" : "confirmed", detail: detail.join("; ") });
8029
+ if (res.ok) {
8030
+ done += items.length;
8031
+ log(`${failed ? "!" : "\u2713"} ${items.map((i) => i.skill).join(", ")} ${failed ? "removal failed" : "removed"} (${removalId})`);
8032
+ } else log(`! could not confirm removal ${removalId}: ${res.error}`);
8033
+ }
8034
+ return done;
8035
+ }
8036
+ async function sync(log = () => {
8037
+ }) {
8038
+ const org = readOrg();
8039
+ if (!org) return { ok: false, removed: 0, error: "not joined" };
8040
+ const res = await report({ installed: [], removed: [], hook_version: void 0 });
8041
+ if (!res.ok) return { ok: false, removed: 0, error: res.error };
8042
+ const removed = await applyRemovals(res.data.pending_removals, log);
8043
+ return { ok: true, removed };
8044
+ }
8045
+
8046
+ // src/hook.ts
7992
8047
  var INSTALL_PATTERNS = [
7993
8048
  { re: /(?:npx\s+(?:-y\s+)?)?skills\s+add\s+(\S+)/, kind: "skill" },
7994
8049
  { re: /claude\s+plugin\s+install\s+(\S+)/, kind: "plugin" },
@@ -8016,10 +8071,41 @@ function extractRemoteMcp(command) {
8016
8071
  const name = positional.find((a) => a !== url) ?? new URL(url).hostname;
8017
8072
  return { name, url };
8018
8073
  }
8074
+ function extractStdioPackage(command) {
8075
+ const tail = command.split(/\s--\s/)[1];
8076
+ if (!tail) return null;
8077
+ const args = (tail.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((a) => a.replace(/^(['"])(.*)\1$/, "$2"));
8078
+ const [bin, ...rest2] = args;
8079
+ const strip = (s) => s.replace(/(.)@[^@/]+$/, "$1");
8080
+ if (/^(npx|bunx|pnpm)$/.test(bin ?? "")) {
8081
+ const pkg = rest2.find((a) => !a.startsWith("-") && a !== "dlx" && a !== "exec");
8082
+ return pkg ? { q: strip(pkg).replace(/^@/, ""), registry: "npm" } : null;
8083
+ }
8084
+ if (/^(uvx|pipx)$/.test(bin ?? "")) {
8085
+ const pkg = rest2.find((a) => !a.startsWith("-") && a !== "run");
8086
+ return pkg ? { q: strip(pkg).toLowerCase().replace(/\[.*\]$/, ""), registry: "pypi" } : null;
8087
+ }
8088
+ if (bin === "docker") {
8089
+ const img = rest2.find((a) => /^(ghcr\.io\/|docker\.io\/)?[\w.-]+\/[\w.-]+(:[\w.-]+)?$/.test(a) && !a.startsWith("-"));
8090
+ if (!img) return null;
8091
+ const m = img.replace(/:[\w.-]+$/, "").match(/mcp\/([\w.-]+)$/);
8092
+ return m ? { q: m[1], registry: "docker" } : null;
8093
+ }
8094
+ return null;
8095
+ }
8019
8096
  function extractInstall(command) {
8020
8097
  for (const p of INSTALL_PATTERNS) {
8021
8098
  const m = command.match(p.re);
8022
- if (m) return { q: m[1].replace(/^['"]|['"]$/g, ""), kind: p.kind };
8099
+ if (!m) continue;
8100
+ const q = m[1].replace(/^['"]|['"]$/g, "");
8101
+ if (p.kind === "mcp") {
8102
+ const pkg = extractStdioPackage(command);
8103
+ if (pkg) return { q: pkg.q, kind: "mcp", registry: pkg.registry };
8104
+ const remote = extractRemoteMcp(command);
8105
+ if (remote) return { q: remote.name, kind: "mcp" };
8106
+ }
8107
+ if (p.kind === "plugin") return { q: q.split("@")[0], kind: "plugin", registry: "plugins" };
8108
+ return { q, kind: p.kind };
8023
8109
  }
8024
8110
  return null;
8025
8111
  }
@@ -8032,14 +8118,17 @@ async function hookEval(stdin) {
8032
8118
  }
8033
8119
  if (payload.tool_name !== "Bash" || !payload.tool_input?.command) return 0;
8034
8120
  const cmd2 = payload.tool_input.command;
8035
- if (/^\s*vet-skills\s+(check|why|status|policy|detonate|hook|join|leave|block)\b/.test(cmd2)) return 0;
8121
+ if (/^\s*vet-skills\s+(check|why|status|sync|policy|detonate|hook|join|leave|block|publish)\b/.test(cmd2)) return 0;
8036
8122
  const found = extractInstall(cmd2);
8037
8123
  if (!found) return 0;
8038
- const r = await check(found.q, { quiet: true, agentKind: "claude_code" });
8124
+ const r = await check(found.q, { quiet: true, agentKind: "claude_code", registry: found.registry });
8039
8125
  const out = renderOutcome(r);
8040
8126
  const remote = found.kind === "mcp" ? extractRemoteMcp(cmd2) : null;
8041
8127
  const watch = async () => {
8042
- if (remote && readOrg()) await report({ installed: [], removed: [], mcp_servers: [remote] }).catch(() => null);
8128
+ if (!readOrg()) return;
8129
+ const res = await report({ installed: [], removed: [], ...remote ? { mcp_servers: [remote] } : {} }).catch(() => null);
8130
+ if (res?.ok && res.data.pending_removals.length) await applyRemovals(res.data.pending_removals, (l) => process.stderr.write(`vet-skills \xB7 ${l}
8131
+ `)).catch(() => 0);
8043
8132
  };
8044
8133
  switch (r.decision.decision) {
8045
8134
  case "allow":
@@ -8073,11 +8162,11 @@ ${out}
8073
8162
  return 0;
8074
8163
  }
8075
8164
  function installClaudeCodeHook() {
8076
- const dir = join2(homedir2(), ".claude");
8077
- const path = join2(dir, "settings.json");
8078
- if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
8165
+ const dir = join3(homedir3(), ".claude");
8166
+ const path = join3(dir, "settings.json");
8167
+ if (!existsSync3(dir)) mkdirSync2(dir, { recursive: true });
8079
8168
  let settings = {};
8080
- if (existsSync2(path)) {
8169
+ if (existsSync3(path)) {
8081
8170
  try {
8082
8171
  settings = JSON.parse(readFileSync2(path, "utf8"));
8083
8172
  } catch {
@@ -8102,6 +8191,7 @@ var { values, positionals } = parseArgs({
8102
8191
  org: { type: "string" },
8103
8192
  yes: { type: "boolean", short: "y" },
8104
8193
  json: { type: "boolean" },
8194
+ ci: { type: "boolean" },
8105
8195
  email: { type: "string" },
8106
8196
  help: { type: "boolean", short: "h" },
8107
8197
  version: { type: "boolean", short: "v" },
@@ -8126,11 +8216,12 @@ var USAGE = `vet-skills ${VERSION} \u2014 check agent skills and MCP servers bef
8126
8216
  vet-skills detonate <path> [--publisher h] upload a local bundle to the sandbox and print its verdict
8127
8217
  vet-skills publish <path> detonate and publish to your organization's private registry
8128
8218
  vet-skills why <skill> last decision and the rule behind it
8129
- vet-skills join <org> --token <t> [--email you@co] enroll this machine in an org
8219
+ vet-skills join <org> --token <t> [--email you@co] [--ci] enroll this machine (or a CI runner, seat-free) in an org
8130
8220
  vet-skills leave back to the personal policy
8131
8221
  vet-skills policy show|set <file> view or replace the local policy
8132
8222
  vet-skills block <skill> [--reason] add to the personal block list
8133
8223
  vet-skills status org, policy version, last report, cache size
8224
+ vet-skills sync pull the org policy and apply pending removals
8134
8225
  vet-skills hook install claude-code register the Claude Code PreToolUse hook
8135
8226
  vet-skills hook eval (called by the agent) read hook JSON on stdin
8136
8227
 
@@ -8154,7 +8245,7 @@ async function main() {
8154
8245
  usage();
8155
8246
  return 1;
8156
8247
  }
8157
- const r = await check(rest[0], { agentKind: values.agent });
8248
+ const r = await check(rest[0], { agentKind: values.agent, registry: values.registry });
8158
8249
  if (values.json) {
8159
8250
  console.log(JSON.stringify({ decision: r.decision, resolved: r.resolved, source: r.source, offline: r.offline }, null, 2));
8160
8251
  return r.decision.decision === "block" ? 2 : 0;
@@ -8211,7 +8302,7 @@ async function main() {
8211
8302
  return 1;
8212
8303
  }
8213
8304
  const dir = resolve(rest[0]);
8214
- if (!existsSync3(join3(dir, "SKILL.md")) && !existsSync3(join3(dir, "package.json"))) {
8305
+ if (!existsSync4(join4(dir, "SKILL.md")) && !existsSync4(join4(dir, "package.json"))) {
8215
8306
  console.log(`${c.blocked("\u2717")} ${dir} has no SKILL.md or package.json`);
8216
8307
  return 1;
8217
8308
  }
@@ -8254,7 +8345,7 @@ async function main() {
8254
8345
  return 1;
8255
8346
  }
8256
8347
  const dir = resolve(rest[0]);
8257
- if (!existsSync3(join3(dir, "SKILL.md"))) {
8348
+ if (!existsSync4(join4(dir, "SKILL.md"))) {
8258
8349
  console.log(`${c.blocked("\u2717")} ${dir} has no SKILL.md (name and version come from its frontmatter)`);
8259
8350
  return 1;
8260
8351
  }
@@ -8306,7 +8397,8 @@ async function main() {
8306
8397
  agent_kind: values.agent ?? "other",
8307
8398
  hook_version: VERSION,
8308
8399
  os: platform(),
8309
- installed: []
8400
+ installed: [],
8401
+ ci: values.ci === true
8310
8402
  });
8311
8403
  if (!res.ok) {
8312
8404
  console.error(`${c.blocked("\u2715")} Could not join ${slug}: ${res.error}`);
@@ -8391,11 +8483,24 @@ async function main() {
8391
8483
  console.log(`${c.verified("\u2713")} Policy updated to v${res.data.policy_version}`);
8392
8484
  }
8393
8485
  for (const a of res.data.pending_approvals) console.log(`approval ${a.skill} \xB7 ${a.status} \xB7 ${a.request_id}`);
8394
- for (const r of res.data.pending_removals) console.log(`${c.caution("!")} removal requested: ${r.skill} (${r.removal_id})`);
8486
+ if (res.data.pending_removals.length) await applyRemovals(res.data.pending_removals, (l) => console.log(l));
8395
8487
  } else console.log(c.caution(`report failed: ${res.error}`));
8396
8488
  }
8397
8489
  return 0;
8398
8490
  }
8491
+ case "sync": {
8492
+ if (!readOrg()) {
8493
+ console.log(c.muted("Not joined to an org; nothing to sync."));
8494
+ return 0;
8495
+ }
8496
+ const r = await sync((l) => console.log(l));
8497
+ if (!r.ok) {
8498
+ console.log(c.caution(`sync failed: ${r.error}`));
8499
+ return 1;
8500
+ }
8501
+ console.log(`${c.verified("\u2713")} in sync \xB7 ${r.removed} removal${r.removed === 1 ? "" : "s"} applied`);
8502
+ return 0;
8503
+ }
8399
8504
  case "hook": {
8400
8505
  const sub = rest[0];
8401
8506
  if (sub === "eval") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-skills",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Vet agent skills and MCP servers before they run. Verdicts from KillerSkills, policy from your org.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,21 +10,14 @@
10
10
  "dist",
11
11
  "README.md"
12
12
  ],
13
- "scripts": {
14
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/index.js --banner:js=\"import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);\" && node scripts/shebang.mjs",
15
- "dev": "tsx src/index.ts",
16
- "test": "node --import tsx --test test/*.test.ts",
17
- "typecheck": "tsc -p tsconfig.json --noEmit",
18
- "prepublishOnly": "pnpm build && node dist/index.js --version"
19
- },
20
13
  "dependencies": {},
21
14
  "devDependencies": {
22
- "@killerskills/policy": "workspace:*",
23
15
  "yaml": "^2.6.0",
24
16
  "@types/node": "^20",
25
17
  "tsx": "^4.19.0",
26
18
  "typescript": "^5",
27
- "esbuild": "^0.28.2"
19
+ "esbuild": "^0.28.2",
20
+ "@killerskills/policy": "0.1.0"
28
21
  },
29
22
  "engines": {
30
23
  "node": ">=20"
@@ -49,5 +42,11 @@
49
42
  ],
50
43
  "publishConfig": {
51
44
  "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/index.js --banner:js=\"import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);\" && node scripts/shebang.mjs",
48
+ "dev": "tsx src/index.ts",
49
+ "test": "node --import tsx --test test/*.test.ts",
50
+ "typecheck": "tsc -p tsconfig.json --noEmit"
52
51
  }
53
- }
52
+ }