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.
@@ -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: 10 * 60 * 1000 });
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
- function missesForPrompt(report) {
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
- function buildPrompt({ report, signals, seenDirRel }) {
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
- 'Seen items the current code gets wrong:',
321
- ...(missesForPrompt(report).map((l) => ` ${l}`)),
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
- const list = items.filter((x) => x.set === set).map((x) => x.item);
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
- lines.push('', '## the change', '', `${String(proposal.title || 'untitled').slice(0, 300)}`, '', `${String(proposal.why || '').slice(0, 600)}`, '', `file: ${proposal.file}`);
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
- // Today nothing: the candidate and the receipt draft live outside the copy.
619
- const KNOWN_OUTPUT = new Set();
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: ${String(proposal.title || 'one guard change').replace(/\s+/g, ' ').slice(0, 72)}\n\nKept by the sentinel bench. Receipt: ${rel}\n\nCo-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>`;
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 = String(e.message || e).split('\n')[0].slice(0, 300);
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,