@claude-flow/cli 3.64.0 → 3.65.1

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 (93) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/catalog-manifest.json +2 -2
  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 +21 -3
  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 +8 -2
  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.js +2 -1
  38. package/dist/src/mcp-tools/http-fetch-pinned.d.ts +44 -0
  39. package/dist/src/mcp-tools/http-fetch-pinned.js +148 -0
  40. package/dist/src/mcp-tools/http-fetch-tools.d.ts +11 -2
  41. package/dist/src/mcp-tools/http-fetch-tools.js +19 -70
  42. package/dist/src/memory/verified-guards.d.ts +70 -0
  43. package/dist/src/memory/verified-guards.js +101 -0
  44. package/dist/src/memory/verified-outcomes.d.ts +68 -3
  45. package/dist/src/memory/verified-outcomes.js +88 -19
  46. package/dist/src/memory/verified-reverify.d.ts +63 -0
  47. package/dist/src/memory/verified-reverify.js +174 -0
  48. package/dist/src/missions/schemas.d.ts +4 -4
  49. package/dist/src/mods/apply.d.ts +2 -0
  50. package/dist/src/mods/apply.js +68 -8
  51. package/dist/src/mods/command-registry/schema.d.ts +36 -36
  52. package/dist/src/mods/compact-snapshots.d.ts +21 -0
  53. package/dist/src/mods/compact-snapshots.js +45 -0
  54. package/dist/src/mods/headless-check.d.ts +58 -0
  55. package/dist/src/mods/headless-check.js +137 -0
  56. package/dist/src/mods/headless-plugins.d.ts +44 -0
  57. package/dist/src/mods/headless-plugins.js +92 -0
  58. package/dist/src/mods/install-trust.d.ts +28 -0
  59. package/dist/src/mods/install-trust.js +127 -0
  60. package/dist/src/mods/install.d.ts +19 -0
  61. package/dist/src/mods/install.js +38 -1
  62. package/dist/src/mods/plugin-paths.d.ts +60 -0
  63. package/dist/src/mods/plugin-paths.js +131 -0
  64. package/dist/src/mods/plugin-repair.d.ts +8 -0
  65. package/dist/src/mods/plugin-repair.js +7 -0
  66. package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
  67. package/dist/src/plugins/marketplace-trust/scan.js +255 -0
  68. package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
  69. package/dist/src/plugins/marketplace-trust/signature.js +140 -0
  70. package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
  71. package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
  72. package/dist/src/services/headless-worker-executor.d.ts +2 -0
  73. package/dist/src/services/headless-worker-executor.js +13 -3
  74. package/package.json +2 -1
  75. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
  76. package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
  77. package/plugins/ruflo-metaharness/README.md +14 -1
  78. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
  79. package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
  80. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
  81. package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
  82. package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
  83. package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
  84. package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
  85. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
  86. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
  87. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  88. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
  89. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
  90. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
  91. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  92. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
  93. package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +1 -1
