@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.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 (79) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +209 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/830.index.js +1 -1
  13. package/dist/agentic-security.mjs +113 -162
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +22 -14
  16. package/src/dataflow/CLAUDE.md +4 -1
  17. package/src/dataflow/async-sequencing.js +8 -3
  18. package/src/dataflow/catalog.js +278 -11
  19. package/src/dataflow/cross-repo.js +1 -1
  20. package/src/dataflow/cross-service-taint.js +1 -1
  21. package/src/dataflow/engine.js +182 -61
  22. package/src/dataflow/ifds.js +10 -5
  23. package/src/dataflow/index.js +15 -3
  24. package/src/dataflow/points-to.js +8 -2
  25. package/src/dataflow/proof-gate.js +7 -0
  26. package/src/dataflow/sanitizer-gate.js +89 -0
  27. package/src/dataflow/tabulation.js +14 -3
  28. package/src/engine.js +154 -7
  29. package/src/integrations/index.js +1 -1
  30. package/src/ir/CLAUDE.md +49 -4
  31. package/src/ir/call-sites.js +66 -0
  32. package/src/ir/callgraph.js +174 -7
  33. package/src/ir/class-hierarchy.js +22 -2
  34. package/src/ir/index.js +138 -51
  35. package/src/ir/ir-stats.js +126 -0
  36. package/src/ir/parser-cpp.js +829 -0
  37. package/src/ir/parser-cs.js +4 -1
  38. package/src/ir/parser-go.js +4 -1
  39. package/src/ir/parser-js.js +5 -1
  40. package/src/ir/parser-kt.js +4 -1
  41. package/src/ir/parser-php.js +10 -3
  42. package/src/ir/parser-py-cst.js +62 -10
  43. package/src/ir/tree-sitter-loader.js +13 -1
  44. package/src/llm-validator/index.js +9 -2
  45. package/src/llm-validator/redact.js +157 -0
  46. package/src/posture/CLAUDE.md +115 -0
  47. package/src/posture/accuracy-scorecard.js +317 -0
  48. package/src/posture/api-contract.js +1 -1
  49. package/src/posture/attestation.js +199 -0
  50. package/src/posture/auditor-walkthrough.js +12 -3
  51. package/src/posture/compliance-policy.js +1 -1
  52. package/src/posture/cross-lang-openapi.js +1 -1
  53. package/src/posture/custom-rules.js +1 -1
  54. package/src/posture/execution-proof.js +52 -0
  55. package/src/posture/exploitability-probability.js +1 -1
  56. package/src/posture/falsification.js +45 -1
  57. package/src/posture/fix-verify.js +55 -2
  58. package/src/posture/license-policy.js +1 -1
  59. package/src/posture/profile.js +1 -1
  60. package/src/posture/proof-tier.js +33 -0
  61. package/src/posture/relevance.js +379 -0
  62. package/src/posture/rule-overrides.js +1 -1
  63. package/src/posture/sca-policy.js +1 -1
  64. package/src/posture/scan-checkpoint.js +277 -0
  65. package/src/posture/suppressions.js +1 -1
  66. package/src/posture/test-runner.js +147 -0
  67. package/src/posture/verification-separation.js +131 -0
  68. package/src/report/index.js +11 -0
  69. package/src/runScan.js +3 -1
  70. package/src/sandbox/CLAUDE.md +218 -0
  71. package/src/sandbox/backend-disabled.js +14 -0
  72. package/src/sandbox/backend-namespace.js +83 -0
  73. package/src/sandbox/backend-userspace.js +100 -0
  74. package/src/sandbox/capabilities.js +53 -0
  75. package/src/sandbox/index.js +30 -0
  76. package/src/sandbox/limits.js +42 -0
  77. package/src/sandbox/result.js +104 -0
  78. package/src/sca/dep-confusion.js +1 -1
  79. package/src/util/yaml.js +24 -0
