@clear-capabilities/agentic-security-scanner 0.166.0 → 0.167.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 (34) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/bin/agentic-security.js +39 -4
  3. package/dist/2199.index.js +348 -0
  4. package/dist/3740.index.js +722 -0
  5. package/dist/4113.index.js +1 -1
  6. package/dist/4142.index.js +929 -0
  7. package/dist/5738.index.js +240 -0
  8. package/dist/7838.index.js +1 -1
  9. package/dist/808.index.js +1330 -0
  10. package/dist/818.index.js +93 -0
  11. package/dist/9800.index.js +245 -0
  12. package/dist/9999.index.js +2 -2
  13. package/dist/agentic-security.mjs +2 -2
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +2 -2
  16. package/src/posture/artifact-registry.js +3 -0
  17. package/src/posture/proof-fabric/cli.js +115 -0
  18. package/src/posture/proof-fabric/fix-proof.js +73 -0
  19. package/src/posture/proof-fabric/oracles.js +101 -0
  20. package/src/posture/proof-fabric/orchestrator.js +129 -0
  21. package/src/posture/proof-fabric/reproducer.js +108 -0
  22. package/src/posture/proof-fabric/runner.js +243 -0
  23. package/src/posture/proof-fabric/states.js +53 -0
  24. package/src/posture/proof-fabric/store.js +83 -0
  25. package/src/posture/rule-compiler/cli.js +141 -0
  26. package/src/posture/rule-compiler/corpus.js +61 -0
  27. package/src/posture/rule-compiler/eligibility.js +25 -0
  28. package/src/posture/rule-compiler/ir.js +87 -0
  29. package/src/posture/rule-compiler/lifecycle.js +57 -0
  30. package/src/posture/rule-compiler/mutation.js +60 -0
  31. package/src/posture/rule-compiler/promotion.js +47 -0
  32. package/src/posture/rule-compiler/store.js +96 -0
  33. package/src/sandbox/backend-userspace.js +7 -1
  34. package/src/sandbox/trust-boundary.js +2 -1
