@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,541 @@
1
+ // trace-correlation.js: sanitize runtime traces and correlate them with the
2
+ // static deployment boundary graph (X-303).
3
+ //
4
+ // The import side:
5
+ // - A trace line is a JSON object. Only an allowlist of topology metadata is
6
+ // kept (timestamp, environment, tenant, source and destination service,
7
+ // host and port, request method, route host and path, outcome, sampled
8
+ // flag, repeat count). Every other field is removed, never stored, and only
9
+ // counted, split into credential-shaped, payload-shaped and other. A field
10
+ // name is never kept, so a header or a body cannot reach the artifact even
11
+ // as a key.
12
+ // - Values that are kept are held to an identifier grammar. A request path
13
+ // loses its query string and fragment, and any segment that looks like a
14
+ // number, a uuid, a hash, a token or a long opaque string becomes `:id`.
15
+ // A record whose kept value is not identifier-shaped is rejected whole,
16
+ // never partially kept.
17
+ // - A record for another environment is not relabelled and not stored: the
18
+ // import is for one environment and counts the rest as rejected. A record
19
+ // with no environment cannot be placed and is rejected too.
20
+ // - Storage is bounded: input bytes, line length, line count, distinct
21
+ // observations. Duplicates (same normalized key) merge into one observation
22
+ // with a summed count and a widened interval. Past the observation cap the
23
+ // oldest are evicted and the eviction is reported.
24
+ //
25
+ // The correlation side matches observations to graph nodes by explicit,
26
+ // numbered rules (R1 exact service pair, R2 route, R3 identity, R4 naming
27
+ // convention). R1-R3 produce `runtime-observation` edges; R4 produces an
28
+ // `inferred` edge because a naming convention is a guess about identity, not
29
+ // an observation of it. A static edge is never edited: observed and inferred
30
+ // edges are separate edges, so the three kinds of evidence stay separate.
31
+ //
32
+ // What coverage means here: an observed edge is evidence that the path
33
+ // carried traffic during the window. It is never evidence that the window held
34
+ // all traffic. `trafficCoverage` is therefore always 'not-established'; sampled,
35
+ // stale and missing observations are listed separately; and a static edge with
36
+ // no matching observation stays `possible`, never `blocked` (absence in a
37
+ // sampled or unknown-completeness trace cannot establish a block).
38
+
39
+ import { semanticId, digestOf, canonicalize } from '../../posture/assurance/identity.js';
40
+ import { EVENT_COUNT_BANDS } from '../runtime-observation.js';
41
+ import { buildBoundaryGraph, nodeIdOf, edgeIdOf, findPath } from './boundary-graph.js';
42
+
43
+ export const OBSERVATION_SET_SCHEMA = 'agentic-security/deployment-observations';
44
+ const OBSERVATION_SET_VERSION = '1.0.0';
45
+
46
+ export const TRACE_LIMITS = Object.freeze({
47
+ maxInputBytes: 4 * 1024 * 1024,
48
+ maxLineBytes: 8 * 1024,
49
+ maxLines: 50_000,
50
+ maxObservations: 2_000,
51
+ maxCount: 1_000_000_000,
52
+ });
53
+ export const DEFAULT_STALE_AFTER_MS = 7 * 24 * 3600 * 1000;
54
+
55
+ export const CORRELATION_RULES = Object.freeze({
56
+ R1: 'exact service pair: source and destination service names match service nodes of the same environment and tenant',
57
+ R2: 'route: request host and path fall under a route node reached from the destination service',
58
+ R3: 'identity: the source identity matches the identity the source service is configured to assume',
59
+ R4: 'naming convention: a DNS style host or an unqualified service name resolves to exactly one service (inferred, never observed)',
60
+ });
61
+
62
+ const OUTCOMES = ['ok', 'denied', 'error'];
63
+ const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
64
+ const TOP_KEYS = ['ts', 'environment', 'tenant', 'source', 'destination', 'route', 'outcome', 'sampled', 'count'];
65
+ const SOURCE_KEYS = ['service', 'identity', 'tenant'];
66
+ const DEST_KEYS = ['service', 'host', 'port', 'tenant'];
67
+ const ROUTE_KEYS = ['method', 'host', 'path'];
68
+
69
+ const SVC = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,127}$/;
70
+ const HOST = /^[A-Za-z0-9*]([A-Za-z0-9._-]{0,251}[A-Za-z0-9])?$/;
71
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
72
+ const ENV = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
73
+
74
+ const CREDENTIAL_KEY = /auth|token|secret|passw|cookie|bearer|credential|session|api[-_]?key|signature|jwt/i;
75
+ const PAYLOAD_KEY = /body|payload|request|response|header|query|param|message|data|content|text|input|output|record|prompt/i;
76
+
77
+ function emptySanitization() {
78
+ return { recordsWithRemovedFields: 0, removedFieldCount: 0, credentialShaped: 0, payloadShaped: 0, other: 0 };
79
+ }
80
+
81
+ function classifyRemoved(key, s) {
82
+ s.removedFieldCount += 1;
83
+ if (CREDENTIAL_KEY.test(key)) s.credentialShaped += 1;
84
+ else if (PAYLOAD_KEY.test(key)) s.payloadShaped += 1;
85
+ else s.other += 1;
86
+ }
87
+
88
+ /** Strip a path to its route shape: no query, no fragment, opaque segments become ':id'. Returns null if nothing usable remains. */
89
+ export function normalizePath(raw) {
90
+ if (typeof raw !== 'string') return null;
91
+ const cut = raw.split(/[?#]/)[0];
92
+ if (!cut.startsWith('/')) return null;
93
+ const segs = cut.split('/').slice(1, 13);
94
+ // Keep only plain words and versions; numbers, ids, hashes and tokens all carry digits or length.
95
+ const out = segs.map((seg) => {
96
+ if (seg === '') return '';
97
+ if (/^[A-Za-z]+([_-][A-Za-z]+)*$/.test(seg) && seg.length <= 32) return seg;
98
+ if (/^v\d{1,3}$/.test(seg)) return seg;
99
+ return ':id';
100
+ });
101
+ const p = out.join('/');
102
+ const rebuilt = `/${p.startsWith('/') ? p.slice(1) : p}`.replace(/\/{2,}/g, '/');
103
+ return rebuilt.length <= 200 ? rebuilt : null;
104
+ }
105
+
106
+ function sanitizeObject(obj, allowed, stats) {
107
+ const kept = {};
108
+ let removed = 0;
109
+ for (const k of Object.keys(obj)) {
110
+ if (allowed.includes(k)) kept[k] = obj[k]; else { classifyRemoved(k, stats); removed += 1; }
111
+ }
112
+ return { kept, removed };
113
+ }
114
+
115
+ /** Normalize one parsed line. Returns `{ obs }` or `{ reject }`. */
116
+ function normalizeRecord(rec, stats) {
117
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return { reject: 'not-an-object' };
118
+ let removedTotal = 0;
119
+ const top = sanitizeObject(rec, TOP_KEYS, stats); removedTotal += top.removed;
120
+ const r = top.kept;
121
+ const sub = (v, keys) => { if (v === undefined) return {}; if (v === null || typeof v !== 'object' || Array.isArray(v)) return null; const s = sanitizeObject(v, keys, stats); removedTotal += s.removed; return s.kept; };
122
+ const src = sub(r.source, SOURCE_KEYS), dst = sub(r.destination, DEST_KEYS), rt = sub(r.route, ROUTE_KEYS);
123
+ if (removedTotal > 0) stats.recordsWithRemovedFields += 1;
124
+ if (src === null || dst === null || rt === null) return { reject: 'bad-structure' };
125
+
126
+ if (typeof r.ts !== 'string' || !ISO.test(r.ts) || !Number.isFinite(Date.parse(r.ts))) return { reject: 'bad-timestamp' };
127
+ if (typeof r.environment !== 'string' || !ENV.test(r.environment)) return { reject: 'missing-environment' };
128
+ if (r.tenant !== undefined && r.tenant !== null && (typeof r.tenant !== 'string' || !ENV.test(r.tenant))) return { reject: 'bad-tenant' };
129
+ for (const t of [src.tenant, dst.tenant]) if (t !== undefined && t !== null && (typeof t !== 'string' || !ENV.test(t))) return { reject: 'bad-tenant' };
130
+ if (src.service !== undefined && (typeof src.service !== 'string' || !SVC.test(src.service))) return { reject: 'bad-source-service' };
131
+ if (src.identity !== undefined && (typeof src.identity !== 'string' || !SVC.test(src.identity))) return { reject: 'bad-identity' };
132
+ if (dst.service !== undefined && (typeof dst.service !== 'string' || !SVC.test(dst.service))) return { reject: 'bad-destination-service' };
133
+ if (dst.host !== undefined && (typeof dst.host !== 'string' || !HOST.test(dst.host))) return { reject: 'bad-destination-host' };
134
+ if (dst.port !== undefined && !(Number.isInteger(dst.port) && dst.port >= 1 && dst.port <= 65535)) return { reject: 'bad-destination-port' };
135
+ if (rt.method !== undefined && !METHODS.includes(rt.method)) return { reject: 'bad-method' };
136
+ if (rt.host !== undefined && (typeof rt.host !== 'string' || !HOST.test(rt.host))) return { reject: 'bad-route-host' };
137
+ let path = null;
138
+ if (rt.path !== undefined) { path = normalizePath(rt.path); if (path === null) return { reject: 'bad-route-path' }; }
139
+ if (r.outcome !== undefined && !OUTCOMES.includes(r.outcome)) return { reject: 'bad-outcome' };
140
+ if (r.sampled !== undefined && typeof r.sampled !== 'boolean') return { reject: 'bad-sampled' };
141
+ if (r.count !== undefined && !(Number.isInteger(r.count) && r.count >= 1 && r.count <= TRACE_LIMITS.maxCount)) return { reject: 'bad-count' };
142
+ if (!src.service && !src.identity && !dst.service && !dst.host && !(rt.host || path)) return { reject: 'no-topology-fields' };
143
+
144
+ const o = {
145
+ environment: r.environment,
146
+ tenant: r.tenant ?? null,
147
+ from: { service: src.service ?? null, identity: src.identity ?? null, tenant: src.tenant ?? r.tenant ?? null },
148
+ to: { service: dst.service ?? null, host: dst.host ?? null, port: dst.port ?? null, tenant: dst.tenant ?? r.tenant ?? null },
149
+ route: (rt.method || rt.host || path) ? { method: rt.method ?? null, host: rt.host ?? null, path } : null,
150
+ outcome: r.outcome ?? 'ok',
151
+ sampled: r.sampled === true,
152
+ eventCount: r.count ?? 1,
153
+ firstObservedAt: r.ts,
154
+ lastObservedAt: r.ts,
155
+ };
156
+ o.id = observationIdOf(o);
157
+ return { obs: o };
158
+ }
159
+
160
+ const OBS_ID_FIELDS = ['environment', 'tenant', 'from', 'to', 'route', 'outcome', 'sampled'];
161
+ function observationIdOf(o) { return semanticId('bobs', o, OBS_ID_FIELDS); }
162
+
163
+ function mergeInto(a, b) {
164
+ a.eventCount = Math.min(TRACE_LIMITS.maxCount, a.eventCount + b.eventCount);
165
+ if (Date.parse(b.firstObservedAt) < Date.parse(a.firstObservedAt)) a.firstObservedAt = b.firstObservedAt;
166
+ if (Date.parse(b.lastObservedAt) > Date.parse(a.lastObservedAt)) a.lastObservedAt = b.lastObservedAt;
167
+ }
168
+
169
+ export function eventCountBand(n) {
170
+ if (n <= 1) return EVENT_COUNT_BANDS[0];
171
+ if (n <= 10) return EVENT_COUNT_BANDS[1];
172
+ if (n <= 100) return EVENT_COUNT_BANDS[2];
173
+ if (n <= 1000) return EVENT_COUNT_BANDS[3];
174
+ return EVENT_COUNT_BANDS[4];
175
+ }
176
+
177
+ function computeSetDigest(set) { return digestOf({ ...set, digest: null }); }
178
+
179
+ function finalizeSet(environment, byId, stats, sanitization, limits) {
180
+ let list = [...byId.values()];
181
+ let evicted = 0;
182
+ if (list.length > limits.maxObservations) {
183
+ list.sort((a, b) => Date.parse(b.lastObservedAt) - Date.parse(a.lastObservedAt) || (a.id < b.id ? -1 : 1));
184
+ evicted = list.length - limits.maxObservations;
185
+ list = list.slice(0, limits.maxObservations);
186
+ }
187
+ list.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
188
+ const times = list.flatMap(o => [Date.parse(o.firstObservedAt), Date.parse(o.lastObservedAt)]);
189
+ const set = {
190
+ schema: OBSERVATION_SET_SCHEMA,
191
+ schemaVersion: OBSERVATION_SET_VERSION,
192
+ environment,
193
+ window: times.length ? { start: new Date(Math.min(...times)).toISOString(), end: new Date(Math.max(...times)).toISOString() } : null,
194
+ observations: list,
195
+ sanitization,
196
+ stats: { ...stats, evicted },
197
+ digest: '',
198
+ };
199
+ set.digest = computeSetDigest(set);
200
+ return set;
201
+ }
202
+
203
+ /**
204
+ * Import runtime trace lines for ONE environment. Never throws, never reads a
205
+ * file, never stores a removed field or a rejected record.
206
+ *
207
+ * @param {string} text JSON Lines
208
+ * @param {object} p
209
+ * @param {string} p.environment the only environment this import accepts
210
+ */
211
+ export function importTraceLines(text, { environment, limits: overrides = {} } = {}) {
212
+ const limits = { ...TRACE_LIMITS, ...overrides };
213
+ if (typeof environment !== 'string' || !ENV.test(environment)) return { status: 'invalid-input', reason: 'environment must be a name such as prod', set: null };
214
+ if (typeof text !== 'string') return { status: 'invalid-input', reason: 'trace input must be text', set: null };
215
+ if (Buffer.byteLength(text, 'utf8') > limits.maxInputBytes) return { status: 'limit-exceeded', reason: `trace input is over the ${limits.maxInputBytes} byte limit; nothing was imported`, set: null };
216
+
217
+ const sanitization = emptySanitization();
218
+ const stats = { linesRead: 0, accepted: 0, duplicates: 0, rejected: 0, rejectedEnvironment: 0, truncatedLines: false, rejectReasons: {} };
219
+ const byId = new Map();
220
+ const lines = text.split('\n');
221
+ for (const raw of lines) {
222
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
223
+ if (line.trim() === '') continue;
224
+ if (stats.linesRead >= limits.maxLines) { stats.truncatedLines = true; break; }
225
+ stats.linesRead += 1;
226
+ const reject = (why) => { stats.rejected += 1; stats.rejectReasons[why] = (stats.rejectReasons[why] ?? 0) + 1; };
227
+ if (Buffer.byteLength(line, 'utf8') > limits.maxLineBytes) { reject('line-too-long'); continue; }
228
+ let rec;
229
+ try { rec = JSON.parse(line); } catch { reject('not-json'); continue; }
230
+ const n = normalizeRecord(rec, sanitization);
231
+ if (n.reject) { reject(n.reject); continue; }
232
+ if (n.obs.environment !== environment) { stats.rejectedEnvironment += 1; stats.rejected += 1; stats.rejectReasons['other-environment'] = (stats.rejectReasons['other-environment'] ?? 0) + 1; continue; }
233
+ const prev = byId.get(n.obs.id);
234
+ if (prev) { mergeInto(prev, n.obs); stats.duplicates += 1; } else byId.set(n.obs.id, n.obs);
235
+ stats.accepted += 1;
236
+ }
237
+ const set = finalizeSet(environment, byId, stats, sanitization, limits);
238
+ return { status: 'ok', reason: null, set };
239
+ }
240
+
241
+ /**
242
+ * Merge a newer import into stored observations of the SAME environment,
243
+ * bounded. Different environments are refused (never merged). Returns a new set.
244
+ */
245
+ export function mergeObservationSets(existing, incoming, { maxObservations = TRACE_LIMITS.maxObservations } = {}) {
246
+ if (!existing || !incoming) return { status: 'invalid-input', reason: 'two observation sets are required', set: null };
247
+ if (existing.environment !== incoming.environment) return { status: 'environment-mismatch', reason: `refusing to merge '${incoming.environment}' observations into '${existing.environment}'`, set: null };
248
+ const byId = new Map(existing.observations.map(o => [o.id, { ...o }]));
249
+ let duplicates = 0;
250
+ for (const o of incoming.observations) {
251
+ const prev = byId.get(o.id);
252
+ if (prev) { mergeInto(prev, o); duplicates += 1; } else byId.set(o.id, { ...o });
253
+ }
254
+ const stats = { linesRead: 0, accepted: byId.size, duplicates, rejected: 0, rejectedEnvironment: 0, truncatedLines: false, rejectReasons: {} };
255
+ const sanitization = {
256
+ recordsWithRemovedFields: existing.sanitization.recordsWithRemovedFields + incoming.sanitization.recordsWithRemovedFields,
257
+ removedFieldCount: existing.sanitization.removedFieldCount + incoming.sanitization.removedFieldCount,
258
+ credentialShaped: existing.sanitization.credentialShaped + incoming.sanitization.credentialShaped,
259
+ payloadShaped: existing.sanitization.payloadShaped + incoming.sanitization.payloadShaped,
260
+ other: existing.sanitization.other + incoming.sanitization.other,
261
+ };
262
+ return { status: 'ok', reason: null, set: finalizeSet(existing.environment, byId, stats, sanitization, { maxObservations }) };
263
+ }
264
+
265
+ // ------------------------------------------------------------ persistence
266
+
267
+ const SET_FIELDS = ['schema', 'schemaVersion', 'environment', 'window', 'observations', 'sanitization', 'stats', 'digest'];
268
+ const OBS_FIELDS = ['id', 'environment', 'tenant', 'from', 'to', 'route', 'outcome', 'sampled', 'eventCount', 'firstObservedAt', 'lastObservedAt'];
269
+
270
+ /** Deterministic text form of an observation set (for the caller to store). */
271
+ export function exportObservationSet(set) { return `${canonicalize(set)}\n`; }
272
+
273
+ /**
274
+ * Read a stored observation set back. Closed world, bounded, digest-checked:
275
+ * a set that was edited, padded with extra fields, or grown past the cap is
276
+ * refused, never partially accepted.
277
+ */
278
+ export function parseObservationSet(text, { maxObservations = TRACE_LIMITS.maxObservations, maxBytes = 2 * TRACE_LIMITS.maxInputBytes } = {}) {
279
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > maxBytes) return { status: 'malformed', reason: 'not text, or over the size limit', set: null };
280
+ let set;
281
+ try { set = JSON.parse(text); } catch { return { status: 'malformed', reason: 'not valid JSON', set: null }; }
282
+ const bad = (reason) => ({ status: 'invalid', reason, set: null });
283
+ if (!set || typeof set !== 'object' || Array.isArray(set)) return bad('not an object');
284
+ for (const k of Object.keys(set)) if (!SET_FIELDS.includes(k)) return bad(`unknown field '${k}'`);
285
+ if (set.schema !== OBSERVATION_SET_SCHEMA) return bad('wrong schema');
286
+ if (typeof set.environment !== 'string' || !ENV.test(set.environment)) return bad('bad environment');
287
+ if (!Array.isArray(set.observations) || set.observations.length > maxObservations) return bad(`observations must be an array of at most ${maxObservations}`);
288
+ const ids = new Set();
289
+ for (const o of set.observations) {
290
+ if (!o || typeof o !== 'object' || Array.isArray(o)) return bad('observation is not an object');
291
+ for (const k of Object.keys(o)) if (!OBS_FIELDS.includes(k)) return bad(`observation has unknown field '${k}'`);
292
+ if (o.environment !== set.environment) return bad('observation environment differs from the set environment');
293
+ if (!ISO.test(o.firstObservedAt ?? '') || !ISO.test(o.lastObservedAt ?? '') || Date.parse(o.firstObservedAt) > Date.parse(o.lastObservedAt)) return bad('observation interval is invalid');
294
+ if (!OUTCOMES.includes(o.outcome) || typeof o.sampled !== 'boolean') return bad('observation outcome or sampled flag is invalid');
295
+ if (!Number.isInteger(o.eventCount) || o.eventCount < 1 || o.eventCount > TRACE_LIMITS.maxCount) return bad('observation count is invalid');
296
+ for (const [f, keys] of [['from', SOURCE_KEYS], ['to', DEST_KEYS]]) {
297
+ if (!o[f] || typeof o[f] !== 'object') return bad(`observation.${f} is missing`);
298
+ for (const k of Object.keys(o[f])) if (!keys.includes(k)) return bad(`observation.${f} has unknown field '${k}'`);
299
+ }
300
+ for (const v of [o.from?.service, o.from?.identity, o.to?.service]) if (v !== null && v !== undefined && !SVC.test(v)) return bad('observation has a non identifier-shaped name');
301
+ for (const v of [o.tenant, o.from?.tenant, o.to?.tenant]) if (v !== null && v !== undefined && !ENV.test(v)) return bad('observation has a bad tenant');
302
+ if (o.to.host !== null && o.to.host !== undefined && !HOST.test(o.to.host)) return bad('observation has a bad host');
303
+ if (o.route !== null) {
304
+ if (typeof o.route !== 'object') return bad('observation.route is invalid');
305
+ for (const k of Object.keys(o.route)) if (!ROUTE_KEYS.includes(k)) return bad(`observation.route has unknown field '${k}'`);
306
+ if (o.route.path !== null && o.route.path !== undefined && (typeof o.route.path !== 'string' || !/^\/[A-Za-z0-9._:\/-]*$/.test(o.route.path) || o.route.path.length > 200)) return bad('observation has a bad route path');
307
+ }
308
+ if (observationIdOf(o) !== o.id) return bad('observation id does not match its content');
309
+ if (ids.has(o.id)) return bad('duplicate observation id');
310
+ ids.add(o.id);
311
+ }
312
+ if (set.digest !== computeSetDigest(set)) return bad('digest does not match the content');
313
+ return { status: 'ok', reason: null, set };
314
+ }
315
+
316
+ // ------------------------------------------------------------ correlation
317
+
318
+ const COMM_RELATIONS = new Set(['calls', 'depends-on', 'network-allows', 'routes-to']);
319
+
320
+ function serviceIndex(graph, environment) {
321
+ const exact = new Map(), bySuffix = new Map();
322
+ for (const n of graph.nodes) {
323
+ if (n.kind !== 'service' || n.environment !== environment) continue;
324
+ exact.set(`${n.tenant ?? ''}\n${n.name}`, n);
325
+ const base = n.name.includes('/') ? n.name.slice(n.name.lastIndexOf('/') + 1) : n.name;
326
+ const k = `${n.tenant ?? ''}\n${base}`;
327
+ if (!bySuffix.has(k)) bySuffix.set(k, []);
328
+ bySuffix.get(k).push(n);
329
+ }
330
+ return { exact, bySuffix };
331
+ }
332
+
333
+ function dnsToService(host) {
334
+ const m = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?)\.([a-z0-9]([a-z0-9-]*[a-z0-9])?)(\.svc(\.cluster\.local)?)?$/.exec(host ?? '');
335
+ return m ? `${m[3]}/${m[1]}` : null;
336
+ }
337
+
338
+ function resolveService(idx, tenant, name, host) {
339
+ const t = tenant ?? '';
340
+ if (name) {
341
+ const hit = idx.exact.get(`${t}\n${name}`);
342
+ if (hit) return { node: hit, rule: 'R1', basis: 'exact-name' };
343
+ if (!name.includes('/')) {
344
+ const c = idx.bySuffix.get(`${t}\n${name}`) ?? [];
345
+ if (c.length === 1) return { node: c[0], rule: 'R4', basis: 'unqualified-name' };
346
+ if (c.length > 1) return { node: null, reason: 'ambiguous-service-name' };
347
+ }
348
+ return { node: null, reason: 'no-service-node' };
349
+ }
350
+ if (host) {
351
+ const dns = dnsToService(host);
352
+ if (dns) {
353
+ const hit = idx.exact.get(`${t}\n${dns}`);
354
+ if (hit) return { node: hit, rule: 'R4', basis: 'dns-name' };
355
+ }
356
+ const c = idx.bySuffix.get(`${t}\n${host}`) ?? [];
357
+ if (c.length === 1) return { node: c[0], rule: 'R4', basis: 'host-as-service-name' };
358
+ return { node: null, reason: c.length > 1 ? 'ambiguous-service-name' : 'no-service-node' };
359
+ }
360
+ return { node: null, reason: 'no-destination' };
361
+ }
362
+
363
+ function pathUnder(prefix, path) {
364
+ if (typeof prefix !== 'string' || typeof path !== 'string') return false;
365
+ if (prefix === '/') return true;
366
+ return path === prefix || path.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
367
+ }
368
+
369
+ /**
370
+ * Correlate an observation set with a static graph. Pure: the clock is the
371
+ * explicit `now` argument. Returns the correlation record; use
372
+ * `applyCorrelation` to fold its edges into a graph.
373
+ *
374
+ * @param {object} graph a valid boundary graph
375
+ * @param {object} set an observation set from importTraceLines
376
+ * @param {object} p
377
+ * @param {string} p.now ISO timestamp the freshness test uses
378
+ * @param {number} [p.staleAfterMs]
379
+ * @param {boolean} [p.createObservedOnlyNodes] default true
380
+ */
381
+ export function correlateObservations(graph, set, { now, staleAfterMs = DEFAULT_STALE_AFTER_MS, createObservedOnlyNodes = true } = {}) {
382
+ if (!graph || !set || set.schema !== OBSERVATION_SET_SCHEMA) return { status: 'invalid-input', reason: 'a boundary graph and an observation set are required' };
383
+ const nowMs = Date.parse(now);
384
+ if (!Number.isFinite(nowMs)) return { status: 'invalid-input', reason: 'now must be an ISO timestamp' };
385
+ const env = set.environment;
386
+ const idx = serviceIndex(graph, env);
387
+ const nodesById = new Map(graph.nodes.map(n => [n.id, n]));
388
+ const outStatic = new Map();
389
+ for (const e of graph.edges) {
390
+ if (e.provenance !== 'static-config') continue;
391
+ if (!outStatic.has(e.from)) outStatic.set(e.from, []);
392
+ outStatic.get(e.from).push(e);
393
+ }
394
+ const staticBetween = (a, b) => graph.edges.filter(e => e.provenance === 'static-config' && e.from === a && e.to === b && COMM_RELATIONS.has(e.relation));
395
+
396
+ const newNodes = new Map();
397
+ const unmatched = [];
398
+ const conflicts = [];
399
+ const acc = new Map(); // observed/inferred edge key -> accumulator
400
+ const touchedStatic = new Set();
401
+ const outcomes = { ok: 0, denied: 0, error: 0 };
402
+ const deniedStatic = new Set();
403
+
404
+ const observedOnly = (name, tenant) => {
405
+ if (!createObservedOnlyNodes || !name) return null;
406
+ const n = { kind: 'service', name, environment: env, tenant: tenant ?? null, repository: null, trustZone: 'unknown', resolved: false, unresolvedReason: 'observed at runtime, not present in the static configuration', attrs: {}, declaredBy: [] };
407
+ newNodes.set(`${n.tenant ?? ''}\n${n.name}`, n);
408
+ return n;
409
+ };
410
+ const idOf = (n) => n.id ?? nodeIdOf(n);
411
+
412
+ const bump = (key, init, o) => {
413
+ let a = acc.get(key);
414
+ if (!a) { a = { ...init, first: o.firstObservedAt, last: o.lastObservedAt, allSampled: true, count: 0 }; acc.set(key, a); }
415
+ if (Date.parse(o.firstObservedAt) < Date.parse(a.first)) a.first = o.firstObservedAt;
416
+ if (Date.parse(o.lastObservedAt) > Date.parse(a.last)) a.last = o.lastObservedAt;
417
+ a.allSampled = a.allSampled && o.sampled;
418
+ a.count = Math.min(TRACE_LIMITS.maxCount, a.count + o.eventCount);
419
+ return a;
420
+ };
421
+
422
+ for (const o of set.observations) {
423
+ outcomes[o.outcome] += 1;
424
+ const srcR = o.from.service ? resolveService(idx, o.from.tenant, o.from.service, null) : { node: null, reason: 'no-source' };
425
+ const dstR = (o.to.service || o.to.host) ? resolveService(idx, o.to.tenant, o.to.service, o.to.host) : { node: null, reason: 'no-destination' };
426
+ let srcNode = srcR.node, dstNode = dstR.node;
427
+ let matchedAny = false;
428
+
429
+ // R1/R4: service pair
430
+ if (o.from.service && (o.to.service || o.to.host)) {
431
+ if (!srcNode && srcR.reason === 'no-service-node') srcNode = observedOnly(o.from.service, o.from.tenant);
432
+ if (!dstNode && dstR.reason === 'no-service-node') dstNode = observedOnly(o.to.service ?? o.to.host, o.to.tenant);
433
+ if (srcNode && dstNode) {
434
+ const srcId = idOf(srcNode), dstId = idOf(dstNode);
435
+ const inferred = srcR.rule === 'R4' || dstR.rule === 'R4';
436
+ const stat = staticBetween(srcId, dstId);
437
+ if (o.outcome === 'ok') {
438
+ const basis = inferred ? (srcR.rule === 'R4' ? srcR.basis : dstR.basis) : null;
439
+ const key = `${inferred ? `I:${basis}` : 'O'}\n${srcId}\n${dstId}`;
440
+ const a = bump(key, { kind: inferred ? 'inferred' : 'runtime', relation: 'calls', from: srcId, to: dstId, rule: inferred ? 'R4' : 'R1', basis, corroborates: new Set(), contradicts: new Set() }, o);
441
+ for (const e of stat) { if (e.pathState !== 'blocked') { a.corroborates.add(e.id); touchedStatic.add(e.id); } }
442
+ for (const e of graph.edges.filter(x => x.provenance === 'static-config' && x.from === srcId && x.to === dstId && x.pathState === 'blocked')) { a.contradicts.add(e.id); conflicts.push({ code: 'observed-over-blocked-path', edgeId: e.id, observationId: o.id }); }
443
+ } else {
444
+ for (const e of stat) deniedStatic.add(e.id);
445
+ }
446
+ matchedAny = true;
447
+ }
448
+ }
449
+
450
+ // R3: identity
451
+ if (o.from.identity && srcNode && o.outcome === 'ok') {
452
+ const srcId = idOf(srcNode);
453
+ const assumes = (outStatic.get(srcId) ?? []).filter(e => e.relation === 'assumes');
454
+ const named = assumes.find(e => { const n = nodesById.get(e.to); return n && (n.name === o.from.identity || n.name.endsWith(`/${o.from.identity}`)); });
455
+ if (named) {
456
+ const a = bump(`O\n${srcId}\n${named.to}\nassumes`, { kind: 'runtime', relation: 'assumes', from: srcId, to: named.to, rule: 'R3', basis: null, corroborates: new Set(), contradicts: new Set() }, o);
457
+ a.corroborates.add(named.id); touchedStatic.add(named.id);
458
+ matchedAny = true;
459
+ } else if (assumes.length) {
460
+ conflicts.push({ code: 'identity-mismatch', service: srcNode.name, observedIdentity: o.from.identity, configured: assumes.map(e => nodesById.get(e.to)?.name).sort(), observationId: o.id });
461
+ matchedAny = true;
462
+ }
463
+ }
464
+
465
+ // R2: route
466
+ if (o.route && (o.route.host || o.route.path) && dstNode && o.outcome === 'ok') {
467
+ const dstId = idOf(dstNode);
468
+ const routes = graph.nodes.filter(n => n.kind === 'route' && n.environment === env && (n.tenant === null || n.tenant === (o.to.tenant ?? null)) && (!o.route.host || n.attrs.host === o.route.host || n.attrs.host === '*') && (o.route.path === null || pathUnder(n.attrs.path, o.route.path)));
469
+ for (const r of routes) {
470
+ const chain = findPath(outStatic, r.id, dstId, { accept: (e) => e.relation === 'routes-to' && e.pathState !== 'blocked', maxHops: 4 });
471
+ if (chain) {
472
+ for (const e of chain) {
473
+ const a = bump(`O\n${e.from}\n${e.to}\nroutes-to`, { kind: 'runtime', relation: 'routes-to', from: e.from, to: e.to, rule: 'R2', basis: null, corroborates: new Set(), contradicts: new Set() }, o);
474
+ a.corroborates.add(e.id); touchedStatic.add(e.id);
475
+ }
476
+ matchedAny = true;
477
+ }
478
+ }
479
+ }
480
+ if (!matchedAny) {
481
+ const reason = !o.to.service && !o.to.host && !o.route ? 'no-destination' : (srcR.reason && !srcNode ? srcR.reason : dstR.reason) ?? 'no-rule-matched';
482
+ unmatched.push({ observationId: o.id, reason });
483
+ }
484
+ }
485
+
486
+ // Build observed / inferred edges
487
+ const edges = [];
488
+ for (const [key, a] of [...acc.entries()].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
489
+ const stale = nowMs - Date.parse(a.last) > staleAfterMs;
490
+ const e = {
491
+ relation: a.relation, from: a.from, to: a.to, environment: env, tenant: null, effect: 'none',
492
+ provenance: a.kind === 'runtime' ? 'runtime-observation' : 'inferred',
493
+ confidence: a.kind === 'inferred' ? 'low' : (a.allSampled || stale ? 'medium' : 'high'),
494
+ pathState: 'possible', observationInterval: null, completeness: null, sourceRevision: null, source: null,
495
+ discriminator: a.kind === 'inferred' ? `R4:${a.basis}` : a.rule, crossRepo: null,
496
+ attrs: { rule: a.rule, countBand: eventCountBand(a.count), ...(a.basis ? { basis: a.basis } : {}), ...(a.kind === 'runtime' && stale ? { stale: true } : {}) },
497
+ };
498
+ if (a.kind === 'runtime') {
499
+ e.pathState = 'runtime-supported';
500
+ e.observationInterval = { start: a.first, end: a.last };
501
+ e.completeness = a.allSampled ? 'sampled' : 'unknown'; // never 'complete'
502
+ }
503
+ e.id = edgeIdOf(e);
504
+ edges.push({ edge: e, acc: a, stale });
505
+ }
506
+
507
+ const staticCandidates = graph.edges.filter(e => e.provenance === 'static-config' && e.environment === env && COMM_RELATIONS.has(e.relation) && e.pathState !== 'blocked' && e.effect !== 'deny');
508
+ const missingEdgeIds = staticCandidates.filter(e => !touchedStatic.has(e.id)).map(e => e.id).sort();
509
+
510
+ return {
511
+ status: 'ok',
512
+ environment: env,
513
+ rules: CORRELATION_RULES,
514
+ window: set.window,
515
+ evaluatedAt: new Date(nowMs).toISOString(),
516
+ staleAfterMs,
517
+ observedNodes: [...newNodes.values()],
518
+ edges: edges.map(x => x.edge),
519
+ corroboration: Object.fromEntries(edges.map(x => [x.edge.id, { corroborates: [...x.acc.corroborates].sort(), contradicts: [...x.acc.contradicts].sort() }])),
520
+ conflicts,
521
+ unmatched,
522
+ outcomes,
523
+ coverage: {
524
+ // Observation is evidence of traffic, never proof that the window held all traffic.
525
+ trafficCoverage: 'not-established',
526
+ observed: edges.filter(x => x.edge.provenance === 'runtime-observation' && !x.acc.allSampled && !x.stale).map(x => x.edge.id).sort(),
527
+ sampled: edges.filter(x => x.acc.allSampled && x.edge.provenance === 'runtime-observation').map(x => x.edge.id).sort(),
528
+ stale: edges.filter(x => x.stale && x.edge.provenance === 'runtime-observation').map(x => x.edge.id).sort(),
529
+ missingStaticEdgeIds: missingEdgeIds,
530
+ deniedObservedStaticEdgeIds: [...deniedStatic].sort(),
531
+ },
532
+ };
533
+ }
534
+
535
+ /** Fold a correlation into a graph as a new validated graph. Static edges are untouched. */
536
+ export function applyCorrelation(graph, correlation) {
537
+ if (!correlation || correlation.status !== 'ok') return { ok: false, graph: null, errors: [{ code: 'RULE_VIOLATION', path: '', message: 'correlation is not usable' }] };
538
+ const nodes = [...graph.nodes, ...correlation.observedNodes];
539
+ const edges = [...graph.edges, ...correlation.edges];
540
+ return buildBoundaryGraph({ repository: graph.repository, revision: graph.revision, nodes, edges, gaps: graph.gaps, sources: graph.sources });
541
+ }
@@ -254,6 +254,25 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
254
254
  'rs-extract-httprequest': 'http-body',