@@ -0,0 +1,218 @@
1
+ # src/sandbox/
2
+
3
+ Confined execution facility for running untrusted target code and candidate
4
+ exploits (R1 of `docs/ROADMAP.md`). This is a hard prerequisite for anything
5
+ that executes code the scanner did not write — no other module in this
6
+ repository runs target code, confined or otherwise.
7
+
8
+ ## Entry point
9
+
10
+ Everything goes through `index.js`:
11
+
12
+ - `sandboxAvailable() -> boolean` — true iff a real confinement primitive was
13
+ detected on this host.
14
+ - `runConfined(argv, opts) -> { status, denied, stdout, stderr, exitCode, timedOut, backend }`
15
+ — dispatches to whichever backend `detectBackend()` selected. `opts.force`
16
+ overrides detection (used by tests, and by any caller that wants to force
17
+ the disabled path deliberately).
18
+
19
+ `status` is one of `'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' |
20
+ 'error'`. All three backends return the identical shape, so callers never
21
+ branch on which backend ran. **`runConfined` never throws** — a missing
22
+ `root`, an unresolvable root, a missing confinement binary, or an invalid
23
+ resource limit all return `status: 'error'` in the normal shape. (A caller
24
+ that wraps it in `try`/`catch` and "falls back" is a classic route to
25
+ unconfined execution, so there is nothing to catch.)
26
+
27
+ ## `blocked` vs `nonzero` vs `ok` — and the limit of what is observable
28
+
29
+ An earlier version derived `status` purely from the exit code, which conflated
30
+ two unrelated outcomes: a program that ran fine and exited 3 was reported
31
+ `'blocked'`, while a program whose out-of-root write was **denied** but which
32
+ exited 0 was reported `'ok'` — a clean run, as far as the caller could tell.
33
+ Both are now separated:
34
+
35
+ | Field | Meaning |
36
+ |---|---|
37
+ | `denied: true` | A confinement violation was **observed** in the confined process's error output. |
38
+ | `status: 'blocked'` | `denied` was true — something was refused. |
39
+ | `status: 'nonzero'` | The command exited non-zero with **no** denial observed. Ordinary program failure, not a confinement event. |
40
+ | `status: 'ok'` | Exited 0 with no denial observed. |
41
+
42
+ **What `denied: false` does not mean.** The signal is read from the confined
43
+ process's own stderr — these OS primitives give the parent no structured
44
+ violation channel. A program that writes outside the root and swallows its own
45
+ error message produces no signal at all, so `denied: false` means "no denial
46
+ was observed", **not** "no denial occurred". `status: 'ok'` is proof that the
47
+ command exited 0 and said nothing about a refusal; it is **not** proof that
48
+ the sandbox refused nothing. Downstream consumers (e.g. an R2 execution
49
+ verification tier) must not read `'ok'` as "ran unimpeded". The reliable
50
+ negative evidence remains the one the escape tests use: check for the side
51
+ effect (the out-of-root file does not exist), not the status.
52
+
53
+ ## Backend selection (`capabilities.js`)
54
+
55
+ `detectBackend({ force })` probes for one confinement primitive, cached after
56
+ the first call (`resetCapabilityCache()` clears it, used between tests):
57
+
58
+ | Platform | Primitive checked | Backend selected |
59
+ |---|---|---|
60
+ | macOS family | userspace confinement binary present and executable | `'userspace'` |
61
+ | Linux family | kernel-namespace tool present and executable | `'namespace'` |
62
+ | neither found | — | `'disabled'` |
63
+
64
+ Each primitive is probed across a **candidate list** of plausible install
65
+ paths (`CONFINE_BINS_USERSPACE` / `CONFINE_BINS_NAMESPACE`), not a single
66
+ hardcoded path. A miss still fails closed to `'disabled'`, which is safe — but
67
+ a single path would be a false negative on any distribution that installs the
68
+ binary elsewhere, silently costing that host its sandbox. The backends run the
69
+ resolved path, not the canonical one.
70
+
71
+ ## Fail-closed rule
72
+
73
+ If no primitive is found, `detectBackend` returns `'disabled'` and
74
+ `runConfined` dispatches to `backend-disabled.js`, which **refuses to execute
75
+ the command at all** — it returns `status: 'disabled'` without ever spawning
76
+ a process. There is no code path in this module that runs target code
77
+ unconfined. An unavailable sandbox disables the execution feature; it never
78
+ silently degrades to running the command directly. This is proven by an
79
+ executing test (`sandbox.test.js`): the disabled backend is invoked with a
80
+ command that would create a marker file, and the test asserts the file does
81
+ not exist afterward.
82
+
83
+ ## What is verified on which platform
84
+
85
+ This module was developed and its tests run on a macOS host. Guarantees below
86
+ are stated per platform — do not extrapolate one platform's result to the
87
+ other.
88
+
89
+ **Userspace backend (macOS family) — verified by execution on this platform:**
90
+ - A write outside the sandbox root is blocked; the target file is never
91
+ created.
92
+ - Outbound network connections are blocked.
93
+ - A wall-clock overrun stops the **direct child** (`status: 'timeout'`,
94
+ `timedOut: true`) — but see "Timeout does not kill the process tree" below.
95
+ This is not full termination and must not be described as such.
96
+ - Benign in-root work (writes inside the root, ordinary commands) still
97
+ succeeds — the gate holds in both directions, not just the blocking one.
98
+ - Fork-storm containment is **weak, not strong, on this platform**. The
99
+ process-count limit (`ulimit -u` / `RLIMIT_NPROC`) is a per-uid, **system-wide**
100
+ cap here, not a per-process-tree cap — it counts every process the user
101
+ owns on the whole machine, not just the sandboxed subtree. Ambient process
102
+ count for a normal user on this host is on the order of several hundred, so
103
+ any usable cap has to sit at "ambient + margin" or it starves the user's own
104
+ unrelated processes before the sandboxed command even starts. That means a
105
+ fork storm inside the sandbox can still spawn a meaningful number of
106
+ processes — bounded to ambient-plus-margin, not to some small absolute
107
+ number — before the cap bites. Treat this as a soft brake, not a hard wall.
108
+ - Address-space capping (`ulimit -v`) is **not enforceable** on this platform.
109
+ `limits.js` (`buildLimitPrelude`) detects this and reports the limit in its
110
+ `unsupported` array instead of emitting a `ulimit -v` line that would
111
+ silently do nothing — an unenforced limit must never look like an enforced
112
+ one.
113
+
114
+ **Kernel-namespace backend (Linux family) — implemented, NOT verified on this
115
+ platform.** The required namespace tool is absent on the macOS development
116
+ host, so `backend-namespace.js`'s escape tests skip with a recorded reason
117
+ rather than being asserted against. Nothing in this guide should be read as a
118
+ claim that the namespace backend's isolation has been demonstrated by
119
+ execution anywhere. It must be verified on a Linux host — with the same
120
+ both-direction escape-attempt tests used for the userspace backend — before
121
+ anything downstream (e.g. an R2 execution-verification tier) relies on it.
122
+
123
+ **And it confines less than "unverified" suggests. Writes are NOT confined on
124
+ this backend — that is false by inspection, not merely undemonstrated.** The
125
+ backend enters new mount/PID/IPC/UTS namespaces and, by default, an empty
126
+ network namespace. The empty network namespace is the *only* confinement it
127
+ implements: it has no route anywhere, which denies egress. For the
128
+ filesystem there is **no remount, no bind mount and no `pivot_root`** — only a
129
+ `cd` into the sandbox root. `cd` sets the working directory; it does not
130
+ restrict where a process may write. A confined command writing to an absolute
131
+ path outside the root (a home directory, a system config path) will
132
+ **succeed**, subject only to ordinary filesystem permissions. The new mount
133
+ namespace isolates mount-table *changes* made by the confined process; it does
134
+ not make the host filesystem read-only.
135
+
136
+ On a Linux host `detectBackend()` selects this backend automatically, so a
137
+ caller there gets network isolation and resource limits and **no write
138
+ confinement at all**. Do not run anything on that path that must not touch the
139
+ host filesystem. Closing the gap means implementing a read-only remount (or
140
+ equivalent) *and* verifying it by execution on a Linux host with both-direction
141
+ escape tests — the guide must not claim write confinement here before both
142
+ have happened.
143
+
144
+ ## Timeout does not kill the process tree
145
+
146
+ `timeoutMs` is enforced with `spawnSync`'s timeout, which signals **only the
147
+ process this module spawned**. Verified by execution on the macOS family: with
148
+ `timeoutMs: 1200`, a command that backgrounded a 4-second child returned
149
+ `status: 'timeout'` and the grandchild survived, completing its work *after*
150
+ the result was returned. So `'timeout'` means "we stopped waiting and killed
151
+ the process we spawned", not "the process tree was terminated". Survivors stay
152
+ inside the policy profile — their writes and egress remain confined — but they
153
+ are still running and still consuming resources. A caller that needs a hard
154
+ tree kill must implement it.
155
+
156
+ The namespace backend is structurally better here: it runs the confined
157
+ command under `--pid --fork`, so the direct child is pid 1 of a new PID
158
+ namespace and killing it should take the namespace's processes with it. That
159
+ is a reasoned expectation from the flags, **not** an executed result — it
160
+ needs the same Linux-host verification as everything else on that backend.
161
+
162
+ ## Known limitation, deliberately accepted: reads are not confined
163
+
164
+ The userspace policy allows `(allow file-read*)` globally — that backend
165
+ confines **writes**, network egress, and resource use, but **not reads**. (The
166
+ kernel-namespace backend confines *less* than that: per the section above, it
167
+ implements network isolation only and does **not** confine writes at all.)
168
+ A confined command can read any file on the host the OS-level
169
+ permissions allow, including outside the sandbox root. Exfiltration of
170
+ readable host files (writing what was read to network or to a location the
171
+ attacker later reads through some other channel) is **out of scope for this
172
+ module**. This is a deliberate R1 scope cut, not an oversight: tightening
173
+ reads requires a threat model for what a confined process may legitimately
174
+ need to read, which belongs with the execution-verification work that
175
+ consumes this sandbox, not with the sandbox primitive itself.
176
+
177
+ ### The parent environment is NOT one of the things a confined process may read
178
+
179
+ Secrets carried in the parent process's environment (API tokens, cloud keys,
180
+ registry auth) are a *distinct* exposure from unconfined file reads — the
181
+ sandbox would be handing them over rather than merely failing to hide them —
182
+ so they are not covered by the scope cut above. Every real backend therefore
183
+ runs the command with a **minimal constructed environment**
184
+ (`buildConfinedEnv` in `result.js`): `PATH`, `ROOT`, `HOME`, `TMPDIR`, `LANG`,
185
+ with `HOME`/`TMPDIR` pointed at the sandbox root. `process.env` is not
186
+ forwarded. A caller that genuinely needs a variable inside passes it
187
+ explicitly as `opts.env`, which is merged on top of the base — an opt-in, one
188
+ variable at a time, not a blanket export.
189
+
190
+ ## Resource limits (`limits.js`)
191
+
192
+ `buildLimitPrelude({ maxProcs, maxFileSizeKb, maxAddressSpaceKb })` returns
193
+ `{ prelude, unsupported }`. `prelude` is a shell fragment of `ulimit` calls to
194
+ prefix before the confined command; `unsupported` lists any requested limit
195
+ that the current platform cannot enforce, so a caller can log or surface that
196
+ degradation rather than assume the limit applied silently.
197
+
198
+ Limit values are interpolated into a shell fragment, so they are **coerced
199
+ with `Number()` and rejected unless finite and non-negative** (`RangeError`,
200
+ which the backends turn into `status: 'error'`). Before that, a
201
+ config-supplied string such as `'999; echo INJECTED'` was emitted verbatim and
202
+ its payload ran — not an escape (the prelude runs inside the confinement) but
203
+ a way for a config-derived value to silently *disable* the limits it was
204
+ supposed to set.
205
+
206
+ ## Extending this module
207
+
208
+ - Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
209
+ keep returning the exact same result shape as each other and as
210
+ `backend-disabled.js` — callers dispatch on `status`/`backend`, not on
211
+ which module ran.
212
+ - Any new backend must add its own both-direction escape test
213
+ (`sandbox-escape.test.js`) before being wired into `index.js`: a `GOOD` case
214
+ showing legitimate in-root work still succeeds, and one `BAD` case per
215
+ escape vector the backend claims to block.
216
+ - Do not add a code path that runs a command when `detectBackend()` returns
217
+ `'disabled'`. If a future backend needs a new capability check, add it to
218
+ `detectBackend`, not around it.
@@ -0,0 +1,14 @@
1
+ // Fail-closed backend. Selected when no confinement primitive is available.
2
+ // It must NEVER execute the command — an unavailable sandbox disables
3
+ // execution features, it does not bypass them.
4
+ export function runDisabled(_argv, _opts) {
5
+ return {
6
+ status: 'disabled',
7
+ denied: false,
8
+ stdout: '',
9
+ stderr: 'agentic-security: refusing to execute — no confinement primitive available on this host.',
10
+ exitCode: null,
11
+ timedOut: false,
12
+ backend: 'disabled',
13
+ };
14
+ }
@@ -0,0 +1,83 @@
1
+ // Kernel-namespace confinement backend (Linux family).
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.
28
+ import { spawnSync } from 'node:child_process';
29
+ import fs from 'node:fs';
30
+ import { resolveNamespaceBin } from './capabilities.js';
31
+ import { buildLimitPrelude } from './limits.js';
32
+ import { buildResult, errorResult, buildConfinedEnv } from './result.js';
33
+
34
+ export function runNamespace(argv, {
35
+ root,
36
+ timeoutMs = 10000,
37
+ allowNetwork = false,
38
+ limits = {},
39
+ env = {},
40
+ maxBuffer = 8 * 1024 * 1024,
41
+ } = {}) {
42
+ // Documented shape, never a throw — see the same note in backend-userspace.
43
+ if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
44
+
45
+ const bin = resolveNamespaceBin();
46
+ if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
47
+
48
+ let resolvedRoot;
49
+ try {
50
+ // Resolve symlinks so the path the kernel actually sees matches what we
51
+ // hand to the child.
52
+ resolvedRoot = fs.realpathSync(root);
53
+ } catch (e) {
54
+ return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
55
+ }
56
+
57
+ let prelude, unsupported;
58
+ try {
59
+ ({ prelude, unsupported } = buildLimitPrelude(limits));
60
+ } catch (e) {
61
+ return errorResult('namespace', `invalid resource limit: ${e.message}`);
62
+ }
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 });
83
+ }
@@ -0,0 +1,100 @@
1
+ // Userspace confinement backend (macOS family). Applies a deny-by-default
2
+ // policy profile: reads allowed, writes confined to the sandbox root, no
3
+ // network egress unless explicitly opted in.
4
+ //
5
+ // TIMEOUT SCOPE — read before trusting `status:'timeout'`. The wall-clock
6
+ // timeout is `spawnSync`'s, which signals only the DIRECT child. Verified by
7
+ // execution on this platform: with `timeoutMs: 1200`, a command that
8
+ // backgrounded a 4-second child returned `status:'timeout'` while the
9
+ // grandchild survived the timeout and completed its work afterwards. So
10
+ // 'timeout' means "we stopped waiting and killed the process we spawned", NOT
11
+ // "the process tree was terminated". Anything left running is still inside the
12
+ // policy profile (its writes and network stay confined), but it is still
13
+ // running. Callers that need a hard tree kill must supply it themselves.
14
+ import { spawnSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import { resolveUserspaceBin } from './capabilities.js';
17
+ import { buildLimitPrelude } from './limits.js';
18
+ import { buildResult, errorResult, buildConfinedEnv } from './result.js';
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
+ function _profile({ allowNetwork }) {
38
+ return [
39
+ '(version 1)',
40
+ '(deny default)',
41
+ '(allow process-exec process-fork)',
42
+ '(allow sysctl-read)',
43
+ '(allow file-read*)',
44
+ '(allow file-write* (subpath (param "ROOT")))',
45
+ allowNetwork ? '(allow network*)' : '',
46
+ ].filter(Boolean).join('\n');
47
+ }
48
+
49
+ export function runUserspace(argv, {
50
+ root,
51
+ timeoutMs = 10000,
52
+ allowNetwork = false,
53
+ limits = {},
54
+ env = {},
55
+ maxBuffer = 8 * 1024 * 1024,
56
+ } = {}) {
57
+ // Documented shape, never a throw: a caller that wraps this in try/catch and
58
+ // "falls back" is a classic route to unconfined execution.
59
+ if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
60
+
61
+ const bin = resolveUserspaceBin();
62
+ if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
63
+
64
+ let resolvedRoot;
65
+ try {
66
+ // Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
67
+ // subpath param matches the path the kernel actually sees.
68
+ resolvedRoot = fs.realpathSync(root);
69
+ } catch (e) {
70
+ return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
71
+ }
72
+
73
+ const effectiveLimits = {
74
+ ...limits,
75
+ maxProcs: limits.maxProcs ?? (_ambientProcCount() + 64),
76
+ };
77
+
78
+ let prelude, unsupported;
79
+ try {
80
+ ({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
81
+ } catch (e) {
82
+ return errorResult('userspace', `invalid resource limit: ${e.message}`);
83
+ }
84
+ const inner = `${prelude}exec "$@"`;
85
+
86
+ const r = spawnSync(
87
+ bin,
88
+ ['-p', _profile({ allowNetwork }), '-D', `ROOT=${resolvedRoot}`,
89
+ '/bin/sh', '-c', inner, '_sbx', ...argv],
90
+ {
91
+ encoding: 'utf8',
92
+ timeout: timeoutMs,
93
+ maxBuffer,
94
+ cwd: resolvedRoot,
95
+ env: buildConfinedEnv({ root: resolvedRoot, env }),
96
+ },
97
+ );
98
+
99
+ return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
100
+ }
@@ -0,0 +1,53 @@
1
+ // Detects which OS confinement primitive is available. Fail-closed: when none
2
+ // is found we report 'disabled', which REFUSES execution rather than running
3
+ // target code unconfined.
4
+ import fs from 'node:fs';
5
+
6
+ // Referenced by path, never by product name (see Global Constraints).
7
+ //
8
+ // Each family lists every plausible install location, probed in order. A
9
+ // single hardcoded path is safe (a miss fails closed to 'disabled') but it is
10
+ // a FALSE NEGATIVE: a host that does have the primitive somewhere else loses
11
+ // the sandbox silently. Probing the candidate set removes that failure mode.
12
+ export const CONFINE_BINS_USERSPACE = Object.freeze([
13
+ '/usr/bin/sandbox-exec',
14
+ '/usr/local/bin/sandbox-exec',
15
+ ]);
16
+ export const CONFINE_BINS_NAMESPACE = Object.freeze([
17
+ '/usr/bin/unshare',
18
+ '/bin/unshare',
19
+ '/usr/local/bin/unshare',
20
+ '/sbin/unshare',
21
+ '/usr/sbin/unshare',
22
+ ]);
23
+
24
+ // Back-compat single-path exports: the first (canonical) candidate.
25
+ export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
26
+ export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
27
+
28
+ let _cached = null;
29
+
30
+ export function resetCapabilityCache() { _cached = null; }
31
+
32
+ /** First executable candidate, or null when none of them exists. */
33
+ export function resolveConfineBin(candidates) {
34
+ for (const p of candidates) if (_isExecutable(p)) return p;
35
+ return null;
36
+ }
37
+
38
+ export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
39
+ export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
40
+
41
+ export function detectBackend({ force } = {}) {
42
+ if (force) return force;
43
+ if (_cached) return _cached;
44
+ let b = 'disabled';
45
+ if (process.platform === 'darwin' && resolveUserspaceBin()) b = 'userspace';
46
+ else if (process.platform === 'linux' && resolveNamespaceBin()) b = 'namespace';
47
+ _cached = b;
48
+ return b;
49
+ }
50
+
51
+ function _isExecutable(p) {
52
+ try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
53
+ }
@@ -0,0 +1,30 @@
1
+ // Single entry point for confined execution.
2
+ //
3
+ // Fail-closed by construction: when no confinement primitive is available the
4
+ // disabled backend is selected, which REFUSES to execute. There is deliberately
5
+ // no code path that runs target code unconfined.
6
+ //
7
+ // Result shape (identical for every backend):
8
+ // { status, denied, stdout, stderr, exitCode, timedOut, backend }
9
+ // status: 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
10
+ // See result.js for what 'blocked' vs 'nonzero' mean and, importantly, what
11
+ // `denied:false` does NOT prove. runConfined never throws — a bad root or an
12
+ // invalid limit returns status 'error', because a caller that catches and
13
+ // falls back is a route to unconfined execution.
14
+ import { detectBackend } from './capabilities.js';
15
+ import { runDisabled } from './backend-disabled.js';
16
+ import { runUserspace } from './backend-userspace.js';
17
+ import { runNamespace } from './backend-namespace.js';
18
+
19
+ export { detectBackend, resetCapabilityCache } from './capabilities.js';
20
+
21
+ export function sandboxAvailable() {
22
+ return detectBackend() !== 'disabled';
23
+ }
24
+
25
+ export function runConfined(argv, opts = {}) {
26
+ const backend = detectBackend({ force: opts.force });
27
+ if (backend === 'userspace') return runUserspace(argv, opts);
28
+ if (backend === 'namespace') return runNamespace(argv, opts);
29
+ return runDisabled(argv, opts);
30
+ }
@@ -0,0 +1,42 @@
1
+ // Resource caps applied as a shell prelude, shared by every real backend.
2
+ //
3
+ // Address-space capping (`ulimit -v`) is NOT enforced on the macOS family —
4
+ // verified by execution. We therefore DECLARE it unsupported rather than
5
+ // emitting a limit that silently does nothing, which would be a false
6
+ // assurance of containment.
7
+ export function buildLimitPrelude({
8
+ maxProcs = 64,
9
+ maxFileSizeKb = 65536,
10
+ maxAddressSpaceKb = null,
11
+ } = {}) {
12
+ const parts = [];
13
+ const unsupported = [];
14
+
15
+ if (maxProcs != null) parts.push(`ulimit -u ${_num('maxProcs', maxProcs)}`);
16
+ if (maxFileSizeKb != null) parts.push(`ulimit -f ${_num('maxFileSizeKb', maxFileSizeKb)}`);
17
+
18
+ if (maxAddressSpaceKb != null) {
19
+ if (process.platform === 'linux') parts.push(`ulimit -v ${_num('maxAddressSpaceKb', maxAddressSpaceKb)}`);
20
+ else unsupported.push('maxAddressSpaceKb');
21
+ }
22
+
23
+ const prelude = parts.length ? parts.join('; ') + '; ' : '';
24
+ return { prelude, unsupported };
25
+ }
26
+
27
+ /**
28
+ * Limit values are interpolated into a shell fragment, so a non-numeric value
29
+ * is shell text. Verified by execution: `maxProcs: '999; echo INJECTED'`
30
+ * emitted `ulimit -u 999; echo INJECTED` and the payload ran. That is not a
31
+ * sandbox escape (the prelude runs INSIDE the confinement) but it lets a
32
+ * config-derived value silently DISABLE the very limits it was meant to set —
33
+ * e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
34
+ * anything that is not a finite, non-negative number.
35
+ */
36
+ function _num(name, v) {
37
+ const n = Number(v);
38
+ if (!Number.isFinite(n) || n < 0) {
39
+ throw new RangeError(`${name} must be a finite, non-negative number (got ${JSON.stringify(v)})`);
40
+ }
41
+ return Math.floor(n);
42
+ }
@@ -0,0 +1,104 @@
1
+ // Shared result construction for every real backend.
2
+ //
3
+ // WHY THIS EXISTS (the misread it prevents): status used to be derived purely
4
+ // from the exit code — `exitCode !== 0` was reported as `'blocked'`. That
5
+ // conflates two entirely different outcomes:
6
+ //
7
+ // 1. A program that ran fine and chose to exit non-zero (a failing test, a
8
+ // grep with no match) was labelled 'blocked' — a false confinement claim.
9
+ // 2. A program whose out-of-root write was DENIED but which still exited 0
10
+ // was labelled 'ok' — the caller saw a clean run and could not tell that
11
+ // the sandbox had refused something. Verified by execution: the denied
12
+ // write returns exit 0 when the command swallows the failure.
13
+ //
14
+ // So the two signals are now separated:
15
+ //
16
+ // - `denied` — a confinement violation was OBSERVED in the child's error
17
+ // output. Best effort, see the honesty note below.
18
+ // - `status` — 'blocked' when a denial was observed, 'nonzero' when the
19
+ // command merely exited non-zero with no denial signal, 'ok'
20
+ // only when it exited 0 with no denial signal.
21
+ //
22
+ // HONESTY NOTE — what `denied:false` does and does not mean. The denial signal
23
+ // is read from the confined process's own stderr (the OS primitives here do
24
+ // not hand the parent a structured violation channel). A program that writes
25
+ // out of root and swallows its own error message produces NO signal, so
26
+ // `denied:false` means "no denial was observed", NOT "no denial occurred".
27
+ // Never treat `status:'ok'` as proof that nothing was refused. It is proof
28
+ // only that the command exited 0 and said nothing about a refusal.
29
+
30
+ const DENIAL_PATTERNS = [
31
+ /operation not permitted/i,
32
+ /permission denied/i,
33
+ /read-only file system/i,
34
+ /deny file-write/i,
35
+ /deny network/i,
36
+ /network is unreachable/i,
37
+ ];
38
+
39
+ /** True iff the confined process's error output shows an observed denial. */
40
+ export function detectDenial(stderr) {
41
+ const s = String(stderr || '');
42
+ return DENIAL_PATTERNS.some((re) => re.test(s));
43
+ }
44
+
45
+ /**
46
+ * Build the single result shape every backend returns. `status` is one of
47
+ * 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
48
+ */
49
+ export function buildResult({ backend, spawnResult, unsupported = [] }) {
50
+ const r = spawnResult;
51
+ const rawStderr = r.stderr ?? '';
52
+ const timedOut = r.error?.code === 'ETIMEDOUT';
53
+ const denied = detectDenial(rawStderr);
54
+
55
+ let status;
56
+ if (timedOut) status = 'timeout';
57
+ else if (r.error) status = 'error';
58
+ else if (denied) status = 'blocked';
59
+ else if (r.status !== 0) status = 'nonzero';
60
+ else status = 'ok';
61
+
62
+ return {
63
+ status,
64
+ denied,
65
+ stdout: r.stdout ?? '',
66
+ stderr: rawStderr + (unsupported.length ? `\n[sandbox] limits not enforceable here: ${unsupported.join(', ')}` : ''),
67
+ exitCode: r.status ?? null,
68
+ timedOut,
69
+ backend,
70
+ };
71
+ }
72
+
73
+ /** Documented-shape error result: runConfined never throws at its callers. */
74
+ export function errorResult(backend, message) {
75
+ return {
76
+ status: 'error',
77
+ denied: false,
78
+ stdout: '',
79
+ stderr: `agentic-security: ${message}`,
80
+ exitCode: null,
81
+ timedOut: false,
82
+ backend,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Minimal environment handed to untrusted code. The parent environment is NOT
88
+ * forwarded: it routinely carries credentials (tokens, cloud keys, registry
89
+ * auth) and the confined program can read and exfiltrate them. This is a
90
+ * distinct exposure from the accepted "reads are not confined" scope cut —
91
+ * that one is about files on disk, this one is about secrets the parent hands
92
+ * over for free. Callers that need extra variables pass them explicitly via
93
+ * `opts.env`, which is merged on top of this base.
94
+ */
95
+ export function buildConfinedEnv({ root, env = {} } = {}) {
96
+ return {
97
+ PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
98
+ ROOT: root,
99
+ HOME: root,
100
+ TMPDIR: root,
101
+ LANG: 'C',
102
+ ...env,
103
+ };
104
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
- import * as yaml from 'js-yaml';
12
+ import * as yaml from '../util/yaml.js';
13
13
  import { createRequire } from 'node:module';
14
14
 
15
15
  const _require = createRequire(import.meta.url);