atris 3.82.2 → 3.82.3

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.
@@ -123,10 +123,10 @@ function applyBaselineOptions(root, rawFindings, opts) {
123
123
 
124
124
  function securityReviewCommand(argv = []) {
125
125
  const sub = argv[0];
126
+ if (sub === 'audit') return auditCommand(argv.slice(1));
126
127
  if (sub === 'help' || argv.includes('-h') || argv.includes('--help')) return printHelp();
127
128
  if (sub === 'rules') return printRules();
128
129
  if (sub === 'hook' || sub === 'install-hook') return installHook();
129
- if (sub === 'audit') return auditCommand(argv.slice(1));
130
130
 
131
131
  const root = process.cwd();
132
132
  let opts;
@@ -402,7 +402,7 @@ function installHook() {
402
402
  }
403
403
 
404
404
  function parseAuditArgs(argv) {
405
- const opts = { profile: 'standard', scope: [], json: false, planOnly: false, budget: null, out: null, hunter: null, verifier: null, timeoutMs: null };
405
+ const opts = { profile: 'standard', scope: [], json: false, planOnly: false, budget: null, out: null, hunter: null, verifier: null, timeoutMs: null, engine: null, yes: false, upload: true, scheduled: false };
406
406
  const takes = (i, flag) => {
407
407
  if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`${flag} needs a value`);
408
408
  return argv[i + 1];
@@ -420,6 +420,10 @@ function parseAuditArgs(argv) {
420
420
  else if (flag === '--hunter') opts.hunter = value();
421
421
  else if (flag === '--verifier') opts.verifier = value();
422
422
  else if (flag === '--timeout') opts.timeoutMs = Number(value()) * 1000;
423
+ else if (flag === '--engine') opts.engine = value();
424
+ else if (flag === '--yes' || flag === '-y') opts.yes = true;
425
+ else if (flag === '--no-upload') opts.upload = false;
426
+ else if (flag === '--scheduled') opts.scheduled = true;
423
427
  else if (arg.startsWith('-')) throw new Error(`unknown flag: ${arg}`);
424
428
  else opts.scope.push(arg);
425
429
  }
@@ -427,6 +431,7 @@ function parseAuditArgs(argv) {
427
431
  if (!PROFILES.includes(opts.profile)) throw new Error(`--profile must be one of ${PROFILES.join(', ')}`);
428
432
  if (opts.budget !== null && (!Number.isInteger(opts.budget) || opts.budget < 1 || opts.budget > 500)) throw new Error('--budget must be a whole number from 1 to 500');
429
433
  if (opts.timeoutMs !== null && (!Number.isFinite(opts.timeoutMs) || opts.timeoutMs < 10000)) throw new Error('--timeout is in seconds, at least 10');
434
+ if (opts.engine !== null && !['atris', 'local'].includes(opts.engine)) throw new Error('--engine must be atris or local');
430
435
  return opts;
431
436
  }
432
437
 
@@ -454,7 +459,7 @@ function renderAuditLanding({ dir, meta, records }) {
454
459
  } else {
455
460
  L.push(` ${confirmed.length} confirmed${sevText ? ` (${sevText})` : ''} · ${needs.length} need a fact from you · ${rejected.length} disproved`);
456
461
  L.push(` read ${cov.fresh} areas this run, ${cov.carried} carried unchanged, ${cov.deferred || 0} left for the next run`);
457
- L.push(` found by ${meta.engines.hunter}, checked by ${meta.engines.verifier}${meta.engines.hunter === meta.engines.verifier ? ' (same engine)' : ''} · ${meta.invocations.used} engine calls of ${meta.budget}`);
462
+ L.push(` found by ${meta.engines.hunter}, checked by ${meta.engines.verifier}${meta.engines.hunter === meta.engines.verifier ? ' (same engine)' : ''} · ${meta.invocations.used} engine calls of ${meta.budget}${meta.credits_used ? ` · ${meta.credits_used} credits used` : ''}`);
458
463
  for (const r of confirmed.slice(0, 6)) {
459
464
  const sink = r.trace[r.trace.length - 1];
460
465
  L.push(` ${r.severity.overall.padEnd(8)} ${r.title} (${sink.file}:${sink.line})`);
@@ -465,33 +470,204 @@ function renderAuditLanding({ dir, meta, records }) {
465
470
  return L.join('\n');
466
471
  }
467
472
 
468
- async function auditCommand(argv) {
473
+ // Ask once on a terminal. Anything but y or yes is a no.
474
+ function askYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
475
+ return new Promise((resolve) => {
476
+ const rl = require('readline').createInterface({ input, output });
477
+ rl.question(question, (answer) => { rl.close(); resolve(/^\s*y(es)?\s*$/i.test(String(answer || ''))); });
478
+ });
479
+ }
480
+
481
+ async function uploadAfterRun(dir, { token, quiet, cloud }) {
482
+ const res = await cloud.uploadRun(dir, { token });
483
+ const say = quiet ? (m) => console.error(m) : (m) => console.log(m);
484
+ if (res.ok) say(' saved the summary to your atris account (no source code was sent)');
485
+ else say(` warning: could not save the summary to your atris account: ${res.reason}. the review itself is done`);
486
+ return res;
487
+ }
488
+
489
+ async function auditUploadCommand(argv, deps) {
490
+ const audit = require('../lib/security-audit');
491
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
492
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
493
+ if (!token) { console.error(' log in first with atris login. then run atris sentinel upload again'); return 2; }
494
+ const arg = argv.find((a) => !a.startsWith('-'));
495
+ let dir = arg ? path.resolve(arg) : null;
496
+ if (!dir) {
497
+ const last = audit.latestRun(deps.root || process.cwd());
498
+ if (!last) { console.error(' no sentinel review has run for this repo yet'); return 2; }
499
+ dir = last.dir;
500
+ }
501
+ if (!fs.existsSync(path.join(dir, 'run-metadata.json'))) { console.error(` ${tildePath(dir)} is not a sentinel run folder`); return 2; }
502
+ const res = await cloud.uploadRun(dir, { token });
503
+ if (!res.ok) { console.error(` could not save ${path.basename(dir)}: ${res.reason}`); return 1; }
504
+ console.log(` saved ${path.basename(dir)} to your atris account (no source code was sent)`);
505
+ return 0;
506
+ }
507
+
508
+ // Price one quick run on atris models for a folder without calling any
509
+ // model: the audit plans, the estimate is quoted, and the plan is dropped.
510
+ async function quoteQuickRun(target, { hunter, verifier, token, cloud }) {
511
+ const audit = require('../lib/security-audit');
512
+ let estimate = null;
513
+ await audit.runAudit({
514
+ root: target, profile: 'quick', hunter, verifier,
515
+ runJobs: async () => { throw new Error('no model call while pricing a schedule'); },
516
+ confirmSpend: async (est) => { estimate = est; return false; },
517
+ });
518
+ if (!estimate) throw new Error('could not plan a review for this folder');
519
+ return cloud.quoteEstimate(estimate, { token });
520
+ }
521
+
522
+ async function auditScheduleCommand(argv, deps) {
523
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
524
+ const { installKeep, removeKeep } = require('../lib/campaign-keep');
525
+ const uninstall = argv.includes('--uninstall');
526
+ let every = 'day';
527
+ let target = null;
528
+ let engine = null;
529
+ let maxCredits = null;
530
+ let yes = false;
531
+ for (let i = 0; i < argv.length; i++) {
532
+ const a = argv[i];
533
+ const [flag, inline] = a.startsWith('--') && a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, null];
534
+ const value = () => (inline !== null ? inline : argv[++i]);
535
+ if (flag === '--every') every = value();
536
+ else if (flag === '--engine') engine = value();
537
+ else if (flag === '--max-credits') maxCredits = Number(value());
538
+ else if (flag === '--yes' || flag === '-y') yes = true;
539
+ else if (flag === '--uninstall' || flag === '--install') continue;
540
+ else if (a.startsWith('-')) { console.error(` unknown flag: ${a}`); return 2; }
541
+ else target = a;
542
+ }
543
+ if (!cloud.SCHEDULE_EVERY[every]) { console.error(' use --every day or --every week'); return 2; }
544
+ if (engine !== null && engine !== 'atris' && engine !== 'local') { console.error(' --engine must be atris or local'); return 2; }
545
+ if (maxCredits !== null && (!Number.isInteger(maxCredits) || maxCredits < 1)) { console.error(' --max-credits must be a whole number of credits, at least 1'); return 2; }
546
+ target = path.resolve(target || deps.root || process.cwd());
547
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { console.error(` ${target} is not a folder`); return 2; }
548
+ const atrisBin = deps.atrisBin || path.resolve(__dirname, '..', 'bin', 'atris.js');
549
+ const plan = cloud.schedulePlan({ target, every, homeDir: deps.homeDir, platform: deps.platform, atrisBin, envPath: deps.envPath });
550
+ const keepDeps = deps.spawn ? { spawn: deps.spawn } : {};
551
+ if (uninstall) {
552
+ // Same folder makes the same timer name, so --every does not matter here.
553
+ const removed = removeKeep(plan, keepDeps);
554
+ if (!removed.ok) { console.error(` could not remove the timer: ${removed.error}`); return 1; }
555
+ cloud.removeApproval(target, deps.homeDir);
556
+ console.log(` scheduled reviews ${removed.removed === false ? 'were not on' : 'are off'} for ${tildePath(target)}`);
557
+ return 0;
558
+ }
559
+ const space = require('../lib/worktree-space');
560
+ if (!deps.atrisBin && (space.isAgentCopyPath(atrisBin) || space.isScratchPath(atrisBin))) {
561
+ console.error(` refusing: this atris runs from ${path.dirname(path.dirname(atrisBin))}, a folder that gets removed. install the timer from a lasting atris install`);
562
+ return 2;
563
+ }
564
+
565
+ // The engine is decided now, with the owner here, and written down. A
566
+ // scheduled run never picks again.
567
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
568
+ let engines;
569
+ try { engines = cloud.resolveEngines({ engine, token, env: deps.env || process.env }); } catch (e) { console.error(` ${e.message}`); return 2; }
570
+ let approval;
571
+ if (engines.mode === 'atris') {
572
+ let quote;
573
+ try { quote = await quoteQuickRun(target, { hunter: engines.hunter, verifier: engines.verifier, token, cloud }); } catch (e) { console.error(` no timer was set. ${e.message}`); return 2; }
574
+ const cap = maxCredits || quote.credits;
575
+ console.log(`\n each scheduled review runs on atris models (no codex or claude needed)`);
576
+ console.log(` ${cloud.describeQuote(quote)}`);
577
+ console.log(` each run may spend at most ${cap} credits, once a ${every}. a run that reaches that stops\n`);
578
+ if (!yes) {
579
+ const input = deps.input || process.stdin;
580
+ if (!input.isTTY && !deps.input) { console.error(' no timer was set. add --yes to approve this spend without the question'); return 1; }
581
+ const ok = await (deps.ask || askYesNo)(` approve up to ${cap} credits per scheduled run? (y/N) `, { input, output: process.stdout });
582
+ if (!ok) { console.log(' no timer was set'); return 1; }
583
+ }
584
+ approval = { engine: 'atris', hunter: engines.hunter, verifier: engines.verifier, max_credits: cap, quoted_credits: quote.credits, every, approved_at: new Date().toISOString() };
585
+ } else {
586
+ approval = { engine: 'local', every, approved_at: new Date().toISOString() };
587
+ }
588
+ cloud.writeApproval(target, approval, deps.homeDir);
589
+ const installed = installKeep(plan, keepDeps);
590
+ if (!installed.ok) { cloud.removeApproval(target, deps.homeDir); console.error(` could not install the timer: ${installed.error}`); return 1; }
591
+ console.log(` scheduled reviews are on for ${tildePath(target)}, once a ${every}`);
592
+ console.log(' each run uses the quick profile and saves its summary to your atris account');
593
+ if (approval.engine === 'local') console.log(' runs use only codex and claude on this machine. they never switch to atris models');
594
+ else console.log(` runs use atris models and stop at ${approval.max_credits} credits`);
595
+ console.log(` log: ${tildePath(plan.log_path)}`);
596
+ console.log(` stop with: atris sentinel schedule --uninstall ${tildePath(target)}`);
597
+ return 0;
598
+ }
599
+
600
+ async function auditCommand(argv, deps = {}) {
469
601
  if (argv[0] === 'help' || argv.includes('-h') || argv.includes('--help')) return printAuditHelp();
470
602
  const audit = require('../lib/security-audit');
603
+ const root = deps.root || process.cwd();
471
604
  if (argv[0] === 'status' || argv[0] === 'last') {
472
- const last = audit.latestRun(process.cwd());
605
+ const last = audit.latestRun(root);
473
606
  if (!last || !last.meta) { console.log('\n no sentinel audit has run for this repo yet\n'); return 0; }
474
607
  if (argv.includes('--json')) { console.log(JSON.stringify({ dir: last.dir, meta: last.meta, findings: last.records }, null, 2)); return 0; }
475
608
  console.log(renderAuditLanding({ dir: last.dir, meta: last.meta, records: last.records }));
476
609
  return 0;
477
610
  }
611
+ if (argv[0] === 'upload') return auditUploadCommand(argv.slice(1), deps);
612
+ if (argv[0] === 'schedule') return auditScheduleCommand(argv.slice(1), deps);
478
613
  let opts;
479
614
  try { opts = parseAuditArgs(argv); } catch (e) { console.error(`security-review audit: ${e.message}`); return 2; }
615
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
616
+ // A timer run does only what the owner approved at install.
617
+ let capOverride = null;
618
+ if (opts.scheduled) {
619
+ const approval = cloud.readApproval(root, deps.homeDir);
620
+ if (!approval) { console.error('security-review audit: this folder has no approved schedule. nothing ran. run atris sentinel schedule again'); return 2; }
621
+ opts.hunter = null;
622
+ opts.verifier = null;
623
+ opts.engine = approval.engine;
624
+ if (approval.engine === 'atris') { opts.yes = true; capOverride = Number(approval.max_credits); }
625
+ }
626
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
627
+ let engines;
628
+ try { engines = cloud.resolveEngines({ engine: opts.engine, hunter: opts.hunter, verifier: opts.verifier, token, env: deps.env || process.env }); } catch (e) { console.error(`security-review audit: ${e.message}`); return 2; }
629
+ const hunter = engines.hunter || undefined;
630
+ const verifier = engines.verifier || undefined;
631
+ const hosted = audit.isHostedEngine(hunter) || audit.isHostedEngine(verifier);
632
+ if (hosted && !token) { console.error('security-review audit: reviews on atris models need a login; run atris login first'); return 2; }
480
633
  const log = opts.json ? () => {} : (msg) => console.log(` · ${msg}`);
634
+ const tell = opts.json ? (m) => console.error(m) : (m) => console.log(m);
635
+ if (hosted && engines.chosen === 'no_local_engine' && !opts.planOnly) tell(' no codex or claude found on this machine. this review runs on atris models');
636
+ let runJobs = deps.runJobs;
637
+ const spend = hosted ? cloud.spendGuard({ quoteCall: deps.quoteCall }) : null;
638
+ if (!runJobs && hosted) runJobs = cloud.atrisJobRunner({ token, spend });
639
+ const confirmSpend = async (estimate) => {
640
+ const quote = await cloud.quoteEstimate(estimate, { token });
641
+ spend.max = capOverride || quote.credits;
642
+ spend.limits = quote.limits;
643
+ tell(`\n ${cloud.describeQuote(quote)}\n`);
644
+ if (capOverride) tell(` this scheduled run may spend at most ${capOverride} credits, as approved`);
645
+ if (opts.yes) return true;
646
+ const input = deps.input || process.stdin;
647
+ if (!input.isTTY && !deps.input) { console.error(' nothing was spent. add --yes to run without this question'); return false; }
648
+ const yes = await (deps.ask || askYesNo)(' spend these credits? (y/N) ', { input, output: opts.json ? process.stderr : process.stdout });
649
+ if (!yes) tell(' nothing was spent');
650
+ return yes;
651
+ };
481
652
  let result;
482
653
  try {
483
654
  result = await audit.runAudit({
484
- root: process.cwd(), profile: opts.profile, scope: opts.scope, budget: opts.budget ?? undefined, out: opts.out,
485
- hunter: opts.hunter || undefined, verifier: opts.verifier || undefined, planOnly: opts.planOnly,
655
+ root, profile: opts.profile, scope: opts.scope, budget: opts.budget ?? undefined, out: opts.out,
656
+ hunter, verifier, planOnly: opts.planOnly, runJobs, confirmSpend,
486
657
  timeoutMs: opts.timeoutMs || undefined, log,
487
658
  });
488
659
  } catch (e) {
489
660
  console.error(`security-review audit: ${e.message}`);
490
661
  return 2;
491
662
  }
663
+ if (result.declined) return 1;
492
664
  const fail = audit.shouldFailAudit(result.records);
493
665
  if (opts.json) console.log(JSON.stringify({ ok: !fail, dir: result.dir, meta: result.meta, findings: result.records }, null, 2));
494
666
  else console.log(renderAuditLanding(result));
667
+ if (spend && spend.blocked) tell(` stopped at the approved limit of ${spend.max} credits. ${spend.blocked} model call${spend.blocked === 1 ? '' : 's'} did not run and wait for the next run`);
668
+ if (opts.upload && token && !opts.planOnly) {
669
+ try { await uploadAfterRun(result.dir, { token, quiet: opts.json, cloud }); } catch (e) { tell(` warning: could not save the summary to your atris account: ${e.message}. the review itself is done`); }
670
+ }
495
671
  return fail ? 1 : 0;
496
672
  }
497
673
 
@@ -510,6 +686,37 @@ function printAuditHelp() {
510
686
  atris security-review audit --out DIR write the run here (outside the repo)
511
687
  atris security-review audit status the last run's landing
512
688
 
689
+ on atris models (no other AI subscription needed, paid in atris credits):
690
+
691
+ atris sentinel --engine atris atris:fast searches, atris:pro checks
692
+ atris sentinel --engine atris --yes skip the price question
693
+ atris sentinel --engine local only codex and claude on this machine
694
+
695
+ with no --engine, local engines run when codex or claude is installed.
696
+ atris models run only when neither is installed and you are logged in.
697
+ every run on atris models shows the most it can cost first and stops
698
+ before it could pass that. lines that look like secrets are hidden and
699
+ only tracked files inside the review are ever sent.
700
+
701
+ saving results to your atris account (on when you are logged in):
702
+
703
+ atris sentinel --no-upload keep this run on this machine only
704
+ atris sentinel upload [run-folder] save a past run (default: the last one)
705
+
706
+ only the summary is saved: plain sentences, file:line locations and counts.
707
+ source code and code lines never leave in an upload.
708
+
709
+ reviews on a timer:
710
+
711
+ atris sentinel schedule --every day [path] quick review once a day, saved to your account
712
+ atris sentinel schedule --every week [path] once a week
713
+ atris sentinel schedule --engine atris --max-credits 20
714
+ timed runs on atris models, at most 20 credits each
715
+ atris sentinel schedule --uninstall [path] stop the timer
716
+
717
+ the engine is picked when the timer goes on. on atris models you see the
718
+ price and say yes then. a timed run never switches to atris models later.
719
+
513
720
  How it works: every source file x attack class with a reason to be read
514
721
  becomes an area in a coverage ledger. Read-only engine hunters each own a
515
722
  few areas. A coverage critic names gaps. Every candidate goes to a different
@@ -553,4 +760,4 @@ function printHelp() {
553
760
  return 0;
554
761
  }
555
762
 
556
- module.exports = { securityReviewCommand };
763
+ module.exports = { securityReviewCommand, auditCommand };
@@ -35,6 +35,7 @@ function cronSchedule(seconds) {
35
35
  if (minutes === 1) return '* * * * *';
36
36
  if (minutes < 60) return `*/${minutes} * * * *`;
37
37
  const hours = Math.round(minutes / 60);
38
+ if (hours >= 168) return '0 0 * * 0';
38
39
  return hours >= 24 ? '0 0 * * *' : `0 */${hours} * * *`;
39
40
  }
40
41
 
@@ -188,17 +188,20 @@ function loadLocalTaskState(root = process.cwd(), { taskIds = [] } = {}) {
188
188
  const db = taskDb.open();
189
189
  const workspaceRoot = taskDb.workspaceRoot(root);
190
190
  const rows = taskDb.listTasks(db, { workspaceRoot, limit: 200 });
191
+ // The visible window must not become a new numbering universe. Task
192
+ // commands resolve these references against the complete workspace.
193
+ const refRows = taskDb.listTasks(db, { workspaceRoot });
191
194
  // Busy queues must not crowd the last outcomes out of the handoff.
192
195
  const recent = ['done', 'failed'].flatMap((status) => taskDb.listTasks(db, { workspaceRoot, status, limit: 3, orderBy: 'updated_at' }));
193
196
  const seen = new Set(rows.map((row) => row.id));
194
197
  rows.push(...recent.filter((row) => !seen.has(row.id)));
195
- if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows);
198
+ if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows, refRows);
196
199
  databaseRead = true;
197
200
  lookup = (id) => {
198
201
  const row = taskDb.getTask(db, id);
199
202
  // A foreign id in a damaged projection is not this workspace's work.
200
203
  if (row && !taskDb.rowInWorkspace(row, workspaceRoot)) return false;
201
- return row ? taskDb.withTaskDisplayRefs([row])[0] : null;
204
+ return row ? taskDb.withTaskDisplayRefs([row], refRows)[0] : null;
202
205
  };
203
206
  // A shortlist is an output budget, not authority to invent a bound job.
204
207
  for (const id of taskIds.slice(0, 8)) {
@@ -671,6 +671,128 @@ async function defaultRunJobs(jobs, { root, concurrency, timeoutMs }) {
671
671
  return runEngineAskJobs(pinned, { root, concurrency, timeoutMs });
672
672
  }
673
673
 
674
+ // Atris-hosted engines (atris:fast, atris:pro) run on the Atris backend and
675
+ // cannot open files, so their prompts carry the source they need. Secrets
676
+ // are hidden at the one send point in lib/sentinel-cloud.js.
677
+ const HOSTED_ENGINE = /^atris:[a-z0-9._-]+$/i;
678
+ const HOSTED_SOURCE_BYTES = { hunter: 96 * 1024, verifier: 32 * 1024 };
679
+ const HOSTED_CONTEXT_LINES = 40;
680
+ // The output limit sent with each hosted turn (the turn API accepts at most 8192).
681
+ const HOSTED_OUTPUT_TOKENS = { hunter: 8192, verifier: 4096 };
682
+ // Largest message a hosted check (verifier, recheck, critic) may send. The
683
+ // quote prices every check call at this size, so the quote is a ceiling.
684
+ const HOSTED_CHECK_MAX_BYTES = 64 * 1024;
685
+ const ENV_STYLE_FILE = /(?:^|\/)(?:\.env(?:\.[\w.-]+)?|\.envrc|.*\.(?:pem|key|p12|pfx|jks|keystore|crt|cer|der|asc|gpg|kdbx)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|\.(?:npmrc|pypirc|netrc|pgpass|htpasswd)|credentials(?:\.[\w]+)?|secrets?(?:\.[\w]+)?|service[-_]?account[\w.-]*\.json)$/i;
686
+
687
+ function isHostedEngine(engine) {
688
+ return HOSTED_ENGINE.test(String(engine || ''));
689
+ }
690
+
691
+ // A file a hosted engine may ever see: tracked by git, not ignored, inside
692
+ // the requested scope, not a skipped folder, and not a key or .env-style file.
693
+ function sensitiveFile(rel) {
694
+ if (ENV_STYLE_FILE.test(rel)) return true;
695
+ try { return require('./security-scan').sensitiveFileFindings([rel]).length > 0; } catch { return false; }
696
+ }
697
+
698
+ function hostedAllowedFiles(root, scope) {
699
+ let tracked = null;
700
+ try {
701
+ tracked = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }).split('\0').filter(Boolean);
702
+ } catch { return new Set(); }
703
+ let ignored = new Set();
704
+ try {
705
+ const r = require('child_process').spawnSync('git', ['check-ignore', '--no-index', '-z', '--stdin'], { cwd: root, input: tracked.join('\0'), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
706
+ ignored = new Set(String(r.stdout || '').split('\0').filter(Boolean));
707
+ } catch {}
708
+ return new Set(tracked.filter((rel) => isRepoRel(rel) && !skippedPath(rel) && inScope(rel, scope) && !ignored.has(rel) && !sensitiveFile(rel)));
709
+ }
710
+
711
+ // Numbered source for a hosted engine. `ranges` maps a file to line windows;
712
+ // a file with no window is shown whole. Stops at the byte cap. Only files in
713
+ // `allowed` are ever read.
714
+ function hostedSource(root, files, { maxBytes, ranges = new Map(), allowed = null } = {}) {
715
+ const out = [];
716
+ let used = 0;
717
+ const skipped = [];
718
+ for (const rel of [...new Set(files)].filter(isRepoRel).filter((f) => allowed && allowed.has(f))) {
719
+ const text = readText(root, rel);
720
+ if (text === null) continue;
721
+ const lines = text.split('\n');
722
+ const windows = ranges.get(rel);
723
+ const keep = new Set();
724
+ if (windows && windows.length) {
725
+ for (const n of windows) for (let i = Math.max(1, n - HOSTED_CONTEXT_LINES); i <= Math.min(lines.length, n + HOSTED_CONTEXT_LINES); i++) keep.add(i);
726
+ } else {
727
+ for (let i = 1; i <= lines.length; i++) keep.add(i);
728
+ }
729
+ const body = [];
730
+ let last = 0;
731
+ for (const n of [...keep].sort((a, b) => a - b)) {
732
+ if (last && n !== last + 1) body.push(' ...');
733
+ const line = lines[n - 1];
734
+ // Secrets are hidden at the one send point (lib/sentinel-cloud.js
735
+ // redactOutgoing), which sees whole key blocks.
736
+ body.push(`${String(n).padStart(5)} ${line}`);
737
+ last = n;
738
+ }
739
+ const block = `### ${rel}\n\`\`\`\n${body.join('\n')}\n\`\`\``;
740
+ if (used + block.length > maxBytes) { skipped.push(rel); continue; }
741
+ used += block.length;
742
+ out.push(block);
743
+ }
744
+ if (!out.length && !skipped.length) return '';
745
+ return [
746
+ '## Source',
747
+ 'You cannot open files in this session. The source you need is below with line numbers; cite files and lines from it.',
748
+ skipped.length ? `These files were too large to include: ${skipped.join(', ')}. Mark units that depend on them "blocked".` : '',
749
+ ...out,
750
+ ].filter(Boolean).join('\n\n');
751
+ }
752
+
753
+ function traceFiles(trace) {
754
+ const ranges = new Map();
755
+ for (const t of trace || []) {
756
+ if (!t || !isRepoRel(t.file)) continue;
757
+ if (!ranges.has(t.file)) ranges.set(t.file, []);
758
+ if (Number.isInteger(t.line)) ranges.get(t.file).push(t.line);
759
+ }
760
+ return ranges;
761
+ }
762
+
763
+ function withHostedSource(root, engine, prompt, { files = [], trace = null, kind = 'hunter', allowed = null } = {}) {
764
+ if (!isHostedEngine(engine)) return prompt;
765
+ if (kind === 'critic') return `${prompt}\n\nYou cannot open files in this session. Judge gaps from the architecture and ledger above.`;
766
+ const ranges = trace ? traceFiles(trace) : new Map();
767
+ const list = trace ? [...ranges.keys()] : files;
768
+ const src = hostedSource(root, list, { maxBytes: HOSTED_SOURCE_BYTES[kind] || HOSTED_SOURCE_BYTES.verifier, ranges, allowed });
769
+ return src ? `${prompt}\n\n${src}` : prompt;
770
+ }
771
+
772
+ // Units as a hosted hunter may see them: seeded lines only from allowed files.
773
+ function hostedUnits(units, allowed) {
774
+ if (!allowed) return units;
775
+ return units.map((u) => ({ ...u, hot_lines: (u.hot_lines || []).filter((h) => allowed.has(h.file)) }));
776
+ }
777
+
778
+ // A trace as a hosted engine may see it: code lines only from allowed files.
779
+ function hostedTrace(trace, allowed) {
780
+ if (!allowed) return trace;
781
+ return (trace || []).map((t) => {
782
+ if (!t || allowed.has(t.file)) return t;
783
+ const out = { ...t };
784
+ delete out.code;
785
+ return out;
786
+ });
787
+ }
788
+
789
+ // Titles and fix summaries are the only finding text that leaves the
790
+ // machine (the account dashboard). Ask for plain words up front; the upload
791
+ // filter replaces anything else.
792
+ const PLAIN_TITLE_HINT = 'one plain sentence saying what goes wrong, with no code, identifiers, file contents or secrets';
793
+ const PLAIN_FIX_HINT = 'one plain sentence describing the change in words, with no code, identifiers, file contents or secrets, for example "Pass the command and its arguments separately instead of building one shell string"';
794
+ const PLAIN_TEXT_RULE = `Write "title" as ${PLAIN_TITLE_HINT}. Write remediation "summary" as ${PLAIN_FIX_HINT}. Keep code, names and lines in remediation "strategy", which stays in the local report.`;
795
+
674
796
  function bullet(lines) {
675
797
  return lines.map((l) => `- ${l}`).join('\n');
676
798
  }
@@ -692,13 +814,13 @@ function hunterPrompt({ architecture, units, exclusions, peerIds }) {
692
814
  const contract = {
693
815
  units: [{ coverage_id: 'one assigned id', disposition: 'covered|candidate|blocked', reviewed_paths: ['repo/relative/path'], checks: [{ invariant: 'the control you checked', method: 'source', result: 'what the source established', reviewed_paths: ['repo/relative/path'] }], candidate_fingerprints: [], unresolved: [] }],
694
816
  candidates: [{
695
- proposed_verdict: 'confirmed|needs_validation', fingerprint: 'path:scope/root-cause-name', title: '', description: '', attack_class: classes[0].id,
817
+ proposed_verdict: 'confirmed|needs_validation', fingerprint: 'path:scope/root-cause-name', title: PLAIN_TITLE_HINT, description: '', attack_class: classes[0].id,
696
818
  coverage_ids: [], attacker: 'the lower-trust principal and what they start with', root_cause: '(confirmed) the source defect', claimed_root_cause: '(needs_validation) the suspected defect',
697
819
  intended_behavior: '', impact: 'the concrete result across the boundary',
698
820
  trace: [{ kind: 'entrypoint|propagation|sink', file: 'repo/relative/path', line: 1, description: '' }],
699
821
  conditions: [{ kind: CONDITION_KINDS.join('|'), description: '' }],
700
822
  severity: { likelihood: { score: SCORES.join('|'), reason: '' }, impact: { score: SCORES.join('|'), reason: '' }, overall: SCORES.join('|') },
701
- remediation: { strategy: 'the smallest source change at the last trusted decision point', regression_test: 'the test that would fail today' },
823
+ remediation: { strategy: 'the smallest source change at the last trusted decision point', summary: PLAIN_FIX_HINT, regression_test: 'the test that would fail today' },
702
824
  blockers: ['(needs_validation) the exact missing fact'], validation_plan: [{ context: 'local|deployment', step: '' }],
703
825
  }],
704
826
  hardening: ['a concrete note that is not a finding'],
@@ -726,15 +848,16 @@ function hunterPrompt({ architecture, units, exclusions, peerIds }) {
726
848
  exclusions.length ? `## Already on record, do not report again\n${bullet(exclusions.slice(0, 20).map((e) => `${e.fingerprint}: ${e.title} (${e.why})`))}\n` : '',
727
849
  peerIds ? `Other hunters own ${peerIds} other units this wave. Stay inside your assigned paths; put anything else in "uncovered".\n` : '',
728
850
  '## Result contract',
851
+ PLAIN_TEXT_RULE,
729
852
  'Every assigned coverage_id appears exactly once in "units". "covered" means you read the paths and the invariant holds. "candidate" lists the fingerprints of the candidates you return. "blocked" means you read the code but one fact you cannot see decides it; say which in "unresolved". Every check lists the paths it read.',
730
853
  'Return only this JSON object, no prose before or after it:',
731
854
  JSON.stringify(contract, null, 2),
732
855
  ].join('\n');
733
856
  }
734
857
 
735
- function withCode(root, trace) {
858
+ function withCode(root, trace, allowed = null) {
736
859
  return (trace || []).map((t) => {
737
- const text = t && isRepoRel(t.file) ? readText(root, t.file) : null;
860
+ const text = t && isRepoRel(t.file) && (!allowed || allowed.has(t.file)) ? readText(root, t.file) : null;
738
861
  const line = text && Number.isInteger(t.line) ? text.split('\n')[t.line - 1] : undefined;
739
862
  return line === undefined ? t : { ...t, code: line.trim().slice(0, 200) };
740
863
  });
@@ -747,7 +870,7 @@ function verifierPrompt({ candidate, cls }) {
747
870
  verdict: 'confirmed|needs_validation|rejected',
748
871
  reason: 'what you read and why it holds or fails',
749
872
  preventing_layer: 'file:line of the control that stops it, or null',
750
- corrected: { title: '', root_cause: '', attacker: '', impact: '', trace: [{ kind: 'entrypoint|propagation|sink', file: '', line: 1, description: '' }], conditions: [], severity: { likelihood: { score: '', reason: '' }, impact: { score: '', reason: '' }, overall: '' }, remediation: { strategy: '', regression_test: '' } },
873
+ corrected: { title: '', root_cause: '', attacker: '', impact: '', trace: [{ kind: 'entrypoint|propagation|sink', file: '', line: 1, description: '' }], conditions: [], severity: { likelihood: { score: '', reason: '' }, impact: { score: '', reason: '' }, overall: '' }, remediation: { strategy: '', summary: '', regression_test: '' } },
751
874
  blockers: ['(needs_validation) the exact missing fact'],
752
875
  validation_plan: [{ context: 'local|deployment', step: '' }],
753
876
  };
@@ -764,6 +887,7 @@ function verifierPrompt({ candidate, cls }) {
764
887
  'A library version or behavior is not outside source when the target has it installed. Read the lockfile, the venv or .venv site-packages, or node_modules before sending it to the owner.',
765
888
  '"rejected": the source disproves it. Name the line that does.',
766
889
  'Use "corrected" only for fields you would change. Read-only: do not run the target or touch the network.',
890
+ `If the title or remediation summary has code, names or file contents, rewrite it in "corrected". ${PLAIN_TEXT_RULE}`,
767
891
  ]),
768
892
  '',
769
893
  `## Attack class: ${cls.title}`,
@@ -791,6 +915,7 @@ function recheckPrompt({ record }) {
791
915
  bullet([
792
916
  'Open each cited file:line. Every sentence in the record must match what the code does now.',
793
917
  'Check the severity against the anchors. Overall can never exceed impact.',
918
+ `If the title or remediation summary has code, names or file contents, return it rewritten in "corrected". ${PLAIN_TEXT_RULE}`,
794
919
  'If the record is right, return {"ok": true}.',
795
920
  'If a detail is wrong but the problem is real, return {"ok": false, "verdict": "confirmed", "corrected": {...only the fields to change}}.',
796
921
  'If the problem is not real after all, return {"ok": false, "verdict": "rejected", "reason": "..."} or {"ok": false, "verdict": "needs_validation", "blockers": [...], "validation_plan": [{"context": "local|deployment", "step": "..."}]}.',
@@ -1007,7 +1132,7 @@ function buildRecord(root, cand, verdict, extra) {
1007
1132
  trace,
1008
1133
  conditions: (cand.conditions || []).filter((c) => c && CONDITION_KINDS.includes(c.kind) && nonEmptyString(c.description)).map((c) => ({ kind: c.kind, description: str(c.description, 400) })),
1009
1134
  severity: clampSeverity(cand.severity),
1010
- remediation: { strategy: str(cand.remediation && cand.remediation.strategy), regression_test: str(cand.remediation && cand.remediation.regression_test) },
1135
+ remediation: { strategy: str(cand.remediation && cand.remediation.strategy), summary: str(cand.remediation && cand.remediation.summary, 400), regression_test: str(cand.remediation && cand.remediation.regression_test) },
1011
1136
  evidence_level: 'source-traced, not executed',
1012
1137
  verified_by: extra.by ? [extra.by] : [],
1013
1138
  };
@@ -1021,7 +1146,8 @@ function mergeCorrected(cand, corrected) {
1021
1146
  if (Array.isArray(corrected.conditions) && corrected.conditions.length) out.conditions = corrected.conditions;
1022
1147
  const s = corrected.severity;
1023
1148
  if (s && s.impact && SCORES.includes(s.impact.score) && SCORES.includes(s.overall) && s.likelihood && SCORES.includes(s.likelihood.score)) out.severity = s;
1024
- if (corrected.remediation && nonEmptyString(corrected.remediation.strategy)) out.remediation = corrected.remediation;
1149
+ if (corrected.remediation && nonEmptyString(corrected.remediation.strategy)) out.remediation = { ...(cand.remediation || {}), ...corrected.remediation };
1150
+ else if (corrected.remediation && nonEmptyString(corrected.remediation.summary)) out.remediation = { ...(cand.remediation || {}), summary: corrected.remediation.summary };
1025
1151
  return out;
1026
1152
  }
1027
1153
 
@@ -1209,6 +1335,23 @@ function pickBatches(ledger, profile, allowance) {
1209
1335
  return batches;
1210
1336
  }
1211
1337
 
1338
+ // The most a hosted run can send: every budgeted call, each at its largest
1339
+ // allowed message and its full output limit. Hunter messages are measured
1340
+ // from this run's real first-wave batches; the largest one becomes the hunter
1341
+ // limit for the whole run. One token per byte is a true ceiling: a token is
1342
+ // never shorter than a byte.
1343
+ function spendEstimate({ root, architecture, ledger, profile, plan, exclusions, hunter, verifier, allowed }) {
1344
+ const batches = pickBatches(ledger, profile, plan.hunters);
1345
+ const sizes = batches.map((units) => Buffer.byteLength(withHostedSource(root, hunter, hunterPrompt({ architecture, units: hostedUnits(units, allowed), exclusions, peerIds: plan.hunters }), { files: units.flatMap((u) => u.starting_paths), kind: 'hunter', allowed })));
1346
+ const hunterMax = Math.max(4096, ...sizes);
1347
+ const criticBytes = Buffer.byteLength(criticPrompt({ architecture, units: ledger, final: true })) + 1024;
1348
+ const checkMax = Math.max(HOSTED_CHECK_MAX_BYTES, criticBytes);
1349
+ const rows = [];
1350
+ if (isHostedEngine(hunter)) rows.push({ role: 'hunter', model: hunter, calls: plan.hunters, input_tokens: hunterMax, output_tokens: HOSTED_OUTPUT_TOKENS.hunter, max_input_bytes: hunterMax });
1351
+ if (isHostedEngine(verifier)) rows.push({ role: 'verifier', model: verifier, calls: plan.total - plan.hunters, input_tokens: checkMax, output_tokens: HOSTED_OUTPUT_TOKENS.verifier, max_input_bytes: checkMax });
1352
+ return { profile, budget: plan.total, calls: rows };
1353
+ }
1354
+
1212
1355
  async function runAudit(opts = {}) {
1213
1356
  const root = path.resolve(opts.root || process.cwd());
1214
1357
  const profile = PROFILES.includes(opts.profile) ? opts.profile : 'standard';
@@ -1253,8 +1396,15 @@ async function runAudit(opts = {}) {
1253
1396
  started_at: now().toISOString(),
1254
1397
  run_status: 'in_progress',
1255
1398
  invocations: { used: 0, hunters: 0, critics: 0, verifiers: 0, rechecks: 0 },
1399
+ credits_used: 0,
1256
1400
  method: 'adapted from cloudflare/security-audit-skill (MIT)',
1257
1401
  };
1402
+ // What a hosted engine may ever be shown (null for local engines, which
1403
+ // read the checkout themselves).
1404
+ const hostedAny = isHostedEngine(hunter) || isHostedEngine(verifier);
1405
+ const allowed = hostedAny ? hostedAllowedFiles(root, scope) : null;
1406
+ const hunterAllowed = isHostedEngine(hunter) ? allowed : null;
1407
+ const verifierAllowed = isHostedEngine(verifier) ? allowed : null;
1258
1408
  const ledgerFile = path.join(dir, 'coverage-ledger.json');
1259
1409
  const saveLedger = () => {
1260
1410
  ledger.sort(cmpId);
@@ -1302,12 +1452,29 @@ async function runAudit(opts = {}) {
1302
1452
  return finish('incomplete', 'budget_cannot_fund_hunters_and_reserves', [], carried.map((r) => r.fingerprint));
1303
1453
  }
1304
1454
 
1455
+ // Hosted engines spend credits: the caller sees an upper-bound estimate and
1456
+ // says yes before the first call. A no leaves nothing behind.
1457
+ if (opts.confirmSpend && hostedAny) {
1458
+ const estimate = spendEstimate({ root, architecture, ledger, profile, plan, exclusions, hunter, verifier, allowed });
1459
+ let ok = false;
1460
+ try { ok = await opts.confirmSpend(estimate); } catch (e) { fs.rmSync(dir, { recursive: true, force: true }); throw e; }
1461
+ if (!ok) {
1462
+ fs.rmSync(dir, { recursive: true, force: true });
1463
+ return { declined: true, dir: null, meta, ledger, records: [], estimate };
1464
+ }
1465
+ }
1466
+
1305
1467
  const call = async (kind, jobs) => {
1306
1468
  if (!jobs.length) return [];
1307
1469
  meta.invocations.used += jobs.length;
1308
1470
  meta.invocations[kind] += jobs.length;
1309
1471
  writeJson(path.join(dir, 'run-metadata.json'), meta);
1310
1472
  const answers = await runJobs(jobs, { root, concurrency, timeoutMs });
1473
+ for (const a of answers || []) {
1474
+ const c = Number(a && a.credits_charged);
1475
+ if (Number.isFinite(c) && c > 0) meta.credits_used += c;
1476
+ }
1477
+ writeJson(path.join(dir, 'run-metadata.json'), meta);
1311
1478
  return answers;
1312
1479
  };
1313
1480
 
@@ -1319,7 +1486,7 @@ async function runAudit(opts = {}) {
1319
1486
  const failures = [];
1320
1487
  const runCritic = async (wave, final) => {
1321
1488
  criticsLeft -= 1;
1322
- const [answer] = await call('critics', [{ engine: verifier, prompt: criticPrompt({ architecture, units: ledger, final }), label: `sentinel critic ${final ? 'final' : `w${wave}`}` }]);
1489
+ const [answer] = await call('critics', [{ engine: verifier, prompt: withHostedSource(root, verifier, criticPrompt({ architecture, units: ledger, final }), { kind: 'critic' }), max_output_tokens: isHostedEngine(verifier) ? HOSTED_OUTPUT_TOKENS.verifier : undefined, label: `sentinel critic ${final ? 'final' : `w${wave}`}` }]);
1323
1490
  const parsed = answer && answer.ok !== false ? extractJson(answer.stdout, (v) => Array.isArray(v.missing_units) || Array.isArray(v.reassign_ids)) : null;
1324
1491
  if (!parsed) return { accepted: 0, failed: true };
1325
1492
  const fresh = (parsed.missing_units || []).map((raw) => proposedUnit(root, raw, { profile, scope })).filter(Boolean);
@@ -1340,11 +1507,11 @@ async function runAudit(opts = {}) {
1340
1507
  const jobs = batches.map((units, i) => {
1341
1508
  const agentId = `h${wave}-${String(i + 1).padStart(2, '0')}`;
1342
1509
  for (const u of units) { u.status = 'in_progress'; u.agent_id = agentId; u.wave = wave; }
1343
- return { engine: hunter, prompt: hunterPrompt({ architecture, units, exclusions, peerIds: waveUnits - units.length }), label: `sentinel hunter ${agentId}`, _units: units, _agent: agentId };
1510
+ return { engine: hunter, prompt: withHostedSource(root, hunter, hunterPrompt({ architecture, units: hostedUnits(units, hunterAllowed), exclusions, peerIds: waveUnits - units.length }), { files: units.flatMap((u) => u.starting_paths), kind: 'hunter', allowed: hunterAllowed }), max_output_tokens: isHostedEngine(hunter) ? HOSTED_OUTPUT_TOKENS.hunter : undefined, label: `sentinel hunter ${agentId}`, _units: units, _agent: agentId };
1344
1511
  });
1345
1512
  saveLedger();
1346
1513
  log(`wave ${wave}: ${jobs.length} hunter${jobs.length === 1 ? '' : 's'} on ${jobs.reduce((n, j) => n + j._units.length, 0)} areas (${hunter})`);
1347
- const answers = await call('hunters', jobs.map(({ engine, prompt, label }) => ({ engine, prompt, label })));
1514
+ const answers = await call('hunters', jobs.map(({ engine, prompt, label, max_output_tokens: m }) => ({ engine, prompt, label, ...(m ? { max_output_tokens: m } : {}) })));
1348
1515
  const uncovered = [];
1349
1516
  answers.forEach((answer, i) => {
1350
1517
  const res = applyHunterResult(root, jobs[i]._units, answer, jobs[i]._agent, hunter);
@@ -1417,13 +1584,22 @@ async function runAudit(opts = {}) {
1417
1584
  records.push(buildRecord(root, cand, 'rejected', { reason: `cited source does not resolve: ${errs.slice(0, 3).join('; ')}`, by: { engine: 'atris', agent_id: 'citation-check' } }));
1418
1585
  continue;
1419
1586
  }
1587
+ // A hosted verifier is only ever shown files this review may send. A
1588
+ // trace outside them (a model citing .env, or a path outside the scope)
1589
+ // never reaches it.
1590
+ const outside = verifierAllowed ? [...new Set((cand.trace || []).map((t) => t && t.file).filter((f) => !verifierAllowed.has(f)))] : [];
1591
+ if (outside.length) {
1592
+ if (cand._carried) pending.push(cand.fingerprint);
1593
+ else records.push(buildRecord(root, cand, 'rejected', { reason: `cites files this review may not send to atris models: ${outside.slice(0, 3).join(', ')}`, by: { engine: 'atris', agent_id: 'scope-check' } }));
1594
+ continue;
1595
+ }
1420
1596
  toVerify.push(cand);
1421
1597
  }
1422
1598
  const verifyBudget = Math.max(0, total - meta.invocations.used);
1423
1599
  const verifyNow = toVerify.slice(0, verifyBudget);
1424
1600
  const notVerified = toVerify.slice(verifyBudget);
1425
1601
  log(`verifying ${verifyNow.length} candidate${verifyNow.length === 1 ? '' : 's'} with ${verifier}`);
1426
- const vJobs = verifyNow.map((cand, i) => ({ engine: verifier, prompt: verifierPrompt({ candidate: { ...cand, trace: withCode(root, cand.trace), _carried: undefined, _unit_class: undefined, carried_from: undefined, verified_by: undefined }, cls: CLASS_BY_ID.get(cand.attack_class) }), label: `sentinel verifier v-${String(i + 1).padStart(2, '0')}` }));
1602
+ const vJobs = verifyNow.map((cand, i) => ({ engine: verifier, prompt: withHostedSource(root, verifier, verifierPrompt({ candidate: { ...cand, trace: withCode(root, cand.trace, verifierAllowed), _carried: undefined, _unit_class: undefined, carried_from: undefined, verified_by: undefined }, cls: CLASS_BY_ID.get(cand.attack_class) }), { trace: cand.trace, kind: 'verifier', allowed: verifierAllowed }), max_output_tokens: isHostedEngine(verifier) ? HOSTED_OUTPUT_TOKENS.verifier : undefined, label: `sentinel verifier v-${String(i + 1).padStart(2, '0')}` }));
1427
1603
  const vAnswers = await call('verifiers', vJobs);
1428
1604
  vAnswers.forEach((answer, i) => {
1429
1605
  const cand = verifyNow[i];
@@ -1440,7 +1616,7 @@ async function runAudit(opts = {}) {
1440
1616
  const confirmed = records.filter((r) => r.verdict === 'confirmed');
1441
1617
  const room = Math.max(0, total - meta.invocations.used);
1442
1618
  const checkNow = confirmed.slice(0, room);
1443
- const rJobs = checkNow.map((rec, i) => ({ engine: verifier, prompt: recheckPrompt({ record: rec }), label: `sentinel recheck r-${String(i + 1).padStart(2, '0')}` }));
1619
+ const rJobs = checkNow.map((rec, i) => ({ engine: verifier, prompt: withHostedSource(root, verifier, recheckPrompt({ record: verifierAllowed ? { ...rec, trace: hostedTrace(rec.trace, verifierAllowed) } : rec }), { trace: rec.trace, kind: 'verifier', allowed: verifierAllowed }), max_output_tokens: isHostedEngine(verifier) ? HOSTED_OUTPUT_TOKENS.verifier : undefined, label: `sentinel recheck r-${String(i + 1).padStart(2, '0')}` }));
1444
1620
  const rAnswers = await call('rechecks', rJobs);
1445
1621
  rAnswers.forEach((answer, i) => {
1446
1622
  const rec = checkNow[i];
@@ -1544,6 +1720,9 @@ module.exports = {
1544
1720
  citationErrors,
1545
1721
  decide,
1546
1722
  runAudit,
1723
+ isHostedEngine,
1724
+ hostedAllowedFiles,
1547
1725
  latestRun,
1726
+ repoName,
1548
1727
  shouldFailAudit,
1549
1728
  };
@@ -0,0 +1,558 @@
1
+ 'use strict';
2
+
3
+ // Sentinel on Atris: hosted engines, credit quotes, run upload, and the
4
+ // review timer. lib/security-audit.js does the audit; this file only talks
5
+ // to the Atris backend and the local timer.
6
+ //
7
+ // engines atris:fast hunts, atris:pro verifies and critiques, through
8
+ // POST /api/atris2/turn. The backend's turn billing charges the
9
+ // credits; this file only reads what each turn's receipt reports.
10
+ // quote GET /api/atris2/billing/quote prices one call per model, so the
11
+ // caller can show an estimate before any spend.
12
+ // upload POST /api/sentinel/runs gets summary fields only (titles,
13
+ // locations, counts). Never source, traces, or code lines.
14
+ // schedule the same launchd/cron timer `atris worktree sweep --install` uses.
15
+ //
16
+ // Zero external deps (Node built-ins only).
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const http = require('http');
22
+ const https = require('https');
23
+
24
+ const HUNTER_MODEL = 'atris:fast';
25
+ const VERIFIER_MODEL = 'atris:pro';
26
+ const LOCAL_ENGINES = ['codex', 'claude'];
27
+ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'informational'];
28
+ const MAX_UPLOAD_BYTES = 1024 * 1024;
29
+ const MAX_UPLOAD_FINDINGS = 500;
30
+ const SCHEDULE_EVERY = { day: 86400, week: 7 * 86400 };
31
+
32
+ // ---------------------------------------------------------------- identity
33
+
34
+ function authToken({ env = process.env, loadCredentials } = {}) {
35
+ if (env.OBELISK_AUTH_TOKEN) return env.OBELISK_AUTH_TOKEN;
36
+ if (env.ATRIS_TOKEN) return env.ATRIS_TOKEN;
37
+ try {
38
+ const load = loadCredentials || require('../utils/auth').loadCredentials;
39
+ const creds = load();
40
+ return creds && creds.token ? String(creds.token) : '';
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ function apiBase() {
47
+ return require('../utils/api').getApiBaseUrl();
48
+ }
49
+
50
+ // A local engine counts as available when its binary is on PATH.
51
+ function localEngineAvailable(name, { env = process.env, platform = process.platform } = {}) {
52
+ const exts = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
53
+ for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
54
+ for (const ext of exts) {
55
+ try { fs.accessSync(path.join(dir, `${name}${ext}`), fs.constants.X_OK); return true; } catch {}
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ // Which engines run the review. An explicit choice always wins; with none,
62
+ // local engines stay the default and Atris models step in only when no local
63
+ // engine is installed and the user is logged in.
64
+ function resolveEngines({ engine = null, hunter = null, verifier = null, token = '', env = process.env } = {}) {
65
+ if (engine && engine !== 'atris' && engine !== 'local') throw new Error('--engine must be atris or local');
66
+ if (engine === 'atris' && (hunter || verifier)) throw new Error('pick --engine atris or --hunter and --verifier, not both');
67
+ if (engine === 'atris') {
68
+ if (!token) throw new Error('reviews on atris models need a login; run atris login first');
69
+ return { mode: 'atris', hunter: HUNTER_MODEL, verifier: VERIFIER_MODEL, chosen: 'flag' };
70
+ }
71
+ if (engine === 'local' || hunter || verifier) return { mode: 'local', hunter: hunter || null, verifier: verifier || null, chosen: 'flag' };
72
+ if (LOCAL_ENGINES.some((name) => localEngineAvailable(name, { env }))) return { mode: 'local', hunter: null, verifier: null, chosen: 'default' };
73
+ if (token) return { mode: 'atris', hunter: HUNTER_MODEL, verifier: VERIFIER_MODEL, chosen: 'no_local_engine' };
74
+ return { mode: 'local', hunter: null, verifier: null, chosen: 'default' };
75
+ }
76
+
77
+ function usesAtrisModels(meta) {
78
+ const e = (meta && meta.engines) || {};
79
+ return [e.hunter, e.verifier, e.critic].some((x) => /^atris:/i.test(String(x || '')));
80
+ }
81
+
82
+ // ---------------------------------------------------------------- http
83
+
84
+ function request(method, url, { token, body = null, accept = 'application/json', timeoutMs = 30000, onData = null } = {}) {
85
+ return new Promise((resolve, reject) => {
86
+ let parsed;
87
+ try { parsed = new URL(url); } catch (e) { reject(new Error(`bad url: ${url}`)); return; }
88
+ const transport = parsed.protocol === 'https:' ? https : http;
89
+ const data = body === null ? null : Buffer.from(JSON.stringify(body));
90
+ const headers = { Accept: accept, 'User-Agent': 'atris-cli-sentinel' };
91
+ if (token) headers.Authorization = `Bearer ${token}`;
92
+ if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length; }
93
+ const req = transport.request({
94
+ hostname: parsed.hostname,
95
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
96
+ path: `${parsed.pathname}${parsed.search}`,
97
+ method,
98
+ headers,
99
+ }, (res) => {
100
+ res.setEncoding('utf8');
101
+ let text = '';
102
+ res.on('data', (chunk) => { text += chunk; if (onData && res.statusCode >= 200 && res.statusCode < 300) onData(chunk); });
103
+ res.on('end', () => {
104
+ let json = null;
105
+ try { json = text ? JSON.parse(text) : null; } catch {}
106
+ resolve({ status: res.statusCode, text, json });
107
+ });
108
+ res.on('error', reject);
109
+ });
110
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`)));
111
+ req.on('error', reject);
112
+ if (data) req.write(data);
113
+ req.end();
114
+ });
115
+ }
116
+
117
+ function httpError(res) {
118
+ const detail = res.json && (res.json.detail || res.json.error);
119
+ const text = typeof detail === 'string' ? detail : (detail ? JSON.stringify(detail) : String(res.text || '').slice(0, 200));
120
+ return `the server said ${res.status}${text ? `: ${text}` : ''}`;
121
+ }
122
+
123
+ // ---------------------------------------------------------------- engines
124
+
125
+ // The strict secret check: every scanner rule, matched raw. No allow
126
+ // markers and no placeholder exemptions, so `example` next to a token does
127
+ // not let it through.
128
+ function strictSecretHit(text) {
129
+ let rules = [];
130
+ try { rules = require('./security-scan').SECRET_RULES || []; } catch {}
131
+ return String(text || '').split('\n').some((part) => rules.some((rule) => {
132
+ try { rule.re.lastIndex = 0; return rule.re.test(part); } catch { return true; }
133
+ }));
134
+ }
135
+
136
+ // Long encoded runs: 40+ base64 characters holding at least one digit, plus
137
+ // or equals sign (plain words and slash paths have none), or 40+ hex digits.
138
+ const LONG_BASE64 = /[A-Za-z0-9+/=_-]{40,}/g;
139
+ const LONG_HEX = /[0-9a-fA-F]{40,}/;
140
+ function hasEncodedRun(line) {
141
+ if (LONG_HEX.test(line)) return true;
142
+ for (const m of String(line).match(LONG_BASE64) || []) if (/[0-9+=]/.test(m)) return true;
143
+ return false;
144
+ }
145
+
146
+ const PRIVATE_KEY_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/;
147
+ const PRIVATE_KEY_END = /-----END [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/;
148
+
149
+ // Every message bound for an Atris model passes through here, so no prompt
150
+ // part (seeded lines, cited code, source blocks, candidate JSON) can skip
151
+ // it. Whole private key blocks go, BEGIN through END (to the end of the
152
+ // message when END never comes). Then any line with a secret rule match,
153
+ // read as is or with JSON escapes undone, or with a long encoded run goes.
154
+ const HIDDEN_LINE = '[line hidden: looks like a secret]';
155
+ function redactOutgoing(message) {
156
+ let hidden = 0;
157
+ const lines = String(message || '').split('\n');
158
+ let inKey = false;
159
+ const out = lines.map((line) => {
160
+ const variants = [line, line.replace(/\\"/g, '"').replace(/\\'/g, "'"), line.replace(/\\n/g, '\n')];
161
+ if (!inKey && variants.some((v) => PRIVATE_KEY_BEGIN.test(v))) {
162
+ inKey = !variants.some((v) => PRIVATE_KEY_END.test(v));
163
+ hidden += 1;
164
+ return HIDDEN_LINE;
165
+ }
166
+ if (inKey) {
167
+ if (PRIVATE_KEY_END.test(line)) inKey = false;
168
+ hidden += 1;
169
+ return HIDDEN_LINE;
170
+ }
171
+ if (variants.some((v) => PRIVATE_KEY_END.test(v) || strictSecretHit(v) || hasEncodedRun(v))) {
172
+ hidden += 1;
173
+ return HIDDEN_LINE;
174
+ }
175
+ return line;
176
+ });
177
+ return { text: out.join('\n'), hidden };
178
+ }
179
+
180
+ // One turn on an Atris model. The reply streams as server-sent events: text
181
+ // arrives as text_delta, the final answer as result, and the receipt carries
182
+ // what the turn was charged.
183
+ async function runAtrisTurn({ model, prompt, token, base = apiBase(), timeoutMs = 15 * 60 * 1000, maxOutputTokens = null }) {
184
+ let buffer = '';
185
+ let text = '';
186
+ let result = null;
187
+ let credits = 0;
188
+ let billed = false;
189
+ const errors = [];
190
+ const readBilling = (billing) => {
191
+ if (!billing || typeof billing !== 'object') return;
192
+ billed = true;
193
+ if (billing.billed === false) return;
194
+ const c = Number(billing.credits_charged);
195
+ if (Number.isFinite(c) && c > 0) credits += c;
196
+ };
197
+ const handle = (block) => {
198
+ const data = block.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s?/, '')).join('\n').trim();
199
+ if (!data || data === '[DONE]') return;
200
+ let ev;
201
+ try { ev = JSON.parse(data); } catch { return; }
202
+ if (!ev || typeof ev !== 'object') return;
203
+ if ((ev.type === 'text_delta' || ev.type === 'text') && ev.content) text += ev.content;
204
+ if (ev.type === 'result' && ev.result) result = String(ev.result);
205
+ if (ev.type === 'error' || ev.error) errors.push(String(ev.error || ev.message || 'stream error'));
206
+ readBilling((ev.receipt && ev.receipt.billing) || ev.billing);
207
+ };
208
+ const onData = (chunk) => {
209
+ buffer += chunk;
210
+ let at = buffer.indexOf('\n\n');
211
+ while (at !== -1) { handle(buffer.slice(0, at)); buffer = buffer.slice(at + 2); at = buffer.indexOf('\n\n'); }
212
+ };
213
+ const { text: message } = redactOutgoing(prompt);
214
+ const body = { message, model, max_turns: 1, disable_connector_tools: true };
215
+ if (Number.isInteger(maxOutputTokens) && maxOutputTokens >= 16) body.max_output_tokens = Math.min(8192, maxOutputTokens);
216
+ let res;
217
+ try {
218
+ res = await request('POST', `${base}/atris2/turn`, { token, body, accept: 'text/event-stream', timeoutMs, onData });
219
+ } catch (e) {
220
+ return { ok: false, stdout: '', reason: e.message, credits_charged: 0, billed: false };
221
+ }
222
+ if (res.status < 200 || res.status >= 300) return { ok: false, stdout: '', reason: httpError(res), credits_charged: 0, billed: res.status === 402 ? false : billed };
223
+ if (buffer.trim()) handle(buffer);
224
+ // A plain JSON reply (no stream) still counts.
225
+ if (!text && !result && res.json && typeof res.json === 'object') {
226
+ result = String(res.json.result || res.json.output || res.json.text || '');
227
+ readBilling((res.json.receipt && res.json.receipt.billing) || res.json.billing);
228
+ }
229
+ const stdout = result || text;
230
+ if (!stdout && errors.length) return { ok: false, stdout: '', reason: errors.join('; '), credits_charged: credits, billed };
231
+ return { ok: Boolean(stdout), stdout, reason: stdout ? undefined : 'empty answer', credits_charged: credits, billed };
232
+ }
233
+
234
+ const CAP_REASON = 'stopped at the approved credit limit';
235
+
236
+ // The spend guard every hosted call goes through. `max` is the approved
237
+ // limit. Before a call starts, its own worst case (quoted at its exact size
238
+ // and output limit) is held against the limit along with every call still
239
+ // running; a call that could pass the limit never starts. After a call, the
240
+ // receipt's charge is counted, or the worst case when no receipt came back.
241
+ function spendGuard({ max = 0, quoteCall } = {}) {
242
+ return { max, spent: 0, held: 0, blocked: 0, calls: 0, limits: {}, quoteCall };
243
+ }
244
+
245
+ // A runJobs for runAudit: atris:* jobs go to the backend, anything else to
246
+ // the local engine runner.
247
+ function atrisJobRunner({ token, base = null, localRunJobs = null, spend = null } = {}) {
248
+ return async (jobs, { root, concurrency = 3, timeoutMs } = {}) => {
249
+ const answers = new Array(jobs.length);
250
+ const hosted = [];
251
+ const local = [];
252
+ jobs.forEach((job, i) => (/^atris:/i.test(String(job.engine || '')) ? hosted : local).push(i));
253
+ if (local.length) {
254
+ const run = localRunJobs || ((js, o) => {
255
+ const { runEngineAskJobs } = require('./engine-ask');
256
+ return runEngineAskJobs(js, o);
257
+ });
258
+ const got = await run(local.map((i) => jobs[i]), { root, concurrency, timeoutMs });
259
+ local.forEach((i, k) => { answers[i] = got[k]; });
260
+ }
261
+ const url = base || apiBase();
262
+ const one = async (job) => {
263
+ const model = job.engine;
264
+ const outTokens = Number.isInteger(job.max_output_tokens) ? job.max_output_tokens : 8192;
265
+ if (!spend) return runAtrisTurn({ model, prompt: job.prompt, token, base: url, timeoutMs, maxOutputTokens: outTokens });
266
+ const bytes = Buffer.byteLength(redactOutgoing(job.prompt).text);
267
+ const limit = spend.limits[model];
268
+ if (limit && bytes > limit) { spend.blocked += 1; return { ok: false, stdout: '', reason: `message is larger than the size this run was priced for (${bytes} of ${limit} bytes)`, credits_charged: 0 }; }
269
+ let worst;
270
+ try { worst = await (spend.quoteCall || ((m, i, o) => quoteCall(m, i, o, { token, base: url })))(model, bytes, outTokens); } catch (e) { spend.blocked += 1; return { ok: false, stdout: '', reason: `no price for this call: ${e.message}`, credits_charged: 0 }; }
271
+ if (spend.spent + spend.held + worst > spend.max) { spend.blocked += 1; return { ok: false, stdout: '', reason: CAP_REASON, credits_charged: 0 }; }
272
+ spend.held += worst;
273
+ spend.calls += 1;
274
+ let answer;
275
+ try { answer = await runAtrisTurn({ model, prompt: job.prompt, token, base: url, timeoutMs, maxOutputTokens: outTokens }); } finally { spend.held -= worst; }
276
+ spend.spent += answer.billed ? Number(answer.credits_charged) || 0 : worst;
277
+ return answer;
278
+ };
279
+ let next = 0;
280
+ const work = async () => {
281
+ while (next < hosted.length) {
282
+ const i = hosted[next++];
283
+ answers[i] = await one(jobs[i]);
284
+ }
285
+ };
286
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, hosted.length)) }, work));
287
+ return answers;
288
+ };
289
+ }
290
+
291
+ // ---------------------------------------------------------------- quote
292
+
293
+ // The price of one call at these exact counts, capped by the backend's own
294
+ // most-per-request when it reports one.
295
+ async function quoteCall(model, inputTokens, outputTokens, { token, base = apiBase(), timeoutMs = 15000 } = {}) {
296
+ const qs = new URLSearchParams({ model, input_tokens: String(Math.ceil(inputTokens)), output_tokens: String(Math.ceil(outputTokens)) });
297
+ const res = await request('GET', `${base}/atris2/billing/quote?${qs}`, { token, timeoutMs });
298
+ if (res.status < 200 || res.status >= 300 || !res.json || !Number.isFinite(Number(res.json.credits))) {
299
+ throw new Error(`could not get a price for ${model}: ${httpError(res)}`);
300
+ }
301
+ return Number(res.json.credits);
302
+ }
303
+
304
+ // Prices every hosted row of a spend estimate: each call at its largest
305
+ // allowed size and full output limit, times the calls. Throws when any quote
306
+ // fails, so a run never spends without a price shown.
307
+ async function quoteEstimate(estimate, { token, base = apiBase(), timeoutMs = 15000 } = {}) {
308
+ const rows = [];
309
+ for (const row of estimate.calls || []) {
310
+ const per = await quoteCall(row.model, row.input_tokens, row.output_tokens, { token, base, timeoutMs });
311
+ rows.push({ ...row, credits_per_call: per, credits: per * row.calls });
312
+ }
313
+ const credits = rows.reduce((n, r) => n + r.credits, 0);
314
+ const limits = {};
315
+ for (const r of rows) if (r.max_input_bytes) limits[r.model] = Math.max(limits[r.model] || 0, r.max_input_bytes);
316
+ return { rows, credits, limits };
317
+ }
318
+
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`);
321
+ return [
322
+ `this review runs on atris models and costs at most ${quote.credits} credits`,
323
+ ` ${parts.join('; ')}`,
324
+ ' the run stops before it could pass that. most runs spend far less',
325
+ ' your code is sent to atris models to be read. lines that look like secrets are hidden first',
326
+ ].join('\n');
327
+ }
328
+
329
+ // ---------------------------------------------------------------- upload
330
+
331
+ function readJsonFile(file) {
332
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
333
+ }
334
+
335
+ // The upload guarantee: POST /api/sentinel/runs carries only plain
336
+ // sentences, file:line locations and counts. Never source, traces, code
337
+ // lines or identifiers. Every sentence passes the allowlist below or is
338
+ // replaced whole with FALLBACK_SENTENCE; nothing is cleaned up and kept.
339
+ //
340
+ // Accepted limit: a model could still describe a secret in ordinary words
341
+ // ("the password is the word swordfish followed by seven"). No filter can
342
+ // tell that from a normal sentence, so the prompts ask for sentences with
343
+ // no code, identifiers, file contents or secrets, and this is where the
344
+ // guarantee stops.
345
+ const FALLBACK_SENTENCE = 'See the full report on your machine.';
346
+ const PROSE_CHARS = /^[A-Za-z0-9 .,'():-]+$/;
347
+
348
+ function plain(value, max = 500) {
349
+ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
350
+ if (!s) return '';
351
+ if (s.length > max) return FALLBACK_SENTENCE;
352
+ if (!PROSE_CHARS.test(s)) return FALLBACK_SENTENCE;
353
+ if (/[A-Za-z0-9]\(/.test(s)) return FALLBACK_SENTENCE; // a call shape: word(
354
+ if (s.replace(/[A-Za-z]'(?=[A-Za-z])/g, '').includes("'")) return FALLBACK_SENTENCE; // quotes, not apostrophes
355
+ if (/\([^)]*[,'][^)]*\)/.test(s)) return FALLBACK_SENTENCE; // argument lists
356
+ if (/[A-Za-z0-9]\.[A-Za-z0-9]/.test(s)) return FALLBACK_SENTENCE; // a.b: paths, members, versions
357
+ if (/[a-z][A-Z]/.test(s)) return FALLBACK_SENTENCE; // camelCase
358
+ if (/[A-Za-z0-9]{20,}/.test(s)) return FALLBACK_SENTENCE; // tokens and long ids
359
+ if (strictSecretHit(s)) return FALLBACK_SENTENCE;
360
+ return s;
361
+ }
362
+
363
+ // file:line from a trace step, kept as is when it is a plain repo path.
364
+ function location(sink) {
365
+ if (!sink || typeof sink.file !== 'string') return '';
366
+ const file = sink.file.trim();
367
+ if (!/^[\w./@+-]+$/.test(file) || file.split('/').includes('..') || file.length > 280) return '';
368
+ return `${file}${Number.isInteger(sink.line) && sink.line > 0 ? `:${sink.line}` : ''}`;
369
+ }
370
+
371
+ function hardeningFromReport(text) {
372
+ const lines = String(text || '').split('\n');
373
+ const start = lines.findIndex((l) => /^##\s+Hardening notes/i.test(l));
374
+ if (start === -1) return [];
375
+ const out = [];
376
+ for (const l of lines.slice(start + 1)) {
377
+ if (/^##\s/.test(l)) break;
378
+ const m = l.match(/^\s*-\s+(.*)$/);
379
+ const note = m ? plain(m[1], 400) : '';
380
+ if (note) out.push(note);
381
+ }
382
+ return [...new Set(out)].slice(0, 50);
383
+ }
384
+
385
+ function needsFromMarkdown(text) {
386
+ const out = [];
387
+ let cur = null;
388
+ let inBlocked = false;
389
+ for (const l of String(text || '').split('\n')) {
390
+ const h = l.match(/^##\s+(.*)$/);
391
+ if (h) { cur = { title: plain(h[1], 300), blocked_on: '' }; out.push(cur); inBlocked = false; continue; }
392
+ if (!cur) continue;
393
+ if (/^Blocked on:/i.test(l)) { inBlocked = true; continue; }
394
+ if (/^\S.*:$/.test(l)) { inBlocked = false; continue; }
395
+ const m = l.match(/^-\s+(.*)$/);
396
+ if (inBlocked && m && !cur.blocked_on) cur.blocked_on = plain(m[1], 400);
397
+ }
398
+ return out;
399
+ }
400
+
401
+ function count(v) {
402
+ const n = Number(v);
403
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
404
+ }
405
+
406
+ // The contract body (POST /api/sentinel/runs), built from a run folder.
407
+ function buildUploadBody(dir) {
408
+ const meta = readJsonFile(path.join(dir, 'run-metadata.json'));
409
+ if (!meta) throw new Error(`no run-metadata.json in ${dir}`);
410
+ const records = readJsonFile(path.join(dir, 'findings.json')) || [];
411
+ let report = '';
412
+ try { report = fs.readFileSync(path.join(dir, 'REPORT.md'), 'utf8'); } catch {}
413
+ let nvText = '';
414
+ try { nvText = fs.readFileSync(path.join(dir, 'NEEDS-VALIDATION.md'), 'utf8'); } catch {}
415
+
416
+ const cov = meta.coverage || {};
417
+ const counts = meta.counts || {};
418
+ const sev = counts.severity || {};
419
+ 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 trace = Array.isArray(r.trace) ? r.trace : [];
424
+ const sink = trace[trace.length - 1] || {};
425
+ const severity = SEVERITIES.includes((r.severity || {}).overall) ? r.severity.overall : 'informational';
426
+ return {
427
+ id: `f-${require('crypto').createHash('sha256').update(String(r.fingerprint || `f${i + 1}`)).digest('hex').slice(0, 12)}`,
428
+ severity,
429
+ title: plain(r.title, 300),
430
+ location: location(sink),
431
+ // Only the plain summary leaves; the code-level strategy stays local.
432
+ fix: plain((r.remediation || {}).summary, 600) || FALLBACK_SENTENCE,
433
+ status: 'open',
434
+ };
435
+ });
436
+ let needs = records.filter((r) => r && r.verdict === 'needs_validation').map((r) => ({
437
+ title: plain(r.title, 300),
438
+ blocked_on: plain(Array.isArray(r.blockers) && r.blockers[0] ? r.blockers[0] : '', 400),
439
+ }));
440
+ if (!needs.length && nvText) needs = needsFromMarkdown(nvText);
441
+ const engines = meta.engines || {};
442
+ const body = {
443
+ repo: String(meta.repo || path.basename(path.dirname(dir))),
444
+ run_id: String(meta.run_id || path.basename(dir)),
445
+ started_at: meta.started_at || null,
446
+ finished_at: meta.finished_at || null,
447
+ profile: String(meta.profile || 'standard'),
448
+ source: usesAtrisModels({ engines }) ? 'atris-models' : 'cli-local',
449
+ coverage: { in_scope: count(cov.in_scope), done: count(cov.done), deferred: count(cov.deferred), blocked: count(cov.blocked) },
450
+ counts: {
451
+ confirmed: count(counts.confirmed),
452
+ needs_validation: count(counts.needs_validation),
453
+ rejected: count(counts.rejected),
454
+ severity: Object.fromEntries(SEVERITIES.map((s) => [s, count(sev[s])])),
455
+ },
456
+ findings,
457
+ needs_validation: needs.slice(0, MAX_UPLOAD_FINDINGS),
458
+ hardening: hardeningFromReport(report),
459
+ credits_used: Number(meta.credits_used) > 0 ? Number(meta.credits_used) : 0,
460
+ };
461
+ while (Buffer.byteLength(JSON.stringify(body)) > MAX_UPLOAD_BYTES && (body.findings.length || body.needs_validation.length || body.hardening.length)) {
462
+ if (body.hardening.length) body.hardening.pop();
463
+ else if (body.needs_validation.length) body.needs_validation.pop();
464
+ else body.findings.pop();
465
+ }
466
+ return body;
467
+ }
468
+
469
+ async function uploadRun(dir, { token, base = apiBase(), timeoutMs = 20000 } = {}) {
470
+ if (!token) return { ok: false, reason: 'not logged in' };
471
+ let body;
472
+ try { body = buildUploadBody(dir); } catch (e) { return { ok: false, reason: e.message }; }
473
+ try {
474
+ const res = await request('POST', `${base}/sentinel/runs`, { token, body, timeoutMs });
475
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: httpError(res), body };
476
+ return { ok: true, id: res.json && res.json.id ? String(res.json.id) : null, body };
477
+ } catch (e) {
478
+ return { ok: false, reason: e.message, body };
479
+ }
480
+ }
481
+
482
+ // ---------------------------------------------------------------- schedule
483
+
484
+ function schedulePlan({ target, every, homeDir = os.homedir(), platform = process.platform, atrisBin, envPath = process.env.PATH || '' }) {
485
+ if (!SCHEDULE_EVERY[every]) throw new Error('--every must be day or week');
486
+ const { planKeep } = require('./campaign-keep');
487
+ return planKeep({
488
+ root: target,
489
+ home: path.join(homeDir, '.atris', 'sentinel'),
490
+ platform,
491
+ homeDir,
492
+ atrisBin,
493
+ envPath,
494
+ job: {
495
+ name: 'sentinel-review',
496
+ args: ['sentinel', '--quick', '--scheduled'],
497
+ every_seconds: SCHEDULE_EVERY[every],
498
+ marker: 'ATRIS_SENTINEL_REVIEW',
499
+ },
500
+ });
501
+ }
502
+
503
+ // What the owner approved when the timer went on: which engines, and for
504
+ // atris models the most credits one run may spend. A scheduled run reads
505
+ // this and nothing else; with no record it does not run at all.
506
+ function scheduleKey(target) {
507
+ let real = path.resolve(target);
508
+ try { real = fs.realpathSync(real); } catch {}
509
+ return require('crypto').createHash('sha256').update(real).digest('hex').slice(0, 16);
510
+ }
511
+
512
+ function approvalPath(target, homeDir = os.homedir()) {
513
+ return path.join(homeDir, '.atris', 'sentinel', 'schedules', `${scheduleKey(target)}.json`);
514
+ }
515
+
516
+ function writeApproval(target, approval, homeDir) {
517
+ const file = approvalPath(target, homeDir);
518
+ fs.mkdirSync(path.dirname(file), { recursive: true });
519
+ const tmp = `${file}.${process.pid}.tmp`;
520
+ fs.writeFileSync(tmp, `${JSON.stringify({ target: path.resolve(target), ...approval }, null, 2)}\n`, { mode: 0o600 });
521
+ fs.renameSync(tmp, file);
522
+ return file;
523
+ }
524
+
525
+ function readApproval(target, homeDir) {
526
+ const a = readJsonFile(approvalPath(target, homeDir));
527
+ if (!a || (a.engine !== 'atris' && a.engine !== 'local')) return null;
528
+ if (a.engine === 'atris' && !(Number(a.max_credits) > 0)) return null;
529
+ return a;
530
+ }
531
+
532
+ function removeApproval(target, homeDir) {
533
+ const file = approvalPath(target, homeDir);
534
+ const had = fs.existsSync(file);
535
+ if (had) fs.unlinkSync(file);
536
+ return had;
537
+ }
538
+
539
+ module.exports = {
540
+ FALLBACK_SENTENCE,
541
+ writeApproval,
542
+ readApproval,
543
+ removeApproval,
544
+ CAP_REASON,
545
+ redactOutgoing,
546
+ spendGuard,
547
+ quoteCall,
548
+ SCHEDULE_EVERY,
549
+ authToken,
550
+ apiBase,
551
+ resolveEngines,
552
+ atrisJobRunner,
553
+ quoteEstimate,
554
+ describeQuote,
555
+ buildUploadBody,
556
+ uploadRun,
557
+ schedulePlan,
558
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.2",
3
+ "version": "3.82.3",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",