@claude-flow/cli 3.63.9 → 3.65.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 (90) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/catalog-manifest.json +3 -3
  3. package/dist/src/commands/cost.d.ts +18 -0
  4. package/dist/src/commands/cost.js +195 -0
  5. package/dist/src/commands/doctor-auto-approve.d.ts +9 -0
  6. package/dist/src/commands/doctor-auto-approve.js +32 -0
  7. package/dist/src/commands/doctor.js +28 -2
  8. package/dist/src/commands/hive-mind-worker.d.ts +2 -0
  9. package/dist/src/commands/hive-mind-worker.js +9 -3
  10. package/dist/src/commands/hive-mind.js +15 -1
  11. package/dist/src/commands/hooks.js +45 -0
  12. package/dist/src/commands/index.js +2 -0
  13. package/dist/src/commands/init.js +76 -2
  14. package/dist/src/commands/mods.js +13 -0
  15. package/dist/src/commands/plugins-paths.d.ts +13 -0
  16. package/dist/src/commands/plugins-paths.js +55 -0
  17. package/dist/src/commands/plugins-verify.d.ts +9 -0
  18. package/dist/src/commands/plugins-verify.js +92 -0
  19. package/dist/src/commands/plugins.js +13 -1
  20. package/dist/src/cost/attribution.d.ts +35 -0
  21. package/dist/src/cost/attribution.js +101 -0
  22. package/dist/src/cost/budget-file.d.ts +57 -0
  23. package/dist/src/cost/budget-file.js +113 -0
  24. package/dist/src/cost/litellm.d.ts +69 -0
  25. package/dist/src/cost/litellm.js +135 -0
  26. package/dist/src/init/executor.d.ts +7 -2
  27. package/dist/src/init/executor.js +64 -10
  28. package/dist/src/init/helper-commands.d.ts +16 -0
  29. package/dist/src/init/helper-commands.js +73 -0
  30. package/dist/src/init/risky-settings-gate.d.ts +39 -0
  31. package/dist/src/init/risky-settings-gate.js +130 -0
  32. package/dist/src/init/settings-risk-scanner.d.ts +35 -0
  33. package/dist/src/init/settings-risk-scanner.js +85 -0
  34. package/dist/src/init/types.d.ts +12 -0
  35. package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
  36. package/dist/src/mcp-tools/capability-brain.js +1 -1
  37. package/dist/src/mcp-tools/hooks-tools.d.ts +1 -0
  38. package/dist/src/mcp-tools/hooks-tools.js +38 -0
  39. package/dist/src/memory/verified-guards.d.ts +70 -0
  40. package/dist/src/memory/verified-guards.js +101 -0
  41. package/dist/src/memory/verified-outcomes.d.ts +228 -0
  42. package/dist/src/memory/verified-outcomes.js +372 -0
  43. package/dist/src/memory/verified-reverify.d.ts +63 -0
  44. package/dist/src/memory/verified-reverify.js +174 -0
  45. package/dist/src/missions/schemas.d.ts +36 -36
  46. package/dist/src/mods/apply.d.ts +2 -0
  47. package/dist/src/mods/apply.js +68 -8
  48. package/dist/src/mods/command-registry/schema.d.ts +222 -222
  49. package/dist/src/mods/compact-snapshots.d.ts +21 -0
  50. package/dist/src/mods/compact-snapshots.js +45 -0
  51. package/dist/src/mods/headless-check.d.ts +58 -0
  52. package/dist/src/mods/headless-check.js +137 -0
  53. package/dist/src/mods/headless-plugins.d.ts +44 -0
  54. package/dist/src/mods/headless-plugins.js +92 -0
  55. package/dist/src/mods/install-trust.d.ts +28 -0
  56. package/dist/src/mods/install-trust.js +127 -0
  57. package/dist/src/mods/install.d.ts +19 -0
  58. package/dist/src/mods/install.js +38 -1
  59. package/dist/src/mods/plugin-paths.d.ts +60 -0
  60. package/dist/src/mods/plugin-paths.js +131 -0
  61. package/dist/src/mods/plugin-repair.d.ts +8 -0
  62. package/dist/src/mods/plugin-repair.js +7 -0
  63. package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
  64. package/dist/src/plugins/marketplace-trust/scan.js +255 -0
  65. package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
  66. package/dist/src/plugins/marketplace-trust/signature.js +140 -0
  67. package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
  68. package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
  69. package/dist/src/services/headless-worker-executor.d.ts +2 -0
  70. package/dist/src/services/headless-worker-executor.js +13 -3
  71. package/package.json +2 -1
  72. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
  73. package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
  74. package/plugins/ruflo-metaharness/README.md +14 -1
  75. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
  76. package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
  77. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
  78. package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
  79. package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
  80. package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
  81. package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
  82. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
  83. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
  84. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  85. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
  86. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
  87. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
  88. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  89. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
  90. package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +1 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ADR-510 — where a ruflo plugin lives on disk, for a headless `claude -p`
