@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,331 @@
1
+ // Resolved derivation and closure inventory (NIX-008).
2
+ //
3
+ // Reads EXPLICITLY SUPPLIED, target-specific exports produced by supported Nix tools; it never runs nix,
4
+ // never discovers an executable and never touches the filesystem (this module imports no fs): store-looking
5
+ // paths are only strings to be validated and related to each other.
6
+ //
7
+ // Supported schemas (all documented in docs/guides/nix.md):
8
+ // nix-path-info-json `nix path-info --json --recursive <installable>`: the object form (keys are store
9
+ // paths, Nix >= 2.19) and the older array form ([{path, references, deriver, ...}])
10
+ // nix-derivation-show-json `nix derivation show --recursive <installable>`: object keyed by .drv path with
11
+ // outputs, inputSrcs, inputDrvs, env; also the wrapped {derivations, version} form
12
+ // whose inputs live under inputs.drvs / inputs.srcs and whose paths omit /nix/store/
13
+ // nix-store-query-text `nix-store --query --requisites <path>`: one store path per line (OUTPUT-ONLY
14
+ // identity: no deriver, hash or version, so it can never support an exact claim)
15
+ //
16
+ // Two different graphs come out and are never conflated: the RUNTIME closure (references of the target's
17
+ // output paths) and the BUILD graph (inputDrvs of its derivation). A node's scopes say which it is in.
18
+ // An exact claim is made only when every condition holds; each failed condition is a disclosure.
19
+
20
+ import { createHash, createVerify } from 'node:crypto';
21
+
22
+ export const NIX_CLOSURE_VERSION = 'nix-closure/1';
23
+ export const CLOSURE_BUDGETS = Object.freeze({ maxBytes: 64 * 1024 * 1024, maxNodes: 200_000, maxEdges: 1_000_000, maxEnvBytes: 1 << 20, maxExports: 16 });
24
+ const SUPPORTED_TOOLS = new Set(['nix', 'nix-store']);
25
+ const SCHEMA_COMMANDS = {
26
+ 'nix-path-info-json': /^nix(?:\s+--\S+)*\s+path-info\b.*--json\b|^nix(?:\s+--\S+)*\s+path-info\b.*--recursive\b/,
27
+ 'nix-derivation-show-json': /^nix(?:\s+--\S+)*\s+(?:derivation\s+show|show-derivation)\b/,
28
+ 'nix-store-query-text': /^nix-store\b.*(?:--query|-q)\b.*(?:--requisites|-R|--references|--tree)\b|^nix-store\s+-qR\b/,
29
+ };
30
+ const STORE = '/nix/store/';
31
+ const BASE32 = '0123456789abcdfghijklmnpqrsvwxyz';
32
+ const STORE_PATH = new RegExp(`^/nix/store/([${BASE32}]{32})-([^/\\x00\\s]+)$`);
33
+ const OUTPUT_SUFFIX = /-(?:dev|bin|lib|out|man|doc|info|devdoc|debug|static|dist|fakeroot|headers|include|terminfo)$/;
34
+ const ROLE_OF_ENV = [
35
+ ['nativeCheckInputs', 'test'], ['checkInputs', 'test'], ['installCheckInputs', 'test'],
36
+ ['nativeBuildInputs', 'build'], ['depsBuildBuild', 'build'], ['depsBuildTarget', 'build'], ['depsBuildBuildPropagated', 'build'], ['propagatedNativeBuildInputs', 'build'],
37
+ ['buildInputs', 'build+runtime'], ['propagatedBuildInputs', 'build+runtime'], ['depsHostHost', 'build+runtime'], ['depsTargetTarget', 'build+runtime'], ['depsHostHostPropagated', 'build+runtime'],
38
+ ];
39
+
40
+ /** Validate and split a store path. Returns null for anything that is not exactly /nix/store/<hash>-<name>. */
41
+ export function parseStorePath(p) {
42
+ if (typeof p !== 'string' || p.length > 1024) return null;
43
+ const m = STORE_PATH.exec(p);
44
+ if (!m || m[2] === '.' || m[2] === '..' || m[2].includes('..')) return null;
45
+ return { path: p, hash: m[1], name: m[2] };
46
+ }
47
+ const normalizePath = (p) => (typeof p === 'string' && /^[0-9a-df-np-sv-z]{32}-/.test(p) ? STORE + p : p);
48
+
49
+ /** Heuristic only: nixpkgs naming is pname-version where the version starts at the first "-<digit>". */
50
+ export function inferNameVersion(name, drvEnv) {
51
+ if (drvEnv && typeof drvEnv.pname === 'string' && typeof drvEnv.version === 'string') return { pname: drvEnv.pname, version: drvEnv.version, authority: 'derivation-env' };
52
+ if (drvEnv && typeof drvEnv.name === 'string') { const m = /^(.+?)-(\d.*)$/.exec(drvEnv.name); if (m) return { pname: m[1], version: m[2], authority: 'derivation-env' }; }
53
+ const base = String(name || '').replace(/\.drv$/, '').replace(OUTPUT_SUFFIX, '');
54
+ const m = /^(.+?)-(\d.*)$/.exec(base);
55
+ return m ? { pname: m[1], version: m[2], authority: 'inferred-from-store-path' } : { pname: base || null, version: null, authority: 'inferred-from-store-path' };
56
+ }
57
+
58
+ export function detectSchema(parsed, text) {
59
+ if (typeof text === 'string' && parsed === undefined) {
60
+ const lines = text.split(/\r?\n/).filter(Boolean);
61
+ return lines.length && lines.every((l) => l.startsWith('/')) ? 'nix-store-query-text' : null;
62
+ }
63
+ if (Array.isArray(parsed)) return parsed.every((x) => x && typeof x === 'object' && typeof x.path === 'string') ? 'nix-path-info-json' : null;
64
+ if (parsed && typeof parsed === 'object') {
65
+ if (parsed.derivations && typeof parsed.derivations === 'object') return 'nix-derivation-show-json';
66
+ const ks = Object.keys(parsed);
67
+ if (!ks.length) return null;
68
+ if (ks.every((k) => /\.drv$/.test(k))) return 'nix-derivation-show-json';
69
+ if (ks.every((k) => /^(?:\/nix\/store\/)?[0-9a-df-np-sv-z]{32}-/.test(k))) return 'nix-path-info-json';
70
+ }
71
+ return null;
72
+ }
73
+
74
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex');
75
+
76
+ /**
77
+ * @param {{exports: Array<{schema?:string, text?:string, data?:any, provenance?:object, signature?:string}>,
78
+ * expected?: {system?:string, installable?:string, revision?:object, flakeLockSha256?:string, maxAgeDays?:number, inputsChangedAtMs?:number, roots?:string[], requireSigned?:boolean, publicKeyPem?:string},
79
+ * now?: number, budgets?: object}} opts
80
+ */
81
+ export function importNixClosure(opts = {}) {
82
+ const b = { ...CLOSURE_BUDGETS, ...(opts.budgets || {}) };
83
+ const now = opts.now ?? Date.now();
84
+ const expected = opts.expected || {};
85
+ const dis = []; const diag = [];
86
+ const add = (kind, detail, extra = {}) => dis.push({ kind, detail, ...extra });
87
+ const out = { version: NIX_CLOSURE_VERSION, status: 'ok', nodes: [], edges: [], disclosures: dis, diagnostics: diag, claims: null, provenance: [], roots: [], coverage: {} };
88
+ const exps = (opts.exports || []).slice(0, b.maxExports);
89
+ if ((opts.exports || []).length > b.maxExports) add('truncated', `only the first ${b.maxExports} exports were read`);
90
+ if (!exps.length) { add('no-export', 'no export was supplied: nothing about the closure is known'); out.status = 'empty'; out.claims = claims(false, false, dis); return out; }
91
+
92
+ // ── parse each export ──
93
+ const pathInfo = new Map(); // path -> {references[], deriver, narHash, narSize, valid}
94
+ const drvs = new Map(); // drv path -> entry
95
+ const queryPaths = new Set();
96
+ const provenance = [];
97
+ let truncated = false; let malformed = 0;
98
+
99
+ for (const [idx, ex] of exps.entries()) {
100
+ const label = `export[${idx}]`;
101
+ let parsed; let text = typeof ex.text === 'string' ? ex.text : null;
102
+ if (text !== null && text.length > b.maxBytes) { add('truncated', `${label} is ${text.length} bytes (limit ${b.maxBytes}); not read`); truncated = true; continue; }
103
+ if (ex.data !== undefined) parsed = ex.data;
104
+ else if (text !== null) { try { parsed = JSON.parse(text); } catch { parsed = undefined; } }
105
+ let schema = ex.schema || detectSchema(parsed, text);
106
+ if (!schema || !(schema in SCHEMA_COMMANDS)) { diag.push({ export: label, kind: 'unsupported-schema', detail: ex.schema ? `schema ${ex.schema} is not supported` : 'could not recognise the export format' }); malformed++; continue; }
107
+ const prov = assessProvenance(ex, schema, expected, now, label, add);
108
+ provenance.push({ export: label, schema, ...prov.summary });
109
+ if (schema === 'nix-store-query-text') {
110
+ const lines = String(text !== null ? text : '').split(/\r?\n/).filter(Boolean);
111
+ for (const l of lines) { const sp = parseStorePath(l.trim()); if (!sp) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a store path: ${l.slice(0, 80)}` }); malformed++; continue; } if (queryPaths.size < b.maxNodes) queryPaths.add(sp.path); else truncated = true; }
112
+ continue;
113
+ }
114
+ if (schema === 'nix-path-info-json') {
115
+ const entries = Array.isArray(parsed) ? parsed.map((e) => [e && e.path, e]) : Object.entries(parsed || {});
116
+ for (const [p, e] of entries) {
117
+ const sp = parseStorePath(normalizePath(p));
118
+ if (!sp) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a store path: ${String(p).slice(0, 80)}` }); malformed++; continue; }
119
+ if (e === null) { pathInfo.set(sp.path, { references: null, valid: false }); continue; } // invalid path in the store
120
+ if (!e || typeof e !== 'object') { diag.push({ export: label, kind: 'malformed-entry', detail: sp.path }); malformed++; continue; }
121
+ if (pathInfo.size >= b.maxNodes) { truncated = true; break; }
122
+ const refs = Array.isArray(e.references) ? e.references.map((r) => parseStorePath(normalizePath(r))).filter(Boolean).map((r) => r.path) : null;
123
+ if (Array.isArray(e.references) && refs.length !== e.references.length) { diag.push({ export: label, kind: 'invalid-store-path', detail: `${sp.path} has reference(s) that are not store paths` }); malformed++; }
124
+ pathInfo.set(sp.path, { references: refs, deriver: typeof e.deriver === 'string' && e.deriver ? normalizePath(e.deriver) : null, narHash: typeof e.narHash === 'string' ? e.narHash : null, narSize: Number.isFinite(e.narSize) ? e.narSize : null, valid: e.valid !== false, signatures: Array.isArray(e.signatures) ? e.signatures.length : 0 });
125
+ }
126
+ continue;
127
+ }
128
+ // derivation show
129
+ const wrapped = parsed && parsed.derivations && typeof parsed.derivations === 'object';
130
+ const table = wrapped ? parsed.derivations : parsed;
131
+ for (const [dp0, e] of Object.entries(table || {})) {
132
+ const dp = normalizePath(dp0);
133
+ const sp = parseStorePath(dp);
134
+ if (!sp || !/\.drv$/.test(sp.name)) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a derivation path: ${String(dp0).slice(0, 80)}` }); malformed++; continue; }
135
+ if (!e || typeof e !== 'object') { diag.push({ export: label, kind: 'malformed-entry', detail: sp.path }); malformed++; continue; }
136
+ if (drvs.size >= b.maxNodes) { truncated = true; break; }
137
+ const outputs = {};
138
+ if (e.outputs && typeof e.outputs === 'object') for (const [n, o] of Object.entries(e.outputs)) { const p = o && o.path ? parseStorePath(normalizePath(o.path)) : null; outputs[n] = p ? p.path : null; }
139
+ const inputDrvs = {};
140
+ const rawIn = wrapped && e.inputs ? (e.inputs.drvs || {}) : (e.inputDrvs || {});
141
+ for (const [k, v] of Object.entries(rawIn)) { const p = parseStorePath(normalizePath(k)); if (!p) { diag.push({ export: label, kind: 'invalid-store-path', detail: `inputDrv key in ${sp.name}` }); malformed++; continue; } inputDrvs[p.path] = Array.isArray(v) ? v : (v && Array.isArray(v.outputs) ? v.outputs : []); }
142
+ const rawSrcs = wrapped && e.inputs ? (e.inputs.srcs || []) : (e.inputSrcs || []);
143
+ const inputSrcs = (Array.isArray(rawSrcs) ? rawSrcs : []).map((s) => parseStorePath(normalizePath(s))).filter(Boolean).map((s) => s.path);
144
+ let env = e.env && typeof e.env === 'object' ? e.env : null;
145
+ if (env && JSON.stringify(env).length > b.maxEnvBytes) { env = null; diag.push({ export: label, kind: 'env-too-large', detail: sp.name }); }
146
+ drvs.set(sp.path, { outputs, inputDrvs, inputSrcs, system: typeof e.system === 'string' ? e.system : null, builder: typeof e.builder === 'string' ? e.builder : null, env });
147
+ }
148
+ }
149
+ out.provenance = provenance;
150
+ if (truncated) { add('truncated', `an export exceeded the node budget (${b.maxNodes}) or size limit; the inventory is partial`); }
151
+ if (malformed) add('malformed', `${malformed} entr${malformed === 1 ? 'y' : 'ies'} could not be used (see diagnostics)`);
152
+
153
+ // ── node table ──
154
+ const nodes = new Map();
155
+ const node = (id, kind) => { if (!nodes.has(id)) { const sp = parseStorePath(id); nodes.set(id, { id, kind, hash: sp ? sp.hash : null, storeName: sp ? sp.name : null, scopes: new Set(), outputs: [], system: null, identity: null, pname: null, version: null, versionAuthority: null, deriver: null, narHash: null, patches: [], sources: [], missing: [], roles: new Set(), group: null }); } return nodes.get(id); };
156
+ const outputOwner = new Map(); // output path -> {drv, outputName}
157
+ for (const [dp, d] of drvs) { for (const [n, p] of Object.entries(d.outputs)) if (p) outputOwner.set(p, { drv: dp, output: n }); }
158
+ const edges = [];
159
+ const edgeKeys = new Set();
160
+ const addEdge = (from, to, kind, role) => { const k = `${from}\u0000${to}\u0000${kind}`; if (edgeKeys.has(k)) return; if (edges.length >= b.maxEdges) { truncated = true; return; } edgeKeys.add(k); edges.push({ from, to, kind, ...(role ? { role } : {}) }); };
161
+
162
+ for (const [p, info] of pathInfo) {
163
+ const n = node(p, 'output');
164
+ n.narHash = info.narHash || null; n.deriver = info.deriver || null;
165
+ if (info.references === null && info.valid !== false) n.missing.push('references');
166
+ if (!info.narHash) n.missing.push('narHash');
167
+ if (!info.deriver) n.missing.push('deriver');
168
+ if (info.references) for (const r of info.references) if (r !== p) addEdge(p, r, 'reference', null);
169
+ }
170
+ for (const p of queryPaths) { const n = node(p, 'output'); n.queryOnly = !pathInfo.has(p); }
171
+ for (const [dp, d] of drvs) {
172
+ const n = node(dp, 'derivation');
173
+ n.system = d.system; n.outputs = Object.entries(d.outputs).map(([name, path]) => ({ name, path })); n.sources = d.inputSrcs;
174
+ const nv = inferNameVersion(n.storeName, d.env);
175
+ n.pname = nv.pname; n.version = nv.version; n.versionAuthority = nv.authority; n.identity = d.env ? 'derivation-env' : 'drv-path-only';
176
+ if (!d.env) n.missing.push('env');
177
+ if (d.env && typeof d.env.patches === 'string') n.patches = d.env.patches.split(/\s+/).filter(Boolean).map((x) => { const sp = parseStorePath(x); return sp ? { path: sp.path, name: sp.name } : null; }).filter(Boolean).map((x) => ({ path: x.path, name: x.name || x.path }));
178
+ if (d.env && typeof d.env.urls === 'string') n.upstreamUrls = d.env.urls.split(/\s+/).filter(Boolean).slice(0, 8);
179
+ if (d.env && typeof d.env.rev === 'string') n.upstreamRev = d.env.rev;
180
+ // role of each input derivation, from the environment lists that name its output paths
181
+ const rolesFor = (outPaths) => {
182
+ const roles = new Set();
183
+ if (!d.env) return roles;
184
+ for (const [key, role] of ROLE_OF_ENV) { const v = d.env[key]; if (typeof v !== 'string') continue; const set = new Set(v.split(/\s+/)); if (outPaths.some((p) => set.has(p))) roles.add(role); }
185
+ return roles;
186
+ };
187
+ for (const [ip, outs] of Object.entries(d.inputDrvs)) {
188
+ const callee = drvs.get(ip);
189
+ const outPaths = callee ? Object.values(callee.outputs).filter(Boolean) : [];
190
+ const roles = rolesFor(outPaths);
191
+ const role = roles.size ? [...roles].sort().join('|') : 'build';
192
+ node(ip, 'derivation');
193
+ addEdge(dp, ip, 'input-drv', role);
194
+ const e = edges.find((x) => x.from === dp && x.to === ip && x.kind === 'input-drv'); if (e) e.outputsUsed = outs;
195
+ }
196
+ for (const s of d.inputSrcs) { const sn = node(s, 'source'); addEdge(dp, s, 'input-src', 'source'); sn.scopes.add('source'); }
197
+ // link derivation <-> outputs
198
+ for (const [name, op] of Object.entries(d.outputs)) if (op) { const on = node(op, 'output'); on.deriver = on.deriver || dp; on.outputName = name; addEdge(op, dp, 'deriver', null); }
199
+ }
200
+ for (const n of nodes.values()) {
201
+ if (n.kind === 'output') {
202
+ const owner = outputOwner.get(n.id) || (n.deriver && drvs.has(n.deriver) ? { drv: n.deriver } : null);
203
+ const d = owner ? drvs.get(owner.drv) : null;
204
+ if (d) { const nv = inferNameVersion(n.storeName, d.env); n.pname = nv.pname; n.version = nv.version; n.versionAuthority = nv.authority; n.identity = d.env ? 'derivation-env' : 'drv-path-only'; n.system = d.system; n.outputName = owner.output || n.outputName; n.patches = (nodes.get(owner.drv) || {}).patches || []; n.upstreamUrls = (nodes.get(owner.drv) || {}).upstreamUrls; n.upstreamRev = (nodes.get(owner.drv) || {}).upstreamRev; }
205
+ else { const nv = inferNameVersion(n.storeName, null); n.pname = nv.pname; n.version = nv.version; n.versionAuthority = 'inferred-from-store-path'; n.identity = 'output-only'; }
206
+ }
207
+ }
208
+
209
+ // ── roots and scopes ──
210
+ let roots = (expected.roots || []).map((r) => parseStorePath(r)).filter(Boolean).map((r) => r.path);
211
+ const fromExpected = roots.length > 0;
212
+ if (!roots.length && pathInfo.size) {
213
+ const referenced = new Set();
214
+ for (const [p, info] of pathInfo) for (const r of info.references || []) if (r !== p) referenced.add(r);
215
+ roots = [...pathInfo.keys()].filter((p) => !referenced.has(p));
216
+ }
217
+ if (!roots.length && queryPaths.size === 1) roots = [...queryPaths];
218
+ const rootDrvs = [];
219
+ for (const r of roots) { const n = nodes.get(r); if (n && n.deriver && drvs.has(n.deriver)) rootDrvs.push(n.deriver); }
220
+ if (roots.length > 1 && !fromExpected) add('ambiguous-root', `${roots.length} paths are not referenced by any other: the target cannot be identified without expected.roots`);
221
+ if (!roots.length) add('no-root', 'no root path could be determined: scopes are not assigned');
222
+ out.roots = roots;
223
+ // runtime closure
224
+ const runtime = new Set(); const rq = [...roots.filter((r) => nodes.has(r))];
225
+ for (const r of rq) runtime.add(r);
226
+ while (rq.length) { const u = rq.shift(); for (const e of edges) if (e.from === u && e.kind === 'reference' && !runtime.has(e.to)) { runtime.add(e.to); rq.push(e.to); } }
227
+ for (const id of runtime) nodes.get(id).scopes.add('runtime');
228
+ // build graph (from the root derivations)
229
+ const buildSeen = new Map(); const bq = [];
230
+ for (const rd of rootDrvs) { buildSeen.set(rd, new Set(['target'])); bq.push(rd); }
231
+ while (bq.length) {
232
+ const u = bq.shift();
233
+ for (const e of edges) if (e.from === u && e.kind === 'input-drv') {
234
+ const roles = new Set(String(e.role || 'build').split('|'));
235
+ const cur = buildSeen.get(e.to) || new Set();
236
+ const before = cur.size;
237
+ for (const r of roles) cur.add(r);
238
+ // roles propagate: a build-only tool's own inputs are build inputs of the target
239
+ if (!buildSeen.has(e.to) || cur.size !== before) { buildSeen.set(e.to, cur); bq.push(e.to); }
240
+ }
241
+ }
242
+ for (const [id, roles] of buildSeen) {
243
+ const n = nodes.get(id); if (!n) continue;
244
+ n.scopes.add('build');
245
+ if ([...roles].every((r) => r === 'test' || r === 'target')) { if (roles.has('test')) n.scopes.add('test'); }
246
+ for (const r of roles) if (r !== 'target') n.roles.add(r);
247
+ }
248
+ // output nodes inherit the build/test scope of their derivation when they were not found in the runtime closure
249
+ for (const n of nodes.values()) if (n.kind === 'output') { const owner = outputOwner.get(n.id); if (owner && buildSeen.has(owner.drv)) { const roles = buildSeen.get(owner.drv); n.scopes.add('build'); if (!n.scopes.has('runtime')) for (const r of roles) if (r !== 'target') n.roles.add(r); if ([...roles].every((r) => r === 'test' || r === 'target') && roles.has('test')) n.scopes.add('test'); } }
250
+
251
+ // same-name different-build groups
252
+ // distinct BUILDS: outputs of one derivation (out, dev, ...) are one build; different derivations are different builds
253
+ const groups = new Map();
254
+ for (const n of nodes.values()) if (n.kind === 'output' && n.pname) { const k = `${n.pname}@${n.version || '?'}`; if (!groups.has(k)) groups.set(k, new Set()); groups.get(k).add((outputOwner.get(n.id) || {}).drv || n.deriver || n.hash); }
255
+ for (const n of nodes.values()) if (n.kind === 'output' && n.pname) { const k = `${n.pname}@${n.version || '?'}`; const g = groups.get(k); n.group = k; n.sameNameDifferentBuild = g.size > 1; }
256
+
257
+ // dangling references / inputDrvs make a graph open
258
+ let openRuntime = false; let openBuild = false;
259
+ for (const [p, info] of pathInfo) for (const r of info.references || []) if (!pathInfo.has(r) && r !== p) { openRuntime = true; }
260
+ for (const d of drvs.values()) for (const ip of Object.keys(d.inputDrvs)) if (!drvs.has(ip)) openBuild = true;
261
+ if (pathInfo.size && openRuntime) add('open-closure', 'some references point at paths that are not in the export: the runtime closure is not closed');
262
+ if (drvs.size && openBuild) add('open-build-graph', 'some inputDrvs are not described in the export: the build graph is not closed');
263
+ for (const n of nodes.values()) if (n.kind === 'output' && n.identity === 'output-only') { if (!dis.some((x) => x.kind === 'output-only-identity')) add('output-only-identity', 'some components are known only by their store path: name and version are inferred from the path, not authoritative', { count: 0 }); dis.find((x) => x.kind === 'output-only-identity').count++; }
264
+ const missingMeta = [...nodes.values()].filter((n) => n.missing.length && n.kind !== 'source');
265
+ if (missingMeta.length) add('missing-metadata', `${missingMeta.length} node(s) lack metadata (${[...new Set(missingMeta.flatMap((n) => n.missing))].join(', ')})`, { count: missingMeta.length });
266
+
267
+ for (const n of nodes.values()) {
268
+ out.nodes.push({ id: n.id, kind: n.kind, componentClass: n.kind === 'source' ? 'source-dependency' : 'resolved-package', hash: n.hash, storeName: n.storeName, pname: n.pname, version: n.version, versionAuthority: n.versionAuthority, identity: n.identity, outputName: n.outputName || null, outputs: n.outputs, system: n.system, scopes: [...n.scopes].sort(), roles: [...n.roles].sort(), deriver: n.deriver, narHash: n.narHash, patches: n.patches, upstream: n.upstreamUrls || n.upstreamRev ? { urls: n.upstreamUrls || [], rev: n.upstreamRev || null } : null, sameNameDifferentBuild: !!n.sameNameDifferentBuild, group: n.group, missing: n.missing });
269
+ }
270
+ out.nodes.sort((a, b) => a.id.localeCompare(b.id));
271
+ out.edges = edges.sort((a, b) => `${a.from}${a.to}${a.kind}`.localeCompare(`${b.from}${b.to}${b.kind}`));
272
+ out.coverage = { outputs: out.nodes.filter((n) => n.kind === 'output').length, derivations: out.nodes.filter((n) => n.kind === 'derivation').length, sources: out.nodes.filter((n) => n.kind === 'source').length, edges: out.edges.length, runtimeClosure: runtime.size, buildGraph: buildSeen.size };
273
+ if (truncated) out.status = 'partial';
274
+ else if (malformed) out.status = 'partial';
275
+
276
+ // ── claims ──
277
+ const blocking = (kinds) => dis.some((d) => kinds.includes(d.kind));
278
+ const common = ['foreign-target', 'foreign-revision', 'stale-export', 'unverified-provenance', 'truncated', 'malformed', 'ambiguous-root', 'no-root', 'invalid-signature', 'unsigned'];
279
+ const exactRuntime = pathInfo.size > 0 && !openRuntime && !blocking([...common, 'output-only-identity']) && [...nodes.values()].filter((n) => n.kind === 'output' && runtime.has(n.id)).every((n) => !n.missing.includes('references'));
280
+ const exactBuild = drvs.size > 0 && !openBuild && !blocking(common) && [...nodes.values()].filter((n) => n.kind === 'derivation').every((n) => !n.missing.includes('env'));
281
+ out.claims = claims(exactBuild, exactRuntime, dis);
282
+ return out;
283
+ }
284
+
285
+ function claims(exactBuildGraph, exactRuntimeClosure, dis) {
286
+ return {
287
+ exactBuildGraph, exactRuntimeClosure, exactInventory: exactBuildGraph && exactRuntimeClosure,
288
+ completeness: exactBuildGraph || exactRuntimeClosure ? 'complete-for-supplied-target' : 'not-claimed',
289
+ reasons: dis.filter((d) => !['no-export'].includes(d.kind)).map((d) => d.kind),
290
+ };
291
+ }
292
+
293
+ function assessProvenance(ex, schema, expected, now, label, add) {
294
+ const p = ex.provenance && typeof ex.provenance === 'object' ? ex.provenance : null;
295
+ const summary = { level: 'none', tool: null, command: null, target: null };
296
+ if (!p) { add('unverified-provenance', `${label}: no provenance envelope (tool, command, target, flake.lock hash): the export cannot be tied to a target`); return { summary }; }
297
+ summary.tool = p.tool || null; summary.command = p.command || null; summary.target = p.target || null; summary.toolVersion = p.toolVersion || null; summary.generatedAt = p.generatedAt || null;
298
+ let level = 'declared';
299
+ if (!SUPPORTED_TOOLS.has(p.tool)) { add('unverified-provenance', `${label}: tool ${p.tool || '(none)'} is not a supported source for ${schema}`); level = 'none'; }
300
+ else if (typeof p.command !== 'string' || !SCHEMA_COMMANDS[schema].test(p.command)) { add('unverified-provenance', `${label}: the recorded command does not produce ${schema}`); level = 'none'; }
301
+ else if (!p.target || typeof p.target !== 'object' || !p.target.system) { add('unverified-provenance', `${label}: provenance names no target system`); level = 'none'; }
302
+ if (level !== 'none') {
303
+ const t = p.target || {};
304
+ if (expected.system && t.system !== expected.system) add('foreign-target', `${label}: export is for system ${t.system}, expected ${expected.system}`);
305
+ if (expected.installable && t.installable !== expected.installable) add('foreign-target', `${label}: export is for ${t.installable || '(unnamed)'}, expected ${expected.installable}`);
306
+ for (const [k, v] of Object.entries(expected.revision || {})) { const got = (p.revision || {})[k]; if (got !== v) add('foreign-revision', `${label}: ${k} revision ${got || '(none)'} differs from the expected ${v}`); }
307
+ if (expected.flakeLockSha256 && p.flakeLockSha256 !== expected.flakeLockSha256) add('stale-export', `${label}: made against a different flake.lock (${String(p.flakeLockSha256 || 'none').slice(0, 12)} vs ${expected.flakeLockSha256.slice(0, 12)})`);
308
+ const at = Date.parse(p.generatedAt);
309
+ if (!Number.isFinite(at)) add('stale-export', `${label}: no usable generation time`);
310
+ else {
311
+ const maxMs = (expected.maxAgeDays ?? 30) * 86400000;
312
+ if (now - at > maxMs) add('stale-export', `${label}: generated ${Math.round((now - at) / 86400000)} days ago (limit ${expected.maxAgeDays ?? 30})`);
313
+ if (Number.isFinite(expected.inputsChangedAtMs) && expected.inputsChangedAtMs > at) add('stale-export', `${label}: the flake inputs changed after this export was generated`);
314
+ }
315
+ }
316
+ // optional attestation
317
+ if (ex.signature) {
318
+ try {
319
+ const body = JSON.stringify({ schema: ex.schema ?? schema, provenance: p, data: ex.data !== undefined ? ex.data : null, text: ex.text !== undefined ? ex.text : null });
320
+ const v = createVerify('sha256'); v.update(body);
321
+ if (expected.publicKeyPem && v.verify(expected.publicKeyPem, Buffer.from(ex.signature, 'base64'))) { if (level !== 'none') level = 'signed'; }
322
+ else add('invalid-signature', `${label}: the signature does not verify`);
323
+ } catch (e) { add('invalid-signature', `${label}: signature check failed (${e.message})`); }
324
+ } else if (expected.requireSigned) add('unsigned', `${label}: a signed export is required`);
325
+ summary.level = level;
326
+ return { summary };
327
+ }
328
+
329
+ /** Canonical body that a producer signs (exported so tests and producers agree on it). */
330
+ export function signedBody(ex) { return JSON.stringify({ schema: ex.schema, provenance: ex.provenance, data: ex.data !== undefined ? ex.data : null, text: ex.text !== undefined ? ex.text : null }); }
331
+ export { sha256 as _sha256 };
@@ -0,0 +1,274 @@
1
+ // Isolated, opt-in Nix evaluation (NIX-011).
2
+ //
3
+ // Ordinary scanning NEVER evaluates customer Nix: nothing imports this module on the default path. An
4
+ // operator who explicitly asks for evaluation gets it only inside an OS sandbox whose isolation this module
5
+ // has just proved with a probe, and only through an evaluator whose required safety flags were
6
+ // feature-detected. The order is fixed and every step before the last is "before project code runs":
7
+ //
8
+ // 1. detectSandbox which backend exists on this host (macOS sandbox-exec, Linux bubblewrap), or none
9
+ // 2. probeSandbox run a probe INSIDE the sandbox that tries to read outside the allowlist, write
10
+ // outside it, reach the network, reach a daemon socket, spawn a process and see a
11
+ // credential variable; the sandbox is accepted only if every attempt is refused
12
+ // 3. detectEvaluator run `--version` / `eval --help` and require every safety flag to exist
13
+ // 4. runIsolatedEval run the target-scoped evaluation with a clean environment, a read-only allowlist,
14
+ // a wall-clock deadline, an output cap and a memory ceiling; kill the whole process group
15
+ //
16
+ // Anything that fails earlier returns `unsupported` or `blocked` with `projectCodeEvaluated:false`; the
17
+ // static findings of the scan are never touched (`staticFindingsRetained:true`) and the result says how to
18
+ // import a supplied export instead. Flags are NOT the boundary: the filesystem/network/process boundary is
19
+ // the sandbox, and the probe tests it independently of any flag. Nix's own build sandbox and restricted
20
+ // evaluation are additional controls, not substitutes. Nothing here runs `nixos-rebuild`, activates a
21
+ // configuration, builds anything or edits a host setting.
22
+
23
+ import { spawn, spawnSync } from 'node:child_process';
24
+ import { createServer as netServer } from 'node:net';
25
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, realpathSync } from 'node:fs';
26
+ import { tmpdir, platform as osPlatform } from 'node:os';
27
+ import { join, dirname, resolve } from 'node:path';
28
+ import { createHash } from 'node:crypto';
29
+
30
+ export const NIX_EVAL_VERSION = 'nix-eval-isolation/1';
31
+ export const DEFAULT_LIMITS = Object.freeze({ deadlineMs: 20_000, maxOutputBytes: 1 << 20, maxRssMb: 512, killGraceMs: 1500, probeDeadlineMs: 15_000 });
32
+ /** Flags the evaluator must support; a missing one means `unsupported`, never "run without it". */
33
+ export const REQUIRED_EVAL_FLAGS = Object.freeze(['--offline', '--no-update-lock-file', '--no-write-lock-file', '--pure-eval', '--json', '--option']);
34
+ /** `--option name value` settings that are always passed. */
35
+ export const SAFETY_OPTIONS = Object.freeze({
36
+ 'allow-import-from-derivation': 'false',
37
+ 'allow-unsafe-native-code-during-evaluation': 'false',
38
+ 'accept-flake-config': 'false',
39
+ 'restrict-eval': 'true',
40
+ 'allowed-uris': '',
41
+ 'substituters': '',
42
+ 'max-jobs': '0',
43
+ });
44
+ const CLEAN_PATH = '/usr/bin:/bin';
45
+ const ATTR_RE = /^[A-Za-z0-9_][A-Za-z0-9_.+'-]*(?:\.(?:[A-Za-z0-9_][A-Za-z0-9_+'-]*|"[A-Za-z0-9_.+-]+"))*$/;
46
+
47
+ // ── 1. sandbox backend ───────────────────────────────────────────────────────
48
+ const which = (bin) => { const r = spawnSync('/usr/bin/which', [bin], { encoding: 'utf8', timeout: 5000 }); return r.status === 0 ? r.stdout.trim() : null; };
49
+
50
+ export function detectSandbox(opts = {}) {
51
+ const plat = opts.platform || osPlatform();
52
+ const find = opts.which || which;
53
+ if (opts.forceBackend === 'none') return { available: false, backend: 'none', platform: plat, reason: 'isolation disabled by the caller' };
54
+ if (plat === 'darwin') {
55
+ const p = find('sandbox-exec');
56
+ return p ? { available: true, backend: 'sandbox-exec', path: p, platform: plat, reason: null } : { available: false, backend: 'none', platform: plat, reason: 'sandbox-exec is not installed' };
57
+ }
58
+ if (plat === 'linux') {
59
+ const p = find('bwrap');
60
+ return p ? { available: true, backend: 'bwrap', path: p, platform: plat, reason: null } : { available: false, backend: 'none', platform: plat, reason: 'bubblewrap (bwrap) is not installed: no tested unprivileged filesystem/network namespace is available' };
61
+ }
62
+ return { available: false, backend: 'none', platform: plat, reason: `no sandbox backend is implemented for ${plat}` };
63
+ }
64
+
65
+ const sbq = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
66
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
67
+
68
+ /**
69
+ * The sandboxed command for a backend. Reads are an ALLOWLIST (the listed roots, system library paths and the
70
+ * evaluator); writes are an allowlist of one scratch directory; network and process creation are denied.
71
+ * @param {{backend:string, path?:string}} sb
72
+ * @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string}} spec
73
+ */
74
+ export function sandboxCommand(sb, spec) {
75
+ const ro = [...new Set(spec.readOnly.map(real))];
76
+ const files = [...new Set((spec.readFiles || []).map(real))];
77
+ const rw = [...new Set(spec.writable.map(real))];
78
+ const exec = real(spec.execFile);
79
+ if (sb.backend === 'sandbox-exec') {
80
+ const profile = [
81
+ '(version 1)', '(allow default)', '(deny network*)',
82
+ '(deny file-write*)', `(allow file-write* ${rw.map((p) => `(subpath ${sbq(p)})`).join(' ')} (subpath "/dev"))`,
83
+ '(deny file-read-data (subpath "/"))',
84
+ `(allow file-read-data (literal "/") ${[spec.cwd, ...rw].map((p) => `(literal ${sbq(real(p))})`).join(' ')} (subpath "/usr") (subpath "/System") (subpath "/Library") (subpath "/private/var/db") (subpath "/dev") (subpath "/nix/store") ${ro.map((p) => `(subpath ${sbq(p)})`).join(' ')} ${files.map((p) => `(literal ${sbq(p)})`).join(' ')} ${rw.map((p) => `(subpath ${sbq(p)})`).join(' ')})`,
85
+ '(deny process-exec*)', `(allow process-exec (literal ${sbq(exec)}) (subpath "/nix/store"))`,
86
+ ].join('\n');
87
+ return { file: sb.path, args: ['-p', profile, exec, ...spec.args], profile };
88
+ }
89
+ if (sb.backend === 'bwrap') {
90
+ const a = ['--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp'];
91
+ for (const p of ['/usr', '/lib', '/lib64', '/bin', '/etc/ssl', '/nix/store']) a.push('--ro-bind-try', p, p);
92
+ for (const p of ro) a.push('--ro-bind', p, p);
93
+ for (const p of files) a.push('--ro-bind', p, p);
94
+ for (const p of rw) a.push('--bind', p, p);
95
+ a.push('--chdir', spec.cwd, exec, ...spec.args);
96
+ return { file: sb.path, args: a, profile: null };
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // ── process supervisor ───────────────────────────────────────────────────────
102
+ function rssOfGroup(pgid) {
103
+ const r = spawnSync('/bin/ps', ['-o', 'rss=', '-g', String(pgid)], { encoding: 'utf8', timeout: 2000 });
104
+ if (r.status !== 0) return 0;
105
+ return r.stdout.split('\n').map((x) => parseInt(x, 10)).filter(Number.isFinite).reduce((a, b) => a + b, 0) / 1024;
106
+ }
107
+ function killGroup(pid, sig) { try { process.kill(-pid, sig); } catch { try { process.kill(pid, sig); } catch { /* gone */ } } }
108
+
109
+ /** Run a command to completion under a deadline, an output cap and a memory ceiling. Never throws. */
110
+ export function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500 }) {
111
+ return new Promise((resolveP) => {
112
+ const t0 = Date.now();
113
+ let child;
114
+ try { child = spawn(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
115
+ catch (e) { return resolveP({ status: 'failed', reason: `could not start: ${e.message}`, stdout: '', stderr: '', exitCode: null, signal: null, ms: 0 }); }
116
+ let out = ''; let err = ''; let bytes = 0; let state = null; let done = false;
117
+ const finish = (extra = {}) => { if (done) return; done = true; clearInterval(poll); clearTimeout(dl); resolveP({ stdout: out, stderr: err.slice(0, 4000), ms: Date.now() - t0, ...extra }); };
118
+ const stop = (why) => { if (state) return; state = why; killGroup(child.pid, 'SIGTERM'); setTimeout(() => killGroup(child.pid, 'SIGKILL'), killGraceMs).unref(); };
119
+ child.stdout.on('data', (d) => { bytes += d.length; if (bytes > maxOutputBytes) { stop('output-limit'); return; } out += d.toString('utf8'); });
120
+ child.stderr.on('data', (d) => { if (err.length < 8000) err += d.toString('utf8'); });
121
+ const dl = setTimeout(() => stop('timed-out'), deadlineMs);
122
+ const poll = setInterval(() => { if (!state && maxRssMb && rssOfGroup(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
123
+ child.on('error', (e) => finish({ status: 'failed', reason: e.message, exitCode: null, signal: null }));
124
+ child.on('close', (code, signal) => {
125
+ killGroup(child.pid, 'SIGKILL'); // reap any descendants
126
+ finish({ status: state || (code === 0 ? 'ok' : 'failed'), exitCode: code, signal, reason: state ? `${state}${state === 'timed-out' ? ` after ${deadlineMs} ms` : ''}` : (code === 0 ? null : `exit ${code}`) });
127
+ });
128
+ });
129
+ }
130
+
131
+ const cleanEnv = (work, extra = {}) => ({ PATH: CLEAN_PATH, HOME: work, TMPDIR: work, LANG: 'C', LC_ALL: 'C', ...extra });
132
+
133
+ // ── 2. probe ─────────────────────────────────────────────────────────────────
134
+ const PROBE_SOURCE = `
135
+ const fs=require('fs'),net=require('net'),cp=require('child_process');
136
+ const [allowedFile,outsideFile,outsideWrite,okWrite,port,sock]=process.argv.slice(2);
137
+ const r={};const t=(k,f)=>{try{r[k]=f()}catch(e){r[k]='refused:'+(e.code||'error')}};
138
+ t('readAllowed',()=>fs.readFileSync(allowedFile,'utf8').trim());
139
+ t('readOutside',()=>{fs.readFileSync(outsideFile,'utf8');return 'READ'});
140
+ t('readHostFile',()=>{fs.readFileSync('/etc/hosts','utf8');return 'READ'});
141
+ t('writeOutside',()=>{fs.writeFileSync(outsideWrite,'x');return 'WROTE'});
142
+ t('writeScratch',()=>{fs.writeFileSync(okWrite,'x');return 'wrote'});
143
+ t('spawn',()=>{cp.execFileSync('/bin/echo',['x']);return 'SPAWNED'});
144
+ t('credentialEnv',()=>process.env.PROBE_CREDENTIAL||'absent');
145
+ let pending=2;const fin=()=>{if(--pending===0){console.log(JSON.stringify(r));process.exit(0)}};
146
+ const tcp=net.connect(Number(port),'127.0.0.1');tcp.on('connect',()=>{r.network='CONNECTED';tcp.destroy();fin()});tcp.on('error',(e)=>{r.network='refused:'+e.code;fin()});
147
+ const un=net.connect(sock);un.on('connect',()=>{r.daemonSocket='CONNECTED';un.destroy();fin()});un.on('error',(e)=>{r.daemonSocket='refused:'+e.code;fin()});
148
+ setTimeout(()=>{r.timeout=true;console.log(JSON.stringify(r));process.exit(0)},8000);
149
+ `;
150
+
151
+ /**
152
+ * Prove the isolation of a backend with real attempts, independent of any evaluator flag. The result is
153
+ * `verified` only if the one permitted read worked, the scratch write worked and every other attempt was refused.
154
+ */
155
+ export async function probeSandbox(sb, opts = {}) {
156
+ if (!sb || !sb.available) return { verified: false, backend: sb ? sb.backend : 'none', reason: (sb && sb.reason) || 'no sandbox', attempts: {} };
157
+ const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
158
+ const base = mkdtempSync(join(tmpdir(), 'nix-eval-probe-'));
159
+ const allowed = join(base, 'ro'); const scratch = join(base, 'rw'); const outside = join(base, 'outside');
160
+ for (const d of [allowed, scratch, outside]) mkdirSync(d);
161
+ writeFileSync(join(allowed, 'a.txt'), 'allowed'); writeFileSync(join(outside, 'secret.txt'), 'secret');
162
+ const script = join(base, 'probe.cjs'); writeFileSync(script, PROBE_SOURCE);
163
+ const sockPath = join(base, 'daemon.sock');
164
+ let connections = 0;
165
+ const tcp = netServer((s) => { connections++; s.destroy(); });
166
+ const uds = netServer((s) => { connections++; s.destroy(); });
167
+ await new Promise((r) => tcp.listen(0, '127.0.0.1', r));
168
+ await new Promise((r) => uds.listen(sockPath, r));
169
+ const port = tcp.address().port;
170
+ try {
171
+ const exe = opts.nodePath || process.execPath;
172
+ const cmd = sandboxCommand(sb, { execFile: exe, args: [script, join(allowed, 'a.txt'), join(outside, 'secret.txt'), join(outside, 'w.txt'), join(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, dirname(dirname(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
173
+ const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
174
+ let attempts = null;
175
+ try { attempts = JSON.parse(r.stdout.trim()); } catch { /* unparseable: not verified */ }
176
+ if (!attempts) return { verified: false, backend: sb.backend, reason: `the probe did not complete inside the sandbox (${r.reason || r.stderr.slice(0, 120) || 'no output'})`, attempts: {} };
177
+ const insufficient = [];
178
+ if (attempts.readAllowed !== 'allowed') insufficient.push('the allowlisted read failed: the sandbox is unusable');
179
+ if (attempts.writeScratch !== 'wrote') insufficient.push('the scratch write failed: the sandbox is unusable');
180
+ if (attempts.readOutside === 'READ') insufficient.push('a file outside the allowlist could be read');
181
+ if (attempts.readHostFile === 'READ') insufficient.push('a host file (/etc/hosts) could be read');
182
+ if (attempts.writeOutside === 'WROTE') insufficient.push('a file outside the scratch directory could be written');
183
+ if (attempts.network === 'CONNECTED') insufficient.push('a network connection succeeded');
184
+ if (attempts.daemonSocket === 'CONNECTED') insufficient.push('a unix daemon socket could be reached');
185
+ if (attempts.spawn === 'SPAWNED') insufficient.push('a native process could be spawned');
186
+ if (attempts.credentialEnv && attempts.credentialEnv !== 'absent') insufficient.push('a credential environment variable was visible');
187
+ if (attempts.timeout) insufficient.push('the probe timed out');
188
+ return { verified: insufficient.length === 0 && connections === 0, backend: sb.backend, reason: insufficient.length ? insufficient.join('; ') : (connections ? 'the probe listeners saw a connection' : null), attempts, listenerConnections: connections, insufficient };
189
+ } finally { tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true }); }
190
+ }
191
+
192
+ // ── 3. evaluator feature detection ───────────────────────────────────────────
193
+ /** @param {{file:string, prefixArgs?:string[]}} nix the evaluator command (a stand-in in tests) */
194
+ export async function detectEvaluator(nix, sb, ctx) {
195
+ const run = async (args) => {
196
+ const cmd = sandboxCommand(sb, { execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
197
+ return supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
198
+ };
199
+ const v = await run(['--version']);
200
+ const ver = (/(\d+\.\d+(?:\.\d+)?)/.exec(v.stdout) || [])[1] || null;
201
+ if (v.status !== 'ok' || !ver) return { ok: false, reason: `the evaluator did not report a version (${v.reason || 'no output'})`, version: null, missing: REQUIRED_EVAL_FLAGS.slice() };
202
+ const h = await run(['eval', '--help']);
203
+ const text = `${h.stdout}\n${h.stderr}`;
204
+ const missing = REQUIRED_EVAL_FLAGS.filter((f) => !text.includes(f));
205
+ return { ok: missing.length === 0, version: ver, missing, reason: missing.length ? `the evaluator lacks required safety flag(s): ${missing.join(', ')}` : null };
206
+ }
207
+
208
+ // ── target selection and argv ────────────────────────────────────────────────
209
+ /** A flake output attribute, validated. Anything outside a plain attribute path is refused. */
210
+ export function selectTarget({ root, attribute, system = null } = {}) {
211
+ if (typeof root !== 'string' || !root.startsWith('/')) return { ok: false, reason: 'the project root must be an absolute path' };
212
+ if (typeof attribute !== 'string' || !ATTR_RE.test(attribute) || attribute.length > 200) return { ok: false, reason: `"${String(attribute).slice(0, 40)}" is not a plain flake attribute path` };
213
+ if (/\.\./.test(attribute)) return { ok: false, reason: 'attribute paths may not contain ..' };
214
+ return { ok: true, installable: `path:${root}#${attribute}`, root, attribute, system };
215
+ }
216
+ export function buildEvalArgs(target) {
217
+ const opts = Object.entries(SAFETY_OPTIONS).flatMap(([k, v]) => ['--option', k, v]);
218
+ return ['eval', '--offline', '--no-update-lock-file', '--no-write-lock-file', '--pure-eval', '--json', ...opts, target.installable];
219
+ }
220
+
221
+ // ── 4. run ───────────────────────────────────────────────────────────────────
222
+ export const IMPORT_FALLBACK = Object.freeze({
223
+ summary: 'Evaluation did not run or did not finish. Static findings are unaffected. To supply the data yourself, generate an export on a trusted machine and pass it in:',
224
+ steps: ['nix path-info --json --recursive <installable> > pathinfo.json', 'nix derivation show --recursive <installable> > drvshow.json', 'Provide both with a provenance envelope (tool, command, target system and installable, flake.lock sha256, generation time); see docs/guides/nix.md'],
225
+ schemas: ['nix-path-info-json', 'nix-derivation-show-json', 'nix-store-query-text'],
226
+ });
227
+
228
+ /**
229
+ * @param {{nix:{file:string,prefixArgs?:string[]}, root:string, attribute:string, system?:string, limits?:object,
230
+ * sandbox?:object, extraReadOnly?:string[], probe?:Function, env?:object}} o
231
+ * @returns {Promise<{status:'ok'|'unsupported'|'blocked'|'timed-out'|'output-limit'|'resource-limit'|'failed', projectCodeEvaluated:boolean, ...}>}
232
+ */
233
+ export async function runIsolatedEval(o) {
234
+ const limits = { ...DEFAULT_LIMITS, ...(o.limits || {}) };
235
+ const keep = { staticFindingsRetained: true, fallback: IMPORT_FALLBACK, evaluation: 'opt-in' };
236
+ const stopBefore = (status, reason, extra = {}) => ({ status, reason, projectCodeEvaluated: false, ...keep, ...extra });
237
+ const target = selectTarget({ root: o.root, attribute: o.attribute, system: o.system });
238
+ if (!target.ok) return stopBefore('blocked', target.reason);
239
+ const sb = o.sandbox || detectSandbox();
240
+ if (!sb.available) return stopBefore('unsupported', `no isolation is available (${sb.reason}); the project's Nix code was NOT evaluated`, { sandbox: sb });
241
+ const probe = await (o.probe || probeSandbox)(sb, { limits });
242
+ if (!probe.verified) return stopBefore('blocked', `the sandbox did not prove its isolation, so the project's Nix code was NOT evaluated: ${probe.reason}`, { sandbox: sb, probe });
243
+ const work = mkdtempSync(join(tmpdir(), 'nix-eval-work-'));
244
+ try {
245
+ const root = real(o.root);
246
+ const nixFiles = [nix_file(o.nix), ...(o.nix.prefixArgs || []).filter((a) => a && !a.startsWith('-')).map(real)];
247
+ const ctx = { work: real(work), readOnly: [root, dirname(dirname(real(o.nix.file))), ...(o.extraReadOnly || [])], readFiles: nixFiles, limits };
248
+ const feat = await detectEvaluator(o.nix, sb, ctx);
249
+ if (!feat.ok) return stopBefore('unsupported', `${feat.reason}; the project's Nix code was NOT evaluated`, { sandbox: sb, probe: { verified: true }, evaluator: feat });
250
+ const args = [...(o.nix.prefixArgs || []), ...buildEvalArgs(target)];
251
+ const cmd = sandboxCommand(sb, { execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
252
+ const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
253
+ const base = { ...keep, projectCodeEvaluated: true, sandbox: { backend: sb.backend }, evaluator: { version: feat.version }, ms: r.ms, target: { attribute: target.attribute, system: target.system, installable: target.installable } };
254
+ if (r.status !== 'ok') return { ...base, status: r.status === 'failed' ? 'failed' : r.status, reason: r.reason || r.stderr.slice(0, 200) };
255
+ let data;
256
+ try { data = JSON.parse(r.stdout); } catch { return { ...base, status: 'failed', reason: 'the evaluator output was not JSON' }; }
257
+ const digest = createHash('sha256').update(r.stdout).digest('hex');
258
+ return { ...base, status: 'ok', reason: null, export: { schema: 'nix-eval-attrs-json', provenance: { tool: 'nix', toolVersion: feat.version, command: `nix ${buildEvalArgs(target).join(' ')}`, target: { system: target.system, installable: target.installable, attribute: target.attribute }, generatedAt: new Date().toISOString(), isolation: sb.backend, outputSha256: digest }, data }, outputSha256: digest };
259
+ } finally { rmSync(work, { recursive: true, force: true }); }
260
+ }
261
+ const nix_file = (n) => real(n.file);
262
+
263
+ /** Evaluation health is its OWN state: it never replaces, empties or downgrades the static scan. */
264
+ export function mergeEvaluationHealth(scanHealth, result) {
265
+ const h = scanHealth && typeof scanHealth === 'object' ? { ...scanHealth } : {};
266
+ const requested = !!result;
267
+ h.evaluation = requested
268
+ ? { requested: true, status: result.status, ran: result.projectCodeEvaluated === true, reason: result.reason || null, ms: result.ms ?? null, fallback: result.status === 'ok' ? null : result.fallback || IMPORT_FALLBACK }
269
+ : { requested: false, status: 'not-requested', ran: false, reason: 'evaluation is opt-in; the scan is static', fallback: null };
270
+ h.staticFindingsRetained = true;
271
+ return h;
272
+ }
273
+
274
+ export { cleanEnv as _cleanEnv, which as _which };