255
255
  'rs-extract-request': 'http-body',
256
256
  'rs-closure-extract-request': 'http-body',
257
+ // QA-006: request-bound handler parameters with no annotation, and the Laravel request accessors. A bound parameter or a merged request
258
+ // bag (input/all/only/except/collect/str/string) can come from the query string or the form, so, like the Flask values bag above, it is
259
+ // classed `http-query`; the single-channel accessors keep their own channel.
260
+ 'cs-aspnet-implicit-action-param': 'http-query',
261
+ 'java-spring-implicit-mapped-param': 'http-query',
262
+ 'php-laravel-request-input': 'http-query',
263
+ 'php-laravel-request-all': 'http-query',
264
+ 'php-laravel-request-only': 'http-query',
265
+ 'php-laravel-request-except': 'http-query',
266
+ 'php-laravel-request-collect': 'http-query',
267
+ 'php-laravel-request-str': 'http-query',
268
+ 'php-laravel-request-string': 'http-query',
269
+ 'php-laravel-request-query': 'http-query',
270
+ 'php-laravel-request-post': 'http-body',
271
+ 'php-laravel-request-json': 'http-body',
272
+ 'php-laravel-request-getcontent': 'http-body',
273
+ 'php-laravel-request-cookie': 'http-cookie',
274
+ 'php-laravel-request-header': 'http-header',
275
+ 'php-laravel-request-file': 'http-upload',
257
276
  });
