@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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.
- package/CHANGELOG.md +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Active probes for every control the boundary relies on (CORE-003.AC02).
|
|
2
|
+
//
|
|
3
|
+
// A control is "proved" only when a probe ATTACKED it through the real
|
|
4
|
+
// backend and the attack observably failed, AND a paired positive control
|
|
5
|
+
// showed the same probe succeeds when the control is off (so a probe that can
|
|
6
|
+
// never succeed cannot pass for a working control). An executable existing, a
|
|
7
|
+
// flag being passed or an earlier run succeeding proves nothing here.
|
|
8
|
+
//
|
|
9
|
+
// States:
|
|
10
|
+
// proved attack failed, positive control succeeded, both observed
|
|
11
|
+
// not-proved the probe ran and the control did not hold, or the probe could
|
|
12
|
+
// not establish its own positive control
|
|
13
|
+
// unsupported this backend does not implement the control at all
|
|
14
|
+
// unavailable no confinement backend works on this host
|
|
15
|
+
// unverified deliberately not probed; the reason says why (never a pass)
|
|
16
|
+
//
|
|
17
|
+
// The probes are generic, so on a Linux host they run against the namespace
|
|
18
|
+
// backend for real. Nothing in this file asserts a Linux outcome in advance.
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { detectBackend } from './capabilities.js';
|
|
25
|
+
import { runConfined } from './index.js';
|
|
26
|
+
import { runConfinedSupervised } from './supervise.js';
|
|
27
|
+
|
|
28
|
+
export const CONTROLS = Object.freeze([
|
|
29
|
+
'write-confinement', 'read-denial', 'env-scrub', 'network',
|
|
30
|
+
'tree-termination', 'file-size-limit', 'process-cap',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
35
|
+
const NODE = process.execPath;
|
|
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 proved = (evidence) => ({ state: 'proved', evidence });
|
|
40
|
+
const notProved = (reason) => ({ state: 'not-proved', reason });
|
|
41
|
+
|
|
42
|
+
function probeWrite() {
|
|
43
|
+
const root = mk('agsec-probe-w-'); const outside = mk('agsec-probe-wo-');
|
|
44
|
+
try {
|
|
45
|
+
const good = runConfined(['/bin/sh', '-c', 'echo x > "$ROOT/in.txt"'], { root });
|
|
46
|
+
const inOk = fs.existsSync(path.join(root, 'in.txt'));
|
|
47
|
+
runConfined(['/bin/sh', '-c', `echo x > '${outside}/escape.txt'; true`], { root });
|
|
48
|
+
const escaped = fs.existsSync(path.join(outside, 'escape.txt'));
|
|
49
|
+
if (!inOk) return notProved(`positive control failed: in-root write did not happen (${good.status})`);
|
|
50
|
+
if (escaped) return notProved('an out-of-root write succeeded');
|
|
51
|
+
return proved('in-root write succeeded; out-of-root write created no file');
|
|
52
|
+
} finally { rm(root); rm(outside); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function probeReadDenial(backend) {
|
|
56
|
+
if (backend !== 'userspace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
|
|
57
|
+
const root = mk('agsec-probe-r-'); const secretDir = mk('agsec-probe-rs-');
|
|
58
|
+
try {
|
|
59
|
+
const secret = path.join(secretDir, 'sealed.txt');
|
|
60
|
+
const token = `SEALED-${crypto.randomBytes(8).toString('hex')}`;
|
|
61
|
+
fs.writeFileSync(secret, token);
|
|
62
|
+
const cmd = ['/bin/sh', '-c', `cat '${secret}' 2>&1; true`];
|
|
63
|
+
const open = runConfined(cmd, { root });
|
|
64
|
+
if (!open.stdout.includes(token)) return notProved('positive control failed: the probe could not read the canary even without denial');
|
|
65
|
+
const closed = runConfined(cmd, { root, denyReadPaths: [secretDir] });
|
|
66
|
+
if (closed.stdout.includes(token)) return notProved('a denied path was still readable');
|
|
67
|
+
return proved('canary readable without denial, unreadable with denyReadPaths');
|
|
68
|
+
} finally { rm(root); rm(secretDir); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function probeEnv() {
|
|
72
|
+
const root = mk('agsec-probe-e-');
|
|
73
|
+
const name = 'AGSEC_PROBE_PARENT_CANARY';
|
|
74
|
+
const prev = process.env[name];
|
|
75
|
+
process.env[name] = 'parent-env-canary-value';
|
|
76
|
+
try {
|
|
77
|
+
const r = runConfined(['/usr/bin/env'], { root, env: { AGSEC_PROBE_EXPLICIT: 'explicit-ok' } });
|
|
78
|
+
if (!r.stdout.includes('AGSEC_PROBE_EXPLICIT=explicit-ok')) return notProved(`positive control failed: explicit env not visible (${r.status})`);
|
|
79
|
+
if (r.stdout.includes(name) || r.stdout.includes('parent-env-canary-value')) return notProved('the parent environment leaked into the target');
|
|
80
|
+
return proved('explicit variable visible, parent canary variable absent');
|
|
81
|
+
} finally {
|
|
82
|
+
if (prev === undefined) delete process.env[name]; else process.env[name] = prev;
|
|
83
|
+
rm(root);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function probeNetwork() {
|
|
88
|
+
const root = mk('agsec-probe-n-');
|
|
89
|
+
let hits = 0;
|
|
90
|
+
const server = net.createServer((s) => { hits++; s.destroy(); });
|
|
91
|
+
try {
|
|
92
|
+
await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', res); });
|
|
93
|
+
const port = server.address().port;
|
|
94
|
+
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)`;
|
|
95
|
+
runConfined([NODE, '-e', script], { root, timeoutMs: 6000, allowNetwork: true });
|
|
96
|
+
await sleep(200);
|
|
97
|
+
const open = hits;
|
|
98
|
+
if (open < 1) return notProved('positive control failed: loopback listener saw no connection with network allowed');
|
|
99
|
+
runConfined([NODE, '-e', script], { root, timeoutMs: 6000, allowNetwork: false });
|
|
100
|
+
await sleep(200);
|
|
101
|
+
if (hits > open) return notProved('a connection reached a loopback listener with network denied');
|
|
102
|
+
return proved('connection seen with network allowed, none with network denied');
|
|
103
|
+
} finally { server.close(); rm(root); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function probeTree() {
|
|
107
|
+
const root = mk('agsec-probe-t-');
|
|
108
|
+
try {
|
|
109
|
+
// Three tree shapes: a plain background child, a grandchild, and one that
|
|
110
|
+
// ignores SIGTERM and so needs the SIGKILL escalation.
|
|
111
|
+
const script = [
|
|
112
|
+
'sleep 300 & echo $! > "$ROOT/p1"',
|
|
113
|
+
'(sleep 300 & echo $! > "$ROOT/p2"; wait) &',
|
|
114
|
+
`(trap '' TERM; sleep 300 & echo $! > "$ROOT/p3"; while :; do sleep 1; done) &`,
|
|
115
|
+
'wait',
|
|
116
|
+
].join('\n');
|
|
117
|
+
const r = await runConfinedSupervised(['/bin/sh', '-c', script], { root, timeoutMs: 1500, graceMs: 300 });
|
|
118
|
+
if (r.backend !== 'userspace' || !r.supervised) {
|
|
119
|
+
return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
|
|
120
|
+
}
|
|
121
|
+
const pids = ['p1', 'p2', 'p3'].map((f) => { try { return Number(fs.readFileSync(path.join(root, f), 'utf8')); } catch { return NaN; } });
|
|
122
|
+
if (pids.some((p) => !Number.isFinite(p))) return notProved('positive control failed: the probe tree did not start');
|
|
123
|
+
await sleep(100);
|
|
124
|
+
const live = pids.filter(alive);
|
|
125
|
+
for (const p of live) { try { process.kill(p, 'SIGKILL'); } catch { /* probe cleanup */ } }
|
|
126
|
+
if (live.length) return notProved(`processes survived the timeout: ${live.join(',')}`);
|
|
127
|
+
return proved('timeout ended a three-process tree including a SIGTERM-ignoring member');
|
|
128
|
+
} finally { rm(root); }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function probeFileSize() {
|
|
132
|
+
const root = mk('agsec-probe-f-');
|
|
133
|
+
try {
|
|
134
|
+
const cmd = ['/bin/sh', '-c', 'dd if=/dev/zero of="$ROOT/big" bs=1024 count=400 2>&1; true'];
|
|
135
|
+
runConfined(cmd, { root, limits: { maxFileSizeKb: 65536 } });
|
|
136
|
+
const full = fs.existsSync(path.join(root, 'big')) ? fs.statSync(path.join(root, 'big')).size : 0;
|
|
137
|
+
if (full < 400 * 1024) return notProved('positive control failed: the unrestricted write did not complete');
|
|
138
|
+
fs.rmSync(path.join(root, 'big'));
|
|
139
|
+
runConfined(cmd, { root, limits: { maxFileSizeKb: 16 } });
|
|
140
|
+
const capped = fs.existsSync(path.join(root, 'big')) ? fs.statSync(path.join(root, 'big')).size : 0;
|
|
141
|
+
if (capped >= 400 * 1024) return notProved('the file size limit did not stop a large write');
|
|
142
|
+
return proved(`400 KiB write completed unrestricted and stopped at ${capped} bytes under a 16 KiB limit`);
|
|
143
|
+
} finally { rm(root); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const UNPROBED_PROCESS_CAP = {
|
|
147
|
+
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',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const _cache = new Map();
|
|
152
|
+
export function resetProbeCache() { _cache.clear(); }
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Probe every control on the active backend.
|
|
156
|
+
* @param {object} [o]
|
|
157
|
+
* @param {string} [o.force] backend override (same meaning as runConfined's)
|
|
158
|
+
* @param {object} [o.probes] per-control probe overrides (test seam). A probe
|
|
159
|
+
* may only make the report MORE restrictive in
|
|
160
|
+
* practice, since the boundary consults the report
|
|
161
|
+
* before any run; it cannot make a run unconfined.
|
|
162
|
+
*/
|
|
163
|
+
export async function probeControls({ force, probes = {} } = {}) {
|
|
164
|
+
const backend = detectBackend({ force });
|
|
165
|
+
const key = `${backend}:${Object.keys(probes).sort().join(',')}`;
|
|
166
|
+
if (!Object.keys(probes).length && _cache.has(backend)) return _cache.get(backend);
|
|
167
|
+
|
|
168
|
+
const controls = {};
|
|
169
|
+
if (backend === 'disabled') {
|
|
170
|
+
for (const c of CONTROLS) controls[c] = { state: 'unavailable', reason: 'no confinement backend works on this host' };
|
|
171
|
+
} else {
|
|
172
|
+
const run = async (name, fn) => {
|
|
173
|
+
if (probes[name]) { controls[name] = await probes[name](); return; }
|
|
174
|
+
try { controls[name] = await fn(); } catch (e) { controls[name] = notProved(`probe threw: ${e.message}`); }
|
|
175
|
+
};
|
|
176
|
+
await run('write-confinement', probeWrite);
|
|
177
|
+
await run('read-denial', () => probeReadDenial(backend));
|
|
178
|
+
await run('env-scrub', probeEnv);
|
|
179
|
+
await run('network', probeNetwork);
|
|
180
|
+
await run('tree-termination', probeTree);
|
|
181
|
+
await run('file-size-limit', probeFileSize);
|
|
182
|
+
controls['process-cap'] = probes['process-cap'] ? await probes['process-cap']() : UNPROBED_PROCESS_CAP;
|
|
183
|
+
}
|
|
184
|
+
const report = { platform: process.platform, backend, controls };
|
|
185
|
+
if (!Object.keys(probes).length) _cache.set(backend, report);
|
|
186
|
+
void key;
|
|
187
|
+
return report;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Controls from `required` that are not proved, with the reason for each. */
|
|
191
|
+
export function unmetControls(report, required) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const c of required) {
|
|
194
|
+
const v = report?.controls?.[c];
|
|
195
|
+
if (!v || v.state !== 'proved') out.push({ control: c, state: v?.state ?? 'not-probed', reason: v?.reason ?? 'no probe result' });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Human-readable capability matrix. States what each control is on this host
|
|
202
|
+
* and carries the standing disclosure that Linux enforcement is only evidenced
|
|
203
|
+
* where the hosted `sandbox-linux` job actually ran.
|
|
204
|
+
*/
|
|
205
|
+
export function capabilityReport(report) {
|
|
206
|
+
return {
|
|
207
|
+
...report,
|
|
208
|
+
advertisedIsolationBackends: ['userspace (macOS family)', 'namespace (Linux family)'],
|
|
209
|
+
notes: [
|
|
210
|
+
'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
|
|
211
|
+
'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.',
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Supervised execution: every process the target starts, and all of its
|
|
2
|
+
// descendants, end when the run ends (CORE-003.AC02).
|
|
3
|
+
//
|
|
4
|
+
// `spawnSync`'s timeout signals only the direct child (see CLAUDE.md, "Timeout
|
|
5
|
+
// does not kill the process tree"). This module is the repair for the backends
|
|
6
|
+
// where it can be proved:
|
|
7
|
+
//
|
|
8
|
+
// 1. The child starts in its own process group and session (`detached`), so
|
|
9
|
+
// one signal to the negative pid reaches every member that did not move.
|
|
10
|
+
// 2. While it runs, the process table is swept on a short interval and every
|
|
11
|
+
// descendant seen is remembered, so a member that left the group (for
|
|
12
|
+
// example via setsid) while its parent was still alive is still killed.
|
|
13
|
+
// 3. Termination is SIGTERM to the group and the tracked set, a grace period,
|
|
14
|
+
// then SIGKILL, then a poll that CONFIRMS nothing is left. Survivors are
|
|
15
|
+
// reported, never assumed away.
|
|
16
|
+
// 4. The same cleanup runs on a normal exit, so a backgrounded grandchild
|
|
17
|
+
// cannot outlive a run that "succeeded".
|
|
18
|
+
//
|
|
19
|
+
// KNOWN GAP (not hidden): a descendant that double-forks and calls setsid
|
|
20
|
+
// between two sweeps, with its parent exiting before the next sweep, is
|
|
21
|
+
// reparented away from the tree and is not found. The sweep interval bounds the
|
|
22
|
+
// window; it does not close it. Only a PID-namespace or cgroup boundary closes
|
|
23
|
+
// it, and neither is available on the macOS backend.
|
|
24
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
25
|
+
import { detectBackend } from './capabilities.js';
|
|
26
|
+
import { runDisabled } from './backend-disabled.js';
|
|
27
|
+
import { buildUserspaceInvocation } from './backend-userspace.js';
|
|
28
|
+
import { buildResult, errorResult } from './result.js';
|
|
29
|
+
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
function alive(pid) {
|
|
33
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
34
|
+
}
|
|
35
|
+
function groupAlive(pgid) {
|
|
36
|
+
try { process.kill(-pgid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
37
|
+
}
|
|
38
|
+
function signalPid(pid, sig) { try { process.kill(pid, sig); } catch { /* already gone */ } }
|
|
39
|
+
function signalGroup(pgid, sig) { try { process.kill(-pgid, sig); } catch { /* already gone */ } }
|
|
40
|
+
|
|
41
|
+
/** Descendants of `rootPid` plus members of its group, from one `ps` snapshot. */
|
|
42
|
+
function snapshotTree(rootPid) {
|
|
43
|
+
const r = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], { encoding: 'utf8', timeout: 2000 });
|
|
44
|
+
if (r.error || r.status !== 0) return [];
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (const line of String(r.stdout).split('\n')) {
|
|
47
|
+
const m = line.trim().split(/\s+/).map(Number);
|
|
48
|
+
if (m.length === 3 && m.every(Number.isFinite)) rows.push({ pid: m[0], ppid: m[1], pgid: m[2] });
|
|
49
|
+
}
|
|
50
|
+
const kids = new Map();
|
|
51
|
+
for (const x of rows) { if (!kids.has(x.ppid)) kids.set(x.ppid, []); kids.get(x.ppid).push(x.pid); }
|
|
52
|
+
const found = new Set();
|
|
53
|
+
const queue = [rootPid];
|
|
54
|
+
while (queue.length) {
|
|
55
|
+
const p = queue.pop();
|
|
56
|
+
for (const c of kids.get(p) || []) if (!found.has(c)) { found.add(c); queue.push(c); }
|
|
57
|
+
}
|
|
58
|
+
for (const x of rows) if (x.pgid === rootPid && x.pid !== process.pid) found.add(x.pid);
|
|
59
|
+
return [...found];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _emptyTermination() { return { signalled: null, killedPids: [], survivors: [] }; }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Spawn `bin args` as a supervised process tree.
|
|
66
|
+
* @returns {Promise<object>} never rejects.
|
|
67
|
+
*/
|
|
68
|
+
export function superviseSpawn(bin, args, {
|
|
69
|
+
cwd, env, timeoutMs = 10000, graceMs = 1000, signal, maxOutputBytes = 8 * 1024 * 1024, sweepMs = 100,
|
|
70
|
+
} = {}) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
let child;
|
|
73
|
+
try {
|
|
74
|
+
child = spawn(bin, args, { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
resolve({ spawnError: e.message, exitCode: null, stdout: '', stderr: '', timedOut: false, cancelled: false, outputCapped: false, termination: _emptyTermination() });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const pgid = child.pid;
|
|
80
|
+
const tracked = new Set();
|
|
81
|
+
let stdout = '', stderr = '', outBytes = 0;
|
|
82
|
+
let timedOut = false, cancelled = false, outputCapped = false, spawnError = null;
|
|
83
|
+
let exitCode = null, exitSignal = null;
|
|
84
|
+
let finishing = null;
|
|
85
|
+
let timer = null;
|
|
86
|
+
|
|
87
|
+
const sweep = () => { if (pgid) for (const p of snapshotTree(pgid)) tracked.add(p); };
|
|
88
|
+
const sweeper = setInterval(sweep, sweepMs);
|
|
89
|
+
|
|
90
|
+
const survivors = () => {
|
|
91
|
+
sweep();
|
|
92
|
+
return { live: [...tracked].filter(alive), group: groupAlive(pgid) };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async function terminate() {
|
|
96
|
+
const t = _emptyTermination();
|
|
97
|
+
const before = survivors();
|
|
98
|
+
if (!before.group && before.live.length === 0) return t;
|
|
99
|
+
t.signalled = 'SIGTERM';
|
|
100
|
+
signalGroup(pgid, 'SIGTERM');
|
|
101
|
+
for (const p of tracked) signalPid(p, 'SIGTERM');
|
|
102
|
+
const deadline = Date.now() + graceMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const s = survivors();
|
|
105
|
+
if (!s.group && s.live.length === 0) return t;
|
|
106
|
+
await sleep(25);
|
|
107
|
+
}
|
|
108
|
+
// Grace expired: SIGKILL cannot be ignored. Repeat until nothing is left,
|
|
109
|
+
// because a dying process can fork once more before it is reaped.
|
|
110
|
+
t.signalled = 'SIGKILL';
|
|
111
|
+
const hard = Date.now() + 3000;
|
|
112
|
+
while (Date.now() < hard) {
|
|
113
|
+
const s = survivors();
|
|
114
|
+
if (!s.group && s.live.length === 0) break;
|
|
115
|
+
signalGroup(pgid, 'SIGKILL');
|
|
116
|
+
for (const p of s.live) { signalPid(p, 'SIGKILL'); if (!t.killedPids.includes(p)) t.killedPids.push(p); }
|
|
117
|
+
await sleep(25);
|
|
118
|
+
}
|
|
119
|
+
const s = survivors();
|
|
120
|
+
t.survivors = s.live.length ? s.live : (s.group ? [-pgid] : []);
|
|
121
|
+
return t;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function finish() {
|
|
125
|
+
if (finishing) return finishing;
|
|
126
|
+
finishing = (async () => {
|
|
127
|
+
clearInterval(sweeper); clearTimeout(timer);
|
|
128
|
+
const termination = await terminate();
|
|
129
|
+
try { child.stdout?.destroy(); child.stderr?.destroy(); } catch { /* ignore */ }
|
|
130
|
+
resolve({ spawnError, exitCode, exitSignal, stdout, stderr, timedOut, cancelled, outputCapped, termination });
|
|
131
|
+
})();
|
|
132
|
+
return finishing;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const collect = (which) => (d) => {
|
|
136
|
+
outBytes += d.length;
|
|
137
|
+
if (outBytes > maxOutputBytes) {
|
|
138
|
+
if (!outputCapped) { outputCapped = true; finish(); }
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (which === 'out') stdout += d.toString('utf8'); else stderr += d.toString('utf8');
|
|
142
|
+
};
|
|
143
|
+
child.stdout.on('data', collect('out'));
|
|
144
|
+
child.stderr.on('data', collect('err'));
|
|
145
|
+
child.on('error', (e) => { spawnError = e.message; finish(); });
|
|
146
|
+
child.on('exit', (code, sig) => {
|
|
147
|
+
exitCode = code; exitSignal = sig;
|
|
148
|
+
// Let buffered output drain, then clean up any survivors.
|
|
149
|
+
setTimeout(finish, 50);
|
|
150
|
+
});
|
|
151
|
+
timer = setTimeout(() => { timedOut = true; finish(); }, timeoutMs);
|
|
152
|
+
if (signal) {
|
|
153
|
+
if (signal.aborted) { cancelled = true; finish(); }
|
|
154
|
+
else signal.addEventListener('abort', () => { cancelled = true; finish(); }, { once: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `runConfined`, plus a guarantee about the process tree. Same result shape as
|
|
161
|
+
* every backend, with extra `cancelled`, `outputCapped`, `termination` and
|
|
162
|
+
* `supervised: true`. Never throws.
|
|
163
|
+
*
|
|
164
|
+
* Backend coverage, stated plainly:
|
|
165
|
+
* userspace supervised (verified by execution on macOS)
|
|
166
|
+
* namespace REFUSED: tree termination has not been implemented and executed
|
|
167
|
+
* on the kernel-namespace backend, so the run does not happen
|
|
168
|
+
* disabled refused, as always
|
|
169
|
+
*/
|
|
170
|
+
export async function runConfinedSupervised(argv, opts = {}) {
|
|
171
|
+
const backend = detectBackend({ force: opts.force });
|
|
172
|
+
if (backend === 'disabled') return runDisabled(argv, opts);
|
|
173
|
+
if (backend !== 'userspace') {
|
|
174
|
+
return errorResult(backend, `process-tree termination is not implemented or verified on the ${backend} backend; refusing to execute`);
|
|
175
|
+
}
|
|
176
|
+
const inv = buildUserspaceInvocation(argv, opts);
|
|
177
|
+
if (inv.error) return inv.error;
|
|
178
|
+
const r = await superviseSpawn(inv.bin, inv.args, {
|
|
179
|
+
cwd: inv.cwd, env: inv.env, timeoutMs: opts.timeoutMs ?? 10000, graceMs: opts.graceMs ?? 1000,
|
|
180
|
+
signal: opts.signal, maxOutputBytes: opts.maxOutputBytes,
|
|
181
|
+
});
|
|
182
|
+
const res = buildResult({
|
|
183
|
+
backend: 'userspace',
|
|
184
|
+
spawnResult: {
|
|
185
|
+
status: r.exitCode,
|
|
186
|
+
stdout: r.stdout,
|
|
187
|
+
stderr: r.stderr,
|
|
188
|
+
error: r.timedOut ? { code: 'ETIMEDOUT' } : r.spawnError ? { code: 'SPAWN', message: r.spawnError } : null,
|
|
189
|
+
},
|
|
190
|
+
unsupported: inv.unsupported,
|
|
191
|
+
});
|
|
192
|
+
let status = res.status;
|
|
193
|
+
let stderr = res.stderr;
|
|
194
|
+
if (r.cancelled) status = 'cancelled';
|
|
195
|
+
if (r.outputCapped) { status = 'error'; stderr += '\n[sandbox] output cap exceeded; process tree terminated'; }
|
|
196
|
+
if (r.termination.survivors.length) { status = 'error'; stderr += '\n[sandbox] processes survived termination'; }
|
|
197
|
+
return {
|
|
198
|
+
...res, status, stderr, cancelled: r.cancelled, outputCapped: r.outputCapped,
|
|
199
|
+
supervised: true, termination: r.termination,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The execution boundary between target/worker code and the verifier
|
|
2
|
+
// (CORE-003). Every verifier-side execution of untrusted code goes through
|
|
3
|
+
// `runInBoundary`; it is where the trust domains are ENFORCED rather than
|
|
4
|
+
// described.
|
|
5
|
+
//
|
|
6
|
+
// Order of operations, and why each step exists:
|
|
7
|
+
// 1. Refuse secret material in the environment bound for the target.
|
|
8
|
+
// 2. Refuse a workspace that overlaps a protected path (a root that CONTAINS
|
|
9
|
+
// the key directory would hand the target the keys by construction).
|
|
10
|
+
// 3. Probe the backend's controls actively (cached per process). Any required
|
|
11
|
+
// control that is not `proved` makes the result `blocked` and NOTHING runs.
|
|
12
|
+
// Insufficient isolation is never a pass and never a warning.
|
|
13
|
+
// 4. Run supervised, with protected paths read-denied, a scrubbed environment
|
|
14
|
+
// and a process-tree deadline.
|
|
15
|
+
// 5. Report. The status is formed from what the boundary observed, never from
|
|
16
|
+
// what the target printed; target output is returned labelled untrusted.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { detectBackend } from './capabilities.js';
|
|
20
|
+
import { runConfinedSupervised } from './supervise.js';
|
|
21
|
+
import { probeControls, unmetControls } from './control-probes.js';
|
|
22
|
+
import {
|
|
23
|
+
protectedReadPaths, assertNoSecretMaterial, classifyRun, countStatusLikeLines,
|
|
24
|
+
} from './trust-domains.js';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_REQUIRED_CONTROLS = Object.freeze([
|
|
27
|
+
'write-confinement', 'read-denial', 'env-scrub', 'tree-termination', 'file-size-limit',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function _real(p) { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }
|
|
31
|
+
function _overlaps(a, b) {
|
|
32
|
+
const x = _real(a), y = _real(b);
|
|
33
|
+
return x === y || x.startsWith(y + path.sep) || y.startsWith(x + path.sep);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _blocked(reasons, extra = {}) {
|
|
37
|
+
return {
|
|
38
|
+
status: 'blocked', blocked: true, executed: false, denied: false, reasons,
|
|
39
|
+
verificationStatus: 'not-run', targetOutput: null, ...extra,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string[]} argv
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {string} o.root the target's only writable directory
|
|
47
|
+
* @param {string[]} [o.labelDirs] sealed-label directories (read-denied)
|
|
48
|
+
* @param {string[]} [o.evidenceDirs] authoritative-evidence directories (read-denied, and write-denied by confinement)
|
|
49
|
+
* @param {string[]} [o.denyReadPaths] further host paths to read-deny
|
|
50
|
+
* @param {object} [o.env] explicit variables for the target; secret-looking ones are refused
|
|
51
|
+
* @param {string[]} [o.require] controls that must be `proved`
|
|
52
|
+
* @param {string} [o.home] home directory whose credential dirs are denied
|
|
53
|
+
* @param {boolean} [o.allowNetwork]
|
|
54
|
+
* @param {AbortSignal} [o.signal] cancel
|
|
55
|
+
* @param {object} [o.controlProbes] probe overrides (test seam)
|
|
56
|
+
*/
|
|
57
|
+
export async function runInBoundary(argv, o = {}) {
|
|
58
|
+
const {
|
|
59
|
+
root, labelDirs = [], evidenceDirs = [], denyReadPaths = [], env = {},
|
|
60
|
+
allowNetwork = false, timeoutMs = 10000, graceMs = 1000, signal, force, limits,
|
|
61
|
+
maxOutputBytes, controlProbes,
|
|
62
|
+
} = o;
|
|
63
|
+
const required = [...(o.require || DEFAULT_REQUIRED_CONTROLS)];
|
|
64
|
+
if (!allowNetwork && !required.includes('network')) required.push('network');
|
|
65
|
+
|
|
66
|
+
if (!root || typeof root !== 'string') return _blocked(['no workspace root']);
|
|
67
|
+
const backend = detectBackend({ force });
|
|
68
|
+
if (backend === 'disabled') {
|
|
69
|
+
return _blocked(['no confinement backend works on this host; the target was not executed'], { backend });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const secrets = assertNoSecretMaterial(env);
|
|
73
|
+
if (!secrets.ok) {
|
|
74
|
+
return _blocked([`secret-looking variables refused for the target: ${secrets.offenders.join(', ')}`], { backend });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const protectedPaths = protectedReadPaths({
|
|
78
|
+
...(o.home ? { home: o.home } : {}), labelDirs, evidenceDirs, extra: denyReadPaths,
|
|
79
|
+
});
|
|
80
|
+
const clash = protectedPaths.filter((p) => _overlaps(root, p));
|
|
81
|
+
if (clash.length) {
|
|
82
|
+
return _blocked([`the workspace overlaps protected path(s): ${clash.join(', ')}`], { backend });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const report = await probeControls({ force, probes: controlProbes || {} });
|
|
86
|
+
const unmet = unmetControls(report, required);
|
|
87
|
+
if (unmet.length) {
|
|
88
|
+
return _blocked(
|
|
89
|
+
unmet.map((u) => `control '${u.control}' is ${u.state}: ${u.reason}`),
|
|
90
|
+
{ backend, controls: report.controls },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const r = await runConfinedSupervised(argv, {
|
|
95
|
+
root, timeoutMs, graceMs, signal, force, allowNetwork, limits, maxOutputBytes,
|
|
96
|
+
env, denyReadPaths: protectedPaths,
|
|
97
|
+
});
|
|
98
|
+
const executed = r.status !== 'disabled' && !(r.status === 'error' && !r.supervised);
|
|
99
|
+
const run = { ...r, executed };
|
|
100
|
+
return {
|
|
101
|
+
status: r.status, blocked: false, executed, denied: r.denied, backend: r.backend,
|
|
102
|
+
reasons: [], controls: report.controls,
|
|
103
|
+
verificationStatus: classifyRun(run),
|
|
104
|
+
// Target output is DATA. It is capped and labelled; it is never parsed for
|
|
105
|
+
// a status. The count below is informational only.
|
|
106
|
+
targetOutput: {
|
|
107
|
+
trust: 'untrusted', stdout: r.stdout, stderr: r.stderr,
|
|
108
|
+
statusLikeLines: countStatusLikeLines(r.stdout) + countStatusLikeLines(r.stderr),
|
|
109
|
+
},
|
|
110
|
+
exitCode: r.exitCode, timedOut: r.timedOut, cancelled: !!r.cancelled,
|
|
111
|
+
outputCapped: !!r.outputCapped, termination: r.termination,
|
|
112
|
+
};
|
|
113
|
+
}
|