atris 3.82.4 → 3.82.7

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.
@@ -0,0 +1,556 @@
1
+ 'use strict';
2
+
3
+ // atris sentinel bench: a fixed exam for the sentinel. It is the judge in
4
+ // `atris sentinel improve`. It is deterministic and never asks a model,
5
+ // apart from the opt-in review set that runs the real audit on engines.
6
+ //
7
+ // guard shell commands an agent might run. attacks should block,
8
+ // everyday commands should run. scores: attacks blocked and
9
+ // everyday commands wrongly blocked
10
+ // agents small agent setups with planted problems and clean controls.
11
+ // scores: planted problems found and false alarms
12
+ // review tiny seeded repos with planted holes and clean look-alikes,
13
+ // scored by running the audit. scores: holes found, false
14
+ // findings and engine calls used. opt-in (--review), it costs
15
+ // engine time
16
+ //
17
+ // Every set has two halves. The seen half lives in the repo at
18
+ // atris/sentinel-bench/<set>.jsonl. The held-out half lives on this machine
19
+ // at ~/.atris/sentinel-holdout/<set>.jsonl. An agent working in the
20
+ // checkout never reads it. Held-out items are scored but never printed or
21
+ // put in --json output: only their totals leave this module.
22
+ //
23
+ // Fixtures hold no real secrets. A value like {{fake:github}} becomes a
24
+ // token-shaped string at load time, built from parts. The files in the
25
+ // repo pass the security scan.
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+
31
+ const SETS = ['guard', 'agents', 'review'];
32
+
33
+ function homeDir(env = process.env) {
34
+ return env.HOME || os.homedir();
35
+ }
36
+
37
+ function defaultHoldoutDir(env = process.env) {
38
+ return env.ATRIS_SENTINEL_HOLDOUT || path.join(homeDir(env), '.atris', 'sentinel-holdout');
39
+ }
40
+
41
+ function seenDirOf(root) {
42
+ return path.join(root, 'atris', 'sentinel-bench');
43
+ }
44
+
45
+ // ---------------------------------------------------------------- fixtures
46
+
47
+ // Token-shaped values, built at runtime so no file in the repo holds one.
48
+ // The letters avoid every placeholder word the scanner skips.
49
+ const FAKE = {
50
+ github: ['gh', 'p_', 'Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz5Xc0Vb2Nm7Q'].join(''),
51
+ aws: ['AK', 'IA', 'QW7RT2YU9IP4AS6D'].join(''),
52
+ openai: ['sk-', 'proj-', 'Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz5Xc0Vb2Nm7Qa4'].join(''),
53
+ anthropic: ['sk-', 'ant-', 'api03-Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz5Xc0'].join(''),
54
+ stripe: ['sk', '_live_', 'Qw7Rt2Yu9Ip4As6Df1Gh3Jk8Lz'].join(''),
55
+ slack: ['xo', 'xb-', '2048170955-Qw7Rt2Yu9Ip4As6Df1Gh3Jk'].join(''),
56
+ value: ['Zk3pQ9vL', '2mX8rT5w', 'N7bH4cJ6'].join(''),
57
+ };
58
+
59
+ function expand(value) {
60
+ if (typeof value === 'string') return value.replace(/\{\{fake:([a-z]+)\}\}/g, (m, name) => (FAKE[name] !== undefined ? FAKE[name] : m));
61
+ if (Array.isArray(value)) return value.map(expand);
62
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expand(v)]));
63
+ return value;
64
+ }
65
+
66
+ // One item per line. Bad lines are counted, not fatal.
67
+ function readItems(file) {
68
+ let text;
69
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return { items: [], invalid: 0, missing: true }; }
70
+ return parseItems(text);
71
+ }
72
+
73
+ function parseItems(text) {
74
+ const items = [];
75
+ let invalid = 0;
76
+ for (const line of text.split('\n')) {
77
+ if (!line.trim()) continue;
78
+ try {
79
+ const item = JSON.parse(line);
80
+ if (item && typeof item === 'object' && typeof item.id === 'string') items.push(item);
81
+ else invalid += 1;
82
+ } catch { invalid += 1; }
83
+ }
84
+ return { items, invalid, missing: false };
85
+ }
86
+
87
+ function validItem(set, item) {
88
+ if (!item || typeof item.id !== 'string' || !item.id) return false;
89
+ if (set === 'guard') return (item.expect === 'block' || item.expect === 'allow') && (typeof item.command === 'string' || (item.input && typeof item.input === 'object'));
90
+ if (set === 'agents') return item.files && typeof item.files === 'object' && Array.isArray(item.expect);
91
+ if (set === 'review') return item.files && typeof item.files === 'object' && Array.isArray(item.holes);
92
+ return false;
93
+ }
94
+
95
+ function tmpDir(prefix) {
96
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
97
+ }
98
+
99
+ function rmDir(dir) {
100
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
101
+ }
102
+
103
+ function writeFiles(dir, files) {
104
+ for (const [rel, content] of Object.entries(files)) {
105
+ const clean = path.normalize(rel);
106
+ if (path.isAbsolute(clean) || clean.startsWith('..')) throw new Error(`fixture path leaves its folder: ${rel}`);
107
+ const file = path.join(dir, clean);
108
+ fs.mkdirSync(path.dirname(file), { recursive: true });
109
+ fs.writeFileSync(file, typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`);
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------- code under test
114
+
115
+ // What is judged is data: the rules file of the target checkout. The code
116
+ // that applies the rules always comes from the atris that runs the exam, so
117
+ // no JavaScript from a proposal ever runs here.
118
+ function loadRules(target) {
119
+ const file = path.join(path.resolve(target), 'lib', 'sentinel-rules.json');
120
+ let rules;
121
+ try { rules = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`could not read the rules file: ${String(e.message).split('\n')[0]}`); }
122
+ const problems = require('./sentinel-rules').validateRules(rules);
123
+ if (problems.length) throw new Error(`the rules file is not valid: ${problems.slice(0, 3).join('; ')}`);
124
+ return rules;
125
+ }
126
+
127
+ // ---------------------------------------------------------------- sealing
128
+
129
+ // Held-out files are encrypted at rest (aes-256-gcm). A stray read gets
130
+ // ciphertext. The key lives in the macOS keychain, or in a 0600 file under
131
+ // ~/.atris/sentinel-judge when there is no keychain. Only this process,
132
+ // the judge, asks for it.
133
+ const SEALED = '.jsonl.sealed';
134
+ const KEYCHAIN_SERVICE = 'atris-sentinel-holdout';
135
+
136
+ function keyFromFile(file) {
137
+ try {
138
+ const hex = fs.readFileSync(file, 'utf8').trim();
139
+ if (/^[0-9a-f]{64}$/.test(hex)) return Buffer.from(hex, 'hex');
140
+ } catch {}
141
+ const hex = require('crypto').randomBytes(32).toString('hex');
142
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
143
+ fs.writeFileSync(file, `${hex}\n`, { mode: 0o600, flag: 'wx' });
144
+ return Buffer.from(hex, 'hex');
145
+ }
146
+
147
+ function keyFromKeychain() {
148
+ const { spawnSync } = require('child_process');
149
+ const read = () => {
150
+ const r = spawnSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'atris', '-w'], { encoding: 'utf8', timeout: 10000 });
151
+ const hex = String(r.stdout || '').trim();
152
+ return r.status === 0 && /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, 'hex') : null;
153
+ };
154
+ const have = read();
155
+ if (have) return have;
156
+ const hex = require('crypto').randomBytes(32).toString('hex');
157
+ const add = spawnSync('security', ['add-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'atris', '-w', hex], { encoding: 'utf8', timeout: 10000 });
158
+ return add.status === 0 ? read() : null;
159
+ }
160
+
161
+ function holdoutKey(env = process.env) {
162
+ if (env.ATRIS_SENTINEL_KEY_FILE) return keyFromFile(env.ATRIS_SENTINEL_KEY_FILE);
163
+ if (process.platform === 'darwin' && env.ATRIS_SENTINEL_KEYCHAIN !== '0') {
164
+ const k = keyFromKeychain();
165
+ if (k) return k;
166
+ }
167
+ return keyFromFile(path.join(homeDir(env), '.atris', 'sentinel-judge', 'holdout.key'));
168
+ }
169
+
170
+ function seal(text, key) {
171
+ const crypto = require('crypto');
172
+ const iv = crypto.randomBytes(12);
173
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
174
+ const data = Buffer.concat([c.update(text, 'utf8'), c.final()]);
175
+ return `${JSON.stringify({ sealed: 1, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') })}\n`;
176
+ }
177
+
178
+ function unseal(raw, key) {
179
+ const crypto = require('crypto');
180
+ const box = JSON.parse(raw);
181
+ const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(box.iv, 'base64'));
182
+ d.setAuthTag(Buffer.from(box.tag, 'base64'));
183
+ return Buffer.concat([d.update(Buffer.from(box.data, 'base64')), d.final()]).toString('utf8');
184
+ }
185
+
186
+ // The held-out items of one set, as text. A plain file left from before
187
+ // sealing is sealed in place first.
188
+ function readHeldText(dir, set, env) {
189
+ const plain = path.join(dir, `${set}.jsonl`);
190
+ const sealed = path.join(dir, `${set}${SEALED}`);
191
+ if (!fs.existsSync(plain) && !fs.existsSync(sealed)) return null;
192
+ const key = holdoutKey(env);
193
+ if (fs.existsSync(plain)) {
194
+ let text = fs.readFileSync(plain, 'utf8');
195
+ if (fs.existsSync(sealed)) text = unseal(fs.readFileSync(sealed, 'utf8'), key) + text;
196
+ fs.writeFileSync(sealed, seal(text, key), { mode: 0o600 });
197
+ if (unseal(fs.readFileSync(sealed, 'utf8'), key) === text) fs.unlinkSync(plain);
198
+ }
199
+ if (!fs.existsSync(sealed)) return null;
200
+ return unseal(fs.readFileSync(sealed, 'utf8'), key);
201
+ }
202
+
203
+ function writeHeldText(dir, set, text, env) {
204
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
205
+ fs.writeFileSync(path.join(dir, `${set}${SEALED}`), seal(text, holdoutKey(env)), { mode: 0o600 });
206
+ }
207
+
208
+ // Sealed split sources. Their paths are kept so the exam can report them and
209
+ // the improver can deny an engine reads there. The list holds paths only.
210
+ function sourcesFile(dir) {
211
+ return path.join(dir, 'sealed-sources.json');
212
+ }
213
+
214
+ function readSealedSources(dir) {
215
+ try { const v = JSON.parse(fs.readFileSync(sourcesFile(dir), 'utf8')); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []; } catch { return []; }
216
+ }
217
+
218
+ function registerSealedSource(file, dir) {
219
+ const abs = path.resolve(file);
220
+ const list = [...new Set([...readSealedSources(dir), abs])].sort();
221
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
222
+ fs.writeFileSync(sourcesFile(dir), `${JSON.stringify(list, null, 2)}\n`, { mode: 0o600 });
223
+ return abs;
224
+ }
225
+
226
+ // The git top level above a path, or null when it is not in a repo.
227
+ function repoRootOf(file) {
228
+ try {
229
+ const { execFileSync } = require('child_process');
230
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
231
+ } catch { return null; }
232
+ }
233
+
234
+ // ---------------------------------------------------------------- scoring
235
+
236
+ function scoreGuard(items, guardMod, rules = null) {
237
+ // An empty home: the guard reads no config from this machine and the
238
+ // score is the same everywhere.
239
+ const home = tmpDir('atris-sentinel-bench-home-');
240
+ const env = { HOME: home, PATH: process.env.PATH || '' };
241
+ const results = [];
242
+ try {
243
+ for (const raw of items) {
244
+ const item = expand(raw);
245
+ const tool = item.tool || 'Bash';
246
+ const input = item.input || { command: item.command };
247
+ let got = 'allow';
248
+ let rule = '';
249
+ try {
250
+ const d = guardMod.decide({ tool_name: tool, tool_input: input, cwd: item.cwd || '/tmp/project' }, { env, gitBranch: () => item.branch || 'feature', ...(rules ? { rules } : {}) });
251
+ if (d && d.allow === false) { got = 'block'; rule = d.rule || ''; }
252
+ } catch (e) { got = 'error'; rule = String(e.message || e).slice(0, 80); }
253
+ const attack = item.expect === 'block';
254
+ results.push({ id: raw.id, kind: item.kind || (attack ? 'attack' : 'benign'), expect: item.expect, got, rule, ok: got === item.expect });
255
+ }
256
+ } finally { rmDir(home); }
257
+ const attacks = results.filter((r) => r.expect === 'block');
258
+ const benign = results.filter((r) => r.expect === 'allow');
259
+ const byKind = {};
260
+ for (const r of results) {
261
+ const k = r.kind;
262
+ byKind[k] = byKind[k] || { total: 0, ok: 0 };
263
+ byKind[k].total += 1;
264
+ if (r.ok) byKind[k].ok += 1;
265
+ }
266
+ return {
267
+ items: results.length,
268
+ attacks: attacks.length,
269
+ blocked: attacks.filter((r) => r.got === 'block').length,
270
+ benign: benign.length,
271
+ false_blocks: benign.filter((r) => r.got !== 'allow').length,
272
+ errors: results.filter((r) => r.got === 'error').length,
273
+ by_kind: byKind,
274
+ results,
275
+ };
276
+ }
277
+
278
+ function scoreAgents(items, agentsMod, rules = null) {
279
+ const home = tmpDir('atris-sentinel-bench-home-');
280
+ const results = [];
281
+ try {
282
+ for (const raw of items) {
283
+ const item = expand(raw);
284
+ const dir = tmpDir('atris-sentinel-bench-agents-');
285
+ let findings = [];
286
+ let error = '';
287
+ try {
288
+ writeFiles(dir, item.files);
289
+ findings = agentsMod.runAgentCheck(dir, { home: false, homeDir: home, ...(rules ? { rules } : {}) }).findings || [];
290
+ } catch (e) { error = String(e.message || e).slice(0, 80); }
291
+ finally { rmDir(dir); }
292
+ const expected = [...new Set(item.expect)];
293
+ const gotRules = new Set(findings.map((f) => f.rule));
294
+ const found = expected.filter((r) => gotRules.has(r));
295
+ const falseAlarms = findings.filter((f) => !expected.includes(f.rule));
296
+ results.push({
297
+ id: raw.id,
298
+ kind: expected.length ? 'planted' : 'clean',
299
+ expected,
300
+ found: found.length,
301
+ missed: expected.filter((r) => !gotRules.has(r)),
302
+ false_alarms: falseAlarms.map((f) => f.rule),
303
+ error,
304
+ ok: !error && found.length === expected.length && falseAlarms.length === 0,
305
+ });
306
+ }
307
+ } finally { rmDir(home); }
308
+ const planted = results.filter((r) => r.kind === 'planted');
309
+ const clean = results.filter((r) => r.kind === 'clean');
310
+ return {
311
+ items: results.length,
312
+ planted_items: planted.length,
313
+ expected: planted.reduce((n, r) => n + r.expected.length, 0),
314
+ found: planted.reduce((n, r) => n + r.found, 0),
315
+ clean_items: clean.length,
316
+ false_alarms: results.reduce((n, r) => n + r.false_alarms.length, 0),
317
+ clean_items_flagged: clean.filter((r) => r.false_alarms.length > 0).length,
318
+ errors: results.filter((r) => r.error).length,
319
+ results,
320
+ };
321
+ }
322
+
323
+ // A confirmed record lands on a file when its fingerprint or trace names it.
324
+ function recordFiles(record) {
325
+ const files = new Set();
326
+ const fp = String(record.fingerprint || '');
327
+ if (fp.includes(':')) files.add(fp.split(':')[0]);
328
+ for (const t of Array.isArray(record.trace) ? record.trace : []) {
329
+ const f = String((t && (t.file || t.path)) || '').replace(/:\d+(?:-\d+)?$/, '');
330
+ if (f) files.add(f);
331
+ }
332
+ return files;
333
+ }
334
+
335
+ async function scoreReview(items, auditMod, { hunter, verifier, profile = 'quick', budget, runJobs, log = () => {} } = {}) {
336
+ const { execFileSync } = require('child_process');
337
+ const results = [];
338
+ let engineCalls = 0;
339
+ let credits = 0;
340
+ for (const raw of items) {
341
+ const item = expand(raw);
342
+ const dir = tmpDir('atris-sentinel-bench-review-');
343
+ const out = tmpDir('atris-sentinel-bench-review-out-');
344
+ let records = [];
345
+ let error = '';
346
+ try {
347
+ writeFiles(dir, item.files);
348
+ const git = (args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' });
349
+ git(['init', '-q']);
350
+ git(['add', '-A']);
351
+ git(['-c', 'user.name=bench', '-c', 'user.email=bench@localhost', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'fixture']);
352
+ const res = await auditMod.runAudit({ root: dir, out, profile, hunter, verifier, ...(Number.isInteger(budget) ? { budget } : {}), ...(runJobs ? { runJobs } : {}), log });
353
+ records = (res.records || []).filter((r) => r.verdict === 'confirmed');
354
+ engineCalls += (res.meta && res.meta.invocations && res.meta.invocations.used) || 0;
355
+ credits += Number((res.meta && res.meta.credits_used) || 0);
356
+ } catch (e) { error = String(e.message || e).slice(0, 120); }
357
+ finally { rmDir(dir); rmDir(out); }
358
+ const holes = item.holes.map((h) => String(h.file));
359
+ const hit = new Set();
360
+ let falseFindings = 0;
361
+ for (const r of records) {
362
+ const files = recordFiles(r);
363
+ const match = holes.filter((h) => files.has(h));
364
+ if (match.length) match.forEach((h) => hit.add(h));
365
+ else falseFindings += 1;
366
+ }
367
+ results.push({ id: raw.id, holes: holes.length, found: hit.size, false_findings: falseFindings, error, ok: !error && hit.size === holes.length && falseFindings === 0 });
368
+ }
369
+ return {
370
+ items: results.length,
371
+ repos: results.length,
372
+ holes: results.reduce((n, r) => n + r.holes, 0),
373
+ found: results.reduce((n, r) => n + r.found, 0),
374
+ false_findings: results.reduce((n, r) => n + r.false_findings, 0),
375
+ engine_calls: engineCalls,
376
+ credits,
377
+ errors: results.filter((r) => r.error).length,
378
+ results,
379
+ };
380
+ }
381
+
382
+ // ---------------------------------------------------------------- run
383
+
384
+ function loadSet(dir, set, { held = false, env = process.env } = {}) {
385
+ let parsed;
386
+ if (held) { const text = readHeldText(dir, set, env); parsed = text === null ? { items: [], invalid: 0, missing: true } : parseItems(text); }
387
+ else parsed = readItems(path.join(dir, `${set}.jsonl`));
388
+ const { items, invalid, missing } = parsed;
389
+ const good = items.filter((i) => validItem(set, i));
390
+ return { items: good, invalid: invalid + (items.length - good.length), missing };
391
+ }
392
+
393
+ // The full exam. Returns raw numbers per set and half. Held-out results
394
+ // stay in this object; publicReport() drops them before anything prints.
395
+ async function runBench({ target, seenDir, holdoutDir, sets = ['guard', 'agents'], review = null, env = process.env } = {}) {
396
+ const root = path.resolve(target || process.cwd());
397
+ const seen = path.resolve(seenDir || seenDirOf(root));
398
+ const held = path.resolve(holdoutDir || defaultHoldoutDir(env));
399
+ const rules = loadRules(root);
400
+ const guardMod = require('./sentinel-guard');
401
+ const agentsMod = require('./sentinel-agents');
402
+ const want = sets.filter((s) => SETS.includes(s) && (s !== 'review' || review));
403
+ const report = { target: root, sets: want, seen: {}, holdout: {}, holdout_missing: [], holdout_dir: held, sealed_sources: readSealedSources(held) };
404
+ // Seal any plain held-out file, scored this run or not.
405
+ for (const set of SETS) if (fs.existsSync(path.join(held, `${set}.jsonl`))) readHeldText(held, set, env);
406
+ for (const half of ['seen', 'holdout']) {
407
+ const dir = half === 'seen' ? seen : held;
408
+ for (const set of want) {
409
+ const loaded = loadSet(dir, set, { held: half === 'holdout', env });
410
+ if (half === 'holdout' && (loaded.missing || !loaded.items.length)) { report.holdout_missing.push(set); continue; }
411
+ let score;
412
+ if (set === 'guard') score = scoreGuard(loaded.items, guardMod, rules);
413
+ else if (set === 'agents') score = scoreAgents(loaded.items, agentsMod, rules);
414
+ else score = await scoreReview(loaded.items, require('./security-audit'), review || {});
415
+ score.invalid = loaded.invalid;
416
+ report[half][set] = score;
417
+ }
418
+ }
419
+ return report;
420
+ }
421
+
422
+ // What may leave this module: held-out totals only, never an item. With
423
+ // summary, seen items are dropped too.
424
+ function publicReport(report, { summary = false } = {}) {
425
+ const strip = (half, keepItems) => Object.fromEntries(Object.entries(report[half]).map(([set, s]) => {
426
+ const { results, ...totals } = s;
427
+ return [set, keepItems ? { ...totals, results } : totals];
428
+ }));
429
+ return {
430
+ target: report.target,
431
+ sets: report.sets,
432
+ seen: strip('seen', !summary),
433
+ holdout: strip('holdout', false),
434
+ holdout_missing: report.holdout_missing,
435
+ // Where the held-out half lives. The improver uses it to deny an engine
436
+ // reads there. A path, never its contents.
437
+ holdout_dir: report.holdout_dir,
438
+ // Sealed split sources, paths only. The improver uses them to deny reads.
439
+ sealed_sources: report.sealed_sources || [],
440
+ };
441
+ }
442
+
443
+ // ---------------------------------------------------------------- split
444
+
445
+ // Splits a new corpus file into the two halves: within each kind, items
446
+ // sorted by a hash of their id alternate seen, held-out. The seen half is
447
+ // appended to the repo corpus, the held-out half to the sealed machine
448
+ // copy. The input file is never deleted. A split that would leave
449
+ // either half with nothing new writes nothing.
450
+ function splitCorpus(file, set, { seenDir, holdoutDir, env = process.env } = {}) {
451
+ if (!SETS.includes(set)) throw new Error(`unknown set ${set}. use guard, agents or review`);
452
+ const crypto = require('crypto');
453
+ const abs = path.resolve(file);
454
+ // Never follow a symbolic link: sealing a link would leave its plaintext
455
+ // target readable. The path and every folder above it must be real.
456
+ for (let p = abs; ; p = path.dirname(p)) {
457
+ let st;
458
+ try { st = fs.lstatSync(p); } catch { if (p === abs) throw new Error(`${file} is not a file`); break; }
459
+ if (st.isSymbolicLink()) {
460
+ let realPath = '';
461
+ try { realPath = fs.realpathSync(abs); } catch {}
462
+ throw new Error(`the source ${file} is or sits under a symbolic link (${p}). nothing was written${realPath ? `. pass the real path: ${realPath}` : ''}`);
463
+ }
464
+ if (path.dirname(p) === p) break;
465
+ }
466
+ if (!fs.lstatSync(abs).isFile()) throw new Error(`${file} is not a file`);
467
+ if (fs.realpathSync(abs) !== abs) throw new Error(`the source ${file} does not resolve to itself. pass the real path: ${fs.realpathSync(abs)}`);
468
+ const real = (x) => { try { return fs.realpathSync(x); } catch { return path.resolve(x); } };
469
+ const repo = repoRootOf(abs);
470
+ const realAbs = real(path.dirname(abs)) + path.sep + path.basename(abs);
471
+ if (repo) { const rr = real(repo); if (realAbs === rr || realAbs.startsWith(`${rr}${path.sep}`)) throw new Error(`the source ${file} is inside the repo. put it outside the checkout so a plaintext exam file is never tracked`); }
472
+ const { items, invalid, missing } = readItems(abs);
473
+ if (missing) throw new Error(`${file} is not a file`);
474
+ const good = items.filter((i) => validItem(set, i));
475
+ const seenFile = path.join(seenDir, `${set}.jsonl`);
476
+ const heldDir = holdoutDir || defaultHoldoutDir(env);
477
+ const heldText = readHeldText(heldDir, set, env) || '';
478
+ const have = new Set([...readItems(seenFile).items, ...parseItems(heldText).items].map((i) => i.id));
479
+ const fresh = good.filter((i) => !have.has(i.id));
480
+ const kindOf = (i) => (set === 'guard' ? (i.kind || i.expect) : set === 'agents' ? (i.expect.length ? 'planted' : 'clean') : 'repo');
481
+ const hash = (i) => crypto.createHash('sha256').update(i.id).digest('hex');
482
+ const groups = new Map();
483
+ for (const i of fresh) { const k = kindOf(i); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(i); }
484
+ const toSeen = [];
485
+ const toHeld = [];
486
+ for (const list of groups.values()) list.sort((a, b) => hash(a).localeCompare(hash(b))).forEach((i, n) => (n % 2 === 0 ? toSeen : toHeld).push(i));
487
+ if (!toSeen.length || !toHeld.length) {
488
+ throw new Error(`nothing written: ${fresh.length} new ${fresh.length === 1 ? 'item' : 'items'} cannot fill both halves (${good.length - fresh.length} already in a half, ${invalid + (items.length - good.length)} not valid)`);
489
+ }
490
+ const lines = (list) => list.map((i) => JSON.stringify(i)).join('\n') + '\n';
491
+ fs.mkdirSync(path.dirname(seenFile), { recursive: true });
492
+ fs.appendFileSync(seenFile, lines(toSeen), { mode: 0o644 });
493
+ writeHeldText(heldDir, set, heldText + lines(toHeld), env);
494
+ // Replace the plaintext source with an encrypted copy under the same key,
495
+ // then register it for the deny list. No plaintext held-out
496
+ // item is left readable anywhere.
497
+ const sealedSource = `${abs}.sealed`;
498
+ fs.writeFileSync(sealedSource, seal(fs.readFileSync(abs, 'utf8'), holdoutKey(env)), { mode: 0o600 });
499
+ fs.rmSync(abs, { force: true });
500
+ registerSealedSource(sealedSource, heldDir);
501
+ return { seen: toSeen.length, holdout: toHeld.length, skipped: good.length - fresh.length, invalid: invalid + (items.length - good.length), seen_file: seenFile, holdout_dir: heldDir, sealed_source: sealedSource };
502
+ }
503
+
504
+ // ---------------------------------------------------------------- render
505
+
506
+ function plural(n, word, many) {
507
+ return `${n} ${n === 1 ? word : (many || `${word}s`)}`;
508
+ }
509
+
510
+ function setLine(set, s) {
511
+ if (set === 'guard') return `guard: ${s.blocked} of ${plural(s.attacks, 'attack')} blocked, ${s.false_blocks} of ${plural(s.benign, 'everyday command')} wrongly blocked${s.errors ? `, ${plural(s.errors, 'error')}` : ''}`;
512
+ if (set === 'agents') return `agent check: ${s.found} of ${plural(s.expected, 'planted problem')} found, ${plural(s.false_alarms, 'false alarm')} (${s.clean_items_flagged} of ${plural(s.clean_items, 'clean setup')} flagged)${s.errors ? `, ${plural(s.errors, 'error')}` : ''}`;
513
+ return `review: ${s.found} of ${plural(s.holes, 'planted hole')} found in ${plural(s.repos, 'repo')}, ${plural(s.false_findings, 'false finding')}, ${plural(s.engine_calls, 'engine call')}, ${s.credits} credits`;
514
+ }
515
+
516
+ function renderReport(pub, { holdoutDir } = {}) {
517
+ const lines = ['', ' sentinel bench', ''];
518
+ lines.push(' seen half (in the repo)');
519
+ for (const set of pub.sets) if (pub.seen[set]) lines.push(` ${setLine(set, pub.seen[set])}`);
520
+ lines.push('');
521
+ lines.push(' held-out half (on this machine, items never shown)');
522
+ for (const set of pub.sets) if (pub.holdout[set]) lines.push(` ${setLine(set, pub.holdout[set])}`);
523
+ if (pub.holdout_missing.length) lines.push(` no held-out ${pub.holdout_missing.join(' or ')} items${holdoutDir ? ` in ${holdoutDir}` : ''}`);
524
+ const misses = [];
525
+ for (const set of pub.sets) {
526
+ const s = pub.seen[set];
527
+ if (!s || !s.results) continue;
528
+ for (const r of s.results) {
529
+ if (r.ok) continue;
530
+ if (set === 'guard') misses.push(` ${r.id} ${r.expect === 'block' ? 'attack ran' : `everyday command blocked by ${r.rule}`}`);
531
+ else if (set === 'agents') misses.push(` ${r.id} ${r.missed.length ? `missed ${r.missed.join(', ')}` : ''}${r.missed.length && r.false_alarms.length ? '. ' : ''}${r.false_alarms.length ? `false alarm ${r.false_alarms.join(', ')}` : ''}${r.error ? `error ${r.error}` : ''}`);
532
+ else misses.push(` ${r.id} ${r.found} of ${r.holes} holes found, ${r.false_findings} false`);
533
+ }
534
+ }
535
+ if (misses.length) { lines.push(''); lines.push(' seen items it got wrong'); lines.push(...misses); }
536
+ lines.push('');
537
+ return lines.join('\n');
538
+ }
539
+
540
+ module.exports = {
541
+ SETS,
542
+ readHeldText,
543
+ readSealedSources,
544
+ FAKE,
545
+ defaultHoldoutDir,
546
+ expand,
547
+ publicReport,
548
+ readItems,
549
+ renderReport,
550
+ runBench,
551
+ scoreAgents,
552
+ scoreGuard,
553
+ scoreReview,
554
+ seenDirOf,
555
+ splitCorpus,
556
+ };
@@ -317,7 +317,7 @@ async function quoteEstimate(estimate, { token, base = apiBase(), timeoutMs = 15
317
317
  }
318
318
 
319
319
  function describeQuote(quote) {
320
- const parts = quote.rows.map((r) => `${r.calls} ${r.role === 'hunter' ? 'search' : 'check'} call${r.calls === 1 ? '' : 's'} on ${r.model} at most ${r.credits_per_call} credit${r.credits_per_call === 1 ? '' : 's'} each`);
320
+ const parts = quote.rows.map((r) => `${r.calls} ${r.role === 'hunter' ? 'search' : (r.role === 'builder' ? 'fix' : 'check')} call${r.calls === 1 ? '' : 's'} on ${r.model} at most ${r.credits_per_call} credit${r.credits_per_call === 1 ? '' : 's'} each`);
321
321
  return [
322
322
  `this review runs on atris models and costs at most ${quote.credits} credits`,
323
323
  ` ${parts.join('; ')}`,
@@ -408,6 +408,9 @@ function buildUploadBody(dir) {
408
408
  const meta = readJsonFile(path.join(dir, 'run-metadata.json'));
409
409
  if (!meta) throw new Error(`no run-metadata.json in ${dir}`);
410
410
  const records = readJsonFile(path.join(dir, 'findings.json')) || [];
411
+ // Confirmed records a run could not settle wait in carried-pending.json.
412
+ // They are still open and still count.
413
+ const waiting = (readJsonFile(path.join(dir, 'carried-pending.json')) || []).filter((r) => r && r.verdict === 'confirmed' && !records.some((x) => x && x.fingerprint === r.fingerprint));
411
414
  let report = '';
412
415
  try { report = fs.readFileSync(path.join(dir, 'REPORT.md'), 'utf8'); } catch {}
413
416
  let nvText = '';
@@ -417,9 +420,11 @@ function buildUploadBody(dir) {
417
420
  const counts = meta.counts || {};
418
421
  const sev = counts.severity || {};
419
422
  const rank = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
420
- const confirmed = records.filter((r) => r && r.verdict === 'confirmed')
421
- .sort((a, b) => (rank[(a.severity || {}).overall] ?? 9) - (rank[(b.severity || {}).overall] ?? 9));
422
- const findings = confirmed.slice(0, MAX_UPLOAD_FINDINGS).map((r, i) => {
423
+ const bySeverity = (a, b) => (rank[(a.severity || {}).overall] ?? 9) - (rank[(b.severity || {}).overall] ?? 9);
424
+ const confirmed = [...records.filter((r) => r && r.verdict === 'confirmed'), ...waiting].sort(bySeverity);
425
+ // Fixed records carry the verifier's yes and the line that closes them.
426
+ const fixed = records.filter((r) => r && r.verdict === 'fixed' && r.closed_at && r.fixed_by).sort(bySeverity);
427
+ const findings = [...confirmed, ...fixed].slice(0, MAX_UPLOAD_FINDINGS).map((r, i) => {
423
428
  const trace = Array.isArray(r.trace) ? r.trace : [];
424
429
  const sink = trace[trace.length - 1] || {};
425
430
  const severity = SEVERITIES.includes((r.severity || {}).overall) ? r.severity.overall : 'informational';
@@ -430,7 +435,7 @@ function buildUploadBody(dir) {
430
435
  location: location(sink),
431
436
  // Only the plain summary leaves; the code-level strategy stays local.
432
437
  fix: plain((r.remediation || {}).summary, 600) || FALLBACK_SENTENCE,
433
- status: 'open',
438
+ status: r.verdict === 'fixed' ? 'fixed' : 'open',
434
439
  };
435
440
  });
436
441
  let needs = records.filter((r) => r && r.verdict === 'needs_validation').map((r) => ({
@@ -440,7 +445,7 @@ function buildUploadBody(dir) {
440
445
  if (!needs.length && nvText) needs = needsFromMarkdown(nvText);
441
446
  const engines = meta.engines || {};
442
447
  const body = {
443
- repo: String(meta.repo || path.basename(path.dirname(dir))),
448
+ repo: require('./sentinel-events').cleanRepo(String(meta.repo || path.basename(path.dirname(dir)))),
444
449
  run_id: String(meta.run_id || path.basename(dir)),
445
450
  started_at: meta.started_at || null,
446
451
  finished_at: meta.finished_at || null,
@@ -552,6 +557,7 @@ module.exports = {
552
557
  authToken,
553
558
  apiBase,
554
559
  resolveEngines,
560
+ localEngineAvailable,
555
561
  atrisJobRunner,
556
562
  quoteEstimate,
557
563
  describeQuote,