@clear-capabilities/agentic-security-scanner 0.127.0 → 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 (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  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 +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -0,0 +1,147 @@
1
+ // R5 (partial, roadmap) — the project's own test suite as a verification
2
+ // stage for `verifyFix()` (see `fix-verify.js`). Closes the gap where a
3
+ // "verified" fix only proved a finding's stableId stopped firing — a patch
4
+ // that deletes the feature entirely would satisfy that just as well as a
5
+ // real fix. Running the project's own tests is the cheapest available check
6
+ // that the application still works.
7
+ //
8
+ // Execution-safety note: this spawns the TARGET PROJECT's own test command
9
+ // in the target project's own directory. That is deliberately NOT routed
10
+ // through the R1 confinement sandbox (`../sandbox/`). That sandbox exists to
11
+ // contain untrusted proof-of-concept exploit code the scanner itself
12
+ // synthesizes — code nobody has vetted, being run for the first time. A
13
+ // project's pre-existing test suite is the opposite case: it is the
14
+ // project's own trusted source, already sitting on disk, and running it is
15
+ // exactly what a human developer does by hand before trusting a fix.
16
+ // Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
17
+ // syscall/network/filesystem restrictions would break the large majority of
18
+ // real test suites (they bind local ports, spawn child processes, write temp
19
+ // fixtures, etc.) for no corresponding security benefit.
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+
25
+ const DEFAULT_TIMEOUT_MS = 300_000;
26
+ const NPM_PLACEHOLDER = /Error: no test specified/i;
27
+
28
+ function _exists(scanRoot, rel) {
29
+ try { return fs.existsSync(path.join(scanRoot, rel)); } catch { return false; }
30
+ }
31
+
32
+ function _isDir(scanRoot, rel) {
33
+ try { return fs.statSync(path.join(scanRoot, rel)).isDirectory(); } catch { return false; }
34
+ }
35
+
36
+ function _binaryAvailable(cmd) {
37
+ try {
38
+ const r = spawnSync(cmd, ['--version'], { timeout: 5_000, stdio: 'ignore' });
39
+ return !(r.error && r.error.code === 'ENOENT');
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // Detect the project's test command. Read-only — never spawns the actual
46
+ // test run, only (optionally) a cheap `--version` probe to confirm a tool
47
+ // like `pytest` is actually installed before committing to it. Returns
48
+ // `null` when nothing detectable is found — most scanned repos will hit
49
+ // this path, and that must not be treated as a failure by callers.
50
+ export function detectTestCommand(scanRoot) {
51
+ if (!scanRoot) return null;
52
+
53
+ // JS/TS — package.json with a real (non-placeholder) `scripts.test`.
54
+ let pkg = null;
55
+ try { pkg = JSON.parse(fs.readFileSync(path.join(scanRoot, 'package.json'), 'utf8')); } catch { pkg = null; }
56
+ const testScript = pkg && pkg.scripts && pkg.scripts.test;
57
+ if (testScript && !NPM_PLACEHOLDER.test(String(testScript))) {
58
+ if (_exists(scanRoot, 'pnpm-lock.yaml')) return { cmd: 'pnpm', args: ['test'], kind: 'pnpm' };
59
+ if (_exists(scanRoot, 'yarn.lock')) return { cmd: 'yarn', args: ['test'], kind: 'yarn' };
60
+ if (_exists(scanRoot, 'bun.lockb') || _exists(scanRoot, 'bun.lock')) return { cmd: 'bun', args: ['test'], kind: 'bun' };
61
+ return { cmd: 'npm', args: ['test', '--silent'], kind: 'npm' };
62
+ }
63
+
64
+ // Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
65
+ // `pytest` binary actually available. If pytest isn't installed we do NOT
66
+ // report a python test command — falling through lets a later language
67
+ // marker (e.g. go.mod in a polyglot repo) still be detected.
68
+ const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
69
+ _exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
70
+ if (pyMarker && _binaryAvailable('pytest')) {
71
+ return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
72
+ }
73
+
74
+ // Go
75
+ if (_exists(scanRoot, 'go.mod')) {
76
+ return { cmd: 'go', args: ['test', './...'], kind: 'go' };
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ // Run the detected test command with a walltime budget. Always returns a
83
+ // result object — never throws. Distinguishes four outcomes:
84
+ // - no detectable/runnable command -> status: 'skipped' (does NOT fail)
85
+ // - ran and exited 0 -> status: 'passed'
86
+ // - ran and exited non-zero -> status: 'failed'
87
+ // - ran past the timeout budget -> status: 'failed', timedOut: true
88
+ export function runProjectTests(scanRoot, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
89
+ const startedAt = Date.now();
90
+ const command = detectTestCommand(scanRoot);
91
+ if (!command) {
92
+ return {
93
+ status: 'skipped', passed: null, skipped: true,
94
+ reason: 'no-test-command-detected', exitCode: null, timedOut: false,
95
+ durationMs: Date.now() - startedAt,
96
+ };
97
+ }
98
+
99
+ let r;
100
+ try {
101
+ r = spawnSync(command.cmd, command.args, {
102
+ cwd: scanRoot,
103
+ encoding: 'utf8',
104
+ timeout: timeoutMs,
105
+ env: { ...process.env, CI: '1' },
106
+ });
107
+ } catch (e) {
108
+ // The spawn call itself threw (rare — e.g. cwd vanished). Treat as
109
+ // "could not run", not "ran and failed".
110
+ return {
111
+ status: 'skipped', passed: null, skipped: true,
112
+ reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
113
+ durationMs: Date.now() - startedAt,
114
+ };
115
+ }
116
+ const durationMs = Date.now() - startedAt;
117
+
118
+ if (r.error && r.error.code === 'ENOENT') {
119
+ // The detected tool isn't actually installed on this machine. Not a
120
+ // test failure — the suite never ran.
121
+ return {
122
+ status: 'skipped', passed: null, skipped: true,
123
+ reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
124
+ };
125
+ }
126
+
127
+ if (r.status === null) {
128
+ // spawnSync sets status:null both on timeout-kill and on being killed by
129
+ // another signal; either way the run did not complete, which is a
130
+ // verification failure, never a skip — we asked for a result and the
131
+ // process was terminated before producing one.
132
+ return {
133
+ status: 'failed', passed: false, skipped: false,
134
+ reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
135
+ };
136
+ }
137
+
138
+ return {
139
+ status: r.status === 0 ? 'passed' : 'failed',
140
+ passed: r.status === 0,
141
+ skipped: false,
142
+ reason: r.status === 0 ? null : 'test-failures',
143
+ exitCode: r.status,
144
+ timedOut: false,
145
+ durationMs,
146
+ };
147
+ }
@@ -0,0 +1,131 @@
1
+ // R7 — adversarial verification with ENFORCED SEPARATION.
2
+ //
3
+ // The falsification pass (`falsification.js`) already tries to DISPROVE a
4
+ // finding. What it could not previously do is *prove* that whoever checked the
5
+ // finding was not whoever produced it. That guarantee is what this module adds,
6
+ // plus a recorded multi-perspective verdict for contested findings.
7
+ //
8
+ // The property that matters: a verifier is structurally unable to rubber-stamp
9
+ // its own finding.
10
+ // - `recordProducer` stamps who produced the finding, WRITE-ONCE. A later
11
+ // party cannot re-stamp itself as the producer to manufacture separation.
12
+ // - `assertSeparation` refuses when the verifier id equals the recorded
13
+ // producer id, and FAILS CLOSED when no producer was recorded at all
14
+ // (separation that cannot be established is not separation).
15
+ // - `recordVerdict` runs that check itself, so there is no code path that
16
+ // records a verdict without it. One verifier gets one vote per lens: a
17
+ // re-vote replaces the previous one rather than stuffing the ballot.
18
+ //
19
+ // RECALL-PRESERVING, same precedent as `falsification.js` and
20
+ // `dataflow/proof-gate.js`: nothing here ever removes a finding and nothing
21
+ // here ever touches `severity`. A `refuted` consensus is a triage signal, not
22
+ // a deletion — absence of proof is not proof of absence.
23
+ //
24
+ // NO THROWING (posture/CLAUDE.md convention): every entry point returns a
25
+ // refusal object `{ ok:false, refused:true, reason }` instead of throwing, so
26
+ // an annotator can call it inside the engine's pipeline without a guard.
27
+
28
+ export const VERIFICATION_VERDICTS = ['upheld', 'refuted', 'undecided'];
29
+
30
+ // Verifier ids are namespaced so they can never collide with a producer id.
31
+ export const VERIFIER_FALSIFICATION = 'verifier:falsification';
32
+ export const VERIFIER_LLM_REVIEW = 'verifier:llm-review';
33
+
34
+ function _refuse(reason) { return { ok: false, refused: true, reason }; }
35
+
36
+ /** Namespaced id for whoever produced a finding — derived from its detector. */
37
+ export function producerIdOf(finding) {
38
+ const p = finding && finding.parser ? String(finding.parser) : 'unknown';
39
+ return `detector:${p}`;
40
+ }
41
+
42
+ /**
43
+ * Stamp who produced this finding. Write-once: a second call with a different
44
+ * id is REFUSED (this is what stops a verifier reassigning provenance to
45
+ * itself). A repeat call with the same id is a no-op success.
46
+ */
47
+ export function recordProducer(finding, producerId) {
48
+ if (!finding || typeof finding !== 'object') return _refuse('no finding');
49
+ const id = producerId ? String(producerId) : '';
50
+ if (!id) return _refuse('no producer id');
51
+ if (!finding.verification || typeof finding.verification !== 'object') {
52
+ finding.verification = { producer: id, verdicts: [] };
53
+ return { ok: true, producer: id };
54
+ }
55
+ if (!finding.verification.producer) {
56
+ finding.verification.producer = id;
57
+ if (!Array.isArray(finding.verification.verdicts)) finding.verification.verdicts = [];
58
+ return { ok: true, producer: id };
59
+ }
60
+ if (finding.verification.producer === id) return { ok: true, producer: id };
61
+ return _refuse(`producer already recorded as "${finding.verification.producer}" — write-once`);
62
+ }
63
+
64
+ /**
65
+ * The separation check. `{ ok:true, producer }` when the verifier is a party
66
+ * other than the producer; a refusal otherwise. Fails closed when no producer
67
+ * has been recorded.
68
+ */
69
+ export function assertSeparation(finding, verifierId) {
70
+ if (!finding || typeof finding !== 'object') return _refuse('no finding');
71
+ const vid = verifierId ? String(verifierId) : '';
72
+ if (!vid) return _refuse('no verifier id');
73
+ const producer = finding.verification && finding.verification.producer;
74
+ if (!producer) {
75
+ return _refuse('no producer recorded — separation cannot be established');
76
+ }
77
+ if (producer === vid) {
78
+ return _refuse(`verifier "${vid}" is the producer of this finding — separation violated`);
79
+ }
80
+ return { ok: true, producer };
81
+ }
82
+
83
+ /**
84
+ * Record one verifier's verdict from one perspective (`lens`, e.g.
85
+ * 'reachability' | 'control-flow' | 'data-shape'). Refuses — recording
86
+ * nothing — when separation fails or the verdict is not one of
87
+ * VERIFICATION_VERDICTS. Never touches severity, never removes anything.
88
+ */
89
+ export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {}) {
90
+ if (!finding || typeof finding !== 'object') return _refuse('no finding');
91
+ if (!lens) return _refuse('no lens');
92
+ if (!VERIFICATION_VERDICTS.includes(verdict)) {
93
+ return _refuse(`unknown verdict "${verdict}" — expected one of ${VERIFICATION_VERDICTS.join('|')}`);
94
+ }
95
+ const sep = assertSeparation(finding, verifierId);
96
+ if (!sep.ok) return sep;
97
+
98
+ const entry = {
99
+ verifierId: String(verifierId),
100
+ lens: String(lens),
101
+ verdict,
102
+ ...(reason ? { reason: String(reason) } : {}),
103
+ };
104
+ const list = finding.verification.verdicts;
105
+ const i = list.findIndex(v => v.verifierId === entry.verifierId && v.lens === entry.lens);
106
+ if (i >= 0) list[i] = entry; else list.push(entry);
107
+ return { ok: true, recorded: entry };
108
+ }
109
+
110
+ /**
111
+ * Majority across every recorded verdict. Ties — including "no verdicts at
112
+ * all" — are 'undecided'. `lenses` is the sorted set of perspectives that
113
+ * actually voted, so a caller can see whether a verdict is one-eyed or a panel.
114
+ */
115
+ export function consensusOf(finding) {
116
+ const list = (finding && finding.verification && Array.isArray(finding.verification.verdicts))
117
+ ? finding.verification.verdicts : [];
118
+ let upheld = 0, refuted = 0, undecided = 0;
119
+ const lenses = new Set();
120
+ for (const v of list) {
121
+ if (!v) continue;
122
+ if (v.lens) lenses.add(String(v.lens));
123
+ if (v.verdict === 'upheld') upheld++;
124
+ else if (v.verdict === 'refuted') refuted++;
125
+ else if (v.verdict === 'undecided') undecided++;
126
+ }
127
+ let verdict = 'undecided';
128
+ if (upheld > refuted) verdict = 'upheld';
129
+ else if (refuted > upheld) verdict = 'refuted';
130
+ return { verdict, upheld, refuted, undecided, lenses: [...lenses].sort() };
131
+ }
package/src/pr-comment.js CHANGED
@@ -25,6 +25,8 @@
25
25
  // route through an LLM for richer prose when AGENTIC_SECURITY_LLM_ENDPOINT
26
26
  // is configured.
27
27
 
28
+ import { escapeMarkdown } from './util/untrusted.js';
29
+
28
30
  const SEVERITY_GLYPH = {
29
31
  critical: '🟥',
30
32
  high: '🟧',
@@ -136,7 +138,7 @@ export function renderPrComment(delta, { repoName, prNumber, prTitle } = {}) {
136
138
  const sev = SEVERITY_GLYPH[f.severity] || '⬜';
137
139
  const route = _route(f);
138
140
  const where = route ? `\`${route}\` (\`${f.file}:${f.line}\`)` : `\`${f.file}:${f.line}\``;
139
- lines.push(`${sev} **${meta?.name || f.vuln}** — ${where}`);
141
+ lines.push(`${sev} **${meta?.name || escapeMarkdown(f.vuln)}** — ${where}`);
140
142
  if (meta) lines.push(` > ${meta.why}`);
141
143
  if (f.remediation) {
142
144
  const onelineFix = String(f.remediation).split('\n')[0].slice(0, 240);
@@ -180,6 +180,11 @@ export function normalizeFindings(scan){
180
180
  paramKeyConfidence: f.poc.paramKeyConfidence || null,
181
181
  paramKeyInferred: typeof f.poc.paramKeyInferred === 'boolean' ? f.poc.paramKeyInferred : null,
182
182
  } : null,
183
+ // R2: execution-proof tier — copied through only when the annotator
184
+ // (posture/proof-tier.js) actually attached it. Never synthesised here;
185
+ // a finding with no proof backing simply omits these fields.
186
+ ...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
187
+ ...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
183
188
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
184
189
  // 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
185
190
  // for this family; `calibration_reason` explains why.
@@ -401,6 +406,12 @@ export function toJSON(scan, meta={}, opts={}){
401
406
  // threw and were skipped. The findings still ship; downstream consumers
402
407
  // see the gap.
403
408
  annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
409
+ // R4 — run attestation: a stable, order-independent digest over this
410
+ // finding set bound to the engine/ruleset/bundle that produced it.
411
+ // Attached by the CLI (posture/attestation.js); null when not computed.
412
+ // It carries its own `proves` / `doesNotProve` statement — do not quote
413
+ // the digest as cross-machine reproducibility, which it is not.
414
+ attestation: scan.attestation || null,
404
415
  _scanMeta: scan._scanMeta || null,
405
416
  };
406
417
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
package/src/runScan.js CHANGED
@@ -122,7 +122,9 @@ export async function runScan(rootDir, opts = {}) {
122
122
  }
123
123
  }
124
124
 
125
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root }, opts.onProgress || (()=>{}));
125
+ // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
126
+ // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
127
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
126
128
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
127
129
  // notify if the operator pinned a different version than what's installed.
128
130
  try { stampScan(root, scan); } catch {}
@@ -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
+ }