@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
package/dist/259.index.js DELETED
@@ -1,975 +0,0 @@
1
- export const id = 259;
2
- export const ids = [259];
3
- export const modules = {
4
-
5
- /***/ 8259:
6
- /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
-
8
-
9
- // EXPORTS
10
- __webpack_require__.d(__webpack_exports__, {
11
- proveFinding: () => (/* binding */ proveFinding)
12
- });
13
-
14
- // EXTERNAL MODULE: external "node:fs"
15
- var external_node_fs_ = __webpack_require__(3024);
16
- // EXTERNAL MODULE: external "node:os"
17
- var external_node_os_ = __webpack_require__(8161);
18
- // EXTERNAL MODULE: external "node:path"
19
- var external_node_path_ = __webpack_require__(6760);
20
- // EXTERNAL MODULE: external "node:child_process"
21
- var external_node_child_process_ = __webpack_require__(1421);
22
- ;// CONCATENATED MODULE: ./src/sandbox/limits.js
23
- // Resource caps applied as a shell prelude, shared by every real backend.
24
- //
25
- // Address-space capping (`ulimit -v`) is NOT enforced on the macOS family —
26
- // verified by execution. We therefore DECLARE it unsupported rather than
27
- // emitting a limit that silently does nothing, which would be a false
28
- // assurance of containment.
29
-
30
-
31
- // `ulimit -u` (RLIMIT_NPROC) is charged per *uid*, system-wide, on both
32
- // platforms this module supports — it is not a per-process-tree cap. A fixed
33
- // default like the 64 below therefore breaks ordinary, non-adversarial runs on
34
- // any host whose user already owns more than ~64 processes, which is most of
35
- // them: the confined shell cannot even fork the helpers it needs to set itself
36
- // up, and the failure looks like a broken sandbox rather than a cap doing its
37
- // job. So unless a caller passes an explicit `maxProcs`, both real backends
38
- // derive one from the ambient count for this uid. That keeps default behaviour
39
- // usable without pretending a low fixed cap is real containment — see the
40
- // "fork-storm containment is weak" note in the module guide.
41
- function ambientRelativeMaxProcs(headroom = 64) {
42
- let ambient = 200;
43
- try {
44
- const out = (0,external_node_child_process_.spawnSync)('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
45
- ambient = Number(String(out.stdout || '').trim()) || 200;
46
- } catch { /* fall through to the conservative default */ }
47
- return ambient + headroom;
48
- }
49
-
50
- function buildLimitPrelude({
51
- maxProcs = 64,
52
- maxFileSizeKb = 65536,
53
- maxAddressSpaceKb = null,
54
- } = {}) {
55
- const parts = [];
56
- const unsupported = [];
57
-
58
- if (maxProcs != null) parts.push(`ulimit -u ${_num('maxProcs', maxProcs)}`);
59
- if (maxFileSizeKb != null) parts.push(`ulimit -f ${_num('maxFileSizeKb', maxFileSizeKb)}`);
60
-
61
- if (maxAddressSpaceKb != null) {
62
- if (process.platform === 'linux') parts.push(`ulimit -v ${_num('maxAddressSpaceKb', maxAddressSpaceKb)}`);
63
- else unsupported.push('maxAddressSpaceKb');
64
- }
65
-
66
- const prelude = parts.length ? parts.join('; ') + '; ' : '';
67
- return { prelude, unsupported };
68
- }
69
-
70
- /**
71
- * Limit values are interpolated into a shell fragment, so a non-numeric value
72
- * is shell text. Verified by execution: `maxProcs: '999; echo INJECTED'`
73
- * emitted `ulimit -u 999; echo INJECTED` and the payload ran. That is not a
74
- * sandbox escape (the prelude runs INSIDE the confinement) but it lets a
75
- * config-derived value silently DISABLE the very limits it was meant to set —
76
- * e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
77
- * anything that is not a finite, non-negative number.
78
- */
79
- function _num(name, v) {
80
- const n = Number(v);
81
- if (!Number.isFinite(n) || n < 0) {
82
- throw new RangeError(`${name} must be a finite, non-negative number (got ${JSON.stringify(v)})`);
83
- }
84
- return Math.floor(n);
85
- }
86
-
87
- ;// CONCATENATED MODULE: ./src/sandbox/result.js
88
- // Shared result construction for every real backend.
89
- //
90
- // WHY THIS EXISTS (the misread it prevents): status used to be derived purely
91
- // from the exit code — `exitCode !== 0` was reported as `'blocked'`. That
92
- // conflates two entirely different outcomes:
93
- //
94
- // 1. A program that ran fine and chose to exit non-zero (a failing test, a
95
- // grep with no match) was labelled 'blocked' — a false confinement claim.
96
- // 2. A program whose out-of-root write was DENIED but which still exited 0
97
- // was labelled 'ok' — the caller saw a clean run and could not tell that
98
- // the sandbox had refused something. Verified by execution: the denied
99
- // write returns exit 0 when the command swallows the failure.
100
- //
101
- // So the two signals are now separated:
102
- //
103
- // - `denied` — a confinement violation was OBSERVED in the child's error
104
- // output. Best effort, see the honesty note below.
105
- // - `status` — 'blocked' when a denial was observed, 'nonzero' when the
106
- // command merely exited non-zero with no denial signal, 'ok'
107
- // only when it exited 0 with no denial signal.
108
- //
109
- // HONESTY NOTE — what `denied:false` does and does not mean. The denial signal
110
- // is read from the confined process's own stderr (the OS primitives here do
111
- // not hand the parent a structured violation channel). A program that writes
112
- // out of root and swallows its own error message produces NO signal, so
113
- // `denied:false` means "no denial was observed", NOT "no denial occurred".
114
- // Never treat `status:'ok'` as proof that nothing was refused. It is proof
115
- // only that the command exited 0 and said nothing about a refusal.
116
-
117
- const DENIAL_PATTERNS = [
118
- /operation not permitted/i,
119
- /permission denied/i,
120
- /read-only file system/i,
121
- /deny file-write/i,
122
- /deny network/i,
123
- /network is unreachable/i,
124
- ];
125
-
126
- /** True iff the confined process's error output shows an observed denial. */
127
- function detectDenial(stderr) {
128
- const s = String(stderr || '');
129
- return DENIAL_PATTERNS.some((re) => re.test(s));
130
- }
131
-
132
- /**
133
- * Build the single result shape every backend returns. `status` is one of
134
- * 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
135
- */
136
- function buildResult({ backend, spawnResult, unsupported = [] }) {
137
- const r = spawnResult;
138
- const rawStderr = r.stderr ?? '';
139
- const timedOut = r.error?.code === 'ETIMEDOUT';
140
- const denied = detectDenial(rawStderr);
141
-
142
- let status;
143
- if (timedOut) status = 'timeout';
144
- else if (r.error) status = 'error';
145
- else if (denied) status = 'blocked';
146
- else if (r.status !== 0) status = 'nonzero';
147
- else status = 'ok';
148
-
149
- return {
150
- status,
151
- denied,
152
- stdout: r.stdout ?? '',
153
- stderr: rawStderr + (unsupported.length ? `\n[sandbox] not enforceable here: ${unsupported.join(', ')}` : ''),
154
- exitCode: r.status ?? null,
155
- timedOut,
156
- backend,
157
- };
158
- }
159
-
160
- /** Documented-shape error result: runConfined never throws at its callers. */
161
- function errorResult(backend, message) {
162
- return {
163
- status: 'error',
164
- denied: false,
165
- stdout: '',
166
- stderr: `agentic-security: ${message}`,
167
- exitCode: null,
168
- timedOut: false,
169
- backend,
170
- };
171
- }
172
-
173
- /**
174
- * Minimal environment handed to untrusted code. The parent environment is NOT
175
- * forwarded: it routinely carries credentials (tokens, cloud keys, registry
176
- * auth) and the confined program can read and exfiltrate them. This is a
177
- * distinct exposure from the accepted "reads are not confined" scope cut —
178
- * that one is about files on disk, this one is about secrets the parent hands
179
- * over for free. Callers that need extra variables pass them explicitly via
180
- * `opts.env`, which is merged on top of this base.
181
- */
182
- function buildConfinedEnv({ root, env = {} } = {}) {
183
- return {
184
- PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
185
- ROOT: root,
186
- HOME: root,
187
- TMPDIR: root,
188
- LANG: 'C',
189
- ...env,
190
- };
191
- }
192
-
193
- ;// CONCATENATED MODULE: ./src/sandbox/backend-userspace.js
194
- // Userspace confinement backend (macOS family). Applies a deny-by-default
195
- // policy profile: reads allowed, writes confined to the sandbox root, no
196
- // network egress unless explicitly opted in.
197
- //
198
- // TIMEOUT SCOPE — read before trusting `status:'timeout'`. The wall-clock
199
- // timeout is `spawnSync`'s, which signals only the DIRECT child. Verified by
200
- // execution on this platform: with `timeoutMs: 1200`, a command that
201
- // backgrounded a 4-second child returned `status:'timeout'` while the
202
- // grandchild survived the timeout and completed its work afterwards. So
203
- // 'timeout' means "we stopped waiting and killed the process we spawned", NOT
204
- // "the process tree was terminated". Anything left running is still inside the
205
- // policy profile (its writes and network stay confined), but it is still
206
- // running. Callers that need a hard tree kill must supply it themselves.
207
-
208
-
209
-
210
-
211
-
212
-
213
- function _profile({ allowNetwork }) {
214
- return [
215
- '(version 1)',
216
- '(deny default)',
217
- '(allow process-exec process-fork)',
218
- '(allow sysctl-read)',
219
- '(allow file-read*)',
220
- '(allow file-write* (subpath (param "ROOT")))',
221
- allowNetwork ? '(allow network*)' : '',
222
- ].filter(Boolean).join('\n');
223
- }
224
-
225
- function runUserspace(argv, {
226
- root,
227
- timeoutMs = 10000,
228
- allowNetwork = false,
229
- limits = {},
230
- env = {},
231
- maxBuffer = 8 * 1024 * 1024,
232
- } = {}) {
233
- // Documented shape, never a throw: a caller that wraps this in try/catch and
234
- // "falls back" is a classic route to unconfined execution.
235
- if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
236
-
237
- const bin = resolveUserspaceBin();
238
- if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
239
-
240
- let resolvedRoot;
241
- try {
242
- // Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
243
- // subpath param matches the path the kernel actually sees.
244
- resolvedRoot = external_node_fs_.realpathSync(root);
245
- } catch (e) {
246
- return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
247
- }
248
-
249
- const effectiveLimits = {
250
- ...limits,
251
- maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
252
- };
253
-
254
- let prelude, unsupported;
255
- try {
256
- ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
257
- } catch (e) {
258
- return errorResult('userspace', `invalid resource limit: ${e.message}`);
259
- }
260
- const inner = `${prelude}exec "$@"`;
261
-
262
- const r = (0,external_node_child_process_.spawnSync)(
263
- bin,
264
- ['-p', _profile({ allowNetwork }), '-D', `ROOT=${resolvedRoot}`,
265
- '/bin/sh', '-c', inner, '_sbx', ...argv],
266
- {
267
- encoding: 'utf8',
268
- timeout: timeoutMs,
269
- maxBuffer,
270
- cwd: resolvedRoot,
271
- env: buildConfinedEnv({ root: resolvedRoot, env }),
272
- },
273
- );
274
-
275
- return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
276
- }
277
-
278
- ;// CONCATENATED MODULE: ./src/sandbox/backend-namespace.js
279
- // Kernel-namespace confinement backend (Linux family).
280
- //
281
- // STATUS. This backend cannot be exercised on the macOS development host — the
282
- // required kernel-namespace tool is absent, so its escape tests skip with a
283
- // recorded reason there. Whether the confinement described below actually
284
- // holds is a per-host fact that only a Linux host can answer, and only by
285
- // EXECUTING the escape suite. Do not read this comment as a verification
286
- // claim; read `src/sandbox/CLAUDE.md` for what has and has not been executed.
287
- //
288
- // WHAT THIS BACKEND CONFINES.
289
- //
290
- // 1. NETWORK EGRESS — an empty network namespace (`--net`, unless the caller
291
- // passes `allowNetwork`). It has no route anywhere.
292
- //
293
- // 2. FILESYSTEM WRITES — a private mount namespace in which every mount
294
- // point present at setup time is rebound READ-ONLY, and only the sandbox
295
- // root is rebound read-write. An out-of-root write therefore fails with
296
- // EROFS. That error text is one of `result.js`'s denial patterns, so an
297
- // escape attempt surfaces as `status:'blocked'` + `denied:true` — the
298
- // same shape the userspace backend produces, which is the main reason
299
- // this shape was chosen over `pivot_root` (see below).
300
- //
301
- // 3. RESOURCE CAPS — the shared `ulimit` prelude.
302
- //
303
- // WHY READ-ONLY REBIND RATHER THAN pivot_root. `pivot_root` into the sandbox
304
- // root is the stronger primitive: after detaching the old root, out-of-root
305
- // paths are not merely read-only, they are absent from the mount namespace
306
- // entirely. It was rejected here for three concrete reasons. (a) It requires
307
- // materialising a system tree (the shell, the C library, the utilities a PoC
308
- // invokes) inside the caller's sandbox root, which pollutes a directory the
309
- // caller owns and reads back. (b) It changes path semantics: `$ROOT` becomes
310
- // `/`, so a caller's absolute paths mean something different on this backend
311
- // than on the userspace one, and the two backends stop being interchangeable.
312
- // (c) An out-of-root write would then fail with ENOENT, which is
313
- // indistinguishable from an ordinary missing path and cannot be reported as a
314
- // confinement denial — the caller loses the `denied` signal precisely where it
315
- // matters most. The read-only rebind keeps paths, keeps the denial signal, and
316
- // keeps both backends returning the same thing for the same escape attempt.
317
- //
318
- // HONEST LIMIT OF THE READ-ONLY REBIND. The namespaces are acquired by
319
- // creating a user namespace, and the confined process is therefore (initially)
320
- // privileged inside it — it holds CAP_SYS_ADMIN over the mount namespace it
321
- // runs in, and could rebind the tree read-write again. That would gut the
322
- // confinement, so after the mounts are established and before the caller's
323
- // command is executed, the backend drops the whole capability set (bounding,
324
- // inheritable, and — via the `noroot` secure bits — the implicit privileges of
325
- // uid 0) and only then executes. If the privilege-dropping utility is not
326
- // present on the host the command still runs under the read-only mount tree,
327
- // but the result DECLARES `privilegeDrop` unenforced (in `unsupported`, the
328
- // same mechanism `limits.js` uses) rather than pretending the hardening
329
- // applied. It is never silently skipped.
330
- //
331
- // FAIL-CLOSED, AND VERIFIED PER RUN RATHER THAN ASSUMED. Every step that
332
- // establishes confinement aborts the run on failure: no namespace variant, no
333
- // filesystem-attach utility, a mount tree that cannot be made read-only, a
334
- // sandbox root that turns out not to be writable — each returns
335
- // `status:'error'` with nothing executed. Beyond that, the confinement is PROVEN by execution on
336
- // every single run: the parent creates a canary path OUTSIDE the sandbox root,
337
- // and the confined shell — already in its final, deprivileged state —
338
- // attempts to create it. If that write succeeds, confinement is not in force
339
- // and the shell exits WITHOUT running the caller's command. A reasoned
340
- // expectation that "the remount should have worked" is exactly the class of
341
- // claim this module exists to refuse.
342
- //
343
- // TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
344
- // the direct child. On this backend the direct child is the namespace tool
345
- // running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
346
- // expected to take the whole namespace's processes with it — better than the
347
- // userspace backend, where a backgrounded grandchild demonstrably survives.
348
- // "Expected", NOT verified, and this one did not clear with the rest: the
349
- // escape suite has now RUN and passed on a Linux runner, but its wall-clock
350
- // case asserts only that the DIRECT CHILD is stopped. No test observes whether
351
- // a backgrounded grandchild dies with the PID namespace, so tree-kill remains
352
- // a reasoned expectation. Do not state it as a guarantee until a test asserts
353
- // the grandchild is gone.
354
- //
355
- // PRIVILEGE. Creating mount/PID/IPC/UTS/network namespaces directly requires
356
- // CAP_SYS_ADMIN, which an ordinary CI account does not have — asking for them
357
- // bare fails with a permission error and the backend cannot start at all. The
358
- // unprivileged route is to create a USER namespace first and take the
359
- // requested namespaces inside it, where the invoking user holds the
360
- // capabilities. So the flag set is chosen by PROBE, not assumed: each variant
361
- // below is executed with a trivial command and the first one that actually
362
- // succeeds is used (and cached). Fail-closed: if no variant works the backend
363
- // returns status 'error' and nothing runs. The confinement flags are NEVER
364
- // relaxed to make a run succeed — dropping `--net` would remove the network
365
- // confinement, so `--net` is part of every probed variant when `allowNetwork`
366
- // is false, and `--mount` is in every variant unconditionally because the
367
- // write confinement is built inside it.
368
-
369
-
370
-
371
-
372
-
373
-
374
-
375
-
376
- // Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
377
- // prefix; the namespace flags themselves are appended identically to all of
378
- // them by `_nsArgs`, so no variant can quietly confine less than another.
379
- //
380
- // 1. user namespace with the invoking user mapped to root inside it — the
381
- // unprivileged route, and the one a standard CI runner needs. It is also
382
- // the only variant under which the write confinement can be built, since
383
- // rebinding the mount tree needs CAP_SYS_ADMIN in the owning namespace.
384
- // 2. user namespace with the invoking user mapped to itself — for hosts
385
- // whose policy permits a user namespace but not the root mapping.
386
- // 3. no prefix — the direct route, which needs CAP_SYS_ADMIN (i.e. root).
387
- // Last so an unprivileged host never pays for a doomed attempt first.
388
- const NS_PRIVILEGE_VARIANTS = Object.freeze([
389
- Object.freeze(['--user', '--map-root-user']),
390
- Object.freeze(['--user', '--map-current-user']),
391
- Object.freeze([]),
392
- ]);
393
-
394
- function _nsArgs(privilegeFlags, allowNetwork) {
395
- const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
396
- if (!allowNetwork) a.push('--net');
397
- return a;
398
- }
399
-
400
- // Markers the confined shell writes to its own stderr so the parent can tell
401
- // a confinement-setup failure from ordinary program output. They are stripped
402
- // from the stderr handed back to the caller.
403
- //
404
- // A payload that PRINTS one of these strings can force `status:'error'` (or a
405
- // false `privilegeDrop` unenforced note). That is the safe direction: the
406
- // worst it achieves is making its own run look like it did not happen, which
407
- // no downstream tier reads as evidence of anything. It cannot make an
408
- // unconfined run look confined.
409
- const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
410
- const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
411
-
412
- // Runs inside the namespaces, still privileged, before the caller's command.
413
- // Builds the write confinement, then hands off to $SBX_FINAL with the
414
- // capability set dropped.
415
- //
416
- // Order matters: the sandbox root is bound onto itself while the tree is still
417
- // writable, so the read-only pass and the read-write rebind of the root never
418
- // have to fight each other. Individual sub-mounts are best-effort (some pseudo
419
- // filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
420
- // what actually decides whether the result is trustworthy.
421
- const SETUP_SCRIPT = `
422
- _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
423
- "$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
424
- "$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
425
- "$SBX_MOUNT" --bind "$ROOT" "$ROOT" || _fail "the sandbox root could not be bind-mounted"
426
- _mps=$(while read -r _a _b _c _d _mp _rest; do printf '%s\\n' "$_mp"; done < /proc/self/mountinfo)
427
- for _mp in $_mps; do
428
- [ "$_mp" = "/" ] && continue
429
- [ "$_mp" = "$ROOT" ] && continue
430
- case "$_mp" in "$ROOT"/*) continue ;; esac
431
- "$SBX_MOUNT" -o remount,bind,ro "$_mp" 2>/dev/null || true
432
- done
433
- "$SBX_MOUNT" -o remount,bind,ro / || _fail "the root filesystem could not be rebound read-only"
434
- # Belt and braces: the root was bound before the read-only pass and skipped by
435
- # it, so this is normally a no-op. Its return code is NOT the gate — the
436
- # executed in-root write check in $SBX_FINAL is, and that one fails closed.
437
- "$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
438
- if [ -n "$SBX_PRIVDROP" ] && "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c 'exit 0' 2>/dev/null; then
439
- exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
440
- fi
441
- echo "${MARK_NO_PRIVDROP}" >&2
442
- exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
443
- `;
444
-
445
- // Runs in the FINAL privilege state, immediately before the caller's command.
446
- // Both directions are checked by execution, every run: the out-of-root canary
447
- // must be refused, and an in-root write must succeed. Either check failing
448
- // means the sandbox is not what it claims, so the command is not run.
449
- const FINAL_SCRIPT = `
450
- _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
451
- if ( : > "$SBX_CANARY" ) 2>/dev/null; then
452
- _fail "an out-of-root write is still possible; refusing to execute"
453
- fi
454
- if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
455
- _fail "the sandbox root is not writable; refusing to execute"
456
- fi
457
- rm -f "$ROOT/.agsec-sbx-wcheck"
458
- cd "$ROOT" && exec "$@"
459
- `;
460
-
461
- /**
462
- * The first privilege variant under which the requested namespaces can
463
- * actually be created on this host, or null when none can. Probed by running
464
- * a trivial command — a reasoned expectation about which flags "should" work
465
- * is exactly what made this backend unusable on an unprivileged runner.
466
- */
467
- function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
468
- const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
469
- const cached = cachedNamespaceVariant(key);
470
- if (cached !== undefined) return cached;
471
-
472
- let chosen = null;
473
- for (const variant of NS_PRIVILEGE_VARIANTS) {
474
- const args = _nsArgs(variant, allowNetwork);
475
- const probe = (0,external_node_child_process_.spawnSync)(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
476
- encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
477
- });
478
- if (!probe.error && probe.status === 0) { chosen = args; break; }
479
- }
480
- cacheNamespaceVariant(key, chosen);
481
- return chosen;
482
- }
483
-
484
- /** Strip the internal markers from stderr before it reaches the caller. */
485
- function _cleanStderr(s) {
486
- return String(s || '')
487
- .split('\n')
488
- .filter((l) => !l.includes(MARK_SETUP_FAILED) && l.trim() !== MARK_NO_PRIVDROP)
489
- .join('\n');
490
- }
491
-
492
- function _setupFailureReason(stderr) {
493
- for (const line of String(stderr || '').split('\n')) {
494
- const i = line.indexOf(MARK_SETUP_FAILED);
495
- if (i !== -1) return line.slice(i + MARK_SETUP_FAILED.length).trim();
496
- }
497
- return null;
498
- }
499
-
500
- function runNamespace(argv, {
501
- root,
502
- timeoutMs = 10000,
503
- allowNetwork = false,
504
- limits = {},
505
- env = {},
506
- maxBuffer = 8 * 1024 * 1024,
507
- } = {}) {
508
- // Documented shape, never a throw — see the same note in backend-userspace.
509
- if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
510
-
511
- const bin = resolveNamespaceBin();
512
- if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
513
-
514
- // Write confinement is built with this utility. No utility, no confinement,
515
- // no run — there is deliberately no branch that proceeds without it.
516
- const mountBin = resolveMountBin();
517
- if (!mountBin) {
518
- return errorResult('namespace',
519
- 'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined');
520
- }
521
-
522
- let resolvedRoot;
523
- try {
524
- // Resolve symlinks so the path the kernel actually sees matches what we
525
- // hand to the child.
526
- resolvedRoot = external_node_fs_.realpathSync(root);
527
- } catch (e) {
528
- return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
529
- }
530
-
531
- // Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
532
- // the confined shell has to fork several helpers to BUILD its confinement,
533
- // so a fixed cap below the ambient count for this uid makes the setup itself
534
- // fail and the sandbox look broken. See `ambientRelativeMaxProcs`.
535
- const effectiveLimits = { ...limits, maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs() };
536
-
537
- let prelude, unsupported;
538
- try {
539
- ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
540
- } catch (e) {
541
- return errorResult('namespace', `invalid resource limit: ${e.message}`);
542
- }
543
-
544
- // Fail closed: no usable variant means the confinement cannot be
545
- // established, so nothing is executed. There is deliberately no path that
546
- // drops confinement flags and runs anyway.
547
- const nsArgs = resolveNamespaceArgs(bin, allowNetwork);
548
- if (!nsArgs) {
549
- return errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined');
550
- }
551
-
552
- // The canary lives OUTSIDE the sandbox root, in a directory this process
553
- // just created and can write. If the confined shell can create it, the
554
- // confinement is not in force and the command is not run.
555
- let canaryDir = null;
556
- try {
557
- canaryDir = external_node_fs_.mkdtempSync(external_node_path_.join(external_node_os_.tmpdir(), 'agsec-sbx-canary-'));
558
- } catch (e) {
559
- return errorResult('namespace', `could not create the confinement canary: ${e.message}`);
560
- }
561
- const canary = external_node_path_.join(canaryDir, 'out-of-root.canary');
562
-
563
- let r;
564
- try {
565
- r = (0,external_node_child_process_.spawnSync)(
566
- bin,
567
- [...nsArgs, '/bin/sh', '-c', prelude + SETUP_SCRIPT, '_sbx', ...argv],
568
- {
569
- encoding: 'utf8',
570
- timeout: timeoutMs,
571
- maxBuffer,
572
- cwd: resolvedRoot,
573
- env: {
574
- ...buildConfinedEnv({ root: resolvedRoot, env }),
575
- SBX_MOUNT: mountBin,
576
- SBX_PRIVDROP: resolvePrivDropBin() || '',
577
- SBX_CANARY: canary,
578
- SBX_FINAL: FINAL_SCRIPT,
579
- },
580
- },
581
- );
582
-
583
- // Parent-side confirmation of the same fact the canary check asserts from
584
- // the inside. Cheap, and it does not depend on the confined shell being
585
- // honest about its own exit code.
586
- if (external_node_fs_.existsSync(canary)) {
587
- return errorResult('namespace',
588
- 'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host');
589
- }
590
- } finally {
591
- try { external_node_fs_.rmSync(canaryDir, { recursive: true, force: true }); } catch { /* best effort */ }
592
- }
593
-
594
- const rawStderr = r.stderr ?? '';
595
- const setupFailure = _setupFailureReason(rawStderr);
596
- if (setupFailure && !r.error) {
597
- // Confinement could not be established (or could not be proven). Nothing
598
- // ran: the shell exits before `exec`ing the caller's command.
599
- return errorResult('namespace', `confinement could not be established: ${setupFailure}`);
600
- }
601
-
602
- const effectiveUnsupported = [...unsupported];
603
- if (rawStderr.includes(MARK_NO_PRIVDROP)) effectiveUnsupported.push('privilegeDrop');
604
-
605
- return buildResult({
606
- backend: 'namespace',
607
- spawnResult: { ...r, stderr: _cleanStderr(rawStderr) },
608
- unsupported: effectiveUnsupported,
609
- });
610
- }
611
-
612
- ;// CONCATENATED MODULE: ./src/sandbox/capabilities.js
613
- // Detects which OS confinement primitive is available. Fail-closed: when none
614
- // is found we report 'disabled', which REFUSES execution rather than running
615
- // target code unconfined.
616
- //
617
- // DETECTION IS FUNCTIONAL, NOT PRESENCE-BASED. An earlier version concluded
618
- // "available" from "the confinement binary is executable". That is a different
619
- // question from the one callers are actually asking. Verified on a Linux CI
620
- // runner: the kernel-namespace tool is installed and executable, but the
621
- // distribution restricts unprivileged user-namespace creation, so every
622
- // privilege variant fails and no confined command can start. Presence-based
623
- // detection reported the backend as available anyway, and `sandboxAvailable()`
624
- // — the signal callers use to decide whether it is safe to EXECUTE UNTRUSTED
625
- // CODE — answered true while nothing could actually be confined. False
626
- // assurance about confinement is precisely the failure this module exists to
627
- // prevent, so a backend now counts as available only if it just ran a trivial
628
- // command through its real code path.
629
- //
630
- // The probe result is cached for the process (one spawn, not one per call —
631
- // detection sits on the path of ordinary scans) and cleared by
632
- // `resetCapabilityCache()`.
633
-
634
-
635
-
636
-
637
-
638
-
639
- // Referenced by path, never by product name (see Global Constraints).
640
- //
641
- // Each family lists every plausible install location, probed in order. A
642
- // single hardcoded path is safe (a miss fails closed to 'disabled') but it is
643
- // a FALSE NEGATIVE: a host that does have the primitive somewhere else loses
644
- // the sandbox silently. Probing the candidate set removes that failure mode.
645
- const CONFINE_BINS_USERSPACE = Object.freeze([
646
- '/usr/bin/sandbox-exec',
647
- '/usr/local/bin/sandbox-exec',
648
- ]);
649
- const CONFINE_BINS_NAMESPACE = Object.freeze([
650
- '/usr/bin/unshare',
651
- '/bin/unshare',
652
- '/usr/local/bin/unshare',
653
- '/sbin/unshare',
654
- '/usr/sbin/unshare',
655
- ]);
656
-
657
- // The filesystem-attach utility used by the namespace backend to establish
658
- // write confinement (read-only rebind of the whole mount tree, read-write
659
- // rebind of the sandbox root). Resolved by path for the same reason as the
660
- // others. Absent => the namespace backend cannot establish write confinement
661
- // and fails closed; it never runs a command with the filesystem open.
662
- const CONFINE_BINS_MOUNT = Object.freeze([
663
- '/usr/bin/mount',
664
- '/bin/mount',
665
- '/sbin/mount',
666
- '/usr/sbin/mount',
667
- ]);
668
-
669
- // The privilege-dropping utility used to remove CAP_SYS_ADMIN (and everything
670
- // else) from the confined process *after* the mounts are in place, so the
671
- // payload cannot simply undo the read-only rebinds. Best-effort hardening on
672
- // top of the mount confinement, not the confinement itself: when it is absent
673
- // the run still happens under the read-only mount tree and the result declares
674
- // `privilegeDrop` unenforced rather than staying silent about it.
675
- const CONFINE_BINS_PRIVDROP = Object.freeze([
676
- '/usr/bin/setpriv',
677
- '/bin/setpriv',
678
- '/sbin/setpriv',
679
- '/usr/sbin/setpriv',
680
- ]);
681
-
682
- // Back-compat single-path exports: the first (canonical) candidate.
683
- const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
684
- const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
685
-
686
- let _cached = null;
687
-
688
- // Which namespace-flag variant actually works on this host, keyed by the
689
- // requested confinement shape. Probing costs a process spawn, so it is done
690
- // once; `undefined` means "not probed yet", `null` means "probed and nothing
691
- // worked" (which the backend turns into a fail-closed error, never a run).
692
- const _nsVariant = new Map();
693
-
694
- function resetCapabilityCache() { _cached = null; _nsVariant.clear(); }
695
-
696
- function cachedNamespaceVariant(key) {
697
- return _nsVariant.has(key) ? _nsVariant.get(key) : undefined;
698
- }
699
- function cacheNamespaceVariant(key, value) { _nsVariant.set(key, value); }
700
-
701
- /** First executable candidate, or null when none of them exists. */
702
- function resolveConfineBin(candidates) {
703
- for (const p of candidates) if (_isExecutable(p)) return p;
704
- return null;
705
- }
706
-
707
- function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
708
- function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
709
- function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
710
- function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
711
-
712
- // Bounded on purpose: a capability check must never hang a scan. The probe is
713
- // a single `exit 0` under confinement, so anything beyond a couple of seconds
714
- // is a host that is not going to answer.
715
- const PROBE_TIMEOUT_MS = Math.max(
716
- 250,
717
- Number(process.env.AGENTIC_SECURITY_SANDBOX_PROBE_TIMEOUT_MS) || 4000,
718
- );
719
-
720
- /**
721
- * Run a trivial command through a backend's real code path and report whether
722
- * confinement actually worked. Anything other than a clean confined run — a
723
- * missing binary, a refused namespace, a timeout, a throw — is `false`. There
724
- * is deliberately no branch that relaxes confinement to make a probe pass: a
725
- * backend that can only succeed with a flag dropped is not available, it is
726
- * `'disabled'`.
727
- */
728
- function _probeThroughBackend(runner) {
729
- let root = null;
730
- try {
731
- root = external_node_fs_.mkdtempSync(external_node_path_.join(external_node_os_.tmpdir(), 'agsec-sbx-probe-'));
732
- const r = runner(['/bin/sh', '-c', 'exit 0'], { root, timeoutMs: PROBE_TIMEOUT_MS });
733
- return r?.status === 'ok';
734
- } catch {
735
- return false;
736
- } finally {
737
- if (root) { try { external_node_fs_.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } }
738
- }
739
- }
740
-
741
- /**
742
- * The real probes, one per backend. The binary check stays only as a cheap
743
- * pre-filter that avoids a pointless temp dir on a host that plainly lacks the
744
- * primitive — it is no longer the answer, just the fast negative.
745
- */
746
- function defaultProbes() {
747
- return {
748
- userspace: () => (resolveUserspaceBin() ? _probeThroughBackend(runUserspace) : false),
749
- namespace: () => (resolveNamespaceBin() ? _probeThroughBackend(runNamespace) : false),
750
- };
751
- }
752
-
753
- /** Backends worth probing on a platform, most-appropriate first. */
754
- function backendCandidates(platform = process.platform) {
755
- if (platform === 'darwin') return ['userspace'];
756
- if (platform === 'linux') return ['namespace'];
757
- return [];
758
- }
759
-
760
- /**
761
- * @param {object} [o]
762
- * @param {string} [o.force] Bypass detection entirely (tests, and callers
763
- * that want the disabled path deliberately).
764
- * @param {object} [o.probes] Probe map override — a seam for tests to drive
765
- * the selection contract with stand-ins on any
766
- * platform. Cannot cause unconfined execution:
767
- * dispatch still goes to the real backend.
768
- * @param {string[]} [o.candidates] Candidate order override (same seam).
769
- */
770
- function detectBackend({ force, probes, candidates } = {}) {
771
- if (force) return force;
772
- if (_cached) return _cached;
773
-
774
- const probeMap = probes || defaultProbes();
775
- const order = candidates || backendCandidates();
776
-
777
- let b = 'disabled';
778
- for (const name of order) {
779
- const probe = probeMap[name];
780
- if (typeof probe !== 'function') continue;
781
- let works = false;
782
- try { works = probe() === true; } catch { works = false; }
783
- if (works) { b = name; break; }
784
- // Otherwise fall through to the next candidate, and ultimately to
785
- // 'disabled' — never to "run it anyway".
786
- }
787
- _cached = b;
788
- return b;
789
- }
790
-
791
- function _isExecutable(p) {
792
- try { external_node_fs_.accessSync(p, external_node_fs_.constants.X_OK); return true; } catch { return false; }
793
- }
794
-
795
- ;// CONCATENATED MODULE: ./src/sandbox/backend-disabled.js
796
- // Fail-closed backend. Selected when no confinement primitive is available.
797
- // It must NEVER execute the command — an unavailable sandbox disables
798
- // execution features, it does not bypass them.
799
- function runDisabled(_argv, _opts) {
800
- return {
801
- status: 'disabled',
802
- denied: false,
803
- stdout: '',
804
- stderr: 'agentic-security: refusing to execute — no confinement primitive available on this host.',
805
- exitCode: null,
806
- timedOut: false,
807
- backend: 'disabled',
808
- };
809
- }
810
-
811
- ;// CONCATENATED MODULE: ./src/sandbox/index.js
812
- // Single entry point for confined execution.
813
- //
814
- // Fail-closed by construction: when no confinement primitive is available the
815
- // disabled backend is selected, which REFUSES to execute. There is deliberately
816
- // no code path that runs target code unconfined.
817
- //
818
- // Result shape (identical for every backend):
819
- // { status, denied, stdout, stderr, exitCode, timedOut, backend }
820
- // status: 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
821
- // See result.js for what 'blocked' vs 'nonzero' mean and, importantly, what
822
- // `denied:false` does NOT prove. runConfined never throws — a bad root or an
823
- // invalid limit returns status 'error', because a caller that catches and
824
- // falls back is a route to unconfined execution.
825
-
826
-
827
-
828
-
829
-
830
-
831
-
832
- function sandboxAvailable() {
833
- return detectBackend() !== 'disabled';
834
- }
835
-
836
- function runConfined(argv, opts = {}) {
837
- const backend = detectBackend({ force: opts.force });
838
- if (backend === 'userspace') return runUserspace(argv, opts);
839
- if (backend === 'namespace') return runNamespace(argv, opts);
840
- return runDisabled(argv, opts);
841
- }
842
-
843
- ;// CONCATENATED MODULE: ./src/posture/proof-tier.js
844
- // How strongly a finding is backed by evidence.
845
- //
846
- // execution-proven — a proof-of-concept RAN inside the sandbox and produced
847
- // the predicted observable effect. The strongest claim.
848
- // proof-failed — a proof-of-concept ran and did NOT demonstrate the bug.
849
- // A triage signal, NOT an automatic false-positive verdict:
850
- // absence of proof is not proof of absence.
851
- // taint-proven — the analyser's static reasoning found it; nothing executed.
852
- // unproven — no analyser backing recorded.
853
- const PROOF_TIERS = Object.freeze([
854
- 'execution-proven', 'proof-failed', 'taint-proven', 'unproven',
855
- ]);
856
-
857
- // Parsers that represent real analysis rather than a plain pattern match.
858
- const _ANALYSED = new Set(['IR-TAINT', 'MULTI-SINK']);
859
-
860
- function proofTierOf(finding) {
861
- if (finding?.proofTier) return finding.proofTier;
862
- return _ANALYSED.has(finding?.parser) ? 'taint-proven' : 'unproven';
863
- }
864
-
865
- function attachProofTier(finding, evidence) {
866
- if (!PROOF_TIERS.includes(evidence?.tier)) {
867
- throw new Error(`unknown proof tier: ${evidence?.tier}`);
868
- }
869
- let tier = evidence.tier;
870
- // Guard the central honesty rule: nothing that did not RUN may be called
871
- // execution-proven or proof-failed. Fall back to the finding's static standing.
872
- if (!evidence.ran && (tier === 'execution-proven' || tier === 'proof-failed')) {
873
- tier = proofTierOf({ ...finding, proofTier: undefined });
874
- }
875
- return { ...finding, proofTier: tier, proofEvidence: { ...evidence, tier } };
876
- }
877
-
878
- ;// CONCATENATED MODULE: ./src/posture/execution-proof.js
879
- // Promote a finding to execution-proven by running its proof-of-concept inside
880
- // the confined execution sandbox and observing a real effect.
881
- //
882
- // Proof is a file the PoC writes, NOT an exit code: the sandbox cannot reliably
883
- // distinguish "denied" from "ran and exited 0", so exit status is not evidence.
884
-
885
-
886
-
887
-
888
-
889
-
890
- const PROOF_MARKER = 'PROVEN';
891
-
892
- function _evidence(over = {}) {
893
- return {
894
- tier: 'taint-proven', backend: detectBackend(), ran: false, observed: null,
895
- reason: null, exitCode: null, timedOut: false, at: new Date().toISOString(), ...over,
896
- };
897
- }
898
-
899
- // A run that never got as far as executing the PoC. `ran:false` for these is
900
- // the whole point: 'proof-failed' asserts "the PoC ran and the predicted effect
901
- // did not appear", which is a triage signal about the FINDING. A sandbox that
902
- // could not start says nothing about the finding at all, and must leave it at
903
- // its static tier rather than manufacturing a failed exploit attempt.
904
- const _DID_NOT_EXECUTE = new Set(['disabled', 'error']);
905
-
906
- // Materialise caller-supplied files into the sandbox root so a PoC can import
907
- // the code it is supposed to exploit. Paths are confined to the root: an
908
- // absolute path or one that climbs out is refused rather than clamped, because
909
- // silently rewriting a path would put a file somewhere the caller did not ask
910
- // for and the PoC would then exercise the wrong code.
911
- function _materialise(root, files) {
912
- for (const [rel, content] of Object.entries(files || {})) {
913
- if (typeof content !== 'string') continue;
914
- const abs = external_node_path_.resolve(root, rel);
915
- if (abs !== root && !abs.startsWith(root + external_node_path_.sep)) {
916
- return `refusing to write '${rel}': it resolves outside the sandbox root`;
917
- }
918
- external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
919
- external_node_fs_.writeFileSync(abs, content, 'utf8');
920
- }
921
- return null;
922
- }
923
-
924
- /**
925
- * @param {object} finding carries `poc: {lang, code}`
926
- * @param {object} [opts]
927
- * @param {object} [opts.files] rel→content written into the sandbox root before
928
- * the PoC runs. This is what lets the SAME PoC be run against a candidate
929
- * patch: pass the patched contents and a still-`execution-proven` verdict
930
- * means the fix did not close the hole.
931
- */
932
- async function proveFinding(finding, { timeoutMs = 10000, force, files } = {}) {
933
- const poc = finding?.poc;
934
- if (!poc?.code) {
935
- return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
936
- }
937
- if (poc.lang !== 'js') {
938
- return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: `unsupported poc language: ${poc.lang}` }));
939
- }
940
- if (!sandboxAvailable()) {
941
- return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no confinement primitive available; refusing to execute' }));
942
- }
943
-
944
- const root = external_node_fs_.realpathSync(external_node_fs_.mkdtempSync(external_node_path_.join(external_node_os_.tmpdir(), 'proof-')));
945
- try {
946
- const badPath = _materialise(root, files);
947
- if (badPath) {
948
- return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: badPath }));
949
- }
950
- external_node_fs_.writeFileSync(external_node_path_.join(root, 'poc.mjs'), poc.code, 'utf8');
951
- const r = runConfined([process.execPath, 'poc.mjs'], { root, timeoutMs, force });
952
- const proven = external_node_fs_.existsSync(external_node_path_.join(root, PROOF_MARKER));
953
- const ran = !r.timedOut && !_DID_NOT_EXECUTE.has(r.status);
954
-
955
- return attachProofTier(finding, _evidence({
956
- tier: proven ? 'execution-proven' : ran ? 'proof-failed' : proofTierOf(finding),
957
- backend: r.backend,
958
- ran,
959
- observed: proven ? `proof marker '${PROOF_MARKER}' written by the proof-of-concept` : null,
960
- reason: proven ? null
961
- : r.status === 'error' ? `the confinement sandbox could not start, so the proof-of-concept never executed (${r.backend} backend): ${String(r.stderr || '').trim() || 'no detail reported'}`
962
- : r.status === 'disabled' ? 'confined execution is disabled; the proof-of-concept was refused and never executed'
963
- : r.timedOut ? 'proof-of-concept exceeded its time budget'
964
- : 'proof-of-concept ran but did not demonstrate the predicted effect',
965
- exitCode: r.exitCode, timedOut: r.timedOut,
966
- }));
967
- } finally {
968
- external_node_fs_.rmSync(root, { recursive: true, force: true });
969
- }
970
- }
971
-
972
-
973
- /***/ })
974
-
975
- };