258
277
 
259
278
  // ─────────────────────────────────────────────────────────────────────────
package/src/mcp/CLAUDE.md CHANGED
@@ -18,7 +18,7 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
18
18
  | `read_scratchpad` | ✓ | paginated read of scratchpad files |
19
19
  | `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
20
20
  | `read_agents_memory` | ✓ | tail of `.agentic-security/AGENTS.md` |
21
- | `synthesize_sca_upgrade` | ✓ | runs an ecosystem dry-run (`npm install --dry-run` etc.); returns the upgrade plan; no writes |
21
+ | `synthesize_sca_upgrade` | ✓ | runs an ecosystem dry-run (`npm install --dry-run` etc.); returns the upgrade plan; no project writes, but the dry-run may reach a package registry, so the capability policy classifies it `external` (see `scanner/src/capabilities/tool-registry.js`) |
22
22
  | `apply_sca_upgrade` | ✗ | backs up manifests, runs the package manager, runs the project test command, restores manifests on test failure |
23
23
  | `query_triage_memory` | ✓ | reads past triage decisions (wont-fix/false-positive) by natural-language query |
24
24
  | `query_findings_memory` | ✓ | reads accumulated scan memory (findings + triage history + AGENTS.md) by natural-language query |
@@ -27,10 +27,12 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
27
27
  | `dataflow_get_node` | ✓ | as above, ID-scoped node lookup (via `server/routes.js`'s `handleNode`, reused unmodified) |
28
28
  | `dataflow_get_edge` | ✓ | as above, ID-scoped edge lookup (`handleEdge`) |
29
29
  | `dataflow_get_flow` | ✓ | as above, ID-scoped flow lookup (`handleFlow`), includes contributing node/edge canonical ids |
30
+ | `invariant_scenario_export` | ✓ | X-407: the bounded stateful scenarios for one executable business-logic contract as a reproducible package (digests, pinned fixture digest, replay manifests when a commit is given, the bounds applied, what could not be built). Reads a contract JSON, a fixture directory and an optional ledger from inside the session root (confined by `tools.js::_confine`, passed in to `invariant-tools.js` as a factory argument so the two files do not import each other); runs nothing, writes nothing. The fixture source is never returned, a scenario holding a secret-looking value is withheld (named by path, never by value), the serialized result is re-checked with `redact.js::redactSecretShapes` and BLOCKED if anything would be redacted (an edited scenario is a different scenario), and every export carries the bounded-sample, not-exhaustive statement. Gated by the `invariant-scenarios` feature (off by default, operator-only): with it off the tool is still listed and answers `status: 'disabled'`. Same function as the CLI's `invariants export` (`posture/invariants/export.js::exportFromFiles`) |
31
+ | `portfolio_progress` | ✓ | X-707: the coverage-aware progress view of a durable portfolio store (verified units, blocked/failed/canceled/stale units with reasons, per-repository coverage, remaining budget, pending human review, worker liveness with stale workers kept visible, and, when a findings file is given, aggregate findings deduplicated by stable identity with per-repository/environment evidence and separate affected releases). Reads a store, its scheduler ledger and optional budgets and findings files from inside the session root (confined by `tools.js::_confine`, passed to `portfolio-tools.js` as a factory argument); writes nothing; a corrupt store is an error, never a reset. A finished controller is reported as finished, never as passed. The serialized view is re-checked with `redact.js::redactSecretShapes` and BLOCKED if anything would be redacted. Gated by the `portfolio-assurance` feature (off by default): with it off the tool is still listed and answers `status: 'disabled'`. Same function as the CLI's `portfolio progress` (`posture/portfolio/progress.js::buildProgressView`) |
30
32
 
31
33
  **Dataflow-tools redaction scope (found by a post-Task-2 whole-branch security review, fixed same day):** the initial cut only redacted `evidence[].location.note`, which turned out to be fixture-only — the real graph-builder emitter never populates `.note` (it uses `{file,line}`). The real source-derived surfaces are `node.destination.raw`/`.literalValue` (lifted verbatim from a scanned call-site argument by `lineage/resolve-destination.js`) and `evidence[].claim`/`.snippet`. `dataflow-tools.js`'s `_redactNode`/`_redactEvidence`/`_redactGraph` now cover all of them, applied on `dataflow_get_graph` (full graph) and `dataflow_get_node` (single node); edges and flows carry only `evidenceRefs` (id strings), never embedded evidence or destination-shaped fields, so they need no redaction pass. `dataflow-tools.js` was also missing from `server.js`'s `CODE_FINGERPRINT` file list (OWASP MCP04/MCP09) — added.
32
34
 
33
- **21 tools, not 12** — this table previously stopped at 12 and the count quoted elsewhere (root `CLAUDE.md`, the non-Claude plugin manifests) said "Six." Re-derive with `grep -c "name: '" scanner/src/mcp/tools.js scanner/src/mcp/dataflow-tools.js | awk -F: '{s+=$2} END{print s}'` — **not** `tools.js` alone, which now undercounts by 4: the 4 `dataflow_*` tools are defined in `dataflow-tools.js` and only imported into `tools.js`'s `ALL_TOOLS`. If a future tool file follows this same reused-adapter-module pattern, extend the file list rather than trusting a single-file grep again.
35
+ **23 tools, not 12** — this table previously stopped at 12 and the count quoted elsewhere (root `CLAUDE.md`, the non-Claude plugin manifests) said "Six." Re-derive with `grep -c "name: '" scanner/src/mcp/tools.js scanner/src/mcp/dataflow-tools.js | awk -F: '{s+=$2} END{print s}'` — **not** `tools.js` alone, which now undercounts by 5: the 4 `dataflow_*` tools are defined in `dataflow-tools.js`, `invariant_scenario_export` in `invariant-tools.js` and `portfolio_progress` in `portfolio-tools.js`, and only imported/constructed into `tools.js`'s `ALL_TOOLS` (re-derive with `grep -c "name: '" scanner/src/mcp/tools.js scanner/src/mcp/dataflow-tools.js scanner/src/mcp/invariant-tools.js scanner/src/mcp/portfolio-tools.js`). If a future tool file follows this same reused-adapter-module pattern, extend the file list rather than trusting a single-file grep again; `server.js`'s `CODE_FINGERPRINT` list must name it too.
34
36
 
35
37
  **Two write tools, not one.** `apply_fix` and `apply_sca_upgrade` both write; `verify_fix` also writes (see its row above) though not to the target project's own files. `apply_fix` additionally requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list; `apply_sca_upgrade` requires `confirm:true` and gates on its own test-restore cycle.
36
38
 
@@ -48,6 +50,7 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
48
50
  | Secret redaction on findings | `redact.js` — applied to snippet/description/title/vuln/remediation/trace |
49
51
  | Audit log | `audit.js` — NDJSON, hash-chained, at `.agentic-security/mcp-audit.log`. Set `$AGENTIC_SECURITY_AUDIT_WEBHOOK=<url>` to also fire-and-forget POST every entry to a remote witness — closes the full-file-rewrite blind spot. Failures land in `mcp-audit.remote-errors.log` and never block a tool call. |
50
52
  | Kill switch | `AGENTIC_SECURITY_MCP_DISABLED=1` refuses every `tools/call` |
53
+ | Capability gate (X-505) | `server.js` asks `capabilities/tool-gate.js` before every handler, for the task identity bound by the operator via `createServer({capabilityPolicy})` (or refuses mutating/external tools when the `capability-enforcement` feature is on with no task bound). Inactive otherwise: no policy and the feature off is byte-for-byte the previous behaviour. Every tool is classified in `capabilities/tool-registry.js` (a new tool without an entry is refused and fails `test/capabilities/tools.test.js`). In-process policy: recorded `in-process-policy`, never `enforced`. A denial returns `{blocked, code, missing, proposedChange}` and never prompts |
51
54
  | Stdio DoS | `stdio.js` — 4MB per-line cap, 8MB buffer cap, drop-until-newline overflow |
52
55
  | Code fingerprint | `server.js::CODE_FINGERPRINT` — SHA-256 of MCP source files, surfaced in `initialize` |
53
56
  | Version | `server.js::SERVER_VERSION` — read from `../../package.json` at module load. **Not** a hardcoded literal. |