@@ -0,0 +1,108 @@
1
+ // Serialized reproducer: the ONLY thing an independent verifier receives (besides revision, finding id and oracle).
2
+ // It carries no reasoning, no model output and no prior results. Everything per-run (nonce, secrets, canary paths,
3
+ // callback URL) is a placeholder substituted fresh by the trusted runner, so a replay can never reuse a stale value
4
+ // and a target cannot learn the values in advance.
5
+ import { createHash } from 'node:crypto';
6
+ import { ORACLE_TYPES } from './oracles.js';
7
+
8
+ const REPRODUCER_SCHEMA = 'agentic-security/reproducer';
9
+ const REPRODUCER_VERSION = 1;
10
+ export const LANGUAGES = Object.freeze(['javascript', 'typescript', 'python', 'java', 'c', 'cpp', 'csharp']);
11
+ export const MAX_FILES = 64;
12
+ export const MAX_FILE_BYTES = 256 * 1024;
13
+ const MAX_TOTAL_BYTES = 1024 * 1024;
14
+ const PLACEHOLDER_RE = /\{\{([A-Z_]+)(?::([a-z0-9_-]{1,32}))?\}\}/g;
15
+ const ALLOWED_PLACEHOLDERS = new Set(['NONCE', 'SECRET', 'CANARY', 'CALLBACK_URL', 'CALLBACK_PORT', 'ROOT', 'NODE', 'PYTHON', 'JAVA', 'JAVAC', 'CC']);
16
+
17
+ export function isSafeRelPath(rel) {
18
+ if (typeof rel !== 'string' || !rel || rel.length > 200) return false;
19
+ if (rel.startsWith('/') || rel.includes('\0') || rel.includes('\\')) return false;
20
+ if (['canary', 'tmp'].includes(rel.split('/')[0])) return false;
21
+ return rel.split('/').every((seg) => seg && seg !== '.' && seg !== '..' && !seg.startsWith('__oracle_'));
22
+ }
23
+
24
+ /** Canonical JSON (sorted keys) so identical reproducers hash identically regardless of key order. */
25
+ export function canonicalize(v) {
26
+ if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`;
27
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(v[k])}`).join(',')}}`;
28
+ return JSON.stringify(v);
29
+ }
30
+ export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
31
+ export const reproducerDigest = (r) => sha256(canonicalize(r));
32
+
33
+ export function validateReproducer(r) {
34
+ const errs = [];
35
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return ['reproducer must be an object'];
36
+ if (r.schema !== REPRODUCER_SCHEMA) errs.push('unknown schema');
37
+ if (r.version !== REPRODUCER_VERSION) errs.push('unsupported reproducer version');
38
+ if (typeof r.findingId !== 'string' || !r.findingId || r.findingId.length > 200) errs.push('findingId required');
39
+ if (!LANGUAGES.includes(r.lang)) errs.push(`unsupported language: ${String(r.lang)}`);
40
+ if (!r.oracle || !ORACLE_TYPES.includes(r.oracle.type)) errs.push(`unknown oracle: ${String(r.oracle?.type)}`);
41
+ const files = r.files ?? {};
42
+ if (typeof files !== 'object' || Array.isArray(files)) errs.push('files must be an object');
43
+ else {
44
+ const names = Object.keys(files);
45
+ if (names.length > MAX_FILES) errs.push(`file count must be at most ${MAX_FILES}`);
46
+ let total = 0;
47
+ for (const n of names) {
48
+ if (!isSafeRelPath(n)) { errs.push(`unsafe file path: ${n}`); continue; }
49
+ if (typeof files[n] !== 'string') { errs.push(`file ${n} must be a string`); continue; }
50
+ const b = Buffer.byteLength(files[n]);
51
+ total += b;
52
+ if (b > MAX_FILE_BYTES) errs.push(`file ${n} exceeds ${MAX_FILE_BYTES} bytes`);
53
+ }
54
+ if (total > MAX_TOTAL_BYTES) errs.push('reproducer exceeds total size limit');
55
+ }
56
+ const sourceNames = r.sources && typeof r.sources === 'object' && !Array.isArray(r.sources) ? Object.keys(r.sources) : [];
57
+ if (Object.keys(files).length + sourceNames.length === 0) errs.push('reproducer must declare at least one file or source');
58
+ for (const key of ['build', 'run']) {
59
+ const a = r[key];
60
+ if (a == null && key === 'build') continue;
61
+ if (!Array.isArray(a) || !a.length || !a.every((x) => typeof x === 'string' && x.length < 2000 && !x.includes('\0'))) errs.push(`${key} must be a non-empty argv array of strings (no shell string)`);
62
+ }
63
+ for (const text of [...Object.values(files || {}), ...(r.build || []), ...(r.run || [])]) {
64
+ if (typeof text !== 'string') continue;
65
+ for (const m of text.matchAll(PLACEHOLDER_RE)) if (!ALLOWED_PLACEHOLDERS.has(m[1])) errs.push(`unknown placeholder {{${m[1]}}}`);
66
+ }
67
+ if (r.watch != null) {
68
+ if (typeof r.watch !== 'object' || Array.isArray(r.watch)) errs.push('watch must be an object');
69
+ else for (const [k, v] of Object.entries(r.watch)) if (!/^[a-z0-9_-]{1,32}$/.test(k) || !isSafeRelPath(v)) errs.push(`bad watch entry: ${k}`);
70
+ }
71
+ if (r.env != null) {
72
+ if (typeof r.env !== 'object' || Array.isArray(r.env)) errs.push('env must be an object');
73
+ else for (const [k, v] of Object.entries(r.env)) {
74
+ if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(k)) errs.push(`bad env var name: ${k}`);
75
+ if (typeof v !== 'string' || v.length > 4096) errs.push(`env value for ${k} must be a short string`);
76
+ }
77
+ }
78
+ if (r.sources != null) {
79
+ if (typeof r.sources !== 'object' || Array.isArray(r.sources)) errs.push('sources must be an object');
80
+ else for (const [dest, src] of Object.entries(r.sources)) if (!isSafeRelPath(dest) || !isSafeRelPath(src)) errs.push(`bad sources entry: ${dest}`);
81
+ }
82
+ if (r.network != null && !['none', 'callback'].includes(r.network)) errs.push('network must be none or callback');
83
+ return errs;
84
+ }
85
+
86
+ /** The view handed to an independent verifier: exactly the reproducer, plus identifiers. Nothing else. */
87
+ export function verifierView({ revision, reproducer, findingId, oracle }) {
88
+ return Object.freeze({ revision: revision ?? null, findingId, oracle, reproducer: structuredClone(reproducer) });
89
+ }
90
+
91
+ export function substitute(text, ctx) {
92
+ return text.replace(PLACEHOLDER_RE, (whole, name, arg) => {
93
+ switch (name) {
94
+ case 'NONCE': return ctx.nonce;
95
+ case 'SECRET': return ctx.secret(arg || 'default');
96
+ case 'CANARY': return ctx.canary(arg || 'default');
97
+ case 'CALLBACK_URL': return ctx.callbackUrl ?? whole;
98
+ case 'CALLBACK_PORT': return String(ctx.callbackPort ?? '');
99
+ case 'ROOT': return ctx.root;
100
+ case 'NODE': return ctx.tools.node;
101
+ case 'PYTHON': return ctx.tools.python ?? whole;
102
+ case 'JAVA': return ctx.tools.java ?? whole;
103
+ case 'JAVAC': return ctx.tools.javac ?? whole;
104
+ case 'CC': return ctx.tools.cc ?? whole;
105
+ default: return whole;
106
+ }
107
+ });
108
+ }
@@ -0,0 +1,243 @@
1
+ // Hermetic execution of one reproducer attempt.
2
+ //
3
+ // Isolation preference: container (when a working runtime and a pre-pulled image exist) > local confined sandbox >
4
+ // unavailable. The local mode is LOWER assurance and says so in every result; it is never silently substituted for a
5
+ // container where a policy requires one (`requireContainer`). Nothing here ever executes target code unconfined.
6
+ //
7
+ // Defaults: outbound network denied; only a runner-controlled loopback callback listener may be reachable, and only when
8
+ // the reproducer asks for it; no privileged containers; bounded CPU, memory, disk, process count and wall time.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+ import { randomBytes, createHash } from 'node:crypto';
14
+ import { runInBoundary } from '../../sandbox/trust-boundary.js';
15
+ import { detectBackend } from '../../sandbox/index.js';
16
+ import { evaluateOracle } from './oracles.js';
17
+ import { validateReproducer, substitute } from './reproducer.js';
18
+
19
+ export const DEFAULT_LIMITS = Object.freeze({
20
+ timeoutMs: 20_000, buildTimeoutMs: 60_000, graceMs: 1000, maxOutputBytes: 256 * 1024,
21
+ cpus: 1, memoryMb: 512, pids: 128, diskMb: 64,
22
+ });
23
+
24
+ const CONTAINER_IMAGES = Object.freeze({
25
+ javascript: 'node:24-slim', typescript: 'node:24-slim', python: 'python:3-slim', java: 'eclipse-temurin:21',
26
+ c: 'gcc:latest', cpp: 'gcc:latest', csharp: 'mcr.microsoft.com/dotnet/sdk:8.0',
27
+ });
28
+
29
+ const SIGNALS = { 134: 'SIGABRT', 135: 'SIGBUS', 136: 'SIGFPE', 132: 'SIGILL', 139: 'SIGSEGV' };
30
+
31
+ function findTool(name, pathEnv = process.env.PATH || '') {
32
+ for (const dir of pathEnv.split(path.delimiter)) {
33
+ if (!dir) continue;
34
+ const p = path.join(dir, name);
35
+ try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return fs.realpathSync(p); } catch { /* next */ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ export function hostTools() {
41
+ return { node: process.execPath, python: findTool('python3'), java: findTool('java'), javac: findTool('javac'), cc: findTool('cc') || findTool('gcc'), dotnet: findTool('dotnet') };
42
+ }
43
+
44
+ /** Language -> tools it needs. Missing tools make the language `unsupported` with a typed reason, never a pass. */
45
+ export function languageSupport(lang, tools = hostTools()) {
46
+ const need = { javascript: ['node'], typescript: ['node'], python: ['python'], java: ['java', 'javac'], c: ['cc'], cpp: ['cc'], csharp: ['dotnet'] }[lang];
47
+ if (!need) return { supported: false, reason: `unsupported language: ${lang}` };
48
+ const missing = need.filter((t) => !tools[t]);
49
+ return missing.length ? { supported: false, reason: `missing toolchain: ${missing.join(', ')}` } : { supported: true, reason: null };
50
+ }
51
+
52
+ /**
53
+ * Pick the execution environment. `docker` is injectable for tests. A container needs a responsive daemon AND the image
54
+ * already present: nothing is ever pulled (no network during proof).
55
+ */
56
+ export function selectEnvironment({ lang, preferContainer = true, requireContainer = false, docker = null, tools = hostTools(), force } = {}) {
57
+ const dockerBin = docker?.bin ?? findTool('docker');
58
+ if (preferContainer && dockerBin) {
59
+ const image = docker?.images?.[lang] ?? CONTAINER_IMAGES[lang];
60
+ const probe = (docker?.probe ?? defaultDockerProbe)(dockerBin, image);
61
+ if (probe.ok) return { mode: 'container', assurance: 'high', runtime: dockerBin, image, reason: null };
62
+ if (requireContainer) return { mode: 'unavailable', assurance: 'none', reason: `container required but unavailable: ${probe.reason}` };
63
+ const local = localEnv(force);
64
+ return { ...local, containerSkipped: probe.reason };
65
+ }
66
+ if (requireContainer) return { mode: 'unavailable', assurance: 'none', reason: 'container required but no container runtime found' };
67
+ return localEnv(force);
68
+ }
69
+
70
+ function localEnv(force) {
71
+ const backend = detectBackend({ force });
72
+ if (backend === 'disabled') return { mode: 'unavailable', assurance: 'none', reason: 'no confinement backend works on this host; refusing to execute' };
73
+ return { mode: 'local-confined', assurance: 'lower', backend, reason: null, note: 'lower-assurance local confinement: not a container boundary' };
74
+ }
75
+
76
+ function defaultDockerProbe(bin, image) {
77
+ const info = spawnSync(bin, ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
78
+ if (info.status !== 0) return { ok: false, reason: 'container daemon not responding' };
79
+ const img = spawnSync(bin, ['image', 'inspect', image], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
80
+ return img.status === 0 ? { ok: true } : { ok: false, reason: `image ${image} is not present locally (proofs never pull)` };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------------------------------------------
84
+
85
+ const digestFile = (p) => { try { return createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } catch { return null; } };
86
+
87
+ const LISTENER_SRC = `
88
+ const http = require('node:http'); const fs = require('node:fs');
89
+ const out = process.argv[1];
90
+ const s = http.createServer((req, res) => {
91
+ const m = /^\\/([A-Za-z0-9_-]{8,64})/.exec(req.url || '');
92
+ if (m) fs.appendFileSync(out, JSON.stringify({ nonce: m[1] }) + '\\n');
93
+ req.resume(); res.end('ok');
94
+ });
95
+ s.on('error', () => process.exit(2));
96
+ s.listen(0, '127.0.0.1', () => { process.stdout.write(String(s.address().port) + '\\n'); });
97
+ setTimeout(() => process.exit(0), 120000).unref();
98
+ `;
99
+
100
+ async function startListener(dir) {
101
+ const hits = path.join(dir, 'hits.jsonl');
102
+ fs.writeFileSync(hits, '');
103
+ const child = spawn(process.execPath, ['-e', LISTENER_SRC, hits], { stdio: ['ignore', 'pipe', 'ignore'], detached: false });
104
+ const port = await new Promise((resolve) => {
105
+ let buf = '';
106
+ const t = setTimeout(() => resolve(null), 3000);
107
+ child.stdout.on('data', (d) => { buf += d; const n = Number(buf.trim()); if (n > 0) { clearTimeout(t); resolve(n); } });
108
+ child.on('exit', () => { clearTimeout(t); resolve(null); });
109
+ });
110
+ return {
111
+ port, child,
112
+ hits: () => { try { return fs.readFileSync(hits, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } },
113
+ stop: () => { try { child.kill('SIGKILL'); } catch { /* gone */ } },
114
+ };
115
+ }
116
+
117
+ function trimOut(s, max) { s = String(s ?? ''); return s.length > max ? `${s.slice(0, max)}\n[truncated]` : s; }
118
+
119
+ /**
120
+ * Run one attempt. Never throws. Result:
121
+ * { status, executed, environment, observation, oracle, durationMs, reasons[], degraded[], nonceDigest }
122
+ * status: 'completed' | 'build-failed' | 'blocked' | 'unsupported' | 'invalid' | 'error'
123
+ * Only `completed` carries an oracle verdict; everything else is incomplete evidence.
124
+ */
125
+ export async function runAttempt(reproducer, opts = {}) {
126
+ const started = Date.now();
127
+ const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
128
+ const done = (o) => ({ executed: false, observation: null, oracle: null, degraded: [], reasons: [], ...o, durationMs: Date.now() - started });
129
+
130
+ const errs = validateReproducer(reproducer);
131
+ if (errs.length) return done({ status: 'invalid', reasons: errs });
132
+ const tools = opts.tools ?? hostTools();
133
+ const sup = languageSupport(reproducer.lang, tools);
134
+ if (!sup.supported) return done({ status: 'unsupported', reasons: [sup.reason] });
135
+ const env = opts.environment ?? selectEnvironment({ lang: reproducer.lang, requireContainer: opts.requireContainer, preferContainer: opts.preferContainer ?? true, force: opts.force });
136
+ if (env.mode === 'unavailable') return done({ status: 'blocked', environment: env, reasons: [env.reason] });
137
+ if (env.mode === 'container') {
138
+ return done({ status: 'blocked', environment: env, reasons: ['container execution is selected but the container runner is not enabled in this build; use requireContainer:false for local-confined proof'] });
139
+ }
140
+ if (reproducer.network === 'callback' && env.backend !== 'userspace') {
141
+ return done({ status: 'unsupported', environment: env, reasons: [`loopback callback needs the userspace backend; this host uses ${env.backend}`] });
142
+ }
143
+
144
+ const nonce = randomBytes(12).toString('hex');
145
+ const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'proof-fabric-')));
146
+ const root = path.join(base, 'root');
147
+ const aux = path.join(base, 'aux');
148
+ fs.mkdirSync(root, { recursive: true });
149
+ fs.mkdirSync(path.join(root, 'canary'));
150
+ fs.mkdirSync(path.join(root, 'tmp'));
151
+ fs.mkdirSync(aux);
152
+ let listener = null;
153
+ try {
154
+ const secrets = {};
155
+ const canaries = {};
156
+ const ctx = {
157
+ nonce, root, tools,
158
+ secret: (n) => (secrets[n] ??= `SECRET-${nonce}-${n}`),
159
+ canary: (n) => { canaries[n] ??= { path: path.join(root, 'canary', `${nonce}-${n}`) }; return canaries[n].path; },
160
+ };
161
+ if (reproducer.network === 'callback') {
162
+ listener = await startListener(aux);
163
+ if (!listener.port) return done({ status: 'error', environment: env, reasons: ['could not start the loopback callback listener'] });
164
+ ctx.callbackPort = listener.port;
165
+ ctx.callbackUrl = `http://127.0.0.1:${listener.port}/${nonce}`;
166
+ }
167
+ // Repository sources imported at proof time (so a patch can be overlaid). Read-only, size-bounded, confined to sourceRoot.
168
+ const imported = {};
169
+ if (reproducer.sources) {
170
+ if (!opts.sourceRoot) return done({ status: 'invalid', environment: env, reasons: ['reproducer imports repository sources but no sourceRoot was given'] });
171
+ const realRoot = fs.realpathSync(opts.sourceRoot);
172
+ for (const [dest, src] of Object.entries(reproducer.sources)) {
173
+ let text;
174
+ if (opts.overlay && Object.hasOwn(opts.overlay, src)) text = String(opts.overlay[src]);
175
+ else {
176
+ const abs = path.resolve(realRoot, src);
177
+ let real; try { real = fs.realpathSync(abs); } catch { return done({ status: 'invalid', environment: env, reasons: [`source not found: ${src}`] }); }
178
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return done({ status: 'invalid', environment: env, reasons: [`source escapes the repository: ${src}`] });
179
+ const st = fs.statSync(real);
180
+ if (!st.isFile() || st.size > 256 * 1024) return done({ status: 'invalid', environment: env, reasons: [`source is not a regular file within the size limit: ${src}`] });
181
+ text = fs.readFileSync(real, 'utf8');
182
+ }
183
+ imported[dest] = text;
184
+ }
185
+ }
186
+ for (const [rel, text] of Object.entries({ ...imported, ...reproducer.files })) {
187
+ const dest = path.join(root, rel);
188
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
189
+ fs.writeFileSync(dest, substitute(text, ctx));
190
+ }
191
+ const argv = (a) => a.map((x) => substitute(x, ctx));
192
+ const watch = {};
193
+ for (const [name, rel] of Object.entries(reproducer.watch || {})) {
194
+ const p = path.join(root, rel);
195
+ watch[name] = { path: p, existedBefore: fs.existsSync(p), digestBefore: digestFile(p) };
196
+ }
197
+ const runEnv = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin', HOME: root, TMPDIR: path.join(root, 'tmp'), LANG: 'C.UTF-8', CI: '1' };
198
+ for (const [k, v] of Object.entries(reproducer.env || {})) runEnv[k] = substitute(v, ctx);
199
+ const boundary = { root, timeoutMs: limits.timeoutMs, graceMs: limits.graceMs, maxOutputBytes: limits.maxOutputBytes, env: runEnv, force: opts.force, signal: opts.signal, controlProbes: opts.controlProbes };
200
+
201
+ const degraded = [];
202
+ if (env.assurance === 'lower') degraded.push('lower-assurance-local-confinement');
203
+
204
+ if (reproducer.build) {
205
+ const b = await runInBoundary(argv(reproducer.build), { ...boundary, timeoutMs: limits.buildTimeoutMs, cwd: undefined });
206
+ if (b.blocked) return done({ status: 'blocked', environment: env, reasons: b.reasons, degraded });
207
+ if (b.timedOut || b.exitCode !== 0) return done({ status: 'build-failed', executed: true, environment: env, reasons: [b.timedOut ? 'build timed out' : `build exited ${b.exitCode}`], degraded, observation: { buildStderr: trimOut(b.targetOutput?.stderr, 2000) } });
208
+ }
209
+
210
+ const r = await runInBoundary(argv(reproducer.run), { ...boundary, ...(listener ? { networkProxyPort: listener.port } : {}) });
211
+ if (r.blocked) return done({ status: 'blocked', environment: env, reasons: r.reasons, degraded });
212
+ if (!r.executed) return done({ status: 'error', environment: env, reasons: [`target did not execute (${r.status})`], degraded });
213
+
214
+ const exitCode = r.exitCode ?? null;
215
+ const observation = {
216
+ exitCode, signal: SIGNALS[exitCode] ?? null, timedOut: !!r.timedOut,
217
+ stdout: trimOut(r.targetOutput?.stdout, limits.maxOutputBytes), stderr: trimOut(r.targetOutput?.stderr, limits.maxOutputBytes),
218
+ nonce, secrets, canaries: {}, callbacks: listener ? listener.hits() : [],
219
+ };
220
+ for (const [n, c] of Object.entries(canaries)) observation.canaries[n] = { exists: fs.existsSync(c.path), existedBefore: false };
221
+ for (const [n, w] of Object.entries(watch)) observation.canaries[n] = { exists: fs.existsSync(w.path), existedBefore: w.existedBefore, digestBefore: w.digestBefore, digestAfter: digestFile(w.path) };
222
+ if (r.outputCapped) degraded.push('output-capped');
223
+ if (r.timedOut) degraded.push('timed-out');
224
+ if (r.termination?.survivors?.length) degraded.push('surviving-processes');
225
+
226
+ const oracle = evaluateOracle(reproducer.oracle, observation);
227
+ // Raw secrets/nonce must not leave this function: persisted evidence carries digests only.
228
+ const safeObs = { ...observation, secrets: Object.fromEntries(Object.keys(secrets).map((k) => [k, '[redacted]'])), nonce: '[redacted]', callbacks: observation.callbacks.length };
229
+ safeObs.stdout = scrub(safeObs.stdout, nonce); safeObs.stderr = scrub(safeObs.stderr, nonce);
230
+ return done({
231
+ status: oracle.unsupported ? 'unsupported' : 'completed', executed: true, environment: { ...env, backend: r.backend ?? env.backend },
232
+ observation: safeObs, oracle, degraded, reasons: oracle.unsupported ? [oracle.reason] : [], controls: r.controls,
233
+ nonceDigest: createHash('sha256').update(nonce).digest('hex').slice(0, 16),
234
+ });
235
+ } catch (e) {
236
+ return done({ status: 'error', environment: env, reasons: [`runner error: ${e.message}`] });
237
+ } finally {
238
+ if (listener) listener.stop();
239
+ fs.rmSync(base, { recursive: true, force: true });
240
+ }
241
+ }
242
+
243
+ const scrub = (s, nonce) => String(s).split(nonce).join('[nonce]');
@@ -0,0 +1,53 @@
1
+ // Evidence state ladder for the Universal Exploit Proof Fabric.
2
+ //
3
+ // Evidence state is SEPARATE from severity and confidence. It extends, never replaces, the vocabulary that already
4
+ // exists: `proof-tier.js` (execution-proven / proof-failed / taint-proven / unproven) and the fix-verify legs
5
+ // (`verifiedFull`, `degradedLegs`). `toProofTier` / `fromProofTier` are the explicit bridges.
6
+ //
7
+ // Transitions are guarded: a state can only be reached from the state directly before it (or by dropping back), so no
8
+ // caller can jump a finding to EXPLOIT_CONFIRMED without passing REPRODUCED, or to FIX_VERIFIED without
9
+ // FIX_REPLAY_BLOCKED. A degraded or incomplete run NEVER advances a state.
10
+ export const EVIDENCE_STATES = Object.freeze([
11
+ 'CANDIDATE',
12
+ 'STATICALLY_CORROBORATED',
13
+ 'REPRODUCER_GENERATED',
14
+ 'REPRODUCED',
15
+ 'EXPLOIT_CONFIRMED',
16
+ 'FIX_REPLAY_BLOCKED',
17
+ 'FIX_VERIFIED',
18
+ ]);
19
+
20
+ export const stateRank = (s) => EVIDENCE_STATES.indexOf(s);
21
+
22
+ const REQUIRES_PREDECESSOR = Object.freeze(Object.fromEntries(EVIDENCE_STATES.map((s, i) => [s, i === 0 ? null : EVIDENCE_STATES[i - 1]])));
23
+
24
+ /**
25
+ * Advance `from` to `to`. Returns { ok, state, reason }. Moving up requires `to` to be exactly the next rung; moving
26
+ * down or staying is always allowed (evidence can be lost, never invented). Unknown states are rejected.
27
+ */
28
+ export function advance(from, to) {
29
+ if (!EVIDENCE_STATES.includes(from)) return { ok: false, state: 'CANDIDATE', reason: `unknown current state: ${String(from)}` };
30
+ if (!EVIDENCE_STATES.includes(to)) return { ok: false, state: from, reason: `unknown target state: ${String(to)}` };
31
+ if (stateRank(to) <= stateRank(from)) return { ok: true, state: to, reason: null };
32
+ if (REQUIRES_PREDECESSOR[to] !== from) return { ok: false, state: from, reason: `cannot reach ${to} from ${from}: ${REQUIRES_PREDECESSOR[to]} is required first` };
33
+ return { ok: true, state: to, reason: null };
34
+ }
35
+
36
+ // Bridges to the older vocabulary so existing consumers keep working.
37
+ export function toProofTier(state) {
38
+ const r = stateRank(state);
39
+ if (r >= stateRank('EXPLOIT_CONFIRMED')) return 'execution-proven';
40
+ if (r >= stateRank('STATICALLY_CORROBORATED')) return 'taint-proven';
41
+ return 'unproven';
42
+ }
43
+
44
+ export function fromProofTier(tier) {
45
+ if (tier === 'taint-proven') return 'STATICALLY_CORROBORATED';
46
+ // execution-proven under the older single-run marker path is one run, not the 3/3 + independent replay the fabric
47
+ // requires, so it maps to REPRODUCED and never to EXPLOIT_CONFIRMED.
48
+ if (tier === 'execution-proven') return 'REPRODUCED';
49
+ return 'CANDIDATE';
50
+ }
51
+
52
+ // Completeness vocabulary shared with fix-verify: FULL only when nothing was skipped.
53
+ export const COMPLETENESS = Object.freeze(['FULL', 'DEGRADED', 'INCOMPLETE']);
@@ -0,0 +1,83 @@
1
+ // Proof artifact store: `.agentic-security/proofs/<findingKey>/<recordDigest>.json`.
2
+ //
3
+ // Reuses the existing state machinery rather than adding another: `safeWriteState` (refuses unsafe dirs and symlinks),
4
+ // `maybeEncryptForWrite` / `maybeDecryptForRead` (the `proofs` artifact is registered `confidential`), retention
5
+ // (evidence class) and legal holds (a hold on `proofs` blocks purge). Nothing persisted contains a plaintext credential:
6
+ // records are passed through a recursive secret filter first, and the runner never returns its per-run nonce or secrets.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
10
+ import { maybeEncryptForWrite, maybeDecryptForRead } from '../encryption-provider.js';
11
+ import { redactSecrets } from '../../llm-validator/redact.js';
12
+ import { canonicalize, sha256 } from './reproducer.js';
13
+
14
+ const PROOF_RECORD_SCHEMA = 'agentic-security/proof-record';
15
+ const PROOFS_DIR = 'proofs';
16
+ const MAX_RECORD_BYTES = 512 * 1024;
17
+
18
+ export const findingKey = (id) => `${String(id).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)}-${sha256(String(id)).slice(0, 8)}`;
19
+
20
+ // The shared redactor catches key=value shapes; bare tokens need their own pass, so persisted evidence cannot carry one.
21
+ const BARE_SECRET_RES = [
22
+ /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g, /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, /\bsk-[A-Za-z0-9_-]{20,}\b/g,
23
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
24
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, /\bSECRET-[0-9a-f]{24}-[a-z0-9_-]+\b/g,
25
+ ];
26
+ function scrubString(text) {
27
+ let t = String(text);
28
+ try { t = redactSecrets(t).text; } catch { return '[unredactable]'; }
29
+ for (const re of BARE_SECRET_RES) t = t.replace(re, '[REDACTED-SECRET]');
30
+ return t;
31
+ }
32
+
33
+ /** Deep-copy `v` with every string passed through the secret redactor. */
34
+ export function scrubDeep(v) {
35
+ if (typeof v === 'string') return scrubString(v);
36
+ if (Array.isArray(v)) return v.map(scrubDeep);
37
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrubDeep(x)]));
38
+ return v;
39
+ }
40
+
41
+ function recordDigest(record) {
42
+ const { recordDigest: _omit, ...rest } = record;
43
+ return sha256(canonicalize(rest));
44
+ }
45
+
46
+ export function saveProof(scanRoot, record) {
47
+ const safe = scrubDeep(record);
48
+ safe.schema = PROOF_RECORD_SCHEMA;
49
+ safe.recordDigest = recordDigest(safe);
50
+ const text = JSON.stringify(safe, null, 2);
51
+ if (Buffer.byteLength(text) > MAX_RECORD_BYTES) return { ok: false, reason: 'proof record exceeds the size limit' };
52
+ if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
53
+ const enc = maybeEncryptForWrite(scanRoot, PROOFS_DIR, text);
54
+ if (!enc.ok) return { ok: false, reason: enc.reason };
55
+ const file = statePath(scanRoot, PROOFS_DIR, findingKey(record.findingId), `${safe.recordDigest}.json`);
56
+ if (!safeWriteState(file, enc.content)) return { ok: false, reason: 'state write refused' };
57
+ return { ok: true, file, digest: safe.recordDigest, encrypted: enc.encrypted };
58
+ }
59
+
60
+ function readRecord(file) {
61
+ const text = maybeDecryptForRead(fs.readFileSync(file, 'utf8'));
62
+ const rec = JSON.parse(text);
63
+ // A record whose content no longer matches its own digest was modified after it was written.
64
+ if (rec.recordDigest !== recordDigest(rec)) return { tampered: true, record: rec };
65
+ return { tampered: false, record: rec };
66
+ }
67
+
68
+ /** Newest-first records for a finding. Tampered records are returned flagged, never silently dropped or trusted. */
69
+ export function listProofs(scanRoot, findingId) {
70
+ const dir = statePath(scanRoot, PROOFS_DIR, findingKey(findingId));
71
+ let names = [];
72
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')); } catch { return []; }
73
+ const out = [];
74
+ for (const n of names) {
75
+ const f = path.join(dir, n);
76
+ try { const { tampered, record } = readRecord(f); out.push({ file: f, tampered, record, mtime: fs.statSync(f).mtimeMs }); } catch { out.push({ file: f, tampered: true, record: null, mtime: 0 }); }
77
+ }
78
+ return out.sort((a, b) => b.mtime - a.mtime);
79
+ }
80
+
81
+ export function latestProof(scanRoot, findingId) {
82
+ return listProofs(scanRoot, findingId).find((p) => p.record) ?? null;
83
+ }
@@ -0,0 +1,141 @@
1
+ // CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback`.
2
+ //
3
+ // Distinct from the pre-existing `labs synthesize-rule` (posture/rule-synthesis.js), which proposes SUPPRESSION
4
+ // rules from repeated false-positive triage verdicts — a different input, a different output and a different
5
+ // command group on purpose, so the two are never confused at the CLI.
6
+ //
7
+ // Non-interactive by construction: no command reads stdin, every corpus evaluation is bounded (no network, no
8
+ // model call — this is the deterministic half; a model may PROPOSE the candidate pattern this CLI is handed, but
9
+ // it never decides promotion).
10
+ import fs from 'node:fs';
11
+ import { statePath } from '../state-dir.js';
12
+ import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
13
+ import { checkEligibility } from './eligibility.js';
14
+ import { buildCorpus } from './corpus.js';
15
+ import { gatePromotion, checkLeakage, PROMOTION_DEFAULTS } from './promotion.js';
16
+ import { recordTransition, stateOf, historyFor } from './lifecycle.js';
17
+ import { loadLedger, saveLedger, saveIR, loadIR, saveEvaluation, writeFixtures, publishCustomRule, unpublishCustomRule } from './store.js';
18
+
19
+ function loadFinding(root, id) {
20
+ const p = statePath(root, 'last-scan.json');
21
+ if (!fs.existsSync(p)) return null;
22
+ const last = JSON.parse(fs.readFileSync(p, 'utf8'));
23
+ return (last.findings || []).find((f) => f.id === id || f.stableId === id) ?? null;
24
+ }
25
+
26
+ function readJsonArg(pathOrInline) {
27
+ if (!pathOrInline) return null;
28
+ try { return JSON.parse(fs.existsSync(pathOrInline) ? fs.readFileSync(pathOrInline, 'utf8') : pathOrInline); } catch { return null; }
29
+ }
30
+
31
+ export async function cmdRules(args, root) {
32
+ const sub = args._[1];
33
+ if (sub === 'synthesize') return cmdSynthesize(args, root);
34
+ if (sub === 'evaluate') return cmdEvaluate(args, root);
35
+ if (sub === 'promote') return cmdPromote(args, root);
36
+ if (sub === 'status') return cmdStatus(args, root);
37
+ if (sub === 'rollback') return cmdRollback(args, root);
38
+ console.error('usage: rules synthesize <finding-id> --spec <file.json> | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id>');
39
+ return 4;
40
+ }
41
+
42
+ // `--spec` carries exactly what a model or a human proposed: id/title/severity/cwe/family/languages/match/
43
+ // remediation plus the corpus inputs (originalCode/patchedCode/nearbyCleanCode/protectedVariantsCode). This
44
+ // command never invents a pattern; it only drafts the IR, checks eligibility and records DRAFT->CANDIDATE.
45
+ async function cmdSynthesize(args, root) {
46
+ const findingId = args._[2];
47
+ const spec = readJsonArg(args.flags.spec);
48
+ if (!findingId || !spec) { console.error('usage: rules synthesize <finding-id> --spec <file.json>'); return 4; }
49
+ const finding = loadFinding(root, findingId);
50
+ if (!finding) { console.error(`Finding ${findingId} not found in last scan.`); return 4; }
51
+ const elig = checkEligibility(finding, { proofState: args.flags['proof-state'] || null });
52
+ if (!elig.eligible) { console.error(`Not eligible for rule synthesis: ${elig.reasons.join('; ')}`); return 4; }
53
+
54
+ const ir = draftRuleIR({ ...spec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
55
+ const errs = validateRuleIR(ir);
56
+ if (errs.length) { console.error(`Invalid rule: ${errs.join('; ')}`); return 4; }
57
+
58
+ const ledger = loadLedger(root);
59
+ const draft = recordTransition(ledger, { ruleId: ir.id, from: null, to: 'DRAFT', reason: `drafted from ${findingId}`, actor: args.flags.actor || 'agent' });
60
+ if (!draft.ok) { console.error(draft.reason); return 4; }
61
+ const cand = recordTransition(ledger, { ruleId: ir.id, from: 'DRAFT', to: 'CANDIDATE', reason: 'eligibility passed', evidence: { eligibility: elig }, actor: args.flags.actor || 'agent' });
62
+ saveLedger(root, ledger);
63
+ saveIR(root, { ...ir, state: 'CANDIDATE' });
64
+ console.log(`Rule ${ir.id}: DRAFT -> CANDIDATE (digest ${ruleIRDigest(ir).slice(0, 12)})`);
65
+ if (!cand.ok) console.error(cand.reason);
66
+ return 0;
67
+ }
68
+
69
+ async function cmdEvaluate(args, root) {
70
+ const id = args._[2];
71
+ if (!id) { console.error('usage: rules evaluate <rule-id> --corpus <file.json>'); return 4; }
72
+ const ir = loadIR(root, id);
73
+ if (!ir) { console.error(`No rule IR found for ${id}. Run rules synthesize first.`); return 4; }
74
+ const corpusSpec = readJsonArg(args.flags.corpus);
75
+ if (!corpusSpec) { console.error('No corpus supplied. Pass --corpus <file.json> with originalCode (and optionally patchedCode/nearbyCleanCode/protectedVariantsCode).'); return 4; }
76
+ const corpus = buildCorpus(ir, corpusSpec);
77
+ const leakage = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [] });
78
+ const gate = gatePromotion({ ir, corpus, leakageCheck: leakage, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : PROMOTION_DEFAULTS });
79
+
80
+ const ledger = loadLedger(root);
81
+ const cur = stateOf(ledger, id);
82
+ if (gate.pass && cur === 'CANDIDATE') recordTransition(ledger, { ruleId: id, from: 'CANDIDATE', to: 'VALIDATED', reason: 'promotion gate passed', evidence: { score: gate.evaluation.score }, actor: args.flags.actor || 'agent' });
83
+ saveLedger(root, ledger);
84
+ saveEvaluation(root, id, gate);
85
+ if (args.flags.json) console.log(JSON.stringify(gate, null, 2));
86
+ else {
87
+ console.log(`Rule ${id}: ${gate.pass ? 'VALIDATED' : 'gate FAILED'}`);
88
+ console.log(` precision ${fmtPct(gate.evaluation.score.precision)} recall ${fmtPct(gate.evaluation.score.recall)} protected-failures ${gate.evaluation.score.protectedFailures}`);
89
+ for (const r of gate.reasons) console.log(` - ${r}`);
90
+ }
91
+ if (gate.pass) { const f = writeFixtures(root, ir, corpus); if (f.ok) console.log(` regression fixtures written: ${f.written.length} file(s) under .agentic-security/rule-compiler/${id}/fixtures/`); }
92
+ return gate.pass ? 0 : 1;
93
+ }
94
+ const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
95
+
96
+ async function cmdPromote(args, root) {
97
+ const id = args._[2];
98
+ if (!id) { console.error('usage: rules promote <rule-id>'); return 4; }
99
+ const ledger = loadLedger(root);
100
+ if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
101
+ if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
102
+ const ir = loadIR(root, id);
103
+ if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
104
+ const pub = publishCustomRule(root, ir);
105
+ if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
106
+ const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
107
+ saveLedger(root, ledger);
108
+ console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
109
+ if (!t.ok) console.error(t.reason);
110
+ return 0;
111
+ }
112
+
113
+ async function cmdRollback(args, root) {
114
+ const id = args._[2];
115
+ if (!id) { console.error('usage: rules rollback <rule-id> [--reason text]'); return 4; }
116
+ const ledger = loadLedger(root);
117
+ const cur = stateOf(ledger, id);
118
+ if (cur !== 'PROMOTED') { console.error(`Rule ${id} is not PROMOTED (current: ${cur ?? 'unknown'}); nothing to roll back.`); return 4; }
119
+ const un = unpublishCustomRule(root, id);
120
+ const t = recordTransition(ledger, { ruleId: id, from: 'PROMOTED', to: 'ROLLED_BACK', reason: args.flags.reason || 'operator rollback', actor: args.flags.actor || 'operator' });
121
+ saveLedger(root, ledger);
122
+ console.log(`Rule ${id}: PROMOTED -> ROLLED_BACK.${un.ok ? '' : ` (warning: could not remove published rule file: ${un.reason})`}`);
123
+ return t.ok ? 0 : 1;
124
+ }
125
+
126
+ async function cmdStatus(args, root) {
127
+ const ledger = loadLedger(root);
128
+ const id = args._[2];
129
+ if (id) {
130
+ const h = historyFor(ledger, id);
131
+ if (!h.length) { console.error(`No history for rule ${id}.`); return 4; }
132
+ if (args.flags.json) console.log(JSON.stringify(h, null, 2));
133
+ else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`); for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`); }
134
+ return 0;
135
+ }
136
+ const ids = [...new Set(ledger.entries.map((e) => e.ruleId))];
137
+ if (args.flags.json) { console.log(JSON.stringify(ids.map((rid) => ({ id: rid, state: stateOf(ledger, rid) })), null, 2)); return 0; }
138
+ if (!ids.length) { console.log('No rules in the lifecycle ledger yet.'); return 0; }
139
+ for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}`);
140
+ return 0;
141
+ }