@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.
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/catalog-manifest.json +3 -3
- package/dist/src/commands/cost.d.ts +18 -0
- package/dist/src/commands/cost.js +195 -0
- package/dist/src/commands/doctor-auto-approve.d.ts +9 -0
- package/dist/src/commands/doctor-auto-approve.js +32 -0
- package/dist/src/commands/doctor.js +28 -2
- package/dist/src/commands/hive-mind-worker.d.ts +2 -0
- package/dist/src/commands/hive-mind-worker.js +9 -3
- package/dist/src/commands/hive-mind.js +15 -1
- package/dist/src/commands/hooks.js +45 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init.js +76 -2
- package/dist/src/commands/mods.js +13 -0
- package/dist/src/commands/plugins-paths.d.ts +13 -0
- package/dist/src/commands/plugins-paths.js +55 -0
- package/dist/src/commands/plugins-verify.d.ts +9 -0
- package/dist/src/commands/plugins-verify.js +92 -0
- package/dist/src/commands/plugins.js +13 -1
- package/dist/src/cost/attribution.d.ts +35 -0
- package/dist/src/cost/attribution.js +101 -0
- package/dist/src/cost/budget-file.d.ts +57 -0
- package/dist/src/cost/budget-file.js +113 -0
- package/dist/src/cost/litellm.d.ts +69 -0
- package/dist/src/cost/litellm.js +135 -0
- package/dist/src/init/executor.d.ts +7 -2
- package/dist/src/init/executor.js +64 -10
- package/dist/src/init/helper-commands.d.ts +16 -0
- package/dist/src/init/helper-commands.js +73 -0
- package/dist/src/init/risky-settings-gate.d.ts +39 -0
- package/dist/src/init/risky-settings-gate.js +130 -0
- package/dist/src/init/settings-risk-scanner.d.ts +35 -0
- package/dist/src/init/settings-risk-scanner.js +85 -0
- package/dist/src/init/types.d.ts +12 -0
- package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
- package/dist/src/mcp-tools/capability-brain.js +1 -1
- package/dist/src/mcp-tools/hooks-tools.d.ts +1 -0
- package/dist/src/mcp-tools/hooks-tools.js +38 -0
- package/dist/src/memory/verified-guards.d.ts +70 -0
- package/dist/src/memory/verified-guards.js +101 -0
- package/dist/src/memory/verified-outcomes.d.ts +228 -0
- package/dist/src/memory/verified-outcomes.js +372 -0
- package/dist/src/memory/verified-reverify.d.ts +63 -0
- package/dist/src/memory/verified-reverify.js +174 -0
- package/dist/src/missions/schemas.d.ts +36 -36
- package/dist/src/mods/apply.d.ts +2 -0
- package/dist/src/mods/apply.js +68 -8
- package/dist/src/mods/command-registry/schema.d.ts +222 -222
- package/dist/src/mods/compact-snapshots.d.ts +21 -0
- package/dist/src/mods/compact-snapshots.js +45 -0
- package/dist/src/mods/headless-check.d.ts +58 -0
- package/dist/src/mods/headless-check.js +137 -0
- package/dist/src/mods/headless-plugins.d.ts +44 -0
- package/dist/src/mods/headless-plugins.js +92 -0
- package/dist/src/mods/install-trust.d.ts +28 -0
- package/dist/src/mods/install-trust.js +127 -0
- package/dist/src/mods/install.d.ts +19 -0
- package/dist/src/mods/install.js +38 -1
- package/dist/src/mods/plugin-paths.d.ts +60 -0
- package/dist/src/mods/plugin-paths.js +131 -0
- package/dist/src/mods/plugin-repair.d.ts +8 -0
- package/dist/src/mods/plugin-repair.js +7 -0
- package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
- package/dist/src/plugins/marketplace-trust/scan.js +255 -0
- package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
- package/dist/src/plugins/marketplace-trust/signature.js +140 -0
- package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
- package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
- package/dist/src/services/headless-worker-executor.d.ts +2 -0
- package/dist/src/services/headless-worker-executor.js +13 -3
- package/package.json +2 -1
- package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
- package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
- package/plugins/ruflo-metaharness/README.md +14 -1
- package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
- package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
- package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
- package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
- package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
- package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
- package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
- package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
- package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
- 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
|