@clear-capabilities/agentic-security-scanner 0.75.0 → 0.76.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.
@@ -0,0 +1,1769 @@
1
+ export const id = 985;
2
+ export const ids = [985];
3
+ export const modules = {
4
+
5
+ /***/ 3985:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+
9
+ // EXPORTS
10
+ __webpack_require__.d(__webpack_exports__, {
11
+ runStdio: () => (/* binding */ runStdio)
12
+ });
13
+
14
+ // EXTERNAL MODULE: external "node:fs"
15
+ var external_node_fs_ = __webpack_require__(3024);
16
+ // EXTERNAL MODULE: external "node:crypto"
17
+ var external_node_crypto_ = __webpack_require__(7598);
18
+ // EXTERNAL MODULE: external "node:path"
19
+ var external_node_path_ = __webpack_require__(6760);
20
+ // EXTERNAL MODULE: external "node:url"
21
+ var external_node_url_ = __webpack_require__(3136);
22
+ // EXTERNAL MODULE: external "node:fs/promises"
23
+ var promises_ = __webpack_require__(1455);
24
+ // EXTERNAL MODULE: ./src/posture/fix-history.js
25
+ var fix_history = __webpack_require__(4407);
26
+ // EXTERNAL MODULE: ./src/posture/integrity.js
27
+ var integrity = __webpack_require__(1130);
28
+ ;// CONCATENATED MODULE: ./src/mcp/redact.js
29
+ // Secret redactor for MCP tool outputs and audit log argument summaries.
30
+ //
31
+ // OWASP MCP01 + MCP10: the scanner reads source code, and findings carry
32
+ // `snippet` / `description` / `trace` strings that may contain hardcoded
33
+ // credentials, API keys, JWTs, private keys, etc. When those flow back to
34
+ // the agent through tools/call responses they land in the agent's context
35
+ // — exposing the secret to model logs, transcripts, and any downstream tool
36
+ // the agent passes them to.
37
+ //
38
+ // We replace high-confidence secret shapes with [REDACTED:<kind>] before
39
+ // emitting them. The original full content is still on disk (scanner
40
+ // findings); the MCP surface is the bottleneck we control.
41
+ //
42
+ // Patterns deliberately stay narrow: high-precision so we don't garble
43
+ // non-secret long strings (UUIDs, SHAs, base64-encoded scan IDs).
44
+
45
+ const PATTERNS = [
46
+ // Provider-specific high-entropy keys (anchored prefixes give very low FP)
47
+ [/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
48
+ [/ASIA[0-9A-Z]{16}/g, 'aws-temp-key'],
49
+ [/gh[pousr]_[A-Za-z0-9]{36,255}/g, 'github-token'],
50
+ [/xox[abprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
51
+ [/sk-ant-[A-Za-z0-9_-]{20,}/g, 'anthropic-key'],
52
+ [/sk-proj-[A-Za-z0-9_-]{20,}/g, 'openai-project-key'],
53
+ [/sk-[A-Za-z0-9]{32,}/g, 'openai-or-stripe-key'],
54
+ [/sk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-key'],
55
+ [/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
56
+ [/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
57
+ [/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
58
+ // JWT — three dot-separated b64url segments starting with eyJ
59
+ [/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
60
+ // PEM-encoded private keys
61
+ [/-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g, 'private-key-block'],
62
+ // Authorization headers — common copy-paste shape
63
+ [/(?:Authorization|authorization)\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/g, 'bearer-token'],
64
+ // Hardcoded password literals — assignment shape with quoted value
65
+ [/(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\n]{6,}["']/gi, 'hardcoded-credential'],
66
+ ];
67
+
68
+ const SNIPPET_MAX = 2000;
69
+ // OWASP A03 — cap input before running 14 regex patterns over it. A forged
70
+ // last-scan.json could plant a 50MB description string; without this cap a
71
+ // single explain_finding/query_taint call would peg CPU. After truncation
72
+ // the snippet still gets the final SNIPPET_MAX trim downstream.
73
+ const INPUT_MAX = 100_000;
74
+
75
+ function redactString(s) {
76
+ if (typeof s !== 'string') return s;
77
+ let out = s;
78
+ if (out.length > INPUT_MAX) out = out.slice(0, INPUT_MAX) + `…(+${out.length - INPUT_MAX})`;
79
+ for (const [re, kind] of PATTERNS) {
80
+ out = out.replace(re, `[REDACTED:${kind}]`);
81
+ }
82
+ if (out.length > SNIPPET_MAX) out = out.slice(0, SNIPPET_MAX) + `…(+${out.length - SNIPPET_MAX})`;
83
+ return out;
84
+ }
85
+
86
+ // Deep-redact every string in a finding-like object (mutates returned copy).
87
+ function redactFinding(f) {
88
+ if (!f || typeof f !== 'object') return f;
89
+ const out = { ...f };
90
+ for (const k of ['snippet', 'description', 'remediation', 'title', 'vuln', 'message']) {
91
+ if (typeof out[k] === 'string') out[k] = redactString(out[k]);
92
+ }
93
+ if (out.trace) {
94
+ try { out.trace = JSON.parse(redactString(JSON.stringify(out.trace))); }
95
+ catch { /* keep as-is if not round-trippable */ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Redact a freeform JSON-stringified argument blob (used by audit log).
101
+ function redactArgsBlob(s) {
102
+ return redactString(s);
103
+ }
104
+
105
+ ;// CONCATENATED MODULE: ./src/posture/agents-memory.js
106
+ // AGENTS.md — writable continual-learning memory (harness-anatomy #2).
107
+ //
108
+ // LangChain post:
109
+ // "Harnesses support memory file standards like AGENTS.md which get
110
+ // injected into context on agent start. As agents add and edit this file,
111
+ // harnesses load the updated file into context. This is a form of
112
+ // continual learning where agents durably store knowledge from one
113
+ // session and inject that knowledge into future sessions."
114
+ //
115
+ // Distinct from CLAUDE.md:
116
+ // - CLAUDE.md = human-authored project conventions, gotchas, layout.
117
+ // - AGENTS.md = agent-authored notes ("what worked / didn't work / I'd try
118
+ // differently next time"). Append-only. Bounded.
119
+ //
120
+ // Lives at `<project>/.agentic-security/AGENTS.md`.
121
+ //
122
+ // Bounds:
123
+ // - MAX_BYTES (default 20 KB) — past this, the oldest entries rotate to
124
+ // `AGENTS.md.archive` (also bounded; oldest archive entries are dropped).
125
+ // - MAX_ENTRY_BYTES (default 2 KB) — caps a single appendage.
126
+ // - Entries are append-only with an ISO timestamp + section divider, so
127
+ // readers can grep / slice by date without parsing.
128
+ //
129
+ // We deliberately avoid tying AGENTS.md to a session-id namespace. The post's
130
+ // recommendation is FLAT continual learning — the whole project's agents see
131
+ // each other's notes. Subagents that want session-scoped scratch use the
132
+ // agent-scratchpad surface instead.
133
+
134
+
135
+
136
+
137
+ const MEMORY_FILE = '.agentic-security/AGENTS.md';
138
+ const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
139
+ const MAX_BYTES = 20 * 1024;
140
+ const MAX_ENTRY_BYTES = 2 * 1024;
141
+ const ARCHIVE_MAX_BYTES = 200 * 1024;
142
+ const HEADER = '# AGENTS.md\n\nAgent-authored continual-learning notes. Each entry: timestamp + agent name + one short paragraph. New entries appended at the bottom; oldest entries rotate to AGENTS.md.archive when this file exceeds 20 KB.\n\n';
143
+
144
+ function _resolve(scanRoot) { return external_node_path_.join(scanRoot, MEMORY_FILE); }
145
+ function _archivePath(scanRoot) { return external_node_path_.join(scanRoot, ARCHIVE_FILE); }
146
+
147
+ function readAgentsMemory(scanRoot) {
148
+ const fp = _resolve(scanRoot);
149
+ if (!external_node_fs_.existsSync(fp)) return '';
150
+ try { return external_node_fs_.readFileSync(fp, 'utf8'); } catch { return ''; }
151
+ }
152
+
153
+ function appendAgentsMemory(scanRoot, { agent, body }) {
154
+ if (typeof agent !== 'string' || !agent.length) {
155
+ return { ok: false, reason: 'agent: required string' };
156
+ }
157
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(agent)) {
158
+ return { ok: false, reason: 'agent: must match [A-Za-z0-9_.-]{1,64}' };
159
+ }
160
+ if (typeof body !== 'string' || !body.trim().length) {
161
+ return { ok: false, reason: 'body: required non-empty string' };
162
+ }
163
+ let snippet = body.trim();
164
+ // Strip control chars and cap.
165
+ snippet = snippet.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, ' ');
166
+ if (snippet.length > MAX_ENTRY_BYTES) {
167
+ snippet = snippet.slice(0, MAX_ENTRY_BYTES) + '…';
168
+ }
169
+ const ts = new Date().toISOString();
170
+ const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
171
+ try {
172
+ const fp = _resolve(scanRoot);
173
+ external_node_fs_.mkdirSync(external_node_path_.dirname(fp), { recursive: true });
174
+ if (!external_node_fs_.existsSync(fp)) external_node_fs_.writeFileSync(fp, HEADER);
175
+ external_node_fs_.appendFileSync(fp, entry);
176
+ _maybeRotate(scanRoot);
177
+ const stat = external_node_fs_.statSync(fp);
178
+ return { ok: true, entryBytes: entry.length, fileSize: stat.size };
179
+ } catch (e) {
180
+ return { ok: false, reason: `write-failed: ${e.message}` };
181
+ }
182
+ }
183
+
184
+ function _maybeRotate(scanRoot) {
185
+ const fp = _resolve(scanRoot);
186
+ let body;
187
+ try { body = external_node_fs_.readFileSync(fp, 'utf8'); } catch { return; }
188
+ if (body.length <= MAX_BYTES) return;
189
+ // Split on the `## ` entry headers. Keep the most-recent N until the head
190
+ // (everything before the cut) drops below MAX_BYTES/2; move the head to
191
+ // the archive.
192
+ const head = HEADER;
193
+ const trailing = body.slice(head.length);
194
+ const sections = trailing.split(/(?=\n## )/g).filter(s => s.length);
195
+ // Walk from the end, accumulating until we have roughly MAX_BYTES/2 of
196
+ // recent entries. Everything else goes to the archive.
197
+ let kept = '', archive = '', accum = 0;
198
+ for (let i = sections.length - 1; i >= 0; i--) {
199
+ if (accum + sections[i].length <= MAX_BYTES / 2) {
200
+ kept = sections[i] + kept;
201
+ accum += sections[i].length;
202
+ } else {
203
+ archive = sections.slice(0, i + 1).join('') + archive;
204
+ break;
205
+ }
206
+ }
207
+ try {
208
+ external_node_fs_.writeFileSync(fp, head + kept);
209
+ if (archive.length) {
210
+ const arcFp = _archivePath(scanRoot);
211
+ let existing = '';
212
+ try { existing = external_node_fs_.existsSync(arcFp) ? external_node_fs_.readFileSync(arcFp, 'utf8') : ''; } catch {}
213
+ let next = existing + archive;
214
+ if (next.length > ARCHIVE_MAX_BYTES) {
215
+ // Drop oldest entries until under cap.
216
+ const oldestSplit = next.split(/(?=\n## )/g).filter(s => s.length);
217
+ while (oldestSplit.length && next.length > ARCHIVE_MAX_BYTES) {
218
+ oldestSplit.shift();
219
+ next = oldestSplit.join('');
220
+ }
221
+ }
222
+ external_node_fs_.writeFileSync(arcFp, next);
223
+ }
224
+ } catch { /* best-effort rotation */ }
225
+ }
226
+
227
+ // Public summary helper for the SessionStart hook. Returns a tail aligned
228
+ // to a section header (no leading partial entry, no leading newline).
229
+ function summarizeForSession(scanRoot, { maxBytes = 6 * 1024 } = {}) {
230
+ const body = readAgentsMemory(scanRoot);
231
+ if (!body) return null;
232
+ if (body.length <= maxBytes) return body;
233
+ const tail = body.slice(-maxBytes);
234
+ const firstSection = tail.indexOf('\n## ');
235
+ if (firstSection < 0) return tail;
236
+ // Slice past the leading `\n` so the result starts with `## `.
237
+ return tail.slice(firstSection + 1);
238
+ }
239
+
240
+ const _internals = { MAX_BYTES, MAX_ENTRY_BYTES, MEMORY_FILE, ARCHIVE_FILE };
241
+
242
+ // EXTERNAL MODULE: external "node:os"
243
+ var external_node_os_ = __webpack_require__(8161);
244
+ ;// CONCATENATED MODULE: ./src/posture/cve-lookup.js
245
+ // CVE lookup — read-only against the per-install OSV / KEV / EPSS caches.
246
+ //
247
+ // LangChain harness-anatomy post:
248
+ // "Knowledge cutoffs mean that models can't directly access new data like
249
+ // updated library versions without the user providing them directly."
250
+ //
251
+ // The validator and any subagent reasoning about an SCA finding can call
252
+ // `lookup_cve(cve_id)` to get the most recently-cached OSV advisory, the
253
+ // CISA KEV entry if listed, and the EPSS exploit-prediction percentile, all
254
+ // with `staleness` metadata so the caller can decide whether to trust the
255
+ // cached value.
256
+ //
257
+ // This module deliberately NEVER triggers a network fetch — the scan
258
+ // pipeline is the only thing that populates the cache. If a CVE isn't
259
+ // cached, we return `present: false` for that source rather than blocking
260
+ // on a fetch and risking a multi-second MCP timeout.
261
+
262
+
263
+
264
+
265
+
266
+
267
+ const CACHE_DIR = external_node_path_.join(external_node_os_.homedir(), '.claude', 'agentic-security', 'osv-cache');
268
+
269
+ function _keyToPath(key) {
270
+ const safe = external_node_crypto_.createHash('sha256').update(key).digest('hex');
271
+ return external_node_path_.join(CACHE_DIR, safe + '.json');
272
+ }
273
+
274
+ function _readCache(key) {
275
+ const fp = _keyToPath(key);
276
+ if (!external_node_fs_.existsSync(fp)) return { present: false };
277
+ let body;
278
+ try { body = external_node_fs_.readFileSync(fp, 'utf8'); }
279
+ catch { return { present: false, error: 'unreadable' }; }
280
+ let parsed;
281
+ try { parsed = JSON.parse(body); }
282
+ catch { return { present: false, error: 'unparseable' }; }
283
+ let mtime = null;
284
+ try { mtime = external_node_fs_.statSync(fp).mtimeMs; } catch {}
285
+ return { present: true, data: parsed, cachedAt: mtime, ageMs: mtime ? Date.now() - mtime : null };
286
+ }
287
+
288
+ function _stalenessTier(ageMs) {
289
+ if (ageMs === null || ageMs === undefined) return 'unknown';
290
+ if (ageMs < 24 * 3600 * 1000) return 'fresh'; // <1d
291
+ if (ageMs < 7 * 24 * 3600 * 1000) return 'recent'; // <1w
292
+ if (ageMs < 30 * 24 * 3600 * 1000) return 'stale'; // <1mo
293
+ return 'very-stale';
294
+ }
295
+
296
+ const CVE_RE = /^CVE-\d{4}-\d{1,7}$/i;
297
+
298
+ function lookupCve(rawId) {
299
+ if (typeof rawId !== 'string' || !CVE_RE.test(rawId)) {
300
+ return { ok: false, reason: 'invalid-cve-id', expected: 'CVE-YYYY-NNNN' };
301
+ }
302
+ const cve = rawId.toUpperCase();
303
+
304
+ // KEV catalog — single cached blob keyed at 'kev:catalog'.
305
+ const kevCacheRaw = _readCache('kev:catalog');
306
+ let kev = { present: false };
307
+ if (kevCacheRaw.present) {
308
+ // The blob shape from engine.js: { ts, byCve: { 'CVE-XXX': { ... } } }
309
+ // sessionStorage shim stores the value as the JSON-stringified inner
310
+ // object directly (no extra wrapper).
311
+ const blob = kevCacheRaw.data;
312
+ const byCve = blob?.byCve || null;
313
+ if (byCve && byCve[cve]) {
314
+ kev = {
315
+ present: true,
316
+ ...byCve[cve],
317
+ cachedAt: kevCacheRaw.cachedAt,
318
+ ageMs: kevCacheRaw.ageMs,
319
+ staleness: _stalenessTier(kevCacheRaw.ageMs),
320
+ };
321
+ } else if (byCve) {
322
+ // Catalog is cached but doesn't list this CVE — meaningful negative.
323
+ kev = {
324
+ present: false, listedInCatalog: false,
325
+ cachedAt: kevCacheRaw.cachedAt, ageMs: kevCacheRaw.ageMs,
326
+ staleness: _stalenessTier(kevCacheRaw.ageMs),
327
+ };
328
+ }
329
+ }
330
+
331
+ // EPSS — per-CVE cache at 'epss:CVE-XXX'.
332
+ const epssRaw = _readCache('epss:' + cve);
333
+ let epss = { present: false };
334
+ if (epssRaw.present) {
335
+ epss = {
336
+ present: epssRaw.data !== false, // engine stores `false` for "looked up, no record"
337
+ score: epssRaw.data?.score ?? null,
338
+ percentile: epssRaw.data?.percentile ?? null,
339
+ cachedAt: epssRaw.cachedAt,
340
+ ageMs: epssRaw.ageMs,
341
+ staleness: _stalenessTier(epssRaw.ageMs),
342
+ };
343
+ }
344
+
345
+ // OSV — entries are keyed by vuln id (GHSA-... or CVE-...). The engine
346
+ // caches them at 'vuln:<id>'. We do a direct CVE lookup AND a soft probe
347
+ // for any known alias the caller provided implicitly through the KEV
348
+ // hit's vendor/product (no — we keep this simple: direct lookup only).
349
+ const osvRaw = _readCache('vuln:' + cve);
350
+ let osv = { present: false };
351
+ if (osvRaw.present) {
352
+ osv = {
353
+ present: true,
354
+ data: osvRaw.data,
355
+ cachedAt: osvRaw.cachedAt, ageMs: osvRaw.ageMs,
356
+ staleness: _stalenessTier(osvRaw.ageMs),
357
+ };
358
+ }
359
+
360
+ return {
361
+ ok: true,
362
+ cve,
363
+ kev,
364
+ epss,
365
+ osv,
366
+ sourcesFound: [kev.present, epss.present, osv.present].filter(Boolean).length,
367
+ note: (kev.present || epss.present || osv.present)
368
+ ? 'cached values only; staleness tier per source. The MCP tool does NOT trigger a network fetch.'
369
+ : 'no cached data for this CVE on the current install. Run a scan against a project that depends on the affected package, or set $AGENTIC_SECURITY_OFFLINE=0 and run a scan to populate the cache.',
370
+ };
371
+ }
372
+
373
+ const cve_lookup_internals = { CACHE_DIR, CVE_RE, _stalenessTier };
374
+
375
+ ;// CONCATENATED MODULE: ./src/mcp/tools.js
376
+ // MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
377
+ // Top 10 (see ./redact.js, ./audit.js, ./server.js for sibling controls).
378
+ //
379
+ // Trust model:
380
+ // - Session root fixed at server boot. No per-call retargeting.
381
+ // - Path arguments lstat-checked (symlinks refused, OWASP MCP05) and
382
+ // realpath-confined to session root.
383
+ // - Tool outputs marked _meta.untrusted_excerpts:true (OWASP MCP03/MCP06)
384
+ // because they may contain text from scanned files, which is adversary-
385
+ // controlled in any context where the agent might read malicious code.
386
+ // - Secret-shaped strings redacted on the way out (OWASP MCP01/MCP10).
387
+ // - `apply_fix` requires confirm:true, valid HMAC signature on
388
+ // last-scan.json, non-shadow finding, and confined file path.
389
+
390
+
391
+
392
+
393
+
394
+
395
+
396
+
397
+
398
+ // Lazy-loaded: these transitively pull in npm packages (fast-glob,
399
+ // @babel/core) that aren't available in the plugin-cache install path
400
+ // (no node_modules). Deferring keeps the MCP server bootable everywhere;
401
+ // the import only runs when a tool that needs them is actually called.
402
+ let _runScan;
403
+ async function getRunScan() {
404
+ if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 9099))).runScan;
405
+ return _runScan;
406
+ }
407
+ let _verifyFixCore;
408
+ async function getVerifyFixCore() {
409
+ if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */ 838).then(__webpack_require__.bind(__webpack_require__, 7838))).verifyFix;
410
+ return _verifyFixCore;
411
+ }
412
+
413
+ const MAX_FILES_PER_SCAN = 1024;
414
+ const MAX_FILE_BYTES = 500_000;
415
+ const MAX_TOTAL_SCAN_BYTES = 50_000_000;
416
+ const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
417
+
418
+ // OWASP A01 — refuse writes to paths that could subvert the security tool
419
+ // itself or the host's source-control / dependency state. A forged finding
420
+ // could otherwise tell apply_fix to overwrite our own rules.yml, our audit
421
+ // log, a .git/hooks/post-commit payload, a CI workflow, an IaC file, or a
422
+ // dependency manifest (premortem #3 expansion).
423
+ //
424
+ // Two kinds of guard:
425
+ // - DIR-prefix matches anywhere under one of these directories
426
+ // - FILE-suffix matches any path whose basename ends with one of these
427
+ const RESERVED_WRITE_PREFIXES = [
428
+ '.git/',
429
+ '.github/',
430
+ '.gitlab/',
431
+ '.circleci/',
432
+ '.buildkite/',
433
+ '.agentic-security/',
434
+ 'node_modules/',
435
+ '.terraform/',
436
+ '.aws/',
437
+ 'k8s/',
438
+ 'kubernetes/',
439
+ ];
440
+ const RESERVED_WRITE_BASENAMES = new Set([
441
+ 'Dockerfile',
442
+ 'Jenkinsfile',
443
+ '.gitlab-ci.yml',
444
+ '.gitlab-ci.yaml',
445
+ 'package.json',
446
+ 'package-lock.json',
447
+ 'yarn.lock',
448
+ 'pnpm-lock.yaml',
449
+ 'pyproject.toml',
450
+ 'Pipfile',
451
+ 'Pipfile.lock',
452
+ 'poetry.lock',
453
+ 'requirements.txt',
454
+ 'go.mod',
455
+ 'go.sum',
456
+ 'Cargo.toml',
457
+ 'Cargo.lock',
458
+ 'composer.json',
459
+ 'composer.lock',
460
+ 'Gemfile',
461
+ 'Gemfile.lock',
462
+ 'pom.xml',
463
+ 'build.gradle',
464
+ 'build.gradle.kts',
465
+ ]);
466
+ const RESERVED_WRITE_SUFFIXES = [
467
+ '.tf',
468
+ '.tfvars',
469
+ 'docker-compose.yml',
470
+ 'docker-compose.yaml',
471
+ ];
472
+ function _isReservedWritePath(sessionRoot, absFile) {
473
+ // Resolve sessionRoot symlinks so the relative path is computed against
474
+ // the same canonical root as `absFile` (which _confine already realpath'd).
475
+ // On macOS /tmp → /private/tmp; without this normalization the relative
476
+ // would contain "../" and the prefix check would miss the reserved path.
477
+ const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
478
+ const rel = external_node_path_.relative(rootReal, absFile).replace(/\\/g, '/');
479
+ if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
480
+ const base = rel.split('/').pop() || '';
481
+ if (RESERVED_WRITE_BASENAMES.has(base)) return true;
482
+ if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
483
+ return false;
484
+ }
485
+
486
+ // LangChain harness-anatomy recommendation: the filesystem is the right
487
+ // collaboration / scratchpad surface for subagents. We carve out one writable
488
+ // directory inside the otherwise-reserved `.agentic-security/` tree —
489
+ // `.agentic-security/agent-scratchpad/<agent>/<session>/` — and expose
490
+ // `append_scratchpad` / `read_scratchpad` for in-progress agent state.
491
+ //
492
+ // Confinement rules:
493
+ // - relative path required (no absolute / no `..`)
494
+ // - must start with `agent-scratchpad/<agent>/<session>/`
495
+ // - `<agent>` and `<session>` are restricted to `[A-Za-z0-9_.-]{1,64}`
496
+ // (no slashes — keeps the prefix exactly three components deep)
497
+ // - file basename: same charset rules
498
+ // - max scratchpad bytes per file: SCRATCHPAD_MAX_FILE_BYTES
499
+ const SCRATCHPAD_PREFIX = '.agentic-security/agent-scratchpad/';
500
+ const SCRATCHPAD_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
501
+ const SCRATCHPAD_MAX_FILE_BYTES = 2 * 1024 * 1024; // 2 MB per file
502
+ const SCRATCHPAD_MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB per scan root
503
+
504
+ function _validateScratchpadPath(relPath) {
505
+ if (typeof relPath !== 'string' || !relPath.length) {
506
+ return { ok: false, reason: 'path: not a string' };
507
+ }
508
+ if (external_node_path_.isAbsolute(relPath)) return { ok: false, reason: 'path: must be relative' };
509
+ if (relPath.includes('..')) return { ok: false, reason: 'path: must not contain ..' };
510
+ const normalized = relPath.replace(/\\/g, '/');
511
+ if (!normalized.startsWith(SCRATCHPAD_PREFIX)) {
512
+ return { ok: false, reason: `path: must start with "${SCRATCHPAD_PREFIX}"` };
513
+ }
514
+ const rest = normalized.slice(SCRATCHPAD_PREFIX.length);
515
+ const parts = rest.split('/');
516
+ if (parts.length < 3) {
517
+ return { ok: false, reason: 'path: must be agent-scratchpad/<agent>/<session>/<file>' };
518
+ }
519
+ const [agent, session, ...fileParts] = parts;
520
+ if (!SCRATCHPAD_NAME_RE.test(agent)) return { ok: false, reason: `path: agent name "${agent}" not in [A-Za-z0-9_.-]{1,64}` };
521
+ if (!SCRATCHPAD_NAME_RE.test(session)) return { ok: false, reason: `path: session id "${session}" not in [A-Za-z0-9_.-]{1,64}` };
522
+ for (const p of fileParts) {
523
+ if (!SCRATCHPAD_NAME_RE.test(p)) return { ok: false, reason: `path: file part "${p}" not in [A-Za-z0-9_.-]{1,64}` };
524
+ }
525
+ return { ok: true, agent, session, fileParts };
526
+ }
527
+
528
+ function _scratchpadAbs(sessionRoot, relPath) {
529
+ return external_node_path_.resolve(sessionRoot, relPath.replace(/\\/g, '/'));
530
+ }
531
+
532
+ function _scratchpadTotalBytes(sessionRoot) {
533
+ const base = external_node_path_.join(sessionRoot, '.agentic-security', 'agent-scratchpad');
534
+ if (!external_node_fs_.existsSync(base)) return 0;
535
+ let total = 0;
536
+ const walk = (dir) => {
537
+ let entries;
538
+ try { entries = external_node_fs_.readdirSync(dir, { withFileTypes: true }); } catch { return; }
539
+ for (const e of entries) {
540
+ const fp = external_node_path_.join(dir, e.name);
541
+ try {
542
+ if (e.isFile()) { total += external_node_fs_.statSync(fp).size; }
543
+ else if (e.isDirectory()) walk(fp);
544
+ } catch { /* skip */ }
545
+ }
546
+ };
547
+ walk(base);
548
+ return total;
549
+ }
550
+
551
+ // ─── Path confinement ────────────────────────────────────────────────────────
552
+ // Lexical check + lstat symlink reject + realpath re-check. OWASP MCP05.
553
+ //
554
+ // For non-existent paths (apply_fix to a new file is a possible legitimate
555
+ // case; in practice we re-check existence at the use-site) we walk up the
556
+ // deepest existing ancestor and realpath that, so a parent-symlink can't
557
+ // silently relocate writes.
558
+ function _confine(sessionRoot, candidate, label) {
559
+ if (typeof candidate !== 'string' || !candidate) throw new Error(`${label}: not a string`);
560
+ const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
561
+ const abs = external_node_path_.isAbsolute(candidate) ? candidate : external_node_path_.resolve(rootReal, candidate);
562
+
563
+ // Lexical pre-check: rejects "../../etc/passwd" before any fs call.
564
+ const relLex = external_node_path_.relative(rootReal, external_node_path_.resolve(abs));
565
+ if (relLex === '' || relLex.startsWith('..') || external_node_path_.isAbsolute(relLex)) {
566
+ throw new Error(`${label}: path "${candidate}" escapes session root`);
567
+ }
568
+
569
+ // If the path exists, the leaf must not be a symlink and its realpath
570
+ // must still be under rootReal.
571
+ if (external_node_fs_.existsSync(abs)) {
572
+ if (external_node_fs_.lstatSync(abs).isSymbolicLink()) {
573
+ throw new Error(`${label}: path "${candidate}" is a symbolic link (refused)`);
574
+ }
575
+ const real = external_node_fs_.realpathSync(abs);
576
+ if (external_node_path_.relative(rootReal, real).startsWith('..')) {
577
+ throw new Error(`${label}: path "${candidate}" resolves outside session root via symlink`);
578
+ }
579
+ return real;
580
+ }
581
+
582
+ // Path doesn't exist — walk up to the deepest existing ancestor and
583
+ // realpath that. If a parent dir is a symlink pointing outside rootReal
584
+ // we catch it here.
585
+ let parent = external_node_path_.dirname(abs);
586
+ while (parent !== external_node_path_.dirname(parent) && !external_node_fs_.existsSync(parent)) {
587
+ parent = external_node_path_.dirname(parent);
588
+ }
589
+ const parentReal = external_node_fs_.realpathSync(parent);
590
+ if (external_node_path_.relative(rootReal, parentReal).startsWith('..')) {
591
+ throw new Error(`${label}: path "${candidate}" parent resolves outside session root`);
592
+ }
593
+ const suffix = external_node_path_.relative(parent, abs);
594
+ return external_node_path_.resolve(parentReal, suffix);
595
+ }
596
+
597
+ function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
598
+ const stateDir = external_node_path_.join(sessionRoot, '.agentic-security');
599
+ const scanFile = external_node_path_.join(stateDir, 'last-scan.json');
600
+ const sigFile = scanFile + '.sig';
601
+ if (!external_node_fs_.existsSync(scanFile)) return { scan: null, status: 'missing' };
602
+ const body = external_node_fs_.readFileSync(scanFile, 'utf8');
603
+ const ok = (0,integrity/* verifyLastScan */.Ef)(body, sigFile);
604
+ if (ok === false) return { scan: null, status: 'tampered' };
605
+ if (ok === null && !allowUnsigned) return { scan: null, status: 'unsigned' };
606
+ let parsed;
607
+ try { parsed = JSON.parse(body); }
608
+ catch { return { scan: null, status: 'unparseable' }; }
609
+ return { scan: parsed, status: ok ? 'verified' : 'unsigned' };
610
+ }
611
+
612
+ function _findById(scan, id) {
613
+ if (!scan) return null;
614
+ return (scan.findings || []).find(f => f.id === id)
615
+ || (scan.secrets || []).find(f => f.id === id)
616
+ || null;
617
+ }
618
+
619
+ // ─── Tool-output offloading (harness-anatomy #1) ────────────────────────────
620
+ // LangChain post: "the harness keeps the head and tail tokens of tool outputs
621
+ // above a threshold number of tokens and offloads the full output to the
622
+ // filesystem." We apply this to any MCP tool response whose findings array
623
+ // exceeds OFFLOAD_THRESHOLD entries: write the full list to a scratchpad
624
+ // file, return only head[0..3] + tail[-2..] + total + path. The agent can
625
+ // call `read_scratchpad(path)` to page through the rest.
626
+ //
627
+ // Design choices:
628
+ // - Threshold is conservative (10) — anything bigger than a casual UI page
629
+ // gets offloaded. Tunable via $AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD.
630
+ // - Offload location is the agent-scratchpad (not a separate dir) so the
631
+ // same cleanup + size caps apply.
632
+ // - File names are deterministic per response (sha256 of JSON.stringify)
633
+ // so two identical responses share the same offload file.
634
+ // - The session id is process.pid + boot timestamp short hash — collides
635
+ // only across restarts within a millisecond, which is fine for cache.
636
+ const OFFLOAD_THRESHOLD = (() => {
637
+ const v = parseInt(process.env.AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD || '10', 10);
638
+ return Number.isFinite(v) && v >= 1 ? v : 10;
639
+ })();
640
+ const MCP_SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
641
+
642
+ function _maybeOffload(sessionRoot, toolName, items) {
643
+ if (!Array.isArray(items) || items.length <= OFFLOAD_THRESHOLD) {
644
+ return { offloaded: false, items, total: items.length };
645
+ }
646
+ const head = items.slice(0, 3);
647
+ const tail = items.slice(-2);
648
+ const json = JSON.stringify({ tool: toolName, total: items.length, items }, null, 2);
649
+ const hashShort = external_node_crypto_.createHash('sha256').update(json).digest('hex').slice(0, 10);
650
+ const rel = `.agentic-security/agent-scratchpad/mcp-offload/${MCP_SESSION_ID}/${toolName}-${hashShort}.json`;
651
+ const abs = external_node_path_.resolve(sessionRoot, rel);
652
+ try {
653
+ external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
654
+ external_node_fs_.writeFileSync(abs, json);
655
+ } catch (e) {
656
+ // If we can't write to disk for some reason, fall back to returning
657
+ // everything — the alternative would be silently dropping data, which
658
+ // is worse than blowing the context.
659
+ return { offloaded: false, items, total: items.length, offloadError: e.message };
660
+ }
661
+ return {
662
+ offloaded: true,
663
+ head, tail, total: items.length,
664
+ scratchpadPath: rel,
665
+ pagingHint: `call read_scratchpad({ path: "${rel}", offset, limit }) to page through; the file is { tool, total, items: [...] } JSON`,
666
+ };
667
+ }
668
+
669
+ // ─── scan_diff ───────────────────────────────────────────────────────────────
670
+ const scan_diff = {
671
+ name: 'scan_diff',
672
+ description: 'Scan a list of files for security findings. Use BEFORE writing a Write/Edit to disk so the agent can self-correct. Returns findings with severity, file:line, title, remediation. Snippets are redacted of obvious secret patterns. Paths confined to the session root; symlinks are refused.',
673
+ inputSchema: {
674
+ type: 'object',
675
+ additionalProperties: false,
676
+ properties: {
677
+ files: {
678
+ type: 'array', minItems: 1, maxItems: MAX_FILES_PER_SCAN,
679
+ items: { type: 'string', minLength: 1, maxLength: 4096 },
680
+ },
681
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
682
+ },
683
+ required: ['files'],
684
+ },
685
+ async handler({ files, severity }, ctx) {
686
+ const sessionRoot = ctx.sessionRoot;
687
+ const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));
688
+
689
+ const fileContents = {};
690
+ let totalBytes = 0;
691
+ for (const a of abs) {
692
+ let stat;
693
+ try { stat = external_node_fs_.statSync(a); } catch { continue; }
694
+ if (!stat.isFile()) continue;
695
+ if (stat.size > MAX_FILE_BYTES) continue;
696
+ totalBytes += stat.size;
697
+ if (totalBytes > MAX_TOTAL_SCAN_BYTES) {
698
+ throw new Error(`scan_diff: total scan size exceeds ${MAX_TOTAL_SCAN_BYTES} bytes`);
699
+ }
700
+ let content;
701
+ try { content = external_node_fs_.readFileSync(a, 'utf8'); } catch { continue; }
702
+ const rel = external_node_path_.relative(sessionRoot, a).replace(/\\/g, '/');
703
+ fileContents[rel] = content;
704
+ }
705
+
706
+ const runScan = await getRunScan();
707
+ const result = await runScan(sessionRoot, { network: false, fileContents });
708
+ const wantSet = new Set(Object.keys(fileContents));
709
+ const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
710
+ const min = sevRank[severity] ?? 0;
711
+ const findings = (result.scan.findings || [])
712
+ .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
713
+ .map(f => redactFinding({
714
+ id: f.id, severity: f.severity, file: f.file, line: f.line,
715
+ title: f.title || f.vuln, cwe: f.cwe,
716
+ description: f.description, remediation: f.remediation,
717
+ }));
718
+ // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
719
+ // The agent gets a head+tail preview plus a path it can page through;
720
+ // the full finding list lives on disk. This is the documented fix for
721
+ // "context rot" — large tool outputs eat the model's attention budget.
722
+ const off = _maybeOffload(sessionRoot, 'scan_diff', findings);
723
+ if (off.offloaded) {
724
+ return {
725
+ _meta: META,
726
+ scannedFiles: Object.keys(fileContents).length,
727
+ findingCount: off.total,
728
+ offloaded: true,
729
+ head: off.head, tail: off.tail,
730
+ scratchpadPath: off.scratchpadPath,
731
+ pagingHint: off.pagingHint,
732
+ };
733
+ }
734
+ return {
735
+ _meta: META,
736
+ scannedFiles: Object.keys(fileContents).length,
737
+ findingCount: findings.length,
738
+ findings,
739
+ };
740
+ },
741
+ };
742
+
743
+ // ─── query_taint ─────────────────────────────────────────────────────────────
744
+ const query_taint = {
745
+ name: 'query_taint',
746
+ description: 'Query whether the last verified scan found a taint path involving a given source and sink. Paginated — returns up to `limit` matches (default 10, max 50) starting at `offset` (default 0); set `truncated:true` and `totalMatches` tell you when to page.',
747
+ inputSchema: {
748
+ type: 'object',
749
+ additionalProperties: false,
750
+ properties: {
751
+ source: { type: 'string', minLength: 1, maxLength: 256 },
752
+ sink: { type: 'string', minLength: 1, maxLength: 256 },
753
+ limit: { type: 'integer', minimum: 1, maximum: 50 },
754
+ offset: { type: 'integer', minimum: 0, maximum: 10000 },
755
+ },
756
+ required: ['source', 'sink'],
757
+ },
758
+ async handler({ source, sink, limit, offset }, ctx) {
759
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
760
+ if (!scan) {
761
+ return { _meta: META, hasResult: false, status, message: `No usable scan state (${status}).` };
762
+ }
763
+ const lim = Number.isInteger(limit) ? Math.min(50, Math.max(1, limit)) : 10;
764
+ const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
765
+ const srcL = String(source).toLowerCase();
766
+ const sinkL = String(sink).toLowerCase();
767
+ // Filter first (cheap), then paginate (so totalMatches is accurate).
768
+ // Harness-engineering note (post-derived): "context window != context
769
+ // attention." Returning hundreds of matches to the agent in one shot
770
+ // dilutes its reasoning; the agent receives a bounded slice plus the
771
+ // cursor to fetch the rest if it wants.
772
+ const all = (scan.findings || []).filter(f => {
773
+ const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
774
+ return hay.includes(srcL) && hay.includes(sinkL);
775
+ });
776
+ const page = all.slice(off, off + lim).map(f => redactFinding({
777
+ id: f.id, severity: f.severity, file: f.file, line: f.line,
778
+ title: f.title || f.vuln, description: f.description,
779
+ trace: f.trace || null,
780
+ }));
781
+ return {
782
+ _meta: META,
783
+ hasResult: true,
784
+ integrity: status,
785
+ scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
786
+ totalMatches: all.length,
787
+ matchCount: page.length,
788
+ offset: off,
789
+ limit: lim,
790
+ truncated: off + page.length < all.length,
791
+ nextOffset: off + page.length < all.length ? off + page.length : null,
792
+ matches: page,
793
+ };
794
+ },
795
+ };
796
+
797
+ // ─── explain_finding ─────────────────────────────────────────────────────────
798
+ const explain_finding = {
799
+ name: 'explain_finding',
800
+ description: 'Return full details for a single finding from the last verified scan. Snippet/description redacted of secret patterns.',
801
+ inputSchema: {
802
+ type: 'object',
803
+ additionalProperties: false,
804
+ properties: {
805
+ finding_id: { type: 'string', minLength: 1, maxLength: 256 },
806
+ },
807
+ required: ['finding_id'],
808
+ },
809
+ async handler({ finding_id }, ctx) {
810
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
811
+ if (!scan) throw new Error(`No usable scan state (${status}).`);
812
+ const f = _findById(scan, finding_id);
813
+ if (!f) throw new Error(`Finding not found: ${finding_id}`);
814
+ const redacted = redactFinding({
815
+ id: f.id, severity: f.severity, file: f.file, line: f.line,
816
+ title: f.title || f.vuln, cwe: f.cwe,
817
+ description: f.description, remediation: f.remediation,
818
+ snippet: f.snippet || null,
819
+ trace: f.trace || null,
820
+ });
821
+ // Harness-anatomy #1: explain_finding's trace is the most-likely-large
822
+ // field on a single finding. Offload when it crosses the threshold so
823
+ // the agent gets a head/tail preview, not a 50-step trace dumped into
824
+ // its context.
825
+ let traceTrimmed = redacted.trace;
826
+ let traceMeta = null;
827
+ if (Array.isArray(redacted.trace) && redacted.trace.length > OFFLOAD_THRESHOLD) {
828
+ const off = _maybeOffload(ctx.sessionRoot, 'explain_finding-trace', redacted.trace);
829
+ if (off.offloaded) {
830
+ traceTrimmed = [...off.head, { _gap: `... ${off.total - off.head.length - off.tail.length} more steps elided; read scratchpad ...` }, ...off.tail];
831
+ traceMeta = {
832
+ totalSteps: off.total,
833
+ scratchpadPath: off.scratchpadPath,
834
+ pagingHint: off.pagingHint,
835
+ };
836
+ }
837
+ }
838
+ return {
839
+ _meta: META,
840
+ ...redacted,
841
+ trace: traceTrimmed,
842
+ traceOffload: traceMeta,
843
+ confidence: f.confidence ?? null,
844
+ hasReplacementFix: typeof f.fix?.replacement === 'string',
845
+ integrity: status,
846
+ };
847
+ },
848
+ };
849
+
850
+ // ─── apply_fix ───────────────────────────────────────────────────────────────
851
+ const apply_fix = {
852
+ name: 'apply_fix',
853
+ description: 'Apply the stored replacement fix for a finding. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if its file path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
854
+ inputSchema: {
855
+ type: 'object',
856
+ additionalProperties: false,
857
+ properties: {
858
+ finding_id: { type: 'string', minLength: 1, maxLength: 256 },
859
+ confirm: { type: 'boolean' },
860
+ dry_run: { type: 'boolean' },
861
+ },
862
+ required: ['finding_id', 'confirm'],
863
+ },
864
+ async handler({ finding_id, confirm, dry_run = false }, ctx) {
865
+ if (confirm !== true) {
866
+ return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
867
+ }
868
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
869
+ if (!scan) {
870
+ return { _meta: META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
871
+ }
872
+ const f = _findById(scan, finding_id);
873
+ if (!f) return { _meta: META, applied: false, reason: `Finding not found: ${finding_id}` };
874
+ if (f._shadow === true) {
875
+ return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
876
+ }
877
+ if (typeof f.fix?.replacement !== 'string') {
878
+ // Premortem #2: templates are patch-shaped text. Same reasoning as
879
+ // the replacement path — do NOT pass through redactString here.
880
+ return {
881
+ _meta: META, applied: false,
882
+ reason: 'No full replacement available — only a template. Apply the template manually.',
883
+ template: f.fix?.code || '',
884
+ file: f.file, line: f.line,
885
+ };
886
+ }
887
+ let absFile;
888
+ try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
889
+ catch (e) {
890
+ return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` };
891
+ }
892
+ if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
893
+ return { _meta: META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
894
+ }
895
+ if (!external_node_fs_.existsSync(absFile)) {
896
+ return { _meta: META, applied: false, reason: `File not found: ${absFile}` };
897
+ }
898
+ const originalContent = await promises_.readFile(absFile, 'utf8');
899
+
900
+ if (dry_run) {
901
+ return {
902
+ _meta: META,
903
+ applied: false, dryRun: true,
904
+ file: f.file,
905
+ originalSize: originalContent.length,
906
+ newSize: f.fix.replacement.length,
907
+ diffSummary: `${originalContent.length} → ${f.fix.replacement.length} bytes`,
908
+ };
909
+ }
910
+
911
+ let entry;
912
+ try {
913
+ entry = await (0,fix_history/* applyFix */.oM)({
914
+ scanRoot: ctx.sessionRoot,
915
+ file: f.file,
916
+ originalContent,
917
+ newContent: f.fix.replacement,
918
+ findingId: f.id,
919
+ stableId: f.stableId || null, // premortem 4R-8
920
+ ruleId: f.rule || null,
921
+ vuln: f.vuln || f.title || null,
922
+ });
923
+ } catch (e) {
924
+ // Harness-engineering: step-budget refusal (post-derived). The
925
+ // deterministic layer enforces at-most-N attempts per stableId. When
926
+ // exceeded, surface it as a structured `budget-exceeded` outcome the
927
+ // agent can recognize — not a generic error.
928
+ if (e && e.name === 'FixAttemptBudgetExceededError') {
929
+ return {
930
+ _meta: META,
931
+ applied: false,
932
+ reason: `budget-exceeded: ${e.message}`,
933
+ budgetExceeded: true,
934
+ attempts: e.attempts,
935
+ maxAttempts: e.max,
936
+ key: e.key,
937
+ };
938
+ }
939
+ throw e;
940
+ }
941
+ return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal };
942
+ },
943
+ };
944
+
945
+ // ─── verify_fix ──────────────────────────────────────────────────────────────
946
+ // Closed-loop verification of a proposed patch BEFORE the agent applies it.
947
+ // Re-scans the patched files in-memory (no disk write), confirms the original
948
+ // stableId is gone, and runs the project's existing linter on the patched
949
+ // files. Returns a structured verdict the agent can use to decide whether to
950
+ // proceed with apply_fix.
951
+ const verify_fix = {
952
+ name: 'verify_fix',
953
+ description: 'Verify a proposed patch before applying. Re-scans the patched files in memory and runs the project linter. Returns { ok, rescan, lint, summary }. No filesystem writes.',
954
+ inputSchema: {
955
+ type: 'object',
956
+ additionalProperties: false,
957
+ properties: {
958
+ stable_id: { type: 'string', minLength: 8, maxLength: 64 },
959
+ files: {
960
+ type: 'object',
961
+ additionalProperties: { type: 'string', maxLength: 500_000 },
962
+ minProperties: 1,
963
+ maxProperties: 8,
964
+ },
965
+ },
966
+ required: ['stable_id', 'files'],
967
+ },
968
+ async handler({ stable_id, files }, ctx) {
969
+ // Confine every file path before passing to the verifier.
970
+ const confined = {};
971
+ for (const [relPath, content] of Object.entries(files || {})) {
972
+ try {
973
+ _confine(ctx.sessionRoot, relPath, 'files key');
974
+ } catch (e) {
975
+ return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` };
976
+ }
977
+ confined[relPath] = String(content);
978
+ }
979
+ try {
980
+ const verifyFixCore = await getVerifyFixCore();
981
+ const r = await verifyFixCore({
982
+ scanRoot: ctx.sessionRoot,
983
+ originalFindingStableId: stable_id,
984
+ files: confined,
985
+ });
986
+ return {
987
+ _meta: META,
988
+ ok: r.ok,
989
+ rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
990
+ lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
991
+ summary: r.summary,
992
+ };
993
+ } catch (e) {
994
+ return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
995
+ }
996
+ },
997
+ };
998
+
999
+ // ─── synthesize_fix ──────────────────────────────────────────────────────────
1000
+ // Return the stored fix replacement + regression-test scaffold for a finding,
1001
+ // WITHOUT applying anything. The agent can call verify_fix → apply_fix in
1002
+ // sequence with the returned blob.
1003
+ const synthesize_fix = {
1004
+ name: 'synthesize_fix',
1005
+ description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
1006
+ inputSchema: {
1007
+ type: 'object',
1008
+ additionalProperties: false,
1009
+ properties: {
1010
+ finding_id: { type: 'string', minLength: 1, maxLength: 256 },
1011
+ },
1012
+ required: ['finding_id'],
1013
+ },
1014
+ async handler({ finding_id }, ctx) {
1015
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
1016
+ if (!scan) {
1017
+ return { _meta: META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
1018
+ }
1019
+ const f = _findById(scan, finding_id);
1020
+ if (!f) return { _meta: META, ok: false, reason: `Finding not found: ${finding_id}` };
1021
+ if (f._shadow === true) return { _meta: META, ok: false, reason: 'shadow findings have no synthesized fix' };
1022
+ const fix = f.fix || {};
1023
+ const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
1024
+ // Patch bounds: count files touched + LoC delta.
1025
+ let touchedFiles = 1;
1026
+ let locDelta = 0;
1027
+ if (hasReplacement) {
1028
+ let orig = '';
1029
+ try {
1030
+ const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
1031
+ orig = external_node_fs_.readFileSync(abs, 'utf8');
1032
+ } catch { /* ignore — counts will reflect new-only LoC */ }
1033
+ locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
1034
+ }
1035
+ const oversized = touchedFiles > 3 || locDelta > 100;
1036
+ // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
1037
+ // not a finding excerpt. Running it through redactString silently corrupts
1038
+ // valid patches whose content happens to match a secret-shape (e.g. a
1039
+ // placeholder like `password = "loadFromEnv"`). Patches MUST pass through
1040
+ // verbatim. Snippet/description/etc. continue to be redacted in
1041
+ // explain_finding / scan_diff — that's the right surface for redaction.
1042
+ return {
1043
+ _meta: META,
1044
+ ok: true,
1045
+ stable_id: f.stableId || null,
1046
+ file: f.file, line: f.line,
1047
+ vuln: f.vuln,
1048
+ severity: f.severity,
1049
+ hasReplacement,
1050
+ replacement: hasReplacement ? fix.replacement : null,
1051
+ template: fix.code || null,
1052
+ remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
1053
+ patchBounds: { touchedFiles, locDelta, oversized },
1054
+ recommendsFixPlan: oversized && !hasReplacement,
1055
+ };
1056
+ },
1057
+ };
1058
+
1059
+ // ─── find_rule_module ───────────────────────────────────────────────────────
1060
+ // Codebase-navigation helper (C.6). Answers "which file under scanner/src/
1061
+ // implements the detector for CWE-X / family Y" by scanning the SAST and
1062
+ // posture sources for `cwe:` / `family:` literals. Cheaper and more reliable
1063
+ // than asking the agent to grep — premortem note: "grep for a common function
1064
+ // name in a large codebase returns thousands of matches."
1065
+ //
1066
+ // Read-only; no findings consumed. Output is a list of file paths + the
1067
+ // matching literal lines so the agent can verify before editing.
1068
+ const find_rule_module = {
1069
+ name: 'find_rule_module',
1070
+ description: 'Find the file(s) under scanner/src/{sast,posture}/ that emit findings for a given CWE id or family name. Use BEFORE editing a rule — answers "where is the SQL-injection detector?" without grepping the whole tree. Returns at most 20 hits; refine the query if too broad.',
1071
+ inputSchema: {
1072
+ type: 'object',
1073
+ additionalProperties: false,
1074
+ properties: {
1075
+ cwe: { type: 'string', minLength: 5, maxLength: 16 },
1076
+ family: { type: 'string', minLength: 2, maxLength: 64 },
1077
+ },
1078
+ },
1079
+ async handler({ cwe, family }, ctx) {
1080
+ if (!cwe && !family) {
1081
+ return { _meta: META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
1082
+ }
1083
+ // Pattern enforcement — the mini-schema validator doesn't do `pattern`.
1084
+ if (cwe && !/^CWE-\d+$/.test(cwe)) {
1085
+ return { _meta: META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
1086
+ }
1087
+ if (family && !/^[a-z][a-z0-9-]+$/.test(family)) {
1088
+ return { _meta: META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
1089
+ }
1090
+ const sessionRoot = ctx.sessionRoot;
1091
+ const roots = [
1092
+ external_node_path_.join(sessionRoot, 'scanner', 'src', 'sast'),
1093
+ external_node_path_.join(sessionRoot, 'scanner', 'src', 'posture'),
1094
+ ];
1095
+ const hits = [];
1096
+ const cweLit = cwe ? new RegExp(`['"\`]${cwe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`) : null;
1097
+ // Family match is broader on purpose: detectors often emit findings
1098
+ // without an explicit `family:` field (it's backfilled by
1099
+ // posture/finding-defaults.js). We match the family literal anywhere in
1100
+ // the file (vuln-name strings, comments, ids) so e.g. searching for "csrf"
1101
+ // surfaces sast/csrf.js even though it doesn't tag findings with the field.
1102
+ const famLit = family ? new RegExp(`\\b${family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-_ ]?')}\\b`, 'i') : null;
1103
+ // Also try a filename-stem match when only family is given.
1104
+ const famFilename = family ? family.toLowerCase() : null;
1105
+ for (const root of roots) {
1106
+ if (!external_node_fs_.existsSync(root)) continue;
1107
+ let entries;
1108
+ try { entries = external_node_fs_.readdirSync(root); } catch { continue; }
1109
+ for (const entry of entries) {
1110
+ if (!entry.endsWith('.js')) continue;
1111
+ const abs = external_node_path_.join(root, entry);
1112
+ let stat;
1113
+ try { stat = external_node_fs_.statSync(abs); } catch { continue; }
1114
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue;
1115
+ let body;
1116
+ try { body = external_node_fs_.readFileSync(abs, 'utf8'); } catch { continue; }
1117
+ const lines = body.split('\n');
1118
+ const matches = [];
1119
+ const stem = entry.replace(/\.js$/, '').toLowerCase();
1120
+ const filenameMatchesFamily = famFilename && (stem === famFilename || stem.includes(famFilename));
1121
+ if (filenameMatchesFamily) {
1122
+ matches.push({ line: 1, text: `<filename "${entry}" matches family>`, kind: 'filename' });
1123
+ }
1124
+ for (let i = 0; i < lines.length; i++) {
1125
+ const line = lines[i];
1126
+ if (cweLit && cweLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'cwe' });
1127
+ else if (famLit && famLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'family' });
1128
+ if (matches.length >= 5) break;
1129
+ }
1130
+ if (matches.length) {
1131
+ hits.push({
1132
+ file: external_node_path_.relative(sessionRoot, abs).replace(/\\/g, '/'),
1133
+ matchCount: matches.length,
1134
+ matches,
1135
+ });
1136
+ if (hits.length >= 20) break;
1137
+ }
1138
+ }
1139
+ if (hits.length >= 20) break;
1140
+ }
1141
+ return {
1142
+ _meta: META,
1143
+ ok: true,
1144
+ query: { cwe: cwe || null, family: family || null },
1145
+ hitCount: hits.length,
1146
+ hits,
1147
+ truncated: hits.length >= 20,
1148
+ };
1149
+ },
1150
+ };
1151
+
1152
+ // ─── append_scratchpad / read_scratchpad ───────────────────────────────────
1153
+ // LangChain harness-anatomy: the filesystem is the durable agent scratchpad.
1154
+ // These tools expose a tightly-confined slice of the project tree for
1155
+ // in-progress agent state: PLAN.md decompositions, offloaded tool outputs,
1156
+ // session notes that survive context resets.
1157
+ //
1158
+ // Confinement (validated in `_validateScratchpadPath`):
1159
+ // ALL paths must start with `.agentic-security/agent-scratchpad/<agent>/<session>/`
1160
+ // and consist of [A-Za-z0-9_.-]{1,64} path components — no `..`, no
1161
+ // absolute paths, no shell metacharacters. This is the ONE place inside
1162
+ // the otherwise-reserved `.agentic-security/` tree where agents can write.
1163
+ // Limits:
1164
+ // - 2 MB per file (write attempts beyond this are refused).
1165
+ // - 50 MB total across the scratchpad — protects against runaway agents.
1166
+ // Operators who want to clean up: `rm -rf .agentic-security/agent-scratchpad`.
1167
+ //
1168
+ // The post: "Agents can store intermediate outputs and maintain state that
1169
+ // outlasts a single session." This is that mechanism.
1170
+
1171
+ const append_scratchpad = {
1172
+ name: 'append_scratchpad',
1173
+ description: 'Append text to a file under .agentic-security/agent-scratchpad/<agent>/<session>/. The ONLY writable location for in-progress agent state (PLAN.md, notes, offloaded tool outputs, decision logs). Path must start with that prefix; <agent>/<session>/file parts are restricted to [A-Za-z0-9_.-]{1,64}. Caps: 2 MB per file, 50 MB total across the scratchpad.',
1174
+ inputSchema: {
1175
+ type: 'object',
1176
+ additionalProperties: false,
1177
+ properties: {
1178
+ path: { type: 'string', minLength: 1, maxLength: 256 },
1179
+ content: { type: 'string', minLength: 1, maxLength: 256 * 1024 },
1180
+ },
1181
+ required: ['path', 'content'],
1182
+ },
1183
+ async handler({ path: relPath, content }, ctx) {
1184
+ const v = _validateScratchpadPath(relPath);
1185
+ if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
1186
+ const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
1187
+ const total = _scratchpadTotalBytes(ctx.sessionRoot);
1188
+ if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
1189
+ return {
1190
+ _meta: META, ok: false,
1191
+ reason: `scratchpad-total-exceeded: ${total} + ${content.length} > ${SCRATCHPAD_MAX_TOTAL_BYTES}. Clean up via "rm -rf .agentic-security/agent-scratchpad" or rotate sessions.`,
1192
+ };
1193
+ }
1194
+ let existing = 0;
1195
+ try { if (external_node_fs_.existsSync(abs)) existing = external_node_fs_.statSync(abs).size; } catch {}
1196
+ if (existing + content.length > SCRATCHPAD_MAX_FILE_BYTES) {
1197
+ return {
1198
+ _meta: META, ok: false,
1199
+ reason: `scratchpad-file-exceeded: ${existing} + ${content.length} > ${SCRATCHPAD_MAX_FILE_BYTES}. Start a new file.`,
1200
+ };
1201
+ }
1202
+ try {
1203
+ external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
1204
+ external_node_fs_.appendFileSync(abs, content);
1205
+ return {
1206
+ _meta: META, ok: true,
1207
+ path: relPath, bytesWritten: content.length, fileSize: existing + content.length,
1208
+ scratchpadTotal: total + content.length,
1209
+ };
1210
+ } catch (e) {
1211
+ return { _meta: META, ok: false, reason: `write-failed: ${e.message}` };
1212
+ }
1213
+ },
1214
+ };
1215
+
1216
+ const read_scratchpad = {
1217
+ name: 'read_scratchpad',
1218
+ description: 'Read a file under .agentic-security/agent-scratchpad/<agent>/<session>/. Paginated for large files via `offset` (default 0) and `limit` (default 4096 bytes, max 64 KB). Returns bytesRead, truncated, nextOffset for paging.',
1219
+ inputSchema: {
1220
+ type: 'object',
1221
+ additionalProperties: false,
1222
+ properties: {
1223
+ path: { type: 'string', minLength: 1, maxLength: 256 },
1224
+ offset: { type: 'integer', minimum: 0, maximum: 100 * 1024 * 1024 },
1225
+ limit: { type: 'integer', minimum: 1, maximum: 64 * 1024 },
1226
+ },
1227
+ required: ['path'],
1228
+ },
1229
+ async handler({ path: relPath, offset, limit }, ctx) {
1230
+ const v = _validateScratchpadPath(relPath);
1231
+ if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
1232
+ const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
1233
+ if (!external_node_fs_.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
1234
+ let stat;
1235
+ try { stat = external_node_fs_.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
1236
+ if (!stat.isFile()) return { _meta: META, ok: false, reason: 'not-a-file' };
1237
+ const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
1238
+ const lim = Number.isInteger(limit) ? Math.min(64 * 1024, Math.max(1, limit)) : 4096;
1239
+ let buf;
1240
+ try {
1241
+ const fd = external_node_fs_.openSync(abs, 'r');
1242
+ const tmp = Buffer.alloc(lim);
1243
+ const read = external_node_fs_.readSync(fd, tmp, 0, lim, off);
1244
+ external_node_fs_.closeSync(fd);
1245
+ buf = tmp.slice(0, read);
1246
+ } catch (e) { return { _meta: META, ok: false, reason: `read-failed: ${e.message}` }; }
1247
+ const text = buf.toString('utf8');
1248
+ return {
1249
+ _meta: META, ok: true,
1250
+ path: relPath,
1251
+ offset: off, limit: lim, bytesRead: buf.length,
1252
+ totalSize: stat.size,
1253
+ truncated: off + buf.length < stat.size,
1254
+ nextOffset: off + buf.length < stat.size ? off + buf.length : null,
1255
+ content: text,
1256
+ };
1257
+ },
1258
+ };
1259
+
1260
+ // ─── append_agents_memory / read_agents_memory ─────────────────────────────
1261
+ // LangChain harness-anatomy #2: AGENTS.md as continual-learning surface.
1262
+ // Lazy-import to keep the MCP module dependency-light.
1263
+
1264
+
1265
+
1266
+ const append_agents_memory = {
1267
+ name: 'append_agents_memory',
1268
+ description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
1269
+ inputSchema: {
1270
+ type: 'object',
1271
+ additionalProperties: false,
1272
+ properties: {
1273
+ agent: { type: 'string', minLength: 1, maxLength: 64 },
1274
+ body: { type: 'string', minLength: 1, maxLength: 4096 },
1275
+ },
1276
+ required: ['agent', 'body'],
1277
+ },
1278
+ async handler({ agent, body }, ctx) {
1279
+ const r = appendAgentsMemory(ctx.sessionRoot, { agent, body });
1280
+ return { _meta: META, ...r };
1281
+ },
1282
+ };
1283
+
1284
+ const read_agents_memory = {
1285
+ name: 'read_agents_memory',
1286
+ description: 'Read the AGENTS.md continual-learning file (and AGENTS.md.archive if needed). Returns the most-recent ~6 KB tail by default; pass `full: true` for everything. The SessionStart hook already surfaces a summary; use this when an agent wants to look up specifics mid-session.',
1287
+ inputSchema: {
1288
+ type: 'object',
1289
+ additionalProperties: false,
1290
+ properties: {
1291
+ full: { type: 'boolean' },
1292
+ },
1293
+ },
1294
+ async handler({ full }, ctx) {
1295
+ const body = readAgentsMemory(ctx.sessionRoot);
1296
+ if (!body) return { _meta: META, present: false };
1297
+ if (full) return { _meta: META, present: true, length: body.length, content: body };
1298
+ // Tail-only — same logic as summarizeForSession but inlined to avoid a
1299
+ // second import surface.
1300
+ const limit = 6 * 1024;
1301
+ if (body.length <= limit) return { _meta: META, present: true, length: body.length, content: body };
1302
+ const tail = body.slice(-limit);
1303
+ const firstSection = tail.indexOf('\n## ');
1304
+ const slice = firstSection >= 0 ? tail.slice(firstSection) : tail;
1305
+ return { _meta: META, present: true, length: body.length, truncated: true, content: slice };
1306
+ },
1307
+ };
1308
+
1309
+ // ─── lookup_cve ────────────────────────────────────────────────────────────
1310
+ // LangChain harness-anatomy #8: bridge the knowledge-cutoff gap by exposing
1311
+ // the local OSV / KEV / EPSS cache as a structured tool. Read-only — never
1312
+ // triggers a network fetch from the MCP path.
1313
+ const lookup_cve = {
1314
+ name: 'lookup_cve',
1315
+ description: 'Look up a CVE id in the local OSV / KEV / EPSS caches. Returns staleness-tiered cached data (fresh / recent / stale / very-stale). Read-only — does NOT fetch fresh data; the scan pipeline is the only thing that populates the cache. Use to inform reasoning about an SCA finding without relying on the model\'s training cutoff.',
1316
+ inputSchema: {
1317
+ type: 'object',
1318
+ additionalProperties: false,
1319
+ properties: {
1320
+ cve: { type: 'string', minLength: 9, maxLength: 20 },
1321
+ },
1322
+ required: ['cve'],
1323
+ },
1324
+ async handler({ cve }, _ctx) {
1325
+ const r = lookupCve(cve);
1326
+ return { _meta: META, ...r };
1327
+ },
1328
+ };
1329
+
1330
+ const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve];
1331
+
1332
+ ;// CONCATENATED MODULE: ./src/mcp/validate.js
1333
+ // Minimal JSON Schema validator — just the subset our tool schemas use.
1334
+ // No deps. Throws on invalid input with a path-prefixed error message.
1335
+ //
1336
+ // Supported keywords: type (object/array/string/boolean/number),
1337
+ // required, properties, items, enum, minItems, maxItems, maxLength,
1338
+ // minLength, additionalProperties (only as `false` — strict).
1339
+
1340
+ const TYPE_OF = (v) => {
1341
+ if (v === null) return 'null';
1342
+ if (Array.isArray(v)) return 'array';
1343
+ return typeof v;
1344
+ };
1345
+
1346
+ function validate(schema, value, path = 'arguments') {
1347
+ if (!schema) return;
1348
+ const t = schema.type;
1349
+ if (t === 'object') {
1350
+ if (TYPE_OF(value) !== 'object') throw new Error(`${path}: expected object, got ${TYPE_OF(value)}`);
1351
+ for (const req of schema.required || []) {
1352
+ if (!(req in value)) throw new Error(`${path}: missing required property "${req}"`);
1353
+ }
1354
+ if (schema.additionalProperties === false) {
1355
+ const allowed = new Set(Object.keys(schema.properties || {}));
1356
+ for (const k of Object.keys(value)) {
1357
+ if (!allowed.has(k)) throw new Error(`${path}: unexpected property "${k}"`);
1358
+ }
1359
+ }
1360
+ for (const [k, sub] of Object.entries(schema.properties || {})) {
1361
+ if (k in value) validate(sub, value[k], `${path}.${k}`);
1362
+ }
1363
+ } else if (t === 'array') {
1364
+ if (!Array.isArray(value)) throw new Error(`${path}: expected array, got ${TYPE_OF(value)}`);
1365
+ if (schema.minItems != null && value.length < schema.minItems) throw new Error(`${path}: minItems=${schema.minItems}, got length=${value.length}`);
1366
+ if (schema.maxItems != null && value.length > schema.maxItems) throw new Error(`${path}: maxItems=${schema.maxItems}, got length=${value.length}`);
1367
+ if (schema.items) for (let i = 0; i < value.length; i++) validate(schema.items, value[i], `${path}[${i}]`);
1368
+ } else if (t === 'string') {
1369
+ if (typeof value !== 'string') throw new Error(`${path}: expected string, got ${TYPE_OF(value)}`);
1370
+ if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: must be one of [${schema.enum.join(', ')}]`);
1371
+ if (schema.maxLength != null && value.length > schema.maxLength) throw new Error(`${path}: maxLength=${schema.maxLength}, got length=${value.length}`);
1372
+ if (schema.minLength != null && value.length < schema.minLength) throw new Error(`${path}: minLength=${schema.minLength}, got length=${value.length}`);
1373
+ } else if (t === 'boolean') {
1374
+ if (typeof value !== 'boolean') throw new Error(`${path}: expected boolean, got ${TYPE_OF(value)}`);
1375
+ } else if (t === 'number' || t === 'integer') {
1376
+ if (typeof value !== 'number') throw new Error(`${path}: expected number, got ${TYPE_OF(value)}`);
1377
+ if (t === 'integer' && !Number.isInteger(value)) throw new Error(`${path}: expected integer`);
1378
+ if (schema.minimum != null && value < schema.minimum) throw new Error(`${path}: < minimum (${schema.minimum})`);
1379
+ if (schema.maximum != null && value > schema.maximum) throw new Error(`${path}: > maximum (${schema.maximum})`);
1380
+ }
1381
+ }
1382
+
1383
+ ;// CONCATENATED MODULE: ./src/mcp/audit.js
1384
+ // Append-only audit log of MCP tool calls — OWASP MCP08.
1385
+ //
1386
+ // Format: one JSON object per line (NDJSON) at
1387
+ // <sessionRoot>/.agentic-security/mcp-audit.log
1388
+ //
1389
+ // Each entry carries `prev` — the SHA-256 of the previous entry's serialized
1390
+ // form. The first entry's prev is "GENESIS". Tampering with any line breaks
1391
+ // the chain from that point forward; a reader can detect partial truncation
1392
+ // or in-place edits.
1393
+ //
1394
+ // REMOTE SINK (post-recommendation #10). The local file alone cannot detect
1395
+ // a total rewrite — an attacker with FS write can re-author the whole log
1396
+ // with fresh hashes. Closing that blind spot requires an off-host witness.
1397
+ // Set $AGENTIC_SECURITY_AUDIT_WEBHOOK to a POST endpoint; every entry is
1398
+ // fire-and-forget POSTed there in addition to the local append. Failures
1399
+ // to reach the webhook are best-effort — they NEVER block a tool call,
1400
+ // because that would let a network outage become a denial of service. They
1401
+ // DO get recorded as `_remoteSinkErr` on the local entry, so an operator
1402
+ // reviewing the log later can spot a forging attempt that targeted the
1403
+ // remote (any gap between local-sequence and remote-sequence is evidence).
1404
+ //
1405
+ // Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
1406
+ // arguments cannot leak via the audit trail OR via the remote sink.
1407
+
1408
+
1409
+
1410
+
1411
+
1412
+
1413
+ const MAX_ARG_BYTES = 1024;
1414
+ const GENESIS = 'GENESIS';
1415
+ const REMOTE_TIMEOUT_MS = 1500;
1416
+
1417
+ // Per-process session ID (harness-anatomy #9). Stamped on every audit entry
1418
+ // so downstream metrics can aggregate by session and surface outliers like
1419
+ // "200 apply_fix calls in one session." The ID is `<pid>-<short-ts>` — not
1420
+ // cryptographically unique, but enough to disambiguate concurrent runs on
1421
+ // the same host. Stable for the lifetime of this Node process.
1422
+ const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
1423
+
1424
+ function _summarize(args) {
1425
+ let s;
1426
+ try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
1427
+ s = redactArgsBlob(s);
1428
+ if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
1429
+ return s;
1430
+ }
1431
+
1432
+ function _sha(s) { return external_node_crypto_.createHash('sha256').update(s).digest('hex'); }
1433
+
1434
+ function _readLastEntryHash(logFile) {
1435
+ if (!external_node_fs_.existsSync(logFile)) return GENESIS;
1436
+ try {
1437
+ const all = external_node_fs_.readFileSync(logFile, 'utf8');
1438
+ const lines = all.split('\n').filter(Boolean);
1439
+ if (!lines.length) return GENESIS;
1440
+ return _sha(lines[lines.length - 1]);
1441
+ } catch { return GENESIS; }
1442
+ }
1443
+
1444
+ // Fire-and-forget POST to the remote sink. Resolves to null on success,
1445
+ // to a short error string on failure. Never throws; never blocks longer
1446
+ // than REMOTE_TIMEOUT_MS. The local audit append happens regardless.
1447
+ async function _postRemote(url, entry) {
1448
+ try {
1449
+ const controller = new AbortController();
1450
+ const t = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
1451
+ const r = await fetch(url, {
1452
+ method: 'POST',
1453
+ headers: { 'Content-Type': 'application/json' },
1454
+ body: JSON.stringify(entry),
1455
+ signal: controller.signal,
1456
+ });
1457
+ clearTimeout(t);
1458
+ if (!r.ok) return `HTTP ${r.status}`;
1459
+ return null;
1460
+ } catch (e) {
1461
+ return String((e && e.message) || e).slice(0, 200);
1462
+ }
1463
+ }
1464
+
1465
+ function auditCall({ sessionRoot, tool, args, outcome, reason }) {
1466
+ if (!sessionRoot) return;
1467
+ try {
1468
+ const dir = external_node_path_.join(sessionRoot, '.agentic-security');
1469
+ external_node_fs_.mkdirSync(dir, { recursive: true });
1470
+ const logFile = external_node_path_.join(dir, 'mcp-audit.log');
1471
+ const entry = {
1472
+ ts: new Date().toISOString(),
1473
+ sessionId: SESSION_ID,
1474
+ tool,
1475
+ outcome,
1476
+ ...(reason ? { reason } : {}),
1477
+ args: _summarize(args),
1478
+ prev: _readLastEntryHash(logFile),
1479
+ };
1480
+ external_node_fs_.appendFileSync(logFile, JSON.stringify(entry) + '\n');
1481
+ // Remote sink (post-recommendation #10). Fire-and-forget. We don't await
1482
+ // the promise so the tool call returns immediately; the remote POST runs
1483
+ // on its own microtask. Failures get logged to a sidecar file so the
1484
+ // operator can detect when the sink is unreachable.
1485
+ const webhook = process.env.AGENTIC_SECURITY_AUDIT_WEBHOOK;
1486
+ if (webhook) {
1487
+ _postRemote(webhook, entry).then((err) => {
1488
+ if (!err) return;
1489
+ try {
1490
+ const errFile = external_node_path_.join(dir, 'mcp-audit.remote-errors.log');
1491
+ external_node_fs_.appendFileSync(errFile, JSON.stringify({
1492
+ ts: new Date().toISOString(), entryTs: entry.ts, tool, err,
1493
+ }) + '\n');
1494
+ } catch { /* nothing else to do */ }
1495
+ });
1496
+ }
1497
+ } catch { /* audit failure must never break a tool call */ }
1498
+ }
1499
+
1500
+ // Verify the chain from start to end. Returns
1501
+ // { ok: true, entries: N } if intact
1502
+ // { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
1503
+ // Reader/operator-facing tool.
1504
+ function verifyAuditLog(logFile) {
1505
+ if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
1506
+ const text = fs.readFileSync(logFile, 'utf8');
1507
+ const lines = text.split('\n').filter(Boolean);
1508
+ let expectedPrev = GENESIS;
1509
+ for (let i = 0; i < lines.length; i++) {
1510
+ let entry;
1511
+ try { entry = JSON.parse(lines[i]); }
1512
+ catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
1513
+ if (entry.prev !== expectedPrev) {
1514
+ return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
1515
+ }
1516
+ expectedPrev = _sha(lines[i]);
1517
+ }
1518
+ return { ok: true, entries: lines.length };
1519
+ }
1520
+
1521
+ ;// CONCATENATED MODULE: ./src/mcp/server.js
1522
+ // MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
1523
+ //
1524
+ // Hardening posture (mapped to OWASP MCP Top 10):
1525
+ // - Session root chosen at server boot, no per-call retargeting (MCP02)
1526
+ // - Every tools/call argument validated against the tool's inputSchema (MCP02/MCP05)
1527
+ // - Every tools/call audited with a hash-chained log (MCP08)
1528
+ // - serverInfo.codeFingerprint = SHA-256 of MCP source files (MCP04/MCP09)
1529
+ // so a fleet can detect tampered or unauthorized server deployments
1530
+ // - AGENTIC_SECURITY_MCP_DISABLED=1 hard-disables all tool calls (MCP09)
1531
+ // - Stdio transport caps line/buffer size (./stdio.js) (MCP05 DoS)
1532
+
1533
+
1534
+
1535
+
1536
+
1537
+
1538
+
1539
+
1540
+
1541
+ const PROTOCOL_VERSION = '2025-03-26';
1542
+ const SERVER_NAME = 'agentic-security';
1543
+
1544
+ // Premortem #6: read version from scanner/package.json at module load so the
1545
+ // MCP `initialize` response can't silently drift from the shipped package
1546
+ // version. A hardcoded constant rotted from 0.39.2 → wrong for every release
1547
+ // that followed. Fall back to 'unknown' rather than a stale literal.
1548
+ const SERVER_VERSION = (() => {
1549
+ try {
1550
+ const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
1551
+ // scanner/src/mcp/ → scanner/package.json
1552
+ const pkgPath = external_node_path_.resolve(here, '..', '..', 'package.json');
1553
+ const pkg = JSON.parse(external_node_fs_.readFileSync(pkgPath, 'utf8'));
1554
+ if (typeof pkg.version === 'string' && pkg.version.length) return pkg.version;
1555
+ } catch { /* fall through */ }
1556
+ return 'unknown';
1557
+ })();
1558
+
1559
+ const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));
1560
+
1561
+ // Code fingerprint — SHA-256 of the MCP source files concatenated in a
1562
+ // stable order. Embedded in `initialize` response so a fleet operator can
1563
+ // detect when an unapproved build is running (OWASP MCP04/MCP09).
1564
+ function _codeFingerprint() {
1565
+ try {
1566
+ const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
1567
+ const files = ['server.js', 'tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
1568
+ const h = external_node_crypto_.createHash('sha256');
1569
+ for (const f of files) {
1570
+ try { h.update(f); h.update(external_node_fs_.readFileSync(external_node_path_.join(here, f))); } catch {}
1571
+ }
1572
+ return h.digest('hex');
1573
+ } catch { return null; }
1574
+ }
1575
+ const CODE_FINGERPRINT = _codeFingerprint();
1576
+
1577
+ function _err(id, code, message, data) {
1578
+ const out = { jsonrpc: '2.0', id, error: { code, message } };
1579
+ if (data !== undefined) out.error.data = data;
1580
+ return out;
1581
+ }
1582
+
1583
+ function _ok(id, result) {
1584
+ return { jsonrpc: '2.0', id, result };
1585
+ }
1586
+
1587
+ function createServer({ sessionRoot = process.cwd() } = {}) {
1588
+ const ctx = { sessionRoot };
1589
+
1590
+ async function handleRequest(msg) {
1591
+ if (!msg || typeof msg !== 'object') return _err(null, -32600, 'Invalid Request');
1592
+ if (msg.jsonrpc !== '2.0') return _err(msg.id ?? null, -32600, 'Invalid Request: jsonrpc must be "2.0"');
1593
+
1594
+ const isNotification = msg.id === undefined || msg.id === null;
1595
+ const id = msg.id ?? null;
1596
+ const disabled = process.env.AGENTIC_SECURITY_MCP_DISABLED === '1';
1597
+
1598
+ switch (msg.method) {
1599
+ case 'initialize':
1600
+ return _ok(id, {
1601
+ protocolVersion: PROTOCOL_VERSION,
1602
+ capabilities: { tools: {} },
1603
+ serverInfo: {
1604
+ name: SERVER_NAME,
1605
+ version: SERVER_VERSION,
1606
+ codeFingerprint: CODE_FINGERPRINT,
1607
+ disabled,
1608
+ },
1609
+ });
1610
+
1611
+ case 'notifications/initialized':
1612
+ return null;
1613
+
1614
+ case 'ping':
1615
+ return _ok(id, {});
1616
+
1617
+ case 'tools/list':
1618
+ return _ok(id, {
1619
+ tools: ALL_TOOLS.map(t => ({
1620
+ name: t.name,
1621
+ description: t.description,
1622
+ inputSchema: t.inputSchema,
1623
+ })),
1624
+ });
1625
+
1626
+ case 'tools/call': {
1627
+ const name = msg.params?.name;
1628
+ const args = msg.params?.arguments ?? {};
1629
+ if (disabled) {
1630
+ auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
1631
+ return _ok(id, {
1632
+ content: [{ type: 'text', text: 'MCP server is disabled (AGENTIC_SECURITY_MCP_DISABLED=1).' }],
1633
+ isError: true,
1634
+ });
1635
+ }
1636
+ const tool = TOOLS_BY_NAME[name];
1637
+ if (!tool) {
1638
+ auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
1639
+ return _err(id, -32602, `Unknown tool: ${name}`);
1640
+ }
1641
+ try { validate(tool.inputSchema, args); }
1642
+ catch (e) {
1643
+ auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
1644
+ return _ok(id, {
1645
+ content: [{ type: 'text', text: `Invalid arguments: ${e.message}` }],
1646
+ isError: true,
1647
+ });
1648
+ }
1649
+ try {
1650
+ const result = await tool.handler(args, ctx);
1651
+ auditCall({ sessionRoot, tool: name, args, outcome: 'ok' });
1652
+ return _ok(id, {
1653
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1654
+ isError: false,
1655
+ });
1656
+ } catch (e) {
1657
+ auditCall({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
1658
+ return _ok(id, {
1659
+ content: [{ type: 'text', text: `Error: ${e.message}` }],
1660
+ isError: true,
1661
+ });
1662
+ }
1663
+ }
1664
+
1665
+ default:
1666
+ if (isNotification) return null;
1667
+ return _err(id, -32601, `Method not found: ${msg.method}`);
1668
+ }
1669
+ }
1670
+
1671
+ return { handleRequest, sessionRoot };
1672
+ }
1673
+
1674
+ // NOTE: no default-singleton export. Callers must use createServer({...})
1675
+ // with an explicit sessionRoot. Removed because the prior default was bound
1676
+ // to process.cwd() at module-load time — a footgun for any caller that
1677
+ // imported `handleRequest` directly (OWASP A05).
1678
+
1679
+
1680
+
1681
+ ;// CONCATENATED MODULE: ./src/mcp/stdio.js
1682
+ // Stdio transport for the MCP server — newline-delimited JSON in/out.
1683
+ //
1684
+ // MCP's stdio transport is NDJSON: one JSON-RPC message per line on stdin,
1685
+ // one response per line on stdout. stderr is reserved for logging.
1686
+ //
1687
+ // Hardening:
1688
+ // - Per-message line cap (MAX_LINE_BYTES). A line over the cap is dropped
1689
+ // and the buffer state is reset so a long oversize payload can't peg
1690
+ // the parser via `buf += chunk` growth.
1691
+ // - Buffer hard cap (MAX_BUFFER_BYTES). Reached if input arrives with no
1692
+ // newlines (e.g., a peer streaming a 4GB stream of `a`). On overflow we
1693
+ // emit a parse-error response and reset.
1694
+
1695
+
1696
+
1697
+ const MAX_LINE_BYTES = 4 * 1024 * 1024; // 4 MB per JSON-RPC message
1698
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024; // 8 MB sliding buffer
1699
+
1700
+ function runStdio({
1701
+ stdin = process.stdin,
1702
+ stdout = process.stdout,
1703
+ stderr = process.stderr,
1704
+ sessionRoot = process.cwd(),
1705
+ } = {}) {
1706
+ const server = createServer({ sessionRoot });
1707
+ let buf = '';
1708
+ let overflowSkip = false; // true while we are dropping bytes until the next newline
1709
+
1710
+ stdin.setEncoding('utf8');
1711
+
1712
+ stdin.on('data', async (chunk) => {
1713
+ if (overflowSkip) {
1714
+ const nl = chunk.indexOf('\n');
1715
+ if (nl === -1) return;
1716
+ // Resume after the next newline.
1717
+ chunk = chunk.slice(nl + 1);
1718
+ overflowSkip = false;
1719
+ }
1720
+
1721
+ buf += chunk;
1722
+
1723
+ // Hard buffer cap — only triggers if a peer is streaming without newlines.
1724
+ if (buf.length > MAX_BUFFER_BYTES) {
1725
+ stderr.write(`mcp: input buffer exceeded ${MAX_BUFFER_BYTES} bytes — dropping until next newline\n`);
1726
+ const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: input too large' } };
1727
+ stdout.write(JSON.stringify(errResponse) + '\n');
1728
+ buf = '';
1729
+ overflowSkip = true;
1730
+ return;
1731
+ }
1732
+
1733
+ let nl;
1734
+ while ((nl = buf.indexOf('\n')) !== -1) {
1735
+ const line = buf.slice(0, nl).trim();
1736
+ buf = buf.slice(nl + 1);
1737
+ if (!line) continue;
1738
+ if (line.length > MAX_LINE_BYTES) {
1739
+ stderr.write(`mcp: dropped oversize line (${line.length} > ${MAX_LINE_BYTES} bytes)\n`);
1740
+ const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: line too large' } };
1741
+ stdout.write(JSON.stringify(errResponse) + '\n');
1742
+ continue;
1743
+ }
1744
+ let msg;
1745
+ try { msg = JSON.parse(line); }
1746
+ catch (e) {
1747
+ stderr.write(`mcp: failed to parse line as JSON: ${e.message}\n`);
1748
+ const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
1749
+ stdout.write(JSON.stringify(errResponse) + '\n');
1750
+ continue;
1751
+ }
1752
+ try {
1753
+ const response = await server.handleRequest(msg);
1754
+ if (response !== null) stdout.write(JSON.stringify(response) + '\n');
1755
+ } catch (e) {
1756
+ stderr.write(`mcp: handler threw: ${e.message}\n`);
1757
+ const errResponse = { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: 'Internal error', data: e.message } };
1758
+ stdout.write(JSON.stringify(errResponse) + '\n');
1759
+ }
1760
+ }
1761
+ });
1762
+
1763
+ stdin.on('end', () => { process.exit(0); });
1764
+ }
1765
+
1766
+
1767
+ /***/ })
1768
+
1769
+ };