@clear-capabilities/agentic-security-scanner 0.163.1 → 0.164.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.
@@ -0,0 +1,178 @@
1
+ // Active probes specific to the Linux namespace backend (X-502, X-503, CORE-003).
2
+ //
3
+ // These are the controls that are only true because of what the kernel does
4
+ // with namespaces, each established the way `control-probes.js` establishes the
5
+ // others: an ATTACK that must fail, paired with a POSITIVE CONTROL showing the
6
+ // same probe succeeds when the control is off, so a probe that can never
7
+ // succeed cannot pass for a working control.
8
+ //
9
+ // pid-namespace the payload is pid 1 and cannot see host pids
10
+ // init-kill-reaps-detached killing the namespace's supervisor (or its init)
11
+ // ends a double-forked, setsid member that no
12
+ // process-group or sweep logic could find
13
+ // protected-canary-masked a protected file and directory under a DECLARED
14
+ // read root are unreadable (masked), and readable
15
+ // when not protected
16
+ // capability-network in capability mode a loopback listener is
17
+ // reachable only when network is allowed
18
+ //
19
+ // Each takes a `run` seam so a test can hand it a runner that is deliberately
20
+ // wrong and see the probe say so. Nothing here asserts a result in advance: a
21
+ // probe that cannot establish its own positive control reports `not-proved`.
22
+ import { spawn, spawnSync } from 'node:child_process';
23
+ import crypto from 'node:crypto';
24
+ import fs from 'node:fs';
25
+ import net from 'node:net';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { runConfined } from './index.js';
29
+ import { buildNamespaceInvocation } from './backend-namespace.js';
30
+ import { heartbeatShell } from './control-probes.js';
31
+
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+ const NODE = process.execPath;
34
+ const proved = (evidence) => ({ state: 'proved', evidence });
35
+ const notProved = (reason) => ({ state: 'not-proved', reason });
36
+
37
+ function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); }
38
+ function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
39
+ const token = (p) => `${p}-${crypto.randomBytes(8).toString('hex')}`;
40
+
41
+ /** The payload is pid 1 of its own PID namespace and sees no host process. */
42
+ export async function probePidNamespace({ run = runConfined } = {}) {
43
+ const root = mk('agsec-lp-pid-');
44
+ try {
45
+ // A host process that certainly exists and is not in the payload's tree.
46
+ const r = run(['/bin/sh', '-c', 'echo PID=$$; ls /proc'], { root, timeoutMs: 8000 });
47
+ const out = String(r.stdout || '');
48
+ const m = /PID=(\d+)/.exec(out);
49
+ if (!m) return notProved(`positive control failed: the probe payload did not report (${r.status})`);
50
+ if (m[1] !== '1') return notProved(`the payload is pid ${m[1]}, not pid 1 of a new PID namespace`);
51
+ const visible = out.split('\n').map((l) => l.trim()).filter((l) => /^\d+$/.test(l));
52
+ if (!visible.length) return notProved('positive control failed: /proc listed no processes, so host visibility could not be judged');
53
+ if (visible.includes(String(process.pid)) || visible.includes(String(process.ppid))) {
54
+ return notProved('the payload can see host processes in /proc');
55
+ }
56
+ return proved(`the payload is pid 1; /proc lists ${visible.length} process(es) and none of the host's`);
57
+ } finally { rm(root); }
58
+ }
59
+
60
+ function heartbeatLines(root, n) {
61
+ try { return fs.readFileSync(path.join(root, `hb${n}`), 'utf8').split('\n').filter(Boolean).length; } catch { return 0; } // agentic-security-ignore: CWE-400
62
+ }
63
+
64
+ function childrenOf(pid) {
65
+ const r = spawnSync('ps', ['-o', 'pid=', '--ppid', String(pid)], { encoding: 'utf8', timeout: 3000 });
66
+ return String(r.stdout || '').split('\n').map((x) => Number(x.trim())).filter(Number.isFinite).filter(Boolean);
67
+ }
68
+
69
+ /**
70
+ * Start a namespace-confined tree that includes a double-forked, setsid member,
71
+ * then kill ONE process from the host: the supervisor (`unshare`) or the
72
+ * namespace's init (its child). Either way the kernel must take every member
73
+ * of the namespace with it.
74
+ *
75
+ * @param {'supervisor'|'init'} victim
76
+ */
77
+ export async function probeInitKillReapsDetached({ victim = 'supervisor', settleMs = 700, deps } = {}) {
78
+ const root = mk('agsec-lp-init-');
79
+ let inv = null; let child = null;
80
+ try {
81
+ const script = [
82
+ `( ${heartbeatShell(1)} ) &`,
83
+ `( setsid /bin/sh -c '${heartbeatShell(2)}' >/dev/null 2>&1 & ) &`,
84
+ `( trap '' TERM; ${heartbeatShell(3)} ) &`,
85
+ 'wait',
86
+ ].join('\n');
87
+ inv = buildNamespaceInvocation(['/bin/sh', '-c', script], { root }, deps);
88
+ if (inv.error) return { state: 'unsupported', reason: String(inv.error.stderr || '').split('\n')[0] };
89
+ if (!inv.treeKill) return { state: 'unsupported', reason: 'this host cannot make the kernel kill the namespace with its supervisor' };
90
+ child = spawn(inv.bin, inv.args, { cwd: inv.cwd, env: inv.env, stdio: 'ignore' });
91
+ const lines = (n) => heartbeatLines(root, n);
92
+ const names = [1, 2, 3];
93
+ const deadline = Date.now() + 8000;
94
+ while (Date.now() < deadline && !names.every((n) => lines(n) >= 3)) await sleep(100);
95
+ if (!names.every((n) => lines(n) >= 3)) return notProved('positive control failed: the tree did not start and heartbeat before the kill');
96
+
97
+ let targets = [child.pid];
98
+ if (victim === 'init') {
99
+ // The namespace's init is the supervisor's direct child (unshare forks
100
+ // it). Kill every direct child, never the supervisor itself.
101
+ targets = childrenOf(child.pid);
102
+ if (!targets.length) return notProved('positive control failed: no namespace init found under the supervisor');
103
+ }
104
+ for (const t of targets) { try { process.kill(t, 'SIGKILL'); } catch { /* already gone */ } }
105
+ await sleep(settleMs);
106
+ const mid = names.map(lines);
107
+ await sleep(settleMs);
108
+ const late = names.map(lines);
109
+ const alive = names.filter((n, i) => late[i] > mid[i]);
110
+ if (alive.length) return notProved(`members of the namespace outlived the kill of its ${victim} (heartbeat ${alive.join(',')})`);
111
+ return proved(`SIGKILL of the namespace ${victim} ended a plain member, a double-forked setsid member and a SIGTERM-ignoring member`);
112
+ } finally {
113
+ try { if (child) child.kill('SIGKILL'); } catch { /* gone */ }
114
+ if (inv && !inv.error) { try { inv.finish('', { hadError: true }); } catch { /* best effort */ } }
115
+ rm(root);
116
+ }
117
+ }
118
+
119
+ /** A protected file and directory under a declared read root are unreadable, and readable when unprotected. */
120
+ export async function probeProtectedCanaryMasked({ run = runConfined } = {}) {
121
+ const root = mk('agsec-lp-prot-'); const base = mk('agsec-lp-pbase-');
122
+ try {
123
+ const dirTok = token('SEALEDDIR'); const fileTok = token('SEALEDFILE'); const pubTok = token('PUBLIC');
124
+ fs.mkdirSync(path.join(base, 'sealed'));
125
+ fs.writeFileSync(path.join(base, 'sealed', 'k.txt'), dirTok);
126
+ fs.writeFileSync(path.join(base, 'sealed-file.txt'), fileTok);
127
+ fs.writeFileSync(path.join(base, 'public.txt'), pubTok);
128
+ const cmd = ['/bin/sh', '-c', `cat '${base}/public.txt' '${base}/sealed/k.txt' '${base}/sealed-file.txt' 2>&1; true`];
129
+ const open = run(cmd, { root, readRoots: [base], timeoutMs: 8000 });
130
+ const o = String(open.stdout || '');
131
+ if (!(o.includes(pubTok) && o.includes(dirTok) && o.includes(fileTok))) {
132
+ return notProved(`positive control failed: the files were not readable without protection (${open.status})`);
133
+ }
134
+ const closed = run(cmd, { root, readRoots: [base], denyReadPaths: [path.join(base, 'sealed'), path.join(base, 'sealed-file.txt')], timeoutMs: 8000 });
135
+ const c = String(closed.stdout || '');
136
+ if (!c.includes(pubTok)) return notProved(`positive control failed: the unprotected file stopped being readable (${closed.status})`);
137
+ if (c.includes(dirTok)) return notProved('a protected directory under a declared root was readable');
138
+ if (c.includes(fileTok)) return notProved('a protected file under a declared root was readable');
139
+ return proved('a protected directory and file under a declared read root were unreadable; the unprotected file and both canaries were readable without protection');
140
+ } finally { rm(root); rm(base); }
141
+ }
142
+
143
+ /** In capability mode a loopback listener is reachable with network allowed and unreachable otherwise. */
144
+ export async function probeCapabilityNetwork({ run = runConfined } = {}) {
145
+ const root = mk('agsec-lp-net-');
146
+ let hits = 0;
147
+ const server = net.createServer((s) => { hits += 1; s.destroy(); });
148
+ try {
149
+ await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', res); });
150
+ const port = server.address().port;
151
+ const script = `require('net').connect(${port},'127.0.0.1').on('error',()=>process.exit(0)).on('connect',()=>process.exit(0));setTimeout(()=>process.exit(0),1500)`;
152
+ const exeReal = fs.realpathSync(NODE);
153
+ run([NODE, '-e', script], { root, readRoots: [exeReal], allowNetwork: true, timeoutMs: 8000 });
154
+ await sleep(200);
155
+ const open = hits;
156
+ if (open < 1) return notProved('positive control failed: the loopback listener saw no connection with network allowed');
157
+ const denied = run([NODE, '-e', script], { root, readRoots: [exeReal], allowNetwork: false, timeoutMs: 8000 });
158
+ await sleep(200);
159
+ if (hits > open) return notProved('a connection reached a loopback listener with network denied');
160
+ void denied;
161
+ return proved('a connection was seen with network allowed and none with network denied (capability mode)');
162
+ } finally { server.close(); rm(root); }
163
+ }
164
+
165
+ /** Every Linux-specific probe, in a fixed order. */
166
+ export async function runLinuxProbes() {
167
+ const out = {};
168
+ const step = async (name, fn) => {
169
+ try { out[name] = await fn(); } catch (e) { out[name] = notProved(`probe threw: ${String(e.message).split('\n')[0]}`); }
170
+ };
171
+ await step('pid-namespace', () => probePidNamespace());
172
+ await step('init-kill-reaps-detached:supervisor', () => probeInitKillReapsDetached({ victim: 'supervisor' }));
173
+ await step('init-kill-reaps-detached:init', () => probeInitKillReapsDetached({ victim: 'init' }));
174
+ await step('protected-canary-masked:default', () => probeProtectedCanaryMasked({ run: (a, o) => runConfined(a, { ...o, readRoots: undefined }) }));
175
+ await step('protected-canary-masked:capability', () => probeProtectedCanaryMasked());
176
+ await step('capability-network', () => probeCapabilityNetwork());
177
+ return out;
178
+ }
@@ -0,0 +1,256 @@
1
+ // Pure construction of the Linux root filesystem the namespace backend pivots
2
+ // into (capability mode, X-502).
3
+ //
4
+ // Nothing in this file touches the kernel. It turns declared roots into a PLAN
5
+ // (which host paths are made visible, read-only or read-write, and which are
6
+ // masked) and a POSIX shell script that carries the plan out inside fresh user,
7
+ // mount and PID namespaces. Keeping it pure is the point: the construction can
8
+ // be unit-tested on any host, with stand-in mount tools, and the claim that the
9
+ // kernel then enforces it is made only by the active probes that run on a Linux
10
+ // host (`linux-probes.js`, the `sandbox-linux` CI job).
11
+ //
12
+ // The model, in one paragraph. A new mount namespace gets a tmpfs root that
13
+ // holds NOTHING. Only the paths in the plan are bind-mounted into it, at the
14
+ // same absolute path they have on the host: the minimal runtime baseline
15
+ // (interpreter, libraries, a few identity files) and the declared read roots
16
+ // read-only, the declared write roots read-write, then a fresh /proc and a
17
+ // minimal /dev. The skeleton is remounted read-only and the process pivots into
18
+ // it; the old root is detached. A protected path is not denied, it is ABSENT:
19
+ // the kernel has no name for it, so there is nothing to open, to follow a link
20
+ // to, or to reach with `..`. Paths keep their meaning, so a caller's absolute
21
+ // paths mean the same thing on this backend as on the userspace one.
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ // What every confined process needs just to start: the dynamic loader, system
26
+ // libraries, the shell and coreutils. Mounted read-only. Directories that are
27
+ // symlinks on the host (`/bin -> usr/bin`) are recreated as symlinks.
28
+ export const BASELINE_DIRS = Object.freeze([
29
+ '/usr', '/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/etc/alternatives',
30
+ ]);
31
+ // Identity and loader files; no credentials. Read-only. A missing one is skipped.
32
+ export const BASELINE_FILES = Object.freeze([
33
+ '/etc/ld.so.cache', '/etc/ld.so.conf', '/etc/localtime', '/etc/nsswitch.conf', '/etc/passwd', '/etc/group',
34
+ ]);
35
+ // Device nodes a process legitimately needs. Nothing else from /dev.
36
+ export const DEV_NODES = Object.freeze(['null', 'zero', 'full', 'random', 'urandom', 'tty']);
37
+
38
+ const BAD_CHARS = /[\0\n\t]/;
39
+
40
+ /** True for a path the plan format can carry (absolute, no NUL/newline/tab, not the root). */
41
+ export function isPlanPath(p) {
42
+ return typeof p === 'string' && p.startsWith('/') && p !== '/' && !BAD_CHARS.test(p);
43
+ }
44
+
45
+ function realOrResolved(p, realpath) {
46
+ const abs = path.resolve(p);
47
+ try { return realpath(abs); } catch { return abs; }
48
+ }
49
+
50
+ function within(parent, child) {
51
+ return child === parent || child.startsWith(parent.endsWith('/') ? parent : parent + '/');
52
+ }
53
+
54
+ /**
55
+ * Build the plan.
56
+ * @param {object} o
57
+ * @param {string} o.root the sandbox root (always read-write)
58
+ * @param {string[]} [o.readRoots] read-only roots (directories or files)
59
+ * @param {string[]} [o.writeRoots] read-write roots
60
+ * @param {string[]} [o.denyReadPaths] paths that must not be readable
61
+ * @param {string} [o.cwd] working directory (must lie inside a bound path)
62
+ * @param {boolean} [o.baseline=true] include the runtime baseline
63
+ * @param {(p:string)=>string} [o.realpath] seam for tests
64
+ * @returns {{ok:true, entries:{path:string, mode:'ro'|'rw'}[], masks:string[], cwd:string} | {ok:false, error:string}}
65
+ */
66
+ export function buildRootPlan({
67
+ root, readRoots = [], writeRoots = [], denyReadPaths = [], cwd = null, baseline = true,
68
+ realpath = fs.realpathSync,
69
+ } = {}) {
70
+ if (!root) return { ok: false, error: 'a sandbox root is required' };
71
+ const want = new Map(); // path -> 'ro' | 'rw' (rw wins)
72
+ const add = (p, mode) => {
73
+ if (want.get(p) === 'rw') return;
74
+ want.set(p, mode);
75
+ };
76
+ const clean = (p, what) => {
77
+ if (typeof p !== 'string' || !p) return { error: `${what} must be a non-empty path` };
78
+ const real = realOrResolved(p, realpath);
79
+ if (real === '/') return { error: `${what} may not be the filesystem root` };
80
+ if (!isPlanPath(real)) return { error: `${what} cannot be carried into a mount plan (${JSON.stringify(p)})` };
81
+ return { path: real };
82
+ };
83
+
84
+ if (baseline) {
85
+ for (const p of [...BASELINE_DIRS, ...BASELINE_FILES]) add(p, 'ro');
86
+ }
87
+ const rootC = clean(root, 'the sandbox root');
88
+ if (rootC.error) return { ok: false, error: rootC.error };
89
+ add(rootC.path, 'rw');
90
+ for (const p of readRoots || []) {
91
+ const c = clean(p, 'a read root');
92
+ if (c.error) return { ok: false, error: c.error };
93
+ add(c.path, 'ro');
94
+ }
95
+ for (const p of writeRoots || []) {
96
+ const c = clean(p, 'a write root');
97
+ if (c.error) return { ok: false, error: c.error };
98
+ add(c.path, 'rw');
99
+ }
100
+
101
+ // Drop an entry already covered by a bound ancestor with at least its access:
102
+ // binding a file over a path inside a read-only mount would have to create it
103
+ // there, which a read-only mount refuses, and it adds nothing.
104
+ const all = [...want.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
105
+ const entries = [];
106
+ for (const [p, mode] of all) {
107
+ const covered = entries.some((e) => e.path !== p && within(e.path, p) && (e.mode === 'rw' || mode === 'ro'));
108
+ if (!covered) entries.push({ path: p, mode });
109
+ }
110
+
111
+ const masks = [];
112
+ for (const p of denyReadPaths || []) {
113
+ if (typeof p !== 'string' || !p) continue;
114
+ const real = realOrResolved(p, realpath);
115
+ if (!isPlanPath(real)) {
116
+ if (real === '/' || BAD_CHARS.test(real)) return { ok: false, error: `a denied path cannot be carried into a mount plan (${JSON.stringify(p)})` };
117
+ continue;
118
+ }
119
+ // A denied path that contains a bound root would hide the root; refuse
120
+ // rather than guess which of the two the caller meant.
121
+ const hides = [rootC.path, ...[...(readRoots || []), ...(writeRoots || [])].map((r) => realOrResolved(r, realpath))]
122
+ .find((r) => within(real, r));
123
+ if (hides) return { ok: false, error: `a denied path overlaps a bound root (${real})` };
124
+ if (!masks.includes(real)) masks.push(real);
125
+ }
126
+
127
+ let workdir = rootC.path;
128
+ if (cwd) {
129
+ const c = clean(cwd, 'the working directory');
130
+ if (c.error) return { ok: false, error: c.error };
131
+ if (!entries.some((e) => within(e.path, c.path))) return { ok: false, error: `the working directory is outside every bound path (${c.path})` };
132
+ workdir = c.path;
133
+ }
134
+ return { ok: true, entries, masks, cwd: workdir };
135
+ }
136
+
137
+ /** The plan as the tab-separated text the setup script reads. */
138
+ export function serializePlan(plan) {
139
+ const lines = [];
140
+ for (const e of plan.entries) lines.push(`B\t${e.mode}\t${e.path}`);
141
+ for (const m of plan.masks) lines.push(`M\t-\t${m}`);
142
+ return lines.join('\n');
143
+ }
144
+
145
+ export const MARK_SETUP_FAILED = 'AGSEC_SANDBOX_SETUP_FAILED:';
146
+ export const MARK_NO_PRIVDROP = 'AGSEC_SANDBOX_PRIVDROP_UNAVAILABLE';
147
+
148
+ /**
149
+ * The setup script for capability mode. Runs inside the new namespaces as the
150
+ * mapped root, before anything of the caller's. Reads, from the environment:
151
+ * SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP absolute tool paths
152
+ * SBX_NEWROOT an empty host directory to become the new root
153
+ * SBX_PLAN the serialized plan
154
+ * SBX_FINAL the second-stage script (final privilege state)
155
+ * Every step is fail-closed: a failure prints the marker and exits before the
156
+ * caller's command is exec'd.
157
+ */
158
+ export const PIVOT_SETUP_SCRIPT = `
159
+ _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
160
+ # Make one bind mount read-only. A plain remount is tried first; some kernels
161
+ # lock the flags a bind inherits inside a user namespace (nosuid, nodev, ...),
162
+ # in which case the remount must restate them, so the second attempt reads the
163
+ # mount's current options and replaces only the access mode.
164
+ _ro() {
165
+ "$SBX_MOUNT" -o remount,bind,ro "$1" 2>/dev/null && return 0
166
+ _cur=$(while read -r _a _b _c _d _mp _o _rest; do if [ "$_mp" = "$1" ]; then echo "$_o"; break; fi; done < /proc/self/mountinfo)
167
+ case "$_cur" in *,*) "$SBX_MOUNT" -o "remount,bind,ro,\${_cur#*,}" "$1" ;; *) "$SBX_MOUNT" -o remount,bind,ro "$1" ;; esac
168
+ }
169
+ NR="$SBX_NEWROOT"
170
+ [ -n "$NR" ] && [ -d "$NR" ] || _fail "no new root directory"
171
+ "$SBX_MOUNT" --make-rprivate / || _fail "mount propagation could not be made private"
172
+ "$SBX_MOUNT" -t tmpfs -o mode=0755,size=16m tmpfs "$NR" || _fail "the new root could not be created"
173
+ _tab=$(printf '\\t')
174
+ while IFS="$_tab" read -r _op _mode _p; do
175
+ [ -n "$_op" ] || continue
176
+ case "$_op" in
177
+ B)
178
+ if [ -L "$_p" ]; then
179
+ mkdir -p "$NR\${_p%/*}" || _fail "could not prepare a link"
180
+ ln -s "$(readlink "$_p")" "$NR$_p" || _fail "could not recreate a link"
181
+ elif [ -d "$_p" ]; then
182
+ mkdir -p "$NR$_p" || _fail "could not prepare a mount point"
183
+ "$SBX_MOUNT" --bind "$_p" "$NR$_p" || _fail "a path could not be made visible"
184
+ if [ "$_mode" = ro ]; then
185
+ _ro "$NR$_p" || _fail "a path could not be made read-only"
186
+ fi
187
+ elif [ -e "$_p" ]; then
188
+ mkdir -p "$NR\${_p%/*}" || _fail "could not prepare a mount point"
189
+ [ -e "$NR$_p" ] || : > "$NR$_p" || _fail "could not prepare a file mount point"
190
+ "$SBX_MOUNT" --bind "$_p" "$NR$_p" || _fail "a file could not be made visible"
191
+ if [ "$_mode" = ro ]; then
192
+ _ro "$NR$_p" || _fail "a file could not be made read-only"
193
+ fi
194
+ fi
195
+ ;;
196
+ esac
197
+ done <<SBX_PLAN_EOF
198
+ $SBX_PLAN
199
+ SBX_PLAN_EOF
200
+ mkdir -p "$NR/proc" "$NR/dev" || _fail "could not prepare /proc and /dev"
201
+ "$SBX_MOUNT" -t proc proc "$NR/proc" 2>/dev/null || true
202
+ "$SBX_MOUNT" -t tmpfs -o mode=0755,size=64k tmpfs "$NR/dev" || _fail "could not create /dev"
203
+ for _n in ${DEV_NODES.join(' ')}; do
204
+ [ -e "/dev/$_n" ] || continue
205
+ : > "$NR/dev/$_n" || continue
206
+ "$SBX_MOUNT" --bind "/dev/$_n" "$NR/dev/$_n" 2>/dev/null || { [ "$_n" = null ] && _fail "/dev/null could not be provided"; }
207
+ done
208
+ while IFS="$_tab" read -r _op _mode _p; do
209
+ [ "$_op" = M ] || continue
210
+ if [ -d "$NR$_p" ]; then
211
+ "$SBX_MOUNT" -t tmpfs -o size=4k,mode=000 tmpfs "$NR$_p" || _fail "a protected directory could not be masked"
212
+ elif [ -e "$NR$_p" ]; then
213
+ "$SBX_MOUNT" --bind /dev/null "$NR$_p" || _fail "a protected file could not be masked"
214
+ fi
215
+ done <<SBX_PLAN_EOF
216
+ $SBX_PLAN
217
+ SBX_PLAN_EOF
218
+ mkdir "$NR/.old" || _fail "could not prepare the old-root mount point"
219
+ _ro "$NR" || _fail "the new root could not be made read-only"
220
+ cd "$NR" || _fail "could not enter the new root"
221
+ "$SBX_PIVOT" "$NR" "$NR/.old" || _fail "pivot_root failed"
222
+ cd / || _fail "could not enter the pivoted root"
223
+ "$SBX_UMOUNT" -l /.old || _fail "the old root could not be detached"
224
+ if [ -z "$SBX_PRIVDROP" ] || ! "$SBX_PRIVDROP" --no-new-privs --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c 'exit 0' 2>/dev/null; then
225
+ _fail "privilege drop is unavailable, and capability mode does not run without it"
226
+ fi
227
+ exec "$SBX_PRIVDROP" --no-new-privs --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all /bin/sh -c "$SBX_FINAL" _sbx "$@"
228
+ `;
229
+
230
+ /**
231
+ * Second stage, in the final privilege state. Both directions are checked by
232
+ * execution on every run: the host canary and the filesystem root are refused,
233
+ * the sandbox root is writable. Either check failing means the sandbox is not
234
+ * what it claims, so the command is not run.
235
+ */
236
+ export const FINAL_SCRIPT = `
237
+ _fail() { echo "${MARK_SETUP_FAILED} $1" >&2; exit 91; }
238
+ if ( : > "$SBX_CANARY" ) 2>/dev/null; then
239
+ _fail "an out-of-root write is still possible; refusing to execute"
240
+ fi
241
+ if [ -n "$SBX_STRICT" ]; then
242
+ if ( : > /.agsec-rootcheck ) 2>/dev/null; then
243
+ _fail "the filesystem root is writable; refusing to execute"
244
+ fi
245
+ if [ -e /.old/usr ]; then
246
+ _fail "the old root is still reachable; refusing to execute"
247
+ fi
248
+ fi
249
+ if ! ( : > "$ROOT/.agsec-sbx-wcheck" ) 2>/dev/null; then
250
+ _fail "the sandbox root is not writable; refusing to execute"
251
+ fi
252
+ rm -f "$ROOT/.agsec-sbx-wcheck"
253
+ _cwd="\${SBX_CWD:-$ROOT}"
254
+ unset SBX_PLAN SBX_FINAL SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP SBX_NEWROOT SBX_MASKS SBX_CANARY SBX_STRICT SBX_CWD
255
+ cd "$_cwd" && exec "$@"
256
+ `;
@@ -16,15 +16,17 @@
16
16
  // 4. The same cleanup runs on a normal exit, so a backgrounded grandchild
17
17
  // cannot outlive a run that "succeeded".
18
18
  //
19
- // KNOWN GAP (not hidden): a descendant that double-forks and calls setsid
20
- // between two sweeps, with its parent exiting before the next sweep, is
21
- // reparented away from the tree and is not found. The sweep interval bounds the
22
- // window; it does not close it. Only a PID-namespace or cgroup boundary closes
23
- // it, and neither is available on the macOS backend.
19
+ // KNOWN GAP on the userspace backend (not hidden): a descendant that
20
+ // double-forks and calls setsid between two sweeps, with its parent exiting
21
+ // before the next sweep, is reparented away from the tree and is not found. The
22
+ // sweep interval bounds the window; it does not close it. Only a PID-namespace
23
+ // or cgroup boundary closes it, and macOS has neither. The namespace backend
24
+ // has the boundary (see `runConfinedSupervised`).
24
25
  import { spawn, spawnSync } from 'node:child_process';
25
26
  import { detectBackend } from './capabilities.js';
26
27
  import { runDisabled } from './backend-disabled.js';
27
28
  import { buildUserspaceInvocation } from './backend-userspace.js';
29
+ import { buildNamespaceInvocation } from './backend-namespace.js';
28
30
  import { buildResult, errorResult } from './result.js';
29
31
 
30
32
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -163,31 +165,48 @@ export function superviseSpawn(bin, args, {
163
165
  *
164
166
  * Backend coverage, stated plainly:
165
167
  * userspace supervised (verified by execution on macOS)
166
- * namespace REFUSED: tree termination has not been implemented and executed
167
- * on the kernel-namespace backend, so the run does not happen
168
+ * namespace supervised ONLY where the PID namespace is created with
169
+ * `--kill-child`: killing the unshare process then makes the
170
+ * kernel kill every member of the namespace, however it detached
171
+ * (setsid, double fork). Where the host's unshare lacks the flag
172
+ * the run is refused, since the sweep alone would be the whole
173
+ * guarantee. That it holds on a host is shown by the active probe
174
+ * (`linux-probes.js`), not by this comment.
168
175
  * disabled refused, as always
169
176
  */
170
177
  export async function runConfinedSupervised(argv, opts = {}) {
171
178
  const backend = detectBackend({ force: opts.force });
172
179
  if (backend === 'disabled') return runDisabled(argv, opts);
173
- if (backend !== 'userspace') {
180
+ if (backend !== 'userspace' && backend !== 'namespace') {
174
181
  return errorResult(backend, `process-tree termination is not implemented or verified on the ${backend} backend; refusing to execute`);
175
182
  }
176
- const inv = buildUserspaceInvocation(argv, opts);
183
+ const inv = backend === 'userspace' ? buildUserspaceInvocation(argv, opts) : buildNamespaceInvocation(argv, opts);
177
184
  if (inv.error) return inv.error;
185
+ if (backend === 'namespace' && !inv.treeKill) {
186
+ inv.dispose();
187
+ return errorResult(backend, 'process-tree termination is not implemented or verified on the namespace backend (this host cannot make the kernel kill the namespace with its supervisor); refusing to execute');
188
+ }
178
189
  const r = await superviseSpawn(inv.bin, inv.args, {
179
190
  cwd: inv.cwd, env: inv.env, timeoutMs: opts.timeoutMs ?? 10000, graceMs: opts.graceMs ?? 1000,
180
191
  signal: opts.signal, maxOutputBytes: opts.maxOutputBytes,
181
192
  });
193
+ let rawStderr = r.stderr;
194
+ let unsupported = inv.unsupported;
195
+ if (backend === 'namespace') {
196
+ const fin = inv.finish(r.stderr, { hadError: !!r.spawnError || r.timedOut });
197
+ if (fin.error) return fin.error;
198
+ rawStderr = fin.stderr;
199
+ unsupported = fin.unsupported;
200
+ }
182
201
  const res = buildResult({
183
- backend: 'userspace',
202
+ backend,
184
203
  spawnResult: {
185
204
  status: r.exitCode,
186
205
  stdout: r.stdout,
187
- stderr: r.stderr,
206
+ stderr: rawStderr,
188
207
  error: r.timedOut ? { code: 'ETIMEDOUT' } : r.spawnError ? { code: 'SPAWN', message: r.spawnError } : null,
189
208
  },
190
- unsupported: inv.unsupported,
209
+ unsupported,
191
210
  });
192
211
  let status = res.status;
193
212
  let stderr = res.stderr;