@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5
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 +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +379 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +841 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +144 -0
- package/src/shared/frontend-root.js +52 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -1,8 +1,166 @@
|
|
|
1
|
-
export const id =
|
|
2
|
-
export const ids = [
|
|
1
|
+
export const id = 2376;
|
|
2
|
+
export const ids = [2376,8218,9390,8752];
|
|
3
3
|
export const modules = {
|
|
4
4
|
|
|
5
|
-
/***/
|
|
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
|
+
/***/ 2376:
|
|
6
164
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
165
|
|
|
8
166
|
|
|
@@ -23,87 +181,261 @@ var external_node_url_ = __webpack_require__(3136);
|
|
|
23
181
|
var promises_ = __webpack_require__(1455);
|
|
24
182
|
// EXTERNAL MODULE: ./src/posture/fix-history.js
|
|
25
183
|
var fix_history = __webpack_require__(4407);
|
|
26
|
-
// EXTERNAL MODULE: ./src/
|
|
27
|
-
var
|
|
28
|
-
// EXTERNAL MODULE: ./src/posture/
|
|
29
|
-
var
|
|
30
|
-
|
|
31
|
-
|
|
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).
|
|
32
196
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// — exposing the secret to model logs, transcripts, and any downstream tool
|
|
38
|
-
// the agent passes them to.
|
|
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.
|
|
39
201
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
},
|
|
68
243
|
];
|
|
69
244
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (typeof s !== 'string') return s;
|
|
79
|
-
let out = s;
|
|
80
|
-
if (out.length > INPUT_MAX) out = out.slice(0, INPUT_MAX) + `…(+${out.length - INPUT_MAX})`;
|
|
81
|
-
for (const [re, kind] of PATTERNS) {
|
|
82
|
-
out = out.replace(re, `[REDACTED:${kind}]`);
|
|
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 */ }
|
|
83
253
|
}
|
|
84
|
-
|
|
85
|
-
return out;
|
|
254
|
+
return null;
|
|
86
255
|
}
|
|
87
256
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
|
|
101
291
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
};
|
|
105
307
|
}
|
|
106
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
|
+
|
|
107
439
|
;// CONCATENATED MODULE: ./src/posture/agents-memory.js
|
|
108
440
|
// AGENTS.md — writable continual-learning memory (harness-anatomy #2).
|
|
109
441
|
//
|
|
@@ -136,6 +468,7 @@ function redactArgsBlob(s) {
|
|
|
136
468
|
|
|
137
469
|
|
|
138
470
|
|
|
471
|
+
|
|
139
472
|
const MEMORY_FILE = '.agentic-security/AGENTS.md';
|
|
140
473
|
const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
|
|
141
474
|
const MAX_BYTES = 20 * 1024;
|
|
@@ -143,8 +476,8 @@ const MAX_ENTRY_BYTES = 2 * 1024;
|
|
|
143
476
|
const ARCHIVE_MAX_BYTES = 200 * 1024;
|
|
144
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';
|
|
145
478
|
|
|
146
|
-
function _resolve(scanRoot) { return
|
|
147
|
-
function _archivePath(scanRoot) { return
|
|
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'); }
|
|
148
481
|
|
|
149
482
|
function readAgentsMemory(scanRoot) {
|
|
150
483
|
const fp = _resolve(scanRoot);
|
|
@@ -172,7 +505,8 @@ function appendAgentsMemory(scanRoot, { agent, body }) {
|
|
|
172
505
|
const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
|
|
173
506
|
try {
|
|
174
507
|
const fp = _resolve(scanRoot);
|
|
175
|
-
|
|
508
|
+
if (!(0,state_dir.stateWritesEnabled)()) return;
|
|
509
|
+
external_node_fs_.mkdirSync(external_node_path_.dirname(fp), { recursive: true });
|
|
176
510
|
if (!external_node_fs_.existsSync(fp)) external_node_fs_.writeFileSync(fp, HEADER);
|
|
177
511
|
external_node_fs_.appendFileSync(fp, entry);
|
|
178
512
|
_maybeRotate(scanRoot);
|
|
@@ -398,25 +732,36 @@ const cve_lookup_internals = { CACHE_DIR, CVE_RE, _stalenessTier };
|
|
|
398
732
|
|
|
399
733
|
|
|
400
734
|
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
403
748
|
// (no node_modules). Deferring keeps the MCP server bootable everywhere;
|
|
404
749
|
// the import only runs when a tool that needs them is actually called.
|
|
405
750
|
let _runScan;
|
|
406
751
|
async function getRunScan() {
|
|
407
|
-
if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__,
|
|
752
|
+
if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 454))).runScan;
|
|
408
753
|
return _runScan;
|
|
409
754
|
}
|
|
410
755
|
let _verifyFixCore;
|
|
411
756
|
async function getVerifyFixCore() {
|
|
412
|
-
if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */
|
|
757
|
+
if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */ 3526).then(__webpack_require__.bind(__webpack_require__, 3526))).verifyFix;
|
|
413
758
|
return _verifyFixCore;
|
|
414
759
|
}
|
|
415
760
|
|
|
416
761
|
const MAX_FILES_PER_SCAN = 1024;
|
|
417
762
|
const MAX_FILE_BYTES = 500_000;
|
|
418
763
|
const MAX_TOTAL_SCAN_BYTES = 50_000_000;
|
|
419
|
-
const
|
|
764
|
+
const tools_META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
|
|
420
765
|
|
|
421
766
|
// OWASP A01 — refuse writes to paths that could subvert the security tool
|
|
422
767
|
// itself or the host's source-control / dependency state. A forged finding
|
|
@@ -471,7 +816,29 @@ const RESERVED_WRITE_SUFFIXES = [
|
|
|
471
816
|
'.tfvars',
|
|
472
817
|
'docker-compose.yml',
|
|
473
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',
|
|
474
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']);
|
|
475
842
|
function _isReservedWritePath(sessionRoot, absFile) {
|
|
476
843
|
// Resolve sessionRoot symlinks so the relative path is computed against
|
|
477
844
|
// the same canonical root as `absFile` (which _confine already realpath'd).
|
|
@@ -480,9 +847,15 @@ function _isReservedWritePath(sessionRoot, absFile) {
|
|
|
480
847
|
const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
|
|
481
848
|
const rel = external_node_path_.relative(rootReal, absFile).replace(/\\/g, '/');
|
|
482
849
|
if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
|
|
483
|
-
const
|
|
850
|
+
const segments = rel.split('/');
|
|
851
|
+
const base = segments[segments.length - 1] || '';
|
|
484
852
|
if (RESERVED_WRITE_BASENAMES.has(base)) return true;
|
|
485
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;
|
|
486
859
|
return false;
|
|
487
860
|
}
|
|
488
861
|
|
|
@@ -528,12 +901,17 @@ function _validateScratchpadPath(relPath) {
|
|
|
528
901
|
return { ok: true, agent, session, fileParts };
|
|
529
902
|
}
|
|
530
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).
|
|
531
909
|
function _scratchpadAbs(sessionRoot, relPath) {
|
|
532
|
-
return
|
|
910
|
+
return _confine(sessionRoot, relPath.replace(/\\/g, '/'), 'scratchpad path');
|
|
533
911
|
}
|
|
534
912
|
|
|
535
913
|
function _scratchpadTotalBytes(sessionRoot) {
|
|
536
|
-
const base =
|
|
914
|
+
const base = (0,state_dir.statePath)(sessionRoot, 'agent-scratchpad');
|
|
537
915
|
if (!external_node_fs_.existsSync(base)) return 0;
|
|
538
916
|
let total = 0;
|
|
539
917
|
const walk = (dir) => {
|
|
@@ -598,8 +976,8 @@ function _confine(sessionRoot, candidate, label) {
|
|
|
598
976
|
}
|
|
599
977
|
|
|
600
978
|
function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
|
|
601
|
-
const
|
|
602
|
-
const scanFile = external_node_path_.join(
|
|
979
|
+
const stateDirPath = (0,state_dir/* stateDir */.Pn)(sessionRoot);
|
|
980
|
+
const scanFile = external_node_path_.join(stateDirPath, 'last-scan.json');
|
|
603
981
|
const sigFile = scanFile + '.sig';
|
|
604
982
|
if (!external_node_fs_.existsSync(scanFile)) return { scan: null, status: 'missing' };
|
|
605
983
|
const body = external_node_fs_.readFileSync(scanFile, 'utf8');
|
|
@@ -672,6 +1050,18 @@ function _maybeOffload(sessionRoot, toolName, items) {
|
|
|
672
1050
|
}
|
|
673
1051
|
|
|
674
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
|
+
|
|
675
1065
|
const scan_diff = {
|
|
676
1066
|
name: 'scan_diff',
|
|
677
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.',
|
|
@@ -708,16 +1098,52 @@ const scan_diff = {
|
|
|
708
1098
|
fileContents[rel] = content;
|
|
709
1099
|
}
|
|
710
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).
|
|
711
1109
|
const runScan = await getRunScan();
|
|
712
|
-
|
|
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 }));
|
|
713
1119
|
const wantSet = new Set(Object.keys(fileContents));
|
|
714
1120
|
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
715
1121
|
const min = sevRank[severity] ?? 0;
|
|
716
|
-
|
|
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)
|
|
717
1143
|
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
718
|
-
.map(f => redactFinding({
|
|
1144
|
+
.map(f => (0,redact/* redactFinding */.lE)({
|
|
719
1145
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
720
|
-
title: f.
|
|
1146
|
+
title: f.vuln, cwe: f.cwe,
|
|
721
1147
|
description: f.description, remediation: f.remediation,
|
|
722
1148
|
}));
|
|
723
1149
|
// Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
|
|
@@ -727,7 +1153,7 @@ const scan_diff = {
|
|
|
727
1153
|
const off = _maybeOffload(sessionRoot, 'scan_diff', findings);
|
|
728
1154
|
if (off.offloaded) {
|
|
729
1155
|
return {
|
|
730
|
-
_meta:
|
|
1156
|
+
_meta: tools_META,
|
|
731
1157
|
scannedFiles: Object.keys(fileContents).length,
|
|
732
1158
|
findingCount: off.total,
|
|
733
1159
|
offloaded: true,
|
|
@@ -737,7 +1163,7 @@ const scan_diff = {
|
|
|
737
1163
|
};
|
|
738
1164
|
}
|
|
739
1165
|
return {
|
|
740
|
-
_meta:
|
|
1166
|
+
_meta: tools_META,
|
|
741
1167
|
scannedFiles: Object.keys(fileContents).length,
|
|
742
1168
|
findingCount: findings.length,
|
|
743
1169
|
findings,
|
|
@@ -763,7 +1189,7 @@ const query_taint = {
|
|
|
763
1189
|
async handler({ source, sink, limit, offset }, ctx) {
|
|
764
1190
|
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
|
|
765
1191
|
if (!scan) {
|
|
766
|
-
return { _meta:
|
|
1192
|
+
return { _meta: tools_META, hasResult: false, status, message: `No usable scan state (${status}).` };
|
|
767
1193
|
}
|
|
768
1194
|
const lim = Number.isInteger(limit) ? Math.min(50, Math.max(1, limit)) : 10;
|
|
769
1195
|
const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
|
|
@@ -778,13 +1204,13 @@ const query_taint = {
|
|
|
778
1204
|
const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
|
|
779
1205
|
return hay.includes(srcL) && hay.includes(sinkL);
|
|
780
1206
|
});
|
|
781
|
-
const page = all.slice(off, off + lim).map(f => redactFinding({
|
|
1207
|
+
const page = all.slice(off, off + lim).map(f => (0,redact/* redactFinding */.lE)({
|
|
782
1208
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
783
1209
|
title: f.title || f.vuln, description: f.description,
|
|
784
1210
|
trace: f.trace || null,
|
|
785
1211
|
}));
|
|
786
1212
|
return {
|
|
787
|
-
_meta:
|
|
1213
|
+
_meta: tools_META,
|
|
788
1214
|
hasResult: true,
|
|
789
1215
|
integrity: status,
|
|
790
1216
|
scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
|
|
@@ -816,7 +1242,7 @@ const explain_finding = {
|
|
|
816
1242
|
if (!scan) throw new Error(`No usable scan state (${status}).`);
|
|
817
1243
|
const f = _findById(scan, finding_id);
|
|
818
1244
|
if (!f) throw new Error(`Finding not found: ${finding_id}`);
|
|
819
|
-
const redacted = redactFinding({
|
|
1245
|
+
const redacted = (0,redact/* redactFinding */.lE)({
|
|
820
1246
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
821
1247
|
title: f.title || f.vuln, cwe: f.cwe,
|
|
822
1248
|
description: f.description, remediation: f.remediation,
|
|
@@ -841,7 +1267,7 @@ const explain_finding = {
|
|
|
841
1267
|
}
|
|
842
1268
|
}
|
|
843
1269
|
return {
|
|
844
|
-
_meta:
|
|
1270
|
+
_meta: tools_META,
|
|
845
1271
|
...redacted,
|
|
846
1272
|
trace: traceTrimmed,
|
|
847
1273
|
traceOffload: traceMeta,
|
|
@@ -861,6 +1287,21 @@ const explain_finding = {
|
|
|
861
1287
|
epssScore: typeof f.epssScore === 'number' ? f.epssScore : null,
|
|
862
1288
|
epssPercentile: typeof f.epssPercentile === 'number' ? f.epssPercentile : null,
|
|
863
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,
|
|
864
1305
|
};
|
|
865
1306
|
},
|
|
866
1307
|
};
|
|
@@ -868,7 +1309,7 @@ const explain_finding = {
|
|
|
868
1309
|
// ─── apply_fix ───────────────────────────────────────────────────────────────
|
|
869
1310
|
const apply_fix = {
|
|
870
1311
|
name: 'apply_fix',
|
|
871
|
-
description: 'Apply the stored replacement
|
|
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.',
|
|
872
1313
|
inputSchema: {
|
|
873
1314
|
type: 'object',
|
|
874
1315
|
additionalProperties: false,
|
|
@@ -876,27 +1317,222 @@ const apply_fix = {
|
|
|
876
1317
|
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
877
1318
|
confirm: { type: 'boolean' },
|
|
878
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
|
+
},
|
|
879
1376
|
},
|
|
880
1377
|
required: ['finding_id', 'confirm'],
|
|
881
1378
|
},
|
|
882
|
-
async handler({ finding_id, confirm, dry_run = false }, ctx) {
|
|
1379
|
+
async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
|
|
883
1380
|
if (confirm !== true) {
|
|
884
|
-
return { _meta:
|
|
1381
|
+
return { _meta: tools_META, applied: false, reason: 'apply_fix requires confirm: true.' };
|
|
885
1382
|
}
|
|
886
1383
|
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
887
1384
|
if (!scan) {
|
|
888
|
-
return { _meta:
|
|
1385
|
+
return { _meta: tools_META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
|
|
889
1386
|
}
|
|
890
1387
|
const f = _findById(scan, finding_id);
|
|
891
|
-
if (!f) return { _meta:
|
|
1388
|
+
if (!f) return { _meta: tools_META, applied: false, reason: `Finding not found: ${finding_id}` };
|
|
892
1389
|
if (f._shadow === true) {
|
|
893
|
-
return { _meta:
|
|
1390
|
+
return { _meta: tools_META, applied: false, reason: 'shadow findings cannot be auto-applied' };
|
|
894
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
|
+
|
|
895
1531
|
if (typeof f.fix?.replacement !== 'string') {
|
|
896
1532
|
// Premortem #2: templates are patch-shaped text. Same reasoning as
|
|
897
1533
|
// the replacement path — do NOT pass through redactString here.
|
|
898
1534
|
return {
|
|
899
|
-
_meta:
|
|
1535
|
+
_meta: tools_META, applied: false,
|
|
900
1536
|
reason: 'No full replacement available — only a template. Apply the template manually.',
|
|
901
1537
|
template: f.fix?.code || '',
|
|
902
1538
|
file: f.file, line: f.line,
|
|
@@ -905,19 +1541,19 @@ const apply_fix = {
|
|
|
905
1541
|
let absFile;
|
|
906
1542
|
try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
|
|
907
1543
|
catch (e) {
|
|
908
|
-
return { _meta:
|
|
1544
|
+
return { _meta: tools_META, applied: false, reason: `path-escape refused: ${e.message}` };
|
|
909
1545
|
}
|
|
910
1546
|
if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
|
|
911
|
-
return { _meta:
|
|
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` };
|
|
912
1548
|
}
|
|
913
1549
|
if (!external_node_fs_.existsSync(absFile)) {
|
|
914
|
-
return { _meta:
|
|
1550
|
+
return { _meta: tools_META, applied: false, reason: `File not found: ${absFile}` };
|
|
915
1551
|
}
|
|
916
1552
|
const originalContent = await promises_.readFile(absFile, 'utf8');
|
|
917
1553
|
|
|
918
1554
|
if (dry_run) {
|
|
919
1555
|
return {
|
|
920
|
-
_meta:
|
|
1556
|
+
_meta: tools_META,
|
|
921
1557
|
applied: false, dryRun: true,
|
|
922
1558
|
file: f.file,
|
|
923
1559
|
originalSize: originalContent.length,
|
|
@@ -926,41 +1562,47 @@ const apply_fix = {
|
|
|
926
1562
|
};
|
|
927
1563
|
}
|
|
928
1564
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
maxAttempts: e.max,
|
|
954
|
-
key: e.key,
|
|
955
|
-
};
|
|
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 };
|
|
956
1589
|
}
|
|
957
|
-
|
|
1590
|
+
return { _meta: tools_META, applied: false, reason: result.reason, verify: result.verify || null };
|
|
958
1591
|
}
|
|
959
1592
|
// R25 (PRD §5): surface the running auto-fix acceptance rate after each
|
|
960
1593
|
// applied fix, so the closed loop reports its own success metric.
|
|
961
1594
|
let acceptance = null;
|
|
962
1595
|
try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* metric is best-effort */ }
|
|
963
|
-
|
|
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
|
+
};
|
|
964
1606
|
},
|
|
965
1607
|
};
|
|
966
1608
|
|
|
@@ -972,7 +1614,7 @@ const apply_fix = {
|
|
|
972
1614
|
// proceed with apply_fix.
|
|
973
1615
|
const verify_fix = {
|
|
974
1616
|
name: 'verify_fix',
|
|
975
|
-
description: 'Verify a proposed patch before applying. Re-scans the patched files in memory
|
|
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.',
|
|
976
1618
|
inputSchema: {
|
|
977
1619
|
type: 'object',
|
|
978
1620
|
additionalProperties: false,
|
|
@@ -984,36 +1626,98 @@ const verify_fix = {
|
|
|
984
1626
|
minProperties: 1,
|
|
985
1627
|
maxProperties: 8,
|
|
986
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
|
+
},
|
|
987
1660
|
},
|
|
988
1661
|
required: ['stable_id', 'files'],
|
|
989
1662
|
},
|
|
990
|
-
async handler({ stable_id, files }, ctx) {
|
|
1663
|
+
async handler({ stable_id, files, fixMeta }, ctx) {
|
|
991
1664
|
// Confine every file path before passing to the verifier.
|
|
992
1665
|
const confined = {};
|
|
993
1666
|
for (const [relPath, content] of Object.entries(files || {})) {
|
|
994
1667
|
try {
|
|
995
1668
|
_confine(ctx.sessionRoot, relPath, 'files key');
|
|
996
1669
|
} catch (e) {
|
|
997
|
-
return { _meta:
|
|
1670
|
+
return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` };
|
|
998
1671
|
}
|
|
999
1672
|
confined[relPath] = String(content);
|
|
1000
1673
|
}
|
|
1001
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
|
+
|
|
1002
1693
|
const verifyFixCore = await getVerifyFixCore();
|
|
1003
1694
|
const r = await verifyFixCore({
|
|
1004
1695
|
scanRoot: ctx.sessionRoot,
|
|
1005
1696
|
originalFindingStableId: stable_id,
|
|
1006
1697
|
files: confined,
|
|
1698
|
+
poc,
|
|
1699
|
+
fixMeta,
|
|
1007
1700
|
});
|
|
1008
1701
|
return {
|
|
1009
|
-
_meta:
|
|
1702
|
+
_meta: tools_META,
|
|
1010
1703
|
ok: r.ok,
|
|
1011
1704
|
rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
|
|
1012
|
-
lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
|
|
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,
|
|
1013
1717
|
summary: r.summary,
|
|
1014
1718
|
};
|
|
1015
1719
|
} catch (e) {
|
|
1016
|
-
return { _meta:
|
|
1720
|
+
return { _meta: tools_META, ok: false, reason: `verify_fix failed: ${e.message}` };
|
|
1017
1721
|
}
|
|
1018
1722
|
},
|
|
1019
1723
|
};
|
|
@@ -1036,11 +1740,11 @@ const synthesize_fix = {
|
|
|
1036
1740
|
async handler({ finding_id }, ctx) {
|
|
1037
1741
|
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
1038
1742
|
if (!scan) {
|
|
1039
|
-
return { _meta:
|
|
1743
|
+
return { _meta: tools_META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
|
|
1040
1744
|
}
|
|
1041
1745
|
const f = _findById(scan, finding_id);
|
|
1042
|
-
if (!f) return { _meta:
|
|
1043
|
-
if (f._shadow === true) return { _meta:
|
|
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' };
|
|
1044
1748
|
const fix = f.fix || {};
|
|
1045
1749
|
const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
|
|
1046
1750
|
// Patch bounds: count files touched + LoC delta.
|
|
@@ -1055,6 +1759,20 @@ const synthesize_fix = {
|
|
|
1055
1759
|
locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
|
|
1056
1760
|
}
|
|
1057
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
|
+
}
|
|
1058
1776
|
// Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
|
|
1059
1777
|
// not a finding excerpt. Running it through redactString silently corrupts
|
|
1060
1778
|
// valid patches whose content happens to match a secret-shape (e.g. a
|
|
@@ -1062,7 +1780,7 @@ const synthesize_fix = {
|
|
|
1062
1780
|
// verbatim. Snippet/description/etc. continue to be redacted in
|
|
1063
1781
|
// explain_finding / scan_diff — that's the right surface for redaction.
|
|
1064
1782
|
return {
|
|
1065
|
-
_meta:
|
|
1783
|
+
_meta: tools_META,
|
|
1066
1784
|
ok: true,
|
|
1067
1785
|
stable_id: f.stableId || null,
|
|
1068
1786
|
file: f.file, line: f.line,
|
|
@@ -1071,9 +1789,21 @@ const synthesize_fix = {
|
|
|
1071
1789
|
hasReplacement,
|
|
1072
1790
|
replacement: hasReplacement ? fix.replacement : null,
|
|
1073
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,
|
|
1074
1798
|
remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
|
|
1075
1799
|
patchBounds: { touchedFiles, locDelta, oversized },
|
|
1076
|
-
|
|
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,
|
|
1077
1807
|
};
|
|
1078
1808
|
},
|
|
1079
1809
|
};
|
|
@@ -1100,14 +1830,14 @@ const find_rule_module = {
|
|
|
1100
1830
|
},
|
|
1101
1831
|
async handler({ cwe, family }, ctx) {
|
|
1102
1832
|
if (!cwe && !family) {
|
|
1103
|
-
return { _meta:
|
|
1833
|
+
return { _meta: tools_META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
|
|
1104
1834
|
}
|
|
1105
1835
|
// Pattern enforcement — the mini-schema validator doesn't do `pattern`.
|
|
1106
1836
|
if (cwe && !/^CWE-\d+$/.test(cwe)) {
|
|
1107
|
-
return { _meta:
|
|
1837
|
+
return { _meta: tools_META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
|
|
1108
1838
|
}
|
|
1109
1839
|
if (family && !/^[a-z][a-z0-9-]+$/.test(family)) {
|
|
1110
|
-
return { _meta:
|
|
1840
|
+
return { _meta: tools_META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
|
|
1111
1841
|
}
|
|
1112
1842
|
const sessionRoot = ctx.sessionRoot;
|
|
1113
1843
|
const roots = [
|
|
@@ -1161,7 +1891,7 @@ const find_rule_module = {
|
|
|
1161
1891
|
if (hits.length >= 20) break;
|
|
1162
1892
|
}
|
|
1163
1893
|
return {
|
|
1164
|
-
_meta:
|
|
1894
|
+
_meta: tools_META,
|
|
1165
1895
|
ok: true,
|
|
1166
1896
|
query: { cwe: cwe || null, family: family || null },
|
|
1167
1897
|
hitCount: hits.length,
|
|
@@ -1204,12 +1934,14 @@ const append_scratchpad = {
|
|
|
1204
1934
|
},
|
|
1205
1935
|
async handler({ path: relPath, content }, ctx) {
|
|
1206
1936
|
const v = _validateScratchpadPath(relPath);
|
|
1207
|
-
if (!v.ok) return { _meta:
|
|
1208
|
-
|
|
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}` }; }
|
|
1209
1941
|
const total = _scratchpadTotalBytes(ctx.sessionRoot);
|
|
1210
1942
|
if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
|
|
1211
1943
|
return {
|
|
1212
|
-
_meta:
|
|
1944
|
+
_meta: tools_META, ok: false,
|
|
1213
1945
|
reason: `scratchpad-total-exceeded: ${total} + ${content.length} > ${SCRATCHPAD_MAX_TOTAL_BYTES}. Clean up via "rm -rf .agentic-security/agent-scratchpad" or rotate sessions.`,
|
|
1214
1946
|
};
|
|
1215
1947
|
}
|
|
@@ -1217,7 +1949,7 @@ const append_scratchpad = {
|
|
|
1217
1949
|
try { if (external_node_fs_.existsSync(abs)) existing = external_node_fs_.statSync(abs).size; } catch {}
|
|
1218
1950
|
if (existing + content.length > SCRATCHPAD_MAX_FILE_BYTES) {
|
|
1219
1951
|
return {
|
|
1220
|
-
_meta:
|
|
1952
|
+
_meta: tools_META, ok: false,
|
|
1221
1953
|
reason: `scratchpad-file-exceeded: ${existing} + ${content.length} > ${SCRATCHPAD_MAX_FILE_BYTES}. Start a new file.`,
|
|
1222
1954
|
};
|
|
1223
1955
|
}
|
|
@@ -1225,12 +1957,12 @@ const append_scratchpad = {
|
|
|
1225
1957
|
external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
|
|
1226
1958
|
external_node_fs_.appendFileSync(abs, content);
|
|
1227
1959
|
return {
|
|
1228
|
-
_meta:
|
|
1960
|
+
_meta: tools_META, ok: true,
|
|
1229
1961
|
path: relPath, bytesWritten: content.length, fileSize: existing + content.length,
|
|
1230
1962
|
scratchpadTotal: total + content.length,
|
|
1231
1963
|
};
|
|
1232
1964
|
} catch (e) {
|
|
1233
|
-
return { _meta:
|
|
1965
|
+
return { _meta: tools_META, ok: false, reason: `write-failed: ${e.message}` };
|
|
1234
1966
|
}
|
|
1235
1967
|
},
|
|
1236
1968
|
};
|
|
@@ -1250,12 +1982,14 @@ const read_scratchpad = {
|
|
|
1250
1982
|
},
|
|
1251
1983
|
async handler({ path: relPath, offset, limit }, ctx) {
|
|
1252
1984
|
const v = _validateScratchpadPath(relPath);
|
|
1253
|
-
if (!v.ok) return { _meta:
|
|
1254
|
-
|
|
1255
|
-
|
|
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' };
|
|
1256
1990
|
let stat;
|
|
1257
|
-
try { stat = external_node_fs_.statSync(abs); } catch (e) { return { _meta:
|
|
1258
|
-
if (!stat.isFile()) return { _meta:
|
|
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' };
|
|
1259
1993
|
const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
|
|
1260
1994
|
const lim = Number.isInteger(limit) ? Math.min(64 * 1024, Math.max(1, limit)) : 4096;
|
|
1261
1995
|
let buf;
|
|
@@ -1265,10 +1999,10 @@ const read_scratchpad = {
|
|
|
1265
1999
|
const read = external_node_fs_.readSync(fd, tmp, 0, lim, off);
|
|
1266
2000
|
external_node_fs_.closeSync(fd);
|
|
1267
2001
|
buf = tmp.slice(0, read);
|
|
1268
|
-
} catch (e) { return { _meta:
|
|
2002
|
+
} catch (e) { return { _meta: tools_META, ok: false, reason: `read-failed: ${e.message}` }; }
|
|
1269
2003
|
const text = buf.toString('utf8');
|
|
1270
2004
|
return {
|
|
1271
|
-
_meta:
|
|
2005
|
+
_meta: tools_META, ok: true,
|
|
1272
2006
|
path: relPath,
|
|
1273
2007
|
offset: off, limit: lim, bytesRead: buf.length,
|
|
1274
2008
|
totalSize: stat.size,
|
|
@@ -1285,6 +2019,7 @@ const read_scratchpad = {
|
|
|
1285
2019
|
|
|
1286
2020
|
|
|
1287
2021
|
|
|
2022
|
+
|
|
1288
2023
|
const append_agents_memory = {
|
|
1289
2024
|
name: 'append_agents_memory',
|
|
1290
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.',
|
|
@@ -1299,7 +2034,7 @@ const append_agents_memory = {
|
|
|
1299
2034
|
},
|
|
1300
2035
|
async handler({ agent, body }, ctx) {
|
|
1301
2036
|
const r = appendAgentsMemory(ctx.sessionRoot, { agent, body });
|
|
1302
|
-
return { _meta:
|
|
2037
|
+
return { _meta: tools_META, ...r };
|
|
1303
2038
|
},
|
|
1304
2039
|
};
|
|
1305
2040
|
|
|
@@ -1315,16 +2050,16 @@ const read_agents_memory = {
|
|
|
1315
2050
|
},
|
|
1316
2051
|
async handler({ full }, ctx) {
|
|
1317
2052
|
const body = readAgentsMemory(ctx.sessionRoot);
|
|
1318
|
-
if (!body) return { _meta:
|
|
1319
|
-
if (full) return { _meta:
|
|
2053
|
+
if (!body) return { _meta: tools_META, present: false };
|
|
2054
|
+
if (full) return { _meta: tools_META, present: true, length: body.length, content: body };
|
|
1320
2055
|
// Tail-only — same logic as summarizeForSession but inlined to avoid a
|
|
1321
2056
|
// second import surface.
|
|
1322
2057
|
const limit = 6 * 1024;
|
|
1323
|
-
if (body.length <= limit) return { _meta:
|
|
2058
|
+
if (body.length <= limit) return { _meta: tools_META, present: true, length: body.length, content: body };
|
|
1324
2059
|
const tail = body.slice(-limit);
|
|
1325
2060
|
const firstSection = tail.indexOf('\n## ');
|
|
1326
2061
|
const slice = firstSection >= 0 ? tail.slice(firstSection) : tail;
|
|
1327
|
-
return { _meta:
|
|
2062
|
+
return { _meta: tools_META, present: true, length: body.length, truncated: true, content: slice };
|
|
1328
2063
|
},
|
|
1329
2064
|
};
|
|
1330
2065
|
|
|
@@ -1346,9 +2081,22 @@ const query_triage_memory = {
|
|
|
1346
2081
|
},
|
|
1347
2082
|
async handler({ query }, ctx) {
|
|
1348
2083
|
const { queryMemory } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 1905));
|
|
1349
|
-
const
|
|
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; }
|
|
1350
2098
|
return {
|
|
1351
|
-
_meta:
|
|
2099
|
+
_meta: tools_META,
|
|
1352
2100
|
count: results.length,
|
|
1353
2101
|
results,
|
|
1354
2102
|
};
|
|
@@ -1373,8 +2121,17 @@ const query_findings_memory = {
|
|
|
1373
2121
|
required: ['query'],
|
|
1374
2122
|
},
|
|
1375
2123
|
async handler({ query }, ctx) {
|
|
1376
|
-
const { queryFindingsMemory } = await __webpack_require__.e(/* import() */
|
|
1377
|
-
|
|
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 };
|
|
1378
2135
|
},
|
|
1379
2136
|
};
|
|
1380
2137
|
|
|
@@ -1395,7 +2152,7 @@ const lookup_cve = {
|
|
|
1395
2152
|
},
|
|
1396
2153
|
async handler({ cve }, _ctx) {
|
|
1397
2154
|
const r = lookupCve(cve);
|
|
1398
|
-
return { _meta:
|
|
2155
|
+
return { _meta: tools_META, ...r };
|
|
1399
2156
|
},
|
|
1400
2157
|
};
|
|
1401
2158
|
|
|
@@ -1413,9 +2170,9 @@ const query_cache_telemetry = {
|
|
|
1413
2170
|
},
|
|
1414
2171
|
async handler({ transcript_path } = {}, ctx) {
|
|
1415
2172
|
const result = (0,cache_economics.analyzeTranscript)({ transcriptPath: transcript_path, projectDir: ctx?.sessionRoot || process.cwd() });
|
|
1416
|
-
if (!result.ok) return { _meta:
|
|
2173
|
+
if (!result.ok) return { _meta: tools_META, ok: false, reason: result.reason };
|
|
1417
2174
|
return {
|
|
1418
|
-
_meta:
|
|
2175
|
+
_meta: tools_META,
|
|
1419
2176
|
ok: true,
|
|
1420
2177
|
metrics: result.metrics,
|
|
1421
2178
|
leaks: result.leaks,
|
|
@@ -1431,7 +2188,7 @@ const query_cache_telemetry = {
|
|
|
1431
2188
|
// ecosystem's native --dry-run command. Safe to call any number of times.
|
|
1432
2189
|
let _scaUpgrade;
|
|
1433
2190
|
async function _getScaUpgrade() {
|
|
1434
|
-
if (!_scaUpgrade) _scaUpgrade = await __webpack_require__.e(/* import() */
|
|
2191
|
+
if (!_scaUpgrade) _scaUpgrade = await __webpack_require__.e(/* import() */ 5333).then(__webpack_require__.bind(__webpack_require__, 5333));
|
|
1435
2192
|
return _scaUpgrade;
|
|
1436
2193
|
}
|
|
1437
2194
|
const synthesize_sca_upgrade = {
|
|
@@ -1451,11 +2208,11 @@ const synthesize_sca_upgrade = {
|
|
|
1451
2208
|
const f = _findById(scan, finding_id);
|
|
1452
2209
|
if (!f) throw new Error(`Finding not found: ${finding_id}`);
|
|
1453
2210
|
if (f.type !== 'vulnerable_dep') {
|
|
1454
|
-
return { _meta:
|
|
2211
|
+
return { _meta: tools_META, ok: false, reason: 'finding is not an SCA vulnerable_dep — use synthesize_fix for SAST findings' };
|
|
1455
2212
|
}
|
|
1456
2213
|
const { planScaUpgrade } = await _getScaUpgrade();
|
|
1457
2214
|
const plan = await planScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f });
|
|
1458
|
-
return { _meta:
|
|
2215
|
+
return { _meta: tools_META, ...plan };
|
|
1459
2216
|
},
|
|
1460
2217
|
};
|
|
1461
2218
|
|
|
@@ -1482,24 +2239,24 @@ const apply_sca_upgrade = {
|
|
|
1482
2239
|
},
|
|
1483
2240
|
async handler({ finding_id, confirm, run_tests = true }, ctx) {
|
|
1484
2241
|
if (confirm !== true) {
|
|
1485
|
-
return { _meta:
|
|
2242
|
+
return { _meta: tools_META, applied: false, reason: 'apply_sca_upgrade requires confirm: true.' };
|
|
1486
2243
|
}
|
|
1487
2244
|
const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
1488
2245
|
if (!scan) {
|
|
1489
|
-
return { _meta:
|
|
2246
|
+
return { _meta: tools_META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
|
|
1490
2247
|
}
|
|
1491
2248
|
const f = _findById(scan, finding_id);
|
|
1492
|
-
if (!f) return { _meta:
|
|
2249
|
+
if (!f) return { _meta: tools_META, applied: false, reason: `Finding not found: ${finding_id}` };
|
|
1493
2250
|
if (f.type !== 'vulnerable_dep') {
|
|
1494
|
-
return { _meta:
|
|
2251
|
+
return { _meta: tools_META, applied: false, reason: 'finding is not an SCA vulnerable_dep — use apply_fix for SAST findings' };
|
|
1495
2252
|
}
|
|
1496
2253
|
const { applyScaUpgrade } = await _getScaUpgrade();
|
|
1497
2254
|
const result = await applyScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f, runTests: run_tests });
|
|
1498
|
-
return { _meta:
|
|
2255
|
+
return { _meta: tools_META, ...result };
|
|
1499
2256
|
},
|
|
1500
2257
|
};
|
|
1501
2258
|
|
|
1502
|
-
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];
|
|
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];
|
|
1503
2260
|
|
|
1504
2261
|
;// CONCATENATED MODULE: ./src/mcp/validate.js
|
|
1505
2262
|
// Minimal JSON Schema validator — just the subset our tool schemas use.
|
|
@@ -1552,149 +2309,8 @@ function validate(schema, value, path = 'arguments') {
|
|
|
1552
2309
|
}
|
|
1553
2310
|
}
|
|
1554
2311
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
//
|
|
1558
|
-
// Format: one JSON object per line (NDJSON) at
|
|
1559
|
-
// <sessionRoot>/.agentic-security/mcp-audit.log
|
|
1560
|
-
//
|
|
1561
|
-
// Each entry carries `prev` — the SHA-256 of the previous entry's serialized
|
|
1562
|
-
// form. The first entry's prev is "GENESIS". Tampering with any line breaks
|
|
1563
|
-
// the chain from that point forward; a reader can detect partial truncation
|
|
1564
|
-
// or in-place edits.
|
|
1565
|
-
//
|
|
1566
|
-
// REMOTE SINK (post-recommendation #10). The local file alone cannot detect
|
|
1567
|
-
// a total rewrite — an attacker with FS write can re-author the whole log
|
|
1568
|
-
// with fresh hashes. Closing that blind spot requires an off-host witness.
|
|
1569
|
-
// Set $AGENTIC_SECURITY_AUDIT_WEBHOOK to a POST endpoint; every entry is
|
|
1570
|
-
// fire-and-forget POSTed there in addition to the local append. Failures
|
|
1571
|
-
// to reach the webhook are best-effort — they NEVER block a tool call,
|
|
1572
|
-
// because that would let a network outage become a denial of service. They
|
|
1573
|
-
// DO get recorded as `_remoteSinkErr` on the local entry, so an operator
|
|
1574
|
-
// reviewing the log later can spot a forging attempt that targeted the
|
|
1575
|
-
// remote (any gap between local-sequence and remote-sequence is evidence).
|
|
1576
|
-
//
|
|
1577
|
-
// Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
|
|
1578
|
-
// arguments cannot leak via the audit trail OR via the remote sink.
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
const MAX_ARG_BYTES = 1024;
|
|
1586
|
-
const GENESIS = 'GENESIS';
|
|
1587
|
-
const REMOTE_TIMEOUT_MS = 1500;
|
|
1588
|
-
|
|
1589
|
-
// Per-process session ID (harness-anatomy #9). Stamped on every audit entry
|
|
1590
|
-
// so downstream metrics can aggregate by session and surface outliers like
|
|
1591
|
-
// "200 apply_fix calls in one session." The ID is `<pid>-<short-ts>` — not
|
|
1592
|
-
// cryptographically unique, but enough to disambiguate concurrent runs on
|
|
1593
|
-
// the same host. Stable for the lifetime of this Node process.
|
|
1594
|
-
const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
|
|
1595
|
-
|
|
1596
|
-
function _summarize(args) {
|
|
1597
|
-
let s;
|
|
1598
|
-
try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
|
|
1599
|
-
s = redactArgsBlob(s);
|
|
1600
|
-
if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
|
|
1601
|
-
return s;
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
function _sha(s) { return external_node_crypto_.createHash('sha256').update(s).digest('hex'); }
|
|
1605
|
-
|
|
1606
|
-
function _readLastEntryHash(logFile) {
|
|
1607
|
-
if (!external_node_fs_.existsSync(logFile)) return GENESIS;
|
|
1608
|
-
try {
|
|
1609
|
-
const all = external_node_fs_.readFileSync(logFile, 'utf8');
|
|
1610
|
-
const lines = all.split('\n').filter(Boolean);
|
|
1611
|
-
if (!lines.length) return GENESIS;
|
|
1612
|
-
return _sha(lines[lines.length - 1]);
|
|
1613
|
-
} catch { return GENESIS; }
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
// Fire-and-forget POST to the remote sink. Resolves to null on success,
|
|
1617
|
-
// to a short error string on failure. Never throws; never blocks longer
|
|
1618
|
-
// than REMOTE_TIMEOUT_MS. The local audit append happens regardless.
|
|
1619
|
-
async function _postRemote(url, entry) {
|
|
1620
|
-
try {
|
|
1621
|
-
const controller = new AbortController();
|
|
1622
|
-
const t = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
|
|
1623
|
-
const r = await fetch(url, {
|
|
1624
|
-
method: 'POST',
|
|
1625
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1626
|
-
body: JSON.stringify(entry),
|
|
1627
|
-
signal: controller.signal,
|
|
1628
|
-
});
|
|
1629
|
-
clearTimeout(t);
|
|
1630
|
-
if (!r.ok) return `HTTP ${r.status}`;
|
|
1631
|
-
return null;
|
|
1632
|
-
} catch (e) {
|
|
1633
|
-
return String((e && e.message) || e).slice(0, 200);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
function auditCall({ sessionRoot, tool, args, outcome, reason }) {
|
|
1638
|
-
if (!sessionRoot) return;
|
|
1639
|
-
try {
|
|
1640
|
-
// Safety: only write audit log if sessionRoot looks like a project root
|
|
1641
|
-
const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
|
|
1642
|
-
let hasMarker = false;
|
|
1643
|
-
for (const m of MARKERS) { try { if (external_node_fs_.existsSync(external_node_path_.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
|
|
1644
|
-
if (!hasMarker) return;
|
|
1645
|
-
const dir = external_node_path_.join(sessionRoot, '.agentic-security');
|
|
1646
|
-
external_node_fs_.mkdirSync(dir, { recursive: true });
|
|
1647
|
-
const logFile = external_node_path_.join(dir, 'mcp-audit.log');
|
|
1648
|
-
const entry = {
|
|
1649
|
-
ts: new Date().toISOString(),
|
|
1650
|
-
sessionId: SESSION_ID,
|
|
1651
|
-
tool,
|
|
1652
|
-
outcome,
|
|
1653
|
-
...(reason ? { reason } : {}),
|
|
1654
|
-
args: _summarize(args),
|
|
1655
|
-
prev: _readLastEntryHash(logFile),
|
|
1656
|
-
};
|
|
1657
|
-
external_node_fs_.appendFileSync(logFile, JSON.stringify(entry) + '\n');
|
|
1658
|
-
// Remote sink (post-recommendation #10). Fire-and-forget. We don't await
|
|
1659
|
-
// the promise so the tool call returns immediately; the remote POST runs
|
|
1660
|
-
// on its own microtask. Failures get logged to a sidecar file so the
|
|
1661
|
-
// operator can detect when the sink is unreachable.
|
|
1662
|
-
const webhook = process.env.AGENTIC_SECURITY_AUDIT_WEBHOOK;
|
|
1663
|
-
if (webhook) {
|
|
1664
|
-
_postRemote(webhook, entry).then((err) => {
|
|
1665
|
-
if (!err) return;
|
|
1666
|
-
try {
|
|
1667
|
-
const errFile = external_node_path_.join(dir, 'mcp-audit.remote-errors.log');
|
|
1668
|
-
external_node_fs_.appendFileSync(errFile, JSON.stringify({
|
|
1669
|
-
ts: new Date().toISOString(), entryTs: entry.ts, tool, err,
|
|
1670
|
-
}) + '\n');
|
|
1671
|
-
} catch { /* nothing else to do */ }
|
|
1672
|
-
});
|
|
1673
|
-
}
|
|
1674
|
-
} catch { /* audit failure must never break a tool call */ }
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// Verify the chain from start to end. Returns
|
|
1678
|
-
// { ok: true, entries: N } if intact
|
|
1679
|
-
// { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
|
|
1680
|
-
// Reader/operator-facing tool.
|
|
1681
|
-
function verifyAuditLog(logFile) {
|
|
1682
|
-
if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
|
|
1683
|
-
const text = fs.readFileSync(logFile, 'utf8');
|
|
1684
|
-
const lines = text.split('\n').filter(Boolean);
|
|
1685
|
-
let expectedPrev = GENESIS;
|
|
1686
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1687
|
-
let entry;
|
|
1688
|
-
try { entry = JSON.parse(lines[i]); }
|
|
1689
|
-
catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
|
|
1690
|
-
if (entry.prev !== expectedPrev) {
|
|
1691
|
-
return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
|
|
1692
|
-
}
|
|
1693
|
-
expectedPrev = _sha(lines[i]);
|
|
1694
|
-
}
|
|
1695
|
-
return { ok: true, entries: lines.length };
|
|
1696
|
-
}
|
|
1697
|
-
|
|
2312
|
+
// EXTERNAL MODULE: ./src/mcp/audit.js
|
|
2313
|
+
var audit = __webpack_require__(9390);
|
|
1698
2314
|
;// CONCATENATED MODULE: ./src/mcp/server.js
|
|
1699
2315
|
// MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
|
|
1700
2316
|
//
|
|
@@ -1741,7 +2357,7 @@ const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));
|
|
|
1741
2357
|
function _codeFingerprint() {
|
|
1742
2358
|
try {
|
|
1743
2359
|
const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
|
|
1744
|
-
const files = ['server.js', 'tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
2360
|
+
const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
1745
2361
|
const h = external_node_crypto_.createHash('sha256');
|
|
1746
2362
|
for (const f of files) {
|
|
1747
2363
|
try { h.update(f); h.update(external_node_fs_.readFileSync(external_node_path_.join(here, f))); } catch {}
|
|
@@ -1804,7 +2420,7 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
|
1804
2420
|
const name = msg.params?.name;
|
|
1805
2421
|
const args = msg.params?.arguments ?? {};
|
|
1806
2422
|
if (disabled) {
|
|
1807
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
|
|
2423
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
|
|
1808
2424
|
return _ok(id, {
|
|
1809
2425
|
content: [{ type: 'text', text: 'MCP server is disabled (AGENTIC_SECURITY_MCP_DISABLED=1).' }],
|
|
1810
2426
|
isError: true,
|
|
@@ -1812,12 +2428,12 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
|
1812
2428
|
}
|
|
1813
2429
|
const tool = TOOLS_BY_NAME[name];
|
|
1814
2430
|
if (!tool) {
|
|
1815
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
|
|
2431
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
|
|
1816
2432
|
return _err(id, -32602, `Unknown tool: ${name}`);
|
|
1817
2433
|
}
|
|
1818
2434
|
try { validate(tool.inputSchema, args); }
|
|
1819
2435
|
catch (e) {
|
|
1820
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
|
|
2436
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
|
|
1821
2437
|
return _ok(id, {
|
|
1822
2438
|
content: [{ type: 'text', text: `Invalid arguments: ${e.message}` }],
|
|
1823
2439
|
isError: true,
|
|
@@ -1825,13 +2441,13 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
|
|
|
1825
2441
|
}
|
|
1826
2442
|
try {
|
|
1827
2443
|
const result = await tool.handler(args, ctx);
|
|
1828
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'ok' });
|
|
2444
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'ok' });
|
|
1829
2445
|
return _ok(id, {
|
|
1830
2446
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
1831
2447
|
isError: false,
|
|
1832
2448
|
});
|
|
1833
2449
|
} catch (e) {
|
|
1834
|
-
auditCall({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
|
|
2450
|
+
(0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
|
|
1835
2451
|
return _ok(id, {
|
|
1836
2452
|
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
1837
2453
|
isError: true,
|
|
@@ -1983,9 +2599,11 @@ function money(n) {
|
|
|
1983
2599
|
|
|
1984
2600
|
// Per-1M-token rates (input / output). Mirror hooks/model-cost-advisor.js MODELS.
|
|
1985
2601
|
const MODEL_RATES = {
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2602
|
+
fable: { label: 'Fable 5', in: 10, out: 50 },
|
|
2603
|
+
opus: { label: 'Opus 4.8', in: 5, out: 25 },
|
|
2604
|
+
sonnet5: { label: 'Sonnet 5', in: 3, out: 15 },
|
|
2605
|
+
sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
|
|
2606
|
+
haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
|
|
1989
2607
|
};
|
|
1990
2608
|
const CACHE_READ_MULT = 0.1; // cache read ≈ 0.1× input
|
|
1991
2609
|
const CACHE_WRITE_MULT = 1.25; // 5-minute cache write ≈ 1.25× input
|
|
@@ -1997,8 +2615,9 @@ const TTL_MS = 5 * 60 * 1000;
|
|
|
1997
2615
|
function rateFor(model) {
|
|
1998
2616
|
if (typeof model !== 'string') return null;
|
|
1999
2617
|
const s = model.toLowerCase();
|
|
2618
|
+
if (s.includes('fable') || s.includes('mythos')) return MODEL_RATES.fable;
|
|
2000
2619
|
if (s.includes('haiku')) return MODEL_RATES.haiku;
|
|
2001
|
-
if (s.includes('sonnet')) return MODEL_RATES.sonnet;
|
|
2620
|
+
if (s.includes('sonnet')) return (s.includes('sonnet-5') || s.includes('sonnet 5')) ? MODEL_RATES.sonnet5 : MODEL_RATES.sonnet;
|
|
2002
2621
|
if (s.includes('opus')) return MODEL_RATES.opus;
|
|
2003
2622
|
return null;
|
|
2004
2623
|
}
|
|
@@ -2226,6 +2845,307 @@ const _internal = {
|
|
|
2226
2845
|
};
|
|
2227
2846
|
|
|
2228
2847
|
|
|
2848
|
+
/***/ }),
|
|
2849
|
+
|
|
2850
|
+
/***/ 8218:
|
|
2851
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
2852
|
+
|
|
2853
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2854
|
+
/* harmony export */ loadFreshLineageGraph: () => (/* binding */ loadFreshLineageGraph),
|
|
2855
|
+
/* harmony export */ loadSignedGraph: () => (/* binding */ loadSignedGraph)
|
|
2856
|
+
/* harmony export */ });
|
|
2857
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
|
|
2858
|
+
/* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1174);
|
|
2859
|
+
/* harmony import */ var _posture_integrity_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(1130);
|
|
2860
|
+
// graph-loader.js — Milestone 3, sub-project Server, increment 1.
|
|
2861
|
+
//
|
|
2862
|
+
// Reads and VERIFIES the `.agentic-security/lineage-graph.json` artifact
|
|
2863
|
+
// before `explore` is allowed to serve a single byte of it. Reuses
|
|
2864
|
+
// `posture/integrity.js`'s `verifyLastScan` DIRECTLY (per the plan and the
|
|
2865
|
+
// root CLAUDE.md's own instruction) — this module does not implement any
|
|
2866
|
+
// signature comparison of its own. `verifyLastScan` already uses
|
|
2867
|
+
// `crypto.timingSafeEqual` internally.
|
|
2868
|
+
//
|
|
2869
|
+
// Loaded ONCE at server startup (see bin/agentic-security.js's cmdExplore)
|
|
2870
|
+
// and held in memory for the life of the process — this is a read-only,
|
|
2871
|
+
// single-scan-snapshot server; a change to the graph on disk mid-session is
|
|
2872
|
+
// out of scope for this increment (threat-model doc's own "P0 is
|
|
2873
|
+
// read-only" framing).
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
|
+
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
/**
|
|
2880
|
+
* @param {string} scanRoot
|
|
2881
|
+
* @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
|
|
2882
|
+
*
|
|
2883
|
+
* Four, and only four, distinct failure reasons — each with its own clear
|
|
2884
|
+
* message so an operator knows exactly what to do next:
|
|
2885
|
+
* - 'missing' — no lineage-graph.json at all. Run a scan with
|
|
2886
|
+
* AGENTIC_SECURITY_LINEAGE_DEEP=1 first.
|
|
2887
|
+
* - 'unsigned' — the graph exists but its .sig sibling does not
|
|
2888
|
+
* (verifyLastScan returns null). Refuse to serve an
|
|
2889
|
+
* unverifiable graph.
|
|
2890
|
+
* - 'tampered' — the graph exists and has a .sig, but the signature does
|
|
2891
|
+
* not match the body (verifyLastScan returns false). The
|
|
2892
|
+
* file was modified after signing, or signed under a
|
|
2893
|
+
* different install key.
|
|
2894
|
+
* - 'malformed' — the body passed signature verification but is not
|
|
2895
|
+
* valid JSON. Should not happen from a normal scan; the
|
|
2896
|
+
* file may be corrupted on disk after signing.
|
|
2897
|
+
*/
|
|
2898
|
+
function loadSignedGraph(scanRoot) {
|
|
2899
|
+
const graphPath = (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, 'lineage-graph.json');
|
|
2900
|
+
const sigPath = graphPath + '.sig';
|
|
2901
|
+
|
|
2902
|
+
if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(graphPath)) {
|
|
2903
|
+
return {
|
|
2904
|
+
ok: false,
|
|
2905
|
+
reason: 'missing',
|
|
2906
|
+
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\`.`,
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
let body;
|
|
2911
|
+
try {
|
|
2912
|
+
body = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(graphPath, 'utf8');
|
|
2913
|
+
} catch (e) {
|
|
2914
|
+
return {
|
|
2915
|
+
ok: false,
|
|
2916
|
+
reason: 'missing',
|
|
2917
|
+
message: `Lineage graph found at ${graphPath} but could not be read: ${e && e.message ? e.message : e}.`,
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
const verified = (0,_posture_integrity_js__WEBPACK_IMPORTED_MODULE_2__/* .verifyLastScan */ .Ef)(body, sigPath);
|
|
2922
|
+
if (verified === null) {
|
|
2923
|
+
return {
|
|
2924
|
+
ok: false,
|
|
2925
|
+
reason: 'unsigned',
|
|
2926
|
+
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.`,
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
if (verified === false) {
|
|
2930
|
+
return {
|
|
2931
|
+
ok: false,
|
|
2932
|
+
reason: 'tampered',
|
|
2933
|
+
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.`,
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
let graph;
|
|
2938
|
+
try {
|
|
2939
|
+
graph = JSON.parse(body);
|
|
2940
|
+
} catch (e) {
|
|
2941
|
+
return {
|
|
2942
|
+
ok: false,
|
|
2943
|
+
reason: 'malformed',
|
|
2944
|
+
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.`,
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
return { ok: true, graph };
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/**
|
|
2952
|
+
* Load .agentic-security/lineage-graph.json ONLY when it is genuinely
|
|
2953
|
+
* fresh for THIS scan — never merely because a file happens to exist on
|
|
2954
|
+
* disk. Shared by every caller that signs or narrates a graph:-derived
|
|
2955
|
+
* compliance claim (M4 sub-project 6c's final whole-branch review found
|
|
2956
|
+
* the identical staleness gap independently reachable from
|
|
2957
|
+
* `attest --obligations` AND `compliance --walkthrough`, and required
|
|
2958
|
+
* this predicate to live in exactly one place rather than being
|
|
2959
|
+
* copy-pasted per caller — a safety check that drifts between two
|
|
2960
|
+
* near-identical inline copies is worse than one shared bug).
|
|
2961
|
+
*
|
|
2962
|
+
* `.agentic-security/lineage-graph.json` is only rewritten when a scan
|
|
2963
|
+
* actually finishes building a graph (`if (scan.lineageGraph)` in
|
|
2964
|
+
* bin/agentic-security.js's persistence code) — an ordinary non-deep
|
|
2965
|
+
* rescan, or a deep scan whose lineage build fails, leaves whatever file
|
|
2966
|
+
* was there from an earlier successful deep scan untouched. Loading that
|
|
2967
|
+
* stale graph and joining it to the CURRENT scan's other data would let a
|
|
2968
|
+
* caller assert a graph-derived fact (e.g. "transit protected") about
|
|
2969
|
+
* code that has since changed.
|
|
2970
|
+
*
|
|
2971
|
+
* `enabled: true` in `scan.scanHealth.lineageAnalysis` does NOT by itself
|
|
2972
|
+
* mean the build succeeded — engine.js sets it the moment
|
|
2973
|
+
* AGENTIC_SECURITY_LINEAGE_DEEP=1 is read, before the build even starts,
|
|
2974
|
+
* and leaves it `true` even when the build later throws (only `failure`
|
|
2975
|
+
* gets set in that case). `requested && enabled` alone therefore still
|
|
2976
|
+
* accepts a stale graph after a failed rebuild — reproduced live via the
|
|
2977
|
+
* scan's own already-shipped fault-injection fixture
|
|
2978
|
+
* (test/lineage-fault-injection.test.js) before this `failure === null`
|
|
2979
|
+
* check was added.
|
|
2980
|
+
*
|
|
2981
|
+
* @param {string} scanRoot
|
|
2982
|
+
* @param {object} scan - the parsed last-scan.json for the CURRENT scan
|
|
2983
|
+
* @returns {{graph:object|null, fresh:boolean, loaded:ReturnType<typeof loadSignedGraph>}}
|
|
2984
|
+
* `fresh` is true only when a signed graph loaded successfully AND this
|
|
2985
|
+
* scan's own scanHealth confirms lineage analysis was requested,
|
|
2986
|
+
* enabled, and did not fail. `graph` is `loaded.graph` when fresh, else
|
|
2987
|
+
* `null` — never the stale file, even when one exists on disk.
|
|
2988
|
+
* `loaded` is the raw `loadSignedGraph` result, so a caller can still
|
|
2989
|
+
* distinguish "no file at all" from "a file exists but isn't fresh" for
|
|
2990
|
+
* its own disclosure message.
|
|
2991
|
+
*/
|
|
2992
|
+
function loadFreshLineageGraph(scanRoot, scan) {
|
|
2993
|
+
const la = scan?.scanHealth?.lineageAnalysis;
|
|
2994
|
+
const requested = la?.requested === true;
|
|
2995
|
+
const enabled = la?.enabled === true;
|
|
2996
|
+
const failure = la?.failure ?? null;
|
|
2997
|
+
const loaded = loadSignedGraph(scanRoot);
|
|
2998
|
+
const fresh = loaded.ok && requested && enabled && failure === null;
|
|
2999
|
+
return { graph: fresh ? loaded.graph : null, fresh, loaded };
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
/***/ }),
|
|
3004
|
+
|
|
3005
|
+
/***/ 4268:
|
|
3006
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
3007
|
+
|
|
3008
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
3009
|
+
/* harmony export */ Yo: () => (/* binding */ handleScan),
|
|
3010
|
+
/* harmony export */ Yu: () => (/* binding */ handleEdge),
|
|
3011
|
+
/* harmony export */ d5: () => (/* binding */ handleNode),
|
|
3012
|
+
/* harmony export */ fn: () => (/* binding */ handleGraph),
|
|
3013
|
+
/* harmony export */ jg: () => (/* binding */ handleFlow),
|
|
3014
|
+
/* harmony export */ rR: () => (/* binding */ handleQuery)
|
|
3015
|
+
/* harmony export */ });
|
|
3016
|
+
/* unused harmony export wrapResponse */
|
|
3017
|
+
/* harmony import */ var _lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(859);
|
|
3018
|
+
// routes.js — Milestone 3, sub-project Server, increment 1.
|
|
3019
|
+
//
|
|
3020
|
+
// Five pure GET-endpoint handlers, each `(graph, ...) -> {status, body}`.
|
|
3021
|
+
// No req/res access anywhere in this file — that is what makes these
|
|
3022
|
+
// handlers unit-testable without an HTTP layer at all. http-server.js is
|
|
3023
|
+
// the only module that touches node:http and calls into these.
|
|
3024
|
+
//
|
|
3025
|
+
// Every response body is wrapped in `wrapResponse`, which adds the exact
|
|
3026
|
+
// envelope fields PRD line 1326 names (quoted in the implementation plan):
|
|
3027
|
+
// "base graph/snapshot digest, schema/extension versions, scope, coverage,
|
|
3028
|
+
// limitations, and contributing canonical IDs."
|
|
3029
|
+
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
/**
|
|
3033
|
+
* Shared response envelope. Maps PRD line 1326's required fields onto the
|
|
3034
|
+
* graph's own real fields:
|
|
3035
|
+
* - digest -> graph.graphId (the base graph/snapshot digest)
|
|
3036
|
+
* - schemaVersion -> graph.schemaVersion
|
|
3037
|
+
* - extensions -> graph.extensions (schema/extension versions —
|
|
3038
|
+
* today always `{}`; see schema.js)
|
|
3039
|
+
* - scope -> graph.scope
|
|
3040
|
+
* - coverage -> graph.coverage
|
|
3041
|
+
* - limitations -> graph.limitations
|
|
3042
|
+
* - canonicalIds -> see the design note below
|
|
3043
|
+
*
|
|
3044
|
+
* "contributing canonical IDs" design decision (disclosed per the plan):
|
|
3045
|
+
* for `handleScan`/`handleGraph`, which describe the WHOLE graph rather
|
|
3046
|
+
* than one entity, `canonicalIds` is `null` — the response body for
|
|
3047
|
+
* `handleGraph` already IS the full nodes/edges/flows arrays, so echoing
|
|
3048
|
+
* every id again here would be pure duplication with no informational
|
|
3049
|
+
* gain, and for a large graph would materially bloat the response for
|
|
3050
|
+
* zero benefit. For `handleNode`/`handleEdge`, `canonicalIds` is the
|
|
3051
|
+
* single id the response is about. For `handleFlow`, `canonicalIds` is
|
|
3052
|
+
* the flow's own id PLUS the node/edge ids that flow's evidence draws
|
|
3053
|
+
* from (source, sink, edgeIds) — a flow is a derived record referencing
|
|
3054
|
+
* several underlying entities, and naming all of them here is genuinely
|
|
3055
|
+
* useful metadata a client would otherwise have to re-derive from the
|
|
3056
|
+
* flow body itself.
|
|
3057
|
+
*/
|
|
3058
|
+
function wrapResponse(data, graph, { canonicalIds = null } = {}) {
|
|
3059
|
+
return {
|
|
3060
|
+
digest: graph?.graphId ?? null,
|
|
3061
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
3062
|
+
extensions: graph?.extensions ?? {},
|
|
3063
|
+
scope: graph?.scope ?? null,
|
|
3064
|
+
coverage: graph?.coverage ?? null,
|
|
3065
|
+
limitations: graph?.limitations ?? [],
|
|
3066
|
+
canonicalIds,
|
|
3067
|
+
data,
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
function _findById(list, id) {
|
|
3072
|
+
if (!Array.isArray(list)) return null;
|
|
3073
|
+
return list.find((item) => item && item.id === id) ?? null;
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
/** Scan/graph metadata — NOT the full node/edge arrays. */
|
|
3077
|
+
function handleScan(graph) {
|
|
3078
|
+
const data = {
|
|
3079
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
3080
|
+
graphId: graph?.graphId ?? null,
|
|
3081
|
+
generatedAt: graph?.generatedAt ?? null,
|
|
3082
|
+
scope: graph?.scope ?? null,
|
|
3083
|
+
scanHealth: graph?.scanHealth ?? null,
|
|
3084
|
+
coverage: graph?.coverage ?? null,
|
|
3085
|
+
};
|
|
3086
|
+
return { status: 200, body: wrapResponse(data, graph, { canonicalIds: null }) };
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
/** The full graph document, unfiltered. For a scoped/narrowed projection, use `handleQuery` (`POST /api/v1/query`, Milestone 5) below instead. */
|
|
3090
|
+
function handleGraph(graph) {
|
|
3091
|
+
return { status: 200, body: wrapResponse(graph, graph, { canonicalIds: null }) };
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
/**
|
|
3095
|
+
* A deterministic typed projection query — Milestone 5's own
|
|
3096
|
+
* `POST /api/v1/query`, the S2 endpoint `handleGraph`'s own header
|
|
3097
|
+
* comment named and deferred. `filter` is the exact `{nodeIds, edgeIds}`
|
|
3098
|
+
* shape `dataflow export --filter`/`exportGraphJSON` already use — reused
|
|
3099
|
+
* via `_filterGraph`, never reimplemented. Final whole-branch review
|
|
3100
|
+
* finding: `undefined` (filter omitted entirely) returns the WHOLE graph,
|
|
3101
|
+
* identical to `handleGraph` — but `{}` (an empty, well-formed filter
|
|
3102
|
+
* object) is NOT the same thing, and does NOT mean "no restriction": both
|
|
3103
|
+
* `nodeIds`/`edgeIds` default to empty Sets inside `_filterGraph`, so `{}`
|
|
3104
|
+
* narrows the graph down to EMPTY node/edge/flow/dataElement arrays. A
|
|
3105
|
+
* caller that wants the whole graph must omit `filter` entirely, never
|
|
3106
|
+
* pass `{}` meaning "everything." A malformed filter is a 400, never a
|
|
3107
|
+
* thrown exception reaching the caller.
|
|
3108
|
+
*/
|
|
3109
|
+
function handleQuery(graph, filter) {
|
|
3110
|
+
const check = (0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__.validateFilterShape)(filter);
|
|
3111
|
+
if (!check.valid) {
|
|
3112
|
+
return { status: 400, body: { error: check.error } };
|
|
3113
|
+
}
|
|
3114
|
+
return { status: 200, body: wrapResponse((0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__/* ._filterGraph */ .e)(graph, filter), graph, { canonicalIds: null }) };
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
/** Look up one node by id. 404 with a clear body if not found. */
|
|
3118
|
+
function handleNode(graph, id) {
|
|
3119
|
+
const node = _findById(graph?.nodes, id);
|
|
3120
|
+
if (!node) {
|
|
3121
|
+
return { status: 404, body: wrapResponse({ error: `node not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
3122
|
+
}
|
|
3123
|
+
return { status: 200, body: wrapResponse(node, graph, { canonicalIds: [id] }) };
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
/** Look up one edge by id. 404 with a clear body if not found. */
|
|
3127
|
+
function handleEdge(graph, id) {
|
|
3128
|
+
const edge = _findById(graph?.edges, id);
|
|
3129
|
+
if (!edge) {
|
|
3130
|
+
return { status: 404, body: wrapResponse({ error: `edge not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
3131
|
+
}
|
|
3132
|
+
return { status: 200, body: wrapResponse(edge, graph, { canonicalIds: [id] }) };
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
/** Look up one flow by id. 404 with a clear body if not found. */
|
|
3136
|
+
function handleFlow(graph, id) {
|
|
3137
|
+
const flow = _findById(graph?.flows, id);
|
|
3138
|
+
if (!flow) {
|
|
3139
|
+
return { status: 404, body: wrapResponse({ error: `flow not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
3140
|
+
}
|
|
3141
|
+
const contributing = new Set([id]);
|
|
3142
|
+
if (flow.source) contributing.add(flow.source);
|
|
3143
|
+
if (flow.sink) contributing.add(flow.sink);
|
|
3144
|
+
for (const eid of (flow.edgeIds || [])) contributing.add(eid);
|
|
3145
|
+
return { status: 200, body: wrapResponse(flow, graph, { canonicalIds: [...contributing] }) };
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
|
|
2229
3149
|
/***/ })
|
|
2230
3150
|
|
|
2231
3151
|
};
|