@@ -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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Signed plugin manifests for the ruflo marketplace (ADR-509 §2.1).
3
+ *
4
+ * At release, scripts/sign-plugins.mjs writes `<plugin>/.claude-plugin/signature.json`
5
+ * for every plugin listed in `.claude-plugin/marketplace.json`: a sorted list of
6
+ * every file's path and SHA-256, the plugin's version, and an Ed25519 signature
7
+ * over the canonical JSON of both. The key pair is the existing helpers signing
8
+ * key (ADR-174): the public half is RUFLO_HELPERS_PUBKEY, already pinned here.
9
+ *
10
+ * This module is the verifier. The canonical bytes MUST match the signer
11
+ * byte-for-byte; a round-trip test (signer script → this verifier) pins that.
12
+ */
13
+ import { createHash, verify as edVerify } from 'node:crypto';
14
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { RUFLO_HELPERS_PUBKEY } from '../../init/helper-signing.js';
17
+ export const SIGNATURE_RELATIVE = '.claude-plugin/signature.json';
18
+ /** Never part of a plugin's signed content: VCS metadata and installed dependencies. */
19
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
20
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
21
+ /** Canonical bytes: fixed key order, files sorted by path. MUST match scripts/sign-plugins.mjs. */
22
+ export function canonicalPluginManifestBytes(m) {
23
+ const files = [...m.files]
24
+ .map((f) => ({ path: f.path, sha256: f.sha256 }))
25
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
26
+ return Buffer.from(JSON.stringify({ plugin: m.plugin, version: m.version, files }), 'utf-8');
27
+ }
28
+ /**
29
+ * Every file under a plugin root, as posix paths, excluding the signature
30
+ * itself. A symlink is an entry of its own (digest of `link:<target>`), never
31
+ * followed: following it would let a link swap in content the digest never saw.
32
+ */
33
+ export function digestPluginDir(root) {
34
+ const out = [];
35
+ const walk = (rel) => {
36
+ const abs = rel ? join(root, rel) : root;
37
+ for (const name of readdirSync(abs).sort()) {
38
+ const childRel = rel ? `${rel}/${name}` : name;
39
+ if (childRel === SIGNATURE_RELATIVE)
40
+ continue;
41
+ const st = lstatSync(join(abs, name));
42
+ if (st.isSymbolicLink())
43
+ out.push({ path: childRel, sha256: sha256(`link:${readlinkSync(join(abs, name))}`) });
44
+ else if (st.isDirectory()) {
45
+ if (!SKIP_DIRS.has(name))
46
+ walk(childRel);
47
+ }
48
+ else if (st.isFile())
49
+ out.push({ path: childRel, sha256: sha256(readFileSync(join(abs, name))) });
50
+ }
51
+ };
52
+ walk('');
53
+ return out;
54
+ }
55
+ function parseSigned(text) {
56
+ try {
57
+ const s = JSON.parse(text);
58
+ if (!s || s.algorithm !== 'ed25519' || typeof s.signature !== 'string' || !s.manifest)
59
+ return null;
60
+ const m = s.manifest;
61
+ if (typeof m.plugin !== 'string' || typeof m.version !== 'string' || !Array.isArray(m.files))
62
+ return null;
63
+ if (!m.files.every((f) => f && typeof f.path === 'string' && typeof f.sha256 === 'string'))
64
+ return null;
65
+ return s;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /** Signature check only: true when `signed.signature` verifies over its manifest. */
72
+ export function signatureValid(signed, pubkeyPem = RUFLO_HELPERS_PUBKEY) {
73
+ try {
74
+ return edVerify(null, canonicalPluginManifestBytes(signed.manifest), pubkeyPem, Buffer.from(signed.signature, 'base64'));
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Check a plugin directory against its signature.json. `expectName` (the
82
+ * marketplace name) must match the signed plugin name: a valid signature
83
+ * copied from another plugin is a bad signature here, not a pass.
84
+ */
85
+ export function verifyPluginDir(dir, expectName, pubkeyPem = RUFLO_HELPERS_PUBKEY) {
86
+ const base = { plugin: expectName, dir, result: 'unsigned', changed: [], missing: [], extra: [] };
87
+ const sigPath = join(dir, SIGNATURE_RELATIVE);
88
+ if (!existsSync(sigPath))
89
+ return base;
90
+ const signed = parseSigned(readFileSync(sigPath, 'utf-8'));
91
+ if (!signed || signed.manifest.plugin !== expectName || !signatureValid(signed, pubkeyPem)) {
92
+ return { ...base, result: 'bad-signature', version: signed?.manifest.version };
93
+ }
94
+ const signedFiles = new Map(signed.manifest.files.map((f) => [f.path, f.sha256]));
95
+ const onDisk = new Map(digestPluginDir(dir).map((f) => [f.path, f.sha256]));
96
+ const changed = [];
97
+ const missing = [];
98
+ const extra = [];
99
+ for (const [path, digest] of signedFiles) {
100
+ const now = onDisk.get(path);
101
+ if (now === undefined)
102
+ missing.push(path);
103
+ else if (now !== digest)
104
+ changed.push(path);
105
+ }
106
+ for (const path of onDisk.keys())
107
+ if (!signedFiles.has(path))
108
+ extra.push(path);
109
+ const clean = changed.length === 0 && missing.length === 0 && extra.length === 0;
110
+ return { ...base, result: clean ? 'verified' : 'modified', changed, missing, extra, version: signed.manifest.version };
111
+ }
112
+ /** The plugins a marketplace clone lists, each with its directory (its `source`, else plugins/<name>). */
113
+ export function listMarketplacePlugins(clone) {
114
+ const catalog = JSON.parse(readFileSync(join(clone, '.claude-plugin', 'marketplace.json'), 'utf-8'));
115
+ const listed = Array.isArray(catalog.plugins) ? catalog.plugins : [];
116
+ const out = [];
117
+ for (const p of listed) {
118
+ if (!p || typeof p.name !== 'string' || !/^[A-Za-z0-9._-]{1,100}$/.test(p.name))
119
+ continue;
120
+ const source = typeof p.source === 'string' ? p.source : `./plugins/${p.name}`;
121
+ // A source outside the clone (absolute, `..`, or a remote object) is not this clone's to verify.
122
+ if (source.startsWith('/') || source.split(/[\\/]/).includes('..'))
123
+ continue;
124
+ out.push({ name: p.name, dir: join(clone, source) });
125
+ }
126
+ return out;
127
+ }
128
+ /** One line per verification, with the differing files named (capped). */
129
+ export function describeVerification(v, cap = 5) {
130
+ if (v.result !== 'modified')
131
+ return `${v.plugin}: ${v.result}`;
132
+ const parts = [
133
+ ...v.changed.map((p) => `changed ${p}`),
134
+ ...v.missing.map((p) => `missing ${p}`),
135
+ ...v.extra.map((p) => `extra ${p}`),
136
+ ];
137
+ const shown = parts.slice(0, cap).join(', ');
138
+ return `${v.plugin}: modified (${shown}${parts.length > cap ? `, … ${parts.length - cap} more` : ''})`;
139
+ }
140
+ //# sourceMappingURL=signature.js.map
@@ -0,0 +1,42 @@
1
+ import { type PluginVerification, type VerifyResult } from './signature.js';
2
+ export declare const VERIFY_STATUS_RELATIVE: string;
3
+ export interface VerifyStatusEntry {
4
+ writtenAt: string;
5
+ clone: string;
6
+ /**
7
+ * Plugin name → the WORST result across its clone copy and every install-cache
8
+ * copy: Claude Code loads an installed plugin from the cache, so a clean clone
9
+ * next to a tampered cache must not read as verified.
10
+ */
11
+ plugins: Record<string, VerifyResult>;
12
+ /** Plugin directory (normalized, see normalizeTrustPath) → that copy's own result: the mod looks up the dir it is loading. */
13
+ paths?: Record<string, VerifyResult>;
14
+ }
15
+ export interface VerifyStatusFile {
16
+ version: 1;
17
+ byConfigDir: Record<string, VerifyStatusEntry>;
18
+ }
19
+ /**
20
+ * The one spelling of a path both sides key on (the CLI writing, the mod
21
+ * reading): forward slashes, no repeated or trailing slash, and a Windows
22
+ * drive path lower-cased (Windows paths are case-insensitive, and HOME /
23
+ * USERPROFILE / os.homedir() disagree on separators). Kept byte-identical to
24
+ * plugins/ruflo-mods/hooks/trust.ts normalizeTrustPath.
25
+ */
26
+ export declare function normalizeTrustPath(p: string): string;
27
+ /** The worse of two results (bad-signature > modified > unsigned > verified). */
28
+ export declare const worseResult: (a: VerifyResult | undefined, b: VerifyResult) => VerifyResult;
29
+ export declare const verifyStatusPath: (home?: string) => string;
30
+ export declare function readVerifyStatus(home?: string): VerifyStatusFile;
31
+ /**
32
+ * Record the clone's results and the install-cache copies' (`cache`): per plugin
33
+ * the worst of all its copies, and per copy its own result keyed by directory.
34
+ */
35
+ export declare function writeVerifyStatus(configDir: string, clone: string, results: readonly PluginVerification[], home?: string, cache?: readonly PluginVerification[]): string;
36
+ /** Verify every plugin a clone lists (or only `names`). A plugin not in the clone is skipped. */
37
+ export declare function verifyClone(clone: string, names?: readonly string[]): PluginVerification[];
38
+ /** Install-cache copies of ruflo plugins (installed_plugins.json), each verified as its own row. */
39
+ export declare function verifyInstallCache(configDir: string, marketplace: string, names?: readonly string[]): PluginVerification[];
40
+ /** The results that §2.1 makes enforceable under refuse-risky. */
41
+ export declare const isUntrusted: (r: VerifyResult) => r is "modified" | "bad-signature";
42
+ //# sourceMappingURL=verify-status.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Where the ruflo marketplace verification lands (ADR-509 §2.1): `ruflo mods
3
+ * install`, `ruflo mods doctor` and `ruflo plugins verify` check the clone
4
+ * Claude Code loads from (and the install cache) and write the result to a
5
+ * status file that the ruflo-mods `plugin.register` gate reads under
6
+ * `modTrust: refuse-risky`.
7
+ *
8
+ * The file lives under the user's home (~/.claude-flow/mods/plugin-verify.json),
9
+ * not in a project: the clone is per Claude Code config dir, and a repository
10
+ * must not be able to ship a file that says its own tampered clone verified.
11
+ * It is keyed by config dir, so two config dirs never overwrite each other.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { listMarketplacePlugins, verifyPluginDir } from './signature.js';
17
+ export const VERIFY_STATUS_RELATIVE = join('.claude-flow', 'mods', 'plugin-verify.json');
18
+ /**
19
+ * The one spelling of a path both sides key on (the CLI writing, the mod
20
+ * reading): forward slashes, no repeated or trailing slash, and a Windows
21
+ * drive path lower-cased (Windows paths are case-insensitive, and HOME /
22
+ * USERPROFILE / os.homedir() disagree on separators). Kept byte-identical to
23
+ * plugins/ruflo-mods/hooks/trust.ts normalizeTrustPath.
24
+ */
25
+ export function normalizeTrustPath(p) {
26
+ let out = p.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
27
+ if (out.length > 1)
28
+ out = out.replace(/\/+$/, '');
29
+ return /^[A-Za-z]:(\/|$)/.test(out) ? out.toLowerCase() : out;
30
+ }
31
+ const RANK = { verified: 0, unsigned: 1, modified: 2, 'bad-signature': 3 };
32
+ /** The worse of two results (bad-signature > modified > unsigned > verified). */
33
+ export const worseResult = (a, b) => (a === undefined || RANK[b] > RANK[a] ? b : a);
34
+ export const verifyStatusPath = (home = homedir()) => join(home, VERIFY_STATUS_RELATIVE);
35
+ export function readVerifyStatus(home = homedir()) {
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(verifyStatusPath(home), 'utf-8'));
38
+ if (parsed && parsed.version === 1 && parsed.byConfigDir && typeof parsed.byConfigDir === 'object')
39
+ return parsed;
40
+ }
41
+ catch {
42
+ // absent or unreadable: nothing verified yet
43
+ }
44
+ return { version: 1, byConfigDir: {} };
45
+ }
46
+ /**
47
+ * Record the clone's results and the install-cache copies' (`cache`): per plugin
48
+ * the worst of all its copies, and per copy its own result keyed by directory.
49
+ */
50
+ export function writeVerifyStatus(configDir, clone, results, home = homedir(), cache = []) {
51
+ const file = readVerifyStatus(home);
52
+ const plugins = {};
53
+ const paths = {};
54
+ for (const r of [...results, ...cache]) {
55
+ plugins[r.plugin] = worseResult(plugins[r.plugin], r.result);
56
+ const key = normalizeTrustPath(r.dir);
57
+ paths[key] = worseResult(paths[key], r.result);
58
+ }
59
+ const key = normalizeTrustPath(configDir);
60
+ // An entry an older writer keyed with another spelling of the same dir is replaced, not left beside it.
61
+ for (const k of Object.keys(file.byConfigDir))
62
+ if (k !== key && normalizeTrustPath(k) === key)
63
+ delete file.byConfigDir[k];
64
+ file.byConfigDir[key] = { writtenAt: new Date().toISOString(), clone, plugins, paths };
65
+ const path = verifyStatusPath(home);
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ const tmp = `${path}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
69
+ renameSync(tmp, path);
70
+ return path;
71
+ }
72
+ /** Verify every plugin a clone lists (or only `names`). A plugin not in the clone is skipped. */
73
+ export function verifyClone(clone, names) {
74
+ const want = names && names.length ? new Set(names) : null;
75
+ return listMarketplacePlugins(clone)
76
+ .filter((p) => !want || want.has(p.name))
77
+ .filter((p) => existsSync(join(p.dir, '.claude-plugin', 'plugin.json')))
78
+ .map((p) => verifyPluginDir(p.dir, p.name));
79
+ }
80
+ /** Install-cache copies of ruflo plugins (installed_plugins.json), each verified as its own row. */
81
+ export function verifyInstallCache(configDir, marketplace, names) {
82
+ let installed;
83
+ try {
84
+ installed = JSON.parse(readFileSync(join(configDir, 'plugins', 'installed_plugins.json'), 'utf-8'));
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ const plugins = installed?.plugins;
90
+ if (!plugins || typeof plugins !== 'object')
91
+ return [];
92
+ const out = [];
93
+ for (const [id, entries] of Object.entries(plugins)) {
94
+ const at = id.lastIndexOf('@');
95
+ if (at <= 0 || id.slice(at + 1) !== marketplace)
96
+ continue;
97
+ const name = id.slice(0, at);
98
+ if (names && names.length && !names.includes(name))
99
+ continue;
100
+ for (const e of Array.isArray(entries) ? entries : []) {
101
+ const path = e?.installPath;
102
+ if (typeof path === 'string' && existsSync(join(path, '.claude-plugin', 'plugin.json')))
103
+ out.push(verifyPluginDir(path, name));
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ /** The results that §2.1 makes enforceable under refuse-risky. */
109
+ export const isUntrusted = (r) => r === 'modified' || r === 'bad-signature';
110
+ //# sourceMappingURL=verify-status.js.map
@@ -135,6 +135,8 @@ export interface HeadlessExecutionResult {
135
135
  error?: string;
136
136
  /** Execution ID for tracking */
137
137
  executionId: string;
138
+ /** ADR-510 — plugins in the worker profile that could not be found; the worker ran without them. */
139
+ missingPlugins?: string[];
138
140
  /**
139
141
  * #2661 — true when the launch was skipped because the same job
140
142
  * (repositoryId + HEAD + worker + config) succeeded within the freshness
@@ -25,6 +25,7 @@ import { join } from 'path';
25
25
  import { getGlobalAiBudget, isQuotaErrorText } from './global-ai-budget.js';
26
26
  import { resolveGitWorkspaceIdentity } from './git-workspace-identity.js';
27
27
  import { getAiJobDedupRegistry, computeAiJobKey, hashWorkerConfig } from './ai-job-dedup.js';
28
+ import { withHeadlessPlugins } from '../mods/headless-plugins.js';
28
29
  // ============================================
29
30
  // Constants
30
31
  // ============================================
@@ -777,6 +778,7 @@ export class HeadlessWorkerExecutor extends EventEmitter {
777
778
  timestamp: new Date(),
778
779
  executionId,
779
780
  error: result.error,
781
+ ...(result.missingPlugins ? { missingPlugins: result.missingPlugins } : {}),
780
782
  };
781
783
  // Log result
782
784
  this.logExecution(executionId, 'result', JSON.stringify(executionResult, null, 2));
@@ -1011,7 +1013,10 @@ Analyze the above codebase context and provide your response following the forma
1011
1013
  * Execute Claude Code in headless mode
1012
1014
  */
1013
1015
  executeClaudeCode(prompt, options) {
1014
- return new Promise((resolve) => {
1016
+ return new Promise((resolveRaw) => {
1017
+ // ADR-510: a plugin the worker profile wants but cannot find is recorded on the result.
1018
+ let missingPlugins = [];
1019
+ const resolve = (r) => resolveRaw(missingPlugins.length > 0 ? { ...r, missingPlugins } : r);
1015
1020
  const env = {
1016
1021
  ...process.env,
1017
1022
  CLAUDE_CODE_HEADLESS: 'true',
@@ -1057,9 +1062,14 @@ Analyze the above codebase context and provide your response following the forma
1057
1062
  // and ALWAYS falls back to the raw text on any mismatch — a schema
1058
1063
  // surprise from an older/newer `claude` CLI must degrade to exactly
1059
1064
  // today's behavior (no usage captured), never break analysis output.
1060
- const child = spawn('claude', ['--print', '--output-format', 'json'], {
1065
+ // ADR-510: `claude -p` on a fresh config loads no marketplace plugins, so
1066
+ // pass the worker profile (default ruflo-mods) as --plugin-dir and turn
1067
+ // function hooks on unless the caller set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS.
1068
+ const staged = withHeadlessPlugins(['--print', '--output-format', 'json'], env, { projectRoot: this.projectRoot });
1069
+ missingPlugins = staged.missing;
1070
+ const child = spawn('claude', staged.argv, {
1061
1071
  cwd: this.projectRoot,
1062
- env,
1072
+ env: staged.env,
1063
1073
  stdio: ['pipe', 'pipe', 'pipe'],
1064
1074
  windowsHide: true, // Prevent phantom console windows on Windows
1065
1075
  detached: process.platform !== 'win32',