@clear-capabilities/agentic-security-scanner 0.130.0 → 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.
@@ -50,27 +50,68 @@ verification tier) must not read `'ok'` as "ran unimpeded". The reliable
50
50
  negative evidence remains the one the escape tests use: check for the side
51
51
  effect (the out-of-root file does not exist), not the status.
52
52
 
53
- ## Backend selection (`capabilities.js`)
53
+ ## Backend selection (`capabilities.js`) — functional, not presence-based
54
54
 
55
- `detectBackend({ force })` probes for one confinement primitive, cached after
56
- the first call (`resetCapabilityCache()` clears it, used between tests):
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".
57
59
 
58
- | Platform | Primitive checked | Backend selected |
60
+ | Platform | Candidate backend | Selected when |
59
61
  |---|---|---|
60
- | macOS family | userspace confinement binary present and executable | `'userspace'` |
61
- | Linux family | kernel-namespace tool present and executable | `'namespace'` |
62
- | neither found | — | `'disabled'` |
63
-
64
- Each primitive is probed across a **candidate list** of plausible install
65
- paths (`CONFINE_BINS_USERSPACE` / `CONFINE_BINS_NAMESPACE`), not a single
66
- hardcoded path. A miss still fails closed to `'disabled'`, which is safe but
67
- a single path would be a false negative on any distribution that installs the
68
- binary elsewhere, silently costing that host its sandbox. The backends run the
69
- resolved path, not the canonical one.
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.
70
111
 
71
112
  ## Fail-closed rule
72
113
 
73
- If no primitive is found, `detectBackend` returns `'disabled'` and
114
+ If no candidate backend's functional probe succeeds, `detectBackend` returns `'disabled'` and
74
115
  `runConfined` dispatches to `backend-disabled.js`, which **refuses to execute
75
116
  the command at all** — it returns `status: 'disabled'` without ever spawning
76
117
  a process. There is no code path in this module that runs target code
@@ -111,35 +152,116 @@ other.
111
152
  silently do nothing — an unenforced limit must never look like an enforced
112
153
  one.
113
154
 
114
- **Kernel-namespace backend (Linux family) — implemented, NOT verified on this
115
- platform.** The required namespace tool is absent on the macOS development
116
- host, so `backend-namespace.js`'s escape tests skip with a recorded reason
117
- rather than being asserted against. Nothing in this guide should be read as a
118
- claim that the namespace backend's isolation has been demonstrated by
119
- execution anywhere. It must be verified on a Linux host — with the same
120
- both-direction escape-attempt tests used for the userspace backend — before
121
- anything downstream (e.g. an R2 execution-verification tier) relies on it.
122
-
123
- **And it confines less than "unverified" suggests. Writes are NOT confined on
124
- this backend that is false by inspection, not merely undemonstrated.** The
125
- backend enters new mount/PID/IPC/UTS namespaces and, by default, an empty
126
- network namespace. The empty network namespace is the *only* confinement it
127
- implements: it has no route anywhere, which denies egress. For the
128
- filesystem there is **no remount, no bind mount and no `pivot_root`** only a
129
- `cd` into the sandbox root. `cd` sets the working directory; it does not
130
- restrict where a process may write. A confined command writing to an absolute
131
- path outside the root (a home directory, a system config path) will
132
- **succeed**, subject only to ordinary filesystem permissions. The new mount
133
- namespace isolates mount-table *changes* made by the confined process; it does
134
- not make the host filesystem read-only.
135
-
136
- On a Linux host `detectBackend()` selects this backend automatically, so a
137
- caller there gets network isolation and resource limits and **no write
138
- confinement at all**. Do not run anything on that path that must not touch the
139
- host filesystem. Closing the gap means implementing a read-only remount (or
140
- equivalent) *and* verifying it by execution on a Linux host with both-direction
141
- escape tests the guide must not claim write confinement here before both
142
- have happened.
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.
143
265
 
144
266
  ## Timeout does not kill the process tree
