atris 3.82.7 → 3.82.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/atris.js +5 -2
- package/commands/security-review.js +1 -0
- package/commands/sentinel-bench.js +83 -5
- package/commands/sentinel-powers.js +97 -11
- package/lib/sentinel-bench.js +50 -2
- package/lib/sentinel-events.js +32 -10
- package/lib/sentinel-guard.js +17 -4
- package/lib/sentinel-improve.js +134 -19
- package/lib/sentinel-night.js +893 -0
- package/lib/sentinel-watch.js +940 -98
- package/package.json +1 -1
package/lib/sentinel-improve.js
CHANGED
|
@@ -177,11 +177,12 @@ async function gather({ env = process.env, request, osvBase, offline = false } =
|
|
|
177
177
|
|
|
178
178
|
// The exam in its own process, from this atris. Returns the public report:
|
|
179
179
|
// seen items and held-out totals, never a held-out item.
|
|
180
|
-
function runJudge({ target, seenDir, sets = ['guard', 'agents'], atrisBin, env = process.env, spawn = spawnSync } = {}) {
|
|
180
|
+
function runJudge({ target, seenDir, sets = ['guard', 'agents'], atrisBin, env = process.env, spawn = spawnSync, timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
181
181
|
const bin = atrisBin || path.join(__dirname, '..', 'bin', 'atris.js');
|
|
182
182
|
const args = [bin, 'sentinel', 'bench', '--json', '--target', target, '--sets', sets.join(',')];
|
|
183
183
|
if (seenDir) args.push('--seen', seenDir);
|
|
184
|
-
const r = spawn(process.execPath, args, { encoding: 'utf8', env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, maxBuffer: 32 * 1024 * 1024, timeout:
|
|
184
|
+
const r = spawn(process.execPath, args, { encoding: 'utf8', env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, maxBuffer: 32 * 1024 * 1024, timeout: Math.max(1, Math.floor(timeoutMs)) });
|
|
185
|
+
if (r.error && r.error.code === 'ETIMEDOUT') throw new Error('the exam ran out of time');
|
|
185
186
|
if (r.status !== 0) throw new Error(`the exam did not run: ${String(r.stderr || r.stdout || '').trim().split('\n').slice(-1)[0] || `exit ${r.status}`}`);
|
|
186
187
|
try { return JSON.parse(r.stdout); } catch { throw new Error('the exam printed something that is not json'); }
|
|
187
188
|
}
|
|
@@ -289,15 +290,36 @@ function keepDecision(before, after, { newIds = [] } = {}) {
|
|
|
289
290
|
|
|
290
291
|
// ---------------------------------------------------------------- propose
|
|
291
292
|
|
|
292
|
-
|
|
293
|
+
// The areas a proposer can be pointed at. A focus narrows the misses it
|
|
294
|
+
// sees to one area so parallel proposers do not all chase the same thing.
|
|
295
|
+
const FOCI = ['exfil', 'destroy', 'disarm', 'agents'];
|
|
296
|
+
const FOCUS_WORDS = {
|
|
297
|
+
exfil: 'commands that send a secret or private file off the machine',
|
|
298
|
+
destroy: 'commands that delete or wreck files, history or data',
|
|
299
|
+
disarm: 'commands that turn the guard or other safety checks off',
|
|
300
|
+
agents: 'agent setup files (CLAUDE.md, AGENTS.md, skills, hooks, mcp configs) with planted problems',
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
function inFocus(set, r, focus) {
|
|
304
|
+
if (!focus) return true;
|
|
305
|
+
if (focus === 'agents') return set === 'agents';
|
|
306
|
+
return set === 'guard' && r.kind === focus;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function missesForPrompt(report, focus = null) {
|
|
293
310
|
const lines = [];
|
|
294
311
|
const seenDir = report.seen || {};
|
|
295
|
-
for (const r of (seenDir.guard && seenDir.guard.results) || []) if (!r.ok) lines.push(`guard ${r.id}: expected ${r.expect}, got ${r.got}${r.rule ? ` (${r.rule})` : ''}`);
|
|
296
|
-
for (const r of (seenDir.agents && seenDir.agents.results) || []) if (!r.ok) lines.push(`agents ${r.id}: missed ${r.missed.join(', ') || 'none'}, false alarms ${r.false_alarms.join(', ') || 'none'}`);
|
|
312
|
+
for (const r of (seenDir.guard && seenDir.guard.results) || []) if (!r.ok && inFocus('guard', r, focus)) lines.push(`guard ${r.id}: expected ${r.expect}, got ${r.got}${r.rule ? ` (${r.rule})` : ''}`);
|
|
313
|
+
for (const r of (seenDir.agents && seenDir.agents.results) || []) if (!r.ok && inFocus('agents', r, focus)) lines.push(`agents ${r.id}: missed ${r.missed.join(', ') || 'none'}, false alarms ${r.false_alarms.join(', ') || 'none'}`);
|
|
297
314
|
return lines;
|
|
298
315
|
}
|
|
299
316
|
|
|
300
|
-
|
|
317
|
+
// Misses per focus area, for handing each parallel proposer its own area.
|
|
318
|
+
function missCounts(report) {
|
|
319
|
+
return Object.fromEntries(FOCI.map((f) => [f, missesForPrompt(report, f).length]));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildPrompt({ report, signals, seenDirRel, focus = null, tonight = [] }) {
|
|
301
323
|
const totals = (half) => Object.entries(report[half] || {}).map(([set, s]) => `${set}: ${JSON.stringify(Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'results' && k !== 'by_kind')))}`).join('\n');
|
|
302
324
|
return [
|
|
303
325
|
'You are improving the atris sentinel, a fixed-rule safety check for coding agents. Propose exactly one small change.',
|
|
@@ -317,8 +339,14 @@ function buildPrompt({ report, signals, seenDirRel }) {
|
|
|
317
339
|
'The new items you add are scored and reported but never count toward keeping this change.',
|
|
318
340
|
'So fix a general weakness, not one string. A rule that only matches the seen item will not move the hidden half.',
|
|
319
341
|
'',
|
|
320
|
-
'
|
|
321
|
-
|
|
342
|
+
'Known limits a literal rule cannot fix, so do not try: one-liners run through an interpreter (python -c, node -e and similar) that read a secret and send it. Literal words cannot tell reading a secret and sending it apart from printing a variable next to a web address, and reviewers rejected three such rules tonight for blocking everyday commands. Pick a different miss.',
|
|
343
|
+
'Before proposing a command_block, picture five everyday developer commands that contain the same literal substrings. If any of them would be blocked, the rule is too broad.',
|
|
344
|
+
'',
|
|
345
|
+
...(focus ? [`Focus: ${focus}, ${FOCUS_WORDS[focus]}. Other proposers cover the other areas tonight. Propose a change for this area only.`, ''] : []),
|
|
346
|
+
...(tonight.length ? ['Learned earlier tonight from a strict reviewer. Treat these as part of the exam:', ...tonight.map((l) => ` ${l}`), ''] : []),
|
|
347
|
+
focus ? `Seen items the current code gets wrong in this area:` : 'Seen items the current code gets wrong:',
|
|
348
|
+
...(missesForPrompt(report, focus).map((l) => ` ${l}`)),
|
|
349
|
+
...(focus && !missesForPrompt(report, focus).length ? [' none. look for a general weakness in this area that the hidden half may hold'] : []),
|
|
322
350
|
'',
|
|
323
351
|
'Current totals, seen half:',
|
|
324
352
|
totals('seen'),
|
|
@@ -502,9 +530,63 @@ function cleanItems(p, prefix) {
|
|
|
502
530
|
return out;
|
|
503
531
|
}
|
|
504
532
|
|
|
533
|
+
// The strict secret check for exam items an engine wrote, proposer or
|
|
534
|
+
// reviewer. Unlike the repo scan it honors no suppression marker and no
|
|
535
|
+
// placeholder exemption: a marker or any match of a secret rule counts.
|
|
536
|
+
// Exam items may write a key only as a {{fake:...}} placeholder.
|
|
537
|
+
const SUPPRESS = /atris-allow-secret|atris-security-ignore|nosec/i;
|
|
538
|
+
function strictSecret(text) {
|
|
539
|
+
const { SECRET_RULES } = require('./security-scan');
|
|
540
|
+
const s = String(text || '');
|
|
541
|
+
if (SUPPRESS.test(s)) return true;
|
|
542
|
+
return SECRET_RULES.some((rule) => rule.re.test(s));
|
|
543
|
+
}
|
|
544
|
+
const SECRET_ITEM_ERROR = 'the proposal holds an exam item that looks like a real key. keys in exam items must be {{fake:...}} placeholders';
|
|
545
|
+
const SECRET_PROPOSAL_ERROR = 'the proposal holds text that looks like a secret';
|
|
546
|
+
|
|
547
|
+
// The one gate for engine text. Every raw string in a value, keys included,
|
|
548
|
+
// is checked with the strict secret check before anything is serialized or
|
|
549
|
+
// written. One failing string fails the whole value. True means clean.
|
|
550
|
+
function gateValue(v, depth = 0) {
|
|
551
|
+
if (depth > 40) return false;
|
|
552
|
+
if (typeof v === 'string') return !strictSecret(v);
|
|
553
|
+
if (Array.isArray(v)) return v.every((x) => gateValue(x, depth + 1));
|
|
554
|
+
if (v && typeof v === 'object') return Object.entries(v).every(([k, x]) => !strictSecret(k) && gateValue(x, depth + 1));
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Errors and reasons that get written are fixed sentences chosen here,
|
|
559
|
+
// never built from engine output: no file names, field names, titles or
|
|
560
|
+
// example text. Anything not known maps to one plain sentence.
|
|
561
|
+
const FIXED_ERRORS = [
|
|
562
|
+
[/^the time limit ran out/, 'the time limit ran out'],
|
|
563
|
+
[/did not return a proposal|not one json|is not json|must be one object|needs the new rules or one to six edits|an edit/, 'the proposer gave no usable proposal'],
|
|
564
|
+
[/only lib\/sentinel-rules\.json may change/, 'the proposal named a file other than lib/sentinel-rules.json. only lib/sentinel-rules.json may change'],
|
|
565
|
+
[/break the schema/, 'the proposed rules break the schema'],
|
|
566
|
+
[/changed files it may not touch/, 'the proposer changed files it may not touch'],
|
|
567
|
+
[/looks like a real key/, SECRET_ITEM_ERROR],
|
|
568
|
+
[/looks like a secret/, SECRET_PROPOSAL_ERROR],
|
|
569
|
+
[/^the proposer failed/, 'the proposer did not finish'],
|
|
570
|
+
[/^the exam did not run|^the exam printed|ran out of time/, 'the exam did not run'],
|
|
571
|
+
[/cannot be kept from reading/, 'that engine may not propose'],
|
|
572
|
+
[/the copy changed while the exam ran/, 'the copy changed while the exam ran'],
|
|
573
|
+
[/touches files other than the rules/, 'the change touches files other than the rules and the seen items'],
|
|
574
|
+
];
|
|
575
|
+
function fixedError(e) {
|
|
576
|
+
const m = String((e && e.message) || e || '');
|
|
577
|
+
for (const [re, text] of FIXED_ERRORS) if (re.test(m)) return text;
|
|
578
|
+
return 'the proposal could not be judged';
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// A keep-rule reason without the item ids it may list.
|
|
582
|
+
function fixedReason(r) {
|
|
583
|
+
return String(r || '').replace(/:.*$/, '');
|
|
584
|
+
}
|
|
585
|
+
|
|
505
586
|
function appendItems(seenDir, items) {
|
|
506
587
|
for (const set of ['guard', 'agents']) {
|
|
507
|
-
|
|
588
|
+
// The gate again at the write: an item that fails is never written.
|
|
589
|
+
const list = items.filter((x) => x.set === set && gateValue(x.item)).map((x) => x.item);
|
|
508
590
|
if (!list.length) continue;
|
|
509
591
|
const file = path.join(seenDir, `${set}.jsonl`);
|
|
510
592
|
fs.appendFileSync(file, list.map((i) => JSON.stringify(i)).join('\n') + '\n');
|
|
@@ -528,7 +610,9 @@ function renderReceipt({ at, engine, proposal, items, before, after, decision, b
|
|
|
528
610
|
lines.push('proposer sealed: reads denied under ~/.atris and the held-out folder, no shell, no mcp servers or connectors, temp HOME, no api keys. only the rules file may change');
|
|
529
611
|
if (error) lines.push(`stopped: ${error}`);
|
|
530
612
|
if (proposal) {
|
|
531
|
-
|
|
613
|
+
// The proposer's title and reasons are never written. The change itself
|
|
614
|
+
// is the rules diff on the branch.
|
|
615
|
+
lines.push('', '## the change', '', 'one change to lib/sentinel-rules.json. read the rules diff on the branch', '');
|
|
532
616
|
lines.push(`new seen exam items: ${items ? items.length : 0}`);
|
|
533
617
|
}
|
|
534
618
|
if (before && after) {
|
|
@@ -553,7 +637,7 @@ function renderReceipt({ at, engine, proposal, items, before, after, decision, b
|
|
|
553
637
|
if (decision.path) lines.push(`- kept because: ${decision.path}`);
|
|
554
638
|
for (const g of decision.gains) lines.push(`- gain: ${g}`);
|
|
555
639
|
for (const n of decision.notes || []) lines.push(`- note: ${n}`);
|
|
556
|
-
for (const r of decision.reasons) lines.push(`- stop: ${r}`);
|
|
640
|
+
for (const r of decision.reasons) lines.push(`- stop: ${fixedReason(r)}`);
|
|
557
641
|
}
|
|
558
642
|
lines.push('');
|
|
559
643
|
return lines.join('\n');
|
|
@@ -561,8 +645,9 @@ function renderReceipt({ at, engine, proposal, items, before, after, decision, b
|
|
|
561
645
|
|
|
562
646
|
// ---------------------------------------------------------------- git
|
|
563
647
|
|
|
564
|
-
function git(cwd, args, { check = true, env } = {}) {
|
|
565
|
-
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...(env ? { env } : {}) });
|
|
648
|
+
function git(cwd, args, { check = true, env, timeoutMs } = {}) {
|
|
649
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...(env ? { env } : {}), ...(timeoutMs ? { timeout: Math.max(1, Math.floor(timeoutMs)) } : {}) });
|
|
650
|
+
if (r.error && r.error.code === 'ETIMEDOUT') throw new Error(`git ${args[0]} ran out of time`);
|
|
566
651
|
if (check && r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr || r.stdout || '').trim().split('\n').slice(-1)[0]}`);
|
|
567
652
|
return r;
|
|
568
653
|
}
|
|
@@ -615,8 +700,9 @@ function foreignFiles(tree) {
|
|
|
615
700
|
}
|
|
616
701
|
|
|
617
702
|
// Files the run itself may leave in a copy besides tracked, committed ones.
|
|
618
|
-
//
|
|
619
|
-
|
|
703
|
+
// The candidate and the receipt draft live outside the copy. Running the exam
|
|
704
|
+
// records the engine choice in the copy's own state file, which is ours.
|
|
705
|
+
const KNOWN_OUTPUT = new Set(['.atris/state/engines.json']);
|
|
620
706
|
|
|
621
707
|
// Live processes this run started. A run is alive by definition. For its
|
|
622
708
|
// own copy the check is that every child it spawned has exited. Fail-closed:
|
|
@@ -675,7 +761,7 @@ function sweepStale(root, env) {
|
|
|
675
761
|
for (const block of list.split('\n\n')) {
|
|
676
762
|
const tree = (block.match(/^worktree (.+)$/m) || [])[1];
|
|
677
763
|
const branch = ((block.match(/^branch refs\/heads\/(.+)$/m) || [])[1]) || '';
|
|
678
|
-
if (!tree || !/^sentinel-improve\//.test(branch) || !/atris-sentinel-improve-/.test(path.basename(tree))) continue;
|
|
764
|
+
if (!tree || !/^sentinel-(?:improve|exam)\//.test(branch) || !/atris-sentinel-improve-/.test(path.basename(tree))) continue;
|
|
679
765
|
const rec = records.get(path.resolve(tree)) || [...records.values()].find((r) => fs.existsSync(r.tree) && fs.existsSync(tree) && fs.realpathSync(r.tree) === fs.realpathSync(tree));
|
|
680
766
|
const keep = (reason) => kept.push({ tree, branch, reason });
|
|
681
767
|
if (!rec) { keep('no record says which run owns it'); continue; }
|
|
@@ -698,6 +784,7 @@ async function improve(opts = {}) {
|
|
|
698
784
|
if (!fs.existsSync(path.join(root, RULES_PATH))) throw new Error(`${root} is not an atris checkout with the sentinel rules in it`);
|
|
699
785
|
const refused = refusedEngine(engine);
|
|
700
786
|
if (refused) throw new Error(refused);
|
|
787
|
+
if (opts.focus && !FOCI.includes(opts.focus)) throw new Error(`unknown focus: ${opts.focus}. use ${FOCI.join(', ')}`);
|
|
701
788
|
const judge = opts.judge || ((args) => runJudge({ ...args, env, atrisBin: opts.atrisBin }));
|
|
702
789
|
const propose = opts.propose || ((args) => askEngine({ ...args, env }));
|
|
703
790
|
const at = now.toISOString();
|
|
@@ -734,13 +821,16 @@ async function improve(opts = {}) {
|
|
|
734
821
|
log('running the exam on the current code');
|
|
735
822
|
const current = judge({ target: tree, seenDir });
|
|
736
823
|
log(`asking ${engine} for one change`);
|
|
737
|
-
const answer = await propose({ engine, prompt: buildPrompt({ report: current, signals, seenDirRel: seenRel }), cwd: tree, sealed: [path.join(homeDir(env), '.atris'), current.holdout_dir, ...(current.sealed_sources || [])] });
|
|
824
|
+
const answer = await propose({ engine, prompt: buildPrompt({ report: current, signals, seenDirRel: seenRel, focus: opts.focus || null }), cwd: tree, sealed: [path.join(homeDir(env), '.atris'), current.holdout_dir, ...(current.sealed_sources || [])] });
|
|
738
825
|
proposal = answer && typeof answer === 'object' && (Array.isArray(answer.edits) || answer.rules) ? answer : extractJsonObject(answer && answer.stdout);
|
|
739
826
|
const bad = checkProposal(proposal, tree);
|
|
740
827
|
if (bad) throw new Error(bad);
|
|
741
828
|
const strayEarly = strayChanges(tree);
|
|
742
829
|
if (strayEarly.length) throw new Error(`the proposer changed files it may not touch: ${strayEarly.slice(0, 3).join(', ')}`);
|
|
830
|
+
if (Array.isArray(proposal.new_items) && !gateValue(proposal.new_items)) throw new Error(SECRET_ITEM_ERROR);
|
|
831
|
+
if (!gateValue(proposal)) throw new Error(SECRET_PROPOSAL_ERROR);
|
|
743
832
|
items = cleanItems(proposal, `imp-${id.slice(0, 8)}`);
|
|
833
|
+
if (items.some((x) => !gateValue(x.item))) throw new Error(SECRET_ITEM_ERROR);
|
|
744
834
|
fs.mkdirSync(candSeen, { recursive: true });
|
|
745
835
|
for (const set of ['guard', 'agents']) {
|
|
746
836
|
const f = path.join(seenDir, `${set}.jsonl`);
|
|
@@ -770,14 +860,14 @@ async function improve(opts = {}) {
|
|
|
770
860
|
const outside = strayChanges(tree, new Set([...ALLOWED_CHANGES, rel]));
|
|
771
861
|
if (outside.length) throw new Error(`the change touches files other than the rules and the seen items: ${outside.slice(0, 3).join(', ')}`);
|
|
772
862
|
git(tree, ['add', RULES_PATH, seenRel]);
|
|
773
|
-
const msg = `Sentinel improve:
|
|
863
|
+
const msg = `Sentinel improve: one guard rules change\n\nKept by the sentinel bench. Receipt: ${rel}\n\nCo-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>`;
|
|
774
864
|
git(tree, ['commit', '-q', '-m', msg], { env });
|
|
775
865
|
keepBranch = true;
|
|
776
866
|
out.branch = branch;
|
|
777
867
|
out.kept = true;
|
|
778
868
|
}
|
|
779
869
|
} catch (e) {
|
|
780
|
-
error =
|
|
870
|
+
error = fixedError(e);
|
|
781
871
|
} finally {
|
|
782
872
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
783
873
|
// Kept, rejected or failed: the same safe check decides if the copy goes.
|
|
@@ -807,6 +897,31 @@ function schedulePlan({ root, engine = 'claude', everyHours = 24, env = process.
|
|
|
807
897
|
}
|
|
808
898
|
|
|
809
899
|
module.exports = {
|
|
900
|
+
SECRET_ITEM_ERROR,
|
|
901
|
+
SECRET_PROPOSAL_ERROR,
|
|
902
|
+
fixedError,
|
|
903
|
+
fixedReason,
|
|
904
|
+
gateValue,
|
|
905
|
+
strictSecret,
|
|
906
|
+
FOCI,
|
|
907
|
+
RULES_PATH,
|
|
908
|
+
SEEN_REL,
|
|
909
|
+
appendItems,
|
|
910
|
+
applyRules,
|
|
911
|
+
askEngine,
|
|
912
|
+
cleanItems,
|
|
913
|
+
dropCopy,
|
|
914
|
+
git,
|
|
915
|
+
homeDir,
|
|
916
|
+
missCounts,
|
|
917
|
+
okMap,
|
|
918
|
+
pgidOf,
|
|
919
|
+
repoTop,
|
|
920
|
+
runAlive,
|
|
921
|
+
runsDir,
|
|
922
|
+
stamp,
|
|
923
|
+
strayChanges,
|
|
924
|
+
totalsLine,
|
|
810
925
|
buildPrompt,
|
|
811
926
|
checkProposal,
|
|
812
927
|
engineEnv,
|