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.
- package/commands/security-review.js +215 -8
- package/lib/campaign-keep.js +1 -0
- package/lib/first-minute.js +5 -2
- package/lib/security-audit.js +191 -12
- package/lib/sentinel-cloud.js +558 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
485
|
-
hunter
|
|
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 };
|
package/lib/campaign-keep.js
CHANGED
|
@@ -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
|
|
package/lib/first-minute.js
CHANGED
|
@@ -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)) {
|
package/lib/security-audit.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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",
|