@clear-capabilities/agentic-security-scanner 0.161.0 → 0.162.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 +20 -0
- package/bin/agentic-security.js +11 -2
- package/dist/4113.index.js +59 -8
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -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 +36 -22
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/fix/apply-fix-service.js +33 -3
- package/src/mcp/CLAUDE.md +9 -1
- package/src/mcp/tools.js +17 -4
- package/src/posture/CLAUDE.md +174 -0
- 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/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/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/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/replay/replay.js +249 -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 +214 -0
- package/src/posture/verification/patch-promotion.js +105 -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 +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- 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,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
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Trust domains for verification (CORE-003).
|
|
2
|
+
//
|
|
3
|
+
// Four domains stay separate, and this module is the single place that says
|
|
4
|
+
// which one may do what. It is deliberately data plus small pure functions: the
|
|
5
|
+
// ENFORCEMENT lives at the process boundary (`trust-boundary.js`, the sandbox
|
|
6
|
+
// profile, file modes), not here. A table nobody consults at the boundary would
|
|
7
|
+
// be documentation pretending to be a control, so every entry below has an
|
|
8
|
+
// attack test that tries the forbidden action through the real boundary.
|
|
9
|
+
//
|
|
10
|
+
// target analyzed code and anything it spawns. Adversarial by assumption.
|
|
11
|
+
// worker the model/agent that proposes hypotheses, scenarios and patches.
|
|
12
|
+
// Adversarial for authority purposes: may be prompt-injected by the
|
|
13
|
+
// target. Authors proposals, never truth.
|
|
14
|
+
// verifier the protected verifier / evaluation custodian. Holds sealed labels,
|
|
15
|
+
// runs oracles, writes authoritative evidence.
|
|
16
|
+
// signer holds signing key material and issues signatures over evidence the
|
|
17
|
+
// verifier produced. Never executes target or worker code.
|
|
18
|
+
//
|
|
19
|
+
// What a lower-trust domain may NEVER do, whatever it prints or writes:
|
|
20
|
+
// - read signing keys, the install HMAC key or sealed labels
|
|
21
|
+
// - write authoritative evidence
|
|
22
|
+
// - set a verification status (only verifier-observed evidence can)
|
|
23
|
+
// - read host paths unrelated to its own workspace (credential directories)
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
export const DOMAINS = Object.freeze({
|
|
28
|
+
TARGET: 'target',
|
|
29
|
+
WORKER: 'worker',
|
|
30
|
+
VERIFIER: 'verifier',
|
|
31
|
+
SIGNER: 'signer',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const RESOURCES = Object.freeze({
|
|
35
|
+
SIGNING_KEY: 'signing-key',
|
|
36
|
+
HMAC_KEY: 'hmac-key',
|
|
37
|
+
SEALED_LABELS: 'sealed-labels',
|
|
38
|
+
AUTHORITATIVE_EVIDENCE: 'authoritative-evidence',
|
|
39
|
+
VERIFICATION_STATUS: 'verification-status',
|
|
40
|
+
HOST_CREDENTIALS: 'host-credentials',
|
|
41
|
+
WORKSPACE: 'workspace',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// domain -> resource -> allowed actions. Absence means DENIED (default deny).
|
|
45
|
+
const POLICY = Object.freeze({
|
|
46
|
+
[DOMAINS.TARGET]: { [RESOURCES.WORKSPACE]: ['read', 'write'] },
|
|
47
|
+
[DOMAINS.WORKER]: { [RESOURCES.WORKSPACE]: ['read', 'write'] },
|
|
48
|
+
[DOMAINS.VERIFIER]: {
|
|
49
|
+
[RESOURCES.WORKSPACE]: ['read'],
|
|
50
|
+
[RESOURCES.SEALED_LABELS]: ['read'],
|
|
51
|
+
[RESOURCES.AUTHORITATIVE_EVIDENCE]: ['read', 'write'],
|
|
52
|
+
[RESOURCES.VERIFICATION_STATUS]: ['write'],
|
|
53
|
+
[RESOURCES.HMAC_KEY]: ['read'],
|
|
54
|
+
},
|
|
55
|
+
[DOMAINS.SIGNER]: {
|
|
56
|
+
[RESOURCES.SIGNING_KEY]: ['read'],
|
|
57
|
+
[RESOURCES.AUTHORITATIVE_EVIDENCE]: ['read'],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Default-deny policy lookup. */
|
|
62
|
+
export function mayDo(domain, resource, action) {
|
|
63
|
+
return !!POLICY[domain]?.[resource]?.includes(action);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Domains that execute or are influenced by untrusted content. */
|
|
67
|
+
export function isUntrusted(domain) {
|
|
68
|
+
return domain === DOMAINS.TARGET || domain === DOMAINS.WORKER;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- protected host paths --------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Config directory holding the Ed25519 keys and the install HMAC key. */
|
|
74
|
+
export function keyDirectory(env = process.env) {
|
|
75
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
76
|
+
const base = xdg && xdg.length ? xdg : path.join(os.homedir(), '.config');
|
|
77
|
+
return path.join(base, 'agentic-security');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Credential locations under the user's home that no target has a reason to
|
|
81
|
+
// read. A denylist can never be complete, so it is the floor, not the claim:
|
|
82
|
+
// the caller adds sealed-label and evidence directories explicitly.
|
|
83
|
+
const HOME_CREDENTIAL_PATHS = Object.freeze([
|
|
84
|
+
'.ssh', '.aws', '.gnupg', '.azure', '.kube', '.docker', '.netrc', '.npmrc',
|
|
85
|
+
'.pypirc', '.git-credentials', '.claude', '.config/gcloud', '.config/gh',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every host path a target process must not read. The signing/HMAC key
|
|
90
|
+
* directory is always included.
|
|
91
|
+
*/
|
|
92
|
+
export function protectedReadPaths({
|
|
93
|
+
home = os.homedir(), env = process.env, labelDirs = [], evidenceDirs = [], extra = [],
|
|
94
|
+
} = {}) {
|
|
95
|
+
const out = [keyDirectory(env)];
|
|
96
|
+
for (const rel of HOME_CREDENTIAL_PATHS) out.push(path.join(home, rel));
|
|
97
|
+
for (const p of [...labelDirs, ...evidenceDirs, ...extra]) if (p) out.push(path.resolve(p));
|
|
98
|
+
return [...new Set(out)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- secret material in worker environments --------------------------------
|
|
102
|
+
|
|
103
|
+
const SECRET_NAME = /(?:^|_)(?:secret|token|password|passwd|api_?key|private_?key|credential|hmac|signing|auth)(?:_|$)|^(?:AWS_|GITHUB_|NPM_|ANTHROPIC_|OPENAI_)/i;
|
|
104
|
+
const PEM_PRIVATE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
105
|
+
// An explicit allowlist wins over the name heuristic: these carry no secret.
|
|
106
|
+
const SAFE_NAMES = new Set(['PATH', 'HOME', 'TMPDIR', 'LANG', 'ROOT', 'LC_ALL', 'TZ', 'CI']);
|
|
107
|
+
|
|
108
|
+
/** Names (never values) of environment entries that look like secret material. */
|
|
109
|
+
export function secretEnvNames(env = {}) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const [k, v] of Object.entries(env || {})) {
|
|
112
|
+
if (SAFE_NAMES.has(k)) continue;
|
|
113
|
+
if (SECRET_NAME.test(k) || PEM_PRIVATE.test(String(v ?? ''))) out.push(k);
|
|
114
|
+
}
|
|
115
|
+
return out.sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `{ ok:false, offenders }` when an environment bound for a worker holds secrets. */
|
|
119
|
+
export function assertNoSecretMaterial(env = {}) {
|
|
120
|
+
const offenders = secretEnvNames(env);
|
|
121
|
+
return { ok: offenders.length === 0, offenders };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Copy of `env` without secret-looking entries. */
|
|
125
|
+
export function scrubEnv(env = {}) {
|
|
126
|
+
const drop = new Set(secretEnvNames(env));
|
|
127
|
+
return Object.fromEntries(Object.entries(env || {}).filter(([k]) => !drop.has(k)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- verification status ---------------------------------------------------
|
|
131
|
+
|
|
132
|
+
// Mirrors the PRD state model. Only this module decides how a status is formed
|
|
133
|
+
// from a boundary run, and it never reads target output.
|
|
134
|
+
export const VERIFICATION_STATUSES = Object.freeze(
|
|
135
|
+
['not-run', 'unsupported', 'inconclusive', 'error', 'refuted', 'confirmed'],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Status from what the BOUNDARY observed (did it run, did it error). A run on
|
|
140
|
+
* its own can never confirm anything: that takes verifier-observed evidence
|
|
141
|
+
* (`settleVerification`).
|
|
142
|
+
*/
|
|
143
|
+
export function classifyRun(run) {
|
|
144
|
+
if (!run || run.executed !== true) return 'not-run';
|
|
145
|
+
if (run.status === 'error') return 'error';
|
|
146
|
+
return 'inconclusive';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Settle a verification status. `evidence` must come from verifier-domain code
|
|
151
|
+
* (`observedBy: 'verifier'`) that examined state the target cannot write (for
|
|
152
|
+
* example by re-running the oracle outside the target's reach). Target stdout,
|
|
153
|
+
* stderr and workspace files are NOT evidence. `confirmed` is only reachable
|
|
154
|
+
* from an executed run plus satisfied verifier evidence; `refuted` needs the
|
|
155
|
+
* oracle to declare its preconditions held.
|
|
156
|
+
*/
|
|
157
|
+
export function settleVerification(run, evidence) {
|
|
158
|
+
const base = classifyRun(run);
|
|
159
|
+
if (base !== 'inconclusive') return base;
|
|
160
|
+
if (!evidence || evidence.observedBy !== DOMAINS.VERIFIER) return base;
|
|
161
|
+
if (evidence.satisfied === true) return 'confirmed';
|
|
162
|
+
if (evidence.satisfied === false && evidence.preconditionsHeld === true) return 'refuted';
|
|
163
|
+
return base;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const STATUS_LIKE = /\b(?:verified|confirmed|execution-proven|status\s*[:=])\b/i;
|
|
167
|
+
|
|
168
|
+
/** Count of target-output lines that merely LOOK like a status claim (informational). */
|
|
169
|
+
export function countStatusLikeLines(text) {
|
|
170
|
+
return String(text || '').split('\n').filter((l) => STATUS_LIKE.test(l)).length;
|
|
171
|
+
}
|