@clear-capabilities/agentic-security-scanner 0.149.4 → 0.150.2
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/CHANGELOG.md +138 -0
- package/bin/agentic-security.js +533 -2
- package/dist/1122.index.js +16 -16
- package/dist/1208.index.js +23 -0
- package/dist/1301.index.js +3 -3
- package/dist/1310.index.js +3161 -0
- package/dist/1379.index.js +3 -3
- package/dist/1444.index.js +3 -3
- package/dist/1660.index.js +1 -1
- package/dist/1700.index.js +5 -5
- package/dist/1905.index.js +107 -12
- package/dist/1920.index.js +10 -10
- package/dist/2238.index.js +4 -4
- package/dist/2271.index.js +3 -3
- package/dist/2432.index.js +8 -8
- package/dist/2659.index.js +1 -1
- package/dist/2826.index.js +2 -2
- package/dist/2830.index.js +3 -3
- package/dist/2923.index.js +1 -1
- package/dist/3001.index.js +4 -4
- package/dist/3117.index.js +4 -4
- package/dist/3180.index.js +9 -9
- package/dist/3276.index.js +1 -1
- package/dist/3415.index.js +1 -1
- package/dist/3499.index.js +1 -1
- package/dist/3518.index.js +4 -4
- package/dist/3736.index.js +7 -7
- package/dist/3839.index.js +4 -4
- package/dist/4113.index.js +441 -418
- package/dist/4265.index.js +4 -4
- package/dist/4384.index.js +8 -3
- package/dist/4399.index.js +292 -0
- package/dist/4547.index.js +2 -2
- package/dist/4863.index.js +6 -6
- package/dist/4970.index.js +2 -2
- package/dist/5051.index.js +15 -15
- package/dist/5144.index.js +4 -4
- package/dist/5333.index.js +8 -8
- package/dist/5343.index.js +2 -2
- package/dist/5350.index.js +6 -6
- package/dist/5561.index.js +1 -1
- package/dist/5637.index.js +10 -5
- package/dist/5724.index.js +21642 -0
- package/dist/5756.index.js +588 -0
- package/dist/5830.index.js +3 -3
- package/dist/6257.index.js +170 -0
- package/dist/6626.index.js +5 -5
- package/dist/6662.index.js +1 -1
- package/dist/6675.index.js +4 -4
- package/dist/6730.index.js +10 -10
- package/dist/6829.index.js +4 -4
- package/dist/6944.index.js +2 -2
- package/dist/6994.index.js +143 -0
- package/dist/7039.index.js +541 -0
- package/dist/7178.index.js +11 -6
- package/dist/7227.index.js +3 -3
- package/dist/7552.index.js +2 -2
- package/dist/7709.index.js +2 -2
- package/dist/7838.index.js +1037 -0
- package/dist/8218.index.js +4 -4
- package/dist/8476.index.js +4 -4
- package/dist/8513.index.js +8 -8
- package/dist/8520.index.js +1 -1
- package/dist/8752.index.js +4 -4
- package/dist/9207.index.js +2 -2
- package/dist/9220.index.js +5 -5
- package/dist/9390.index.js +6 -6
- package/dist/9503.index.js +2 -2
- package/dist/9560.index.js +3221 -0
- package/dist/957.index.js +127 -0
- package/dist/9801.index.js +1 -1
- package/dist/9824.index.js +5 -5
- package/dist/agentic-security.mjs +23 -23
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/discovery/disprove.js +6 -1
- package/src/discovery/hunter.js +10 -1
- package/src/discovery/llm-invoke.js +77 -0
- package/src/egress/policy.js +11 -1
- package/src/egress/redact.js +1 -1
- package/src/engine.js +37 -1
- package/src/llm-validator/agent-loop.js +215 -0
- package/src/llm-validator/agent-tools.js +271 -0
- package/src/llm-validator/explain-proposal.js +106 -0
- package/src/llm-validator/fix-proposal.js +149 -0
- package/src/llm-validator/index.js +51 -3
- package/src/llm-validator/model-capabilities.js +269 -0
- package/src/llm-validator/model-probe.js +232 -0
- package/src/llm-validator/model-status.js +27 -0
- package/src/llm-validator/ollama-provider.js +382 -0
- package/src/llm-validator/oom-feedback.js +69 -0
- package/src/llm-validator/poc-proposal.js +122 -0
- package/src/llm-validator/providers.js +75 -0
- package/src/llm-validator/redact.js +177 -11
- package/src/report/index.js +33 -0
- package/src/sast/java-ast-folding.js +18 -2
|
@@ -0,0 +1,3161 @@
|
|
|
1
|
+
export const id = 1310;
|
|
2
|
+
export const ids = [1310,8218,9390,8752];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 9390:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ auditCall: () => (/* binding */ auditCall)
|
|
10
|
+
/* harmony export */ });
|
|
11
|
+
/* unused harmony export verifyAuditLog */
|
|
12
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
|
|
13
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6760);
|
|
14
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(7598);
|
|
15
|
+
/* harmony import */ var _redact_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(3468);
|
|
16
|
+
/* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(1174);
|
|
17
|
+
// Append-only audit log of MCP tool calls — OWASP MCP08.
|
|
18
|
+
//
|
|
19
|
+
// Format: one JSON object per line (NDJSON) at
|
|
20
|
+
// <sessionRoot>/.agentic-security/mcp-audit.log
|
|
21
|
+
//
|
|
22
|
+
// Each entry carries `prev` — the SHA-256 of the previous entry's serialized
|
|
23
|
+
// form. The first entry's prev is "GENESIS". Tampering with any line breaks
|
|
24
|
+
// the chain from that point forward; a reader can detect partial truncation
|
|
25
|
+
// or in-place edits.
|
|
26
|
+
//
|
|
27
|
+
// REMOTE SINK (post-recommendation #10). The local file alone cannot detect
|
|
28
|
+
// a total rewrite — an attacker with FS write can re-author the whole log
|
|
29
|
+
// with fresh hashes. Closing that blind spot requires an off-host witness.
|
|
30
|
+
// Set $AGENTIC_SECURITY_AUDIT_WEBHOOK to a POST endpoint; every entry is
|
|
31
|
+
// fire-and-forget POSTed there in addition to the local append. Failures
|
|
32
|
+
// to reach the webhook are best-effort — they NEVER block a tool call,
|
|
33
|
+
// because that would let a network outage become a denial of service. They
|
|
34
|
+
// DO get recorded as `_remoteSinkErr` on the local entry, so an operator
|
|
35
|
+
// reviewing the log later can spot a forging attempt that targeted the
|
|
36
|
+
// remote (any gap between local-sequence and remote-sequence is evidence).
|
|
37
|
+
//
|
|
38
|
+
// Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
|
|
39
|
+
// arguments cannot leak via the audit trail OR via the remote sink.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
const MAX_ARG_BYTES = 1024;
|
|
48
|
+
const GENESIS = 'GENESIS';
|
|
49
|
+
const REMOTE_TIMEOUT_MS = 1500;
|
|
50
|
+
|
|
51
|
+
// Per-process session ID (harness-anatomy #9). Stamped on every audit entry
|
|
52
|
+
// so downstream metrics can aggregate by session and surface outliers like
|
|
53
|
+
// "200 apply_fix calls in one session." The ID is `<pid>-<short-ts>` — not
|
|
54
|
+
// cryptographically unique, but enough to disambiguate concurrent runs on
|
|
55
|
+
// the same host. Stable for the lifetime of this Node process.
|
|
56
|
+
const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
|
|
57
|
+
|
|
58
|
+
function _summarize(args) {
|
|
59
|
+
let s;
|
|
60
|
+
try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
|
|
61
|
+
s = (0,_redact_js__WEBPACK_IMPORTED_MODULE_4__/* .redactArgsBlob */ .MC)(s);
|
|
62
|
+
if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _sha(s) { return node_crypto__WEBPACK_IMPORTED_MODULE_2__.createHash('sha256').update(s).digest('hex'); }
|
|
67
|
+
|
|
68
|
+
function _readLastEntryHash(logFile) {
|
|
69
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(logFile)) return GENESIS;
|
|
70
|
+
try {
|
|
71
|
+
const all = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(logFile, 'utf8');
|
|
72
|
+
const lines = all.split('\n').filter(Boolean);
|
|
73
|
+
if (!lines.length) return GENESIS;
|
|
74
|
+
return _sha(lines[lines.length - 1]);
|
|
75
|
+
} catch { return GENESIS; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fire-and-forget POST to the remote sink. Resolves to null on success,
|
|
79
|
+
// to a short error string on failure. Never throws; never blocks longer
|
|
80
|
+
// than REMOTE_TIMEOUT_MS. The local audit append happens regardless.
|
|
81
|
+
async function _postRemote(url, entry) {
|
|
82
|
+
try {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const t = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
|
|
85
|
+
const r = await fetch(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify(entry),
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
clearTimeout(t);
|
|
92
|
+
if (!r.ok) return `HTTP ${r.status}`;
|
|
93
|
+
return null;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return String((e && e.message) || e).slice(0, 200);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function auditCall({ sessionRoot, tool, args, outcome, reason }) {
|
|
100
|
+
if (!sessionRoot) return;
|
|
101
|
+
try {
|
|
102
|
+
// Safety: only write audit log if sessionRoot looks like a project root
|
|
103
|
+
const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
|
|
104
|
+
let hasMarker = false;
|
|
105
|
+
for (const m of MARKERS) { try { if (node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
|
|
106
|
+
if (!hasMarker) return;
|
|
107
|
+
const dir = (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_3__/* .stateDir */ .Pn)(sessionRoot);
|
|
108
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
|
|
109
|
+
const logFile = node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, 'mcp-audit.log');
|
|
110
|
+
const entry = {
|
|
111
|
+
ts: new Date().toISOString(),
|
|
112
|
+
sessionId: SESSION_ID,
|
|
113
|
+
tool,
|
|
114
|
+
outcome,
|
|
115
|
+
...(reason ? { reason } : {}),
|
|
116
|
+
args: _summarize(args),
|
|
117
|
+
prev: _readLastEntryHash(logFile),
|
|
118
|
+
};
|
|
119
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.appendFileSync(logFile, JSON.stringify(entry) + '\n');
|
|
120
|
+
// Remote sink (post-recommendation #10). Fire-and-forget. We don't await
|
|
121
|
+
// the promise so the tool call returns immediately; the remote POST runs
|
|
122
|
+
// on its own microtask. Failures get logged to a sidecar file so the
|
|
123
|
+
// operator can detect when the sink is unreachable.
|
|
124
|
+
const webhook = process.env.AGENTIC_SECURITY_AUDIT_WEBHOOK;
|
|
125
|
+
if (webhook) {
|
|
126
|
+
_postRemote(webhook, entry).then((err) => {
|
|
127
|
+
if (!err) return;
|
|
128
|
+
try {
|
|
129
|
+
const errFile = node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, 'mcp-audit.remote-errors.log');
|
|
130
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.appendFileSync(errFile, JSON.stringify({
|
|
131
|
+
ts: new Date().toISOString(), entryTs: entry.ts, tool, err,
|
|
132
|
+
}) + '\n');
|
|
133
|
+
} catch { /* nothing else to do */ }
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
} catch { /* audit failure must never break a tool call */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Verify the chain from start to end. Returns
|
|
140
|
+
// { ok: true, entries: N } if intact
|
|
141
|
+
// { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
|
|
142
|
+
// Reader/operator-facing tool.
|
|
143
|
+
function verifyAuditLog(logFile) {
|
|
144
|
+
if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
|
|
145
|
+
const text = fs.readFileSync(logFile, 'utf8');
|
|
146
|
+
const lines = text.split('\n').filter(Boolean);
|
|
147
|
+
let expectedPrev = GENESIS;
|
|
148
|
+
for (let i = 0; i < lines.length; i++) {
|
|
149
|
+
let entry;
|
|
150
|
+
try { entry = JSON.parse(lines[i]); }
|
|
151
|
+
catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
|
|
152
|
+
if (entry.prev !== expectedPrev) {
|
|
153
|
+
return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
|
|
154
|
+
}
|
|
155
|
+
expectedPrev = _sha(lines[i]);
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, entries: lines.length };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
/***/ }),
|
|
162
|
+
|
|
163
|
+
/***/ 1310:
|
|
164
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
// EXPORTS
|
|
168
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
169
|
+
runStdio: () => (/* binding */ runStdio)
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
173
|
+
var external_node_fs_ = __webpack_require__(3024);
|
|
174
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
175
|
+
var external_node_crypto_ = __webpack_require__(7598);
|
|
176
|
+
// EXTERNAL MODULE: external "node:path"
|
|
177
|
+
var external_node_path_ = __webpack_require__(6760);
|
|
178
|
+
// EXTERNAL MODULE: external "node:url"
|
|
179
|
+
var external_node_url_ = __webpack_require__(3136);
|
|
180
|
+
// EXTERNAL MODULE: external "node:fs/promises"
|
|
181
|
+
var promises_ = __webpack_require__(1455);
|
|
182
|
+
// EXTERNAL MODULE: ./src/posture/fix-history.js
|
|
183
|
+
var fix_history = __webpack_require__(4407);
|
|
184
|
+
// EXTERNAL MODULE: ./src/fix/apply-fix-service.js
|
|
185
|
+
var apply_fix_service = __webpack_require__(7730);
|
|
186
|
+
// EXTERNAL MODULE: ./src/posture/material-change.js
|
|
187
|
+
var material_change = __webpack_require__(4629);
|
|
188
|
+
// EXTERNAL MODULE: ./src/fix/approver-registry.js
|
|
189
|
+
var approver_registry = __webpack_require__(437);
|
|
190
|
+
;// CONCATENATED MODULE: ./src/posture/deterministic-fix.js
|
|
191
|
+
// Deterministic fix synthesis (#1) — for the narrow set of vulnerability classes
|
|
192
|
+
// where a context-INDEPENDENT literal swap is a safe, correct fix, produce a
|
|
193
|
+
// full-file replacement from the current file content. No LLM, no guessing, no
|
|
194
|
+
// per-finding bloat in last-scan.json (the patch is materialized on demand by
|
|
195
|
+
// synthesize_fix from the live file, not stored on every finding).
|
|
196
|
+
//
|
|
197
|
+
// Safety: every patch this produces is still gated by verify_fix before apply_fix
|
|
198
|
+
// writes it (original finding gone + no new ≥medium + lint clean). So a swap that
|
|
199
|
+
// a rule mis-attributed simply fails verification instead of landing a bad edit —
|
|
200
|
+
// this module widens the deterministic-fix surface without weakening the gate.
|
|
201
|
+
//
|
|
202
|
+
// Returns { patch: { [relFile]: newContent }, ruleId } or null when no
|
|
203
|
+
// deterministic fix applies to the finding.
|
|
204
|
+
|
|
205
|
+
const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
206
|
+
const PY_EXT = /\.py$/i;
|
|
207
|
+
|
|
208
|
+
// Each rule gates on the finding's cwe/family, then rewrites the whole-file
|
|
209
|
+
// content. transform() returns the new content, or null when nothing changed
|
|
210
|
+
// (e.g. the vulnerable token isn't literally present — then we don't claim a fix).
|
|
211
|
+
const RULES = [
|
|
212
|
+
{
|
|
213
|
+
id: 'weak-hash-sha256',
|
|
214
|
+
// md5 / sha1 → sha256. Every occurrence in the file is a weak hash, so
|
|
215
|
+
// swapping them all is safe; the verifier confirms the weak-hash finding is
|
|
216
|
+
// gone and nothing worse appeared.
|
|
217
|
+
applies: (f) => /CWE-(?:327|328|916)/.test(f.cwe || '') || /weak.?hash/i.test(f.family || ''),
|
|
218
|
+
transform: (content, file) => {
|
|
219
|
+
let out = content;
|
|
220
|
+
if (JS_EXT.test(file)) {
|
|
221
|
+
out = out.replace(/(\bcreateHash\s*\(\s*['"`])(?:md5|sha1)(['"`])/gi, '$1sha256$2');
|
|
222
|
+
} else if (PY_EXT.test(file)) {
|
|
223
|
+
out = out.replace(/\bhashlib\.(?:md5|sha1)\s*\(/g, 'hashlib.sha256(');
|
|
224
|
+
}
|
|
225
|
+
return out !== content ? out : null;
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'tls-verify-on',
|
|
230
|
+
// Disabled TLS verification → enabled. rejectUnauthorized:false → true (JS),
|
|
231
|
+
// verify=False → verify=True (Python requests).
|
|
232
|
+
applies: (f) => /CWE-295/.test(f.cwe || '') || /tls.?no.?verify|cert.?(?:none|verify)/i.test(f.family || ''),
|
|
233
|
+
transform: (content, file) => {
|
|
234
|
+
let out = content;
|
|
235
|
+
if (JS_EXT.test(file)) {
|
|
236
|
+
out = out.replace(/(\brejectUnauthorized\s*:\s*)false\b/g, '$1true');
|
|
237
|
+
} else if (PY_EXT.test(file)) {
|
|
238
|
+
out = out.replace(/(\bverify\s*=\s*)False\b/g, '$1True');
|
|
239
|
+
}
|
|
240
|
+
return out !== content ? out : null;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
function synthesizeDeterministicPatch(finding, fileContent) {
|
|
246
|
+
if (!finding || typeof fileContent !== 'string' || !finding.file) return null;
|
|
247
|
+
for (const rule of RULES) {
|
|
248
|
+
try {
|
|
249
|
+
if (!rule.applies(finding)) continue;
|
|
250
|
+
const next = rule.transform(fileContent, finding.file);
|
|
251
|
+
if (next && next !== fileContent) return { patch: { [finding.file]: next }, ruleId: rule.id };
|
|
252
|
+
} catch { /* a single rule failing must never break synthesis */ }
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// EXTERNAL MODULE: ./src/posture/integrity.js
|
|
258
|
+
var integrity = __webpack_require__(1130);
|
|
259
|
+
// EXTERNAL MODULE: ./src/posture/state-dir.js
|
|
260
|
+
var state_dir = __webpack_require__(1174);
|
|
261
|
+
// EXTERNAL MODULE: ./src/posture/cache-economics.js
|
|
262
|
+
var cache_economics = __webpack_require__(8752);
|
|
263
|
+
// EXTERNAL MODULE: ./src/mcp/redact.js
|
|
264
|
+
var redact = __webpack_require__(3468);
|
|
265
|
+
// EXTERNAL MODULE: ./src/report/index.js + 3 modules
|
|
266
|
+
var report = __webpack_require__(457);
|
|
267
|
+
// EXTERNAL MODULE: ./src/posture/provenance/schema.js
|
|
268
|
+
var schema = __webpack_require__(4594);
|
|
269
|
+
// EXTERNAL MODULE: ./src/server/graph-loader.js
|
|
270
|
+
var graph_loader = __webpack_require__(8218);
|
|
271
|
+
// EXTERNAL MODULE: ./src/server/routes.js
|
|
272
|
+
var routes = __webpack_require__(4268);
|
|
273
|
+
// EXTERNAL MODULE: ./src/lineage/redact-graph.js
|
|
274
|
+
var redact_graph = __webpack_require__(334);
|
|
275
|
+
// EXTERNAL MODULE: ./src/lineage/export-json.js
|
|
276
|
+
var export_json = __webpack_require__(859);
|
|
277
|
+
;// CONCATENATED MODULE: ./src/mcp/dataflow-tools.js
|
|
278
|
+
// dataflow-tools.js — Milestone 4, sub-project MCP tools.
|
|
279
|
+
//
|
|
280
|
+
// Thin, read-only MCP adapter over the DataFlowGraph v1 artifact. Every
|
|
281
|
+
// piece of actual graph-loading and graph-query logic here is REUSED,
|
|
282
|
+
// unmodified, from scanner/src/server/ (built for the `explore` HTTP
|
|
283
|
+
// server, Milestone 3): loadSignedGraph does the signed-artifact
|
|
284
|
+
// load+verify, the four handleX functions do the lookups. This module
|
|
285
|
+
// adds nothing but MCP tool shape (name/description/inputSchema/handler)
|
|
286
|
+
// and MCP-appropriate error handling — no new graph-query logic is
|
|
287
|
+
// written here, on purpose (see this sub-project's own scoping doc).
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
|
|
295
|
+
|
|
296
|
+
function _loadOrFailure(sessionRoot) {
|
|
297
|
+
const loaded = (0,graph_loader.loadSignedGraph)(sessionRoot);
|
|
298
|
+
if (loaded.ok) return { graph: loaded.graph };
|
|
299
|
+
return {
|
|
300
|
+
failure: {
|
|
301
|
+
_meta: META,
|
|
302
|
+
hasResult: false,
|
|
303
|
+
reason: loaded.reason,
|
|
304
|
+
message: loaded.message,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Milestone 5, large-graph pagination: an optional `filter` input narrows the
|
|
310
|
+
// returned graph via the exact same `validateFilterShape`/`_filterGraph`
|
|
311
|
+
// pair the CLI's own `--filter` and the `explore` server's new
|
|
312
|
+
// `POST /api/v1/query` endpoint both already use — one real, shared
|
|
313
|
+
// primitive, not a third drifting copy. KNOWN, DISCLOSED GAP (still open):
|
|
314
|
+
// an OMITTED filter still returns the whole graph inline, with the same
|
|
315
|
+
// stdio.js MAX_LINE_BYTES (4MB) risk on a very large, unfiltered scan as
|
|
316
|
+
// before this change — this increment adds an opt-in capability for a
|
|
317
|
+
// caller that supplies a filter, it does not add a forced fallback/offload
|
|
318
|
+
// for a caller that doesn't. That remains a follow-up increment.
|
|
319
|
+
// Final whole-branch review finding: `filter: {}` is NOT the same as
|
|
320
|
+
// omitting `filter` — `_filterGraph` treats an empty (but well-formed)
|
|
321
|
+
// filter object as "narrow to nothing" (empty nodeIds/edgeIds Sets), so
|
|
322
|
+
// `filter: {}` returns an EMPTY graph (zero nodes/edges/flows), not the
|
|
323
|
+
// whole one. Called out explicitly in this tool's own `description` below
|
|
324
|
+
// so an agent reaching for "no restriction" reaches for OMITTING the
|
|
325
|
+
// argument, never for `{}`.
|
|
326
|
+
const dataflow_get_graph = {
|
|
327
|
+
name: 'dataflow_get_graph',
|
|
328
|
+
description: 'Return the DataFlowGraph v1 artifact from the last signed, verified deep-mode scan: nodes, edges, flows, scope, coverage, and limitations. Requires a prior `AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan`. Optional `filter: {nodeIds, edgeIds}` narrows the returned nodes/edges/flows/dataElements (same primitive as the CLI\'s `--filter` and the `explore` server\'s `POST /api/v1/query`). IMPORTANT: omit `filter` entirely for the whole graph — passing `filter: {}` returns an EMPTY graph (zero nodes/edges/flows), not the whole one, since an empty filter narrows to nothing rather than meaning "no restriction". KNOWN GAP: an OMITTED filter still returns the whole graph inline with no pagination/offload — may exceed the stdio transport line cap on a very large, unfiltered graph; supply a real, non-empty filter to narrow the response.',
|
|
329
|
+
inputSchema: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
additionalProperties: false,
|
|
332
|
+
properties: {
|
|
333
|
+
filter: {
|
|
334
|
+
type: 'object',
|
|
335
|
+
additionalProperties: false,
|
|
336
|
+
properties: {
|
|
337
|
+
nodeIds: { type: 'array', items: { type: 'string' } },
|
|
338
|
+
edgeIds: { type: 'array', items: { type: 'string' } },
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
async handler(args, ctx) {
|
|
344
|
+
const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
|
|
345
|
+
if (failure) return failure;
|
|
346
|
+
// Milestone 5, large-graph pagination: reuses the exact same
|
|
347
|
+
// validateFilterShape/_filterGraph pair the new POST /api/v1/query
|
|
348
|
+
// server endpoint and the CLI's own --filter both use — one real,
|
|
349
|
+
// shared primitive, not a third drifting copy.
|
|
350
|
+
const filterCheck = (0,export_json.validateFilterShape)(args?.filter);
|
|
351
|
+
if (!filterCheck.valid) {
|
|
352
|
+
return { _meta: META, hasResult: false, reason: 'invalid-filter', message: filterCheck.error };
|
|
353
|
+
}
|
|
354
|
+
const { status, body } = (0,routes/* handleGraph */.fn)(graph);
|
|
355
|
+
return {
|
|
356
|
+
_meta: META,
|
|
357
|
+
hasResult: true,
|
|
358
|
+
status,
|
|
359
|
+
data: (0,redact_graph/* _redactGraph */.zl)(args?.filter ? (0,export_json/* _filterGraph */.e)(body.data, args.filter) : body.data),
|
|
360
|
+
digest: body.digest,
|
|
361
|
+
schemaVersion: body.schemaVersion,
|
|
362
|
+
extensions: body.extensions,
|
|
363
|
+
scope: body.scope,
|
|
364
|
+
coverage: body.coverage,
|
|
365
|
+
limitations: body.limitations,
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const dataflow_get_node = {
|
|
371
|
+
name: 'dataflow_get_node',
|
|
372
|
+
description: 'Look up one node by canonical id in the DataFlowGraph v1 artifact.',
|
|
373
|
+
inputSchema: {
|
|
374
|
+
type: 'object',
|
|
375
|
+
additionalProperties: false,
|
|
376
|
+
properties: { id: { type: 'string', minLength: 1, maxLength: 512 } },
|
|
377
|
+
required: ['id'],
|
|
378
|
+
},
|
|
379
|
+
async handler({ id }, ctx) {
|
|
380
|
+
const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
|
|
381
|
+
if (failure) return failure;
|
|
382
|
+
const { status, body } = (0,routes/* handleNode */.d5)(graph, id);
|
|
383
|
+
return {
|
|
384
|
+
_meta: META,
|
|
385
|
+
hasResult: true,
|
|
386
|
+
notFound: status === 404,
|
|
387
|
+
data: (0,redact_graph/* _redactNode */.T2)(body.data),
|
|
388
|
+
canonicalIds: body.canonicalIds,
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const dataflow_get_edge = {
|
|
394
|
+
name: 'dataflow_get_edge',
|
|
395
|
+
description: 'Look up one edge by canonical id in the DataFlowGraph v1 artifact.',
|
|
396
|
+
inputSchema: {
|
|
397
|
+
type: 'object',
|
|
398
|
+
additionalProperties: false,
|
|
399
|
+
properties: { id: { type: 'string', minLength: 1, maxLength: 512 } },
|
|
400
|
+
required: ['id'],
|
|
401
|
+
},
|
|
402
|
+
async handler({ id }, ctx) {
|
|
403
|
+
const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
|
|
404
|
+
if (failure) return failure;
|
|
405
|
+
const { status, body } = (0,routes/* handleEdge */.Yu)(graph, id);
|
|
406
|
+
return {
|
|
407
|
+
_meta: META,
|
|
408
|
+
hasResult: true,
|
|
409
|
+
notFound: status === 404,
|
|
410
|
+
data: body.data,
|
|
411
|
+
canonicalIds: body.canonicalIds,
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const dataflow_get_flow = {
|
|
417
|
+
name: 'dataflow_get_flow',
|
|
418
|
+
description: 'Look up one flow by canonical id in the DataFlowGraph v1 artifact, including its contributing node/edge canonical ids.',
|
|
419
|
+
inputSchema: {
|
|
420
|
+
type: 'object',
|
|
421
|
+
additionalProperties: false,
|
|
422
|
+
properties: { id: { type: 'string', minLength: 1, maxLength: 512 } },
|
|
423
|
+
required: ['id'],
|
|
424
|
+
},
|
|
425
|
+
async handler({ id }, ctx) {
|
|
426
|
+
const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
|
|
427
|
+
if (failure) return failure;
|
|
428
|
+
const { status, body } = (0,routes/* handleFlow */.jg)(graph, id);
|
|
429
|
+
return {
|
|
430
|
+
_meta: META,
|
|
431
|
+
hasResult: true,
|
|
432
|
+
notFound: status === 404,
|
|
433
|
+
data: body.data,
|
|
434
|
+
canonicalIds: body.canonicalIds,
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
;// CONCATENATED MODULE: ./src/posture/agents-memory.js
|
|
440
|
+
// AGENTS.md — writable continual-learning memory (harness-anatomy #2).
|
|
441
|
+
//
|
|
442
|
+
// LangChain post:
|
|
443
|
+
// "Harnesses support memory file standards like AGENTS.md which get
|
|
444
|
+
// injected into context on agent start. As agents add and edit this file,
|
|
445
|
+
// harnesses load the updated file into context. This is a form of
|
|
446
|
+
// continual learning where agents durably store knowledge from one
|
|
447
|
+
// session and inject that knowledge into future sessions."
|
|
448
|
+
//
|
|
449
|
+
// Distinct from CLAUDE.md:
|
|
450
|
+
// - CLAUDE.md = human-authored project conventions, gotchas, layout.
|
|
451
|
+
// - AGENTS.md = agent-authored notes ("what worked / didn't work / I'd try
|
|
452
|
+
// differently next time"). Append-only. Bounded.
|
|
453
|
+
//
|
|
454
|
+
// Lives at `<project>/.agentic-security/AGENTS.md`.
|
|
455
|
+
//
|
|
456
|
+
// Bounds:
|
|
457
|
+
// - MAX_BYTES (default 20 KB) — past this, the oldest entries rotate to
|
|
458
|
+
// `AGENTS.md.archive` (also bounded; oldest archive entries are dropped).
|
|
459
|
+
// - MAX_ENTRY_BYTES (default 2 KB) — caps a single appendage.
|
|
460
|
+
// - Entries are append-only with an ISO timestamp + section divider, so
|
|
461
|
+
// readers can grep / slice by date without parsing.
|
|
462
|
+
//
|
|
463
|
+
// We deliberately avoid tying AGENTS.md to a session-id namespace. The post's
|
|
464
|
+
// recommendation is FLAT continual learning — the whole project's agents see
|
|
465
|
+
// each other's notes. Subagents that want session-scoped scratch use the
|
|
466
|
+
// agent-scratchpad surface instead.
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
const MEMORY_FILE = '.agentic-security/AGENTS.md';
|
|
473
|
+
const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
|
|
474
|
+
const MAX_BYTES = 20 * 1024;
|
|
475
|
+
const MAX_ENTRY_BYTES = 2 * 1024;
|
|
476
|
+
const ARCHIVE_MAX_BYTES = 200 * 1024;
|
|
477
|
+
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';
|
|
478
|
+
|
|
479
|
+
function _resolve(scanRoot) { return (0,state_dir.statePath)(scanRoot, 'AGENTS.md'); }
|
|
480
|
+
function _archivePath(scanRoot) { return (0,state_dir.statePath)(scanRoot, 'AGENTS.md.archive'); }
|
|
481
|
+
|
|
482
|
+
function readAgentsMemory(scanRoot) {
|
|
483
|
+
const fp = _resolve(scanRoot);
|
|
484
|
+
if (!external_node_fs_.existsSync(fp)) return '';
|
|
485
|
+
try { return external_node_fs_.readFileSync(fp, 'utf8'); } catch { return ''; }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function appendAgentsMemory(scanRoot, { agent, body }) {
|
|
489
|
+
if (typeof agent !== 'string' || !agent.length) {
|
|
490
|
+
return { ok: false, reason: 'agent: required string' };
|
|
491
|
+
}
|
|
492
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(agent)) {
|
|
493
|
+
return { ok: false, reason: 'agent: must match [A-Za-z0-9_.-]{1,64}' };
|
|
494
|
+
}
|
|
495
|
+
if (typeof body !== 'string' || !body.trim().length) {
|
|
496
|
+
return { ok: false, reason: 'body: required non-empty string' };
|
|
497
|
+
}
|
|
498
|
+
let snippet = body.trim();
|
|
499
|
+
// Strip control chars and cap.
|
|
500
|
+
snippet = snippet.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, ' ');
|
|
501
|
+
if (snippet.length > MAX_ENTRY_BYTES) {
|
|
502
|
+
snippet = snippet.slice(0, MAX_ENTRY_BYTES) + '…';
|
|
503
|
+
}
|
|
504
|
+
const ts = new Date().toISOString();
|
|
505
|
+
const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
|
|
506
|
+
try {
|
|
507
|
+
const fp = _resolve(scanRoot);
|
|
508
|
+
if (!(0,state_dir.stateWritesEnabled)()) return;
|
|
509
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(fp), { recursive: true });
|
|
510
|
+
if (!external_node_fs_.existsSync(fp)) external_node_fs_.writeFileSync(fp, HEADER);
|
|
511
|
+
external_node_fs_.appendFileSync(fp, entry);
|
|
512
|
+
_maybeRotate(scanRoot);
|
|
513
|
+
const stat = external_node_fs_.statSync(fp);
|
|
514
|
+
return { ok: true, entryBytes: entry.length, fileSize: stat.size };
|
|
515
|
+
} catch (e) {
|
|
516
|
+
return { ok: false, reason: `write-failed: ${e.message}` };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _maybeRotate(scanRoot) {
|
|
521
|
+
const fp = _resolve(scanRoot);
|
|
522
|
+
let body;
|
|
523
|
+
try { body = external_node_fs_.readFileSync(fp, 'utf8'); } catch { return; }
|
|
524
|
+
if (body.length <= MAX_BYTES) return;
|
|
525
|
+
// Split on the `## ` entry headers. Keep the most-recent N until the head
|
|
526
|
+
// (everything before the cut) drops below MAX_BYTES/2; move the head to
|
|
527
|
+
// the archive.
|
|
528
|
+
const head = HEADER;
|
|
529
|
+
const trailing = body.slice(head.length);
|
|
530
|
+
const sections = trailing.split(/(?=\n## )/g).filter(s => s.length);
|
|
531
|
+
// Walk from the end, accumulating until we have roughly MAX_BYTES/2 of
|
|
532
|
+
// recent entries. Everything else goes to the archive.
|
|
533
|
+
let kept = '', archive = '', accum = 0;
|
|
534
|
+
for (let i = sections.length - 1; i >= 0; i--) {
|
|
535
|
+
if (accum + sections[i].length <= MAX_BYTES / 2) {
|
|
536
|
+
kept = sections[i] + kept;
|
|
537
|
+
accum += sections[i].length;
|
|
538
|
+
} else {
|
|
539
|
+
archive = sections.slice(0, i + 1).join('') + archive;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
external_node_fs_.writeFileSync(fp, head + kept);
|
|
545
|
+
if (archive.length) {
|
|
546
|
+
const arcFp = _archivePath(scanRoot);
|
|
547
|
+
let existing = '';
|
|
548
|
+
try { existing = external_node_fs_.existsSync(arcFp) ? external_node_fs_.readFileSync(arcFp, 'utf8') : ''; } catch {}
|
|
549
|
+
let next = existing + archive;
|
|
550
|
+
if (next.length > ARCHIVE_MAX_BYTES) {
|
|
551
|
+
// Drop oldest entries until under cap.
|
|
552
|
+
const oldestSplit = next.split(/(?=\n## )/g).filter(s => s.length);
|
|
553
|
+
while (oldestSplit.length && next.length > ARCHIVE_MAX_BYTES) {
|
|
554
|
+
oldestSplit.shift();
|
|
555
|
+
next = oldestSplit.join('');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
external_node_fs_.writeFileSync(arcFp, next);
|
|
559
|
+
}
|
|
560
|
+
} catch { /* best-effort rotation */ }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Public summary helper for the SessionStart hook. Returns a tail aligned
|
|
564
|
+
// to a section header (no leading partial entry, no leading newline).
|
|
565
|
+
function summarizeForSession(scanRoot, { maxBytes = 6 * 1024 } = {}) {
|
|
566
|
+
const body = readAgentsMemory(scanRoot);
|
|
567
|
+
if (!body) return null;
|
|
568
|
+
if (body.length <= maxBytes) return body;
|
|
569
|
+
const tail = body.slice(-maxBytes);
|
|
570
|
+
const firstSection = tail.indexOf('\n## ');
|
|
571
|
+
if (firstSection < 0) return tail;
|
|
572
|
+
// Slice past the leading `\n` so the result starts with `## `.
|
|
573
|
+
return tail.slice(firstSection + 1);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const _internals = { MAX_BYTES, MAX_ENTRY_BYTES, MEMORY_FILE, ARCHIVE_FILE };
|
|
577
|
+
|
|
578
|
+
// EXTERNAL MODULE: external "node:os"
|
|
579
|
+
var external_node_os_ = __webpack_require__(8161);
|
|
580
|
+
;// CONCATENATED MODULE: ./src/posture/cve-lookup.js
|
|
581
|
+
// CVE lookup — read-only against the per-install OSV / KEV / EPSS caches.
|
|
582
|
+
//
|
|
583
|
+
// LangChain harness-anatomy post:
|
|
584
|
+
// "Knowledge cutoffs mean that models can't directly access new data like
|
|
585
|
+
// updated library versions without the user providing them directly."
|
|
586
|
+
//
|
|
587
|
+
// The validator and any subagent reasoning about an SCA finding can call
|
|
588
|
+
// `lookup_cve(cve_id)` to get the most recently-cached OSV advisory, the
|
|
589
|
+
// CISA KEV entry if listed, and the EPSS exploit-prediction percentile, all
|
|
590
|
+
// with `staleness` metadata so the caller can decide whether to trust the
|
|
591
|
+
// cached value.
|
|
592
|
+
//
|
|
593
|
+
// This module deliberately NEVER triggers a network fetch — the scan
|
|
594
|
+
// pipeline is the only thing that populates the cache. If a CVE isn't
|
|
595
|
+
// cached, we return `present: false` for that source rather than blocking
|
|
596
|
+
// on a fetch and risking a multi-second MCP timeout.
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
const CACHE_DIR = external_node_path_.join(external_node_os_.homedir(), '.claude', 'agentic-security', 'osv-cache');
|
|
604
|
+
|
|
605
|
+
function _keyToPath(key) {
|
|
606
|
+
const safe = external_node_crypto_.createHash('sha256').update(key).digest('hex');
|
|
607
|
+
return external_node_path_.join(CACHE_DIR, safe + '.json');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function _readCache(key) {
|
|
611
|
+
const fp = _keyToPath(key);
|
|
612
|
+
if (!external_node_fs_.existsSync(fp)) return { present: false };
|
|
613
|
+
let body;
|
|
614
|
+
try { body = external_node_fs_.readFileSync(fp, 'utf8'); }
|
|
615
|
+
catch { return { present: false, error: 'unreadable' }; }
|
|
616
|
+
let parsed;
|
|
617
|
+
try { parsed = JSON.parse(body); }
|
|
618
|
+
catch { return { present: false, error: 'unparseable' }; }
|
|
619
|
+
let mtime = null;
|
|
620
|
+
try { mtime = external_node_fs_.statSync(fp).mtimeMs; } catch {}
|
|
621
|
+
return { present: true, data: parsed, cachedAt: mtime, ageMs: mtime ? Date.now() - mtime : null };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function _stalenessTier(ageMs) {
|
|
625
|
+
if (ageMs === null || ageMs === undefined) return 'unknown';
|
|
626
|
+
if (ageMs < 24 * 3600 * 1000) return 'fresh'; // <1d
|
|
627
|
+
if (ageMs < 7 * 24 * 3600 * 1000) return 'recent'; // <1w
|
|
628
|
+
if (ageMs < 30 * 24 * 3600 * 1000) return 'stale'; // <1mo
|
|
629
|
+
return 'very-stale';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const CVE_RE = /^CVE-\d{4}-\d{1,7}$/i;
|
|
633
|
+
|
|
634
|
+
function lookupCve(rawId) {
|
|
635
|
+
if (typeof rawId !== 'string' || !CVE_RE.test(rawId)) {
|
|
636
|
+
return { ok: false, reason: 'invalid-cve-id', expected: 'CVE-YYYY-NNNN' };
|
|
637
|
+
}
|
|
638
|
+
const cve = rawId.toUpperCase();
|
|
639
|
+
|
|
640
|
+
// KEV catalog — single cached blob keyed at 'kev:catalog'.
|
|
641
|
+
const kevCacheRaw = _readCache('kev:catalog');
|
|
642
|
+
let kev = { present: false };
|
|
643
|
+
if (kevCacheRaw.present) {
|
|
644
|
+
// The blob shape from engine.js: { ts, byCve: { 'CVE-XXX': { ... } } }
|
|
645
|
+
// sessionStorage shim stores the value as the JSON-stringified inner
|
|
646
|
+
// object directly (no extra wrapper).
|
|
647
|
+
const blob = kevCacheRaw.data;
|
|
648
|
+
const byCve = blob?.byCve || null;
|
|
649
|
+
if (byCve && byCve[cve]) {
|
|
650
|
+
kev = {
|
|
651
|
+
present: true,
|
|
652
|
+
...byCve[cve],
|
|
653
|
+
cachedAt: kevCacheRaw.cachedAt,
|
|
654
|
+
ageMs: kevCacheRaw.ageMs,
|
|
655
|
+
staleness: _stalenessTier(kevCacheRaw.ageMs),
|
|
656
|
+
};
|
|
657
|
+
} else if (byCve) {
|
|
658
|
+
// Catalog is cached but doesn't list this CVE — meaningful negative.
|
|
659
|
+
kev = {
|
|
660
|
+
present: false, listedInCatalog: false,
|
|
661
|
+
cachedAt: kevCacheRaw.cachedAt, ageMs: kevCacheRaw.ageMs,
|
|
662
|
+
staleness: _stalenessTier(kevCacheRaw.ageMs),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// EPSS — per-CVE cache at 'epss:CVE-XXX'.
|
|
668
|
+
const epssRaw = _readCache('epss:' + cve);
|
|
669
|
+
let epss = { present: false };
|
|
670
|
+
if (epssRaw.present) {
|
|
671
|
+
epss = {
|
|
672
|
+
present: epssRaw.data !== false, // engine stores `false` for "looked up, no record"
|
|
673
|
+
score: epssRaw.data?.score ?? null,
|
|
674
|
+
percentile: epssRaw.data?.percentile ?? null,
|
|
675
|
+
cachedAt: epssRaw.cachedAt,
|
|
676
|
+
ageMs: epssRaw.ageMs,
|
|
677
|
+
staleness: _stalenessTier(epssRaw.ageMs),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// OSV — entries are keyed by vuln id (GHSA-... or CVE-...). The engine
|
|
682
|
+
// caches them at 'vuln:<id>'. We do a direct CVE lookup AND a soft probe
|
|
683
|
+
// for any known alias the caller provided implicitly through the KEV
|
|
684
|
+
// hit's vendor/product (no — we keep this simple: direct lookup only).
|
|
685
|
+
const osvRaw = _readCache('vuln:' + cve);
|
|
686
|
+
let osv = { present: false };
|
|
687
|
+
if (osvRaw.present) {
|
|
688
|
+
osv = {
|
|
689
|
+
present: true,
|
|
690
|
+
data: osvRaw.data,
|
|
691
|
+
cachedAt: osvRaw.cachedAt, ageMs: osvRaw.ageMs,
|
|
692
|
+
staleness: _stalenessTier(osvRaw.ageMs),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
ok: true,
|
|
698
|
+
cve,
|
|
699
|
+
kev,
|
|
700
|
+
epss,
|
|
701
|
+
osv,
|
|
702
|
+
sourcesFound: [kev.present, epss.present, osv.present].filter(Boolean).length,
|
|
703
|
+
note: (kev.present || epss.present || osv.present)
|
|
704
|
+
? 'cached values only; staleness tier per source. The MCP tool does NOT trigger a network fetch.'
|
|
705
|
+
: '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.',
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const cve_lookup_internals = { CACHE_DIR, CVE_RE, _stalenessTier };
|
|
710
|
+
|
|
711
|
+
;// CONCATENATED MODULE: ./src/mcp/tools.js
|
|
712
|
+
// MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
|
|
713
|
+
// Top 10 (see ./redact.js, ./audit.js, ./server.js for sibling controls).
|
|
714
|
+
//
|
|
715
|
+
// Trust model:
|
|
716
|
+
// - Session root fixed at server boot. No per-call retargeting.
|
|
717
|
+
// - Path arguments lstat-checked (symlinks refused, OWASP MCP05) and
|
|
718
|
+
// realpath-confined to session root.
|
|
719
|
+
// - Tool outputs marked _meta.untrusted_excerpts:true (OWASP MCP03/MCP06)
|
|
720
|
+
// because they may contain text from scanned files, which is adversary-
|
|
721
|
+
// controlled in any context where the agent might read malicious code.
|
|
722
|
+
// - Secret-shaped strings redacted on the way out (OWASP MCP01/MCP10).
|
|
723
|
+
// - `apply_fix` requires confirm:true, valid HMAC signature on
|
|
724
|
+
// last-scan.json, non-shadow finding, and confined file path.
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
// Git-origin provenance (Finding Provenance M0/M1). Distinct from
|
|
741
|
+
// `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
|
|
742
|
+
// (Sigstore/SLSA attestation) — see report/index.js's import comment.
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
// Lazy-loaded: these transitively pull in npm packages (@babel/core and
|
|
747
|
+
// friends) that aren't available in the plugin-cache install path
|
|
748
|
+
// (no node_modules). Deferring keeps the MCP server bootable everywhere;
|
|
749
|
+
// the import only runs when a tool that needs them is actually called.
|
|
750
|
+
let _runScan;
|
|
751
|
+
async function getRunScan() {
|
|
752
|
+
if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 454))).runScan;
|
|
753
|
+
return _runScan;
|
|
754
|
+
}
|
|
755
|
+
let _verifyFixCore;
|
|
756
|
+
async function getVerifyFixCore() {
|
|
757
|
+
if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */ 3526).then(__webpack_require__.bind(__webpack_require__, 3526))).verifyFix;
|
|
758
|
+
return _verifyFixCore;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const MAX_FILES_PER_SCAN = 1024;
|
|
762
|
+
const MAX_FILE_BYTES = 500_000;
|
|
763
|
+
const MAX_TOTAL_SCAN_BYTES = 50_000_000;
|
|
764
|
+
const tools_META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
|
|
765
|
+
|
|
766
|
+
// OWASP A01 — refuse writes to paths that could subvert the security tool
|
|
767
|
+
// itself or the host's source-control / dependency state. A forged finding
|
|
768
|
+
// could otherwise tell apply_fix to overwrite our own rules.yml, our audit
|
|
769
|
+
// log, a .git/hooks/post-commit payload, a CI workflow, an IaC file, or a
|
|
770
|
+
// dependency manifest (premortem #3 expansion).
|
|
771
|
+
//
|
|
772
|
+
// Two kinds of guard:
|
|
773
|
+
// - DIR-prefix matches anywhere under one of these directories
|
|
774
|
+
// - FILE-suffix matches any path whose basename ends with one of these
|
|
775
|
+
const RESERVED_WRITE_PREFIXES = [
|
|
776
|
+
'.git/',
|
|
777
|
+
'.github/',
|
|
778
|
+
'.gitlab/',
|
|
779
|
+
'.circleci/',
|
|
780
|
+
'.buildkite/',
|
|
781
|
+
'.agentic-security/',
|
|
782
|
+
'node_modules/',
|
|
783
|
+
'.terraform/',
|
|
784
|
+
'.aws/',
|
|
785
|
+
'k8s/',
|
|
786
|
+
'kubernetes/',
|
|
787
|
+
];
|
|
788
|
+
const RESERVED_WRITE_BASENAMES = new Set([
|
|
789
|
+
'Dockerfile',
|
|
790
|
+
'Jenkinsfile',
|
|
791
|
+
'.gitlab-ci.yml',
|
|
792
|
+
'.gitlab-ci.yaml',
|
|
793
|
+
'package.json',
|
|
794
|
+
'package-lock.json',
|
|
795
|
+
'yarn.lock',
|
|
796
|
+
'pnpm-lock.yaml',
|
|
797
|
+
'pyproject.toml',
|
|
798
|
+
'Pipfile',
|
|
799
|
+
'Pipfile.lock',
|
|
800
|
+
'poetry.lock',
|
|
801
|
+
'requirements.txt',
|
|
802
|
+
'go.mod',
|
|
803
|
+
'go.sum',
|
|
804
|
+
'Cargo.toml',
|
|
805
|
+
'Cargo.lock',
|
|
806
|
+
'composer.json',
|
|
807
|
+
'composer.lock',
|
|
808
|
+
'Gemfile',
|
|
809
|
+
'Gemfile.lock',
|
|
810
|
+
'pom.xml',
|
|
811
|
+
'build.gradle',
|
|
812
|
+
'build.gradle.kts',
|
|
813
|
+
]);
|
|
814
|
+
const RESERVED_WRITE_SUFFIXES = [
|
|
815
|
+
'.tf',
|
|
816
|
+
'.tfvars',
|
|
817
|
+
'docker-compose.yml',
|
|
818
|
+
'docker-compose.yaml',
|
|
819
|
+
// _CONFINEMENT rule 3 — backup and lock files. The specific lock BASENAMES
|
|
820
|
+
// above cover the ecosystems we know; this catches the rest (`deps.lock`,
|
|
821
|
+
// `foo.bak`) without needing to enumerate them. Nothing an autofix should
|
|
822
|
+
// ever be rewriting: a `.bak` is someone's safety copy and a `.lock` is
|
|
823
|
+
// generated state.
|
|
824
|
+
'.bak',
|
|
825
|
+
'.lock',
|
|
826
|
+
];
|
|
827
|
+
// _CONFINEMENT rule 3 — build output. Matched as a PATH SEGMENT at any depth,
|
|
828
|
+
// not as a top-level prefix, because build output is routinely nested
|
|
829
|
+
// (`packages/web/dist/`, `services/api/target/`) and a top-level-only check
|
|
830
|
+
// would refuse the monorepo root and allow every package inside it.
|
|
831
|
+
//
|
|
832
|
+
// This matters most in THIS repository: `scanner/dist/` holds the shipped
|
|
833
|
+
// bundle, which carries its own SHA-256 integrity sidecar precisely because
|
|
834
|
+
// what it contains matters. Before this, `apply_fix` would rewrite it and
|
|
835
|
+
// report success.
|
|
836
|
+
//
|
|
837
|
+
// NOTE for a future change, deliberately not made here: the PREFIX list above
|
|
838
|
+
// (`node_modules/`, `.git/`, …) is still top-level-only, so a nested
|
|
839
|
+
// `packages/a/node_modules/` is not covered by it. That is a separate widening
|
|
840
|
+
// with its own blast radius and belongs in its own change with its own tests.
|
|
841
|
+
const RESERVED_WRITE_DIR_SEGMENTS = new Set(['dist', 'build', 'target']);
|
|
842
|
+
function _isReservedWritePath(sessionRoot, absFile) {
|
|
843
|
+
// Resolve sessionRoot symlinks so the relative path is computed against
|
|
844
|
+
// the same canonical root as `absFile` (which _confine already realpath'd).
|
|
845
|
+
// On macOS /tmp → /private/tmp; without this normalization the relative
|
|
846
|
+
// would contain "../" and the prefix check would miss the reserved path.
|
|
847
|
+
const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
|
|
848
|
+
const rel = external_node_path_.relative(rootReal, absFile).replace(/\\/g, '/');
|
|
849
|
+
if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
|
|
850
|
+
const segments = rel.split('/');
|
|
851
|
+
const base = segments[segments.length - 1] || '';
|
|
852
|
+
if (RESERVED_WRITE_BASENAMES.has(base)) return true;
|
|
853
|
+
if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
|
|
854
|
+
// Any DIRECTORY segment that names build output — checked over
|
|
855
|
+
// `segments.length - 1` so a source file legitimately called `build` or
|
|
856
|
+
// `dist` is not refused for its own name; only living inside such a
|
|
857
|
+
// directory counts.
|
|
858
|
+
if (segments.slice(0, -1).some(seg => RESERVED_WRITE_DIR_SEGMENTS.has(seg))) return true;
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// LangChain harness-anatomy recommendation: the filesystem is the right
|
|
863
|
+
// collaboration / scratchpad surface for subagents. We carve out one writable
|
|
864
|
+
// directory inside the otherwise-reserved `.agentic-security/` tree —
|
|
865
|
+
// `.agentic-security/agent-scratchpad/<agent>/<session>/` — and expose
|
|
866
|
+
// `append_scratchpad` / `read_scratchpad` for in-progress agent state.
|
|
867
|
+
//
|
|
868
|
+
// Confinement rules:
|
|
869
|
+
// - relative path required (no absolute / no `..`)
|
|
870
|
+
// - must start with `agent-scratchpad/<agent>/<session>/`
|
|
871
|
+
// - `<agent>` and `<session>` are restricted to `[A-Za-z0-9_.-]{1,64}`
|
|
872
|
+
// (no slashes — keeps the prefix exactly three components deep)
|
|
873
|
+
// - file basename: same charset rules
|
|
874
|
+
// - max scratchpad bytes per file: SCRATCHPAD_MAX_FILE_BYTES
|
|
875
|
+
const SCRATCHPAD_PREFIX = '.agentic-security/agent-scratchpad/';
|
|
876
|
+
const SCRATCHPAD_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
877
|
+
const SCRATCHPAD_MAX_FILE_BYTES = 2 * 1024 * 1024; // 2 MB per file
|
|
878
|
+
const SCRATCHPAD_MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB per scan root
|
|
879
|
+
|
|
880
|
+
function _validateScratchpadPath(relPath) {
|
|
881
|
+
if (typeof relPath !== 'string' || !relPath.length) {
|
|
882
|
+
return { ok: false, reason: 'path: not a string' };
|
|
883
|
+
}
|
|
884
|
+
if (external_node_path_.isAbsolute(relPath)) return { ok: false, reason: 'path: must be relative' };
|
|
885
|
+
if (relPath.includes('..')) return { ok: false, reason: 'path: must not contain ..' };
|
|
886
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
887
|
+
if (!normalized.startsWith(SCRATCHPAD_PREFIX)) {
|
|
888
|
+
return { ok: false, reason: `path: must start with "${SCRATCHPAD_PREFIX}"` };
|
|
889
|
+
}
|
|
890
|
+
const rest = normalized.slice(SCRATCHPAD_PREFIX.length);
|
|
891
|
+
const parts = rest.split('/');
|
|
892
|
+
if (parts.length < 3) {
|
|
893
|
+
return { ok: false, reason: 'path: must be agent-scratchpad/<agent>/<session>/<file>' };
|
|
894
|
+
}
|
|
895
|
+
const [agent, session, ...fileParts] = parts;
|
|
896
|
+
if (!SCRATCHPAD_NAME_RE.test(agent)) return { ok: false, reason: `path: agent name "${agent}" not in [A-Za-z0-9_.-]{1,64}` };
|
|
897
|
+
if (!SCRATCHPAD_NAME_RE.test(session)) return { ok: false, reason: `path: session id "${session}" not in [A-Za-z0-9_.-]{1,64}` };
|
|
898
|
+
for (const p of fileParts) {
|
|
899
|
+
if (!SCRATCHPAD_NAME_RE.test(p)) return { ok: false, reason: `path: file part "${p}" not in [A-Za-z0-9_.-]{1,64}` };
|
|
900
|
+
}
|
|
901
|
+
return { ok: true, agent, session, fileParts };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Routes through the same lstat+realpath confinement every other write/
|
|
905
|
+
// path-taking tool uses (OWASP MCP05) — a lexical prefix/charset check
|
|
906
|
+
// alone doesn't stop a pre-planted symlink at any path component from
|
|
907
|
+
// relocating the write/read outside the session root. Throws on escape;
|
|
908
|
+
// callers must catch (see append_scratchpad / read_scratchpad).
|
|
909
|
+
function _scratchpadAbs(sessionRoot, relPath) {
|
|
910
|
+
return _confine(sessionRoot, relPath.replace(/\\/g, '/'), 'scratchpad path');
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function _scratchpadTotalBytes(sessionRoot) {
|
|
914
|
+
const base = (0,state_dir.statePath)(sessionRoot, 'agent-scratchpad');
|
|
915
|
+
if (!external_node_fs_.existsSync(base)) return 0;
|
|
916
|
+
let total = 0;
|
|
917
|
+
const walk = (dir) => {
|
|
918
|
+
let entries;
|
|
919
|
+
try { entries = external_node_fs_.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
920
|
+
for (const e of entries) {
|
|
921
|
+
const fp = external_node_path_.join(dir, e.name);
|
|
922
|
+
try {
|
|
923
|
+
if (e.isFile()) { total += external_node_fs_.statSync(fp).size; }
|
|
924
|
+
else if (e.isDirectory()) walk(fp);
|
|
925
|
+
} catch { /* skip */ }
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
walk(base);
|
|
929
|
+
return total;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ─── Path confinement ────────────────────────────────────────────────────────
|
|
933
|
+
// Lexical check + lstat symlink reject + realpath re-check. OWASP MCP05.
|
|
934
|
+
//
|
|
935
|
+
// For non-existent paths (apply_fix to a new file is a possible legitimate
|
|
936
|
+
// case; in practice we re-check existence at the use-site) we walk up the
|
|
937
|
+
// deepest existing ancestor and realpath that, so a parent-symlink can't
|
|
938
|
+
// silently relocate writes.
|
|
939
|
+
function _confine(sessionRoot, candidate, label) {
|
|
940
|
+
if (typeof candidate !== 'string' || !candidate) throw new Error(`${label}: not a string`);
|
|
941
|
+
const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
|
|
942
|
+
const abs = external_node_path_.isAbsolute(candidate) ? candidate : external_node_path_.resolve(rootReal, candidate);
|
|
943
|
+
|
|
944
|
+
// Lexical pre-check: rejects "../../etc/passwd" before any fs call.
|
|
945
|
+
const relLex = external_node_path_.relative(rootReal, external_node_path_.resolve(abs));
|
|
946
|
+
if (relLex === '' || relLex.startsWith('..') || external_node_path_.isAbsolute(relLex)) {
|
|
947
|
+
throw new Error(`${label}: path "${candidate}" escapes session root`);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// If the path exists, the leaf must not be a symlink and its realpath
|
|
951
|
+
// must still be under rootReal.
|
|
952
|
+
if (external_node_fs_.existsSync(abs)) {
|
|
953
|
+
if (external_node_fs_.lstatSync(abs).isSymbolicLink()) {
|
|
954
|
+
throw new Error(`${label}: path "${candidate}" is a symbolic link (refused)`);
|
|
955
|
+
}
|
|
956
|
+
const real = external_node_fs_.realpathSync(abs);
|
|
957
|
+
if (external_node_path_.relative(rootReal, real).startsWith('..')) {
|
|
958
|
+
throw new Error(`${label}: path "${candidate}" resolves outside session root via symlink`);
|
|
959
|
+
}
|
|
960
|
+
return real;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Path doesn't exist — walk up to the deepest existing ancestor and
|
|
964
|
+
// realpath that. If a parent dir is a symlink pointing outside rootReal
|
|
965
|
+
// we catch it here.
|
|
966
|
+
let parent = external_node_path_.dirname(abs);
|
|
967
|
+
while (parent !== external_node_path_.dirname(parent) && !external_node_fs_.existsSync(parent)) {
|
|
968
|
+
parent = external_node_path_.dirname(parent);
|
|
969
|
+
}
|
|
970
|
+
const parentReal = external_node_fs_.realpathSync(parent);
|
|
971
|
+
if (external_node_path_.relative(rootReal, parentReal).startsWith('..')) {
|
|
972
|
+
throw new Error(`${label}: path "${candidate}" parent resolves outside session root`);
|
|
973
|
+
}
|
|
974
|
+
const suffix = external_node_path_.relative(parent, abs);
|
|
975
|
+
return external_node_path_.resolve(parentReal, suffix);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
|
|
979
|
+
const stateDirPath = (0,state_dir/* stateDir */.Pn)(sessionRoot);
|
|
980
|
+
const scanFile = external_node_path_.join(stateDirPath, 'last-scan.json');
|
|
981
|
+
const sigFile = scanFile + '.sig';
|
|
982
|
+
if (!external_node_fs_.existsSync(scanFile)) return { scan: null, status: 'missing' };
|
|
983
|
+
const body = external_node_fs_.readFileSync(scanFile, 'utf8');
|
|
984
|
+
const ok = (0,integrity/* verifyLastScan */.Ef)(body, sigFile);
|
|
985
|
+
if (ok === false) return { scan: null, status: 'tampered' };
|
|
986
|
+
if (ok === null && !allowUnsigned) return { scan: null, status: 'unsigned' };
|
|
987
|
+
let parsed;
|
|
988
|
+
try { parsed = JSON.parse(body); }
|
|
989
|
+
catch { return { scan: null, status: 'unparseable' }; }
|
|
990
|
+
return { scan: parsed, status: ok ? 'verified' : 'unsigned' };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function _findById(scan, id) {
|
|
994
|
+
if (!scan) return null;
|
|
995
|
+
return (scan.findings || []).find(f => f.id === id)
|
|
996
|
+
|| (scan.secrets || []).find(f => f.id === id)
|
|
997
|
+
|| (scan.supplyChain || []).find(f => f.id === id)
|
|
998
|
+
|| (scan.logicVulns || []).find(f => f.id === id)
|
|
999
|
+
|| null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ─── Tool-output offloading (harness-anatomy #1) ────────────────────────────
|
|
1003
|
+
// LangChain post: "the harness keeps the head and tail tokens of tool outputs
|
|
1004
|
+
// above a threshold number of tokens and offloads the full output to the
|
|
1005
|
+
// filesystem." We apply this to any MCP tool response whose findings array
|
|
1006
|
+
// exceeds OFFLOAD_THRESHOLD entries: write the full list to a scratchpad
|
|
1007
|
+
// file, return only head[0..3] + tail[-2..] + total + path. The agent can
|
|
1008
|
+
// call `read_scratchpad(path)` to page through the rest.
|
|
1009
|
+
//
|
|
1010
|
+
// Design choices:
|
|
1011
|
+
// - Threshold is conservative (10) — anything bigger than a casual UI page
|
|
1012
|
+
// gets offloaded. Tunable via $AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD.
|
|
1013
|
+
// - Offload location is the agent-scratchpad (not a separate dir) so the
|
|
1014
|
+
// same cleanup + size caps apply.
|
|
1015
|
+
// - File names are deterministic per response (sha256 of JSON.stringify)
|
|
1016
|
+
// so two identical responses share the same offload file.
|
|
1017
|
+
// - The session id is process.pid + boot timestamp short hash — collides
|
|
1018
|
+
// only across restarts within a millisecond, which is fine for cache.
|
|
1019
|
+
const OFFLOAD_THRESHOLD = (() => {
|
|
1020
|
+
const v = parseInt(process.env.AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD || '10', 10);
|
|
1021
|
+
return Number.isFinite(v) && v >= 1 ? v : 10;
|
|
1022
|
+
})();
|
|
1023
|
+
const MCP_SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
|
|
1024
|
+
|
|
1025
|
+
function _maybeOffload(sessionRoot, toolName, items) {
|
|
1026
|
+
if (!Array.isArray(items) || items.length <= OFFLOAD_THRESHOLD) {
|
|
1027
|
+
return { offloaded: false, items, total: items.length };
|
|
1028
|
+
}
|
|
1029
|
+
const head = items.slice(0, 3);
|
|
1030
|
+
const tail = items.slice(-2);
|
|
1031
|
+
const json = JSON.stringify({ tool: toolName, total: items.length, items }, null, 2);
|
|
1032
|
+
const hashShort = external_node_crypto_.createHash('sha256').update(json).digest('hex').slice(0, 10);
|
|
1033
|
+
const rel = `.agentic-security/agent-scratchpad/mcp-offload/${MCP_SESSION_ID}/${toolName}-${hashShort}.json`;
|
|
1034
|
+
const abs = external_node_path_.resolve(sessionRoot, rel);
|
|
1035
|
+
try {
|
|
1036
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
|
|
1037
|
+
external_node_fs_.writeFileSync(abs, json);
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
// If we can't write to disk for some reason, fall back to returning
|
|
1040
|
+
// everything — the alternative would be silently dropping data, which
|
|
1041
|
+
// is worse than blowing the context.
|
|
1042
|
+
return { offloaded: false, items, total: items.length, offloadError: e.message };
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
offloaded: true,
|
|
1046
|
+
head, tail, total: items.length,
|
|
1047
|
+
scratchpadPath: rel,
|
|
1048
|
+
pagingHint: `call read_scratchpad({ path: "${rel}", offset, limit }) to page through; the file is { tool, total, items: [...] } JSON`,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ─── scan_diff ───────────────────────────────────────────────────────────────
|
|
1053
|
+
// Test seam for the write boundary (PRD F6.4).
|
|
1054
|
+
//
|
|
1055
|
+
// `_confine` and `isReservedWrite` ARE the confinement contract in
|
|
1056
|
+
// agents/_CONFINEMENT.md. A boundary is only worth what its refusals are worth,
|
|
1057
|
+
// and refusals cannot be adversarially tested through the public tools without
|
|
1058
|
+
// also exercising a real scan, a real patch and a real filesystem write — so
|
|
1059
|
+
// the check would be measuring four things and attributing failure to one.
|
|
1060
|
+
//
|
|
1061
|
+
// Exported under the `_internals` convention this codebase already uses
|
|
1062
|
+
// (see posture/poc-inprocess.js). Not part of the MCP tool surface.
|
|
1063
|
+
const tools_internals = { _confine, isReservedWrite: _isReservedWritePath };
|
|
1064
|
+
|
|
1065
|
+
const scan_diff = {
|
|
1066
|
+
name: 'scan_diff',
|
|
1067
|
+
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.',
|
|
1068
|
+
inputSchema: {
|
|
1069
|
+
type: 'object',
|
|
1070
|
+
additionalProperties: false,
|
|
1071
|
+
properties: {
|
|
1072
|
+
files: {
|
|
1073
|
+
type: 'array', minItems: 1, maxItems: MAX_FILES_PER_SCAN,
|
|
1074
|
+
items: { type: 'string', minLength: 1, maxLength: 4096 },
|
|
1075
|
+
},
|
|
1076
|
+
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
|
|
1077
|
+
},
|
|
1078
|
+
required: ['files'],
|
|
1079
|
+
},
|
|
1080
|
+
async handler({ files, severity }, ctx) {
|
|
1081
|
+
const sessionRoot = ctx.sessionRoot;
|
|
1082
|
+
const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));
|
|
1083
|
+
|
|
1084
|
+
const fileContents = {};
|
|
1085
|
+
let totalBytes = 0;
|
|
1086
|
+
for (const a of abs) {
|
|
1087
|
+
let stat;
|
|
1088
|
+
try { stat = external_node_fs_.statSync(a); } catch { continue; }
|
|
1089
|
+
if (!stat.isFile()) continue;
|
|
1090
|
+
if (stat.size > MAX_FILE_BYTES) continue;
|
|
1091
|
+
totalBytes += stat.size;
|
|
1092
|
+
if (totalBytes > MAX_TOTAL_SCAN_BYTES) {
|
|
1093
|
+
throw new Error(`scan_diff: total scan size exceeds ${MAX_TOTAL_SCAN_BYTES} bytes`);
|
|
1094
|
+
}
|
|
1095
|
+
let content;
|
|
1096
|
+
try { content = external_node_fs_.readFileSync(a, 'utf8'); } catch { continue; }
|
|
1097
|
+
const rel = external_node_path_.relative(sessionRoot, a).replace(/\\/g, '/');
|
|
1098
|
+
fileContents[rel] = content;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is default-on
|
|
1102
|
+
// for the interactive CLI scan but was never requested here, so an
|
|
1103
|
+
// agent's pre-write self-correction scan was regex/AST-only — blind to
|
|
1104
|
+
// any bug whose source and sink are connected only through a call
|
|
1105
|
+
// (`fileContents` scopes the deep engine's IR to exactly the files
|
|
1106
|
+
// passed in, same bound this tool already enforces via MAX_FILES_PER_SCAN
|
|
1107
|
+
// / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
|
|
1108
|
+
// full-project deep scan).
|
|
1109
|
+
const runScan = await getRunScan();
|
|
1110
|
+
// FR-704 (assurance-hardening PRD): this tool's own description promises
|
|
1111
|
+
// "runs scan in memory" — without this, runFullScan's own state writers
|
|
1112
|
+
// (dpia.md, ropa.md, privacy-framework.json, threat-model.json, and
|
|
1113
|
+
// others) fire unconditionally on every call, silently mutating the
|
|
1114
|
+
// user's real project on every pre-write self-correction scan. Confirmed
|
|
1115
|
+
// by direct execution before this fix (11 state artifacts written by a
|
|
1116
|
+
// single scan_diff-shaped call).
|
|
1117
|
+
const result = await (0,state_dir/* withStateWritesDisabled */.Ao)(() =>
|
|
1118
|
+
runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
|
|
1119
|
+
const wantSet = new Set(Object.keys(fileContents));
|
|
1120
|
+
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
1121
|
+
const min = sevRank[severity] ?? 0;
|
|
1122
|
+
// Stage 6 correctness audit (historical): this used to only read
|
|
1123
|
+
// result.scan.findings (the SAST channel) — scan.secrets and
|
|
1124
|
+
// scan.logicVulns are separate arrays on the raw runScan() result, and a
|
|
1125
|
+
// hand-rolled 3-channel concat here was a second, divergent copy of the
|
|
1126
|
+
// merge report/index.js's normalizeFindings() already does (four
|
|
1127
|
+
// channels, plus per-channel defaulting and remediation-string
|
|
1128
|
+
// resolution the old concat re-implemented separately and could drift
|
|
1129
|
+
// from). Assurance-hardening PRD FR-105 ("JSON, SARIF, HTML, CSV, JUnit,
|
|
1130
|
+
// and MCP outputs derive from the same validated object"): route through
|
|
1131
|
+
// the same canonical merge every other output format uses.
|
|
1132
|
+
//
|
|
1133
|
+
// This closes the field-mapping/dedup divergence, but does NOT make
|
|
1134
|
+
// scan_diff surface SCA/supply-chain findings end to end: this handler
|
|
1135
|
+
// never builds a `depFileContents` map (everything a caller passes in
|
|
1136
|
+
// `files`, manifests included, lands in `fileContents`), and manifest-
|
|
1137
|
+
// based supply-chain detection in engine.js reads only `depFileContents`
|
|
1138
|
+
// — so `result.scan.supplyChain` is always empty for this tool today
|
|
1139
|
+
// regardless of this fix. That is a separate, real limitation (scan_diff
|
|
1140
|
+
// was designed for pre-write code self-correction, not manifest
|
|
1141
|
+
// scanning), left as-is rather than silently claimed fixed here.
|
|
1142
|
+
const findings = (0,report.normalizeFindings)(result.scan)
|
|
1143
|
+
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
1144
|
+
.map(f => (0,redact/* redactFinding */.lE)({
|
|
1145
|
+
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
1146
|
+
title: f.vuln, cwe: f.cwe,
|
|
1147
|
+
description: f.description, remediation: f.remediation,
|
|
1148
|
+
}));
|
|
1149
|
+
// Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
|
|
1150
|
+
// The agent gets a head+tail preview plus a path it can page through;
|
|
1151
|
+
// the full finding list lives on disk. This is the documented fix for
|
|
1152
|
+
// "context rot" — large tool outputs eat the model's attention budget.
|
|
1153
|
+
const off = _maybeOffload(sessionRoot, 'scan_diff', findings);
|
|
1154
|
+
if (off.offloaded) {
|
|
1155
|
+
return {
|
|
1156
|
+
_meta: tools_META,
|
|
1157
|
+
scannedFiles: Object.keys(fileContents).length,
|
|
1158
|
+
findingCount: off.total,
|
|
1159
|
+
offloaded: true,
|
|
1160
|
+
head: off.head, tail: off.tail,
|
|
1161
|
+
scratchpadPath: off.scratchpadPath,
|
|
1162
|
+
pagingHint: off.pagingHint,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
_meta: tools_META,
|
|
1167
|
+
scannedFiles: Object.keys(fileContents).length,
|
|
1168
|
+
findingCount: findings.length,
|
|
1169
|
+
findings,
|
|
1170
|
+
};
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
// ─── query_taint ─────────────────────────────────────────────────────────────
|
|
1175
|
+
const query_taint = {
|
|
1176
|
+
name: 'query_taint',
|
|
1177
|
+
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.',
|
|
1178
|
+
inputSchema: {
|
|
1179
|
+
type: 'object',
|
|
1180
|
+
additionalProperties: false,
|
|
1181
|
+
properties: {
|
|
1182
|
+
source: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1183
|
+
sink: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1184
|
+
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
|
1185
|
+
offset: { type: 'integer', minimum: 0, maximum: 10000 },
|
|
1186
|
+
},
|
|
1187
|
+
required: ['source', 'sink'],
|
|
1188
|
+
},
|
|
1189
|
+
async handler({ source, sink, limit, offset }, ctx) {
|
|
1190
|
+
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
1191
|
+
if (!scan) {
|
|
1192
|
+
return { _meta: tools_META, hasResult: false, status, message: `No usable scan state (${status}).` };
|
|
1193
|
+
}
|
|
1194
|
+
const lim = Number.isInteger(limit) ? Math.min(50, Math.max(1, limit)) : 10;
|
|
1195
|
+
const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
|
|
1196
|
+
const srcL = String(source).toLowerCase();
|
|
1197
|
+
const sinkL = String(sink).toLowerCase();
|
|
1198
|
+
// Filter first (cheap), then paginate (so totalMatches is accurate).
|
|
1199
|
+
// Harness-engineering note (post-derived): "context window != context
|
|
1200
|
+
// attention." Returning hundreds of matches to the agent in one shot
|
|
1201
|
+
// dilutes its reasoning; the agent receives a bounded slice plus the
|
|
1202
|
+
// cursor to fetch the rest if it wants.
|
|
1203
|
+
const all = (scan.findings || []).filter(f => {
|
|
1204
|
+
const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
|
|
1205
|
+
return hay.includes(srcL) && hay.includes(sinkL);
|
|
1206
|
+
});
|
|
1207
|
+
const page = all.slice(off, off + lim).map(f => (0,redact/* redactFinding */.lE)({
|
|
1208
|
+
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
1209
|
+
title: f.title || f.vuln, description: f.description,
|
|
1210
|
+
trace: f.trace || null,
|
|
1211
|
+
}));
|
|
1212
|
+
return {
|
|
1213
|
+
_meta: tools_META,
|
|
1214
|
+
hasResult: true,
|
|
1215
|
+
integrity: status,
|
|
1216
|
+
scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
|
|
1217
|
+
totalMatches: all.length,
|
|
1218
|
+
matchCount: page.length,
|
|
1219
|
+
offset: off,
|
|
1220
|
+
limit: lim,
|
|
1221
|
+
truncated: off + page.length < all.length,
|
|
1222
|
+
nextOffset: off + page.length < all.length ? off + page.length : null,
|
|
1223
|
+
matches: page,
|
|
1224
|
+
};
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// ─── explain_finding ─────────────────────────────────────────────────────────
|
|
1229
|
+
const explain_finding = {
|
|
1230
|
+
name: 'explain_finding',
|
|
1231
|
+
description: 'Return full details for a single finding from the last verified scan. Snippet/description redacted of secret patterns.',
|
|
1232
|
+
inputSchema: {
|
|
1233
|
+
type: 'object',
|
|
1234
|
+
additionalProperties: false,
|
|
1235
|
+
properties: {
|
|
1236
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1237
|
+
},
|
|
1238
|
+
required: ['finding_id'],
|
|
1239
|
+
},
|
|
1240
|
+
async handler({ finding_id }, ctx) {
|
|
1241
|
+
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
1242
|
+
if (!scan) throw new Error(`No usable scan state (${status}).`);
|
|
1243
|
+
const f = _findById(scan, finding_id);
|
|
1244
|
+
if (!f) throw new Error(`Finding not found: ${finding_id}`);
|
|
1245
|
+
const redacted = (0,redact/* redactFinding */.lE)({
|
|
1246
|
+
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
1247
|
+
title: f.title || f.vuln, cwe: f.cwe,
|
|
1248
|
+
description: f.description, remediation: f.remediation,
|
|
1249
|
+
snippet: f.snippet || null,
|
|
1250
|
+
trace: f.trace || null,
|
|
1251
|
+
});
|
|
1252
|
+
// Harness-anatomy #1: explain_finding's trace is the most-likely-large
|
|
1253
|
+
// field on a single finding. Offload when it crosses the threshold so
|
|
1254
|
+
// the agent gets a head/tail preview, not a 50-step trace dumped into
|
|
1255
|
+
// its context.
|
|
1256
|
+
let traceTrimmed = redacted.trace;
|
|
1257
|
+
let traceMeta = null;
|
|
1258
|
+
if (Array.isArray(redacted.trace) && redacted.trace.length > OFFLOAD_THRESHOLD) {
|
|
1259
|
+
const off = _maybeOffload(ctx.sessionRoot, 'explain_finding-trace', redacted.trace);
|
|
1260
|
+
if (off.offloaded) {
|
|
1261
|
+
traceTrimmed = [...off.head, { _gap: `... ${off.total - off.head.length - off.tail.length} more steps elided; read scratchpad ...` }, ...off.tail];
|
|
1262
|
+
traceMeta = {
|
|
1263
|
+
totalSteps: off.total,
|
|
1264
|
+
scratchpadPath: off.scratchpadPath,
|
|
1265
|
+
pagingHint: off.pagingHint,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
_meta: tools_META,
|
|
1271
|
+
...redacted,
|
|
1272
|
+
trace: traceTrimmed,
|
|
1273
|
+
traceOffload: traceMeta,
|
|
1274
|
+
confidence: f.confidence ?? null,
|
|
1275
|
+
hasReplacementFix: typeof f.fix?.replacement === 'string',
|
|
1276
|
+
integrity: status,
|
|
1277
|
+
// Risk-signal passthrough so agents can decide priority without
|
|
1278
|
+
// re-reading last-scan.json or re-fetching OSV/KEV/EPSS. compositeRisk
|
|
1279
|
+
// is the canonical sort key; the other fields are its provenance.
|
|
1280
|
+
compositeRisk: f.compositeRisk ?? null,
|
|
1281
|
+
compositeRiskTier: f.compositeRiskTier ?? null,
|
|
1282
|
+
compositeRiskFactors: Array.isArray(f.compositeRiskFactors) ? f.compositeRiskFactors : [],
|
|
1283
|
+
exploitability: f.exploitability ?? null,
|
|
1284
|
+
exploitabilityTier: f.exploitabilityTier ?? null,
|
|
1285
|
+
mitigationVerdict: f.mitigationVerdict ?? null,
|
|
1286
|
+
kev: !!(f.kev || f.kevListed || f.weaponized),
|
|
1287
|
+
epssScore: typeof f.epssScore === 'number' ? f.epssScore : null,
|
|
1288
|
+
epssPercentile: typeof f.epssPercentile === 'number' ? f.epssPercentile : null,
|
|
1289
|
+
exploitedNow: !!f.exploitedNow,
|
|
1290
|
+
// Which commit introduced this finding. `includeEmail` stays at its
|
|
1291
|
+
// DEFAULT (false) unconditionally — unlike the JSON report there is no
|
|
1292
|
+
// operator-set env escape for it here, because the consumer is an
|
|
1293
|
+
// agent that has no business receiving a committer's email address.
|
|
1294
|
+
// `pseudonymize`, by contrast, IS read back from the same env var
|
|
1295
|
+
// report/index.js's `_normalizedProvenance` reads
|
|
1296
|
+
// (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors) —
|
|
1297
|
+
// fix-round item 4: an operator who set that policy was still getting
|
|
1298
|
+
// raw committer names (and, via providerEnrichment, raw reviewer
|
|
1299
|
+
// logins/CODEOWNERS lines) through this MCP surface because this call
|
|
1300
|
+
// passed no options object at all, silently defeating their policy at
|
|
1301
|
+
// this one output boundary while report/index.js honoured it.
|
|
1302
|
+
findingProvenance: f.findingProvenance ? (0,schema/* redactFindingProvenance */.As)(f.findingProvenance, {
|
|
1303
|
+
pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
|
|
1304
|
+
}) : null,
|
|
1305
|
+
};
|
|
1306
|
+
},
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
// ─── apply_fix ───────────────────────────────────────────────────────────────
|
|
1310
|
+
const apply_fix = {
|
|
1311
|
+
name: 'apply_fix',
|
|
1312
|
+
description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
|
|
1313
|
+
inputSchema: {
|
|
1314
|
+
type: 'object',
|
|
1315
|
+
additionalProperties: false,
|
|
1316
|
+
properties: {
|
|
1317
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1318
|
+
confirm: { type: 'boolean' },
|
|
1319
|
+
dry_run: { type: 'boolean' },
|
|
1320
|
+
patch: {
|
|
1321
|
+
type: 'object',
|
|
1322
|
+
additionalProperties: { type: 'string', maxLength: 500_000 },
|
|
1323
|
+
minProperties: 1, maxProperties: 8,
|
|
1324
|
+
},
|
|
1325
|
+
// Stage 6 correctness audit: same gap and same fix as verify_fix — the
|
|
1326
|
+
// honesty gate is reachable but was never wired to any real caller.
|
|
1327
|
+
// Here it's stronger than advisory: the inline re-verify below already
|
|
1328
|
+
// gates the WRITE on `verdict.ok`, and verifyFixCore's own `ok`
|
|
1329
|
+
// formula already folds in `honesty.ok` when fixMeta is supplied — so
|
|
1330
|
+
// passing it through here makes a dishonest fixMeta (hand-wave
|
|
1331
|
+
// residual, uncited false-positive verdict) block the write itself,
|
|
1332
|
+
// not just report a verdict.
|
|
1333
|
+
fixMeta: {
|
|
1334
|
+
type: 'object',
|
|
1335
|
+
additionalProperties: false,
|
|
1336
|
+
properties: {
|
|
1337
|
+
residual: { type: 'string', maxLength: 2000 },
|
|
1338
|
+
verdict: { type: 'string', maxLength: 64 },
|
|
1339
|
+
evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
|
|
1340
|
+
signals: {
|
|
1341
|
+
type: 'object',
|
|
1342
|
+
additionalProperties: false,
|
|
1343
|
+
properties: {
|
|
1344
|
+
sinkSignatureChanged: { type: 'boolean' },
|
|
1345
|
+
allCallersRouted: { type: 'boolean' },
|
|
1346
|
+
testDiscriminates: { type: 'boolean' },
|
|
1347
|
+
rateLimitOnly: { type: 'boolean' },
|
|
1348
|
+
docsOnly: { type: 'boolean' },
|
|
1349
|
+
logOnlyNoReject: { type: 'boolean' },
|
|
1350
|
+
partialSanitization: { type: 'boolean' },
|
|
1351
|
+
},
|
|
1352
|
+
},
|
|
1353
|
+
// FR-307/FR-1002: this schema had `additionalProperties: false`
|
|
1354
|
+
// and never declared `approval` — the property apply-fix-
|
|
1355
|
+
// service.js's high-impact-change gate has required since FR-307
|
|
1356
|
+
// was built. A real MCP caller supplying fixMeta.approval was
|
|
1357
|
+
// rejected by validate.js at the schema layer before the handler
|
|
1358
|
+
// ever ran, silently making the approval gate (and FR-1002's
|
|
1359
|
+
// identity check layered on it) unreachable from this tool's
|
|
1360
|
+
// only real production entry point. See D-0024.
|
|
1361
|
+
approval: {
|
|
1362
|
+
type: 'object',
|
|
1363
|
+
additionalProperties: false,
|
|
1364
|
+
properties: {
|
|
1365
|
+
approvedBy: { type: 'string', minLength: 1, maxLength: 200 },
|
|
1366
|
+
reason: { type: 'string', minLength: 1, maxLength: 1000 },
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
// FR-1003: separation-of-duties. Self-reported the same way
|
|
1370
|
+
// approvedBy is — this tool has no way to determine who actually
|
|
1371
|
+
// wrote a patch, so `author` is a claim, checked against a
|
|
1372
|
+
// configurable policy the same way `approval` is.
|
|
1373
|
+
author: { type: 'string', minLength: 1, maxLength: 200 },
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
required: ['finding_id', 'confirm'],
|
|
1378
|
+
},
|
|
1379
|
+
async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
|
|
1380
|
+
if (confirm !== true) {
|
|
1381
|
+
return { _meta: tools_META, applied: false, reason: 'apply_fix requires confirm: true.' };
|
|
1382
|
+
}
|
|
1383
|
+
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
1384
|
+
if (!scan) {
|
|
1385
|
+
return { _meta: tools_META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
|
|
1386
|
+
}
|
|
1387
|
+
const f = _findById(scan, finding_id);
|
|
1388
|
+
if (!f) return { _meta: tools_META, applied: false, reason: `Finding not found: ${finding_id}` };
|
|
1389
|
+
if (f._shadow === true) {
|
|
1390
|
+
return { _meta: tools_META, applied: false, reason: 'shadow findings cannot be auto-applied' };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// #3 — verifier-approved patch path. When the caller supplies `patch` (a
|
|
1394
|
+
// files map, same shape as verify_fix), apply_fix re-runs the verifier
|
|
1395
|
+
// INLINE and writes only if it passes: the original finding's stableId is
|
|
1396
|
+
// gone, no new ≥medium finding was introduced, and lint is clean. This lets
|
|
1397
|
+
// a deterministic OR LLM-synthesized patch be applied for the ~100% of
|
|
1398
|
+
// findings that ship only a template/description (no stored fix.replacement).
|
|
1399
|
+
// Security: all existing gates hold (confirm, last-scan HMAC, reserved
|
|
1400
|
+
// paths, confinement, fix-history backup + attempt budget); the write is
|
|
1401
|
+
// additionally gated on a FRESH verification, so a stale/forged patch can't
|
|
1402
|
+
// slip through — there is no token to replay, the verify runs here and now.
|
|
1403
|
+
if (patch && typeof patch === 'object' && Object.keys(patch).length) {
|
|
1404
|
+
if (!f.stableId) {
|
|
1405
|
+
return { _meta: tools_META, applied: false, reason: 'finding has no stableId — cannot verify a patch against it' };
|
|
1406
|
+
}
|
|
1407
|
+
const confinedAbs = {};
|
|
1408
|
+
for (const [rel, content] of Object.entries(patch)) {
|
|
1409
|
+
let abs;
|
|
1410
|
+
try { abs = _confine(ctx.sessionRoot, rel, 'patch key'); }
|
|
1411
|
+
catch (e) { return { _meta: tools_META, applied: false, reason: `path-escape refused: ${e.message}` }; }
|
|
1412
|
+
if (_isReservedWritePath(ctx.sessionRoot, abs)) {
|
|
1413
|
+
return { _meta: tools_META, applied: false, reason: `reserved path refused: ${rel}` };
|
|
1414
|
+
}
|
|
1415
|
+
confinedAbs[rel] = { abs, content: String(content) };
|
|
1416
|
+
}
|
|
1417
|
+
// Inline re-verify — the load-bearing gate. Must pass to write.
|
|
1418
|
+
let verdict;
|
|
1419
|
+
try {
|
|
1420
|
+
const _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
|
|
1421
|
+
if (process.env.AGENTIC_SECURITY_FIX_RUN_TESTS === '1') {
|
|
1422
|
+
// Addition #7 — connect the closed-loop verifier: add the project test
|
|
1423
|
+
// suite as a fourth verification leg (scan + lint + tests). Opt-in
|
|
1424
|
+
// because many repos have no runner and we must not fail-closed by
|
|
1425
|
+
// default. Normalized to the scan+lint verdict shape used below.
|
|
1426
|
+
const { verifyFixWithTests } = await __webpack_require__.e(/* import() */ 4113).then(__webpack_require__.bind(__webpack_require__, 4113));
|
|
1427
|
+
const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
|
|
1428
|
+
verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict };
|
|
1429
|
+
} else {
|
|
1430
|
+
const verifyFixCore = await getVerifyFixCore();
|
|
1431
|
+
verdict = await verifyFixCore({
|
|
1432
|
+
scanRoot: ctx.sessionRoot,
|
|
1433
|
+
originalFindingStableId: f.stableId,
|
|
1434
|
+
files: _files,
|
|
1435
|
+
fixMeta,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
return { _meta: tools_META, applied: false, reason: `patch verification failed: ${e.message}` };
|
|
1440
|
+
}
|
|
1441
|
+
if (!verdict.ok) {
|
|
1442
|
+
return {
|
|
1443
|
+
_meta: tools_META, applied: false,
|
|
1444
|
+
reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
|
|
1445
|
+
verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
// FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
|
|
1449
|
+
// applyFixHistory() directly and never called applyVerifiedFix() — so
|
|
1450
|
+
// the high-impact-change approval gate (auth/authZ/crypto/PII/schema/
|
|
1451
|
+
// infra-privilege/public-API) built for the OTHER apply_fix branch
|
|
1452
|
+
// (stored fix.replacement) never ran here at all, for any input. Since
|
|
1453
|
+
// this is the branch the tool's own description calls the one that
|
|
1454
|
+
// covers "~100% of findings that ship only a template," that gap was
|
|
1455
|
+
// the larger of the two found this cycle. Same before/after content
|
|
1456
|
+
// shape `apply-fix-service.js` already uses — read-first-in-try/catch
|
|
1457
|
+
// (D-0012), never existsSync-then-readFileSync.
|
|
1458
|
+
const filesForMaterialClassification = {};
|
|
1459
|
+
for (const [rel, v] of Object.entries(confinedAbs)) {
|
|
1460
|
+
let before = '';
|
|
1461
|
+
try { before = await promises_.readFile(v.abs, 'utf8'); } catch { /* new file — before stays '' */ }
|
|
1462
|
+
filesForMaterialClassification[rel] = { before, after: v.content };
|
|
1463
|
+
}
|
|
1464
|
+
const materialClassification = (0,material_change/* classifyFixMaterialRisk */.kz)(filesForMaterialClassification);
|
|
1465
|
+
if (dry_run) {
|
|
1466
|
+
return { _meta: tools_META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
|
|
1467
|
+
}
|
|
1468
|
+
if (materialClassification.highImpactCategories.length) {
|
|
1469
|
+
const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
|
|
1470
|
+
const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
|
|
1471
|
+
typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
|
|
1472
|
+
typeof approval.reason === 'string' && approval.reason.trim().length > 0);
|
|
1473
|
+
if (!hasApprovalEvidence) {
|
|
1474
|
+
return {
|
|
1475
|
+
_meta: tools_META, applied: false,
|
|
1476
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
|
|
1477
|
+
materialClassification,
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
const approverRegistry = (0,approver_registry.loadApproverRegistry)(ctx.sessionRoot);
|
|
1481
|
+
const requiredRoles = (0,approver_registry/* requiredRolesFor */.K)(approverRegistry, materialClassification.highImpactCategories);
|
|
1482
|
+
const identityCheck = (0,approver_registry.verifyApprover)(approverRegistry, approval.approvedBy, requiredRoles);
|
|
1483
|
+
if (!identityCheck.verified) {
|
|
1484
|
+
return {
|
|
1485
|
+
_meta: tools_META, applied: false,
|
|
1486
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
|
|
1487
|
+
materialClassification,
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
// FR-1003: separation-of-duties, same no-op-unless-configured gate
|
|
1491
|
+
// as apply-fix-service.js's own copy — see approver-registry.js.
|
|
1492
|
+
const sodCheck = (0,approver_registry.checkSeparationOfDuties)(approverRegistry, fixMeta?.author, approval.approvedBy);
|
|
1493
|
+
if (!sodCheck.ok) {
|
|
1494
|
+
return {
|
|
1495
|
+
_meta: tools_META, applied: false,
|
|
1496
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
|
|
1497
|
+
materialClassification,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
const written = [];
|
|
1502
|
+
try {
|
|
1503
|
+
for (const [rel, v] of Object.entries(confinedAbs)) {
|
|
1504
|
+
const fileExisted = external_node_fs_.existsSync(v.abs);
|
|
1505
|
+
const originalContent = fileExisted ? await promises_.readFile(v.abs, 'utf8') : '';
|
|
1506
|
+
const entry = await (0,fix_history/* applyFix */.oM)({
|
|
1507
|
+
scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
|
|
1508
|
+
findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
|
|
1509
|
+
findingProvenance: f.findingProvenance || null,
|
|
1510
|
+
});
|
|
1511
|
+
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
|
|
1512
|
+
}
|
|
1513
|
+
} catch (e) {
|
|
1514
|
+
// FR-306: roll back every file THIS batch already wrote before the
|
|
1515
|
+
// failure — applyFixHistory already restored the one file that just
|
|
1516
|
+
// failed; this covers the rest, so a multi-file patch never leaves
|
|
1517
|
+
// some files patched and others not.
|
|
1518
|
+
for (const w of written) {
|
|
1519
|
+
try { await (0,fix_history/* revertEntryById */.rJ)(ctx.sessionRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
|
|
1520
|
+
}
|
|
1521
|
+
if (e && e.name === 'FixAttemptBudgetExceededError') {
|
|
1522
|
+
return { _meta: tools_META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
|
|
1523
|
+
}
|
|
1524
|
+
throw e;
|
|
1525
|
+
}
|
|
1526
|
+
let acceptance = null;
|
|
1527
|
+
try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* best-effort */ }
|
|
1528
|
+
return { _meta: tools_META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (typeof f.fix?.replacement !== 'string') {
|
|
1532
|
+
// Premortem #2: templates are patch-shaped text. Same reasoning as
|
|
1533
|
+
// the replacement path — do NOT pass through redactString here.
|
|
1534
|
+
return {
|
|
1535
|
+
_meta: tools_META, applied: false,
|
|
1536
|
+
reason: 'No full replacement available — only a template. Apply the template manually.',
|
|
1537
|
+
template: f.fix?.code || '',
|
|
1538
|
+
file: f.file, line: f.line,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
let absFile;
|
|
1542
|
+
try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
|
|
1543
|
+
catch (e) {
|
|
1544
|
+
return { _meta: tools_META, applied: false, reason: `path-escape refused: ${e.message}` };
|
|
1545
|
+
}
|
|
1546
|
+
if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
|
|
1547
|
+
return { _meta: tools_META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
|
|
1548
|
+
}
|
|
1549
|
+
if (!external_node_fs_.existsSync(absFile)) {
|
|
1550
|
+
return { _meta: tools_META, applied: false, reason: `File not found: ${absFile}` };
|
|
1551
|
+
}
|
|
1552
|
+
const originalContent = await promises_.readFile(absFile, 'utf8');
|
|
1553
|
+
|
|
1554
|
+
if (dry_run) {
|
|
1555
|
+
return {
|
|
1556
|
+
_meta: tools_META,
|
|
1557
|
+
applied: false, dryRun: true,
|
|
1558
|
+
file: f.file,
|
|
1559
|
+
originalSize: originalContent.length,
|
|
1560
|
+
newSize: f.fix.replacement.length,
|
|
1561
|
+
diffSummary: `${originalContent.length} → ${f.fix.replacement.length} bytes`,
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// FR-301/A-08 (assurance-hardening PRD): this branch used to write
|
|
1566
|
+
// f.fix.replacement straight to disk with NO fresh verification — no
|
|
1567
|
+
// rescan, no lint, nothing confirming the stored replacement actually
|
|
1568
|
+
// closes the finding it claims to fix. The caller-patch branch above
|
|
1569
|
+
// already required this; there is no reason a STORED fix should be
|
|
1570
|
+
// trusted more than a caller-supplied one just because it shipped with
|
|
1571
|
+
// the finding. Routed through the same applyVerifiedFix() service the
|
|
1572
|
+
// CLI's `fix --apply` now also uses (src/fix/apply-fix-service.js) —
|
|
1573
|
+
// confinement/reserved-path are re-checked there too (harmless
|
|
1574
|
+
// redundancy with the dry_run preview above, kept for that preview's
|
|
1575
|
+
// size-diff shape) but the load-bearing addition is the verification
|
|
1576
|
+
// gate before the write.
|
|
1577
|
+
if (!f.stableId) {
|
|
1578
|
+
return { _meta: tools_META, applied: false, reason: 'finding has no stableId — cannot verify a stored fix against it' };
|
|
1579
|
+
}
|
|
1580
|
+
const result = await (0,apply_fix_service/* applyVerifiedFix */.On)({
|
|
1581
|
+
scanRoot: ctx.sessionRoot,
|
|
1582
|
+
finding: f,
|
|
1583
|
+
files: { [f.file]: f.fix.replacement },
|
|
1584
|
+
fixMeta,
|
|
1585
|
+
});
|
|
1586
|
+
if (!result.ok) {
|
|
1587
|
+
if (result.budgetExceeded) {
|
|
1588
|
+
return { _meta: tools_META, applied: false, reason: result.reason, budgetExceeded: true, attempts: result.attempts, maxAttempts: result.maxAttempts, key: result.key };
|
|
1589
|
+
}
|
|
1590
|
+
return { _meta: tools_META, applied: false, reason: result.reason, verify: result.verify || null };
|
|
1591
|
+
}
|
|
1592
|
+
// R25 (PRD §5): surface the running auto-fix acceptance rate after each
|
|
1593
|
+
// applied fix, so the closed loop reports its own success metric.
|
|
1594
|
+
let acceptance = null;
|
|
1595
|
+
try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* metric is best-effort */ }
|
|
1596
|
+
const entry = result.written[0];
|
|
1597
|
+
return {
|
|
1598
|
+
// FR-305: verifiedFull distinguishes "every required leg (lint, tests)
|
|
1599
|
+
// genuinely ran and passed" from "passed, but a required leg was
|
|
1600
|
+
// skipped or unavailable" — verified:true alone conflates them.
|
|
1601
|
+
_meta: tools_META, applied: true, verified: true, verifiedFull: result.verifiedFull,
|
|
1602
|
+
historyId: entry.historyId, file: entry.file, backupPath: entry.backupPath,
|
|
1603
|
+
integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance,
|
|
1604
|
+
verify: result.verify,
|
|
1605
|
+
};
|
|
1606
|
+
},
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// ─── verify_fix ──────────────────────────────────────────────────────────────
|
|
1610
|
+
// Closed-loop verification of a proposed patch BEFORE the agent applies it.
|
|
1611
|
+
// Re-scans the patched files in-memory (no disk write), confirms the original
|
|
1612
|
+
// stableId is gone, and runs the project's existing linter on the patched
|
|
1613
|
+
// files. Returns a structured verdict the agent can use to decide whether to
|
|
1614
|
+
// proceed with apply_fix.
|
|
1615
|
+
const verify_fix = {
|
|
1616
|
+
name: 'verify_fix',
|
|
1617
|
+
description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary }. Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
|
|
1618
|
+
inputSchema: {
|
|
1619
|
+
type: 'object',
|
|
1620
|
+
additionalProperties: false,
|
|
1621
|
+
properties: {
|
|
1622
|
+
stable_id: { type: 'string', minLength: 8, maxLength: 64 },
|
|
1623
|
+
files: {
|
|
1624
|
+
type: 'object',
|
|
1625
|
+
additionalProperties: { type: 'string', maxLength: 500_000 },
|
|
1626
|
+
minProperties: 1,
|
|
1627
|
+
maxProperties: 8,
|
|
1628
|
+
},
|
|
1629
|
+
// Stage 6 correctness audit: posture/fix-honesty-gate.js's deterministic
|
|
1630
|
+
// honesty checks (vague-assurance residual prose, unbacked false-
|
|
1631
|
+
// positive verdicts, tier/residual consistency) were fully built and
|
|
1632
|
+
// fix-verify.js already consulted them when given a `fixMeta` — but
|
|
1633
|
+
// this schema never had a `fixMeta` property, so no call through the
|
|
1634
|
+
// MCP surface could ever supply one. The gate can only run against
|
|
1635
|
+
// claims the AGENT self-reports (residual risk, verdict, evidence,
|
|
1636
|
+
// completeness signals) — nothing here is server-computable — so
|
|
1637
|
+
// fixing this meant exposing the property, not inventing a lookup.
|
|
1638
|
+
fixMeta: {
|
|
1639
|
+
type: 'object',
|
|
1640
|
+
additionalProperties: false,
|
|
1641
|
+
properties: {
|
|
1642
|
+
residual: { type: 'string', maxLength: 2000 },
|
|
1643
|
+
verdict: { type: 'string', maxLength: 64 },
|
|
1644
|
+
evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
|
|
1645
|
+
signals: {
|
|
1646
|
+
type: 'object',
|
|
1647
|
+
additionalProperties: false,
|
|
1648
|
+
properties: {
|
|
1649
|
+
sinkSignatureChanged: { type: 'boolean' },
|
|
1650
|
+
allCallersRouted: { type: 'boolean' },
|
|
1651
|
+
testDiscriminates: { type: 'boolean' },
|
|
1652
|
+
rateLimitOnly: { type: 'boolean' },
|
|
1653
|
+
docsOnly: { type: 'boolean' },
|
|
1654
|
+
logOnlyNoReject: { type: 'boolean' },
|
|
1655
|
+
partialSanitization: { type: 'boolean' },
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
},
|
|
1659
|
+
},
|
|
1660
|
+
},
|
|
1661
|
+
required: ['stable_id', 'files'],
|
|
1662
|
+
},
|
|
1663
|
+
async handler({ stable_id, files, fixMeta }, ctx) {
|
|
1664
|
+
// Confine every file path before passing to the verifier.
|
|
1665
|
+
const confined = {};
|
|
1666
|
+
for (const [relPath, content] of Object.entries(files || {})) {
|
|
1667
|
+
try {
|
|
1668
|
+
_confine(ctx.sessionRoot, relPath, 'files key');
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` };
|
|
1671
|
+
}
|
|
1672
|
+
confined[relPath] = String(content);
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
// The PoC-re-check leg (verifyFixCore's `pocLeg`) needs a `poc` param
|
|
1676
|
+
// to do anything — until now nothing supplied one, so it always
|
|
1677
|
+
// reported {status:'not-requested'} through this surface (see
|
|
1678
|
+
// posture/CLAUDE.md's disclosure). Rather than widening inputSchema
|
|
1679
|
+
// to make the CALLER pass PoC data back, look it up server-side: the
|
|
1680
|
+
// scan pipeline already attaches an HTTP-shaped f.poc to matching
|
|
1681
|
+
// findings by default (engine.js's annotatePocs), and last-scan.json
|
|
1682
|
+
// already carries it under the same stableId this handler receives.
|
|
1683
|
+
// Best-effort: a missing/unsigned/tampered scan just means no PoC is
|
|
1684
|
+
// available to re-check, not a verify_fix failure — the rescan/lint/
|
|
1685
|
+
// tests legs below are independent of this and still apply.
|
|
1686
|
+
let poc = null;
|
|
1687
|
+
try {
|
|
1688
|
+
const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
1689
|
+
const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
|
|
1690
|
+
if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
|
|
1691
|
+
} catch { /* best-effort lookup; poc stays null */ }
|
|
1692
|
+
|
|
1693
|
+
const verifyFixCore = await getVerifyFixCore();
|
|
1694
|
+
const r = await verifyFixCore({
|
|
1695
|
+
scanRoot: ctx.sessionRoot,
|
|
1696
|
+
originalFindingStableId: stable_id,
|
|
1697
|
+
files: confined,
|
|
1698
|
+
poc,
|
|
1699
|
+
fixMeta,
|
|
1700
|
+
});
|
|
1701
|
+
return {
|
|
1702
|
+
_meta: tools_META,
|
|
1703
|
+
ok: r.ok,
|
|
1704
|
+
rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
|
|
1705
|
+
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: (0,redact/* redactString */.rd)(r.lint.output || '').slice(0, 1500) },
|
|
1706
|
+
// verifyFix computes five legs, not two — tests/honesty/poc were
|
|
1707
|
+
// being silently dropped here, leaving an agent with no structured
|
|
1708
|
+
// way to see WHY verification failed when the failure was in one
|
|
1709
|
+
// of those three (only the free-text summary carried it).
|
|
1710
|
+
// test-runner.js's runProjectTests never returns raw stdout/stderr,
|
|
1711
|
+
// so no redaction is needed there; honesty.violations are static,
|
|
1712
|
+
// code-generated strings; poc.reason is redacted defensively since
|
|
1713
|
+
// it can echo proof-harness detail derived from scanned source.
|
|
1714
|
+
tests: r.tests,
|
|
1715
|
+
honesty: r.honesty,
|
|
1716
|
+
poc: r.poc ? { ...r.poc, reason: r.poc.reason ? (0,redact/* redactString */.rd)(r.poc.reason) : r.poc.reason } : r.poc,
|
|
1717
|
+
summary: r.summary,
|
|
1718
|
+
};
|
|
1719
|
+
} catch (e) {
|
|
1720
|
+
return { _meta: tools_META, ok: false, reason: `verify_fix failed: ${e.message}` };
|
|
1721
|
+
}
|
|
1722
|
+
},
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
// ─── synthesize_fix ──────────────────────────────────────────────────────────
|
|
1726
|
+
// Return the stored fix replacement + regression-test scaffold for a finding,
|
|
1727
|
+
// WITHOUT applying anything. The agent can call verify_fix → apply_fix in
|
|
1728
|
+
// sequence with the returned blob.
|
|
1729
|
+
const synthesize_fix = {
|
|
1730
|
+
name: 'synthesize_fix',
|
|
1731
|
+
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.',
|
|
1732
|
+
inputSchema: {
|
|
1733
|
+
type: 'object',
|
|
1734
|
+
additionalProperties: false,
|
|
1735
|
+
properties: {
|
|
1736
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1737
|
+
},
|
|
1738
|
+
required: ['finding_id'],
|
|
1739
|
+
},
|
|
1740
|
+
async handler({ finding_id }, ctx) {
|
|
1741
|
+
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
1742
|
+
if (!scan) {
|
|
1743
|
+
return { _meta: tools_META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
|
|
1744
|
+
}
|
|
1745
|
+
const f = _findById(scan, finding_id);
|
|
1746
|
+
if (!f) return { _meta: tools_META, ok: false, reason: `Finding not found: ${finding_id}` };
|
|
1747
|
+
if (f._shadow === true) return { _meta: tools_META, ok: false, reason: 'shadow findings have no synthesized fix' };
|
|
1748
|
+
const fix = f.fix || {};
|
|
1749
|
+
const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
|
|
1750
|
+
// Patch bounds: count files touched + LoC delta.
|
|
1751
|
+
let touchedFiles = 1;
|
|
1752
|
+
let locDelta = 0;
|
|
1753
|
+
if (hasReplacement) {
|
|
1754
|
+
let orig = '';
|
|
1755
|
+
try {
|
|
1756
|
+
const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
|
|
1757
|
+
orig = external_node_fs_.readFileSync(abs, 'utf8');
|
|
1758
|
+
} catch { /* ignore — counts will reflect new-only LoC */ }
|
|
1759
|
+
locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
|
|
1760
|
+
}
|
|
1761
|
+
const oversized = touchedFiles > 3 || locDelta > 100;
|
|
1762
|
+
// #1 — deterministic autofix: for classes with a safe context-independent
|
|
1763
|
+
// swap (weak hash, TLS verify-off), materialize a full-file patch from the
|
|
1764
|
+
// live file. The agent passes `autofix.patch` straight to apply_fix, which
|
|
1765
|
+
// re-verifies it (rescan-clean + no new ≥medium + lint) before writing — so
|
|
1766
|
+
// even a mis-attributed swap can't land a bad edit. No stored replacement,
|
|
1767
|
+
// no per-finding bloat in last-scan.json.
|
|
1768
|
+
let autofix = null;
|
|
1769
|
+
if (!hasReplacement) {
|
|
1770
|
+
try {
|
|
1771
|
+
const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
|
|
1772
|
+
const det = synthesizeDeterministicPatch(f, external_node_fs_.readFileSync(abs, 'utf8'));
|
|
1773
|
+
if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
|
|
1774
|
+
} catch { /* best-effort — no file / no rule → no autofix */ }
|
|
1775
|
+
}
|
|
1776
|
+
// Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
|
|
1777
|
+
// not a finding excerpt. Running it through redactString silently corrupts
|
|
1778
|
+
// valid patches whose content happens to match a secret-shape (e.g. a
|
|
1779
|
+
// placeholder like `password = "loadFromEnv"`). Patches MUST pass through
|
|
1780
|
+
// verbatim. Snippet/description/etc. continue to be redacted in
|
|
1781
|
+
// explain_finding / scan_diff — that's the right surface for redaction.
|
|
1782
|
+
return {
|
|
1783
|
+
_meta: tools_META,
|
|
1784
|
+
ok: true,
|
|
1785
|
+
stable_id: f.stableId || null,
|
|
1786
|
+
file: f.file, line: f.line,
|
|
1787
|
+
vuln: f.vuln,
|
|
1788
|
+
severity: f.severity,
|
|
1789
|
+
hasReplacement,
|
|
1790
|
+
replacement: hasReplacement ? fix.replacement : null,
|
|
1791
|
+
template: fix.code || null,
|
|
1792
|
+
autofix,
|
|
1793
|
+
// #15 — the regression test the scan annotator already generated for this
|
|
1794
|
+
// finding (present when a PoC was built). Surfaced here so the fix flow
|
|
1795
|
+
// writes the test alongside the patch; fix-verify-loop then runs it, so an
|
|
1796
|
+
// applied fix ships with a test that fails pre-fix and passes post-fix.
|
|
1797
|
+
regression_test: f.regression_test || null,
|
|
1798
|
+
remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
|
|
1799
|
+
patchBounds: { touchedFiles, locDelta, oversized },
|
|
1800
|
+
// oversized can only be true when hasReplacement is true (locDelta is
|
|
1801
|
+
// only computed in that branch, and touchedFiles never varies) — a
|
|
1802
|
+
// `!hasReplacement` conjunct here was a structural contradiction that
|
|
1803
|
+
// made this permanently false. The correct signal: the stored
|
|
1804
|
+
// replacement itself is too big to trust auto-applying, and there's
|
|
1805
|
+
// no safer deterministic alternative.
|
|
1806
|
+
recommendsFixPlan: oversized && !autofix,
|
|
1807
|
+
};
|
|
1808
|
+
},
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
// ─── find_rule_module ───────────────────────────────────────────────────────
|
|
1812
|
+
// Codebase-navigation helper (C.6). Answers "which file under scanner/src/
|
|
1813
|
+
// implements the detector for CWE-X / family Y" by scanning the SAST and
|
|
1814
|
+
// posture sources for `cwe:` / `family:` literals. Cheaper and more reliable
|
|
1815
|
+
// than asking the agent to grep — premortem note: "grep for a common function
|
|
1816
|
+
// name in a large codebase returns thousands of matches."
|
|
1817
|
+
//
|
|
1818
|
+
// Read-only; no findings consumed. Output is a list of file paths + the
|
|
1819
|
+
// matching literal lines so the agent can verify before editing.
|
|
1820
|
+
const find_rule_module = {
|
|
1821
|
+
name: 'find_rule_module',
|
|
1822
|
+
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.',
|
|
1823
|
+
inputSchema: {
|
|
1824
|
+
type: 'object',
|
|
1825
|
+
additionalProperties: false,
|
|
1826
|
+
properties: {
|
|
1827
|
+
cwe: { type: 'string', minLength: 5, maxLength: 16 },
|
|
1828
|
+
family: { type: 'string', minLength: 2, maxLength: 64 },
|
|
1829
|
+
},
|
|
1830
|
+
},
|
|
1831
|
+
async handler({ cwe, family }, ctx) {
|
|
1832
|
+
if (!cwe && !family) {
|
|
1833
|
+
return { _meta: tools_META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
|
|
1834
|
+
}
|
|
1835
|
+
// Pattern enforcement — the mini-schema validator doesn't do `pattern`.
|
|
1836
|
+
if (cwe && !/^CWE-\d+$/.test(cwe)) {
|
|
1837
|
+
return { _meta: tools_META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
|
|
1838
|
+
}
|
|
1839
|
+
if (family && !/^[a-z][a-z0-9-]+$/.test(family)) {
|
|
1840
|
+
return { _meta: tools_META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
|
|
1841
|
+
}
|
|
1842
|
+
const sessionRoot = ctx.sessionRoot;
|
|
1843
|
+
const roots = [
|
|
1844
|
+
external_node_path_.join(sessionRoot, 'scanner', 'src', 'sast'),
|
|
1845
|
+
external_node_path_.join(sessionRoot, 'scanner', 'src', 'posture'),
|
|
1846
|
+
];
|
|
1847
|
+
const hits = [];
|
|
1848
|
+
const cweLit = cwe ? new RegExp(`['"\`]${cwe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`) : null;
|
|
1849
|
+
// Family match is broader on purpose: detectors often emit findings
|
|
1850
|
+
// without an explicit `family:` field (it's backfilled by
|
|
1851
|
+
// posture/finding-defaults.js). We match the family literal anywhere in
|
|
1852
|
+
// the file (vuln-name strings, comments, ids) so e.g. searching for "csrf"
|
|
1853
|
+
// surfaces sast/csrf.js even though it doesn't tag findings with the field.
|
|
1854
|
+
const famLit = family ? new RegExp(`\\b${family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-_ ]?')}\\b`, 'i') : null;
|
|
1855
|
+
// Also try a filename-stem match when only family is given.
|
|
1856
|
+
const famFilename = family ? family.toLowerCase() : null;
|
|
1857
|
+
for (const root of roots) {
|
|
1858
|
+
if (!external_node_fs_.existsSync(root)) continue;
|
|
1859
|
+
let entries;
|
|
1860
|
+
try { entries = external_node_fs_.readdirSync(root); } catch { continue; }
|
|
1861
|
+
for (const entry of entries) {
|
|
1862
|
+
if (!entry.endsWith('.js')) continue;
|
|
1863
|
+
const abs = external_node_path_.join(root, entry);
|
|
1864
|
+
let stat;
|
|
1865
|
+
try { stat = external_node_fs_.statSync(abs); } catch { continue; }
|
|
1866
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue;
|
|
1867
|
+
let body;
|
|
1868
|
+
try { body = external_node_fs_.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
1869
|
+
const lines = body.split('\n');
|
|
1870
|
+
const matches = [];
|
|
1871
|
+
const stem = entry.replace(/\.js$/, '').toLowerCase();
|
|
1872
|
+
const filenameMatchesFamily = famFilename && (stem === famFilename || stem.includes(famFilename));
|
|
1873
|
+
if (filenameMatchesFamily) {
|
|
1874
|
+
matches.push({ line: 1, text: `<filename "${entry}" matches family>`, kind: 'filename' });
|
|
1875
|
+
}
|
|
1876
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1877
|
+
const line = lines[i];
|
|
1878
|
+
if (cweLit && cweLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'cwe' });
|
|
1879
|
+
else if (famLit && famLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'family' });
|
|
1880
|
+
if (matches.length >= 5) break;
|
|
1881
|
+
}
|
|
1882
|
+
if (matches.length) {
|
|
1883
|
+
hits.push({
|
|
1884
|
+
file: external_node_path_.relative(sessionRoot, abs).replace(/\\/g, '/'),
|
|
1885
|
+
matchCount: matches.length,
|
|
1886
|
+
matches,
|
|
1887
|
+
});
|
|
1888
|
+
if (hits.length >= 20) break;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (hits.length >= 20) break;
|
|
1892
|
+
}
|
|
1893
|
+
return {
|
|
1894
|
+
_meta: tools_META,
|
|
1895
|
+
ok: true,
|
|
1896
|
+
query: { cwe: cwe || null, family: family || null },
|
|
1897
|
+
hitCount: hits.length,
|
|
1898
|
+
hits,
|
|
1899
|
+
truncated: hits.length >= 20,
|
|
1900
|
+
};
|
|
1901
|
+
},
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
// ─── append_scratchpad / read_scratchpad ───────────────────────────────────
|
|
1905
|
+
// LangChain harness-anatomy: the filesystem is the durable agent scratchpad.
|
|
1906
|
+
// These tools expose a tightly-confined slice of the project tree for
|
|
1907
|
+
// in-progress agent state: PLAN.md decompositions, offloaded tool outputs,
|
|
1908
|
+
// session notes that survive context resets.
|
|
1909
|
+
//
|
|
1910
|
+
// Confinement (validated in `_validateScratchpadPath`):
|
|
1911
|
+
// ALL paths must start with `.agentic-security/agent-scratchpad/<agent>/<session>/`
|
|
1912
|
+
// and consist of [A-Za-z0-9_.-]{1,64} path components — no `..`, no
|
|
1913
|
+
// absolute paths, no shell metacharacters. This is the ONE place inside
|
|
1914
|
+
// the otherwise-reserved `.agentic-security/` tree where agents can write.
|
|
1915
|
+
// Limits:
|
|
1916
|
+
// - 2 MB per file (write attempts beyond this are refused).
|
|
1917
|
+
// - 50 MB total across the scratchpad — protects against runaway agents.
|
|
1918
|
+
// Operators who want to clean up: `rm -rf .agentic-security/agent-scratchpad`.
|
|
1919
|
+
//
|
|
1920
|
+
// The post: "Agents can store intermediate outputs and maintain state that
|
|
1921
|
+
// outlasts a single session." This is that mechanism.
|
|
1922
|
+
|
|
1923
|
+
const append_scratchpad = {
|
|
1924
|
+
name: 'append_scratchpad',
|
|
1925
|
+
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.',
|
|
1926
|
+
inputSchema: {
|
|
1927
|
+
type: 'object',
|
|
1928
|
+
additionalProperties: false,
|
|
1929
|
+
properties: {
|
|
1930
|
+
path: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1931
|
+
content: { type: 'string', minLength: 1, maxLength: 256 * 1024 },
|
|
1932
|
+
},
|
|
1933
|
+
required: ['path', 'content'],
|
|
1934
|
+
},
|
|
1935
|
+
async handler({ path: relPath, content }, ctx) {
|
|
1936
|
+
const v = _validateScratchpadPath(relPath);
|
|
1937
|
+
if (!v.ok) return { _meta: tools_META, ok: false, reason: v.reason };
|
|
1938
|
+
let abs;
|
|
1939
|
+
try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
|
|
1940
|
+
catch (e) { return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` }; }
|
|
1941
|
+
const total = _scratchpadTotalBytes(ctx.sessionRoot);
|
|
1942
|
+
if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
|
|
1943
|
+
return {
|
|
1944
|
+
_meta: tools_META, ok: false,
|
|
1945
|
+
reason: `scratchpad-total-exceeded: ${total} + ${content.length} > ${SCRATCHPAD_MAX_TOTAL_BYTES}. Clean up via "rm -rf .agentic-security/agent-scratchpad" or rotate sessions.`,
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
let existing = 0;
|
|
1949
|
+
try { if (external_node_fs_.existsSync(abs)) existing = external_node_fs_.statSync(abs).size; } catch {}
|
|
1950
|
+
if (existing + content.length > SCRATCHPAD_MAX_FILE_BYTES) {
|
|
1951
|
+
return {
|
|
1952
|
+
_meta: tools_META, ok: false,
|
|
1953
|
+
reason: `scratchpad-file-exceeded: ${existing} + ${content.length} > ${SCRATCHPAD_MAX_FILE_BYTES}. Start a new file.`,
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
try {
|
|
1957
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
|
|
1958
|
+
external_node_fs_.appendFileSync(abs, content);
|
|
1959
|
+
return {
|
|
1960
|
+
_meta: tools_META, ok: true,
|
|
1961
|
+
path: relPath, bytesWritten: content.length, fileSize: existing + content.length,
|
|
1962
|
+
scratchpadTotal: total + content.length,
|
|
1963
|
+
};
|
|
1964
|
+
} catch (e) {
|
|
1965
|
+
return { _meta: tools_META, ok: false, reason: `write-failed: ${e.message}` };
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
};
|
|
1969
|
+
|
|
1970
|
+
const read_scratchpad = {
|
|
1971
|
+
name: 'read_scratchpad',
|
|
1972
|
+
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.',
|
|
1973
|
+
inputSchema: {
|
|
1974
|
+
type: 'object',
|
|
1975
|
+
additionalProperties: false,
|
|
1976
|
+
properties: {
|
|
1977
|
+
path: { type: 'string', minLength: 1, maxLength: 256 },
|
|
1978
|
+
offset: { type: 'integer', minimum: 0, maximum: 100 * 1024 * 1024 },
|
|
1979
|
+
limit: { type: 'integer', minimum: 1, maximum: 64 * 1024 },
|
|
1980
|
+
},
|
|
1981
|
+
required: ['path'],
|
|
1982
|
+
},
|
|
1983
|
+
async handler({ path: relPath, offset, limit }, ctx) {
|
|
1984
|
+
const v = _validateScratchpadPath(relPath);
|
|
1985
|
+
if (!v.ok) return { _meta: tools_META, ok: false, reason: v.reason };
|
|
1986
|
+
let abs;
|
|
1987
|
+
try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
|
|
1988
|
+
catch (e) { return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` }; }
|
|
1989
|
+
if (!external_node_fs_.existsSync(abs)) return { _meta: tools_META, ok: false, reason: 'not-found' };
|
|
1990
|
+
let stat;
|
|
1991
|
+
try { stat = external_node_fs_.statSync(abs); } catch (e) { return { _meta: tools_META, ok: false, reason: `stat-failed: ${e.message}` }; }
|
|
1992
|
+
if (!stat.isFile()) return { _meta: tools_META, ok: false, reason: 'not-a-file' };
|
|
1993
|
+
const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
|
|
1994
|
+
const lim = Number.isInteger(limit) ? Math.min(64 * 1024, Math.max(1, limit)) : 4096;
|
|
1995
|
+
let buf;
|
|
1996
|
+
try {
|
|
1997
|
+
const fd = external_node_fs_.openSync(abs, 'r');
|
|
1998
|
+
const tmp = Buffer.alloc(lim);
|
|
1999
|
+
const read = external_node_fs_.readSync(fd, tmp, 0, lim, off);
|
|
2000
|
+
external_node_fs_.closeSync(fd);
|
|
2001
|
+
buf = tmp.slice(0, read);
|
|
2002
|
+
} catch (e) { return { _meta: tools_META, ok: false, reason: `read-failed: ${e.message}` }; }
|
|
2003
|
+
const text = buf.toString('utf8');
|
|
2004
|
+
return {
|
|
2005
|
+
_meta: tools_META, ok: true,
|
|
2006
|
+
path: relPath,
|
|
2007
|
+
offset: off, limit: lim, bytesRead: buf.length,
|
|
2008
|
+
totalSize: stat.size,
|
|
2009
|
+
truncated: off + buf.length < stat.size,
|
|
2010
|
+
nextOffset: off + buf.length < stat.size ? off + buf.length : null,
|
|
2011
|
+
content: text,
|
|
2012
|
+
};
|
|
2013
|
+
},
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// ─── append_agents_memory / read_agents_memory ─────────────────────────────
|
|
2017
|
+
// LangChain harness-anatomy #2: AGENTS.md as continual-learning surface.
|
|
2018
|
+
// Lazy-import to keep the MCP module dependency-light.
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
const append_agents_memory = {
|
|
2024
|
+
name: 'append_agents_memory',
|
|
2025
|
+
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.',
|
|
2026
|
+
inputSchema: {
|
|
2027
|
+
type: 'object',
|
|
2028
|
+
additionalProperties: false,
|
|
2029
|
+
properties: {
|
|
2030
|
+
agent: { type: 'string', minLength: 1, maxLength: 64 },
|
|
2031
|
+
body: { type: 'string', minLength: 1, maxLength: 4096 },
|
|
2032
|
+
},
|
|
2033
|
+
required: ['agent', 'body'],
|
|
2034
|
+
},
|
|
2035
|
+
async handler({ agent, body }, ctx) {
|
|
2036
|
+
const r = appendAgentsMemory(ctx.sessionRoot, { agent, body });
|
|
2037
|
+
return { _meta: tools_META, ...r };
|
|
2038
|
+
},
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
const read_agents_memory = {
|
|
2042
|
+
name: 'read_agents_memory',
|
|
2043
|
+
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.',
|
|
2044
|
+
inputSchema: {
|
|
2045
|
+
type: 'object',
|
|
2046
|
+
additionalProperties: false,
|
|
2047
|
+
properties: {
|
|
2048
|
+
full: { type: 'boolean' },
|
|
2049
|
+
},
|
|
2050
|
+
},
|
|
2051
|
+
async handler({ full }, ctx) {
|
|
2052
|
+
const body = readAgentsMemory(ctx.sessionRoot);
|
|
2053
|
+
if (!body) return { _meta: tools_META, present: false };
|
|
2054
|
+
if (full) return { _meta: tools_META, present: true, length: body.length, content: body };
|
|
2055
|
+
// Tail-only — same logic as summarizeForSession but inlined to avoid a
|
|
2056
|
+
// second import surface.
|
|
2057
|
+
const limit = 6 * 1024;
|
|
2058
|
+
if (body.length <= limit) return { _meta: tools_META, present: true, length: body.length, content: body };
|
|
2059
|
+
const tail = body.slice(-limit);
|
|
2060
|
+
const firstSection = tail.indexOf('\n## ');
|
|
2061
|
+
const slice = firstSection >= 0 ? tail.slice(firstSection) : tail;
|
|
2062
|
+
return { _meta: tools_META, present: true, length: body.length, truncated: true, content: slice };
|
|
2063
|
+
},
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
// ─── query_triage_memory ───────────────────────────────────────────────────
|
|
2067
|
+
// Natural-language Q&A over past triage decisions (wont-fix / false-positive
|
|
2068
|
+
// markings + reasons). Backed by .agentic-security/triage-memory.jsonl, which
|
|
2069
|
+
// is auto-populated by triage.transition(). Returns at most 10 most-relevant
|
|
2070
|
+
// past decisions.
|
|
2071
|
+
|
|
2072
|
+
const query_triage_memory = {
|
|
2073
|
+
name: 'query_triage_memory',
|
|
2074
|
+
description: 'Search past triage decisions (wont-fix / false-positive) by natural-language query. Returns up to 10 most-relevant past decisions with their reasons. Use when you see a new finding and want to know "did we already decide on something like this?" — answers in seconds without re-reading the full AGENTS.md narrative.',
|
|
2075
|
+
inputSchema: {
|
|
2076
|
+
type: 'object',
|
|
2077
|
+
additionalProperties: false,
|
|
2078
|
+
properties: {
|
|
2079
|
+
query: { type: 'string', description: 'Free-text terms to match against past reasons / vuln text / file paths / family names.' },
|
|
2080
|
+
},
|
|
2081
|
+
},
|
|
2082
|
+
async handler({ query }, ctx) {
|
|
2083
|
+
const { queryMemory } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 1905));
|
|
2084
|
+
const raw = queryMemory(ctx.sessionRoot, query || '');
|
|
2085
|
+
// Stage 6 correctness audit: this returned queryMemory's output
|
|
2086
|
+
// verbatim, with no redaction pass — every other tool that echoes
|
|
2087
|
+
// scanned-source-derived text redacts it (mcp/CLAUDE.md's "Adding a new
|
|
2088
|
+
// tool" step 3). Round-trip through redactString the same way
|
|
2089
|
+
// redactFinding already does for its own opaque `.trace` field: results
|
|
2090
|
+
// here mix shapes (a triage decision's free-text `reason`, a finding's
|
|
2091
|
+
// `vuln`/`family`/file path), so scrubbing the whole serialized
|
|
2092
|
+
// structure catches secret-shaped substrings regardless of which field
|
|
2093
|
+
// they landed in, rather than hardcoding a field allowlist that could
|
|
2094
|
+
// miss one.
|
|
2095
|
+
let results;
|
|
2096
|
+
try { results = JSON.parse((0,redact/* redactString */.rd)(JSON.stringify(raw))); }
|
|
2097
|
+
catch { results = raw; }
|
|
2098
|
+
return {
|
|
2099
|
+
_meta: tools_META,
|
|
2100
|
+
count: results.length,
|
|
2101
|
+
results,
|
|
2102
|
+
};
|
|
2103
|
+
},
|
|
2104
|
+
};
|
|
2105
|
+
|
|
2106
|
+
// ─── query_findings_memory ─────────────────────────────────────────────────
|
|
2107
|
+
// Natural-language Q&A across the scanner's accumulated institutional
|
|
2108
|
+
// memory: current findings + past triage decisions + scan history +
|
|
2109
|
+
// AGENTS.md narrative. Use to answer "have we seen something like this
|
|
2110
|
+
// before?" without reading multiple files.
|
|
2111
|
+
|
|
2112
|
+
const query_findings_memory = {
|
|
2113
|
+
name: 'query_findings_memory',
|
|
2114
|
+
description: 'Search the scanner accumulated memory (current scan findings + past wont-fix/false-positive decisions + scan history + AGENTS.md narrative) by natural-language terms. Returns top-10 results scored by term-match count and ranked finding > triage > history > AGENTS.md.',
|
|
2115
|
+
inputSchema: {
|
|
2116
|
+
type: 'object',
|
|
2117
|
+
additionalProperties: false,
|
|
2118
|
+
properties: {
|
|
2119
|
+
query: { type: 'string', description: 'Natural-language search terms (2+ chars each).' },
|
|
2120
|
+
},
|
|
2121
|
+
required: ['query'],
|
|
2122
|
+
},
|
|
2123
|
+
async handler({ query }, ctx) {
|
|
2124
|
+
const { queryFindingsMemory } = await __webpack_require__.e(/* import() */ 3839).then(__webpack_require__.bind(__webpack_require__, 3839));
|
|
2125
|
+
const raw = queryFindingsMemory(ctx.sessionRoot, query || '');
|
|
2126
|
+
// Stage 6 correctness audit — same redaction gap and same fix as
|
|
2127
|
+
// query_triage_memory just above: this mixes four differently-shaped
|
|
2128
|
+
// result kinds (finding / triage / history / AGENTS.md text), so a
|
|
2129
|
+
// whole-structure redactString round-trip is applied rather than a
|
|
2130
|
+
// per-field allowlist that could miss one of the four shapes.
|
|
2131
|
+
let body;
|
|
2132
|
+
try { body = JSON.parse((0,redact/* redactString */.rd)(JSON.stringify(raw))); }
|
|
2133
|
+
catch { body = raw; }
|
|
2134
|
+
return { _meta: tools_META, ...body };
|
|
2135
|
+
},
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
// ─── lookup_cve ────────────────────────────────────────────────────────────
|
|
2139
|
+
// LangChain harness-anatomy #8: bridge the knowledge-cutoff gap by exposing
|
|
2140
|
+
// the local OSV / KEV / EPSS cache as a structured tool. Read-only — never
|
|
2141
|
+
// triggers a network fetch from the MCP path.
|
|
2142
|
+
const lookup_cve = {
|
|
2143
|
+
name: 'lookup_cve',
|
|
2144
|
+
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.',
|
|
2145
|
+
inputSchema: {
|
|
2146
|
+
type: 'object',
|
|
2147
|
+
additionalProperties: false,
|
|
2148
|
+
properties: {
|
|
2149
|
+
cve: { type: 'string', minLength: 9, maxLength: 20 },
|
|
2150
|
+
},
|
|
2151
|
+
required: ['cve'],
|
|
2152
|
+
},
|
|
2153
|
+
async handler({ cve }, _ctx) {
|
|
2154
|
+
const r = lookupCve(cve);
|
|
2155
|
+
return { _meta: tools_META, ...r };
|
|
2156
|
+
},
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
const query_cache_telemetry = {
|
|
2160
|
+
name: 'query_cache_telemetry',
|
|
2161
|
+
description: 'Read prompt-cache economics for the current session from the Claude Code transcript: cache-hit %, $ saved by caching, $ wasted on avoidable cache misses (model switches / TTL gaps / prefix changes), and a per-model breakdown. Read-only, no network. Use to reason about token-cost efficiency and whether a model switch is worth the cache rewarm.',
|
|
2162
|
+
inputSchema: {
|
|
2163
|
+
type: 'object',
|
|
2164
|
+
additionalProperties: false,
|
|
2165
|
+
properties: {
|
|
2166
|
+
// Optional explicit transcript path; otherwise derived from the session root.
|
|
2167
|
+
transcript_path: { type: 'string', minLength: 1, maxLength: 4096 },
|
|
2168
|
+
},
|
|
2169
|
+
required: [],
|
|
2170
|
+
},
|
|
2171
|
+
async handler({ transcript_path } = {}, ctx) {
|
|
2172
|
+
const result = (0,cache_economics.analyzeTranscript)({ transcriptPath: transcript_path, projectDir: ctx?.sessionRoot || process.cwd() });
|
|
2173
|
+
if (!result.ok) return { _meta: tools_META, ok: false, reason: result.reason };
|
|
2174
|
+
return {
|
|
2175
|
+
_meta: tools_META,
|
|
2176
|
+
ok: true,
|
|
2177
|
+
metrics: result.metrics,
|
|
2178
|
+
leaks: result.leaks,
|
|
2179
|
+
report: (0,cache_economics.formatCacheReport)(result),
|
|
2180
|
+
statusline: (0,cache_economics.renderCacheStatusLine)(result.metrics),
|
|
2181
|
+
};
|
|
2182
|
+
},
|
|
2183
|
+
};
|
|
2184
|
+
|
|
2185
|
+
// ─── synthesize_sca_upgrade ───────────────────────────────────────────────
|
|
2186
|
+
// Phase 3 / Item 5 of the SCA improvement plan. Read-only counterpart to
|
|
2187
|
+
// apply_sca_upgrade — produces a structured upgrade plan via the
|
|
2188
|
+
// ecosystem's native --dry-run command. Safe to call any number of times.
|
|
2189
|
+
let _scaUpgrade;
|
|
2190
|
+
async function _getScaUpgrade() {
|
|
2191
|
+
if (!_scaUpgrade) _scaUpgrade = await __webpack_require__.e(/* import() */ 5333).then(__webpack_require__.bind(__webpack_require__, 5333));
|
|
2192
|
+
return _scaUpgrade;
|
|
2193
|
+
}
|
|
2194
|
+
const synthesize_sca_upgrade = {
|
|
2195
|
+
name: 'synthesize_sca_upgrade',
|
|
2196
|
+
description: 'Generate an upgrade plan for a single SCA finding. Runs the ecosystem dry-run (npm install --dry-run, pip install --dry-run, cargo update --dry-run). Returns { ecosystem, package, currentVersion, targetVersion, isBreaking, command, manifestFiles, dryRun, testCommand }. No writes.',
|
|
2197
|
+
inputSchema: {
|
|
2198
|
+
type: 'object',
|
|
2199
|
+
additionalProperties: false,
|
|
2200
|
+
properties: {
|
|
2201
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
2202
|
+
},
|
|
2203
|
+
required: ['finding_id'],
|
|
2204
|
+
},
|
|
2205
|
+
async handler({ finding_id }, ctx) {
|
|
2206
|
+
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
2207
|
+
if (!scan) throw new Error(`No usable scan state (${status}).`);
|
|
2208
|
+
const f = _findById(scan, finding_id);
|
|
2209
|
+
if (!f) throw new Error(`Finding not found: ${finding_id}`);
|
|
2210
|
+
if (f.type !== 'vulnerable_dep') {
|
|
2211
|
+
return { _meta: tools_META, ok: false, reason: 'finding is not an SCA vulnerable_dep — use synthesize_fix for SAST findings' };
|
|
2212
|
+
}
|
|
2213
|
+
const { planScaUpgrade } = await _getScaUpgrade();
|
|
2214
|
+
const plan = await planScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f });
|
|
2215
|
+
return { _meta: tools_META, ...plan };
|
|
2216
|
+
},
|
|
2217
|
+
};
|
|
2218
|
+
|
|
2219
|
+
// ─── apply_sca_upgrade ────────────────────────────────────────────────────
|
|
2220
|
+
// Phase 3 / Item 5 of the SCA improvement plan. The MCP `apply_fix` path
|
|
2221
|
+
// refuses every package-manager manifest by design. This tool bypasses
|
|
2222
|
+
// that ONLY for the install pathway — it shells out to the ecosystem's
|
|
2223
|
+
// native package manager (npm / pip / cargo / go) which is the right
|
|
2224
|
+
// surface for safely modifying manifests + lockfiles. Backs up affected
|
|
2225
|
+
// manifests before the install; runs the project's test command (if
|
|
2226
|
+
// detected); rolls back manifests if tests fail.
|
|
2227
|
+
const apply_sca_upgrade = {
|
|
2228
|
+
name: 'apply_sca_upgrade',
|
|
2229
|
+
description: 'Apply a vulnerable_dep upgrade. Backs up manifests, runs the package manager, runs the project test command, restores manifests on test failure. Requires confirm:true. Set run_tests:false to skip the test gate (NOT recommended).',
|
|
2230
|
+
inputSchema: {
|
|
2231
|
+
type: 'object',
|
|
2232
|
+
additionalProperties: false,
|
|
2233
|
+
properties: {
|
|
2234
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
2235
|
+
confirm: { type: 'boolean' },
|
|
2236
|
+
run_tests: { type: 'boolean' },
|
|
2237
|
+
},
|
|
2238
|
+
required: ['finding_id', 'confirm'],
|
|
2239
|
+
},
|
|
2240
|
+
async handler({ finding_id, confirm, run_tests = true }, ctx) {
|
|
2241
|
+
if (confirm !== true) {
|
|
2242
|
+
return { _meta: tools_META, applied: false, reason: 'apply_sca_upgrade requires confirm: true.' };
|
|
2243
|
+
}
|
|
2244
|
+
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
2245
|
+
if (!scan) {
|
|
2246
|
+
return { _meta: tools_META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
|
|
2247
|
+
}
|
|
2248
|
+
const f = _findById(scan, finding_id);
|
|
2249
|
+
if (!f) return { _meta: tools_META, applied: false, reason: `Finding not found: ${finding_id}` };
|
|
2250
|
+
if (f.type !== 'vulnerable_dep') {
|
|
2251
|
+
return { _meta: tools_META, applied: false, reason: 'finding is not an SCA vulnerable_dep — use apply_fix for SAST findings' };
|
|
2252
|
+
}
|
|
2253
|
+
const { applyScaUpgrade } = await _getScaUpgrade();
|
|
2254
|
+
const result = await applyScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f, runTests: run_tests });
|
|
2255
|
+
return { _meta: tools_META, ...result };
|
|
2256
|
+
},
|
|
2257
|
+
};
|
|
2258
|
+
|
|
2259
|
+
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, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry, dataflow_get_graph, dataflow_get_node, dataflow_get_edge, dataflow_get_flow];
|
|
2260
|
+
|
|
2261
|
+
// EXTERNAL MODULE: ./src/mcp/validate.js
|
|
2262
|
+
var validate = __webpack_require__(1211);
|
|
2263
|
+
// EXTERNAL MODULE: ./src/mcp/audit.js
|
|
2264
|
+
var audit = __webpack_require__(9390);
|
|
2265
|
+
;// CONCATENATED MODULE: ./src/mcp/server.js
|
|
2266
|
+
// MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
|
|
2267
|
+
//
|
|
2268
|
+
// Hardening posture (mapped to OWASP MCP Top 10):
|
|
2269
|
+
// - Session root chosen at server boot, no per-call retargeting (MCP02)
|
|
2270
|
+
// - Every tools/call argument validated against the tool's inputSchema (MCP02/MCP05)
|
|
2271
|
+
// - Every tools/call audited with a hash-chained log (MCP08)
|
|
2272
|
+
// - serverInfo.codeFingerprint = SHA-256 of MCP source files (MCP04/MCP09)
|
|
2273
|
+
// so a fleet can detect tampered or unauthorized server deployments
|
|
2274
|
+
// - AGENTIC_SECURITY_MCP_DISABLED=1 hard-disables all tool calls (MCP09)
|
|
2275
|
+
// - Stdio transport caps line/buffer size (./stdio.js) (MCP05 DoS)
|
|
2276
|
+
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
const PROTOCOL_VERSION = '2025-03-26';
|
|
2286
|
+
const SERVER_NAME = 'agentic-security';
|
|
2287
|
+
|
|
2288
|
+
// Premortem #6: read version from scanner/package.json at module load so the
|
|
2289
|
+
// MCP `initialize` response can't silently drift from the shipped package
|
|
2290
|
+
// version. A hardcoded constant rotted from 0.39.2 → wrong for every release
|
|
2291
|
+
// that followed. Fall back to 'unknown' rather than a stale literal.
|
|
2292
|
+
const SERVER_VERSION = (() => {
|
|
2293
|
+
try {
|
|
2294
|
+
const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
|
|
2295
|
+
// scanner/src/mcp/ → scanner/package.json
|
|
2296
|
+
const pkgPath = external_node_path_.resolve(here, '..', '..', 'package.json');
|
|
2297
|
+
const pkg = JSON.parse(external_node_fs_.readFileSync(pkgPath, 'utf8'));
|
|
2298
|
+
if (typeof pkg.version === 'string' && pkg.version.length) return pkg.version;
|
|
2299
|
+
} catch { /* fall through */ }
|
|
2300
|
+
return 'unknown';
|
|
2301
|
+
})();
|
|
2302
|
+
|
|
2303
|
+
const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));
|
|
2304
|
+
|
|
2305
|
+
// Code fingerprint — SHA-256 of the MCP source files concatenated in a
|
|
2306
|
+
// stable order. Embedded in `initialize` response so a fleet operator can
|
|
2307
|
+
// detect when an unapproved build is running (OWASP MCP04/MCP09).
|
|
2308
|
+
function _codeFingerprint() {
|
|
2309
|
+
try {
|
|
2310
|
+
const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
|
|
2311
|
+
const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
2312
|
+
const h = external_node_crypto_.createHash('sha256');
|
|
2313
|
+
for (const f of files) {
|
|
2314
|
+
try { h.update(f); h.update(external_node_fs_.readFileSync(external_node_path_.join(here, f))); } catch {}
|
|
2315
|
+
}
|
|
2316
|
+
return h.digest('hex');
|
|
2317
|
+
} catch { return null; }
|
|
2318
|
+
}
|
|
2319
|
+
const CODE_FINGERPRINT = _codeFingerprint();
|
|
2320
|
+
|
|
2321
|
+
function _err(id, code, message, data) {
|
|
2322
|
+
const out = { jsonrpc: '2.0', id, error: { code, message } };
|
|
2323
|
+
if (data !== undefined) out.error.data = data;
|
|
2324
|
+
return out;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function _ok(id, result) {
|
|
2328
|
+
return { jsonrpc: '2.0', id, result };
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
2332
|
+
const ctx = { sessionRoot };
|
|
2333
|
+
|
|
2334
|
+
async function handleRequest(msg) {
|
|
2335
|
+
if (!msg || typeof msg !== 'object') return _err(null, -32600, 'Invalid Request');
|
|
2336
|
+
if (msg.jsonrpc !== '2.0') return _err(msg.id ?? null, -32600, 'Invalid Request: jsonrpc must be "2.0"');
|
|
2337
|
+
|
|
2338
|
+
const isNotification = msg.id === undefined || msg.id === null;
|
|
2339
|
+
const id = msg.id ?? null;
|
|
2340
|
+
const disabled = process.env.AGENTIC_SECURITY_MCP_DISABLED === '1';
|
|
2341
|
+
|
|
2342
|
+
switch (msg.method) {
|
|
2343
|
+
case 'initialize':
|
|
2344
|
+
return _ok(id, {
|
|
2345
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2346
|
+
capabilities: { tools: {} },
|
|
2347
|
+
serverInfo: {
|
|
2348
|
+
name: SERVER_NAME,
|
|
2349
|
+
version: SERVER_VERSION,
|
|
2350
|
+
codeFingerprint: CODE_FINGERPRINT,
|
|
2351
|
+
disabled,
|
|
2352
|
+
},
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
case 'notifications/initialized':
|
|
2356
|
+
return null;
|
|
2357
|
+
|
|
2358
|
+
case 'ping':
|
|
2359
|
+
return _ok(id, {});
|
|
2360
|
+
|
|
2361
|
+
case 'tools/list':
|
|
2362
|
+
return _ok(id, {
|
|
2363
|
+
tools: ALL_TOOLS.map(t => ({
|
|
2364
|
+
name: t.name,
|
|
2365
|
+
description: t.description,
|
|
2366
|
+
inputSchema: t.inputSchema,
|
|
2367
|
+
})),
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
case 'tools/call': {
|
|
2371
|
+
const name = msg.params?.name;
|
|
2372
|
+
const args = msg.params?.arguments ?? {};
|
|
2373
|
+
if (disabled) {
|
|
2374
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
|
|
2375
|
+
return _ok(id, {
|
|
2376
|
+
content: [{ type: 'text', text: 'MCP server is disabled (AGENTIC_SECURITY_MCP_DISABLED=1).' }],
|
|
2377
|
+
isError: true,
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
const tool = TOOLS_BY_NAME[name];
|
|
2381
|
+
if (!tool) {
|
|
2382
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
|
|
2383
|
+
return _err(id, -32602, `Unknown tool: ${name}`);
|
|
2384
|
+
}
|
|
2385
|
+
try { (0,validate/* validate */.t)(tool.inputSchema, args); }
|
|
2386
|
+
catch (e) {
|
|
2387
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
|
|
2388
|
+
return _ok(id, {
|
|
2389
|
+
content: [{ type: 'text', text: `Invalid arguments: ${e.message}` }],
|
|
2390
|
+
isError: true,
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
try {
|
|
2394
|
+
const result = await tool.handler(args, ctx);
|
|
2395
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'ok' });
|
|
2396
|
+
return _ok(id, {
|
|
2397
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
2398
|
+
isError: false,
|
|
2399
|
+
});
|
|
2400
|
+
} catch (e) {
|
|
2401
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
|
|
2402
|
+
return _ok(id, {
|
|
2403
|
+
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
2404
|
+
isError: true,
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
default:
|
|
2410
|
+
if (isNotification) return null;
|
|
2411
|
+
return _err(id, -32601, `Method not found: ${msg.method}`);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
return { handleRequest, sessionRoot };
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// NOTE: no default-singleton export. Callers must use createServer({...})
|
|
2419
|
+
// with an explicit sessionRoot. Removed because the prior default was bound
|
|
2420
|
+
// to process.cwd() at module-load time — a footgun for any caller that
|
|
2421
|
+
// imported `handleRequest` directly (OWASP A05).
|
|
2422
|
+
|
|
2423
|
+
|
|
2424
|
+
|
|
2425
|
+
;// CONCATENATED MODULE: ./src/mcp/stdio.js
|
|
2426
|
+
// Stdio transport for the MCP server — newline-delimited JSON in/out.
|
|
2427
|
+
//
|
|
2428
|
+
// MCP's stdio transport is NDJSON: one JSON-RPC message per line on stdin,
|
|
2429
|
+
// one response per line on stdout. stderr is reserved for logging.
|
|
2430
|
+
//
|
|
2431
|
+
// Hardening:
|
|
2432
|
+
// - Per-message line cap (MAX_LINE_BYTES). A line over the cap is dropped
|
|
2433
|
+
// and the buffer state is reset so a long oversize payload can't peg
|
|
2434
|
+
// the parser via `buf += chunk` growth.
|
|
2435
|
+
// - Buffer hard cap (MAX_BUFFER_BYTES). Reached if input arrives with no
|
|
2436
|
+
// newlines (e.g., a peer streaming a 4GB stream of `a`). On overflow we
|
|
2437
|
+
// emit a parse-error response and reset.
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
const MAX_LINE_BYTES = 4 * 1024 * 1024; // 4 MB per JSON-RPC message
|
|
2442
|
+
const MAX_BUFFER_BYTES = 8 * 1024 * 1024; // 8 MB sliding buffer
|
|
2443
|
+
|
|
2444
|
+
function runStdio({
|
|
2445
|
+
stdin = process.stdin,
|
|
2446
|
+
stdout = process.stdout,
|
|
2447
|
+
stderr = process.stderr,
|
|
2448
|
+
sessionRoot = process.cwd(),
|
|
2449
|
+
} = {}) {
|
|
2450
|
+
const server = createServer({ sessionRoot });
|
|
2451
|
+
let buf = '';
|
|
2452
|
+
let overflowSkip = false; // true while we are dropping bytes until the next newline
|
|
2453
|
+
|
|
2454
|
+
stdin.setEncoding('utf8');
|
|
2455
|
+
|
|
2456
|
+
stdin.on('data', async (chunk) => {
|
|
2457
|
+
if (overflowSkip) {
|
|
2458
|
+
const nl = chunk.indexOf('\n');
|
|
2459
|
+
if (nl === -1) return;
|
|
2460
|
+
// Resume after the next newline.
|
|
2461
|
+
chunk = chunk.slice(nl + 1);
|
|
2462
|
+
overflowSkip = false;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
buf += chunk;
|
|
2466
|
+
|
|
2467
|
+
// Hard buffer cap — only triggers if a peer is streaming without newlines.
|
|
2468
|
+
if (buf.length > MAX_BUFFER_BYTES) {
|
|
2469
|
+
stderr.write(`mcp: input buffer exceeded ${MAX_BUFFER_BYTES} bytes — dropping until next newline\n`);
|
|
2470
|
+
const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: input too large' } };
|
|
2471
|
+
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
2472
|
+
buf = '';
|
|
2473
|
+
overflowSkip = true;
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
let nl;
|
|
2478
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
2479
|
+
const line = buf.slice(0, nl).trim();
|
|
2480
|
+
buf = buf.slice(nl + 1);
|
|
2481
|
+
if (!line) continue;
|
|
2482
|
+
if (line.length > MAX_LINE_BYTES) {
|
|
2483
|
+
stderr.write(`mcp: dropped oversize line (${line.length} > ${MAX_LINE_BYTES} bytes)\n`);
|
|
2484
|
+
const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: line too large' } };
|
|
2485
|
+
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
2486
|
+
continue;
|
|
2487
|
+
}
|
|
2488
|
+
let msg;
|
|
2489
|
+
try { msg = JSON.parse(line); }
|
|
2490
|
+
catch (e) {
|
|
2491
|
+
stderr.write(`mcp: failed to parse line as JSON: ${e.message}\n`);
|
|
2492
|
+
const errResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
|
|
2493
|
+
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
try {
|
|
2497
|
+
const response = await server.handleRequest(msg);
|
|
2498
|
+
if (response !== null) stdout.write(JSON.stringify(response) + '\n');
|
|
2499
|
+
} catch (e) {
|
|
2500
|
+
stderr.write(`mcp: handler threw: ${e.message}\n`);
|
|
2501
|
+
const errResponse = { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: 'Internal error', data: e.message } };
|
|
2502
|
+
stdout.write(JSON.stringify(errResponse) + '\n');
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
stdin.on('end', () => { process.exit(0); });
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
/***/ }),
|
|
2512
|
+
|
|
2513
|
+
/***/ 1211:
|
|
2514
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2515
|
+
|
|
2516
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2517
|
+
/* harmony export */ t: () => (/* binding */ validate)
|
|
2518
|
+
/* harmony export */ });
|
|
2519
|
+
// Minimal JSON Schema validator — just the subset our tool schemas use.
|
|
2520
|
+
// No deps. Throws on invalid input with a path-prefixed error message.
|
|
2521
|
+
//
|
|
2522
|
+
// Supported keywords: type (object/array/string/boolean/number),
|
|
2523
|
+
// required, properties, items, enum, minItems, maxItems, maxLength,
|
|
2524
|
+
// minLength, additionalProperties (only as `false` — strict).
|
|
2525
|
+
|
|
2526
|
+
const TYPE_OF = (v) => {
|
|
2527
|
+
if (v === null) return 'null';
|
|
2528
|
+
if (Array.isArray(v)) return 'array';
|
|
2529
|
+
return typeof v;
|
|
2530
|
+
};
|
|
2531
|
+
|
|
2532
|
+
function validate(schema, value, path = 'arguments') {
|
|
2533
|
+
if (!schema) return;
|
|
2534
|
+
const t = schema.type;
|
|
2535
|
+
if (t === 'object') {
|
|
2536
|
+
if (TYPE_OF(value) !== 'object') throw new Error(`${path}: expected object, got ${TYPE_OF(value)}`);
|
|
2537
|
+
for (const req of schema.required || []) {
|
|
2538
|
+
if (!(req in value)) throw new Error(`${path}: missing required property "${req}"`);
|
|
2539
|
+
}
|
|
2540
|
+
if (schema.additionalProperties === false) {
|
|
2541
|
+
const allowed = new Set(Object.keys(schema.properties || {}));
|
|
2542
|
+
for (const k of Object.keys(value)) {
|
|
2543
|
+
if (!allowed.has(k)) throw new Error(`${path}: unexpected property "${k}"`);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
for (const [k, sub] of Object.entries(schema.properties || {})) {
|
|
2547
|
+
if (k in value) validate(sub, value[k], `${path}.${k}`);
|
|
2548
|
+
}
|
|
2549
|
+
} else if (t === 'array') {
|
|
2550
|
+
if (!Array.isArray(value)) throw new Error(`${path}: expected array, got ${TYPE_OF(value)}`);
|
|
2551
|
+
if (schema.minItems != null && value.length < schema.minItems) throw new Error(`${path}: minItems=${schema.minItems}, got length=${value.length}`);
|
|
2552
|
+
if (schema.maxItems != null && value.length > schema.maxItems) throw new Error(`${path}: maxItems=${schema.maxItems}, got length=${value.length}`);
|
|
2553
|
+
if (schema.items) for (let i = 0; i < value.length; i++) validate(schema.items, value[i], `${path}[${i}]`);
|
|
2554
|
+
} else if (t === 'string') {
|
|
2555
|
+
if (typeof value !== 'string') throw new Error(`${path}: expected string, got ${TYPE_OF(value)}`);
|
|
2556
|
+
if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: must be one of [${schema.enum.join(', ')}]`);
|
|
2557
|
+
if (schema.maxLength != null && value.length > schema.maxLength) throw new Error(`${path}: maxLength=${schema.maxLength}, got length=${value.length}`);
|
|
2558
|
+
if (schema.minLength != null && value.length < schema.minLength) throw new Error(`${path}: minLength=${schema.minLength}, got length=${value.length}`);
|
|
2559
|
+
} else if (t === 'boolean') {
|
|
2560
|
+
if (typeof value !== 'boolean') throw new Error(`${path}: expected boolean, got ${TYPE_OF(value)}`);
|
|
2561
|
+
} else if (t === 'number' || t === 'integer') {
|
|
2562
|
+
if (typeof value !== 'number') throw new Error(`${path}: expected number, got ${TYPE_OF(value)}`);
|
|
2563
|
+
if (t === 'integer' && !Number.isInteger(value)) throw new Error(`${path}: expected integer`);
|
|
2564
|
+
if (schema.minimum != null && value < schema.minimum) throw new Error(`${path}: < minimum (${schema.minimum})`);
|
|
2565
|
+
if (schema.maximum != null && value > schema.maximum) throw new Error(`${path}: > maximum (${schema.maximum})`);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
|
|
2570
|
+
/***/ }),
|
|
2571
|
+
|
|
2572
|
+
/***/ 8752:
|
|
2573
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2574
|
+
|
|
2575
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2576
|
+
/* harmony export */ analyzeTranscript: () => (/* binding */ analyzeTranscript),
|
|
2577
|
+
/* harmony export */ formatCacheReport: () => (/* binding */ formatCacheReport),
|
|
2578
|
+
/* harmony export */ renderCacheStatusLine: () => (/* binding */ renderCacheStatusLine)
|
|
2579
|
+
/* harmony export */ });
|
|
2580
|
+
/* unused harmony export _internal */
|
|
2581
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
|
|
2582
|
+
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8161);
|
|
2583
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
|
|
2584
|
+
// Prompt-cache economics — turn Claude Code's own transcript usage into a
|
|
2585
|
+
// dollarized report: how much prompt caching saved, how much was wasted on
|
|
2586
|
+
// avoidable cache misses, and what invalidated the cache.
|
|
2587
|
+
//
|
|
2588
|
+
// Source of truth: the Claude Code transcript at
|
|
2589
|
+
// ~/.claude/projects/<enc>/<session>.jsonl
|
|
2590
|
+
// where <enc> is CLAUDE_PROJECT_DIR with `/` and `.` replaced by `-`. Each
|
|
2591
|
+
// assistant turn carries `message.usage` with input/output/cache_read/
|
|
2592
|
+
// cache_creation token counts (and a 5m/1h write split). We price those against
|
|
2593
|
+
// per-model rates to compute real economics — no estimates, no network.
|
|
2594
|
+
//
|
|
2595
|
+
// Pure compute on parsed records; only `locateTranscript`/`parseTranscriptUsage`
|
|
2596
|
+
// touch the filesystem. ESM (scanner tree). A trimmed CJS twin lives at
|
|
2597
|
+
// hooks/lib/transcript.js for the CJS hooks; test/cache-economics.test.js asserts
|
|
2598
|
+
// the two agree.
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
|
|
2603
|
+
// Cents-scale money formatter (fmtUsd in risk-dollars.js targets five-figure
|
|
2604
|
+
// breach costs and won't round sub-dollar values).
|
|
2605
|
+
function money(n) {
|
|
2606
|
+
const v = Number(n) || 0;
|
|
2607
|
+
return Math.abs(v) >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(4)}`;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// Per-1M-token rates (input / output). Mirror hooks/model-cost-advisor.js MODELS.
|
|
2611
|
+
const MODEL_RATES = {
|
|
2612
|
+
fable: { label: 'Fable 5', in: 10, out: 50 },
|
|
2613
|
+
opus: { label: 'Opus 4.8', in: 5, out: 25 },
|
|
2614
|
+
sonnet5: { label: 'Sonnet 5', in: 3, out: 15 },
|
|
2615
|
+
sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
|
|
2616
|
+
haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
|
|
2617
|
+
};
|
|
2618
|
+
const CACHE_READ_MULT = 0.1; // cache read ≈ 0.1× input
|
|
2619
|
+
const CACHE_WRITE_MULT = 1.25; // 5-minute cache write ≈ 1.25× input
|
|
2620
|
+
const CACHE_WRITE_1H_MULT = 2.0; // 1-hour cache write ≈ 2× input
|
|
2621
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
2622
|
+
|
|
2623
|
+
// Map any model string to a rate family. Returns null for unpriceable models
|
|
2624
|
+
// (e.g. "<synthetic>" sidechain/compaction turns) so they're skipped.
|
|
2625
|
+
function rateFor(model) {
|
|
2626
|
+
if (typeof model !== 'string') return null;
|
|
2627
|
+
const s = model.toLowerCase();
|
|
2628
|
+
if (s.includes('fable') || s.includes('mythos')) return MODEL_RATES.fable;
|
|
2629
|
+
if (s.includes('haiku')) return MODEL_RATES.haiku;
|
|
2630
|
+
if (s.includes('sonnet')) return (s.includes('sonnet-5') || s.includes('sonnet 5')) ? MODEL_RATES.sonnet5 : MODEL_RATES.sonnet;
|
|
2631
|
+
if (s.includes('opus')) return MODEL_RATES.opus;
|
|
2632
|
+
return null;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// ── Transcript discovery + parse ─────────────────────────────────────────────
|
|
2636
|
+
|
|
2637
|
+
function encodeProjectDir(dir) {
|
|
2638
|
+
return String(dir).replace(/[/.]/g, '-');
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// Locate the session transcript. Prefer an explicit (hook-provided) path; else
|
|
2642
|
+
// derive the project's transcript dir and take the most-recently-modified jsonl.
|
|
2643
|
+
function locateTranscript({ transcriptPath, projectDir } = {}) {
|
|
2644
|
+
try {
|
|
2645
|
+
if (transcriptPath && node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(transcriptPath)) return transcriptPath;
|
|
2646
|
+
} catch { /* fall through */ }
|
|
2647
|
+
try {
|
|
2648
|
+
const dir = node_path__WEBPACK_IMPORTED_MODULE_2__.join(node_os__WEBPACK_IMPORTED_MODULE_1__.homedir(), '.claude', 'projects', encodeProjectDir(projectDir || process.cwd()));
|
|
2649
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(dir)) return null;
|
|
2650
|
+
const files = node_fs__WEBPACK_IMPORTED_MODULE_0__.readdirSync(dir)
|
|
2651
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
2652
|
+
.map(f => ({ f: node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, f), m: node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, f)).mtimeMs }))
|
|
2653
|
+
.sort((a, b) => b.m - a.m);
|
|
2654
|
+
return files.length ? files[0].f : null;
|
|
2655
|
+
} catch { return null; }
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// Parse a transcript jsonl into per-assistant-turn usage records. Skips lines
|
|
2659
|
+
// that aren't priceable assistant turns.
|
|
2660
|
+
function parseTranscriptUsage(jsonlPath) {
|
|
2661
|
+
let raw;
|
|
2662
|
+
try { raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(jsonlPath, 'utf8'); } catch { return []; }
|
|
2663
|
+
const records = [];
|
|
2664
|
+
for (const line of raw.split('\n')) {
|
|
2665
|
+
const t = line.trim();
|
|
2666
|
+
if (!t) continue;
|
|
2667
|
+
let o;
|
|
2668
|
+
try { o = JSON.parse(t); } catch { continue; }
|
|
2669
|
+
if (o.type !== 'assistant') continue;
|
|
2670
|
+
const msg = o.message;
|
|
2671
|
+
const u = msg && msg.usage;
|
|
2672
|
+
if (!u || !msg.model || !rateFor(msg.model)) continue;
|
|
2673
|
+
const cc = u.cache_creation || {};
|
|
2674
|
+
records.push({
|
|
2675
|
+
model: msg.model,
|
|
2676
|
+
input: u.input_tokens || 0,
|
|
2677
|
+
output: u.output_tokens || 0,
|
|
2678
|
+
cacheRead: u.cache_read_input_tokens || 0,
|
|
2679
|
+
cacheCreate: u.cache_creation_input_tokens || 0,
|
|
2680
|
+
cacheCreate5m: cc.ephemeral_5m_input_tokens || 0,
|
|
2681
|
+
cacheCreate1h: cc.ephemeral_1h_input_tokens || 0,
|
|
2682
|
+
ts: o.timestamp ? Date.parse(o.timestamp) : null,
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
return records;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// ── Pure economics ───────────────────────────────────────────────────────────
|
|
2689
|
+
|
|
2690
|
+
function writeCostUsd(r, inRate) {
|
|
2691
|
+
const m5 = r.cacheCreate5m || 0, m1 = r.cacheCreate1h || 0;
|
|
2692
|
+
if (m5 + m1 > 0) return (m5 * CACHE_WRITE_MULT + m1 * CACHE_WRITE_1H_MULT) * inRate;
|
|
2693
|
+
return (r.cacheCreate || 0) * CACHE_WRITE_MULT * inRate; // breakdown absent
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
// Aggregate economics over parsed records.
|
|
2697
|
+
function computeCacheEconomics(records) {
|
|
2698
|
+
let turns = 0, inTok = 0, outTok = 0, cacheRead = 0, cacheCreate = 0;
|
|
2699
|
+
let actualUsd = 0, uncachedUsd = 0, writePremiumUsd = 0;
|
|
2700
|
+
const perModel = {};
|
|
2701
|
+
|
|
2702
|
+
for (const r of records) {
|
|
2703
|
+
const rate = rateFor(r.model);
|
|
2704
|
+
if (!rate) continue;
|
|
2705
|
+
turns++;
|
|
2706
|
+
const inRate = rate.in / 1e6, outRate = rate.out / 1e6;
|
|
2707
|
+
|
|
2708
|
+
const readCost = r.cacheRead * inRate * CACHE_READ_MULT;
|
|
2709
|
+
const writeCost = writeCostUsd(r, inRate);
|
|
2710
|
+
const inCost = r.input * inRate;
|
|
2711
|
+
const outCost = r.output * outRate;
|
|
2712
|
+
const turnActual = readCost + writeCost + inCost + outCost;
|
|
2713
|
+
// What this turn would have cost with NO caching: every input-side token full price.
|
|
2714
|
+
const turnUncached = (r.cacheRead + r.cacheCreate + r.input) * inRate + outCost;
|
|
2715
|
+
|
|
2716
|
+
actualUsd += turnActual;
|
|
2717
|
+
uncachedUsd += turnUncached;
|
|
2718
|
+
writePremiumUsd += writeCost - (r.cacheCreate * inRate); // the >1× premium paid to cache
|
|
2719
|
+
|
|
2720
|
+
inTok += r.input; outTok += r.output; cacheRead += r.cacheRead; cacheCreate += r.cacheCreate;
|
|
2721
|
+
|
|
2722
|
+
const key = rate.label;
|
|
2723
|
+
const pm = perModel[key] || (perModel[key] = { turns: 0, actualUsd: 0, cacheRead: 0, inputSide: 0 });
|
|
2724
|
+
pm.turns++; pm.actualUsd += turnActual; pm.cacheRead += r.cacheRead;
|
|
2725
|
+
pm.inputSide += r.cacheRead + r.cacheCreate + r.input;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
const inputSide = cacheRead + cacheCreate + inTok;
|
|
2729
|
+
return {
|
|
2730
|
+
turns,
|
|
2731
|
+
tokens: { input: inTok, output: outTok, cacheRead, cacheCreate },
|
|
2732
|
+
actualUsd,
|
|
2733
|
+
uncachedUsd,
|
|
2734
|
+
savedUsd: uncachedUsd - actualUsd, // net $ caching saved (can dip negative early)
|
|
2735
|
+
writePremiumUsd, // $ invested establishing caches
|
|
2736
|
+
cacheHitRatio: inputSide ? cacheRead / inputSide : 0,
|
|
2737
|
+
costPerTurnUsd: turns ? actualUsd / turns : 0,
|
|
2738
|
+
perModel,
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// Attribute cache drops: a turn that re-ingests a large prefix cold after a warm
|
|
2743
|
+
// prior turn. Cause = model-switch | cache-expired | prefix-change.
|
|
2744
|
+
function detectInvalidators(records) {
|
|
2745
|
+
const leaks = [];
|
|
2746
|
+
const MIN_WARM = 2000;
|
|
2747
|
+
for (let i = 1; i < records.length; i++) {
|
|
2748
|
+
const prev = records[i - 1], cur = records[i];
|
|
2749
|
+
const prevWarm = prev.cacheRead + prev.input + prev.cacheCreate;
|
|
2750
|
+
if (prevWarm < MIN_WARM) continue;
|
|
2751
|
+
const curFresh = cur.input + cur.cacheCreate;
|
|
2752
|
+
const coldish = cur.cacheRead < prevWarm * 0.25 && curFresh > prevWarm * 0.5;
|
|
2753
|
+
if (!coldish) continue;
|
|
2754
|
+
|
|
2755
|
+
let cause;
|
|
2756
|
+
if (cur.model !== prev.model) cause = 'model-switch';
|
|
2757
|
+
else if (cur.ts && prev.ts && (cur.ts - prev.ts) > TTL_MS) cause = 'cache-expired';
|
|
2758
|
+
else cause = 'prefix-change';
|
|
2759
|
+
|
|
2760
|
+
const rate = rateFor(cur.model);
|
|
2761
|
+
const inRate = rate ? rate.in / 1e6 : 0;
|
|
2762
|
+
// Extra paid vs. having kept the prefix as a cheap cache read.
|
|
2763
|
+
const wastedUsd = prevWarm * inRate * (1 - CACHE_READ_MULT);
|
|
2764
|
+
leaks.push({ turn: i, cause, wastedUsd, model: cur.model });
|
|
2765
|
+
}
|
|
2766
|
+
return leaks;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// Convenience: locate → parse → compute → detect. Returns { ok:false } when no
|
|
2770
|
+
// transcript is available.
|
|
2771
|
+
function analyzeTranscript(opts = {}) {
|
|
2772
|
+
const transcript = locateTranscript(opts);
|
|
2773
|
+
if (!transcript) return { ok: false, reason: 'no-transcript' };
|
|
2774
|
+
const records = parseTranscriptUsage(transcript);
|
|
2775
|
+
if (!records.length) return { ok: false, reason: 'no-priceable-turns', transcript };
|
|
2776
|
+
return {
|
|
2777
|
+
ok: true,
|
|
2778
|
+
transcript,
|
|
2779
|
+
metrics: computeCacheEconomics(records),
|
|
2780
|
+
leaks: detectInvalidators(records),
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// ── Report formatting ────────────────────────────────────────────────────────
|
|
2785
|
+
|
|
2786
|
+
const CAUSE_LABEL = {
|
|
2787
|
+
'model-switch': 'model switch (cache is model-scoped)',
|
|
2788
|
+
'cache-expired': 'cache expired (gap > 5-min TTL)',
|
|
2789
|
+
'prefix-change': 'prefix changed (system prompt / tools / context edit)',
|
|
2790
|
+
};
|
|
2791
|
+
|
|
2792
|
+
// F6 — one-line HUD for a Claude Code statusLine command (mirrors
|
|
2793
|
+
// watch-mode.js renderStatusLine). Takes the metrics from computeCacheEconomics.
|
|
2794
|
+
function renderCacheStatusLine(metrics) {
|
|
2795
|
+
if (!metrics || !metrics.turns) return 'agentic-security: no session cost yet';
|
|
2796
|
+
const hit = Math.round(metrics.cacheHitRatio * 100);
|
|
2797
|
+
return `agentic-security: ${money(metrics.actualUsd)} · ${hit}% cached · ${money(metrics.costPerTurnUsd)}/turn`;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function formatCacheReport(result) {
|
|
2801
|
+
if (!result.ok) {
|
|
2802
|
+
return result.reason === 'no-transcript'
|
|
2803
|
+
? 'agentic-security: no Claude Code transcript found for this project yet.'
|
|
2804
|
+
: 'agentic-security: transcript has no priceable model turns yet.';
|
|
2805
|
+
}
|
|
2806
|
+
const m = result.metrics;
|
|
2807
|
+
const lines = [];
|
|
2808
|
+
lines.push('');
|
|
2809
|
+
lines.push(' Prompt-cache economics — this session');
|
|
2810
|
+
lines.push(` ${result.turns ?? m.turns} model turns\n`);
|
|
2811
|
+
lines.push(` cache hit ratio ${(m.cacheHitRatio * 100).toFixed(1)}% (input-side tokens served from cache)`);
|
|
2812
|
+
lines.push(` spent ${money(m.actualUsd)} (~${money(m.costPerTurnUsd)}/turn)`);
|
|
2813
|
+
lines.push(` ▶ saved by caching ${money(m.savedUsd)} vs. ${money(m.uncachedUsd)} with no cache`);
|
|
2814
|
+
lines.push(` invested in caches ${money(m.writePremiumUsd)} (write premium over base input)`);
|
|
2815
|
+
lines.push('');
|
|
2816
|
+
lines.push(' tokens: '
|
|
2817
|
+
+ `${m.tokens.cacheRead.toLocaleString()} cached-read · `
|
|
2818
|
+
+ `${m.tokens.cacheCreate.toLocaleString()} cache-write · `
|
|
2819
|
+
+ `${m.tokens.input.toLocaleString()} fresh-in · `
|
|
2820
|
+
+ `${m.tokens.output.toLocaleString()} out`);
|
|
2821
|
+
|
|
2822
|
+
const models = Object.keys(m.perModel);
|
|
2823
|
+
if (models.length > 1) {
|
|
2824
|
+
lines.push('\n by model:');
|
|
2825
|
+
for (const k of models.sort()) {
|
|
2826
|
+
const pm = m.perModel[k];
|
|
2827
|
+
const hr = pm.inputSide ? (pm.cacheRead / pm.inputSide * 100).toFixed(0) : '0';
|
|
2828
|
+
lines.push(` ${k.padEnd(12)} ${pm.turns} turns · ${money(pm.actualUsd)} · ${hr}% cached`);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
if (result.leaks && result.leaks.length) {
|
|
2833
|
+
const wasted = result.leaks.reduce((s, l) => s + l.wastedUsd, 0);
|
|
2834
|
+
lines.push(`\n ⚠ cache leaks (${result.leaks.length}, ~${money(wasted)} wasted re-ingesting context):`);
|
|
2835
|
+
const byCause = {};
|
|
2836
|
+
for (const l of result.leaks) {
|
|
2837
|
+
(byCause[l.cause] || (byCause[l.cause] = { n: 0, usd: 0 })).n++;
|
|
2838
|
+
byCause[l.cause].usd += l.wastedUsd;
|
|
2839
|
+
}
|
|
2840
|
+
for (const c of Object.keys(byCause).sort()) {
|
|
2841
|
+
lines.push(` · ${byCause[c].n}× ${CAUSE_LABEL[c] || c} — ~${money(byCause[c].usd)}`);
|
|
2842
|
+
}
|
|
2843
|
+
lines.push(' Keep one model + a stable system prompt within a working window to avoid these.');
|
|
2844
|
+
} else {
|
|
2845
|
+
lines.push('\n ✓ no cache leaks detected — your context stayed warm.');
|
|
2846
|
+
}
|
|
2847
|
+
lines.push('');
|
|
2848
|
+
return lines.join('\n');
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// Test surface (underscore export is exempt from the dead-module gate).
|
|
2852
|
+
const _internal = {
|
|
2853
|
+
MODEL_RATES, CACHE_READ_MULT, CACHE_WRITE_MULT, CACHE_WRITE_1H_MULT,
|
|
2854
|
+
rateFor, locateTranscript, parseTranscriptUsage, computeCacheEconomics, detectInvalidators,
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2857
|
+
|
|
2858
|
+
/***/ }),
|
|
2859
|
+
|
|
2860
|
+
/***/ 8218:
|
|
2861
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2862
|
+
|
|
2863
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2864
|
+
/* harmony export */ loadFreshLineageGraph: () => (/* binding */ loadFreshLineageGraph),
|
|
2865
|
+
/* harmony export */ loadSignedGraph: () => (/* binding */ loadSignedGraph)
|
|
2866
|
+
/* harmony export */ });
|
|
2867
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
|
|
2868
|
+
/* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1174);
|
|
2869
|
+
/* harmony import */ var _posture_integrity_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(1130);
|
|
2870
|
+
// graph-loader.js — Milestone 3, sub-project Server, increment 1.
|
|
2871
|
+
//
|
|
2872
|
+
// Reads and VERIFIES the `.agentic-security/lineage-graph.json` artifact
|
|
2873
|
+
// before `explore` is allowed to serve a single byte of it. Reuses
|
|
2874
|
+
// `posture/integrity.js`'s `verifyLastScan` DIRECTLY (per the plan and the
|
|
2875
|
+
// root CLAUDE.md's own instruction) — this module does not implement any
|
|
2876
|
+
// signature comparison of its own. `verifyLastScan` already uses
|
|
2877
|
+
// `crypto.timingSafeEqual` internally.
|
|
2878
|
+
//
|
|
2879
|
+
// Loaded ONCE at server startup (see bin/agentic-security.js's cmdExplore)
|
|
2880
|
+
// and held in memory for the life of the process — this is a read-only,
|
|
2881
|
+
// single-scan-snapshot server; a change to the graph on disk mid-session is
|
|
2882
|
+
// out of scope for this increment (threat-model doc's own "P0 is
|
|
2883
|
+
// read-only" framing).
|
|
2884
|
+
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* @param {string} scanRoot
|
|
2891
|
+
* @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
|
|
2892
|
+
*
|
|
2893
|
+
* Four, and only four, distinct failure reasons — each with its own clear
|
|
2894
|
+
* message so an operator knows exactly what to do next:
|
|
2895
|
+
* - 'missing' — no lineage-graph.json at all. Run a scan with
|
|
2896
|
+
* AGENTIC_SECURITY_LINEAGE_DEEP=1 first.
|
|
2897
|
+
* - 'unsigned' — the graph exists but its .sig sibling does not
|
|
2898
|
+
* (verifyLastScan returns null). Refuse to serve an
|
|
2899
|
+
* unverifiable graph.
|
|
2900
|
+
* - 'tampered' — the graph exists and has a .sig, but the signature does
|
|
2901
|
+
* not match the body (verifyLastScan returns false). The
|
|
2902
|
+
* file was modified after signing, or signed under a
|
|
2903
|
+
* different install key.
|
|
2904
|
+
* - 'malformed' — the body passed signature verification but is not
|
|
2905
|
+
* valid JSON. Should not happen from a normal scan; the
|
|
2906
|
+
* file may be corrupted on disk after signing.
|
|
2907
|
+
*/
|
|
2908
|
+
function loadSignedGraph(scanRoot) {
|
|
2909
|
+
const graphPath = (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, 'lineage-graph.json');
|
|
2910
|
+
const sigPath = graphPath + '.sig';
|
|
2911
|
+
|
|
2912
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(graphPath)) {
|
|
2913
|
+
return {
|
|
2914
|
+
ok: false,
|
|
2915
|
+
reason: 'missing',
|
|
2916
|
+
message: `No lineage graph found at ${graphPath}. Run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first (e.g. \`AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan\`), then re-run \`agentic-security explore\`.`,
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
let body;
|
|
2921
|
+
try {
|
|
2922
|
+
body = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(graphPath, 'utf8');
|
|
2923
|
+
} catch (e) {
|
|
2924
|
+
return {
|
|
2925
|
+
ok: false,
|
|
2926
|
+
reason: 'missing',
|
|
2927
|
+
message: `Lineage graph found at ${graphPath} but could not be read: ${e && e.message ? e.message : e}.`,
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
const verified = (0,_posture_integrity_js__WEBPACK_IMPORTED_MODULE_2__/* .verifyLastScan */ .Ef)(body, sigPath);
|
|
2932
|
+
if (verified === null) {
|
|
2933
|
+
return {
|
|
2934
|
+
ok: false,
|
|
2935
|
+
reason: 'unsigned',
|
|
2936
|
+
message: `Lineage graph at ${graphPath} has no signature file (${sigPath} is missing). Refusing to serve an unverifiable graph. Re-run the scan (AGENTIC_SECURITY_LINEAGE_DEEP=1) to regenerate both files together.`,
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
if (verified === false) {
|
|
2940
|
+
return {
|
|
2941
|
+
ok: false,
|
|
2942
|
+
reason: 'tampered',
|
|
2943
|
+
message: `Lineage graph at ${graphPath} FAILED signature verification — its contents do not match ${sigPath}. The file may have been modified after the scan, or signed under a different install key. Refusing to serve a tampered graph. Re-run the scan to regenerate it.`,
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
let graph;
|
|
2948
|
+
try {
|
|
2949
|
+
graph = JSON.parse(body);
|
|
2950
|
+
} catch (e) {
|
|
2951
|
+
return {
|
|
2952
|
+
ok: false,
|
|
2953
|
+
reason: 'malformed',
|
|
2954
|
+
message: `Lineage graph at ${graphPath} passed signature verification but is not valid JSON (${e && e.message ? e.message : e}). This should not happen from a normal scan — the file may be corrupted. Re-run the scan to regenerate it.`,
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
return { ok: true, graph };
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
/**
|
|
2962
|
+
* Load .agentic-security/lineage-graph.json ONLY when it is genuinely
|
|
2963
|
+
* fresh for THIS scan — never merely because a file happens to exist on
|
|
2964
|
+
* disk. Shared by every caller that signs or narrates a graph:-derived
|
|
2965
|
+
* compliance claim (M4 sub-project 6c's final whole-branch review found
|
|
2966
|
+
* the identical staleness gap independently reachable from
|
|
2967
|
+
* `attest --obligations` AND `compliance --walkthrough`, and required
|
|
2968
|
+
* this predicate to live in exactly one place rather than being
|
|
2969
|
+
* copy-pasted per caller — a safety check that drifts between two
|
|
2970
|
+
* near-identical inline copies is worse than one shared bug).
|
|
2971
|
+
*
|
|
2972
|
+
* `.agentic-security/lineage-graph.json` is only rewritten when a scan
|
|
2973
|
+
* actually finishes building a graph (`if (scan.lineageGraph)` in
|
|
2974
|
+
* bin/agentic-security.js's persistence code) — an ordinary non-deep
|
|
2975
|
+
* rescan, or a deep scan whose lineage build fails, leaves whatever file
|
|
2976
|
+
* was there from an earlier successful deep scan untouched. Loading that
|
|
2977
|
+
* stale graph and joining it to the CURRENT scan's other data would let a
|
|
2978
|
+
* caller assert a graph-derived fact (e.g. "transit protected") about
|
|
2979
|
+
* code that has since changed.
|
|
2980
|
+
*
|
|
2981
|
+
* `enabled: true` in `scan.scanHealth.lineageAnalysis` does NOT by itself
|
|
2982
|
+
* mean the build succeeded — engine.js sets it the moment
|
|
2983
|
+
* AGENTIC_SECURITY_LINEAGE_DEEP=1 is read, before the build even starts,
|
|
2984
|
+
* and leaves it `true` even when the build later throws (only `failure`
|
|
2985
|
+
* gets set in that case). `requested && enabled` alone therefore still
|
|
2986
|
+
* accepts a stale graph after a failed rebuild — reproduced live via the
|
|
2987
|
+
* scan's own already-shipped fault-injection fixture
|
|
2988
|
+
* (test/lineage-fault-injection.test.js) before this `failure === null`
|
|
2989
|
+
* check was added.
|
|
2990
|
+
*
|
|
2991
|
+
* @param {string} scanRoot
|
|
2992
|
+
* @param {object} scan - the parsed last-scan.json for the CURRENT scan
|
|
2993
|
+
* @returns {{graph:object|null, fresh:boolean, loaded:ReturnType<typeof loadSignedGraph>}}
|
|
2994
|
+
* `fresh` is true only when a signed graph loaded successfully AND this
|
|
2995
|
+
* scan's own scanHealth confirms lineage analysis was requested,
|
|
2996
|
+
* enabled, and did not fail. `graph` is `loaded.graph` when fresh, else
|
|
2997
|
+
* `null` — never the stale file, even when one exists on disk.
|
|
2998
|
+
* `loaded` is the raw `loadSignedGraph` result, so a caller can still
|
|
2999
|
+
* distinguish "no file at all" from "a file exists but isn't fresh" for
|
|
3000
|
+
* its own disclosure message.
|
|
3001
|
+
*/
|
|
3002
|
+
function loadFreshLineageGraph(scanRoot, scan) {
|
|
3003
|
+
const la = scan?.scanHealth?.lineageAnalysis;
|
|
3004
|
+
const requested = la?.requested === true;
|
|
3005
|
+
const enabled = la?.enabled === true;
|
|
3006
|
+
const failure = la?.failure ?? null;
|
|
3007
|
+
const loaded = loadSignedGraph(scanRoot);
|
|
3008
|
+
const fresh = loaded.ok && requested && enabled && failure === null;
|
|
3009
|
+
return { graph: fresh ? loaded.graph : null, fresh, loaded };
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
|
|
3013
|
+
/***/ }),
|
|
3014
|
+
|
|
3015
|
+
/***/ 4268:
|
|
3016
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
3017
|
+
|
|
3018
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
3019
|
+
/* harmony export */ Yo: () => (/* binding */ handleScan),
|
|
3020
|
+
/* harmony export */ Yu: () => (/* binding */ handleEdge),
|
|
3021
|
+
/* harmony export */ d5: () => (/* binding */ handleNode),
|
|
3022
|
+
/* harmony export */ fn: () => (/* binding */ handleGraph),
|
|
3023
|
+
/* harmony export */ jg: () => (/* binding */ handleFlow),
|
|
3024
|
+
/* harmony export */ rR: () => (/* binding */ handleQuery)
|
|
3025
|
+
/* harmony export */ });
|
|
3026
|
+
/* unused harmony export wrapResponse */
|
|
3027
|
+
/* harmony import */ var _lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(859);
|
|
3028
|
+
// routes.js — Milestone 3, sub-project Server, increment 1.
|
|
3029
|
+
//
|
|
3030
|
+
// Five pure GET-endpoint handlers, each `(graph, ...) -> {status, body}`.
|
|
3031
|
+
// No req/res access anywhere in this file — that is what makes these
|
|
3032
|
+
// handlers unit-testable without an HTTP layer at all. http-server.js is
|
|
3033
|
+
// the only module that touches node:http and calls into these.
|
|
3034
|
+
//
|
|
3035
|
+
// Every response body is wrapped in `wrapResponse`, which adds the exact
|
|
3036
|
+
// envelope fields PRD line 1326 names (quoted in the implementation plan):
|
|
3037
|
+
// "base graph/snapshot digest, schema/extension versions, scope, coverage,
|
|
3038
|
+
// limitations, and contributing canonical IDs."
|
|
3039
|
+
|
|
3040
|
+
|
|
3041
|
+
|
|
3042
|
+
/**
|
|
3043
|
+
* Shared response envelope. Maps PRD line 1326's required fields onto the
|
|
3044
|
+
* graph's own real fields:
|
|
3045
|
+
* - digest -> graph.graphId (the base graph/snapshot digest)
|
|
3046
|
+
* - schemaVersion -> graph.schemaVersion
|
|
3047
|
+
* - extensions -> graph.extensions (schema/extension versions —
|
|
3048
|
+
* today always `{}`; see schema.js)
|
|
3049
|
+
* - scope -> graph.scope
|
|
3050
|
+
* - coverage -> graph.coverage
|
|
3051
|
+
* - limitations -> graph.limitations
|
|
3052
|
+
* - canonicalIds -> see the design note below
|
|
3053
|
+
*
|
|
3054
|
+
* "contributing canonical IDs" design decision (disclosed per the plan):
|
|
3055
|
+
* for `handleScan`/`handleGraph`, which describe the WHOLE graph rather
|
|
3056
|
+
* than one entity, `canonicalIds` is `null` — the response body for
|
|
3057
|
+
* `handleGraph` already IS the full nodes/edges/flows arrays, so echoing
|
|
3058
|
+
* every id again here would be pure duplication with no informational
|
|
3059
|
+
* gain, and for a large graph would materially bloat the response for
|
|
3060
|
+
* zero benefit. For `handleNode`/`handleEdge`, `canonicalIds` is the
|
|
3061
|
+
* single id the response is about. For `handleFlow`, `canonicalIds` is
|
|
3062
|
+
* the flow's own id PLUS the node/edge ids that flow's evidence draws
|
|
3063
|
+
* from (source, sink, edgeIds) — a flow is a derived record referencing
|
|
3064
|
+
* several underlying entities, and naming all of them here is genuinely
|
|
3065
|
+
* useful metadata a client would otherwise have to re-derive from the
|
|
3066
|
+
* flow body itself.
|
|
3067
|
+
*/
|
|
3068
|
+
function wrapResponse(data, graph, { canonicalIds = null } = {}) {
|
|
3069
|
+
return {
|
|
3070
|
+
digest: graph?.graphId ?? null,
|
|
3071
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
3072
|
+
extensions: graph?.extensions ?? {},
|
|
3073
|
+
scope: graph?.scope ?? null,
|
|
3074
|
+
coverage: graph?.coverage ?? null,
|
|
3075
|
+
limitations: graph?.limitations ?? [],
|
|
3076
|
+
canonicalIds,
|
|
3077
|
+
data,
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
function _findById(list, id) {
|
|
3082
|
+
if (!Array.isArray(list)) return null;
|
|
3083
|
+
return list.find((item) => item && item.id === id) ?? null;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/** Scan/graph metadata — NOT the full node/edge arrays. */
|
|
3087
|
+
function handleScan(graph) {
|
|
3088
|
+
const data = {
|
|
3089
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
3090
|
+
graphId: graph?.graphId ?? null,
|
|
3091
|
+
generatedAt: graph?.generatedAt ?? null,
|
|
3092
|
+
scope: graph?.scope ?? null,
|
|
3093
|
+
scanHealth: graph?.scanHealth ?? null,
|
|
3094
|
+
coverage: graph?.coverage ?? null,
|
|
3095
|
+
};
|
|
3096
|
+
return { status: 200, body: wrapResponse(data, graph, { canonicalIds: null }) };
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
/** The full graph document, unfiltered. For a scoped/narrowed projection, use `handleQuery` (`POST /api/v1/query`, Milestone 5) below instead. */
|
|
3100
|
+
function handleGraph(graph) {
|
|
3101
|
+
return { status: 200, body: wrapResponse(graph, graph, { canonicalIds: null }) };
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* A deterministic typed projection query — Milestone 5's own
|
|
3106
|
+
* `POST /api/v1/query`, the S2 endpoint `handleGraph`'s own header
|
|
3107
|
+
* comment named and deferred. `filter` is the exact `{nodeIds, edgeIds}`
|
|
3108
|
+
* shape `dataflow export --filter`/`exportGraphJSON` already use — reused
|
|
3109
|
+
* via `_filterGraph`, never reimplemented. Final whole-branch review
|
|
3110
|
+
* finding: `undefined` (filter omitted entirely) returns the WHOLE graph,
|
|
3111
|
+
* identical to `handleGraph` — but `{}` (an empty, well-formed filter
|
|
3112
|
+
* object) is NOT the same thing, and does NOT mean "no restriction": both
|
|
3113
|
+
* `nodeIds`/`edgeIds` default to empty Sets inside `_filterGraph`, so `{}`
|
|
3114
|
+
* narrows the graph down to EMPTY node/edge/flow/dataElement arrays. A
|
|
3115
|
+
* caller that wants the whole graph must omit `filter` entirely, never
|
|
3116
|
+
* pass `{}` meaning "everything." A malformed filter is a 400, never a
|
|
3117
|
+
* thrown exception reaching the caller.
|
|
3118
|
+
*/
|
|
3119
|
+
function handleQuery(graph, filter) {
|
|
3120
|
+
const check = (0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__.validateFilterShape)(filter);
|
|
3121
|
+
if (!check.valid) {
|
|
3122
|
+
return { status: 400, body: { error: check.error } };
|
|
3123
|
+
}
|
|
3124
|
+
return { status: 200, body: wrapResponse((0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__/* ._filterGraph */ .e)(graph, filter), graph, { canonicalIds: null }) };
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
/** Look up one node by id. 404 with a clear body if not found. */
|
|
3128
|
+
function handleNode(graph, id) {
|
|
3129
|
+
const node = _findById(graph?.nodes, id);
|
|
3130
|
+
if (!node) {
|
|
3131
|
+
return { status: 404, body: wrapResponse({ error: `node not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
3132
|
+
}
|
|
3133
|
+
return { status: 200, body: wrapResponse(node, graph, { canonicalIds: [id] }) };
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
/** Look up one edge by id. 404 with a clear body if not found. */
|
|
3137
|
+
function handleEdge(graph, id) {
|
|
3138
|
+
const edge = _findById(graph?.edges, id);
|
|
3139
|
+
if (!edge) {
|
|
3140
|
+
return { status: 404, body: wrapResponse({ error: `edge not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
3141
|
+
}
|
|
3142
|
+
return { status: 200, body: wrapResponse(edge, graph, { canonicalIds: [id] }) };
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/** Look up one flow by id. 404 with a clear body if not found. */
|
|
3146
|
+
function handleFlow(graph, id) {
|
|
3147
|
+
const flow = _findById(graph?.flows, id);
|
|
3148
|
+
if (!flow) {
|
|
3149
|
+
return { status: 404, body: wrapResponse({ error: `flow not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
3150
|
+
}
|
|
3151
|
+
const contributing = new Set([id]);
|
|
3152
|
+
if (flow.source) contributing.add(flow.source);
|
|
3153
|
+
if (flow.sink) contributing.add(flow.sink);
|
|
3154
|
+
for (const eid of (flow.edgeIds || [])) contributing.add(eid);
|
|
3155
|
+
return { status: 200, body: wrapResponse(flow, graph, { canonicalIds: [...contributing] }) };
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
|
|
3159
|
+
/***/ })
|
|
3160
|
+
|
|
3161
|
+
};
|