@clear-capabilities/agentic-security-scanner 0.130.0 → 0.133.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 (50) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/bin/agentic-security.js +39 -3
  3. package/dist/113.index.js +294 -5
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +7 -4
  6. package/dist/238.index.js +218 -0
  7. package/dist/259.index.js +975 -0
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +294 -5
  11. package/dist/637.index.js +1 -1
  12. package/dist/agentic-security.mjs +18 -57
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/package.json +19 -10
  15. package/src/engine.js +48 -1
  16. package/src/ir/parser-js.js +8 -0
  17. package/src/llm-validator/cost-ceiling.js +199 -0
  18. package/src/llm-validator/index.js +241 -12
  19. package/src/llm-validator/local-endpoint.js +90 -0
  20. package/src/mcp/tools.js +2 -2
  21. package/src/posture/CLAUDE.md +83 -6
  22. package/src/posture/accuracy-scorecard.js +37 -6
  23. package/src/posture/attestation.js +7 -4
  24. package/src/posture/corpus-enroll.js +303 -0
  25. package/src/posture/corpus-match.js +67 -0
  26. package/src/posture/custom-rules.js +2 -2
  27. package/src/posture/execution-proof.js +44 -4
  28. package/src/posture/fix-metrics.js +197 -0
  29. package/src/posture/fix-verify.js +76 -2
  30. package/src/posture/integrity.js +42 -9
  31. package/src/posture/learning.js +8 -1
  32. package/src/posture/model-routing.js +26 -0
  33. package/src/posture/model-trust.js +174 -0
  34. package/src/posture/poc-inprocess.js +165 -0
  35. package/src/posture/prove-findings.js +148 -0
  36. package/src/posture/root-cause-sweep.js +0 -0
  37. package/src/posture/rule-overrides.js +64 -3
  38. package/src/posture/state-dir.js +25 -0
  39. package/src/posture/vuln-archaeology.js +231 -0
  40. package/src/report/index.js +7 -0
  41. package/src/runScan.js +2 -6
  42. package/src/sandbox/CLAUDE.md +190 -46
  43. package/src/sandbox/backend-namespace.js +328 -48
  44. package/src/sandbox/backend-userspace.js +6 -19
  45. package/src/sandbox/capabilities.js +132 -4
  46. package/src/sandbox/limits.js +21 -0
  47. package/src/sandbox/result.js +1 -1
  48. package/src/sast/CLAUDE.md +4 -0
  49. package/src/sast/crypto-specialist.js +247 -0
  50. package/src/util/glob.js +173 -0
@@ -1,36 +1,242 @@
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.
6
- //
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.
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.
21
- //
22
- // TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
23
- // the direct child. On this backend the direct child is the namespace tool
24
- // running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
25
- // expected to take the whole namespace's processes with it — better than the
26
- // userspace backend, where a backgrounded grandchild demonstrably survives.
27
- // "Expected", not verified: like everything else here it needs a Linux host.
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.
9
+ //
10
+ // WHAT THIS BACKEND CONFINES.
11
+ //
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.
64
+ //
65
+ // TIMEOUT SCOPE — SETTLED BY TWO CI RUNS, AFTER TWO WRONG CLAIMS.
66
+ //
67
+ // This comment asserted for a long time that killing the direct child would
68
+ // reap the whole namespace, since that child is pid 1 of a new PID namespace
69
+ // (`--pid --fork`) — and that this made the backend BETTER than the userspace
70
+ // one. A test was written to check rather than assume. What CI actually found,
71
+ // in two rounds:
72
+ //
73
+ // 1. With the default SIGTERM the timeout did nothing at all: a 1200 ms
74
+ // budget against a payload sleeping 30 s returned after `30057 ms`, having
75
+ // run the payload to completion. The kernel does not deliver
76
+ // default-action signals to a PID namespace's pid 1 from outside it, so
77
+ // with no handler installed SIGTERM is simply dropped.
78
+ // 2. With `killSignal: 'SIGKILL'` — which cannot be ignored — the call
79
+ // returns in about 1.2 s. The DIRECT CHILD is bounded. But a backgrounded
80
+ // grandchild still outlived it and wrote its marker, so the PID namespace
81
+ // does NOT reap the tree here.
82
+ //
83
+ // Settled: SIGKILL bounds the direct child promptly; it does not kill the tree.
84
+ // That is the SAME limitation the userspace backend carries, not an improvement
85
+ // on it. Confinement is unaffected — survivors remain inside the mount and
86
+ // network namespaces and can neither write out of root nor reach the network —
87
+ // but there is no bound on how long descendants run, and any caller needing one
88
+ // must impose it (see `posture/prove-findings.js`, which does). Pinned by
89
+ // "KNOWN GAP: the timeout bounds the direct child but does NOT reap the tree"
90
+ // in `sandbox-escape.test.js`, which fails in both directions.
91
+ //
92
+ // PRIVILEGE. Creating mount/PID/IPC/UTS/network namespaces directly requires
93
+ // CAP_SYS_ADMIN, which an ordinary CI account does not have — asking for them
94
+ // bare fails with a permission error and the backend cannot start at all. The
95
+ // unprivileged route is to create a USER namespace first and take the
96
+ // requested namespaces inside it, where the invoking user holds the
97
+ // capabilities. So the flag set is chosen by PROBE, not assumed: each variant
98
+ // below is executed with a trivial command and the first one that actually
99
+ // succeeds is used (and cached). Fail-closed: if no variant works the backend
100
+ // returns status 'error' and nothing runs. The confinement flags are NEVER
101
+ // relaxed to make a run succeed — dropping `--net` would remove the network
102
+ // confinement, so `--net` is part of every probed variant when `allowNetwork`
103
+ // is false, and `--mount` is in every variant unconditionally because the
104
+ // write confinement is built inside it.
28
105
  import { spawnSync } from 'node:child_process';