3
+ * worker (`--plugin-dir <path>`) or an Agent SDK session
4
+ * (`plugins: [{ type: "local", path }]`).
5
+ *
6
+ * `claude -p` on a fresh config loads no marketplace plugins: the marketplace
7
+ * is cloned only by an interactive, trusted start. So a worker has to be told
8
+ * where each plugin is. Sources, in order:
9
+ *
10
+ * 1. a `--source local` directory marketplace (a ruflo checkout) that Claude
11
+ * Code itself records in the user-level `known_marketplaces.json`, i.e.
12
+ * one the user added through an interactive, trusted start;
13
+ * 2. the marketplace clone (`<config>/plugins/marketplaces/ruflo/...`);
14
+ * 3. the install cache (`<config>/plugins/cache/ruflo/<name>/<version>`).
15
+ *
16
+ * A folder without `.claude-plugin/plugin.json` is never returned: the plugin
17
+ * is reported missing instead. Read-only; the filesystem is injectable.
18
+ *
19
+ * Trust: nothing inside the project decides the path. A repository's own
20
+ * `.claude/settings.json` / `settings.local.json` (`extraKnownMarketplaces`)
21
+ * is never read here: interactive Claude Code gates a project-declared
22
+ * marketplace behind workspace trust, but `--plugin-dir` loads with no prompt
23
+ * and replaces the user's installed copy of the same name. Letting a cloned
24
+ * repo point the headless worker at its own folder would run repo-supplied
25
+ * function hooks in every daemon worker, in place of the user's guard.
26
+ */
27
+ import { type ReadFs } from './plugin-resolve.js';
28
+ export type PluginSource = 'local' | 'clone' | 'cache';
29
+ export interface ResolvedPlugin {
30
+ name: string;
31
+ /** Absolute plugin folder (the one holding `.claude-plugin/plugin.json`). */
32
+ path: string;
33
+ source: PluginSource;
34
+ }
35
+ export interface MissingPlugin {
36
+ name: string;
37
+ reason: string;
38
+ }
39
+ export interface PluginPaths {
40
+ resolved: ResolvedPlugin[];
41
+ missing: MissingPlugin[];
42
+ }
43
+ /** ReadFs plus a directory listing (for the install cache's version folders). */
44
+ export interface PluginFs extends ReadFs {
45
+ list(path: string): string[];
46
+ }
47
+ export declare const nodePluginFs: PluginFs;
48
+ export interface ResolveOptions {
49
+ /**
50
+ * Accepted for callers' convenience but deliberately not consulted: the
51
+ * project's own settings never choose a headless plugin folder (see "Trust").
52
+ */
53
+ projectRoot?: string;
54
+ env?: NodeJS.ProcessEnv;
55
+ home?: string;
56
+ fs?: PluginFs;
57
+ }
58
+ /** Resolve each plugin name (e.g. `ruflo-mods`) to an absolute folder, or report it missing. */
59
+ export declare function resolvePluginPaths(names: readonly string[], opts?: ResolveOptions): PluginPaths;
60
+ //# sourceMappingURL=plugin-paths.d.ts.map
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ADR-510 — where a ruflo plugin lives on disk, for a headless `claude -p`
3
+ * worker (`--plugin-dir <path>`) or an Agent SDK session
4
+ * (`plugins: [{ type: "local", path }]`).
5
+ *
6
+ * `claude -p` on a fresh config loads no marketplace plugins: the marketplace
7
+ * is cloned only by an interactive, trusted start. So a worker has to be told
8
+ * where each plugin is. Sources, in order:
9
+ *
10
+ * 1. a `--source local` directory marketplace (a ruflo checkout) that Claude
11
+ * Code itself records in the user-level `known_marketplaces.json`, i.e.
12
+ * one the user added through an interactive, trusted start;
13
+ * 2. the marketplace clone (`<config>/plugins/marketplaces/ruflo/...`);
14
+ * 3. the install cache (`<config>/plugins/cache/ruflo/<name>/<version>`).
15
+ *
16
+ * A folder without `.claude-plugin/plugin.json` is never returned: the plugin
17
+ * is reported missing instead. Read-only; the filesystem is injectable.
18
+ *
19
+ * Trust: nothing inside the project decides the path. A repository's own
20
+ * `.claude/settings.json` / `settings.local.json` (`extraKnownMarketplaces`)
21
+ * is never read here: interactive Claude Code gates a project-declared
22
+ * marketplace behind workspace trust, but `--plugin-dir` loads with no prompt
23
+ * and replaces the user's installed copy of the same name. Letting a cloned
24
+ * repo point the headless worker at its own folder would run repo-supplied
25
+ * function hooks in every daemon worker, in place of the user's guard.
26
+ */
27
+ import { existsSync, readdirSync } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
30
+ import { MARKETPLACE_NAME } from './install.js';
31
+ import { claudeConfigDir, marketplaceState, nodeReadFs, pluginManifestIn } from './plugin-resolve.js';
32
+ export const nodePluginFs = {
33
+ ...nodeReadFs,
34
+ list: (p) => {
35
+ try {
36
+ return existsSync(p) ? readdirSync(p) : [];
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ },
42
+ };
43
+ const MANIFEST = join('.claude-plugin', 'plugin.json');
44
+ function readJson(fs, path) {
45
+ const text = fs.readText(path);
46
+ if (text === undefined)
47
+ return undefined;
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ /**
57
+ * The directory marketplace Claude Code knows as `ruflo`, from the user-level
58
+ * `known_marketplaces.json` only (see "Trust" above). A relative path is
59
+ * rejected: Claude Code records absolute ones, and a relative path would
60
+ * resolve against whatever directory the worker happens to start in.
61
+ */
62
+ function localMarketplaceDir(configDir, fs) {
63
+ const market = marketplaceState(configDir, fs);
64
+ if (market.source?.source !== 'directory')
65
+ return null;
66
+ const declared = typeof market.source.path === 'string' ? market.source.path : null;
67
+ const dir = market.location ?? declared;
68
+ return dir && isAbsolute(dir) && (!declared || isAbsolute(declared)) ? dir : null;
69
+ }
70
+ /** Numeric-aware version compare ("0.10.0" > "0.9.3"); non-numeric parts compare as text. */
71
+ function compareVersions(a, b) {
72
+ const pa = a.split(/[.-]/);
73
+ const pb = b.split(/[.-]/);
74
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
75
+ const x = pa[i] ?? '';
76
+ const y = pb[i] ?? '';
77
+ const nx = /^\d+$/.test(x) ? Number(x) : NaN;
78
+ const ny = /^\d+$/.test(y) ? Number(y) : NaN;
79
+ const d = !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y);
80
+ if (d !== 0)
81
+ return d;
82
+ }
83
+ return 0;
84
+ }
85
+ /** The cached install: installed_plugins.json's path first, else the highest version folder. */
86
+ function cachedPluginDir(configDir, name, fs) {
87
+ const record = readJson(fs, join(configDir, 'plugins', 'installed_plugins.json'));
88
+ const plugins = isRecord(record) && isRecord(record.plugins) ? record.plugins : undefined;
89
+ const entries = plugins && Array.isArray(plugins[`${name}@${MARKETPLACE_NAME}`]) ? plugins[`${name}@${MARKETPLACE_NAME}`] : [];
90
+ for (const e of entries) {
91
+ if (isRecord(e) && typeof e.installPath === 'string' && fs.exists(join(e.installPath, MANIFEST)))
92
+ return e.installPath;
93
+ }
94
+ const base = join(configDir, 'plugins', 'cache', MARKETPLACE_NAME, name);
95
+ const versions = fs.list(base).filter((v) => fs.exists(join(base, v, MANIFEST))).sort(compareVersions);
96
+ return versions.length > 0 ? join(base, versions[versions.length - 1]) : null;
97
+ }
98
+ /** Plugin folder from a marketplace root (its marketplace.json `source`, else plugins/<name>). */
99
+ function pluginDirIn(root, name, fs) {
100
+ const manifest = pluginManifestIn(root, `${name}@${MARKETPLACE_NAME}`, fs);
101
+ return fs.exists(manifest) ? dirname(dirname(manifest)) : null;
102
+ }
103
+ /** Resolve each plugin name (e.g. `ruflo-mods`) to an absolute folder, or report it missing. */
104
+ export function resolvePluginPaths(names, opts = {}) {
105
+ const env = opts.env ?? process.env;
106
+ const fs = opts.fs ?? nodePluginFs;
107
+ const configDir = claudeConfigDir(env, opts.home ?? homedir());
108
+ const local = localMarketplaceDir(configDir, fs);
109
+ const market = marketplaceState(configDir, fs);
110
+ const clone = market.source?.source === 'directory' ? null : market.location;
111
+ const out = { resolved: [], missing: [] };
112
+ for (const raw of names) {
113
+ const name = raw.split('@')[0].trim();
114
+ if (!name)
115
+ continue;
116
+ const fromLocal = local ? pluginDirIn(local, name, fs) : null;
117
+ const fromClone = !fromLocal && clone ? pluginDirIn(clone, name, fs) : null;
118
+ const fromCache = !fromLocal && !fromClone ? cachedPluginDir(configDir, name, fs) : null;
119
+ const hit = [[fromLocal, 'local'], [fromClone, 'clone'], [fromCache, 'cache']];
120
+ const found = hit.find(([p]) => p !== null);
121
+ if (found) {
122
+ out.resolved.push({ name, path: resolve(found[0]), source: found[1] });
123
+ }
124
+ else {
125
+ const looked = [local && `local marketplace ${local}`, clone && `clone ${clone}`, `cache ${join(configDir, 'plugins', 'cache', MARKETPLACE_NAME, name)}`].filter(Boolean);
126
+ out.missing.push({ name, reason: `no ${MANIFEST} in ${looked.join(', ')}` });
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ //# sourceMappingURL=plugin-paths.js.map
@@ -47,6 +47,14 @@ export interface RepairOptions {
47
47
  exec?: Exec;
48
48
  env?: NodeJS.ProcessEnv;
49
49
  fs?: ReadFs;
50
+ /**
51
+ * ADR-509 §2.2: called once the marketplace step has refreshed the clone and
52
+ * before any `claude plugin install`; a plugin id it returns is not installed
53
+ * (its step is a failure naming the reason).
54
+ */
55
+ beforeInstall?: (clone: string | null) => {
56
+ blocked: ReadonlyMap<string, string>;
57
+ };
50
58
  }
51
59
  /**
52
60
  * `update` when Claude Code already knows ruflo by the wanted source; else
@@ -88,9 +88,16 @@ export async function repairPluginInstall(opts) {
88
88
  steps.push({ argv: installArgv(p.id, opts.scope), state: 'failed', output: `${p.id} is not in ${wanted.source.path}` });
89
89
  return { ok: missing.length === 0, steps };
90
90
  }
91
+ const gate = opts.beforeInstall?.(market.location);
91
92
  let ok = true;
92
93
  for (const plugin of plugins) {
93
94
  const argv = installArgv(plugin.id, opts.scope);
95
+ const blocked = gate?.blocked.get(plugin.id);
96
+ if (blocked) {
97
+ steps.push({ argv, state: 'failed', output: blocked });
98
+ ok = false;
99
+ continue;
100
+ }
94
101
  if (!cloneHas(market, plugin.id, fs)) {
95
102
  steps.push({ argv, state: plugin.required ? 'failed' : 'pending', output: `${plugin.id} is not in the ruflo marketplace${plugin.required ? ' even after the update' : ' yet (pending)'}` });
96
103
  ok &&= !plugin.required;
@@ -0,0 +1,23 @@
1
+ export type ScanSeverity = 'high' | 'medium';
2
+ export interface ScanFinding {
3
+ plugin: string;
4
+ severity: ScanSeverity;
5
+ file: string;
6
+ rule: string;
7
+ detail: string;
8
+ }
9
+ /** Every finding for one plugin directory; empty when the plugin runs nothing on install. */
10
+ export declare function scanPluginDir(plugin: string, root: string): ScanFinding[];
11
+ export declare const formatScanFinding: (f: ScanFinding) => string;
12
+ export interface ScanGate {
13
+ /** Plugins with a high finding that were not accepted: their install must not run. */
14
+ blocked: string[];
15
+ /** Plugins with a high finding that --accept-risk let through. */
16
+ accepted: string[];
17
+ findings: ScanFinding[];
18
+ }
19
+ /** Applies §2.2: a high finding blocks, unless the plugin is named in acceptRisk. */
20
+ export declare function gateScan(findings: readonly ScanFinding[], acceptRisk: ReadonlySet<string>): ScanGate;
21
+ /** `--accept-risk a,b` or repeated: plugin names (or ids) to install past a high scan finding (ADR-509 §2.2). */
22
+ export declare function acceptRiskFrom(flags: Record<string, unknown>): string[];
23
+ //# sourceMappingURL=scan.d.ts.map
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Static scan of a Claude Code plugin before it is installed (ADR-509 §2.2).
3
+ *
4
+ * Reads what the plugin will make Claude Code run: `.mcp.json` servers,
5
+ * `hooks/hooks.json` command hooks, and its hook modules (function hooks).
6
+ * Rules: the settings-risk-scanner command rules for anything that becomes a
7
+ * shell command, and the ruflo-mods trust classifier's capability names for
8
+ * hook modules (`$.process.*`, `$.http.*`, `$.mcp.*`, `$.tool.call`, ...).
9
+ *
10
+ * `high` stops an install (override per plugin with --accept-risk); `medium`
11
+ * is printed and the install continues. Static by design: a hook that fetches
12
+ * code at run time is caught only by the network/process rules (ADR-509 §3).
13
+ */
14
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
16
+ import { scanCommandStringForRisk } from '../../init/settings-risk-scanner.js';
17
+ /** Hook-module capabilities, named as the ruflo-mods trust classifier (hooks/trust.ts RISKY_CALLS) names them. */
18
+ const MODULE_CAPABILITIES = [
19
+ { re: /\$\s*\.\s*process\s*\.\s*(run|spawn)\b/, rule: 'module-process', detail: 'runs host commands ($.process)' },
20
+ { re: /\$\s*\.\s*http\s*\.\s*fetch\b/, rule: 'module-network', detail: 'makes network requests ($.http.fetch)' },
21
+ { re: /\$\s*\.\s*mcp\s*\.\s*(call|connect)\b/, rule: 'module-mcp', detail: 'calls or connects MCP servers ($.mcp)' },
22
+ { re: /\$\s*\.\s*tool\s*\.\s*call\b/, rule: 'module-tool-call', detail: 'runs any tool, Bash included ($.tool.call)' },
23
+ { re: /\$\s*\.\s*config\s*\.\s*set\b/, rule: 'module-config', detail: 'changes Claude Code settings ($.config.set)' },
24
+ ];
25
+ /** Node escape hatches a hook module should never need: they bypass the host's `$` entirely. */
26
+ const MODULE_ESCAPES = [
27
+ { re: /(?:\brequire\s*\(\s*|\bimport\s*\(\s*|\bfrom\s+|\bimport\s+)['"](?:node:)?child_process['"]/, rule: 'module-child-process', detail: 'imports child_process (runs commands outside the host)' },
28
+ { re: /\beval\s*\(|\bnew\s+Function\s*\(/, rule: 'module-dynamic-code', detail: 'evaluates code built at run time (eval / new Function)' },
29
+ ];
30
+ const MODULE_EXT = /\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/;
31
+ const MAX_FILE_BYTES = 1024 * 1024;
32
+ const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
33
+ function readJson(path) {
34
+ try {
35
+ return JSON.parse(readFileSync(path, 'utf-8'));
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ function scanMcp(plugin, root, out) {
42
+ const file = '.mcp.json';
43
+ if (!existsSync(join(root, file)))
44
+ return;
45
+ const parsed = readJson(join(root, file));
46
+ const servers = record(parsed) && record(parsed.mcpServers) ? parsed.mcpServers : record(parsed) ? parsed : {};
47
+ for (const [name, raw] of Object.entries(servers)) {
48
+ if (!record(raw))
49
+ continue;
50
+ const command = typeof raw.command === 'string' ? raw.command : '';
51
+ const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string') : [];
52
+ const line = [command, ...args].join(' ').trim();
53
+ if (line) {
54
+ for (const reason of scanCommandStringForRisk(line))
55
+ out.push({ plugin, severity: 'high', file, rule: 'mcp-command', detail: `server "${name}" ${reason}` });
56
+ // `npx -y pkg` / `npx pkg@latest` runs whatever the registry serves at start time.
57
+ if (/(^|\s)(npx|bunx|pnpx)(\s|$)/.test(line) && (/(^|\s)-y(\s|$)|--yes\b/.test(line) || /@latest\b/.test(line))) {
58
+ out.push({ plugin, severity: 'medium', file, rule: 'mcp-unpinned-package', detail: `server "${name}" runs an unpinned package at start (${line.slice(0, 80)})` });
59
+ }
60
+ }
61
+ if (typeof raw.url === 'string' && /^http:\/\//i.test(raw.url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(raw.url)) {
62
+ out.push({ plugin, severity: 'medium', file, rule: 'mcp-plain-http', detail: `server "${name}" connects over plain http to a remote host` });
63
+ }
64
+ }
65
+ }
66
+ function scanHooksJson(plugin, root, out) {
67
+ const file = 'hooks/hooks.json';
68
+ if (!existsSync(join(root, file)))
69
+ return;
70
+ const parsed = readJson(join(root, file));
71
+ const hooks = record(parsed) && record(parsed.hooks) ? parsed.hooks : {};
72
+ for (const [event, groups] of Object.entries(hooks)) {
73
+ if (!Array.isArray(groups))
74
+ continue;
75
+ for (const group of groups) {
76
+ const list = record(group) && Array.isArray(group.hooks) ? group.hooks : [];
77
+ for (const h of list) {
78
+ if (!record(h) || typeof h.command !== 'string')
79
+ continue;
80
+ for (const reason of scanCommandStringForRisk(h.command))
81
+ out.push({ plugin, severity: 'high', file, rule: 'hook-command', detail: `${event}: ${reason}` });
82
+ }
83
+ }
84
+ }
85
+ }
86
+ /** Hook-module files under hooks/, for coverage beyond what hooks.json lists (tests, oversize files skipped). */
87
+ function moduleFiles(root) {
88
+ const out = [];
89
+ const walk = (rel, depth) => {
90
+ if (depth > 6)
91
+ return;
92
+ let names;
93
+ try {
94
+ names = readdirSync(join(root, rel));
95
+ }
96
+ catch {
97
+ return;
98
+ }
99
+ for (const name of names.sort()) {
100
+ if (name === 'node_modules' || name === '.git' || name === 'tests' || name === '__tests__')
101
+ continue;
102
+ const childRel = `${rel}/${name}`;
103
+ const st = lstatSync(join(root, childRel));
104
+ if (st.isDirectory())
105
+ walk(childRel, depth + 1);
106
+ else if (st.isFile() && MODULE_EXT.test(name) && !/\.(test|spec)\.[a-z]+$/.test(name) && st.size <= MAX_FILE_BYTES)
107
+ out.push(childRel);
108
+ }
109
+ };
110
+ if (existsSync(join(root, 'hooks')))
111
+ walk('hooks', 0);
112
+ return out;
113
+ }
114
+ // Static module specifiers: import/export ... from, side-effect import, import(), require().
115
+ const SPECIFIERS = [
116
+ /\bfrom\s*['"`]([^'"`\n]+)['"`]/g,
117
+ /\bimport\s*['"`]([^'"`\n]+)['"`]/g,
118
+ /\bimport\s*\(\s*['"`]([^'"`\n]+)['"`]\s*[,)]/g,
119
+ /\brequire\s*\(\s*['"`]([^'"`\n]+)['"`]\s*\)/g,
120
+ ];
121
+ // import(x) / require(x) with a computed argument: what it loads cannot be read statically.
122
+ const COMPUTED_LOAD = /\b(?:import|require)\s*\(\s*(?![\s'"]|`[^`$]*`)/;
123
+ const MAX_MODULES = 2000;
124
+ // `import type … from` / `export type … from` are erased by the TypeScript loader: nothing loads.
125
+ const TYPE_ONLY = /\b(?:import|export)\s+type\s+[^;'"`]{0,2000}?\bfrom\s*['"][^'"\n]+['"]/g;
126
+ function relativeSpecifiers(text) {
127
+ const out = new Set();
128
+ const loaded = text.replace(TYPE_ONLY, '');
129
+ for (const re of SPECIFIERS)
130
+ for (const m of loaded.matchAll(re))
131
+ if (m[1].startsWith('./') || m[1].startsWith('../'))
132
+ out.add(m[1]);
133
+ return [...out];
134
+ }
135
+ /** Node / TypeScript-style resolution of a relative specifier: exact, + extension, .js→.ts, index.*. */
136
+ function resolveSpecifier(fromDir, spec) {
137
+ const base = resolve(fromDir, spec);
138
+ const exts = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
139
+ const swapped = /\.(c|m)?js$/.test(base) ? [base.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts')] : [];
140
+ const candidates = [base, ...swapped, ...exts.map((e) => base + e), ...exts.map((e) => join(base, `index${e}`))];
141
+ for (const c of candidates) {
142
+ try {
143
+ if (statSync(c).isFile())
144
+ return c;
145
+ }
146
+ catch {
147
+ // try the next
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ const relTo = (root, abs) => relative(root, abs).split(sep).join('/');
153
+ /**
154
+ * What the engine actually loads: the hooks.json \`modules\` (resolved against
155
+ * hooks.json's directory) and every relative import they reach. None of the
156
+ * walk's skips apply here: a listed or imported file is scanned whatever its
157
+ * name or size, and one that cannot be read or is too large is itself a high
158
+ * finding (\`unscannable\`), never silently passed.
159
+ */
160
+ function scanLoadedModules(plugin, root, out, scanned) {
161
+ const hooksJson = join(root, 'hooks', 'hooks.json');
162
+ if (!existsSync(hooksJson))
163
+ return;
164
+ const parsed = readJson(hooksJson);
165
+ const listed = record(parsed) && Array.isArray(parsed.modules) ? parsed.modules : [];
166
+ const queue = [];
167
+ for (const m of listed) {
168
+ if (typeof m !== 'string')
169
+ continue;
170
+ const abs = resolveSpecifier(dirname(hooksJson), m) ?? resolve(dirname(hooksJson), m);
171
+ queue.push({ abs, from: 'hooks/hooks.json' });
172
+ }
173
+ const rootAbs = resolve(root);
174
+ while (queue.length) {
175
+ const { abs, from } = queue.shift();
176
+ if (scanned.has(abs))
177
+ continue;
178
+ scanned.add(abs);
179
+ const file = relTo(rootAbs, abs);
180
+ if (scanned.size > MAX_MODULES) {
181
+ out.push({ plugin, severity: 'high', file, rule: 'unscannable', detail: `more than ${MAX_MODULES} modules reachable from hooks.json` });
182
+ return;
183
+ }
184
+ if (file.startsWith('../') || isAbsolute(file)) {
185
+ out.push({ plugin, severity: 'high', file, rule: 'module-outside-plugin', detail: `loaded by ${from} but outside the plugin directory` });
186
+ }
187
+ let text;
188
+ try {
189
+ const st = lstatSync(abs);
190
+ if (!st.isFile())
191
+ throw new Error(st.isSymbolicLink() ? 'a symbolic link' : 'not a regular file');
192
+ if (st.size > MAX_FILE_BYTES)
193
+ throw new Error(`${st.size} bytes, over the ${MAX_FILE_BYTES}-byte scan limit`);
194
+ text = readFileSync(abs, 'utf-8');
195
+ }
196
+ catch (error) {
197
+ const code = error.code;
198
+ out.push({ plugin, severity: 'high', file, rule: 'unscannable', detail: `loaded by ${from} but cannot be scanned (${code === 'ENOENT' ? 'missing' : error.message})` });
199
+ continue;
200
+ }
201
+ scanText(plugin, file, text, out);
202
+ if (COMPUTED_LOAD.test(text))
203
+ out.push({ plugin, severity: 'high', file, rule: 'module-computed-import', detail: 'loads a module whose name is computed at run time (import()/require() of a non-literal)' });
204
+ for (const spec of relativeSpecifiers(text)) {
205
+ const next = resolveSpecifier(dirname(abs), spec);
206
+ if (next)
207
+ queue.push({ abs: next, from: file });
208
+ else
209
+ out.push({ plugin, severity: 'medium', file, rule: 'module-unresolved-import', detail: `imports ${spec}, which does not resolve to a file` });
210
+ }
211
+ }
212
+ }
213
+ function scanText(plugin, file, text, out) {
214
+ for (const r of MODULE_ESCAPES)
215
+ if (r.re.test(text))
216
+ out.push({ plugin, severity: 'high', file, rule: r.rule, detail: r.detail });
217
+ for (const r of MODULE_CAPABILITIES)
218
+ if (r.re.test(text))
219
+ out.push({ plugin, severity: 'medium', file, rule: r.rule, detail: r.detail });
220
+ }
221
+ function scanModules(plugin, root, out) {
222
+ const scanned = new Set();
223
+ scanLoadedModules(plugin, root, out, scanned);
224
+ for (const file of moduleFiles(root)) {
225
+ const abs = resolve(root, file);
226
+ if (scanned.has(abs))
227
+ continue;
228
+ scanned.add(abs);
229
+ scanText(plugin, file, readFileSync(abs, 'utf-8'), out);
230
+ }
231
+ }
232
+ /** Every finding for one plugin directory; empty when the plugin runs nothing on install. */
233
+ export function scanPluginDir(plugin, root) {
234
+ const out = [];
235
+ if (!existsSync(root))
236
+ return out;
237
+ scanMcp(plugin, root, out);
238
+ scanHooksJson(plugin, root, out);
239
+ scanModules(plugin, root, out);
240
+ return out;
241
+ }
242
+ export const formatScanFinding = (f) => `[${f.severity}] ${f.plugin}: ${f.file} (${f.rule}) ${f.detail}`;
243
+ /** Applies §2.2: a high finding blocks, unless the plugin is named in acceptRisk. */
244
+ export function gateScan(findings, acceptRisk) {
245
+ const high = new Set(findings.filter((f) => f.severity === 'high').map((f) => f.plugin));
246
+ const accepted = [...high].filter((p) => acceptRisk.has(p));
247
+ return { blocked: [...high].filter((p) => !acceptRisk.has(p)), accepted, findings: [...findings] };
248
+ }
249
+ /** `--accept-risk a,b` or repeated: plugin names (or ids) to install past a high scan finding (ADR-509 §2.2). */
250
+ export function acceptRiskFrom(flags) {
251
+ const raw = flags.acceptRisk ?? flags['accept-risk'];
252
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
253
+ return list.flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter((v) => /^[A-Za-z0-9._-]{1,100}(@[A-Za-z0-9._-]{1,100})?$/.test(v));
254
+ }
255
+ //# sourceMappingURL=scan.js.map
@@ -0,0 +1,53 @@
1
+ export declare const SIGNATURE_RELATIVE = ".claude-plugin/signature.json";
2
+ export interface PluginFileEntry {
3
+ path: string;
4
+ sha256: string;
5
+ }
6
+ export interface PluginManifest {
7
+ plugin: string;
8
+ version: string;
9
+ files: PluginFileEntry[];
10
+ }
11
+ export interface SignedPluginManifest {
12
+ manifest: PluginManifest;
13
+ signature: string;
14
+ algorithm: 'ed25519';
15
+ }
16
+ export type VerifyResult = 'verified' | 'modified' | 'unsigned' | 'bad-signature';
17
+ export interface PluginVerification {
18
+ plugin: string;
19
+ dir: string;
20
+ result: VerifyResult;
21
+ /** Files whose digest differs from the signed one. */
22
+ changed: string[];
23
+ /** Signed files that are gone. */
24
+ missing: string[];
25
+ /** Files present on disk that the signature does not list. */
26
+ extra: string[];
27
+ version?: string;
28
+ }
29
+ /** Canonical bytes: fixed key order, files sorted by path. MUST match scripts/sign-plugins.mjs. */
30
+ export declare function canonicalPluginManifestBytes(m: PluginManifest): Buffer;
31
+ /**
32
+ * Every file under a plugin root, as posix paths, excluding the signature
33
+ * itself. A symlink is an entry of its own (digest of `link:<target>`), never
34
+ * followed: following it would let a link swap in content the digest never saw.
35
+ */
36
+ export declare function digestPluginDir(root: string): PluginFileEntry[];
37
+ /** Signature check only: true when `signed.signature` verifies over its manifest. */
38
+ export declare function signatureValid(signed: SignedPluginManifest, pubkeyPem?: string): boolean;
39
+ /**
40
+ * Check a plugin directory against its signature.json. `expectName` (the
41
+ * marketplace name) must match the signed plugin name: a valid signature
42
+ * copied from another plugin is a bad signature here, not a pass.
43
+ */
44
+ export declare function verifyPluginDir(dir: string, expectName: string, pubkeyPem?: string): PluginVerification;
45
+ export interface MarketplacePlugin {
46
+ name: string;
47
+ dir: string;
48
+ }
49
+ /** The plugins a marketplace clone lists, each with its directory (its `source`, else plugins/<name>). */
50
+ export declare function listMarketplacePlugins(clone: string): MarketplacePlugin[];
51
+ /** One line per verification, with the differing files named (capped). */
52
+ export declare function describeVerification(v: PluginVerification, cap?: number): string;
53
+ //# sourceMappingURL=signature.d.ts.map