@dzhechkov/harness-core 0.3.23 → 0.3.25
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/dist/benchmark.d.ts +6 -0
- package/dist/benchmark.d.ts.map +1 -1
- package/dist/benchmark.js +50 -3
- package/dist/benchmark.js.map +1 -1
- package/dist/capability-vocab.d.ts +90 -0
- package/dist/capability-vocab.d.ts.map +1 -0
- package/dist/capability-vocab.js +286 -0
- package/dist/capability-vocab.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-scan.d.ts +75 -0
- package/dist/mcp-scan.d.ts.map +1 -0
- package/dist/mcp-scan.js +383 -0
- package/dist/mcp-scan.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +8 -4
- package/dist/operations.js.map +1 -1
- package/dist/reconcile.d.ts +79 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +134 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/targets.d.ts +2 -8
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +5 -4
- package/dist/targets.js.map +1 -1
- package/package.json +4 -3
- package/src/benchmark.ts +55 -3
- package/src/capability-vocab.ts +307 -0
- package/src/index.ts +25 -0
- package/src/mcp-scan.ts +489 -0
- package/src/operations.ts +8 -4
- package/src/reconcile.ts +207 -0
- package/src/targets.ts +10 -5
package/src/mcp-scan.ts
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz mcp-scan` — "npm audit for agent tools".
|
|
3
|
+
*
|
|
4
|
+
* A deterministic, static (no-execution) security scan of a project's agent
|
|
5
|
+
* permission surface. It reads Claude-Code-style `.claude/settings*.json`
|
|
6
|
+
* permission grants and MCP server declarations (`.mcp.json`, `.vscode/mcp.json`)
|
|
7
|
+
* and emits a three-tier verdict (`clean` / `medium` / `high`) with findings.
|
|
8
|
+
*
|
|
9
|
+
* The rule set is adapted from the MetaHarness `threat-model` skill
|
|
10
|
+
* (ruvnet/agent-harness-generator) and mapped onto the Claude Code permission
|
|
11
|
+
* grammar. See `docs/research/metaharness-analysis.md` §1.
|
|
12
|
+
*
|
|
13
|
+
* Semantics (verified against the MetaHarness rules + Claude Code merge model):
|
|
14
|
+
* - settings.json and settings.local.json are evaluated as a MERGED surface
|
|
15
|
+
* (union of allow, union of deny); deny wins over allow.
|
|
16
|
+
* - findings are reported per CAPABILITY (one shell / network / write finding
|
|
17
|
+
* with a count + examples), not per individual grant.
|
|
18
|
+
* - secrets-reachability requires MCP to be active (per MetaHarness).
|
|
19
|
+
* - `low`-severity findings are informational and do NOT change the verdict.
|
|
20
|
+
*
|
|
21
|
+
* Verdict (highest non-low severity wins):
|
|
22
|
+
* - `high` (exit 2): shell granted · default-deny off · secrets reachable ·
|
|
23
|
+
* hardcoded secret in MCP env · all-MCP-servers enabled ·
|
|
24
|
+
* MCP server runs an interpreter / package-runner
|
|
25
|
+
* - `medium` (exit 1): network granted · file-write granted · remote MCP server
|
|
26
|
+
* - `clean` (exit 0): no high/medium findings (low/info may still be present)
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { basename, join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
SHELL_TOOLS,
|
|
36
|
+
INTERPRETER_RE, PACKAGE_RUNNERS, INLINE_CODE_ARGS,
|
|
37
|
+
SHELL_NET_RE, SHELL_WRITE_RE, SECRET_FILE_RE, MAX_FILE_BYTES,
|
|
38
|
+
parseGrant, isWildcard, toolKind,
|
|
39
|
+
type CapabilityClass,
|
|
40
|
+
} from './capability-vocab.js';
|
|
41
|
+
|
|
42
|
+
/** Re-exported for back-compat (was historically exported from this module). */
|
|
43
|
+
export { parseGrant } from './capability-vocab.js';
|
|
44
|
+
|
|
45
|
+
/** Severity of a single finding. `low` is informational (verdict-neutral). */
|
|
46
|
+
export type McpSeverity = 'high' | 'medium' | 'low';
|
|
47
|
+
|
|
48
|
+
/** The capability class a finding concerns. */
|
|
49
|
+
export type McpCapability = CapabilityClass;
|
|
50
|
+
|
|
51
|
+
/** A single static-scan finding. */
|
|
52
|
+
export interface McpFinding {
|
|
53
|
+
/** Stable rule id, e.g. `MS-SHELL-GRANT`. */
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly severity: McpSeverity;
|
|
56
|
+
readonly capability: McpCapability;
|
|
57
|
+
/** Relative path / source the finding came from. */
|
|
58
|
+
readonly source: string;
|
|
59
|
+
/** Human-readable explanation. */
|
|
60
|
+
readonly detail: string;
|
|
61
|
+
/** The grant / value (or aggregated count + examples) that triggered the rule. */
|
|
62
|
+
readonly evidence: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The overall verdict. `clean` when there are zero high/medium findings. */
|
|
66
|
+
export type McpVerdict = 'clean' | 'medium' | 'high';
|
|
67
|
+
|
|
68
|
+
/** Result of {@link scanMcp}. */
|
|
69
|
+
export interface McpScanReport {
|
|
70
|
+
readonly verdict: McpVerdict;
|
|
71
|
+
/** Process exit code: clean=0, medium=1, high=2. */
|
|
72
|
+
readonly exitCode: 0 | 1 | 2;
|
|
73
|
+
readonly findings: readonly McpFinding[];
|
|
74
|
+
/** Relative paths actually read during the scan. */
|
|
75
|
+
readonly scanned: readonly string[];
|
|
76
|
+
/** Derived capability flags (for badges / `--json`). */
|
|
77
|
+
readonly capabilities: {
|
|
78
|
+
readonly shell: boolean;
|
|
79
|
+
readonly network: boolean;
|
|
80
|
+
readonly fileWrite: boolean;
|
|
81
|
+
readonly secretsReachable: boolean;
|
|
82
|
+
/** Scoped to the `.claude/settings*.json` surface. */
|
|
83
|
+
readonly defaultDeny: boolean;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Compile a Claude arg glob (`*` wildcard) to a RegExp. */
|
|
88
|
+
function globToRe(arg: string): RegExp {
|
|
89
|
+
const esc = arg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
90
|
+
return new RegExp(`^${esc}$`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when a deny rule covers a grant (deny wins). Case-insensitive on tool. */
|
|
94
|
+
function denyCovers(grant: string, denyList: string[]): boolean {
|
|
95
|
+
const g = parseGrant(grant);
|
|
96
|
+
return denyList.some((d) => {
|
|
97
|
+
const dd = parseGrant(d);
|
|
98
|
+
if (dd.tool === '*') return true;
|
|
99
|
+
if (dd.tool.toLowerCase() !== g.tool.toLowerCase()) return false;
|
|
100
|
+
if (dd.arg === null) return true; // bare-tool deny covers every arg
|
|
101
|
+
if (g.arg === null) return false;
|
|
102
|
+
try {
|
|
103
|
+
return globToRe(dd.arg).test(g.arg);
|
|
104
|
+
} catch {
|
|
105
|
+
return dd.arg === g.arg;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when the deny list actually protects secret FILE locations. */
|
|
111
|
+
function denyProtectsSecrets(denyList: string[]): boolean {
|
|
112
|
+
return denyList.some((d) => {
|
|
113
|
+
const { tool, arg } = parseGrant(d);
|
|
114
|
+
const t = tool.toLowerCase();
|
|
115
|
+
if (t !== 'read' && t !== 'glob' && t !== '*' && t !== 'bash') return false;
|
|
116
|
+
return arg !== null && SECRET_FILE_RE.test(arg);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Heuristic: does an MCP env value look like a hardcoded secret (not a `${VAR}` ref)? */
|
|
121
|
+
function looksLikeHardcodedSecret(key: string, value: unknown): boolean {
|
|
122
|
+
if (typeof value !== 'string') return false;
|
|
123
|
+
if (!/key|token|secret|password|api|credential|auth/i.test(key)) return false;
|
|
124
|
+
const v = value.trim();
|
|
125
|
+
if (v === '') return false;
|
|
126
|
+
if (/^\$\{?\w+\}?$/.test(v)) return false; // pure ${VAR} / $VAR reference
|
|
127
|
+
if (/^(true|false|production|development|test|none|null|\d+)$/i.test(v)) return false;
|
|
128
|
+
return v.length >= 8;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// accumulator
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
interface Accumulator {
|
|
136
|
+
findings: McpFinding[];
|
|
137
|
+
scanned: string[];
|
|
138
|
+
shellGrants: string[];
|
|
139
|
+
networkGrants: string[];
|
|
140
|
+
writeGrants: string[];
|
|
141
|
+
shellArgNetwork: string[];
|
|
142
|
+
shellArgWrite: string[];
|
|
143
|
+
mcpToolGrants: string[];
|
|
144
|
+
unknownTools: string[];
|
|
145
|
+
denyRules: string[];
|
|
146
|
+
hasReadGrant: boolean;
|
|
147
|
+
hasSettings: boolean;
|
|
148
|
+
hasDeny: boolean;
|
|
149
|
+
wildcardAll: boolean;
|
|
150
|
+
mcpActive: boolean;
|
|
151
|
+
enableAllMcp: boolean;
|
|
152
|
+
enableAllMcpSource: string;
|
|
153
|
+
serverShell: boolean;
|
|
154
|
+
serverNetwork: boolean;
|
|
155
|
+
serverSecret: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readJson(path: string): unknown | null {
|
|
159
|
+
try {
|
|
160
|
+
const st = lstatSync(path);
|
|
161
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_FILE_BYTES) return null;
|
|
162
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function asStringArray(value: unknown): string[] {
|
|
169
|
+
return Array.isArray(value) ? value.filter((x): x is string => typeof x === 'string') : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
173
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// scanners
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function scanSettings(root: string, rel: string, acc: Accumulator): void {
|
|
181
|
+
const abs = join(root, rel);
|
|
182
|
+
if (!existsSync(abs)) return;
|
|
183
|
+
const data = readJson(abs);
|
|
184
|
+
if (!isPlainObject(data)) return;
|
|
185
|
+
acc.scanned.push(rel);
|
|
186
|
+
acc.hasSettings = true;
|
|
187
|
+
|
|
188
|
+
const perms = isPlainObject(data['permissions']) ? data['permissions'] : {};
|
|
189
|
+
const allow = asStringArray(perms['allow']);
|
|
190
|
+
const deny = asStringArray(perms['deny']);
|
|
191
|
+
acc.denyRules.push(...deny);
|
|
192
|
+
if (deny.length > 0) acc.hasDeny = true;
|
|
193
|
+
|
|
194
|
+
for (const grant of allow) {
|
|
195
|
+
const { tool, arg } = parseGrant(grant);
|
|
196
|
+
if (grant.trim() === '*' || (SHELL_TOOLS.has(tool.toLowerCase()) && arg === null)) {
|
|
197
|
+
acc.wildcardAll = true;
|
|
198
|
+
}
|
|
199
|
+
const kind = toolKind(tool);
|
|
200
|
+
switch (kind) {
|
|
201
|
+
case 'shell':
|
|
202
|
+
acc.shellGrants.push(grant);
|
|
203
|
+
if (arg !== null && SHELL_NET_RE.test(arg)) acc.shellArgNetwork.push(grant);
|
|
204
|
+
if (arg !== null && SHELL_WRITE_RE.test(arg)) acc.shellArgWrite.push(grant);
|
|
205
|
+
break;
|
|
206
|
+
case 'network':
|
|
207
|
+
acc.networkGrants.push(grant);
|
|
208
|
+
break;
|
|
209
|
+
case 'file-write':
|
|
210
|
+
acc.writeGrants.push(grant);
|
|
211
|
+
break;
|
|
212
|
+
case 'read':
|
|
213
|
+
acc.hasReadGrant = true;
|
|
214
|
+
break;
|
|
215
|
+
case 'mcp':
|
|
216
|
+
acc.mcpToolGrants.push(grant);
|
|
217
|
+
acc.mcpActive = true;
|
|
218
|
+
break;
|
|
219
|
+
case 'safe':
|
|
220
|
+
break;
|
|
221
|
+
default:
|
|
222
|
+
acc.unknownTools.push(grant);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (data['enableAllProjectMcpServers'] === true) {
|
|
227
|
+
acc.enableAllMcp = true;
|
|
228
|
+
acc.enableAllMcpSource = rel;
|
|
229
|
+
acc.mcpActive = true;
|
|
230
|
+
}
|
|
231
|
+
if (Array.isArray(data['enabledMcpjsonServers']) && data['enabledMcpjsonServers'].length > 0) {
|
|
232
|
+
acc.mcpActive = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function scanMcpServers(root: string, rel: string, acc: Accumulator): void {
|
|
237
|
+
const abs = join(root, rel);
|
|
238
|
+
if (!existsSync(abs)) return;
|
|
239
|
+
const data = readJson(abs);
|
|
240
|
+
if (!isPlainObject(data)) return;
|
|
241
|
+
acc.scanned.push(rel);
|
|
242
|
+
|
|
243
|
+
const merged: Record<string, unknown> = {};
|
|
244
|
+
if (isPlainObject(data['mcpServers'])) Object.assign(merged, data['mcpServers']);
|
|
245
|
+
if (isPlainObject(data['servers'])) Object.assign(merged, data['servers']);
|
|
246
|
+
if (Object.keys(merged).length > 0) acc.mcpActive = true;
|
|
247
|
+
|
|
248
|
+
for (const [name, raw] of Object.entries(merged)) {
|
|
249
|
+
if (!isPlainObject(raw)) continue;
|
|
250
|
+
const type = typeof raw['type'] === 'string' ? (raw['type'] as string) : '';
|
|
251
|
+
const url = typeof raw['url'] === 'string' ? (raw['url'] as string) : '';
|
|
252
|
+
const command = typeof raw['command'] === 'string' ? (raw['command'] as string) : '';
|
|
253
|
+
const args = asStringArray(raw['args']);
|
|
254
|
+
const env = isPlainObject(raw['env']) ? raw['env'] : {};
|
|
255
|
+
|
|
256
|
+
if (type === 'sse' || type === 'http' || url !== '') {
|
|
257
|
+
acc.serverNetwork = true;
|
|
258
|
+
acc.findings.push({
|
|
259
|
+
id: 'MS-MCP-REMOTE',
|
|
260
|
+
severity: 'medium',
|
|
261
|
+
capability: 'network',
|
|
262
|
+
source: rel,
|
|
263
|
+
detail: `MCP server "${name}" is remote (${type || 'url'}) — sends data to an external endpoint.`,
|
|
264
|
+
evidence: url || type,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const cmdBase = basename(command).toLowerCase();
|
|
269
|
+
const isInterpreter = INTERPRETER_RE.test(cmdBase);
|
|
270
|
+
const isRunner = PACKAGE_RUNNERS.has(cmdBase);
|
|
271
|
+
const hasInlineCode = args.some((a) => INLINE_CODE_ARGS.has(a));
|
|
272
|
+
if (command !== '' && (isInterpreter || isRunner || hasInlineCode)) {
|
|
273
|
+
acc.serverShell = true;
|
|
274
|
+
const why = isRunner
|
|
275
|
+
? `package runner "${cmdBase}" fetches and executes remote code`
|
|
276
|
+
: hasInlineCode
|
|
277
|
+
? `inline-code argument`
|
|
278
|
+
: `interpreter "${cmdBase}" can run arbitrary code`;
|
|
279
|
+
acc.findings.push({
|
|
280
|
+
id: 'MS-MCP-SHELL-CMD',
|
|
281
|
+
severity: 'high',
|
|
282
|
+
capability: 'shell',
|
|
283
|
+
source: rel,
|
|
284
|
+
detail: `MCP server "${name}" launches via ${why}.`,
|
|
285
|
+
evidence: [command, ...args].join(' ').slice(0, 120),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const [k, v] of Object.entries(env)) {
|
|
290
|
+
if (looksLikeHardcodedSecret(k, v)) {
|
|
291
|
+
acc.serverSecret = true;
|
|
292
|
+
acc.findings.push({
|
|
293
|
+
id: 'MS-SECRET-HARDCODED',
|
|
294
|
+
severity: 'high',
|
|
295
|
+
capability: 'secrets',
|
|
296
|
+
source: rel,
|
|
297
|
+
detail: `MCP server "${name}" env "${k}" appears to contain a hardcoded secret — use \${ENV_VAR} indirection instead.`,
|
|
298
|
+
evidence: `${k}=${String(v).slice(0, 4)}…`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// entry point
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
function aggregate(
|
|
310
|
+
grants: string[],
|
|
311
|
+
deny: string[],
|
|
312
|
+
id: string,
|
|
313
|
+
severity: McpSeverity,
|
|
314
|
+
capability: McpCapability,
|
|
315
|
+
label: string,
|
|
316
|
+
): McpFinding | null {
|
|
317
|
+
const survivors = grants.filter((g) => !denyCovers(g, deny));
|
|
318
|
+
if (survivors.length === 0) return null;
|
|
319
|
+
const examples = survivors.slice(0, 3).join(', ');
|
|
320
|
+
return {
|
|
321
|
+
id,
|
|
322
|
+
severity,
|
|
323
|
+
capability,
|
|
324
|
+
source: '.claude/settings*.json',
|
|
325
|
+
detail: `${label} via ${survivors.length} allow rule(s) not covered by a deny rule.`,
|
|
326
|
+
evidence: survivors.length > 3 ? `e.g. ${examples}, … (+${survivors.length - 3} more)` : examples,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Statically scan a project/pack root for an unsafe agent permission surface.
|
|
332
|
+
* Deterministic and read-only — never executes anything it finds.
|
|
333
|
+
*/
|
|
334
|
+
export function scanMcp(rootDir: string): McpScanReport {
|
|
335
|
+
const acc: Accumulator = {
|
|
336
|
+
findings: [],
|
|
337
|
+
scanned: [],
|
|
338
|
+
shellGrants: [],
|
|
339
|
+
networkGrants: [],
|
|
340
|
+
writeGrants: [],
|
|
341
|
+
shellArgNetwork: [],
|
|
342
|
+
shellArgWrite: [],
|
|
343
|
+
mcpToolGrants: [],
|
|
344
|
+
unknownTools: [],
|
|
345
|
+
denyRules: [],
|
|
346
|
+
hasReadGrant: false,
|
|
347
|
+
hasSettings: false,
|
|
348
|
+
hasDeny: false,
|
|
349
|
+
wildcardAll: false,
|
|
350
|
+
mcpActive: false,
|
|
351
|
+
enableAllMcp: false,
|
|
352
|
+
enableAllMcpSource: '.claude/settings*.json',
|
|
353
|
+
serverShell: false,
|
|
354
|
+
serverNetwork: false,
|
|
355
|
+
serverSecret: false,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// order is irrelevant to the result: all signals are merged before evaluation.
|
|
359
|
+
scanSettings(rootDir, join('.claude', 'settings.json'), acc);
|
|
360
|
+
scanSettings(rootDir, join('.claude', 'settings.local.json'), acc);
|
|
361
|
+
scanMcpServers(rootDir, '.mcp.json', acc);
|
|
362
|
+
scanMcpServers(rootDir, join('.vscode', 'mcp.json'), acc);
|
|
363
|
+
|
|
364
|
+
const deny = acc.denyRules;
|
|
365
|
+
|
|
366
|
+
// --- aggregated capability findings (merged surface, deny-suppressed) ---
|
|
367
|
+
const shellSurvivors = acc.shellGrants.filter((g) => !denyCovers(g, deny));
|
|
368
|
+
if (shellSurvivors.length > 0) {
|
|
369
|
+
const anyWildcard = shellSurvivors.some((g) => isWildcard(parseGrant(g).arg));
|
|
370
|
+
const examples = shellSurvivors.slice(0, 3).join(', ');
|
|
371
|
+
acc.findings.push({
|
|
372
|
+
id: anyWildcard ? 'MS-SHELL-WILDCARD' : 'MS-SHELL-GRANT',
|
|
373
|
+
severity: 'high',
|
|
374
|
+
capability: 'shell',
|
|
375
|
+
source: '.claude/settings*.json',
|
|
376
|
+
detail: anyWildcard
|
|
377
|
+
? `Wildcard shell grant — arbitrary command execution. ${shellSurvivors.length} shell allow rule(s).`
|
|
378
|
+
: `Shell execution granted via ${shellSurvivors.length} allow rule(s).`,
|
|
379
|
+
evidence: shellSurvivors.length > 3 ? `e.g. ${examples}, … (+${shellSurvivors.length - 3} more)` : examples,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const netFinding = aggregate(
|
|
384
|
+
[...acc.networkGrants, ...acc.shellArgNetwork],
|
|
385
|
+
deny,
|
|
386
|
+
'MS-NETWORK-GRANT',
|
|
387
|
+
'medium',
|
|
388
|
+
'network',
|
|
389
|
+
'Outbound network access granted',
|
|
390
|
+
);
|
|
391
|
+
if (netFinding) acc.findings.push(netFinding);
|
|
392
|
+
|
|
393
|
+
const writeFinding = aggregate(
|
|
394
|
+
[...acc.writeGrants, ...acc.shellArgWrite],
|
|
395
|
+
deny,
|
|
396
|
+
'MS-FILEWRITE-GRANT',
|
|
397
|
+
'medium',
|
|
398
|
+
'file-write',
|
|
399
|
+
'Filesystem write access granted',
|
|
400
|
+
);
|
|
401
|
+
if (writeFinding) acc.findings.push(writeFinding);
|
|
402
|
+
|
|
403
|
+
// --- enable-all-mcp ---
|
|
404
|
+
if (acc.enableAllMcp) {
|
|
405
|
+
acc.findings.push({
|
|
406
|
+
id: 'MS-MCP-ALL-ENABLED',
|
|
407
|
+
severity: 'high',
|
|
408
|
+
capability: 'mcp',
|
|
409
|
+
source: acc.enableAllMcpSource,
|
|
410
|
+
detail: `enableAllProjectMcpServers: true — every project MCP server is trusted unconditionally (no per-server gate).`,
|
|
411
|
+
evidence: 'enableAllProjectMcpServers: true',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- secrets reachability (MetaHarness: requires MCP active) ---
|
|
416
|
+
const secretsReachable = acc.mcpActive && acc.hasReadGrant && !denyProtectsSecrets(deny);
|
|
417
|
+
if (secretsReachable) {
|
|
418
|
+
acc.findings.push({
|
|
419
|
+
id: 'MS-SECRETS-REACHABLE',
|
|
420
|
+
severity: 'high',
|
|
421
|
+
capability: 'secrets',
|
|
422
|
+
source: '.claude/settings*.json',
|
|
423
|
+
detail: `MCP is active and a Read grant exists without a deny rule protecting secret files (.env, SSH keys, cloud creds) — secrets are reachable by tools.`,
|
|
424
|
+
evidence: 'allow contains Read(...) ∧ MCP active ∧ deny lacks a secret-file guard',
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- default-deny posture (scoped to the settings surface) ---
|
|
429
|
+
const defaultDeny = acc.hasSettings ? acc.hasDeny && !acc.wildcardAll : true;
|
|
430
|
+
if (acc.hasSettings && !defaultDeny) {
|
|
431
|
+
acc.findings.push({
|
|
432
|
+
id: 'MS-DEFAULT-DENY-OFF',
|
|
433
|
+
severity: 'high',
|
|
434
|
+
capability: 'policy',
|
|
435
|
+
source: '.claude/settings*.json',
|
|
436
|
+
detail: acc.wildcardAll
|
|
437
|
+
? `An allow rule grants a bare wildcard — everything is permitted (no default-deny).`
|
|
438
|
+
: `No deny rules declared — the permission surface has no guardrail (no default-deny baseline).`,
|
|
439
|
+
evidence: acc.wildcardAll ? 'allow contains "*" / "Bash(*)"' : 'permissions.deny is empty',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- informational (low) findings: verdict-neutral coverage signal ---
|
|
444
|
+
if (acc.mcpToolGrants.length > 0) {
|
|
445
|
+
acc.findings.push({
|
|
446
|
+
id: 'MS-MCP-TOOLS-ALLOWED',
|
|
447
|
+
severity: 'low',
|
|
448
|
+
capability: 'mcp',
|
|
449
|
+
source: '.claude/settings*.json',
|
|
450
|
+
detail: `${acc.mcpToolGrants.length} MCP tool(s) explicitly allowed (per-tool gate — the safe pattern).`,
|
|
451
|
+
evidence: acc.mcpToolGrants.slice(0, 3).join(', '),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (acc.unknownTools.length > 0) {
|
|
455
|
+
acc.findings.push({
|
|
456
|
+
id: 'MS-UNKNOWN-TOOL',
|
|
457
|
+
severity: 'low',
|
|
458
|
+
capability: 'policy',
|
|
459
|
+
source: '.claude/settings*.json',
|
|
460
|
+
detail: `${acc.unknownTools.length} grant(s) for tools not in the recognised capability sets — coverage gap, review manually.`,
|
|
461
|
+
evidence: acc.unknownTools.slice(0, 3).join(', '),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// --- verdict (low is verdict-neutral) ---
|
|
466
|
+
const hasHigh = acc.findings.some((f) => f.severity === 'high');
|
|
467
|
+
const hasMedium = acc.findings.some((f) => f.severity === 'medium');
|
|
468
|
+
const verdict: McpVerdict = hasHigh ? 'high' : hasMedium ? 'medium' : 'clean';
|
|
469
|
+
const exitCode: 0 | 1 | 2 = verdict === 'high' ? 2 : verdict === 'medium' ? 1 : 0;
|
|
470
|
+
|
|
471
|
+
const order = { high: 0, medium: 1, low: 2 } as const;
|
|
472
|
+
const findings = [...acc.findings].sort(
|
|
473
|
+
(a, b) => order[a.severity] - order[b.severity] || a.id.localeCompare(b.id),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
verdict,
|
|
478
|
+
exitCode,
|
|
479
|
+
findings,
|
|
480
|
+
scanned: acc.scanned,
|
|
481
|
+
capabilities: {
|
|
482
|
+
shell: shellSurvivors.length > 0 || acc.serverShell,
|
|
483
|
+
network: Boolean(netFinding) || acc.serverNetwork,
|
|
484
|
+
fileWrite: Boolean(writeFinding),
|
|
485
|
+
secretsReachable: secretsReachable || acc.serverSecret,
|
|
486
|
+
defaultDeny,
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
package/src/operations.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { CODEX_SKILLS_ROOT } from '@dzhechkov/adapter-codex';
|
|
|
14
14
|
import { HERMES_SKILLS_ROOT } from '@dzhechkov/adapter-hermes';
|
|
15
15
|
import { OPENCODE_SKILLS_ROOT } from '@dzhechkov/adapter-opencode';
|
|
16
16
|
import { OPENCLAUDE_SKILLS_ROOT } from '@dzhechkov/adapter-openclaude';
|
|
17
|
+
import { COPILOT_INSTRUCTIONS_ROOT } from '@dzhechkov/adapter-copilot';
|
|
17
18
|
import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
|
|
18
19
|
import { computeRiskScore } from './risk-scoring.js';
|
|
19
20
|
|
|
@@ -80,6 +81,9 @@ const SKILLS_ROOTS: Record<TargetName, string> = {
|
|
|
80
81
|
opencode: OPENCODE_SKILLS_ROOT,
|
|
81
82
|
hermes: HERMES_SKILLS_ROOT,
|
|
82
83
|
openclaude: OPENCLAUDE_SKILLS_ROOT,
|
|
84
|
+
// copilot is a lossy instruction adapter (not a skills tree); enrichment never
|
|
85
|
+
// fires for it (no matching branch below), but the map must be total.
|
|
86
|
+
copilot: COPILOT_INSTRUCTIONS_ROOT,
|
|
83
87
|
};
|
|
84
88
|
|
|
85
89
|
/**
|
|
@@ -412,13 +416,13 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
412
416
|
});
|
|
413
417
|
}
|
|
414
418
|
|
|
415
|
-
// 3. Adapter resolvability
|
|
416
|
-
const adapters = ['adapter-claude', 'adapter-codex', 'adapter-hermes', 'adapter-
|
|
419
|
+
// 3. Adapter resolvability (one per target platform)
|
|
420
|
+
const adapters = ['adapter-claude', 'adapter-codex', 'adapter-opencode', 'adapter-hermes', 'adapter-openclaude', 'adapter-copilot'];
|
|
417
421
|
const foundAdapters = adapters.filter((a) => existsSync(join(root, 'packages/@dzhechkov', a)));
|
|
418
422
|
checks.push({
|
|
419
423
|
name: 'adapters present',
|
|
420
|
-
ok: foundAdapters.length ===
|
|
421
|
-
detail: `${foundAdapters.length}
|
|
424
|
+
ok: foundAdapters.length === adapters.length,
|
|
425
|
+
detail: `${foundAdapters.length}/${adapters.length} adapters found`,
|
|
422
426
|
});
|
|
423
427
|
|
|
424
428
|
// 4. Package version consistency
|