145
267
 
@@ -163,8 +285,8 @@ needs the same Linux-host verification as everything else on that backend.
163
285
 
164
286
  The userspace policy allows `(allow file-read*)` globally — that backend
165
287
  confines **writes**, network egress, and resource use, but **not reads**. (The
166
- kernel-namespace backend confines *less* than that: per the section above, it
167
- implements network isolation only and does **not** confine writes at all.)
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.)
168
290
  A confined command can read any file on the host the OS-level
169
291
  permissions allow, including outside the sandbox root. Exfiltration of
170
292
  readable host files (writing what was read to network or to a location the
@@ -1,36 +1,227 @@
1
1
  // Kernel-namespace confinement backend (Linux family).
2
2
  //
3
- // STATUS: implemented, not verified on this platform — the required
4
- // kernel-namespace tool is not present here, so its escape tests skip with a
5
- // recorded reason. Verify on a Linux host before relying on it for R2.
3
+ // STATUS. This backend cannot be exercised on the macOS development host — the
4
+ // required kernel-namespace tool is absent, so its escape tests skip with a
5
+ // recorded reason there. Whether the confinement described below actually
6
+ // holds is a per-host fact that only a Linux host can answer, and only by
7
+ // EXECUTING the escape suite. Do not read this comment as a verification
8
+ // claim; read `src/sandbox/CLAUDE.md` for what has and has not been executed.
6
9
  //
7
- // WHAT THIS BACKEND ACTUALLY CONFINES — do not overstate it. It enters new
8
- // mount/PID/IPC/UTS namespaces and, unless `allowNetwork`, an empty network
9
- // namespace. The empty network namespace has no route anywhere, and that is
10
- // the ONE confinement this backend implements: network egress.
10
+ // WHAT THIS BACKEND CONFINES.
11
11
  //
12
- // It does NOT confine writes. There is no remount, no bind mount and no
13
- // pivot_root here — only a `cd` into the sandbox root. A `cd` sets the working
14
- // directory; it does not restrict where a process may write. A confined
15
- // command that writes to an absolute path outside the root (a home directory,
16
- // a system config path) will SUCCEED, subject only to ordinary filesystem
17
- // permissions. The new mount namespace isolates mount-table CHANGES made by
18
- // the confined process from the host; it does not make the host filesystem
19
- // read-only. Treat filesystem confinement as ABSENT on this backend until a
20
- // read-only remount is implemented AND verified by execution on a Linux host.
12
+ // 1. NETWORK EGRESS an empty network namespace (`--net`, unless the caller
13
+ // passes `allowNetwork`). It has no route anywhere.
14
+ //
15
+ // 2. FILESYSTEM WRITES a private mount namespace in which every mount
16
+ // point present at setup time is rebound READ-ONLY, and only the sandbox
17
+ // root is rebound read-write. An out-of-root write therefore fails with
18
+ // EROFS. That error text is one of `result.js`'s denial patterns, so an
19
+ // escape attempt surfaces as `status:'blocked'` + `denied:true` the
20
+ // same shape the userspace backend produces, which is the main reason
21
+ // this shape was chosen over `pivot_root` (see below).
22
+ //
23
+ // 3. RESOURCE CAPS — the shared `ulimit` prelude.
24
+ //
25
+ // WHY READ-ONLY REBIND RATHER THAN pivot_root. `pivot_root` into the sandbox
26
+ // root is the stronger primitive: after detaching the old root, out-of-root
27
+ // paths are not merely read-only, they are absent from the mount namespace
28
+ // entirely. It was rejected here for three concrete reasons. (a) It requires
29
+ // materialising a system tree (the shell, the C library, the utilities a PoC
30
+ // invokes) inside the caller's sandbox root, which pollutes a directory the
31
+ // caller owns and reads back. (b) It changes path semantics: `$ROOT` becomes
32
+ // `/`, so a caller's absolute paths mean something different on this backend
33
+ // than on the userspace one, and the two backends stop being interchangeable.
34
+ // (c) An out-of-root write would then fail with ENOENT, which is
35
+ // indistinguishable from an ordinary missing path and cannot be reported as a
36
+ // confinement denial — the caller loses the `denied` signal precisely where it
37
+ // matters most. The read-only rebind keeps paths, keeps the denial signal, and
38
+ // keeps both backends returning the same thing for the same escape attempt.
39
+ //
40
+ // HONEST LIMIT OF THE READ-ONLY REBIND. The namespaces are acquired by
41
+ // creating a user namespace, and the confined process is therefore (initially)
42
+ // privileged inside it — it holds CAP_SYS_ADMIN over the mount namespace it
43
+ // runs in, and could rebind the tree read-write again. That would gut the
44
+ // confinement, so after the mounts are established and before the caller's
45
+ // command is executed, the backend drops the whole capability set (bounding,
46
+ // inheritable, and — via the `noroot` secure bits — the implicit privileges of
47
+ // uid 0) and only then executes. If the privilege-dropping utility is not
48
+ // present on the host the command still runs under the read-only mount tree,
49
+ // but the result DECLARES `privilegeDrop` unenforced (in `unsupported`, the
50
+ // same mechanism `limits.js` uses) rather than pretending the hardening
51
+ // applied. It is never silently skipped.
52
+ //
53
+ // FAIL-CLOSED, AND VERIFIED PER RUN RATHER THAN ASSUMED. Every step that
54
+ // establishes confinement aborts the run on failure: no namespace variant, no
55
+ // filesystem-attach utility, a mount tree that cannot be made read-only, a
56
+ // sandbox root that turns out not to be writable — each returns
57
+ // `status:'error'` with nothing executed. Beyond that, the confinement is PROVEN by execution on
58
+ // every single run: the parent creates a canary path OUTSIDE the sandbox root,
59
+ // and the confined shell — already in its final, deprivileged state —
60
+ // attempts to create it. If that write succeeds, confinement is not in force
61
+ // and the shell exits WITHOUT running the caller's command. A reasoned
62
+ // expectation that "the remount should have worked" is exactly the class of
63
+ // claim this module exists to refuse.
21
64
  //
22
65
  // TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
23
66
  // the direct child. On this backend the direct child is the namespace tool
24
67
  // running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
25
68
  // expected to take the whole namespace's processes with it — better than the
26
69
  // userspace backend, where a backgrounded grandchild demonstrably survives.
27
- // "Expected", not verified: like everything else here it needs a Linux host.
70
+ // "Expected", NOT verified, and this one did not clear with the rest: the
71
+ // escape suite has now RUN and passed on a Linux runner, but its wall-clock
72
+ // case asserts only that the DIRECT CHILD is stopped. No test observes whether
73
+ // a backgrounded grandchild dies with the PID namespace, so tree-kill remains
74
+ // a reasoned expectation. Do not state it as a guarantee until a test asserts
75
+ // the grandchild is gone.
76
+ //
77
+ // PRIVILEGE. Creating mount/PID/IPC/UTS/network namespaces directly requires
78
+ // CAP_SYS_ADMIN, which an ordinary CI account does not have — asking for them
79
+ // bare fails with a permission error and the backend cannot start at all. The
80
+ // unprivileged route is to create a USER namespace first and take the
81
+ // requested namespaces inside it, where the invoking user holds the
82
+ // capabilities. So the flag set is chosen by PROBE, not assumed: each variant
83
+ // below is executed with a trivial command and the first one that actually
84
+ // succeeds is used (and cached). Fail-closed: if no variant works the backend
85
+ // returns status 'error' and nothing runs. The confinement flags are NEVER
86
+ // relaxed to make a run succeed — dropping `--net` would remove the network
87
+ // confinement, so `--net` is part of every probed variant when `allowNetwork`
88
+ // is false, and `--mount` is in every variant unconditionally because the
89
+ // write confinement is built inside it.
28
90
  import { spawnSync } from 'node:child_process';
29
91
  import fs from 'node:fs';
30
- import { resolveNamespaceBin } from './capabilities.js';
31
- import { buildLimitPrelude } from './limits.js';
92
+ import os from 'node:os';
93
+ import path from 'node:path';
94
+ import {
95
+ resolveNamespaceBin, resolveMountBin, resolvePrivDropBin,
96
+ cachedNamespaceVariant, cacheNamespaceVariant,
97
+ } from './capabilities.js';
98
+ import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
32
99
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
33
100
 
101
+ // Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
102
+ // prefix; the namespace flags themselves are appended identically to all of
103
+ // them by `_nsArgs`, so no variant can quietly confine less than another.
104
+ //
105
+ // 1. user namespace with the invoking user mapped to root inside it — the
106
+ // unprivileged route, and the one a standard CI runner needs. It is also
107
+ // the only variant under which the write confinement can be built, since
108
+ // rebinding the mount tree needs CAP_SYS_ADMIN in the owning namespace.
109
+ // 2. user namespace with the invoking user mapped to itself — for hosts
110
+ // whose policy permits a user namespace but not the root mapping.
111
+ // 3. no prefix — the direct route, which needs CAP_SYS_ADMIN (i.e. root).
112
+ // Last so an unprivileged host never pays for a doomed attempt first.
113
+ const NS_PRIVILEGE_VARIANTS = Object.freeze([
114
+ Object.freeze(['--user', '--map-root-user']),
115
+ Object.freeze(['--user', '--map-current-user']),
116
+ Object.freeze([]),
117
+ ]);
118
+
119
+ function _nsArgs(privilegeFlags, allowNetwork) {
120
+ const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
121
+ if (!allowNetwork) a.push('--net');
122
+ return a;
123
+ }
124
+
125
+ // Markers the confined shell writes to its own stderr so the parent can tell
126
+ // a confinement-setup failure from ordinary program output. They are stripped
127
+ // from the stderr handed back to the caller.
128
+ //
129
+ // A payload that PRINTS one of these strings can force `status:'error'` (or a
130
+ // false `privilegeDrop` unenforced note). That is the safe direction: the
131
+ // worst it achieves is making its own run look like it did not happen, which
132
+ // no downstream tier reads as evidence of anything. It cannot make an
133
+ // unconfined run look confined.
134
+ const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
135
+ const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
136
+
137
+ // Runs inside the namespaces, still privileged, before the caller's command.
138
+ // Builds the write confinement, then hands off to $SBX_FINAL with the
139
+ // capability set dropped.
140
+ //
141
+ // Order matters: the sandbox root is bound onto itself while the tree is still
142
+ // writable, so the read-only pass and the read-write rebind of the root never
143
+ // have to fight each other. Individual sub-mounts are best-effort (some pseudo
144
+ // filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
145
+ // what actually decides whether the result is trustworthy.
146
+ const SETUP_SCRIPT = `
147
+ _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
148
+ "$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
149
+ "$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
150
+ "$SBX_MOUNT" --bind "$ROOT" "$ROOT" || _fail "the sandbox root could not be bind-mounted"
151
+ _mps=$(while read -r _a _b _c _d _mp _rest; do printf '%s\\n' "$_mp"; done < /proc/self/mountinfo)
152
+ for _mp in $_mps; do
153
+ [ "$_mp" = "/" ] && continue
154
+ [ "$_mp" = "$ROOT" ] && continue
155
+ case "$_mp" in "$ROOT"/*) continue ;; esac
156
+ "$SBX_MOUNT" -o remount,bind,ro "$_mp" 2>/dev/null || true
157
+ done
158
+ "$SBX_MOUNT" -o remount,bind,ro / || _fail "the root filesystem could not be rebound read-only"
159
+ # Belt and braces: the root was bound before the read-only pass and skipped by
160
+ # it, so this is normally a no-op. Its return code is NOT the gate — the
161
+ # executed in-root write check in $SBX_FINAL is, and that one fails closed.
162
+ "$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
163
+ 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
164
+ exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
165
+ fi
166
+ echo "${MARK_NO_PRIVDROP}" >&2
167
+ exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
168
+ `;
169
+
170
+ // Runs in the FINAL privilege state, immediately before the caller's command.
171
+ // Both directions are checked by execution, every run: the out-of-root canary
172
+ // must be refused, and an in-root write must succeed. Either check failing
173
+ // means the sandbox is not what it claims, so the command is not run.
174
+ const FINAL_SCRIPT = `
175
+ _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
176
+ if ( : > "$SBX_CANARY" ) 2>/dev/null; then
177
+ _fail "an out-of-root write is still possible; refusing to execute"
178
+ fi
179
+ if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
180
+ _fail "the sandbox root is not writable; refusing to execute"
181
+ fi
182
+ rm -f "$ROOT/.agsec-sbx-wcheck"
183
+ cd "$ROOT" && exec "$@"
184
+ `;
185
+
186
+ /**
187
+ * The first privilege variant under which the requested namespaces can
188
+ * actually be created on this host, or null when none can. Probed by running
189
+ * a trivial command — a reasoned expectation about which flags "should" work
190
+ * is exactly what made this backend unusable on an unprivileged runner.
191
+ */
192
+ export function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
193
+ const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
194
+ const cached = cachedNamespaceVariant(key);
195
+ if (cached !== undefined) return cached;
196
+
197
+ let chosen = null;
198
+ for (const variant of NS_PRIVILEGE_VARIANTS) {
199
+ const args = _nsArgs(variant, allowNetwork);
200
+ const probe = spawnSync(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
201
+ encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
202
+ });
203
+ if (!probe.error && probe.status === 0) { chosen = args; break; }
204
+ }
205
+ cacheNamespaceVariant(key, chosen);
206
+ return chosen;
207
+ }
208
+
209
+ /** Strip the internal markers from stderr before it reaches the caller. */
210
+ function _cleanStderr(s) {
211
+ return String(s || '')
212
+ .split('\n')
213
+ .filter((l) => !l.includes(MARK_SETUP_FAILED) && l.trim() !== MARK_NO_PRIVDROP)
214
+ .join('\n');
215
+ }
216
+
217
+ function _setupFailureReason(stderr) {
218
+ for (const line of String(stderr || '').split('\n')) {
219
+ const i = line.indexOf(MARK_SETUP_FAILED);
220
+ if (i !== -1) return line.slice(i + MARK_SETUP_FAILED.length).trim();
221
+ }
222
+ return null;
223
+ }
224
+
34
225
  export function runNamespace(argv, {
35
226
  root,
36
227
  timeoutMs = 10000,
@@ -45,6 +236,14 @@ export function runNamespace(argv, {
45
236
  const bin = resolveNamespaceBin();
46
237
  if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
47
238
 
239
+ // Write confinement is built with this utility. No utility, no confinement,
240
+ // no run — there is deliberately no branch that proceeds without it.
241
+ const mountBin = resolveMountBin();
242
+ if (!mountBin) {
243
+ return errorResult('namespace',
244
+ 'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined');
245
+ }
246
+
48
247
  let resolvedRoot;
49
248
  try {
50
249
  // Resolve symlinks so the path the kernel actually sees matches what we
@@ -54,30 +253,83 @@ export function runNamespace(argv, {
54
253
  return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
55
254
  }
56
255
 
256
+ // Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
257
+ // the confined shell has to fork several helpers to BUILD its confinement,
258
+ // so a fixed cap below the ambient count for this uid makes the setup itself
259
+ // fail and the sandbox look broken. See `ambientRelativeMaxProcs`.
260
+ const effectiveLimits = { ...limits, maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs() };
261
+
57
262
  let prelude, unsupported;
58
263
  try {
59
- ({ prelude, unsupported } = buildLimitPrelude(limits));
264
+ ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
60
265
  } catch (e) {
61
266
  return errorResult('namespace', `invalid resource limit: ${e.message}`);
62
267
  }
63
- // `cd` only sets the working directory — it is NOT write confinement. See
64
- // the header note.
65
- const inner = `${prelude}cd "$ROOT" && exec "$@"`;
66
-
67
- const nsArgs = ['--mount', '--pid', '--ipc', '--uts', '--fork'];
68
- if (!allowNetwork) nsArgs.push('--net');
69
-
70
- const r = spawnSync(
71
- bin,
72
- [...nsArgs, '/bin/sh', '-c', inner, '_sbx', ...argv],
73
- {
74
- encoding: 'utf8',
75
- timeout: timeoutMs,
76
- maxBuffer,
77
- cwd: resolvedRoot,
78
- env: buildConfinedEnv({ root: resolvedRoot, env }),
79
- },
80
- );
81
-
82
- return buildResult({ backend: 'namespace', spawnResult: r, unsupported });
268
+
269
+ // Fail closed: no usable variant means the confinement cannot be
270
+ // established, so nothing is executed. There is deliberately no path that
271
+ // drops confinement flags and runs anyway.
272
+ const nsArgs = resolveNamespaceArgs(bin, allowNetwork);
273
+ if (!nsArgs) {
274
+ return errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined');
275
+ }
276
+
277
+ // The canary lives OUTSIDE the sandbox root, in a directory this process
278
+ // just created and can write. If the confined shell can create it, the
279
+ // confinement is not in force and the command is not run.
280
+ let canaryDir = null;
281
+ try {
282
+ canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-canary-'));
283
+ } catch (e) {
284
+ return errorResult('namespace', `could not create the confinement canary: ${e.message}`);
285
+ }
286
+ const canary = path.join(canaryDir, 'out-of-root.canary');
287
+
288
+ let r;
289
+ try {
290
+ r = spawnSync(
291
+ bin,
292
+ [...nsArgs, '/bin/sh', '-c', prelude + SETUP_SCRIPT, '_sbx', ...argv],
293
+ {
294
+ encoding: 'utf8',
295
+ timeout: timeoutMs,
296
+ maxBuffer,
297
+ cwd: resolvedRoot,
298
+ env: {
299
+ ...buildConfinedEnv({ root: resolvedRoot, env }),
300
+ SBX_MOUNT: mountBin,
301
+ SBX_PRIVDROP: resolvePrivDropBin() || '',
302
+ SBX_CANARY: canary,
303
+ SBX_FINAL: FINAL_SCRIPT,
304
+ },
305
+ },
306
+ );
307
+
308
+ // Parent-side confirmation of the same fact the canary check asserts from
309
+ // the inside. Cheap, and it does not depend on the confined shell being
310
+ // honest about its own exit code.
311
+ if (fs.existsSync(canary)) {
312
+ return errorResult('namespace',
313
+ 'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host');
314
+ }
315
+ } finally {
316
+ try { fs.rmSync(canaryDir, { recursive: true, force: true }); } catch { /* best effort */ }
317
+ }
318
+
319
+ const rawStderr = r.stderr ?? '';
320
+ const setupFailure = _setupFailureReason(rawStderr);
321
+ if (setupFailure && !r.error) {
322
+ // Confinement could not be established (or could not be proven). Nothing
323
+ // ran: the shell exits before `exec`ing the caller's command.
324
+ return errorResult('namespace', `confinement could not be established: ${setupFailure}`);
325
+ }
326
+
327
+ const effectiveUnsupported = [...unsupported];
328
+ if (rawStderr.includes(MARK_NO_PRIVDROP)) effectiveUnsupported.push('privilegeDrop');
329
+
330
+ return buildResult({
331
+ backend: 'namespace',
332
+ spawnResult: { ...r, stderr: _cleanStderr(rawStderr) },
333
+ unsupported: effectiveUnsupported,
334
+ });
83
335
  }