29
106
  import fs from 'node:fs';
30
- import { resolveNamespaceBin } from './capabilities.js';
31
- import { buildLimitPrelude } from './limits.js';
107
+ import os from 'node:os';
108
+ import path from 'node:path';
109
+ import {
110
+ resolveNamespaceBin, resolveMountBin, resolvePrivDropBin,
111
+ cachedNamespaceVariant, cacheNamespaceVariant,
112
+ } from './capabilities.js';
113
+ import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
32
114
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
33
115
 
116
+ // Ordered most-portable-first. Each entry is only the PRIVILEGE-acquisition
117
+ // prefix; the namespace flags themselves are appended identically to all of
118
+ // them by `_nsArgs`, so no variant can quietly confine less than another.
119
+ //
120
+ // 1. user namespace with the invoking user mapped to root inside it — the
121
+ // unprivileged route, and the one a standard CI runner needs. It is also
122
+ // the only variant under which the write confinement can be built, since
123
+ // rebinding the mount tree needs CAP_SYS_ADMIN in the owning namespace.
124
+ // 2. user namespace with the invoking user mapped to itself — for hosts
125
+ // whose policy permits a user namespace but not the root mapping.
126
+ // 3. no prefix — the direct route, which needs CAP_SYS_ADMIN (i.e. root).
127
+ // Last so an unprivileged host never pays for a doomed attempt first.
128
+ const NS_PRIVILEGE_VARIANTS = Object.freeze([
129
+ Object.freeze(['--user', '--map-root-user']),
130
+ Object.freeze(['--user', '--map-current-user']),
131
+ Object.freeze([]),
132
+ ]);
133
+
134
+ function _nsArgs(privilegeFlags, allowNetwork) {
135
+ const a = [...privilegeFlags, '--mount', '--pid', '--ipc', '--uts', '--fork'];
136
+ if (!allowNetwork) a.push('--net');
137
+ return a;
138
+ }
139
+
140
+ // Markers the confined shell writes to its own stderr so the parent can tell
141
+ // a confinement-setup failure from ordinary program output. They are stripped
142
+ // from the stderr handed back to the caller.
143
+ //
144
+ // A payload that PRINTS one of these strings can force `status:'error'` (or a
145
+ // false `privilegeDrop` unenforced note). That is the safe direction: the
146
+ // worst it achieves is making its own run look like it did not happen, which
147
+ // no downstream tier reads as evidence of anything. It cannot make an
148
+ // unconfined run look confined.
149
+ const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
150
+ const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
151
+
152
+ // Runs inside the namespaces, still privileged, before the caller's command.
153
+ // Builds the write confinement, then hands off to $SBX_FINAL with the
154
+ // capability set dropped.
155
+ //
156
+ // Order matters: the sandbox root is bound onto itself while the tree is still
157
+ // writable, so the read-only pass and the read-write rebind of the root never
158
+ // have to fight each other. Individual sub-mounts are best-effort (some pseudo
159
+ // filesystems legitimately refuse a rebind); the canary check in $SBX_FINAL is
160
+ // what actually decides whether the result is trustworthy.
161
+ const SETUP_SCRIPT = `
162
+ _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
163
+ "$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
164
+ "$SBX_MOUNT" -t proc proc /proc 2>/dev/null || true
165
+ "$SBX_MOUNT" --bind "$ROOT" "$ROOT" || _fail "the sandbox root could not be bind-mounted"
166
+ _mps=$(while read -r _a _b _c _d _mp _rest; do printf '%s\\n' "$_mp"; done < /proc/self/mountinfo)
167
+ for _mp in $_mps; do
168
+ [ "$_mp" = "/" ] && continue
169
+ [ "$_mp" = "$ROOT" ] && continue
170
+ case "$_mp" in "$ROOT"/*) continue ;; esac
171
+ "$SBX_MOUNT" -o remount,bind,ro "$_mp" 2>/dev/null || true
172
+ done
173
+ "$SBX_MOUNT" -o remount,bind,ro / || _fail "the root filesystem could not be rebound read-only"
174
+ # Belt and braces: the root was bound before the read-only pass and skipped by
175
+ # it, so this is normally a no-op. Its return code is NOT the gate — the
176
+ # executed in-root write check in $SBX_FINAL is, and that one fails closed.
177
+ "$SBX_MOUNT" -o remount,bind,rw "$ROOT" 2>/dev/null || true
178
+ 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
179
+ exec "$SBX_PRIVDROP" --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
180
+ fi
181
+ echo "${MARK_NO_PRIVDROP}" >&2
182
+ exec /bin/sh -c "$SBX_FINAL" _sbx "$@"
183
+ `;
184
+
185
+ // Runs in the FINAL privilege state, immediately before the caller's command.
186
+ // Both directions are checked by execution, every run: the out-of-root canary
187
+ // must be refused, and an in-root write must succeed. Either check failing
188
+ // means the sandbox is not what it claims, so the command is not run.
189
+ const FINAL_SCRIPT = `
190
+ _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
191
+ if ( : > "$SBX_CANARY" ) 2>/dev/null; then
192
+ _fail "an out-of-root write is still possible; refusing to execute"
193
+ fi
194
+ if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
195
+ _fail "the sandbox root is not writable; refusing to execute"
196
+ fi
197
+ rm -f "$ROOT/.agsec-sbx-wcheck"
198
+ cd "$ROOT" && exec "$@"
199
+ `;
200
+
201
+ /**
202
+ * The first privilege variant under which the requested namespaces can
203
+ * actually be created on this host, or null when none can. Probed by running
204
+ * a trivial command — a reasoned expectation about which flags "should" work
205
+ * is exactly what made this backend unusable on an unprivileged runner.
206
+ */
207
+ export function resolveNamespaceArgs(bin, allowNetwork, { probeTimeoutMs = 5000 } = {}) {
208
+ const key = `${bin}:${allowNetwork ? 'net' : 'nonet'}`;
209
+ const cached = cachedNamespaceVariant(key);
210
+ if (cached !== undefined) return cached;
211
+
212
+ let chosen = null;
213
+ for (const variant of NS_PRIVILEGE_VARIANTS) {
214
+ const args = _nsArgs(variant, allowNetwork);
215
+ const probe = spawnSync(bin, [...args, '/bin/sh', '-c', 'exit 0'], {
216
+ encoding: 'utf8', timeout: probeTimeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
217
+ });
218
+ if (!probe.error && probe.status === 0) { chosen = args; break; }
219
+ }
220
+ cacheNamespaceVariant(key, chosen);
221
+ return chosen;
222
+ }
223
+
224
+ /** Strip the internal markers from stderr before it reaches the caller. */
225
+ function _cleanStderr(s) {
226
+ return String(s || '')
227
+ .split('\n')
228
+ .filter((l) => !l.includes(MARK_SETUP_FAILED) && l.trim() !== MARK_NO_PRIVDROP)
229
+ .join('\n');
230
+ }
231
+
232
+ function _setupFailureReason(stderr) {
233
+ for (const line of String(stderr || '').split('\n')) {
234
+ const i = line.indexOf(MARK_SETUP_FAILED);
235
+ if (i !== -1) return line.slice(i + MARK_SETUP_FAILED.length).trim();
236
+ }
237
+ return null;
238
+ }
239
+
34
240
  export function runNamespace(argv, {
35
241
  root,
36
242
  timeoutMs = 10000,
@@ -45,6 +251,14 @@ export function runNamespace(argv, {
45
251
  const bin = resolveNamespaceBin();
46
252
  if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
47
253
 
254
+ // Write confinement is built with this utility. No utility, no confinement,
255
+ // no run — there is deliberately no branch that proceeds without it.
256
+ const mountBin = resolveMountBin();
257
+ if (!mountBin) {
258
+ return errorResult('namespace',
259
+ 'no filesystem-attach binary found on this host, so write confinement cannot be established; refusing to execute unconfined');
260
+ }
261
+
48
262
  let resolvedRoot;
49
263
  try {
50
264
  // Resolve symlinks so the path the kernel actually sees matches what we
@@ -54,30 +268,96 @@ export function runNamespace(argv, {
54
268
  return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
55
269
  }
56
270
 
271
+ // Same per-uid RLIMIT_NPROC trap as the userspace backend, and worse here:
272
+ // the confined shell has to fork several helpers to BUILD its confinement,
273
+ // so a fixed cap below the ambient count for this uid makes the setup itself
274
+ // fail and the sandbox look broken. See `ambientRelativeMaxProcs`.
275
+ const effectiveLimits = { ...limits, maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs() };
276
+
57
277
  let prelude, unsupported;
58
278
  try {
59
- ({ prelude, unsupported } = buildLimitPrelude(limits));
279
+ ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
60
280
  } catch (e) {
61
281
  return errorResult('namespace', `invalid resource limit: ${e.message}`);
62
282
  }
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 });
283
+
284
+ // Fail closed: no usable variant means the confinement cannot be
285
+ // established, so nothing is executed. There is deliberately no path that
286
+ // drops confinement flags and runs anyway.
287
+ const nsArgs = resolveNamespaceArgs(bin, allowNetwork);
288
+ if (!nsArgs) {
289
+ return errorResult('namespace', 'kernel namespaces could not be created on this host (unprivileged user-namespace creation appears to be denied); refusing to execute unconfined');
290
+ }
291
+
292
+ // The canary lives OUTSIDE the sandbox root, in a directory this process
293
+ // just created and can write. If the confined shell can create it, the
294
+ // confinement is not in force and the command is not run.
295
+ let canaryDir = null;
296
+ try {
297
+ canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-canary-'));
298
+ } catch (e) {
299
+ return errorResult('namespace', `could not create the confinement canary: ${e.message}`);
300
+ }
301
+ const canary = path.join(canaryDir, 'out-of-root.canary');
302
+
303
+ let r;
304
+ try {
305
+ r = spawnSync(
306
+ bin,
307
+ [...nsArgs, '/bin/sh', '-c', prelude + SETUP_SCRIPT, '_sbx', ...argv],
308
+ {
309
+ encoding: 'utf8',
310
+ timeout: timeoutMs,
311
+ // SIGKILL, not the SIGTERM default, and this is the whole reason the
312
+ // timeout did not work here. The direct child is pid 1 of a new PID
313
+ // namespace (`--pid --fork`), and the kernel does not deliver
314
+ // default-action signals to a namespace's pid 1 from outside it — a
315
+ // process with no handler installed for SIGTERM simply does not die.
316
+ // SIGKILL is the one signal that cannot be ignored or blocked, so it
317
+ // is the only signal that can bound a payload here.
318
+ //
319
+ // Found by CI, not by reasoning: the first Linux run of the tree-kill
320
+ // test recorded duration_ms 30057 against a 1200 ms budget with the
321
+ // payload run to completion. The comment above this function used to
322
+ // claim the opposite.
323
+ killSignal: 'SIGKILL',
324
+ maxBuffer,
325
+ cwd: resolvedRoot,
326
+ env: {
327
+ ...buildConfinedEnv({ root: resolvedRoot, env }),
328
+ SBX_MOUNT: mountBin,
329
+ SBX_PRIVDROP: resolvePrivDropBin() || '',
330
+ SBX_CANARY: canary,
331
+ SBX_FINAL: FINAL_SCRIPT,
332
+ },
333
+ },
334
+ );
335
+
336
+ // Parent-side confirmation of the same fact the canary check asserts from
337
+ // the inside. Cheap, and it does not depend on the confined shell being
338
+ // honest about its own exit code.
339
+ if (fs.existsSync(canary)) {
340
+ return errorResult('namespace',
341
+ 'the confined process created a file outside the sandbox root: write confinement is NOT in force on this host');
342
+ }
343
+ } finally {
344
+ try { fs.rmSync(canaryDir, { recursive: true, force: true }); } catch { /* best effort */ }
345
+ }
346
+
347
+ const rawStderr = r.stderr ?? '';
348
+ const setupFailure = _setupFailureReason(rawStderr);
349
+ if (setupFailure && !r.error) {
350
+ // Confinement could not be established (or could not be proven). Nothing
351
+ // ran: the shell exits before `exec`ing the caller's command.
352
+ return errorResult('namespace', `confinement could not be established: ${setupFailure}`);
353
+ }
354
+
355
+ const effectiveUnsupported = [...unsupported];
356
+ if (rawStderr.includes(MARK_NO_PRIVDROP)) effectiveUnsupported.push('privilegeDrop');
357
+
358
+ return buildResult({
359
+ backend: 'namespace',
360
+ spawnResult: { ...r, stderr: _cleanStderr(rawStderr) },
361
+ unsupported: effectiveUnsupported,
362
+ });
83
363
  }
@@ -14,26 +14,9 @@
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import fs from 'node:fs';
16
16
  import { resolveUserspaceBin } from './capabilities.js';
17
- import { buildLimitPrelude } from './limits.js';
17
+ import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
18
18
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
19
19
 
20
- // `ulimit -u` (RLIMIT_NPROC) is a per-uid, system-wide cap on this platform,
21
- // not a per-process-tree cap (verified by execution in Task 2). A fixed
22
- // default like the 64 in buildLimitPrelude() breaks ordinary, non-adversarial
23
- // runs on any host whose user already has more than ~64 ambient processes —
24
- // which is common. So unless the caller passes an explicit maxProcs, we
25
- // compute one relative to the ambient count for this uid, the same
26
- // workaround Task 2 used in its own test, to keep default behavior usable
27
- // without pretending a low fixed cap is real containment.
28
- function _ambientProcCount() {
29
- try {
30
- const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
31
- return Number(String(out.stdout || '').trim()) || 200;
32
- } catch {
33
- return 200;
34
- }
35
- }
36
-
37
20
  function _profile({ allowNetwork }) {
38
21
  return [
39
22
  '(version 1)',
@@ -72,7 +55,7 @@ export function runUserspace(argv, {
72
55
 
73
56
  const effectiveLimits = {
74
57
  ...limits,
75
- maxProcs: limits.maxProcs ?? (_ambientProcCount() + 64),
58
+ maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
76
59
  };
77
60
 
78
61
  let prelude, unsupported;
@@ -90,6 +73,10 @@ export function runUserspace(argv, {
90
73
  {
91
74
  encoding: 'utf8',
92
75
  timeout: timeoutMs,
76
+ // Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
77
+ // A payload that installs a SIGTERM handler would otherwise outlive its
78
+ // own budget while the caller is told it timed out.
79
+ killSignal: 'SIGKILL',
93
80
  maxBuffer,
94
81
  cwd: resolvedRoot,
95
82
  env: buildConfinedEnv({ root: resolvedRoot, env }),
@@ -1,7 +1,28 @@
1
1
  // Detects which OS confinement primitive is available. Fail-closed: when none
2
2
  // is found we report 'disabled', which REFUSES execution rather than running
3
3
  // target code unconfined.
4
+ //
5
+ // DETECTION IS FUNCTIONAL, NOT PRESENCE-BASED. An earlier version concluded
6
+ // "available" from "the confinement binary is executable". That is a different
7
+ // question from the one callers are actually asking. Verified on a Linux CI
8
+ // runner: the kernel-namespace tool is installed and executable, but the
9
+ // distribution restricts unprivileged user-namespace creation, so every
10
+ // privilege variant fails and no confined command can start. Presence-based
11
+ // detection reported the backend as available anyway, and `sandboxAvailable()`
12
+ // — the signal callers use to decide whether it is safe to EXECUTE UNTRUSTED
13
+ // CODE — answered true while nothing could actually be confined. False
14
+ // assurance about confinement is precisely the failure this module exists to
15
+ // prevent, so a backend now counts as available only if it just ran a trivial
16
+ // command through its real code path.
17
+ //
18
+ // The probe result is cached for the process (one spawn, not one per call —
19
+ // detection sits on the path of ordinary scans) and cleared by
20
+ // `resetCapabilityCache()`.
4
21
  import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { runUserspace } from './backend-userspace.js';
25
+ import { runNamespace } from './backend-namespace.js';
5
26
 
6
27
  // Referenced by path, never by product name (see Global Constraints).
7
28
  //
@@ -21,13 +42,49 @@ export const CONFINE_BINS_NAMESPACE = Object.freeze([
21
42
  '/usr/sbin/unshare',
22
43
  ]);
23
44
 
45
+ // The filesystem-attach utility used by the namespace backend to establish
46
+ // write confinement (read-only rebind of the whole mount tree, read-write
47
+ // rebind of the sandbox root). Resolved by path for the same reason as the
48
+ // others. Absent => the namespace backend cannot establish write confinement
49
+ // and fails closed; it never runs a command with the filesystem open.
50
+ export const CONFINE_BINS_MOUNT = Object.freeze([
51
+ '/usr/bin/mount',
52
+ '/bin/mount',
53
+ '/sbin/mount',
54
+ '/usr/sbin/mount',
55
+ ]);
56
+
57
+ // The privilege-dropping utility used to remove CAP_SYS_ADMIN (and everything
58
+ // else) from the confined process *after* the mounts are in place, so the
59
+ // payload cannot simply undo the read-only rebinds. Best-effort hardening on
60
+ // top of the mount confinement, not the confinement itself: when it is absent
61
+ // the run still happens under the read-only mount tree and the result declares
62
+ // `privilegeDrop` unenforced rather than staying silent about it.
63
+ export const CONFINE_BINS_PRIVDROP = Object.freeze([
64
+ '/usr/bin/setpriv',
65
+ '/bin/setpriv',
66
+ '/sbin/setpriv',
67
+ '/usr/sbin/setpriv',
68
+ ]);
69
+
24
70
  // Back-compat single-path exports: the first (canonical) candidate.
25
71
  export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
26
72
  export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
27
73
 
28
74
  let _cached = null;
29
75
 
30
- export function resetCapabilityCache() { _cached = null; }
76
+ // Which namespace-flag variant actually works on this host, keyed by the
77
+ // requested confinement shape. Probing costs a process spawn, so it is done
78
+ // once; `undefined` means "not probed yet", `null` means "probed and nothing
79
+ // worked" (which the backend turns into a fail-closed error, never a run).
80
+ const _nsVariant = new Map();
81
+
82
+ export function resetCapabilityCache() { _cached = null; _nsVariant.clear(); }
83
+
84
+ export function cachedNamespaceVariant(key) {
85
+ return _nsVariant.has(key) ? _nsVariant.get(key) : undefined;
86
+ }
87
+ export function cacheNamespaceVariant(key, value) { _nsVariant.set(key, value); }
31
88
 
32
89
  /** First executable candidate, or null when none of them exists. */
33
90
  export function resolveConfineBin(candidates) {
@@ -37,13 +94,84 @@ export function resolveConfineBin(candidates) {
37
94
 
38
95
  export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
39
96
  export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
97
+ export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
98
+ export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
40
99
 
41
- export function detectBackend({ force } = {}) {
100
+ // Bounded on purpose: a capability check must never hang a scan. The probe is
101
+ // a single `exit 0` under confinement, so anything beyond a couple of seconds
102
+ // is a host that is not going to answer.
103
+ const PROBE_TIMEOUT_MS = Math.max(
104
+ 250,
105
+ Number(process.env.AGENTIC_SECURITY_SANDBOX_PROBE_TIMEOUT_MS) || 4000,
106
+ );
107
+
108
+ /**
109
+ * Run a trivial command through a backend's real code path and report whether
110
+ * confinement actually worked. Anything other than a clean confined run — a
111
+ * missing binary, a refused namespace, a timeout, a throw — is `false`. There
112
+ * is deliberately no branch that relaxes confinement to make a probe pass: a
113
+ * backend that can only succeed with a flag dropped is not available, it is
114
+ * `'disabled'`.
115
+ */
116
+ function _probeThroughBackend(runner) {
117
+ let root = null;
118
+ try {
119
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-probe-'));
120
+ const r = runner(['/bin/sh', '-c', 'exit 0'], { root, timeoutMs: PROBE_TIMEOUT_MS });
121
+ return r?.status === 'ok';
122
+ } catch {
123
+ return false;
124
+ } finally {
125
+ if (root) { try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The real probes, one per backend. The binary check stays only as a cheap
131
+ * pre-filter that avoids a pointless temp dir on a host that plainly lacks the
132
+ * primitive — it is no longer the answer, just the fast negative.
133
+ */
134
+ export function defaultProbes() {
135
+ return {
136
+ userspace: () => (resolveUserspaceBin() ? _probeThroughBackend(runUserspace) : false),
137
+ namespace: () => (resolveNamespaceBin() ? _probeThroughBackend(runNamespace) : false),
138
+ };
139
+ }
140
+
141
+ /** Backends worth probing on a platform, most-appropriate first. */
142
+ export function backendCandidates(platform = process.platform) {
143
+ if (platform === 'darwin') return ['userspace'];
144
+ if (platform === 'linux') return ['namespace'];
145
+ return [];
146
+ }
147
+
148
+ /**
149
+ * @param {object} [o]
150
+ * @param {string} [o.force] Bypass detection entirely (tests, and callers
151
+ * that want the disabled path deliberately).
152
+ * @param {object} [o.probes] Probe map override — a seam for tests to drive
153
+ * the selection contract with stand-ins on any
154
+ * platform. Cannot cause unconfined execution:
155
+ * dispatch still goes to the real backend.
156
+ * @param {string[]} [o.candidates] Candidate order override (same seam).
157
+ */
158
+ export function detectBackend({ force, probes, candidates } = {}) {
42
159
  if (force) return force;
43
160
  if (_cached) return _cached;
161
+
162
+ const probeMap = probes || defaultProbes();
163
+ const order = candidates || backendCandidates();
164
+
44
165
  let b = 'disabled';
45
- if (process.platform === 'darwin' && resolveUserspaceBin()) b = 'userspace';
46
- else if (process.platform === 'linux' && resolveNamespaceBin()) b = 'namespace';
166
+ for (const name of order) {
167
+ const probe = probeMap[name];
168
+ if (typeof probe !== 'function') continue;
169
+ let works = false;
170
+ try { works = probe() === true; } catch { works = false; }
171
+ if (works) { b = name; break; }
172
+ // Otherwise fall through to the next candidate, and ultimately to
173
+ // 'disabled' — never to "run it anyway".
174
+ }
47
175
  _cached = b;
48
176
  return b;
49
177
  }
@@ -4,6 +4,27 @@
4
4
  // verified by execution. We therefore DECLARE it unsupported rather than
5
5
  // emitting a limit that silently does nothing, which would be a false
6
6
  // assurance of containment.
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ // `ulimit -u` (RLIMIT_NPROC) is charged per *uid*, system-wide, on both
10
+ // platforms this module supports — it is not a per-process-tree cap. A fixed
11
+ // default like the 64 below therefore breaks ordinary, non-adversarial runs on
12
+ // any host whose user already owns more than ~64 processes, which is most of
13
+ // them: the confined shell cannot even fork the helpers it needs to set itself
14
+ // up, and the failure looks like a broken sandbox rather than a cap doing its
15
+ // job. So unless a caller passes an explicit `maxProcs`, both real backends
16
+ // derive one from the ambient count for this uid. That keeps default behaviour
17
+ // usable without pretending a low fixed cap is real containment — see the
18
+ // "fork-storm containment is weak" note in the module guide.
19
+ export function ambientRelativeMaxProcs(headroom = 64) {
20
+ let ambient = 200;
21
+ try {
22
+ const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
23
+ ambient = Number(String(out.stdout || '').trim()) || 200;
24
+ } catch { /* fall through to the conservative default */ }
25
+ return ambient + headroom;
26
+ }
27
+
7
28
  export function buildLimitPrelude({
8
29
  maxProcs = 64,
9
30
  maxFileSizeKb = 65536,