@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -0,0 +1,340 @@
1
+ # src/sandbox/
2
+
3
+ Confined execution facility for running untrusted target code and candidate
4
+ exploits (R1 of `docs/ROADMAP.md`). This is a hard prerequisite for anything
5
+ that executes code the scanner did not write — no other module in this
6
+ repository runs target code, confined or otherwise.
7
+
8
+ ## Entry point
9
+
10
+ Everything goes through `index.js`:
11
+
12
+ - `sandboxAvailable() -> boolean` — true iff a real confinement primitive was
13
+ detected on this host.
14
+ - `runConfined(argv, opts) -> { status, denied, stdout, stderr, exitCode, timedOut, backend }`
15
+ — dispatches to whichever backend `detectBackend()` selected. `opts.force`
16
+ overrides detection (used by tests, and by any caller that wants to force
17
+ the disabled path deliberately).
18
+
19
+ `status` is one of `'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' |
20
+ 'error'`. All three backends return the identical shape, so callers never
21
+ branch on which backend ran. **`runConfined` never throws** — a missing
22
+ `root`, an unresolvable root, a missing confinement binary, or an invalid
23
+ resource limit all return `status: 'error'` in the normal shape. (A caller
24
+ that wraps it in `try`/`catch` and "falls back" is a classic route to
25
+ unconfined execution, so there is nothing to catch.)
26
+
27
+ ## `blocked` vs `nonzero` vs `ok` — and the limit of what is observable
28
+
29
+ An earlier version derived `status` purely from the exit code, which conflated
30
+ two unrelated outcomes: a program that ran fine and exited 3 was reported
31
+ `'blocked'`, while a program whose out-of-root write was **denied** but which
32
+ exited 0 was reported `'ok'` — a clean run, as far as the caller could tell.
33
+ Both are now separated:
34
+
35
+ | Field | Meaning |
36
+ |---|---|
37
+ | `denied: true` | A confinement violation was **observed** in the confined process's error output. |
38
+ | `status: 'blocked'` | `denied` was true — something was refused. |
39
+ | `status: 'nonzero'` | The command exited non-zero with **no** denial observed. Ordinary program failure, not a confinement event. |
40
+ | `status: 'ok'` | Exited 0 with no denial observed. |
41
+
42
+ **What `denied: false` does not mean.** The signal is read from the confined
43
+ process's own stderr — these OS primitives give the parent no structured
44
+ violation channel. A program that writes outside the root and swallows its own
45
+ error message produces no signal at all, so `denied: false` means "no denial
46
+ was observed", **not** "no denial occurred". `status: 'ok'` is proof that the
47
+ command exited 0 and said nothing about a refusal; it is **not** proof that
48
+ the sandbox refused nothing. Downstream consumers (e.g. an R2 execution
49
+ verification tier) must not read `'ok'` as "ran unimpeded". The reliable
50
+ negative evidence remains the one the escape tests use: check for the side
51
+ effect (the out-of-root file does not exist), not the status.
52
+
53
+ ## Backend selection (`capabilities.js`) — functional, not presence-based
54
+
55
+ `detectBackend({ force })` selects a backend by **executing a trivial command
56
+ (`exit 0`) through that backend's real code path** and reporting the backend
57
+ only if that run succeeds. Availability means "confinement demonstrably works
58
+ here", never "the confinement binary is installed".
59
+
60
+ | Platform | Candidate backend | Selected when |
61
+ |---|---|---|
62
+ | macOS family | `'userspace'` | a trivial command ran confined and returned `status:'ok'` |
63
+ | Linux family | `'namespace'` | a trivial command ran confined and returned `status:'ok'` |
64
+ | any | `'disabled'` | no candidate's probe succeeded |
65
+
66
+ A candidate whose probe fails is **skipped**, detection falls through to the
67
+ next candidate, and with nothing left the answer is `'disabled'`. The probe is
68
+ never allowed to pass by weakening confinement: there is no branch that drops a
69
+ flag to get a green run, because a backend that can only succeed unconfined is
70
+ not an available backend.
71
+
72
+ **Why presence was the wrong question.** Verified on a Linux CI runner: the
73
+ kernel-namespace tool is installed and executable, but the distribution
74
+ restricts unprivileged user-namespace creation, so every privilege variant in
75
+ `backend-namespace.js` fails and no confined command can start. Presence-based
76
+ detection reported `'namespace'` and `sandboxAvailable()` answered `true` while
77
+ every actual run failed. `sandboxAvailable()` is the signal callers use to
78
+ decide whether it is safe to **execute untrusted code**; answering "the tool is
79
+ installed" when the honest answer is "confinement does not work here" is false
80
+ assurance of exactly the kind this module exists to prevent.
81
+
82
+ **On a host that restricts unprivileged namespace creation, the backend
83
+ therefore reports unavailable and the execution features that depend on it are
84
+ DISABLED — not degraded.** `detectBackend()` returns `'disabled'`,
85
+ `sandboxAvailable()` returns `false`, `runConfined` refuses to execute, and
86
+ `execution-proof.js` leaves findings at their static tier with a reason naming
87
+ the sandbox. Nothing runs unconfined and no weaker confinement is substituted.
88
+ The sandbox-dependent tests in `sandbox-escape.test.js` and
89
+ `execution-proof.test.js` skip there, each with an explicit
90
+ "SKIPPED, NOT PASSED … UNVERIFIED here" reason — a skip is a declared gap in
91
+ verification, never a pass.
92
+
93
+ **Cost and bounds.** The probe costs one spawn and its result (positive *and*
94
+ negative) is cached for the process, so ordinary scans pay it at most once;
95
+ `resetCapabilityCache()` clears it. The probe runs with a short timeout
96
+ (4 s default, `AGENTIC_SECURITY_SANDBOX_PROBE_TIMEOUT_MS` to override) and a
97
+ throw is treated as a failure, so a capability check can never hang a scan.
98
+ `force` bypasses probing entirely.
99
+
100
+ `detectBackend` also accepts `{ probes, candidates }` — a test seam that drives
101
+ the selection contract with stand-ins on any platform. It cannot produce
102
+ unconfined execution: dispatch in `index.js` still goes to the real backend.
103
+
104
+ Each primitive's binary is resolved across a **candidate list** of plausible
105
+ install paths (`CONFINE_BINS_USERSPACE` / `CONFINE_BINS_NAMESPACE`), not a
106
+ single hardcoded path, and that lookup now serves only as a cheap fast-negative
107
+ before the real probe. A miss still fails closed to `'disabled'`, which is safe
108
+ — but a single path would be a false negative on any distribution that installs
109
+ the binary elsewhere, silently costing that host its sandbox. The backends run
110
+ the resolved path, not the canonical one.
111
+
112
+ ## Fail-closed rule
113
+
114
+ If no candidate backend's functional probe succeeds, `detectBackend` returns `'disabled'` and
115
+ `runConfined` dispatches to `backend-disabled.js`, which **refuses to execute
116
+ the command at all** — it returns `status: 'disabled'` without ever spawning
117
+ a process. There is no code path in this module that runs target code
118
+ unconfined. An unavailable sandbox disables the execution feature; it never
119
+ silently degrades to running the command directly. This is proven by an
120
+ executing test (`sandbox.test.js`): the disabled backend is invoked with a
121
+ command that would create a marker file, and the test asserts the file does
122
+ not exist afterward.
123
+
124
+ ## What is verified on which platform
125
+
126
+ This module was developed and its tests run on a macOS host. Guarantees below
127
+ are stated per platform — do not extrapolate one platform's result to the
128
+ other.
129
+
130
+ **Userspace backend (macOS family) — verified by execution on this platform:**
131
+ - A write outside the sandbox root is blocked; the target file is never
132
+ created.
133
+ - Outbound network connections are blocked.
134
+ - A wall-clock overrun stops the **direct child** (`status: 'timeout'`,
135
+ `timedOut: true`) — but see "Timeout does not kill the process tree" below.
136
+ This is not full termination and must not be described as such.
137
+ - Benign in-root work (writes inside the root, ordinary commands) still
138
+ succeeds — the gate holds in both directions, not just the blocking one.
139
+ - Fork-storm containment is **weak, not strong, on this platform**. The
140
+ process-count limit (`ulimit -u` / `RLIMIT_NPROC`) is a per-uid, **system-wide**
141
+ cap here, not a per-process-tree cap — it counts every process the user
142
+ owns on the whole machine, not just the sandboxed subtree. Ambient process
143
+ count for a normal user on this host is on the order of several hundred, so
144
+ any usable cap has to sit at "ambient + margin" or it starves the user's own
145
+ unrelated processes before the sandboxed command even starts. That means a
146
+ fork storm inside the sandbox can still spawn a meaningful number of
147
+ processes — bounded to ambient-plus-margin, not to some small absolute
148
+ number — before the cap bites. Treat this as a soft brake, not a hard wall.
149
+ - Address-space capping (`ulimit -v`) is **not enforceable** on this platform.
150
+ `limits.js` (`buildLimitPrelude`) detects this and reports the limit in its
151
+ `unsupported` array instead of emitting a `ulimit -v` line that would
152
+ silently do nothing — an unenforced limit must never look like an enforced
153
+ one.
154
+
155
+ **Kernel-namespace backend (Linux family) — verified by execution in CI.** The
156
+ required namespace tool is absent on the macOS development host, so
157
+ `backend-namespace.js`'s escape tests skip there with a recorded reason rather
158
+ than being asserted against. The verification therefore lives in CI, and it has
159
+ now run: on the `sandbox-linux` job the functional probe selected the
160
+ `namespace` backend and the full escape suite executed and passed (Ubuntu
161
+ 24.04, kernel `6.17.0-1020-azure`, 41 assertions, 0 failures). All eight
162
+ escape-attempt cases are asserted on that host in both directions — in-root
163
+ write succeeds, out-of-root write is blocked and creates no file, a denied
164
+ write is not reported as a clean run, the confined process cannot rebind the
165
+ filesystem writable again, an ordinary non-zero exit stays `nonzero` rather
166
+ than `blocked`, the parent environment is not handed over, outbound network is
167
+ blocked, and a wall-clock overrun stops the direct child.
168
+
169
+ Two limits carry over unchanged and are **not** claims this verification
170
+ retires. The wall-clock case stops the *direct child*, not the process tree —
171
+ the same caveat the userspace backend carries. And this is one kernel and one
172
+ image: a different kernel is a different host fact, which is exactly why the
173
+ job runs per push rather than being recorded once and trusted forever.
174
+
175
+ **How it stays verified: the `sandbox-linux` CI job.** Hosted runners restrict
176
+ unprivileged user-namespace creation at the kernel's access-control layer, so
177
+ the functional probe fails by default and the escape suite would skip — which
178
+ is why this backend went unverified for as long as it did. The `sandbox-linux`
179
+ job in `.github/workflows/ci.yml` relaxes that **host policy** for itself (it
180
+ has passwordless root) and then runs the existing suite unchanged. It relaxes a
181
+ restriction on creating namespaces; it does not relax a single assertion or
182
+ confinement flag. `scripts/sandbox-linux-verify.mjs` then prints the selected
183
+ backend and `RAN`/`SKIPPED` for every test and **exits non-zero unless the
184
+ kernel-namespace suite actually ran**, so a skip can never be mistaken for a
185
+ pass in a green job. That guard is what makes the verification durable rather
186
+ than a one-time observation: if a future runner image re-tightens the policy,
187
+ the job fails rather than quietly reverting to "skipped, green".
188
+
189
+ **Privilege: the namespaces are acquired unprivileged, and the flag set is
190
+ probed rather than assumed.** Creating mount/PID/IPC/UTS/network namespaces
191
+ directly requires `CAP_SYS_ADMIN`; an ordinary CI account does not have it, so
192
+ asking for them bare fails with a permission error and the backend cannot start
193
+ at all. `resolveNamespaceArgs()` therefore tries an ordered list of
194
+ privilege-acquisition prefixes — user namespace with the invoking user mapped
195
+ to root inside it, then user namespace with the user mapped to itself, then no
196
+ prefix (which needs root) — and **executes a trivial command under each**,
197
+ selecting the first that actually succeeds. The result is cached per
198
+ binary/network shape and cleared by `resetCapabilityCache()`.
199
+
200
+ The confinement flags are identical across every variant and are **never
201
+ relaxed to make a run succeed**: `--net` is present in every probed variant
202
+ whenever `allowNetwork` is false, and `--mount` unconditionally, because the
203
+ write confinement is built inside that mount namespace — dropping either to
204
+ get a green run would remove a confinement. If no variant succeeds the backend returns
205
+ `status: 'error'` and **nothing is executed**, the same fail-closed rule as the
206
+ disabled backend. The selection contract (flags always present, `allowNetwork`
207
+ the only way `--net` is absent, `null` when every probe fails) is asserted by
208
+ executing tests in `sandbox.test.js` driven with stand-in binaries, so it holds
209
+ on any platform; whether a given kernel actually grants the namespaces is a
210
+ per-host fact only that host can answer.
211
+
212
+ **Write confinement: implemented and verified in CI** (see the escape-suite
213
+ result above; cases 1–4 are exactly this mechanism). This backend used to confine
214
+ network egress and nothing else — no remount, no bind mount, no `pivot_root`,
215
+ just a `cd` — so an absolute out-of-root write succeeded. That gap is now
216
+ closed in code:
217
+
218
+ 1. A **private mount namespace** in which every mount point present at setup
219
+ time is rebound **read-only**, and only the sandbox root is rebound
220
+ read-write. An out-of-root write therefore fails with `EROFS`, whose error
221
+ text is one of `result.js`'s denial patterns — so an escape attempt
222
+ surfaces as `status:'blocked'` + `denied:true`, the same shape the
223
+ userspace backend produces for the same attempt.
224
+ 2. A **capability drop** (whole bounding + inheritable set, plus the `noroot`
225
+ secure bits so uid 0 stops implying privilege) applied *after* the mounts
226
+ and *before* the caller's command. Without it the payload would hold
227
+ `CAP_SYS_ADMIN` over its own mount namespace — the namespaces are acquired
228
+ via a user namespace — and could simply rebind the tree writable again.
229
+ 3. A **per-run proof by execution**, not a reasoned expectation. The parent
230
+ seeds a canary path *outside* the sandbox root; the confined shell, already
231
+ in its final deprivileged state, attempts to create it and refuses to
232
+ `exec` the caller's command if that write succeeds. The parent then
233
+ re-checks the canary from outside, so the verdict does not depend on the
234
+ confined shell being honest about its own exit code.
235
+
236
+ **Why read-only rebind and not `pivot_root`.** `pivot_root` is the stronger
237
+ primitive — after detaching the old root, out-of-root paths are absent from
238
+ the mount namespace rather than merely read-only. It was rejected for three
239
+ concrete reasons. (a) It requires materialising a system tree (shell, C
240
+ library, whatever a PoC invokes) inside the *caller's* sandbox root, polluting
241
+ a directory the caller owns and reads back. (b) It changes path semantics —
242
+ `$ROOT` becomes `/` — so the two backends stop being interchangeable for the
243
+ same caller input. (c) An out-of-root write would then fail with `ENOENT`,
244
+ indistinguishable from an ordinary missing path, which destroys the `denied`
245
+ signal exactly where it matters most. The read-only rebind keeps paths, keeps
246
+ the denial signal, and keeps both backends answering the same way.
247
+
248
+ **Fail-closed throughout.** No namespace variant, no filesystem-attach
249
+ utility, a mount tree that cannot be rebound read-only, a sandbox root that
250
+ cannot be rebound writable, or a canary that turns out writable — each returns
251
+ `status:'error'` with **nothing executed**. There is no branch that proceeds
252
+ with the filesystem open.
253
+
254
+ **The one hardening that can be absent, and it is declared.** If the
255
+ privilege-dropping utility is not on the host, the command still runs under
256
+ the read-only mount tree but the result carries `privilegeDrop` in the
257
+ `unsupported` list (surfaced on stderr as `[sandbox] not enforceable here:
258
+ privilegeDrop`), the same mechanism `limits.js` uses for an unenforceable
259
+ limit. It is never silently skipped, and the escape test that covers the
260
+ rebind attack fails if it is missing rather than quietly passing.
261
+
262
+ **None of the above has been executed anywhere yet.** It is asserted by
263
+ `sandbox-escape.test.js`'s kernel-namespace suite, which skips on macOS. Until
264
+ a CI log shows that suite `RAN`, this section describes code, not evidence.
265
+
266
+ ## Timeout does not kill the process tree
267
+
268
+ `timeoutMs` is enforced with `spawnSync`'s timeout, which signals **only the
269
+ process this module spawned**. Verified by execution on the macOS family: with
270
+ `timeoutMs: 1200`, a command that backgrounded a 4-second child returned
271
+ `status: 'timeout'` and the grandchild survived, completing its work *after*
272
+ the result was returned. So `'timeout'` means "we stopped waiting and killed
273
+ the process we spawned", not "the process tree was terminated". Survivors stay
274
+ inside the policy profile — their writes and egress remain confined — but they
275
+ are still running and still consuming resources. A caller that needs a hard
276
+ tree kill must implement it.
277
+
278
+ The namespace backend is structurally better here: it runs the confined
279
+ command under `--pid --fork`, so the direct child is pid 1 of a new PID
280
+ namespace and killing it should take the namespace's processes with it. That
281
+ is a reasoned expectation from the flags, **not** an executed result — it
282
+ needs the same Linux-host verification as everything else on that backend.
283
+
284
+ ## Known limitation, deliberately accepted: reads are not confined
285
+
286
+ The userspace policy allows `(allow file-read*)` globally — that backend
287
+ confines **writes**, network egress, and resource use, but **not reads**. (The
288
+ kernel-namespace backend has the same cut: per the section above its mount
289
+ tree is rebound read-only, not detached, so everything on it stays readable.)
290
+ A confined command can read any file on the host the OS-level
291
+ permissions allow, including outside the sandbox root. Exfiltration of
292
+ readable host files (writing what was read to network or to a location the
293
+ attacker later reads through some other channel) is **out of scope for this
294
+ module**. This is a deliberate R1 scope cut, not an oversight: tightening
295
+ reads requires a threat model for what a confined process may legitimately
296
+ need to read, which belongs with the execution-verification work that
297
+ consumes this sandbox, not with the sandbox primitive itself.
298
+
299
+ ### The parent environment is NOT one of the things a confined process may read
300
+
301
+ Secrets carried in the parent process's environment (API tokens, cloud keys,
302
+ registry auth) are a *distinct* exposure from unconfined file reads — the
303
+ sandbox would be handing them over rather than merely failing to hide them —
304
+ so they are not covered by the scope cut above. Every real backend therefore
305
+ runs the command with a **minimal constructed environment**
306
+ (`buildConfinedEnv` in `result.js`): `PATH`, `ROOT`, `HOME`, `TMPDIR`, `LANG`,
307
+ with `HOME`/`TMPDIR` pointed at the sandbox root. `process.env` is not
308
+ forwarded. A caller that genuinely needs a variable inside passes it
309
+ explicitly as `opts.env`, which is merged on top of the base — an opt-in, one
310
+ variable at a time, not a blanket export.
311
+
312
+ ## Resource limits (`limits.js`)
313
+
314
+ `buildLimitPrelude({ maxProcs, maxFileSizeKb, maxAddressSpaceKb })` returns
315
+ `{ prelude, unsupported }`. `prelude` is a shell fragment of `ulimit` calls to
316
+ prefix before the confined command; `unsupported` lists any requested limit
317
+ that the current platform cannot enforce, so a caller can log or surface that
318
+ degradation rather than assume the limit applied silently.
319
+
320
+ Limit values are interpolated into a shell fragment, so they are **coerced
321
+ with `Number()` and rejected unless finite and non-negative** (`RangeError`,
322
+ which the backends turn into `status: 'error'`). Before that, a
323
+ config-supplied string such as `'999; echo INJECTED'` was emitted verbatim and
324
+ its payload ran — not an escape (the prelude runs inside the confinement) but
325
+ a way for a config-derived value to silently *disable* the limits it was
326
+ supposed to set.
327
+
328
+ ## Extending this module
329
+
330
+ - Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
331
+ keep returning the exact same result shape as each other and as
332
+ `backend-disabled.js` — callers dispatch on `status`/`backend`, not on
333
+ which module ran.
334
+ - Any new backend must add its own both-direction escape test
335
+ (`sandbox-escape.test.js`) before being wired into `index.js`: a `GOOD` case
336
+ showing legitimate in-root work still succeeds, and one `BAD` case per
337
+ escape vector the backend claims to block.
338
+ - Do not add a code path that runs a command when `detectBackend()` returns
339
+ `'disabled'`. If a future backend needs a new capability check, add it to
340
+ `detectBackend`, not around it.
@@ -0,0 +1,14 @@
1
+ // Fail-closed backend. Selected when no confinement primitive is available.
2
+ // It must NEVER execute the command — an unavailable sandbox disables
3
+ // execution features, it does not bypass them.
4
+ export function runDisabled(_argv, _opts) {
5
+ return {
6
+ status: 'disabled',
7
+ denied: false,
8
+ stdout: '',
9
+ stderr: 'agentic-security: refusing to execute — no confinement primitive available on this host.',
10
+ exitCode: null,
11
+ timedOut: false,
12
+ backend: 'disabled',
13
+ };
14
+ }