@clear-capabilities/agentic-security-scanner 0.163.1 → 0.165.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.
@@ -53,7 +53,7 @@ function probeWrite() {
53
53
  }
54
54
 
55
55
  function probeReadDenial(backend) {
56
- if (backend !== 'userspace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
56
+ if (backend !== 'userspace' && backend !== 'namespace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
57
57
  const root = mk('agsec-probe-r-'); const secretDir = mk('agsec-probe-rs-');
58
58
  try {
59
59
  const secret = path.join(secretDir, 'sealed.txt');
@@ -103,7 +103,11 @@ async function probeNetwork() {
103
103
  } finally { server.close(); rm(root); }
104
104
  }
105
105
 
106
- async function probeTree() {
106
+ async function probeTree(backend) {
107
+ // Inside a PID namespace the pids a payload records are not host pids, so the
108
+ // pid-based check below would test unrelated host processes. The namespace
109
+ // backend gets a probe that never turns a recorded pid into a signal.
110
+ if (backend === 'namespace') return probeTreeHeartbeat();
107
111
  const root = mk('agsec-probe-t-');
108
112
  try {
109
113
  // Three tree shapes: a plain background child, a grandchild, and one that
@@ -115,7 +119,7 @@ async function probeTree() {
115
119
  'wait',
116
120
  ].join('\n');
117
121
  const r = await runConfinedSupervised(['/bin/sh', '-c', script], { root, timeoutMs: 1500, graceMs: 300 });
118
- if (r.backend !== 'userspace' || !r.supervised) {
122
+ if (!r.supervised) {
119
123
  return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
120
124
  }
121
125
  const pids = ['p1', 'p2', 'p3'].map((f) => { try { return Number(fs.readFileSync(path.join(root, f), 'utf8')); } catch { return NaN; } });
@@ -128,6 +132,52 @@ async function probeTree() {
128
132
  } finally { rm(root); }
129
133
  }
130
134
 
135
+ /**
136
+ * A shell loop that appends a counter line to `$ROOT/hb<n>` every 200 ms and
137
+ * stops by itself after `max` lines, so a probe that finds a survivor cannot
138
+ * leave it running for ever.
139
+ */
140
+ export function heartbeatShell(n, max = 300) {
141
+ return `i=0; while [ $i -lt ${max} ]; do i=$((i+1)); echo $i >> "$ROOT/hb${n}"; sleep 0.2; done`;
142
+ }
143
+
144
+ // Tree termination by heartbeat, for a backend whose payload pids are not host
145
+ // pids. Four tree shapes each append a line to their own file every 200 ms: a
146
+ // plain background loop, a grandchild, one that ignores SIGTERM, and a
147
+ // double-forked member that left the session with setsid. After the supervised
148
+ // run returns, a process that survived keeps appending. The positive control is
149
+ // the same files: each must have advanced during the run, so a heartbeat that
150
+ // could never advance cannot pass for a tree that was terminated.
151
+ export async function probeTreeHeartbeat({ run = runConfinedSupervised, settleMs = 700 } = {}) {
152
+ const root = mk('agsec-probe-th-');
153
+ try {
154
+ const beat = (n) => heartbeatShell(n);
155
+ const script = [
156
+ `( ${beat(1)} ) &`,
157
+ `( ( ${beat(2)} ) & wait ) &`,
158
+ `( trap '' TERM; ${beat(3)} ) &`,
159
+ `( setsid /bin/sh -c '${beat(4)}' >/dev/null 2>&1 & ) &`,
160
+ 'wait',
161
+ ].join('\n');
162
+ const r = await run(['/bin/sh', '-c', script], { root, timeoutMs: 1800, graceMs: 300 });
163
+ if (!r.supervised) {
164
+ return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
165
+ }
166
+ const lines = (n) => { try { return fs.readFileSync(path.join(root, `hb${n}`), 'utf8').split('\n').filter(Boolean).length; } catch { return 0; } };
167
+ const names = [1, 2, 3, 4];
168
+ const atReturn = names.map(lines);
169
+ const stalled = names.filter((n, i) => atReturn[i] < 3);
170
+ if (stalled.length) return notProved(`positive control failed: heartbeat ${stalled.join(',')} did not advance while the run was live`);
171
+ await sleep(settleMs);
172
+ const mid = names.map(lines);
173
+ await sleep(settleMs);
174
+ const late = names.map(lines);
175
+ const alive = names.filter((n, i) => late[i] > mid[i]);
176
+ if (alive.length) return notProved(`members of the tree were still running after the run ended (heartbeat ${alive.join(',')})`);
177
+ return proved('four tree shapes (background, grandchild, SIGTERM-ignoring, setsid double fork) all stopped when the run ended');
178
+ } finally { rm(root); }
179
+ }
180
+
131
181
  function probeFileSize() {
132
182
  const root = mk('agsec-probe-f-');
133
183
  try {
@@ -145,9 +195,41 @@ function probeFileSize() {
145
195
 
146
196
  const UNPROBED_PROCESS_CAP = {
147
197
  state: 'unverified',
148
- reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall) and did not refuse on the hosted Linux runner in the last release; no enforcement is claimed on any backend',
198
+ reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall); no enforcement is claimed on that backend. On the Linux namespace backend the cap is probed (see probeProcessCap)',
149
199
  };
150
200
 
201
+ const PROC_CAP_FORKS = 6;
202
+ /** Forks `PROC_CAP_FORKS` concurrent children; each child announces itself, so a refusal shows as missing announcements. */
203
+ export const PROC_CAP_SCRIPT = `i=0; while [ $i -lt ${PROC_CAP_FORKS} ]; do i=$((i+1)); ( echo CHILD_$i; sleep 1 ) & done; wait; echo DONE`;
204
+
205
+ const countChildren = (r) => (String(r.stdout || '').match(/CHILD_\d/g) || []).length;
206
+
207
+ /**
208
+ * The process-count cap on the namespace backend. A command that forks six
209
+ * concurrent children is run twice through the real backend: under a generous cap
210
+ * every child must start (the positive control: the probe can succeed, and the cap
211
+ * mechanism itself does not break ordinary forking), and under a cap far below
212
+ * what it needs the kernel must refuse some of them. RLIMIT_NPROC is not enforced
213
+ * for a process holding CAP_SYS_RESOURCE in the initial namespace, so whether it
214
+ * binds inside the user namespace is exactly what this measures rather than assumes.
215
+ *
216
+ * `run` is a seam so a test can hand it a runner that ignores the cap (or one that
217
+ * refuses everything) and see the probe say so.
218
+ */
219
+ export function probeProcessCap({ run = runConfined, mode = {} } = {}) {
220
+ const root = mk('agsec-probe-p-');
221
+ try {
222
+ const cmd = ['/bin/sh', '-c', PROC_CAP_SCRIPT];
223
+ const generous = run(cmd, { root, ...mode, timeoutMs: 10000, limits: { maxProcs: 512 } });
224
+ const gOk = countChildren(generous);
225
+ if (gOk !== PROC_CAP_FORKS) return notProved(`positive control failed: only ${gOk} of ${PROC_CAP_FORKS} children started under a generous cap (${generous.status})`);
226
+ const tight = run(cmd, { root, ...mode, timeoutMs: 10000, limits: { maxProcs: 3 } });
227
+ const tOk = countChildren(tight);
228
+ if (tOk >= PROC_CAP_FORKS) return notProved(`the process-count cap did not refuse: all ${tOk} children started under a cap of 3`);
229
+ return proved(`${gOk} of ${PROC_CAP_FORKS} children started under a cap of 512; only ${tOk} under a cap of 3`);
230
+ } finally { rm(root); }
231
+ }
232
+
151
233
  const _cache = new Map();
152
234
  export function resetProbeCache() { _cache.clear(); }
153
235
 
@@ -177,9 +259,11 @@ export async function probeControls({ force, probes = {} } = {}) {
177
259
  await run('read-denial', () => probeReadDenial(backend));
178
260
  await run('env-scrub', probeEnv);
179
261
  await run('network', probeNetwork);
180
- await run('tree-termination', probeTree);
262
+ await run('tree-termination', () => probeTree(backend));
181
263
  await run('file-size-limit', probeFileSize);
182
- controls['process-cap'] = probes['process-cap'] ? await probes['process-cap']() : UNPROBED_PROCESS_CAP;
264
+ if (probes['process-cap']) controls['process-cap'] = await probes['process-cap']();
265
+ else if (backend === 'namespace') await run('process-cap', () => probeProcessCap());
266
+ else controls['process-cap'] = UNPROBED_PROCESS_CAP;
183
267
  }
184
268
  const report = { platform: process.platform, backend, controls };
185
269
  if (!Object.keys(probes).length) _cache.set(backend, report);
@@ -209,7 +293,7 @@ export function capabilityReport(report) {
209
293
  notes: [
210
294
  'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
211
295
  'Linux enforcement cannot be exercised on a macOS host. It is evidenced only where the sandbox-linux CI job ran this same probe set.',
212
- 'Process-count caps are not claimed enforced on any backend.',
296
+ 'A process-count cap is claimed only where its probe reports proved (the Linux namespace backend, on the hosted job); it is never claimed on the macOS backend.',
213
297
  ],
214
298
  };
215
299
  }
@@ -54,6 +54,8 @@ export function buildLimitPrelude({
54
54
  * e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
55
55
  * anything that is not a finite, non-negative number.
56
56
  */
57
+ export function validateMaxProcs(v) { return _num('maxProcs', v); }
58
+
57
59
  function _num(name, v) {
58
60
  const n = Number(v);
59
61
  if (!Number.isFinite(n) || n < 0) {
@@ -0,0 +1,179 @@
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, probeProcessCap } 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
+ await step('process-cap:capability', () => probeProcessCap({ mode: { readRoots: [] } }));
178
+ return out;
179
+ }
@@ -0,0 +1,263 @@
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
+ _nproc="$SBX_NPROC"; _prlimit="$SBX_PRLIMIT"
255
+ unset SBX_PLAN SBX_FINAL SBX_MOUNT SBX_UMOUNT SBX_PIVOT SBX_PRIVDROP SBX_NEWROOT SBX_MASKS SBX_CANARY SBX_STRICT SBX_CWD SBX_NPROC SBX_PRLIMIT
256
+ # The process-count cap is applied last, to the caller's command only: the checks
257
+ # above fork, and so does the setup before them. A prlimit that cannot run stops
258
+ # the command (the exec fails), never lets it run uncapped.
259
+ if [ -n "$_nproc" ]; then
260
+ cd "$_cwd" && exec "$_prlimit" --nproc="$_nproc" -- "$@"
261
+ fi
262
+ cd "$_cwd" && exec "$@"
263
+ `;