@clear-capabilities/agentic-security-scanner 0.175.0 → 0.177.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 (110) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/agentic-security.js +206 -28
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2066.index.js +851 -0
  5. package/dist/2199.index.js +1 -1
  6. package/dist/2584.index.js +3350 -0
  7. package/dist/2923.index.js +13 -0
  8. package/dist/2946.index.js +81 -0
  9. package/dist/3196.index.js +27 -0
  10. package/dist/3727.index.js +292 -0
  11. package/dist/37d64df006576248d0d8.json +286 -0
  12. package/dist/4168.index.js +509 -0
  13. package/dist/4281.index.js +993 -0
  14. package/dist/5099.index.js +96 -0
  15. package/dist/5456.index.js +291 -0
  16. package/dist/5616.index.js +1471 -0
  17. package/dist/5620.index.js +2494 -0
  18. package/dist/5817.index.js +714 -0
  19. package/dist/5937.index.js +672 -0
  20. package/dist/6333.index.js +98 -0
  21. package/dist/6655.index.js +3221 -0
  22. package/dist/7838.index.js +491 -974
  23. package/dist/818.index.js +4 -3
  24. package/dist/83.index.js +159 -0
  25. package/dist/8551.index.js +82 -0
  26. package/dist/8633.index.js +93 -0
  27. package/dist/8714.index.js +76 -76
  28. package/dist/8825.index.js +84 -0
  29. package/dist/932.index.js +186 -0
  30. package/dist/9560.index.js +358 -19
  31. package/dist/9800.index.js +6 -1
  32. package/dist/agentic-security.mjs +10 -10
  33. package/dist/agentic-security.mjs.sha256 +1 -1
  34. package/dist/b4289e728659450b5e19.js +32 -0
  35. package/package.json +8 -4
  36. package/src/artifact/CLAUDE.md +4 -0
  37. package/src/artifact/artifact-evidence.js +63 -0
  38. package/src/artifact/cosign.js +61 -0
  39. package/src/artifact/engine-source.js +72 -0
  40. package/src/artifact/oci-layout.js +47 -0
  41. package/src/artifact/registry.js +213 -0
  42. package/src/capabilities/tool-registry.js +2 -0
  43. package/src/lineage/CLAUDE.md +4 -0
  44. package/src/lineage/identity-token.js +11 -0
  45. package/src/lineage/observation-adapters.js +13 -0
  46. package/src/lineage/runtime-edge-states.js +139 -0
  47. package/src/lineage/runtime-explorer-view.js +82 -0
  48. package/src/lineage/runtime-observation.js +11 -0
  49. package/src/mcp/CLAUDE.md +3 -1
  50. package/src/mcp/proof-tools.js +132 -0
  51. package/src/mcp/server.js +1 -1
  52. package/src/mcp/tools.js +3 -1
  53. package/src/posture/CLAUDE.md +23 -0
  54. package/src/posture/artifact-registry.js +4 -0
  55. package/src/posture/custom-rules.js +54 -8
  56. package/src/posture/dnr/attack-facts.js +31 -0
  57. package/src/posture/dnr/cli.js +138 -0
  58. package/src/posture/dnr/detection-ir.js +123 -0
  59. package/src/posture/dnr/exploit-correlation.js +25 -0
  60. package/src/posture/dnr/exposure-window.js +134 -0
  61. package/src/posture/dnr/hunt.js +84 -0
  62. package/src/posture/dnr/log-reader.js +189 -0
  63. package/src/posture/dnr/matchers.js +90 -0
  64. package/src/posture/dnr/outputs.js +262 -0
  65. package/src/posture/dnr/payload-classes.js +120 -0
  66. package/src/posture/dnr/redact.js +25 -0
  67. package/src/posture/dnr/store.js +57 -0
  68. package/src/posture/dnr/validate.js +64 -0
  69. package/src/posture/dnr/view.js +51 -0
  70. package/src/posture/evaluation/lab/arm-ollama.js +88 -0
  71. package/src/posture/evaluation/lab/arms.js +142 -0
  72. package/src/posture/evaluation/lab/cli.js +135 -0
  73. package/src/posture/evaluation/lab/dataset.js +139 -0
  74. package/src/posture/evaluation/lab/materialize.js +44 -0
  75. package/src/posture/evaluation/lab/neutralize.js +144 -0
  76. package/src/posture/evaluation/lab/report.js +194 -0
  77. package/src/posture/evaluation/lab/run.js +225 -0
  78. package/src/posture/evaluation/lab/score.js +135 -0
  79. package/src/posture/evaluation/lab/scoreboard.js +48 -0
  80. package/src/posture/evaluation/lab/stats.js +41 -0
  81. package/src/posture/evaluation/lab/store.js +53 -0
  82. package/src/posture/fleet/cli.js +260 -63
  83. package/src/posture/fleet/discover-edges.js +135 -0
  84. package/src/posture/fleet/enterprise-graph.js +189 -0
  85. package/src/posture/fleet/fleet-graph.js +9 -4
  86. package/src/posture/fleet/fleet-manifest.js +50 -5
  87. package/src/posture/fleet/job-runner.js +153 -0
  88. package/src/posture/fleet/job-store.js +138 -0
  89. package/src/posture/fleet/lineage-summary.js +55 -0
  90. package/src/posture/fleet/org-health.js +56 -0
  91. package/src/posture/fleet/scan-cache.js +112 -0
  92. package/src/posture/fleet/scan-child.js +32 -0
  93. package/src/posture/proof-fabric/cli.js +34 -6
  94. package/src/posture/proof-fabric/container.js +57 -0
  95. package/src/posture/proof-fabric/fix-proof.js +2 -1
  96. package/src/posture/proof-fabric/generate-python.js +129 -0
  97. package/src/posture/proof-fabric/generate.js +214 -0
  98. package/src/posture/proof-fabric/reproducer.js +3 -0
  99. package/src/posture/proof-fabric/runner.js +44 -13
  100. package/src/posture/proof-fabric/siblings.js +77 -0
  101. package/src/posture/routing/policy-router.js +103 -0
  102. package/src/posture/rule-compiler/cli.js +101 -9
  103. package/src/posture/rule-compiler/corpus.js +13 -9
  104. package/src/posture/rule-compiler/ir.js +52 -9
  105. package/src/posture/rule-compiler/mutation.js +72 -1
  106. package/src/posture/rule-compiler/promotion.js +21 -5
  107. package/src/posture/rule-compiler/proposal.js +137 -0
  108. package/src/posture/rule-compiler/regression.js +95 -0
  109. package/src/posture/rule-compiler/store.js +45 -1
  110. package/src/posture/rule-pack-signing.js +4 -2
