atris 3.82.5 → 3.82.8

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('; ')}`,
@@ -557,6 +557,7 @@ module.exports = {
557
557
  authToken,
558
558
  apiBase,
559
559
  resolveEngines,
560
+ localEngineAvailable,
560
561
  atrisJobRunner,
561
562
  quoteEstimate,
562
563
  describeQuote,
@@ -225,31 +225,53 @@ function writeState(root, kind, open, env) {
225
225
  }
226
226
 
227
227
  // The open events of this run, plus a resolved copy of each finding the
228
- // last upload reported open that is gone now.
229
- function withResolved(root, kind, openEvents, { env = process.env, now = new Date() } = {}) {
228
+ // last upload reported open that is gone now. hold(fp) true keeps a gone
229
+ // finding open: it is not sent as resolved and stays in the state. A
230
+ // later run can resolve it (a fix that is only on a side branch).
231
+ //
232
+ // read: the locations (lockfiles) this run read. A finding's recorded
233
+ // locations are merged, never replaced: one leaves the set only when this
234
+ // run read it and it no longer holds the finding. A finding resolves only
235
+ // when its set is empty.
236
+ function withResolved(root, kind, openEvents, { env = process.env, now = new Date(), hold = null, meta = null, read = null } = {}) {
230
237
  const before = readState(root, kind, env);
231
238
  const current = new Set(openEvents.map((e) => e.fingerprint));
232
239
  const resolved = [];
240
+ const held = [];
241
+ const heldLocations = [];
242
+ const next = {};
243
+ const unreadOf = (prev) => (read && prev && Array.isArray(prev.lockfiles) ? prev.lockfiles.filter((l) => !read.has(l)) : []);
233
244
  for (const [fp, prev] of Object.entries(before)) {
234
245
  if (current.has(fp)) continue;
246
+ const left = unreadOf(prev);
247
+ if (left.length) { held.push(fp); heldLocations.push(fp); next[fp] = { ...prev, lockfiles: left }; continue; }
248
+ if (hold && hold(fp, prev)) { held.push(fp); next[fp] = prev; continue; }
235
249
  resolved.push(makeEvent({ kind, repo: prev.repo, severity: prev.severity, title: prev.title, detail: prev.detail, status: 'resolved', fingerprint: fp, occurredAt: now }));
236
250
  }
237
- const next = {};
238
- for (const e of openEvents) next[e.fingerprint] = { repo: e.repo, severity: e.severity, title: e.title, detail: e.detail || '' };
239
- return { events: [...openEvents, ...resolved], resolved, commit: () => writeState(root, kind, next, env) };
251
+ // meta: local-only facts kept beside a finding (never uploaded), like
252
+ // which lockfiles held it.
253
+ for (const e of openEvents) {
254
+ const m = (meta && meta.get(e.fingerprint)) || {};
255
+ const entry = { repo: e.repo, severity: e.severity, title: e.title, detail: e.detail || '', ...m };
256
+ if (Array.isArray(m.lockfiles)) entry.lockfiles = [...new Set([...m.lockfiles, ...unreadOf(before[e.fingerprint])])];
257
+ next[e.fingerprint] = entry;
258
+ }
259
+ return { events: [...openEvents, ...resolved], resolved, held, heldLocations, commit: () => writeState(root, kind, next, env) };
240
260
  }
241
261
 
242
262
  // Upload one run's findings of one kind and move the state on success.
243
- async function reportFindings(root, kind, openEvents, { token, base, env = process.env, upload = true, extra = [] } = {}) {
244
- const plan = withResolved(root, kind, openEvents, { env });
245
- if (!upload) return { ok: true, skipped: 'no-upload', sent: 0, resolved: plan.resolved.length };
246
- if (!token) return { ok: false, skipped: 'not-logged-in', reason: 'not logged in', sent: 0, resolved: plan.resolved.length };
263
+ async function reportFindings(root, kind, openEvents, { token, base, env = process.env, upload = true, extra = [], hold = null, meta = null, read = null } = {}) {
264
+ const plan = withResolved(root, kind, openEvents, { env, hold, meta, read });
265
+ const held = plan.held.length;
266
+ const heldLocations = plan.heldLocations.length;
267
+ if (!upload) return { ok: true, skipped: 'no-upload', sent: 0, resolved: plan.resolved.length, held, heldLocations };
268
+ if (!token) return { ok: false, skipped: 'not-logged-in', reason: 'not logged in', sent: 0, resolved: plan.resolved.length, held, heldLocations };
247
269
  const res = await postEvents([...extra, ...plan.events], { token, base });
248
270
  if (res.ok) {
249
271
  try { plan.commit(); } catch {}
250
272
  try { await flushQueue({ token, base, env }); } catch {}
251
273
  }
252
- return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length };
274
+ return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length, held, heldLocations };
253
275
  }
254
276
 
255
277
  // ---------------------------------------------------------------- review fixes