@@ -0,0 +1,213 @@
1
+ // A minimal OCI Distribution (v2) client for ONE purpose: fetching a single image the operator named explicitly
2
+ // (`image scan --registry <ref>`), so it can be scanned like a local archive (PRD 5, requirement 1: "explicit remote OCI reference").
3
+ //
4
+ // It is deliberately small and deliberately strict. It is hostile-input handling first:
5
+ // * a reference is parsed, not interpolated: no scheme, no userinfo, no whitespace, no path tricks;
6
+ // * transport is HTTPS. Plain HTTP is accepted only for a loopback registry (a local test or a local mirror); anything else is refused;
7
+ // * every redirect is followed by hand, at most 5 hops, to an HTTPS (or loopback) target, and the credential is NOT sent to a host
8
+ // other than the registry's own, which is what keeps a blob redirected to a CDN from receiving it;
9
+ // * every body is digest-verified against what the manifest said, and size-capped while it streams, so a registry that lies about
10
+ // a size or sends endless bytes is cut off, not buffered;
11
+ // * the manifest a registry returns for a tag is hashed and checked against `Docker-Content-Digest` when one is sent, and for a
12
+ // digest reference against the reference itself;
13
+ // * credentials come from the environment or the Docker config for that registry only, are never logged, never put in an error,
14
+ // and live in an object that refuses to serialize.
15
+ // Nothing is executed and nothing is extracted to disk; the result is an OCI-layout archive in memory for `image-archive.js`.
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { sha256Hex, buildOciArchive } from './oci-layout.js';
20
+
21
+ export class RegistryError extends Error {
22
+ constructor(message, code) { super(message); this.name = 'RegistryError'; this.code = code; }
23
+ }
24
+
25
+ export const REGISTRY_LIMITS = Object.freeze({ maxManifestBytes: 1024 * 1024, maxConfigBytes: 4 * 1024 * 1024, maxLayerBytes: 512 * 1024 * 1024, maxTotalBytes: 1024 * 1024 * 1024, maxLayers: 128, maxRedirects: 5, requestTimeoutMs: 60_000, totalTimeoutMs: 15 * 60_000 });
26
+
27
+ const MANIFEST_ACCEPT = [
28
+ 'application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json',
29
+ 'application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.v2+json',
30
+ ].join(', ');
31
+ const INDEX_TYPES = new Set(['application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json']);
32
+ const MANIFEST_TYPES = new Set(['application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.v2+json']);
33
+ const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
34
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
35
+
36
+ /** Credentials that cannot be printed: `JSON.stringify`, `String()` and `util.inspect` all give a placeholder. */
37
+ class Credentials {
38
+ #basic = null; #bearer = null;
39
+ constructor({ user, password, token }) { if (token) this.#bearer = token; else if (user && password) this.#basic = Buffer.from(`${user}:${password}`).toString('base64'); }
40
+ get present() { return !!(this.#basic || this.#bearer); }
41
+ basicHeader() { return this.#basic ? `Basic ${this.#basic}` : null; }
42
+ bearerHeader() { return this.#bearer ? `Bearer ${this.#bearer}` : null; }
43
+ toJSON() { return '[credentials]'; }
44
+ toString() { return '[credentials]'; }
45
+ [Symbol.for('nodejs.util.inspect.custom')]() { return '[credentials]'; }
46
+ }
47
+
48
+ /** The registry's own credentials: environment first, then the Docker config entry for exactly this registry. Never another host's. */
49
+ export function resolveCredentials(registry, { env = process.env, dockerConfig = path.join(os.homedir(), '.docker', 'config.json') } = {}) {
50
+ if (env.AGENTIC_SECURITY_REGISTRY_TOKEN) return new Credentials({ token: env.AGENTIC_SECURITY_REGISTRY_TOKEN });
51
+ if (env.AGENTIC_SECURITY_REGISTRY_USER && env.AGENTIC_SECURITY_REGISTRY_PASSWORD) return new Credentials({ user: env.AGENTIC_SECURITY_REGISTRY_USER, password: env.AGENTIC_SECURITY_REGISTRY_PASSWORD });
52
+ try {
53
+ const cfg = JSON.parse(fs.readFileSync(dockerConfig, 'utf8'));
54
+ const keys = [registry, `https://${registry}`, ...(registry === 'registry-1.docker.io' ? ['https://index.docker.io/v1/', 'docker.io'] : [])];
55
+ for (const k of keys) {
56
+ const auth = cfg.auths?.[k]?.auth;
57
+ if (typeof auth === 'string') { const [user, ...rest] = Buffer.from(auth, 'base64').toString('utf8').split(':'); if (user && rest.length) return new Credentials({ user, password: rest.join(':') }); }
58
+ }
59
+ } catch { /* no usable Docker config */ }
60
+ return new Credentials({});
61
+ }
62
+
63
+ /** @returns {{registry: string, repository: string, reference: string, isDigest: boolean, loopback: boolean}} */
64
+ export function parseReference(input) {
65
+ const raw = String(input ?? '');
66
+ if (!raw || raw.length > 300 || /[\s\0\\]/.test(raw) || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.includes('@') && !/@sha256:[0-9a-f]{64}$/.test(raw) || raw.includes('..') || raw.startsWith('-')) throw new RegistryError('not a valid image reference', 'bad_reference');
67
+ let rest = raw; let reference = 'latest'; let isDigest = false;
68
+ const at = rest.indexOf('@');
69
+ if (at !== -1) { reference = rest.slice(at + 1); rest = rest.slice(0, at); isDigest = true; }
70
+ const parts = rest.split('/');
71
+ let registry = 'registry-1.docker.io';
72
+ if (parts.length > 1 && (parts[0].includes('.') || parts[0].includes(':') || parts[0] === 'localhost')) registry = parts.shift();
73
+ let repo = parts.join('/');
74
+ if (!isDigest) { const colon = repo.lastIndexOf(':'); if (colon !== -1) { reference = repo.slice(colon + 1); repo = repo.slice(0, colon); } }
75
+ if (registry === 'registry-1.docker.io' && !repo.includes('/')) repo = `library/${repo}`;
76
+ if (registry === 'docker.io' || registry === 'index.docker.io') registry = 'registry-1.docker.io';
77
+ if (!/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/.test(repo)) throw new RegistryError('not a valid repository name', 'bad_reference');
78
+ if (!isDigest && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(reference)) throw new RegistryError('not a valid tag', 'bad_reference');
79
+ if (isDigest && !DIGEST_RE.test(reference)) throw new RegistryError('only sha256 digests are supported', 'bad_reference');
80
+ if (!/^(?:\[::1\]|[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?)(?::\d{1,5})?$/.test(registry)) throw new RegistryError('not a valid registry host', 'bad_reference');
81
+ const host = registry.replace(/:\d+$/, '');
82
+ return { registry, repository: repo, reference, isDigest, loopback: LOOPBACK_HOST.test(host) };
83
+ }
84
+
85
+ const scrubUrl = (u) => { try { const x = new URL(u); return `${x.protocol}//${x.host}${x.pathname}`; } catch { return '[url]'; } };
86
+
87
+ export async function fetchImage(input, { platform = 'linux/amd64', credentials = null, fetchImpl = globalThis.fetch, limits = REGISTRY_LIMITS, signal = null, env = process.env, allowLoopbackHttp = false } = {}) {
88
+ const ref = parseReference(input);
89
+ const L = { ...REGISTRY_LIMITS, ...limits };
90
+ const creds = credentials ?? resolveCredentials(ref.registry, { env });
91
+ const scheme = ref.loopback && allowLoopbackHttp ? 'http' : 'https';
92
+ const origin = `${scheme}://${ref.registry}`;
93
+ const deadline = Date.now() + L.totalTimeoutMs;
94
+ let authHeader = null; let total = 0;
95
+ const allowedUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' || (x.protocol === 'http:' && LOOPBACK_HOST.test(x.hostname)); } catch { return false; } };
96
+
97
+ async function request(url, { accept, auth = true, maxBytes, expectDigest = null } = {}) {
98
+ let current = url; let sendAuth = auth;
99
+ for (let hop = 0; hop <= L.maxRedirects; hop++) {
100
+ if (!allowedUrl(current)) throw new RegistryError(`refusing a non-HTTPS target: ${scrubUrl(current)}`, 'insecure_transport');
101
+ if (Date.now() > deadline) throw new RegistryError('the total time budget was exhausted', 'timeout');
102
+ if (signal?.aborted) throw new RegistryError('the request was cancelled', 'network');
103
+ const sameOrigin = new URL(current).origin === new URL(origin).origin;
104
+ const headers = { 'user-agent': 'agentic-security-image-scan', ...(accept ? { accept } : {}) };
105
+ if (sendAuth && sameOrigin) { const h = authHeader ?? creds.bearerHeader(); if (h) headers.authorization = h; }
106
+ const ctl = new AbortController();
107
+ const t = setTimeout(() => ctl.abort(), L.requestTimeoutMs);
108
+ const onAbort = () => ctl.abort(); signal?.addEventListener('abort', onAbort, { once: true });
109
+ let res;
110
+ try { res = await fetchImpl(current, { headers, redirect: 'manual', signal: ctl.signal }); }
111
+ catch (e) { throw new RegistryError(ctl.signal.aborted ? 'the request timed out or was cancelled' : `request failed: ${String(e.cause?.code ?? e.code ?? e.message).slice(0, 80)}`, 'network'); }
112
+ finally { clearTimeout(t); signal?.removeEventListener('abort', onAbort); }
113
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
114
+ const next = new URL(res.headers.get('location'), current).toString();
115
+ if (new URL(next).origin !== new URL(origin).origin) sendAuth = false; // never carry the registry credential to another host
116
+ current = next; continue;
117
+ }
118
+ if (res.status === 401 && sameOrigin && !authHeader) return { status: 401, challenge: res.headers.get('www-authenticate') ?? '' };
119
+ if (res.status === 404) throw new RegistryError('not found in the registry', 'not_found');
120
+ if (res.status === 401 || res.status === 403) throw new RegistryError('the registry refused access (authentication required or not permitted)', 'unauthorized');
121
+ if (!res.ok) throw new RegistryError(`the registry answered ${res.status}`, 'registry_error');
122
+ const declared = Number(res.headers.get('content-length'));
123
+ if (Number.isFinite(declared) && declared > maxBytes) throw new RegistryError(`a response larger than the ${maxBytes}-byte limit`, 'too_large');
124
+ const chunks = []; let got = 0;
125
+ for await (const c of res.body) {
126
+ got += c.length; total += c.length;
127
+ if (got > maxBytes || total > L.maxTotalBytes) { try { await res.body.cancel(); } catch { /* closing */ } throw new RegistryError('the response exceeded its size limit', 'too_large'); }
128
+ chunks.push(c);
129
+ }
130
+ const body = Buffer.concat(chunks);
131
+ if (expectDigest && `sha256:${sha256Hex(body)}` !== expectDigest) throw new RegistryError('a downloaded blob does not match its digest', 'digest_mismatch');
132
+ return { status: res.status, body, headers: res.headers };
133
+ }
134
+ throw new RegistryError('too many redirects', 'redirect_loop');
135
+ }
136
+
137
+ async function authed(url, opts) {
138
+ let r = await request(url, opts);
139
+ if (r.status !== 401) return r;
140
+ const basic = creds.basicHeader();
141
+ if (/^Basic\b/i.test(r.challenge)) {
142
+ if (!basic) throw new RegistryError('the registry requires authentication and none is configured', 'unauthorized');
143
+ authHeader = basic;
144
+ r = await request(url, opts);
145
+ if (r.status === 401) throw new RegistryError('the registry rejected the credentials', 'unauthorized');
146
+ return r;
147
+ }
148
+ const m = /^Bearer\s+(.*)$/i.exec(r.challenge);
149
+ if (!m) throw new RegistryError(basic ? 'the registry asked for a scheme this client does not support' : 'the registry requires authentication and none is configured', 'unauthorized');
150
+ const params = Object.fromEntries([...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((x) => [x[1], x[2]]));
151
+ if (!params.realm) throw new RegistryError('the authentication challenge named no token endpoint', 'unauthorized');
152
+ const tokenUrl = new URL(params.realm);
153
+ if (params.service) tokenUrl.searchParams.set('service', params.service);
154
+ tokenUrl.searchParams.set('scope', params.scope || `repository:${ref.repository}:pull`);
155
+ if (!allowedUrl(tokenUrl.toString())) throw new RegistryError('refusing a non-HTTPS token endpoint', 'insecure_transport');
156
+ // the token endpoint may be a different host (Docker Hub's is); the registry credential goes only to the host the challenge named, over HTTPS
157
+ const headers = { 'user-agent': 'agentic-security-image-scan', ...(basic ? { authorization: basic } : {}) };
158
+ let tr;
159
+ try { tr = await fetchImpl(tokenUrl.toString(), { headers, redirect: 'error' }); } catch { throw new RegistryError('the token request failed', 'network'); }
160
+ if (!tr.ok) throw new RegistryError(`the token endpoint answered ${tr.status}`, 'unauthorized');
161
+ let tj; try { tj = JSON.parse((await tr.text()).slice(0, 65536)); } catch { throw new RegistryError('the token endpoint returned something that is not JSON', 'unauthorized'); }
162
+ const token = tj.token ?? tj.access_token;
163
+ if (typeof token !== 'string' || !token) throw new RegistryError('the token endpoint returned no token', 'unauthorized');
164
+ authHeader = `Bearer ${token}`;
165
+ r = await request(url, opts);
166
+ if (r.status === 401) throw new RegistryError('the registry rejected the token', 'unauthorized');
167
+ return r;
168
+ }
169
+
170
+ const base = `${origin}/v2/${ref.repository}`;
171
+ let m = await authed(`${base}/manifests/${ref.reference}`, { accept: MANIFEST_ACCEPT, maxBytes: L.maxManifestBytes, ...(ref.isDigest ? { expectDigest: ref.reference } : {}) });
172
+ let manifestBytes = m.body;
173
+ const topDigest = `sha256:${sha256Hex(m.body)}`;
174
+ const headerDigest = m.headers.get('docker-content-digest');
175
+ if (headerDigest && headerDigest !== `sha256:${sha256Hex(manifestBytes)}`) throw new RegistryError('the manifest does not match the digest the registry reported for it', 'digest_mismatch');
176
+ let doc = parseJson(manifestBytes, 'manifest');
177
+ let mediaType = doc.mediaType ?? m.headers.get('content-type')?.split(';')[0];
178
+ let selected = null;
179
+ if (INDEX_TYPES.has(mediaType) || Array.isArray(doc.manifests)) {
180
+ const [os_, arch] = platform.split('/');
181
+ const entries = (doc.manifests ?? []).filter((e) => e?.platform && e.platform.os === os_ && e.platform.architecture === arch && DIGEST_RE.test(String(e.digest)));
182
+ if (!entries.length) throw new RegistryError(`the image has no ${platform} variant (available: ${(doc.manifests ?? []).map((e) => `${e?.platform?.os}/${e?.platform?.architecture}`).filter((x) => !x.includes('undefined')).slice(0, 12).join(', ') || 'none'})`, 'platform_not_found');
183
+ selected = entries[0];
184
+ m = await authed(`${base}/manifests/${selected.digest}`, { accept: MANIFEST_ACCEPT, maxBytes: L.maxManifestBytes, expectDigest: selected.digest });
185
+ manifestBytes = m.body; doc = parseJson(manifestBytes, 'platform manifest'); mediaType = doc.mediaType ?? selected.mediaType;
186
+ }
187
+ if (!MANIFEST_TYPES.has(mediaType) && !(Array.isArray(doc.layers) && doc.config)) throw new RegistryError('the registry returned a manifest of a kind this client does not read', 'unsupported_manifest');
188
+ if (!doc.config || !DIGEST_RE.test(String(doc.config.digest)) || !Array.isArray(doc.layers)) throw new RegistryError('the manifest is malformed', 'malformed_manifest');
189
+ if (doc.layers.length > L.maxLayers) throw new RegistryError(`the image has more than ${L.maxLayers} layers`, 'too_large');
190
+ for (const l of doc.layers) {
191
+ if (!DIGEST_RE.test(String(l.digest))) throw new RegistryError('a layer has no sha256 digest', 'malformed_manifest');
192
+ if (/foreign|nondistributable/i.test(String(l.mediaType)) || Array.isArray(l.urls)) throw new RegistryError('the image has a foreign (externally hosted) layer, which is not fetched', 'unsupported_manifest');
193
+ if (!Number.isInteger(l.size) || l.size < 0 || l.size > L.maxLayerBytes) throw new RegistryError(`a layer is larger than the ${L.maxLayerBytes}-byte limit`, 'too_large');
194
+ }
195
+ const blob = async (desc, cap) => {
196
+ if (Number.isInteger(desc.size) && desc.size > cap) throw new RegistryError('a blob is larger than its limit', 'too_large');
197
+ const r = await authed(`${base}/blobs/${desc.digest}`, { maxBytes: cap, expectDigest: desc.digest });
198
+ if (Number.isInteger(desc.size) && r.body.length !== desc.size) throw new RegistryError('a blob is not the size its manifest declared', 'digest_mismatch');
199
+ return r.body;
200
+ };
201
+ const config = await blob(doc.config, L.maxConfigBytes);
202
+ const layers = [];
203
+ for (const l of doc.layers) layers.push(await blob(l, L.maxLayerBytes));
204
+ const built = buildOciArchive({ manifest: manifestBytes, config, layers, mediaType: MANIFEST_TYPES.has(mediaType) ? mediaType : 'application/vnd.oci.image.manifest.v1+json' });
205
+ return {
206
+ tar: built.tar, manifestDigest: built.manifestDigest,
207
+ source: { kind: 'registry', registry: ref.registry, repository: ref.repository, reference: ref.reference, platform: selected ? `${selected.platform.os}/${selected.platform.architecture}` : null, indexDigest: selected ? topDigest : null, bytes: total, authenticated: creds.present },
208
+ };
209
+ }
210
+
211
+ function parseJson(buf, what) {
212
+ try { const j = JSON.parse(buf.toString('utf8')); if (!j || typeof j !== 'object' || Array.isArray(j)) throw new Error('not an object'); return j; } catch { throw new RegistryError(`the ${what} is not valid JSON`, 'malformed_manifest'); }
213
+ }
@@ -43,6 +43,8 @@ export const TOOL_CAPABILITIES = Object.freeze({
43
43
  portfolio_progress: read('builds a read-only progress view from a portfolio store, ledger and optional inputs, confined to the session root'),
44
44
  apply_fix: mutating('writes verified patches into the project'),
45
45
  verify_fix: mutating('runs the project linter and tests and appends fix metrics'),
46
+ prove_finding: mutating('runs a reproducer in the confined proof environment and writes a proof record'),
47
+ prove_fix: mutating('replays the original exploit against a copy of the patched sources and runs the project tests; writes nothing to the project'),
46
48
  append_scratchpad: mutating('writes under the agent scratchpad'),
47
49
  append_agents_memory: mutating('appends to the continual-learning file'),
48
50
  synthesize_sca_upgrade: external('runs a package manager dry-run, which may reach a registry'),
@@ -1259,3 +1259,7 @@ synthetic root `$hs`, seeds record-parameter sources, and treats `length`/`null`
1259
1259
  field-to-store flows from `language/nix-privacy.js` (a personal-data `config.<module>.<field>` interpolated into another option;
1260
1260
  hashed, measured or compared references are protected and make no flow). Coverage reports `unknown` for both languages: no
1261
1261
  taint-recall tier is borrowed. See `scanner/src/language/CLAUDE.md`.
1262
+
1263
+ ## Runtime twin additions (PRD 2 gap closure)
1264
+
1265
+ `runtime-edge-states.js` derives the six edge states and the identity / absent-from-static queries from the existing correlation, as a pure read: nothing is written into the static graph. `RUNTIME_EXPLOIT_CORRELATED` is never inferred from observation; it needs validated operator evidence. `identity-token.js` supplies a keyed-hash tokenizer injected into the adapters through `context.tokenizeIdentity`; the adapters stay zero-import and drop identity when no tokenizer is given. The attribute allowlist test pins `identity.token`; widen it deliberately, never by loosening the grammar.
@@ -0,0 +1,11 @@
1
+ // A keyed-hash token for an authenticated identity (PRD 2 section 1, "authenticated identity"). The observation contract is closed-world
2
+ // and refuses raw identities, so what is stored is `idt_` + 16 hex of an HMAC under the per-install key:
3
+ // * the same identity always yields the same token on this install, so "the same identity used these paths" can be answered;
4
+ // * without the install key the token cannot be reversed or confirmed by guessing usernames (an unkeyed hash could);
5
+ // * tokens from two installs do not match, which is deliberate: they are not a portable identifier.
6
+ // It is a pseudonym, not anonymity: someone who holds the install key and a candidate username can confirm a match.
7
+ import { signLastScan } from '../posture/integrity.js';
8
+
9
+ export function makeIdentityTokenizer() {
10
+ return (raw) => (typeof raw === 'string' && raw.length > 0 ? `idt_${signLastScan(`identity-token:v1:${raw}`).slice(0, 16)}` : null);
11
+ }
@@ -305,6 +305,16 @@ function _splitHostPort(v) {
305
305
  return { host: _safeIdentifierOrNull(host), port };
306
306
  }
307
307
 
308
+ // Identity is recorded ONLY as a keyed-hash token, and only when the importer supplied the tokenizer (`context.tokenizeIdentity`).
309
+ // This module stays zero-import and holds no key: with no tokenizer, identity is dropped, never stored raw and never hashed without a
310
+ // key (an unkeyed hash of a username is reversible by guessing). A raw value that is not a plausible identity string is dropped too.
311
+ function _identityToken(context, raw) {
312
+ if (typeof context?.tokenizeIdentity !== 'function') return null;
313
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 256 || /[\u0000-\u001f]/.test(raw)) return null;
314
+ const t = context.tokenizeIdentity(raw);
315
+ return typeof t === 'string' && /^idt_[0-9a-f]{16}$/.test(t) ? t : null;
316
+ }
317
+
308
318
  function _draftBase(context, environmentOverride) {
309
319
  const { version, adapter, source, environment, windowStart, windowEnd, importedAt, retention } = context;
310
320
  return {
@@ -398,6 +408,7 @@ export function parseOtelJsonObservations(text, context) {
398
408
  if (storeService) attributes['destination.service'] = storeService;
399
409
 
400
410
  if (Object.keys(attributes).length === 0) continue; // nothing safe to report for this span
411
+ { const idt = _identityToken(context, a['enduser.id']); if (idt) attributes['identity.token'] = idt; }
401
412
 
402
413
  const at = _isoFromUnixNano(span.startTimeUnixNano) ?? context.windowStart;
403
414
  drafts.push({
@@ -547,6 +558,7 @@ export function parseK8sAuditLogObservations(text, context) {
547
558
  }
548
559
 
549
560
  if (Object.keys(attributes).length === 0) continue;
561
+ { const idt = _identityToken(context, _isPlainObject(record.user) ? record.user.username : undefined); if (idt) attributes['identity.token'] = idt; }
550
562
 
551
563
  const observedAt = typeof record.requestReceivedTimestamp === 'string' && Number.isFinite(Date.parse(record.requestReceivedTimestamp))
552
564
  ? record.requestReceivedTimestamp
@@ -610,6 +622,7 @@ export function parseHttpAccessLogObservations(text, context) {
610
622
  if (scheme) attributes['destination.scheme'] = scheme;
611
623
 
612
624
  if (Object.keys(attributes).length === 0) continue;
625
+ { const idt = _identityToken(context, record.user ?? record.remote_user ?? record.authenticated_user); if (idt) attributes['identity.token'] = idt; }
613
626
 
614
627
  const ts = record.timestamp ?? record.time;
615
628
  const observedAt = typeof ts === 'string' && Number.isFinite(Date.parse(ts)) ? ts : context.windowStart;
@@ -0,0 +1,139 @@
1
+ // Edge states and the identity-aware / absent-from-static queries of the Runtime-Corroborated Security Twin (PRD 2: "Edge States",
2
+ // section 6). Pure: no fs, no I/O, never throws, never mutates the graph. It READS what `observation-correlation.js` and the
3
+ // observation store already hold; it adds no second matching engine and writes nothing into the static graph (the overlay stays an
4
+ // overlay).
5
+ //
6
+ // The six states, and when each is ALLOWED to appear:
7
+ // STATIC_ONLY no telemetry was consulted at all (not "nothing seen": nothing was looked at)
8
+ // RUNTIME_NOT_OBSERVED telemetry was consulted for this environment/window and no trusted observation maps to the edge.
9
+ // Never "unreachable": the window may be short, the exporter partial, the mapping weak.
10
+ // RUNTIME_OBSERVED at least one high- or medium-confidence observation maps to the edge
11
+ // RUNTIME_BLOCKED observations map to the edge and every decision recorded for them was `denied` (the path was
12
+ // attempted and refused); an edge with both allowed and denied observations carries OBSERVED and BLOCKED
13
+ // RUNTIME_POLICY_VIOLATION the edge was observed (not only denied) AND a flow through it is `prohibited` by the privacy policy
14
+ // RUNTIME_EXPLOIT_CORRELATED the edge was observed AND the operator supplied explicit evidence naming this edge/flow/node as the
15
+ // target of a proven exploit. Never inferred from observation alone: seeing traffic is not seeing an attack.
16
+ // Low-confidence and ambiguous observations (PRD: "low-confidence observations remain unmatched") never create a state; they are
17
+ // counted in `uncertainObservations` so the weakness is visible instead of silently dropped.
18
+ export const EDGE_STATES = Object.freeze(['STATIC_ONLY', 'RUNTIME_NOT_OBSERVED', 'RUNTIME_OBSERVED', 'RUNTIME_BLOCKED', 'RUNTIME_POLICY_VIOLATION', 'RUNTIME_EXPLOIT_CORRELATED']);
19
+ const SEVERITY_ORDER = ['RUNTIME_EXPLOIT_CORRELATED', 'RUNTIME_POLICY_VIOLATION', 'RUNTIME_BLOCKED', 'RUNTIME_OBSERVED', 'RUNTIME_NOT_OBSERVED', 'STATIC_ONLY'];
20
+ const TRUSTED = new Set(['high', 'medium']);
21
+
22
+ const arr = (v) => (Array.isArray(v) ? v : []);
23
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
24
+
25
+ /** Operator-supplied exploit evidence: [{ kind, id, edgeId?|flowId?|nodeId? }]. Anything malformed is reported, not trusted. */
26
+ export function validateExploitEvidence(list) {
27
+ const errors = []; const ok = [];
28
+ if (list === null || list === undefined) return { evidence: [], errors };
29
+ if (!Array.isArray(list)) return { evidence: [], errors: ['exploit evidence must be an array'] };
30
+ list.forEach((e, i) => {
31
+ if (!isObj(e) || typeof e.id !== 'string' || !e.id || !['proof', 'dnr-hunt', 'incident'].includes(e.kind)) { errors.push(`entry ${i}: needs kind (proof|dnr-hunt|incident) and id`); return; }
32
+ const targets = ['edgeId', 'flowId', 'nodeId'].filter((k) => typeof e[k] === 'string' && e[k]);
33
+ if (targets.length !== 1) { errors.push(`entry ${i}: name exactly one of edgeId, flowId, nodeId`); return; }
34
+ ok.push({ kind: e.kind, id: e.id.slice(0, 120), [targets[0]]: e[targets[0]] });
35
+ });
36
+ return { evidence: ok, errors };
37
+ }
38
+
39
+ /**
40
+ * @returns {{ evaluated: boolean, byNode: Record<string, {states: string[], observations: number, allowed: number, denied: number}>, byEdge: Record<string, {states: string[], primary: string, observations: number, allowed: number, denied: number, uncertainObservations: number, identityTokens: string[], exploitEvidence: object[], note: string}>, counts: Record<string, number>, limitations: string[] }}
41
+ */
42
+ export function deriveEdgeStates(graph, correlation, observations, { exploitEvidence = [] } = {}) {
43
+ const edges = arr(graph?.edges).filter((e) => isObj(e) && typeof e.id === 'string');
44
+ const flows = arr(graph?.flows).filter((f) => isObj(f) && typeof f.id === 'string');
45
+ const evaluated = !!correlation?.evaluated;
46
+ const considered = new Set(arr(correlation?.consideredObservationIds));
47
+ const byEdge = {}; const counts = Object.fromEntries(EDGE_STATES.map((s) => [s, 0]));
48
+ const limitations = [
49
+ 'RUNTIME_NOT_OBSERVED means "no trusted observation in this window", never "unreachable" or "safe".',
50
+ 'Only high- and medium-confidence mappings count; low-confidence and ambiguous observations stay unmatched and are counted as uncertain.',
51
+ 'RUNTIME_EXPLOIT_CORRELATED requires explicit operator-supplied evidence naming the edge, flow or node; observation alone never sets it.',
52
+ ];
53
+ const flowsByEdge = new Map();
54
+ for (const f of flows) for (const eid of arr(f.edgeIds)) { const a = flowsByEdge.get(eid) ?? []; a.push(f); flowsByEdge.set(eid, a); }
55
+ const obsByEdge = new Map();
56
+ for (const o of arr(observations)) {
57
+ if (!isObj(o) || (evaluated && considered.size && !considered.has(o.id))) continue;
58
+ for (const eid of arr(o.matchedEdgeIds)) { const a = obsByEdge.get(eid) ?? []; a.push(o); obsByEdge.set(eid, a); }
59
+ }
60
+ for (const e of edges) {
61
+ if (!evaluated) { byEdge[e.id] = entry(['STATIC_ONLY'], { note: 'no telemetry was consulted' }); counts.STATIC_ONLY++; continue; }
62
+ const all = obsByEdge.get(e.id) ?? [];
63
+ const trusted = all.filter((o) => TRUSTED.has(o.matchConfidence));
64
+ const uncertain = all.length - trusted.length;
65
+ if (!trusted.length) { byEdge[e.id] = entry(['RUNTIME_NOT_OBSERVED'], { uncertain, note: uncertain ? `${uncertain} low-confidence observation(s) not counted` : 'no observation in this window' }); counts.RUNTIME_NOT_OBSERVED++; continue; }
66
+ const dec = (o) => o.attributes?.['network.securityDecision'];
67
+ const allowed = trusted.filter((o) => dec(o) === 'allowed').length; const denied = trusted.filter((o) => dec(o) === 'denied').length;
68
+ const states = [];
69
+ const onlyDenied = denied > 0 && allowed === 0 && trusted.every((o) => dec(o) === 'denied');
70
+ if (!onlyDenied) states.push('RUNTIME_OBSERVED');
71
+ if (denied > 0) states.push('RUNTIME_BLOCKED');
72
+ const prohibited = flowsByEdge.get(e.id)?.some((f) => f.policyVerdict === 'prohibited');
73
+ if (!onlyDenied && prohibited) states.push('RUNTIME_POLICY_VIOLATION');
74
+ const flowIds = new Set((flowsByEdge.get(e.id) ?? []).map((f) => f.id));
75
+ const nodeIds = new Set([e.source, e.target, e.from, e.to].filter(Boolean));
76
+ const ev = arr(exploitEvidence).filter((x) => x.edgeId === e.id || (x.flowId && flowIds.has(x.flowId)) || (x.nodeId && nodeIds.has(x.nodeId)));
77
+ if (!onlyDenied && ev.length) states.push('RUNTIME_EXPLOIT_CORRELATED');
78
+ const tokens = [...new Set(trusted.map((o) => o.attributes?.['identity.token']).filter(Boolean))].sort();
79
+ byEdge[e.id] = entry(states, { observations: trusted.length, allowed, denied, uncertain, tokens, ev, note: '' });
80
+ for (const s of states) counts[s]++;
81
+ }
82
+ // Observations the matcher placed on a NODE but on no edge or flow (a queue publish, a store write with no modelled path into it).
83
+ // They still carry the allow/deny fact, so the refused queue publish is visible instead of vanishing between edge and node.
84
+ const byNode = {};
85
+ if (evaluated) {
86
+ const perNode = new Map();
87
+ for (const o of arr(observations)) {
88
+ if (!isObj(o) || (considered.size && !considered.has(o.id)) || !TRUSTED.has(o.matchConfidence) || arr(o.matchedEdgeIds).length) continue;
89
+ for (const nid of arr(o.matchedNodeIds)) { const a = perNode.get(nid) ?? []; a.push(o); perNode.set(nid, a); }
90
+ }
91
+ for (const [nid, list] of perNode) {
92
+ const dec = (o) => o.attributes?.['network.securityDecision'];
93
+ const denied = list.filter((o) => dec(o) === 'denied').length; const allowed = list.filter((o) => dec(o) === 'allowed').length;
94
+ const onlyDenied = denied > 0 && denied === list.length;
95
+ byNode[nid] = { states: [...(onlyDenied ? [] : ['RUNTIME_OBSERVED']), ...(denied ? ['RUNTIME_BLOCKED'] : [])], observations: list.length, allowed, denied };
96
+ }
97
+ }
98
+ return { evaluated, byEdge, byNode, counts, limitations };
99
+
100
+ function entry(states, { observations = 0, allowed = 0, denied = 0, uncertain = 0, tokens = [], ev = [], note = '' } = {}) {
101
+ const primary = SEVERITY_ORDER.find((s) => states.includes(s));
102
+ return { states, primary, observations, allowed, denied, uncertainObservations: uncertain, identityTokens: tokens, exploitEvidence: ev, note };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * "Show runtime flows absent from the static graph": the observations the matcher could NOT place on any static node, reduced to the
108
+ * destination identifiers the closed-world schema already allows. These are CANDIDATES. The matcher failing to place an observation
109
+ * can mean a path the static analysis missed, or a mapping the matcher is too weak to make; this lists them, it does not decide.
110
+ */
111
+ export function runtimeFlowsAbsentFromStatic(observations, correlation) {
112
+ const unmatched = new Set(arr(correlation?.unmatchedObservationIds));
113
+ const rows = [];
114
+ for (const o of arr(observations)) {
115
+ if (!isObj(o) || !unmatched.has(o.id)) continue;
116
+ // an observation the matcher placed on a static NODE is not absent from the static graph; it only lacks a modelled flow
117
+ if (arr(o.matchedNodeIds).length || arr(o.matchedEdgeIds).length) continue;
118
+ const a = isObj(o.attributes) ? o.attributes : {};
119
+ const dest = ['destination.host', 'destination.service', 'destination.port', 'destination.scheme'].filter((k) => a[k] !== undefined).map((k) => `${k.split('.')[1]}=${a[k]}`).join(' ');
120
+ if (!dest) continue;
121
+ rows.push({ observationId: o.id, destination: dest, service: a['service.name'] ?? null, environment: o.environment ?? null, firstObservedAt: o.firstObservedAt ?? null, lastObservedAt: o.lastObservedAt ?? null, eventCountBand: o.eventCountBand ?? null });
122
+ }
123
+ rows.sort((x, y) => (x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0));
124
+ return { candidates: rows, note: 'Unmatched observations are candidates, not proof that static analysis missed a path: the matcher may be too weak to place them.' };
125
+ }
126
+
127
+ /** "Show identities using risky paths": identity TOKENS (never identities) seen on edges in the risky states, with a band, never an exact count. */
128
+ export function identitiesUsingRiskyPaths(edgeStates) {
129
+ const risky = new Set(['RUNTIME_POLICY_VIOLATION', 'RUNTIME_EXPLOIT_CORRELATED']);
130
+ const byToken = new Map();
131
+ for (const [edgeId, e] of Object.entries(edgeStates?.byEdge ?? {})) {
132
+ if (!e.states.some((s) => risky.has(s))) continue;
133
+ for (const t of e.identityTokens) { const x = byToken.get(t) ?? { token: t, edges: [], states: new Set() }; x.edges.push(edgeId); e.states.filter((s) => risky.has(s)).forEach((s) => x.states.add(s)); byToken.set(t, x); }
134
+ }
135
+ return {
136
+ identities: [...byToken.values()].map((x) => ({ token: x.token, edgeIds: x.edges.sort(), states: [...x.states].sort() })).sort((a, b) => (a.token < b.token ? -1 : 1)),
137
+ note: 'Tokens are keyed hashes under this install\'s key; they identify "the same identity" but never reveal who. Edges with no identity telemetry contribute nothing, which is not evidence that no identity used them.',
138
+ };
139
+ }
@@ -0,0 +1,82 @@
1
+ // The runtime half of the Data Flow Explorer as ONE static, script-free HTML page (PRD 2 section 5). Pure: it renders what
2
+ // `runtime-edge-states.js` and the correlation already computed, writes nothing, and runs nothing in the browser. It exists as a page
3
+ // rather than a route on the local explore server because that server's contract (`server/CLAUDE.md`) deliberately serves only the
4
+ // signed static graph; widening it is a security decision of its own. A page needs no server, no token and no script.
5
+ //
6
+ // Five views, in the order the PRD names them: Static, Runtime, Static vs Runtime, Observed High-Risk Paths, Unexpected Runtime Paths.
7
+ // Clicking (expanding) an edge shows BOTH its static evidence and its runtime evidence. Every flow is also shown as a path
8
+ // ("source -> ... -> sink") with the state of each hop, which is the PRD's "credit-card field -> API -> payments service -> DB" view.
9
+ //
10
+ // Everything interpolated into the page is escaped. Attribute values (hosts, services) came from telemetry the operator supplied and
11
+ // are treated as hostile text. The page states, at the top, what absence of observation does and does not mean.
12
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
13
+ const arr = (v) => (Array.isArray(v) ? v : []);
14
+ const SENSITIVE = new Set(['PII', 'PHI', 'PCI', 'FIN', 'CREDENTIALS']);
15
+ const CSS = 'body{font:14px/1.45 system-ui,sans-serif;margin:24px auto;max-width:1100px;padding:0 16px;color:#1b1f24;background:#fff}'
16
+ + '@media(prefers-color-scheme:dark){body{background:#14171a;color:#e6e8ea}}'
17
+ + 'table{border-collapse:collapse;width:100%;margin:8px 0}td,th{border:1px solid #8886;padding:4px 8px;text-align:left;vertical-align:top}'
18
+ + 'summary{cursor:pointer}.s{display:inline-block;padding:0 6px;border-radius:4px;border:1px solid #8888;font-size:12px;margin-right:4px}'
19
+ + '.RUNTIME_BLOCKED,.RUNTIME_POLICY_VIOLATION,.RUNTIME_EXPLOIT_CORRELATED{border-color:#c0392b;color:#c0392b}.RUNTIME_OBSERVED{border-color:#1e8449;color:#1e8449}'
20
+ + '.note{border-left:4px solid #8888;padding:4px 12px;margin:12px 0}nav a{margin-right:12px}code{font-size:12px}';
21
+
22
+ export function renderRuntimeExplorer({ graph, edgeStates, correlation, observations = [], absent = { candidates: [] }, title = 'Runtime twin' }) {
23
+ const nodes = new Map(arr(graph?.nodes).map((n) => [n.id, n]));
24
+ const edges = arr(graph?.edges); const flows = arr(graph?.flows);
25
+ const dataEls = new Map(arr(graph?.dataElements).map((d) => [d.id, d]));
26
+ const label = (id) => esc(nodes.get(id)?.label ?? id);
27
+ const stateOf = (eid) => edgeStates?.byEdge?.[eid];
28
+ const obsByEdge = new Map();
29
+ for (const o of arr(observations)) for (const eid of arr(o.matchedEdgeIds)) { const a = obsByEdge.get(eid) ?? []; a.push(o); obsByEdge.set(eid, a); }
30
+ const chips = (states) => arr(states).map((s) => `<span class="s ${esc(s)}">${esc(s)}</span>`).join('');
31
+ const flowClasses = (f) => arr(f.dataElementIds).flatMap((id) => arr(dataEls.get(id)?.dataClasses));
32
+ const observedEdge = (eid) => stateOf(eid)?.states?.includes('RUNTIME_OBSERVED');
33
+
34
+ const staticRows = edges.map((e) => `<tr><td>${label(e.from)} &rarr; ${label(e.to)}</td><td><code>${esc(e.id)}</code></td><td>${esc(e.protocol?.name ?? e.protocol?.kind ?? '')}</td><td>${esc(arr(e.boundaryCrossings).join(', '))}</td></tr>`).join('');
35
+ const runtimeRows = edges.map((e) => { const s = stateOf(e.id); return `<tr><td>${label(e.from)} &rarr; ${label(e.to)}</td><td>${chips(s?.states)}</td><td>${s?.observations ?? 0}</td><td>${esc(s?.identityTokens?.length ?? 0)}</td></tr>`; }).join('');
36
+ const bothRows = edges.map((e) => { const s = stateOf(e.id); return `<tr><td>${label(e.from)} &rarr; ${label(e.to)}</td><td>${esc(arr(e.boundaryCrossings).length ? 'crosses a boundary' : 'internal')}, ${esc(e.provenance ?? '')}</td><td>${chips(s?.states)}${s?.uncertainObservations ? ` <small>(${s.uncertainObservations} uncertain, not counted)</small>` : ''}</td></tr>`; }).join('');
37
+
38
+ const detail = (e) => {
39
+ const s = stateOf(e.id); const obs = obsByEdge.get(e.id) ?? [];
40
+ const o = obs.map((x) => `<li>${esc(x.adapter)} / ${esc(x.environment ?? 'no environment')}: ${esc(x.firstObservedAt)} to ${esc(x.lastObservedAt)}, count band ${esc(x.eventCountBand)}, match ${esc(x.matchMethod)} (${esc(x.matchConfidence)})${x.attributes?.['network.securityDecision'] ? `, decision ${esc(x.attributes['network.securityDecision'])}` : ''}${x.attributes?.['identity.token'] ? `, identity <code>${esc(x.attributes['identity.token'])}</code>` : ''}</li>`).join('');
41
+ return `<details><summary>${label(e.from)} &rarr; ${label(e.to)} ${chips(s?.states)}</summary>`
42
+ + `<h4>Static evidence</h4><p><code>${esc(e.id)}</code>, provenance ${esc(e.provenance)}, coverage ${esc(e.coverageStatus)}; boundaries: ${esc(arr(e.boundaryCrossings).join(', ') || 'none')}; evidence: ${esc(arr(e.evidenceRefs).join(', ') || 'none')}</p>`
43
+ + `<h4>Runtime evidence</h4>${obs.length ? `<ul>${o}</ul>` : `<p>${esc(s?.note || 'none')}</p>`}</details>`;
44
+ };
45
+
46
+ const pathRow = (f) => {
47
+ const hops = arr(f.edgeIds).map((eid) => { const e = edges.find((x) => x.id === eid); return e ? `${label(e.from)} &rarr; ${label(e.to)} ${chips(stateOf(eid)?.states)}` : esc(eid); });
48
+ return `<tr><td>${esc(arr(f.dataElementIds).map((id) => dataEls.get(id)?.label ?? id).join(', '))}<br><small>${esc(flowClasses(f).join(', '))}</small></td><td>${hops.join('<br>') || '(no modelled hops)'}</td><td>${esc(f.policyVerdict)}</td></tr>`;
49
+ };
50
+ const highRisk = flows.filter((f) => arr(f.edgeIds).some(observedEdge) && (flowClasses(f).some((c) => SENSITIVE.has(c)) || f.policyVerdict === 'prohibited' || arr(f.edgeIds).some((eid) => arr(edges.find((e) => e.id === eid)?.boundaryCrossings).length)));
51
+ const blockedNodes = Object.entries(edgeStates?.byNode ?? {});
52
+ const unexpected = [
53
+ ...arr(absent.candidates).map((c) => `<tr><td>${esc(c.destination)}</td><td>observed, not placed on any static node</td><td>${esc(c.firstObservedAt)} to ${esc(c.lastObservedAt)}</td></tr>`),
54
+ ...blockedNodes.filter(([, n]) => n.states.includes('RUNTIME_BLOCKED')).map(([id, n]) => `<tr><td>${label(id)}</td><td>attempted and refused (${n.denied} denied)</td><td></td></tr>`),
55
+ ];
56
+ const section = (id, h, body) => `<section id="${id}"><h2>${h}</h2>${body}</section>`;
57
+ const table = (head, rows, empty) => (rows.length ? `<table><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>${rows.join('')}</table>` : `<p>${esc(empty)}</p>`);
58
+ const body = [
59
+ `<h1>${esc(title)}</h1>`,
60
+ `<div class="note">${edgeStates?.evaluated ? 'Telemetry was consulted.' : '<strong>No telemetry was consulted:</strong> every edge is STATIC_ONLY. That is not evidence that nothing happens at runtime.'} <strong>Not observed does not mean unreachable or safe.</strong> Only high- and medium-confidence mappings are counted. Telemetry values shown are metadata only; identities appear only as keyed-hash tokens.</div>`,
61
+ '<nav><a href="#static">Static</a><a href="#runtime">Runtime</a><a href="#both">Static vs Runtime</a><a href="#risk">Observed high-risk paths</a><a href="#unexpected">Unexpected runtime paths</a><a href="#paths">Flow paths</a><a href="#edges">Edge detail</a></nav>',
62
+ section('static', 'Static', table(['Edge', 'Id', 'Protocol', 'Boundaries'], staticRows ? [staticRows] : [], 'No static edges.')),
63
+ section('runtime', 'Runtime', table(['Edge', 'State', 'Trusted observations', 'Identity tokens'], runtimeRows ? [runtimeRows] : [], 'No edges.')),
64
+ section('both', 'Static vs Runtime', table(['Edge', 'Static', 'Runtime'], bothRows ? [bothRows] : [], 'No edges.')),
65
+ section('risk', 'Observed high-risk paths', table(['Data', 'Path and state per hop', 'Policy'], highRisk.map(pathRow), 'No observed flow is sensitive, policy-prohibited or boundary-crossing. This is a statement about the telemetry window, not about the system.')),
66
+ section('unexpected', 'Unexpected runtime paths', table(['Destination or node', 'What was seen', 'When'], unexpected, 'Nothing unplaced or refused. Unplaced observations are candidates, not proof static analysis missed a path.')),
67
+ section('paths', 'Flow paths', table(['Data', 'Path and state per hop', 'Policy'], flows.map(pathRow), 'No flows.')),
68
+ section('edges', 'Edge detail (expand an edge for its static and runtime evidence)', edges.map(detail).join('') || '<p>No edges.</p>'),
69
+ ].join('\n');
70
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>${esc(title)}</title><style>${CSS}</style></head><body>${body}</body></html>\n`;
71
+ }
72
+
73
+ /** "Show vulnerable functions actually exercised in production": scan findings whose own path appears in the runtime trace. Expects findings already
74
+ * annotated by `posture/runtime-correlation.js`; it adds no matching of its own. */
75
+ export function vulnerableFunctionsInProduction(findings) {
76
+ const list = arr(findings).filter((f) => f && typeof f === 'object');
77
+ const withEvidence = list.filter((f) => f.runtimeEvidence);
78
+ if (!withEvidence.some((f) => f.runtimeEvidence.state !== 'unknown')) return { evaluated: false, reason: 'no runtime trace could be evaluated (none present, or it says nothing about any finding): put one at .agentic-security/runtime-trace.jsonl (or set AGENTIC_SECURITY_RUNTIME_TRACE_PATH). This is not "no vulnerable function ran".' };
79
+ const observed = withEvidence.filter((f) => f.runtimeEvidence.state === 'observed').map((f) => ({ id: f.id, severity: f.severity, vuln: f.vuln, file: f.file, line: f.line, cwe: f.cwe, basis: f.runtimeEvidence.basis, sampling: f.runtimeEvidence.sampling?.state ?? null }));
80
+ const counts = { observed: observed.length, notObservedInWindow: withEvidence.filter((f) => f.runtimeEvidence.state === 'not_observed_in_window').length, unknown: withEvidence.filter((f) => f.runtimeEvidence.state === 'unknown').length };
81
+ return { evaluated: true, findings: observed, counts, note: 'A finding listed here has a function, line or route on its path in the trace. A finding not listed may still run: counts show how many were not observed or unknown. Absence of observation is a statement about the window and the instrumentation, not about reachability.' };
82
+ }
@@ -173,8 +173,13 @@ export const RUNTIME_ATTRIBUTE_KEYS = Object.freeze([
173
173
  // RUNTIME_SECURITY_DECISION_VALUES below and its own dedicated check in
174
174
  // validateObservationAttributes.
175
175
  'network.securityDecision',
176
+ // authenticated identity (PRD 2 section 1) as a KEYED-HASH TOKEN only, never the identity itself: `idt_` + 16 hex. The importer
177
+ // computes it (HMAC under the per-install key) and hands the adapters a tokenizer; no raw username/email can pass this grammar.
178
+ 'identity.token',
176
179
  ]);
177
180
 
181
+ export const IDENTITY_TOKEN_RE = /^idt_[0-9a-f]{16}$/;
182
+
178
183
  // The closed enum for 'network.securityDecision' — deliberately narrower
179
184
  // than the generic identifier-value check every other scalar attribute
180
185
  // gets, since this field has exactly two legitimate values and nothing
@@ -305,6 +310,12 @@ export function validateObservationAttributes(attributes) {
305
310
  }
306
311
  continue;
307
312
  }
313
+ if (key === 'identity.token') {
314
+ if (typeof value !== 'string' || !IDENTITY_TOKEN_RE.test(value)) {
315
+ err(`$.attributes["${key}"]`, 'must be an identity token (idt_ followed by 16 hex characters) — a raw identity is never accepted');
316
+ }
317
+ continue;
318
+ }
308
319
  const t = typeof value;
309
320
  if (t === 'string') {
310
321
  if (!_isIdentifierValue(value)) {
package/src/mcp/CLAUDE.md CHANGED
@@ -29,10 +29,12 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
29
29
  | `dataflow_get_flow` | ✓ | as above, ID-scoped flow lookup (`handleFlow`), includes contributing node/edge canonical ids |
30
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
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`) |
32
+ | `prove_finding` | ✗ | PRD 1: runs a reproducer (supplied, stored or generated from the finding) in the confined proof environment; requires `confirm:true`; returns evidence state and counts, never the target's output; writes a proof record under `.agentic-security/proofs/` |
33
+ | `prove_fix` | ✗ | PRD 1: replays the original exploit against a COPY of the patched sources, runs the static verification and a sibling-exposure search; requires `confirm:true`; writes nothing to the project |
32
34
 
33
35
  **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.
34
36
 
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.
37
+ **25 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.
36
38
 
37
39
  **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.
38
40