atris 3.82.5 → 3.82.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/atris.js CHANGED
@@ -144,7 +144,8 @@ const jsonRequested = process.argv.slice(2).includes('--json');
144
144
  const dryRunRequested = updateArgs.includes('--dry-run');
145
145
  const skipUpdateCheck = Boolean(process.env.ATRIS_SKIP_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || helpRequested || jsonRequested);
146
146
  // Local-only commands make no registry call and start no background install.
147
- if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99'].includes(updateCommand)))) {
147
+ // sentinel prints its own one-line notice at the end of the run instead.
148
+ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel'].includes(updateCommand)))) {
148
149
  updateCheckPromise = checkForUpdates()
149
150
  .then((updateInfo) => {
150
151
  if (updateInfo) {
@@ -634,6 +635,8 @@ function showHelpAll() {
634
635
  console.log(' slop - Deterministic slop detector: frontend/prose tells, dead code (slop dead --exports)');
635
636
  console.log(' sentinel - Security audit: engines hunt for holes, a different model verifies each one');
636
637
  console.log(' free checks: sentinel agents, sentinel watch, sentinel guard install');
638
+ console.log(' exam and self-improvement: sentinel bench, sentinel improve');
639
+ console.log(' fix a confirmed finding on its own branch: sentinel fix <id>');
637
640
  console.log(' re-check old findings against today\'s code: sentinel recheck');
638
641
  console.log(' voice - Scan binary voice tells or judge reply shape and plainness');
639
642
  console.log(' recover - Explain stopped missions; --apply performs safe recovery only');
@@ -3376,8 +3379,10 @@ if (command === 'guide') {
3376
3379
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3377
3380
  } else if (command === 'sentinel') {
3378
3381
  // Sentinel: the full security audit (engines hunt, another model verifies). Same as `security-review audit`.
3382
+ // A stale install gets one plain upgrade line at the end of every sentinel command.
3383
+ const sentinelNotice = require('../commands/sentinel-powers').startUpdateNotice(process.argv.slice(3));
3379
3384
  Promise.resolve().then(() => require('../commands/security-review').securityReviewCommand(['audit', ...process.argv.slice(3)]))
3380
- .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
3385
+ .then(async (code) => { try { await sentinelNotice.print(); } catch {} exitAfterStdoutDrain(typeof code === 'number' ? code : 0); })
3381
3386
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3382
3387
  } else if (command === 'security-review' || command === 'secure') {
3383
3388
  // Security review: deterministic secrets/PII/code-risk scan (no LLM). Exit 1 = HIGH finding,
@@ -747,6 +747,11 @@ async function auditCommand(argv, deps = {}) {
747
747
  const run = { agents: powers.agentsCommand, guard: powers.guardCommand, watch: powers.watchCommand, flush: powers.flushCommand }[argv[0]];
748
748
  return run(argv.slice(1), deps);
749
749
  }
750
+ if (argv[0] === 'bench' || argv[0] === 'improve') {
751
+ const sb = require('./sentinel-bench');
752
+ return (argv[0] === 'bench' ? sb.benchCommand : sb.improveCommand)(argv.slice(1), deps);
753
+ }
754
+ if (argv[0] === 'fix') return require('./sentinel-fix').fixCommand(argv.slice(1), deps);
750
755
  if (argv[0] === 'upload') return auditUploadCommand(argv.slice(1), deps);
751
756
  if (argv[0] === 'recheck') return auditRecheckCommand(argv.slice(1), deps);
752
757
  if (argv[0] === 'schedule') return auditScheduleCommand(argv.slice(1), deps);
@@ -854,12 +859,25 @@ function printAuditHelp() {
854
859
  atris sentinel agents --home also check ~/.claude and ~/.codex
855
860
  atris sentinel watch [path] known attacks in your dependencies (osv database)
856
861
  atris sentinel watch --fix update affected packages on a new branch, never pushed
862
+ atris sentinel watch --fix --allow-major also take fixes that jump to a new major version
857
863
  atris sentinel guard install [--global] stop risky agent commands before they run
858
864
  atris sentinel guard test see which sample commands the guard stops
859
865
 
860
866
  results go to your atris dashboard when you are logged in. add --no-upload
861
867
  to keep them on this machine. secret values and file contents never leave.
862
868
 
869
+ the exam and the loop that improves the sentinel (in an atris checkout):
870
+
871
+ atris sentinel bench [--summary] score the guard and agent check on a seen and a held-out half
872
+ atris sentinel improve [--engine claude] one engine proposes a change, the exam decides if it stays
873
+ atris sentinel improve --schedule run the improve loop nightly
874
+
875
+ turning a confirmed finding into a fix:
876
+
877
+ atris sentinel fix list confirmed findings you can fix
878
+ atris sentinel fix <id> [path] smallest fix plus a regression test on a new branch
879
+ atris sentinel fix help how a fix is proved before it is committed
880
+
863
881
  reviews on a timer:
864
882
 
865
883
  atris sentinel schedule --every day [path] quick review once a day, saved to your account
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ // atris sentinel bench and atris sentinel improve.
4
+ //
5
+ // atris sentinel bench [--summary] [--json] [--review] [--sets guard,agents]
6
+ // atris sentinel bench --split <file> --set guard|agents|review
7
+ // atris sentinel improve [--engine claude] [--offline] [--json]
8
+ // atris sentinel improve --schedule [--every 24] [--engine claude] | --unschedule
9
+
10
+ const path = require('path');
11
+
12
+ function readFlag(argv, name) {
13
+ const i = argv.indexOf(name);
14
+ if (i !== -1 && argv[i + 1] !== undefined && !String(argv[i + 1]).startsWith('--')) return argv[i + 1];
15
+ const inline = argv.find((a) => String(a).startsWith(`${name}=`));
16
+ return inline ? inline.slice(name.length + 1) : null;
17
+ }
18
+
19
+ function checkFlags(argv, flags, values) {
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = String(argv[i]);
22
+ const name = a.split('=')[0];
23
+ if (values.includes(name)) { if (!a.includes('=')) i += 1; continue; }
24
+ if (flags.includes(a)) continue;
25
+ throw new Error(`unknown option: ${a}`);
26
+ }
27
+ }
28
+
29
+ function benchHelp() {
30
+ console.log(`
31
+ atris sentinel bench, a fixed exam for the sentinel
32
+
33
+ atris sentinel bench score the guard and the agent check
34
+ atris sentinel bench --summary totals only, no item lines
35
+ atris sentinel bench --review also run the review set on engines (costs engine time)
36
+ atris sentinel bench --json raw numbers for scripts
37
+ atris sentinel bench --split <file> --set guard|agents|review
38
+ split new items into the seen half (in the repo)
39
+ and the sealed held-out half (on this machine).
40
+ the plaintext input is replaced with an encrypted copy
41
+
42
+ the seen half lives in atris/sentinel-bench. the held-out half lives in
43
+ ~/.atris/sentinel-holdout, encrypted with a key in your keychain. its
44
+ items are never printed. only lib/sentinel-rules.json of the checkout is
45
+ judged, always with this atris's own code. every number
46
+ is raw: attacks blocked, everyday commands wrongly blocked, problems
47
+ found, false alarms. there is no blended score.
48
+ `);
49
+ return 0;
50
+ }
51
+
52
+ async function benchCommand(argv, deps = {}) {
53
+ if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') return benchHelp();
54
+ try { checkFlags(argv, ['--summary', '--json', '--review'], ['--split', '--set', '--sets', '--target', '--seen', '--hunter', '--verifier', '--budget']); } catch (e) { console.error(` ${e.message}`); return 2; }
55
+ const bench = require('../lib/sentinel-bench');
56
+ const env = deps.env || process.env;
57
+ const root = path.resolve(readFlag(argv, '--target') || deps.root || process.cwd());
58
+ const seenDir = path.resolve(readFlag(argv, '--seen') || bench.seenDirOf(root));
59
+ const holdoutDir = bench.defaultHoldoutDir(env);
60
+ const split = readFlag(argv, '--split');
61
+ if (split) {
62
+ const set = readFlag(argv, '--set');
63
+ if (!set) { console.error(' --split needs --set guard, agents or review'); return 2; }
64
+ let res;
65
+ try { res = bench.splitCorpus(path.resolve(split), set, { seenDir, holdoutDir, env }); } catch (e) { console.error(` could not split: ${e.message}`); return 1; }
66
+ if (argv.includes('--json')) { console.log(JSON.stringify({ ok: true, ...res }, null, 2)); return 0; }
67
+ console.log(`\n ${res.seen} items went to the seen half: ${path.relative(process.cwd(), res.seen_file) || res.seen_file}`);
68
+ console.log(` ${res.holdout} items went to the held-out half on this machine`);
69
+ if (res.skipped) console.log(` ${res.skipped} items were already in a half and were skipped`);
70
+ if (res.invalid) console.log(` ${res.invalid} lines were not valid items and were skipped`);
71
+ console.log(` the plaintext input was replaced with an encrypted copy at ${res.sealed_source}. no readable held-out item is left behind\n`);
72
+ return 0;
73
+ }
74
+ const review = argv.includes('--review');
75
+ const setsRaw = readFlag(argv, '--sets');
76
+ const sets = setsRaw ? setsRaw.split(',').map((s) => s.trim()).filter(Boolean) : ['guard', 'agents', ...(review ? ['review'] : [])];
77
+ const bad = sets.filter((s) => !bench.SETS.includes(s));
78
+ if (bad.length) { console.error(` unknown set: ${bad.join(', ')}. use guard, agents or review`); return 2; }
79
+ const budget = readFlag(argv, '--budget');
80
+ const reviewOpts = review ? {
81
+ hunter: readFlag(argv, '--hunter') || 'codex',
82
+ verifier: readFlag(argv, '--verifier') || 'claude',
83
+ ...(budget ? { budget: Number(budget) } : {}),
84
+ log: argv.includes('--json') ? () => {} : (m) => console.error(` ${m}`),
85
+ ...(deps.runJobs ? { runJobs: deps.runJobs } : {}),
86
+ } : null;
87
+ let report;
88
+ try { report = await bench.runBench({ target: root, seenDir, sets, review: reviewOpts, env }); } catch (e) { console.error(` the exam did not run: ${e.message}`); return 1; }
89
+ const pub = bench.publicReport(report, { summary: argv.includes('--summary') });
90
+ if (argv.includes('--json')) console.log(JSON.stringify({ ok: true, ...pub }, null, 2));
91
+ else console.log(bench.renderReport(pub, { holdoutDir: holdoutDir.replace(require('os').homedir(), '~') }));
92
+ return 0;
93
+ }
94
+
95
+ function improveHelp() {
96
+ console.log(`
97
+ atris sentinel improve, one exam-checked change to the sentinel
98
+
99
+ atris sentinel improve [--engine claude] propose one change and judge it
100
+ atris sentinel improve --offline skip the published advisory lookup
101
+ atris sentinel improve --schedule [--every 24] [--engine claude]
102
+ run it on a timer, nightly by default
103
+ atris sentinel improve --unschedule turn the timer off
104
+
105
+ claude proposes one change to lib/sentinel-rules.json on a new branch,
106
+ sealed off from the held-out exam. the sentinel bench, fixed code with no
107
+ model, scores it before and after. it is kept only when
108
+ held-out attack catch holds or rises, wrong blocks and false alarms do
109
+ not rise and no seen item that passed now fails. a kept change waits on
110
+ its branch for you. nothing is merged, pushed or released.
111
+ `);
112
+ return 0;
113
+ }
114
+
115
+ async function improveCommand(argv, deps = {}) {
116
+ if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') return improveHelp();
117
+ try { checkFlags(argv, ['--json', '--offline', '--schedule', '--unschedule'], ['--engine', '--every']); } catch (e) { console.error(` ${e.message}`); return 2; }
118
+ const improve = require('../lib/sentinel-improve');
119
+ const env = deps.env || process.env;
120
+ const engine = readFlag(argv, '--engine') || 'claude';
121
+ if (!/^[a-z][a-z0-9:_-]{0,30}$/.test(engine)) { console.error(` unknown engine: ${engine}`); return 2; }
122
+ const refused = require('../lib/sentinel-improve').refusedEngine(engine);
123
+ if (refused && !argv.includes('--unschedule')) { console.error(` ${refused}`); return 2; }
124
+ const root = deps.root || process.cwd();
125
+ if (argv.includes('--schedule') || argv.includes('--unschedule')) {
126
+ const { installKeep, removeKeep } = require('../lib/campaign-keep');
127
+ const everyHours = Number(readFlag(argv, '--every') || 24);
128
+ if (!Number.isFinite(everyHours) || everyHours <= 0) { console.error(' --every needs a number of hours'); return 2; }
129
+ let top;
130
+ try { top = require('child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { console.error(' run this inside the atris checkout the loop should improve'); return 2; }
131
+ const plan = improve.schedulePlan({ root: top, engine, everyHours, env, atrisBin: deps.atrisBin });
132
+ if (argv.includes('--unschedule')) {
133
+ const removed = (deps.removeKeep || removeKeep)(plan);
134
+ if (!removed.ok) { console.error(` could not turn the timer off: ${removed.error}`); return 1; }
135
+ console.log(` sentinel improve timer: ${removed.removed === false ? 'was not on' : 'off'} for ${top}`);
136
+ return 0;
137
+ }
138
+ const space = require('../lib/worktree-space');
139
+ const bin = deps.atrisBin || path.join(__dirname, '..', 'bin', 'atris.js');
140
+ if (!deps.atrisBin && (space.isAgentCopyPath(bin) || space.isScratchPath(bin) || space.isAgentCopyPath(top))) {
141
+ console.error(' refusing: this atris or this checkout lives in a folder that gets removed. install the timer from a lasting checkout');
142
+ return 2;
143
+ }
144
+ const installed = (deps.installKeep || installKeep)(plan);
145
+ if (!installed.ok) { console.error(` could not turn the timer on: ${installed.error}`); return 1; }
146
+ console.log(` sentinel improve timer: on for ${top}, every ${everyHours} ${everyHours === 1 ? 'hour' : 'hours'} with ${engine}`);
147
+ console.log(` log: ${plan.log_path}`);
148
+ console.log(' each run leaves a receipt in ~/.atris/sentinel-improve/receipts. kept changes wait on their branch for you');
149
+ return 0;
150
+ }
151
+ let res;
152
+ try {
153
+ res = await improve.improve({ root, engine, env, offline: argv.includes('--offline'), log: argv.includes('--json') ? () => {} : (m) => console.log(` ${m}`), ...(deps.improveOpts || {}) });
154
+ } catch (e) { console.error(` improve did not run: ${e.message}`); return 1; }
155
+ if (argv.includes('--json')) {
156
+ console.log(JSON.stringify({ ok: !res.error, kept: res.kept, branch: res.branch, error: res.error || null, decision: res.decision, receipt: res.receipt }, null, 2));
157
+ return res.error ? 1 : 0;
158
+ }
159
+ console.log('');
160
+ if (res.error) console.log(` stopped: ${res.error}`);
161
+ else if (res.kept) console.log(` kept. the change waits on branch ${res.branch} for your review`);
162
+ else console.log(' rejected. the branch was removed');
163
+ if (res.decision) {
164
+ for (const g of res.decision.gains) console.log(` gain: ${g}`);
165
+ for (const n of res.decision.notes || []) console.log(` note: ${n}`);
166
+ for (const r of res.decision.reasons) console.log(` stop: ${r}`);
167
+ }
168
+ console.log(` receipt: ${res.receipt.replace(require('os').homedir(), '~')}\n`);
169
+ return res.error ? 1 : 0;
170
+ }
171
+
172
+ module.exports = { benchCommand, improveCommand };
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ // atris sentinel fix [finding-id|--all-open] [path]
4
+ //
5
+ // Turns a confirmed sentinel finding into a reviewed fix on its own branch.
6
+ // The work lives in lib/sentinel-fix.js; this file reads flags, prices atris
7
+ // model calls before any spend, prints the landing and sends one event.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function tildePath(p, home = require('os').homedir()) {
13
+ return p && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
14
+ }
15
+
16
+ function parseFixArgs(argv) {
17
+ const opts = { positionals: [], allOpen: false, verify: null, builder: null, verifier: null, engine: null, yes: false, pr: false, upload: true, timeoutMs: null };
18
+ const takes = (i, flag) => {
19
+ if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) throw new Error(`${flag} needs a value`);
20
+ return argv[i + 1];
21
+ };
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const arg = argv[i];
24
+ const [flag, inline] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
25
+ const value = () => (inline !== null ? inline : takes(i++, flag));
26
+ if (flag === '--all-open') opts.allOpen = true;
27
+ else if (flag === '--verify') opts.verify = value();
28
+ else if (flag === '--builder') opts.builder = value();
29
+ else if (flag === '--verifier') opts.verifier = value();
30
+ else if (flag === '--engine') opts.engine = value();
31
+ else if (flag === '--yes' || flag === '-y') opts.yes = true;
32
+ else if (flag === '--pr') opts.pr = true;
33
+ else if (flag === '--no-upload') opts.upload = false;
34
+ else if (flag === '--timeout') opts.timeoutMs = Number(value()) * 1000;
35
+ else if (arg.startsWith('-')) throw new Error(`unknown flag: ${arg}`);
36
+ else opts.positionals.push(arg);
37
+ }
38
+ if (opts.engine !== null && !['atris', 'local'].includes(opts.engine)) throw new Error('--engine must be atris or local');
39
+ if (opts.timeoutMs !== null && (!Number.isFinite(opts.timeoutMs) || opts.timeoutMs < 10000)) throw new Error('--timeout is in seconds, at least 10');
40
+ if (opts.verify !== null && !opts.verify.trim()) throw new Error('--verify needs a command');
41
+ // fix <id> <path>, fix <path>, fix <id>, fix --all-open <path>
42
+ const [a, b] = opts.positionals;
43
+ if (opts.positionals.length > 2) throw new Error('give at most a finding id and a path');
44
+ if (opts.allOpen) {
45
+ if (opts.positionals.length > 1) throw new Error('--all-open takes only a path');
46
+ opts.path = a || null;
47
+ } else if (opts.positionals.length === 2) {
48
+ opts.id = a;
49
+ opts.path = b;
50
+ } else if (a !== undefined) {
51
+ let isDir = false;
52
+ try { isDir = fs.statSync(a).isDirectory(); } catch {}
53
+ if (isDir) opts.path = a; else opts.id = a;
54
+ }
55
+ return opts;
56
+ }
57
+
58
+ function printFixHelp() {
59
+ console.log(`
60
+ atris sentinel fix, a reviewed fix for a confirmed finding
61
+
62
+ atris sentinel fix list confirmed findings you can fix
63
+ atris sentinel fix <id> [path] fix one finding on its own branch
64
+ atris sentinel fix --all-open [path] fix every finding with no fix branch yet
65
+ atris sentinel fix <id> --verify "make test"
66
+ run this test command (default: npm test,
67
+ pytest or go test, whichever the repo uses)
68
+ atris sentinel fix <id> --builder claude --verifier codex
69
+ which engine writes the fix and which checks it
70
+ atris sentinel fix <id> --engine atris atris models write and check, after a price
71
+ atris sentinel fix <id> --pr push the branch and open a pull request
72
+
73
+ it needs a clean working tree. the fix is built in a temp copy on a new
74
+ branch named sentinel/fix-<id> made from the branch you are on. your own
75
+ checkout and branch are never changed. nothing is merged. nothing is
76
+ pushed unless you add --pr.
77
+
78
+ builder and checker may be codex, claude or an atris model. atris models
79
+ always show a price and wait for a yes.
80
+
81
+ a fix is committed only when all three hold:
82
+ the new test fails on the old code
83
+ the full test suite passes with the fix
84
+ a second engine reads the fixed source and finds the attack no longer works
85
+
86
+ the new test must fail on a real failed check. a missing module, a syntax
87
+ error or a timeout does not count. otherwise nothing is committed and the
88
+ attempt is saved as a patch under ~/.atris/sentinel/fixes.
89
+
90
+ the new tests run with a temp home, no tokens or keys in the environment
91
+ and push turned off for the temp copy. they still run as you and are not a
92
+ sandbox. read the new test before you run it on code you do not trust.
93
+ `);
94
+ return 0;
95
+ }
96
+
97
+ function askYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
98
+ return new Promise((resolve) => {
99
+ const rl = require('readline').createInterface({ input, output });
100
+ rl.question(question, (answer) => { rl.close(); resolve(/^\s*y(es)?\s*$/i.test(String(answer || ''))); });
101
+ });
102
+ }
103
+
104
+ function sevOf(r) {
105
+ return (r.severity && r.severity.overall) || 'unrated';
106
+ }
107
+
108
+ async function fixCommand(argv, deps = {}) {
109
+ if (argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) return printFixHelp();
110
+ const fix = deps.fixLib || require('../lib/sentinel-fix');
111
+ const env = deps.env || process.env;
112
+ let opts;
113
+ try { opts = parseFixArgs(argv); } catch (e) { console.error(` ${e.message}`); return 2; }
114
+
115
+ const start = path.resolve(opts.path || deps.root || process.cwd());
116
+ const top = fix.git(start, ['rev-parse', '--show-toplevel'], { allowFail: true });
117
+ if (!top) { console.error(` ${tildePath(start)} is not a git repository. a fix needs a branch to go on`); return 2; }
118
+ const root = top;
119
+
120
+ const { records } = fix.confirmedFindings(root);
121
+ if (!records.length) { console.log('\n no confirmed findings for this repository. run atris sentinel first\n'); return 0; }
122
+
123
+ if (!opts.id && !opts.allOpen) {
124
+ console.log('\n confirmed findings you can fix\n');
125
+ for (const r of records) {
126
+ const has = fix.branchExists(root, fix.branchFor(r));
127
+ console.log(` ${fix.shortId(r.fingerprint)} ${sevOf(r).padEnd(8)} ${r.title}${has ? ' (fix branch exists)' : ''}`);
128
+ }
129
+ console.log(`\n fix one with: atris sentinel fix ${fix.shortId(records[0].fingerprint)}\n`);
130
+ return 0;
131
+ }
132
+
133
+ let targets;
134
+ if (opts.allOpen) {
135
+ targets = records.filter((r) => !fix.branchExists(root, fix.branchFor(r)));
136
+ if (!targets.length) { console.log('\n every confirmed finding already has a fix branch\n'); return 0; }
137
+ } else {
138
+ const r = fix.matchFinding(records, opts.id);
139
+ if (!r) { console.error(` no confirmed finding matches ${opts.id}. run atris sentinel fix to list them`); return 2; }
140
+ targets = [r];
141
+ }
142
+
143
+ if (fix.changedPaths(root).length) { console.error(' this repository has uncommitted changes. commit or stash them first so the fix branch holds only the fix'); return 2; }
144
+ const from = fix.git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true });
145
+ if (!from || from === 'HEAD') { console.error(' check out a branch first. a fix branch is made from the branch you are on'); return 2; }
146
+
147
+ const testCommand = opts.verify || fix.detectTestCommand(root);
148
+ if (!testCommand) { console.error(' no test command found. name one with --verify "<command>"'); return 2; }
149
+
150
+ let engines;
151
+ try { engines = fix.resolveFixEngines({ engine: opts.engine, builder: opts.builder, verifier: opts.verifier, env, available: deps.available }); } catch (e) { console.error(` ${e.message}`); return 2; }
152
+
153
+ const cloud = deps.cloud || require('../lib/sentinel-cloud');
154
+ const hosted = fix.isHosted(engines.builder) || fix.isHosted(engines.verifier);
155
+ const token = deps.token !== undefined ? deps.token : cloud.authToken();
156
+ let hostedRun = deps.hostedRun || null;
157
+ let allowed = new Set();
158
+ if (hosted) {
159
+ if (!token) { console.error(' fixes on atris models need a login. run atris login first'); return 2; }
160
+ allowed = require('../lib/security-audit').hostedAllowedFiles(root, []);
161
+ const rows = [];
162
+ if (fix.isHosted(engines.builder)) {
163
+ const sizes = targets.map((r) => Buffer.byteLength(fix.builderPrompt(r, { testCommand, hosted: true, fileList: [...allowed].slice(0, 400), source: fix.hostedSourceBlock(root, fix.citedFiles(r), allowed, 64 * 1024) })));
164
+ const max = Math.max(4096, ...sizes) + 1024;
165
+ rows.push({ role: 'builder', model: engines.builder, calls: targets.length, input_tokens: max, output_tokens: fix.HOSTED_OUTPUT_TOKENS.builder, max_input_bytes: max });
166
+ }
167
+ if (fix.isHosted(engines.verifier)) rows.push({ role: 'verifier', model: engines.verifier, calls: targets.length, input_tokens: fix.HOSTED_CHECK_MAX_BYTES, output_tokens: fix.HOSTED_OUTPUT_TOKENS.verifier, max_input_bytes: fix.HOSTED_CHECK_MAX_BYTES });
168
+ let quote;
169
+ try { quote = await cloud.quoteEstimate({ calls: rows }, { token }); } catch (e) { console.error(` nothing was spent. ${e.message}`); return 2; }
170
+ console.log(`\n ${cloud.describeQuote(quote).replace(/^this review/, 'this fix')}\n`);
171
+ let yes = opts.yes;
172
+ if (!yes) {
173
+ const input = deps.input || process.stdin;
174
+ if (!input.isTTY && !deps.input) { console.error(' nothing was spent. add --yes to run without this question'); return 1; }
175
+ yes = await (deps.ask || askYesNo)(' spend these credits? (y/N) ', { input, output: process.stdout });
176
+ if (!yes) { console.log(' nothing was spent'); return 1; }
177
+ }
178
+ if (!hostedRun) {
179
+ const spend = cloud.spendGuard({ quoteCall: deps.quoteCall });
180
+ spend.max = quote.credits;
181
+ spend.limits = quote.limits;
182
+ const runner = cloud.atrisJobRunner({ token, spend });
183
+ hostedRun = (jobs) => runner(jobs, { root, concurrency: 1, timeoutMs: opts.timeoutMs || 20 * 60 * 1000 });
184
+ }
185
+ }
186
+
187
+ const log = deps.log || ((m) => console.log(` · ${m}`));
188
+ const ctx = {
189
+ from,
190
+ testCommand,
191
+ engines,
192
+ env,
193
+ allowed,
194
+ hostedRun,
195
+ runBuilder: deps.runBuilder || fix.defaultRunBuilder,
196
+ runChecker: deps.runChecker || fix.defaultRunChecker,
197
+ timeouts: { build: opts.timeoutMs || 20 * 60 * 1000, check: opts.timeoutMs || 10 * 60 * 1000, test: 20 * 60 * 1000 },
198
+ log,
199
+ };
200
+
201
+ console.log(`\n ◉ sentinel fix · ${engines.builder} writes the fix · ${engines.verifier} checks it · tests: ${testCommand}`);
202
+ const results = [];
203
+ for (const record of targets) {
204
+ console.log(`\n ${fix.shortId(record.fingerprint)} · ${record.title}`);
205
+ const res = await fix.fixOne(root, record, ctx);
206
+ results.push({ record, res });
207
+ if (res.ok) {
208
+ console.log(` prepared a fix on a new branch: ${res.branch}`);
209
+ console.log(` the new test failed on the old code and passes with the fix. the full test suite passes. ${engines.verifier} checked from source that the attack no longer works`);
210
+ console.log(` review it with: git diff ${from}...${res.branch}`);
211
+ if (opts.pr) {
212
+ const pr = fix.openPr(root, res, record, { run: deps.run, verifier: engines.verifier });
213
+ res.pr = pr;
214
+ if (pr.ok) console.log(` opened a pull request${pr.url ? `: ${pr.url}` : ''}. nothing was merged`);
215
+ else console.log(` no pull request was opened: ${pr.reason}${pr.pushed ? '' : '. nothing was pushed'}`);
216
+ } else {
217
+ console.log(' nothing was pushed');
218
+ }
219
+ } else {
220
+ console.log(` no fix was committed: ${res.reason}`);
221
+ if (res.branch === null || res.branch === undefined) console.log(` you are on ${from}${res.patch ? `. the attempt is saved at ${tildePath(res.patch, env.HOME || undefined)}` : ''}`);
222
+ }
223
+ }
224
+
225
+ const done = results.filter((x) => x.res.ok);
226
+ if (done.length) {
227
+ const events = require('../lib/sentinel-events');
228
+ let upload;
229
+ if (!opts.upload) upload = { skipped: 'no-upload' };
230
+ else if (!token) upload = { skipped: 'not-logged-in' };
231
+ else {
232
+ try { upload = await events.postEvents(done.map((x) => fix.fixEvent(root, x.record, x.res)), { token, base: deps.base }); } catch (e) { upload = { ok: false, reason: e.message }; }
233
+ }
234
+ if (upload.skipped === 'no-upload') console.log('\n kept on this machine. nothing was sent (--no-upload)');
235
+ else if (upload.skipped === 'not-logged-in') console.log('\n not logged in. nothing was sent to your dashboard');
236
+ else if (!upload.ok) console.log(`\n warning: could not tell your atris dashboard: ${upload.reason}. the fix itself is done`);
237
+ else console.log('\n told your atris dashboard. no code was sent');
238
+ }
239
+ console.log('');
240
+ return results.every((x) => x.res.ok) ? 0 : 1;
241
+ }
242
+
243
+ module.exports = { fixCommand, parseFixArgs };
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // atris sentinel agents [path] [--home] check an agent setup
7
7
  // atris sentinel guard install|uninstall|status|test [--global]
8
- // atris sentinel watch [path] [--fix] known attacks in dependencies
8
+ // atris sentinel watch [path] [--fix] [--allow-major] known attacks in dependencies
9
9
  // atris sentinel flush send queued guard events
10
10
  //
11
11
  // An upload problem is a warning line. It never changes the exit code.
@@ -50,6 +50,34 @@ function uploadLine(res, what) {
50
50
  return ` sent ${res.sent - (res.resolved || 0)} ${what} to your atris dashboard${resolved}. no file contents or secret values were sent`;
51
51
  }
52
52
 
53
+ // ---------------------------------------------------------------- update
54
+
55
+ // A stale global install misses sentinel fixes. Every sentinel command
56
+ // ends with one line when npm has a newer atris. The check starts with the
57
+ // command and gets a short wait at the end. It never blocks or fails a run.
58
+ function startUpdateNotice(argv = [], { env = process.env, check, timeoutMs = 1500, packageRoot = path.join(__dirname, '..') } = {}) {
59
+ const off = env.ATRIS_SKIP_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER
60
+ || argv.some((a) => ['--json', '--help', '-h', 'help', '--quiet'].includes(a));
61
+ if (off) return { print: async () => null };
62
+ const updates = require('../utils/update-check');
63
+ let pending;
64
+ try { pending = Promise.resolve((check || updates.checkForUpdates)()).catch(() => null); } catch { pending = Promise.resolve(null); }
65
+ return {
66
+ async print(log = console.log) {
67
+ let timer;
68
+ const late = new Promise((r) => { timer = setTimeout(() => r(null), timeoutMs); if (timer.unref) timer.unref(); });
69
+ const info = await Promise.race([pending, late]);
70
+ clearTimeout(timer);
71
+ if (!info || !info.needsUpdate) return null;
72
+ let how = 'npm install -g atris@latest';
73
+ try { if (updates.isGitCheckout(packageRoot)) how = 'atris upgrade'; } catch {}
74
+ const line = ` a newer atris is out: ${info.latest}. this one is ${info.installed}. update with: ${how}`;
75
+ log(line);
76
+ return line;
77
+ },
78
+ };
79
+ }
80
+
53
81
  // ---------------------------------------------------------------- agents
54
82
 
55
83
  async function runAgents(root, { home = false, upload = true, deps = {} } = {}) {
@@ -83,37 +111,92 @@ async function agentsCommand(argv, deps = {}) {
83
111
 
84
112
  // ---------------------------------------------------------------- watch
85
113
 
114
+ // A threat that is gone here counts as fixed only on the repo's default
115
+ // branch, or when the default branch no longer holds the affected version.
116
+ // On a side branch it stays open on the dashboard.
117
+ function holdForBranch(branch) {
118
+ if (!branch || branch.onDefault) return null;
119
+ const live = branch.liveFingerprints;
120
+ if (!live) return () => true;
121
+ return (fp) => live.has(fp);
122
+ }
123
+
86
124
  async function runWatchCheck(root, { upload = true, deps = {} } = {}) {
87
125
  const watch = require('../lib/sentinel-watch');
88
126
  const events = require('../lib/sentinel-events');
89
- const result = await watch.runWatch(root, { base: deps.osvBase || process.env.ATRIS_OSV_BASE || watch.OSV_BASE, ...(deps.request ? { request: deps.request } : {}) });
127
+ const result = await watch.runWatch(root, { base: deps.osvBase || process.env.ATRIS_OSV_BASE || watch.OSV_BASE, ...(deps.request ? { request: deps.request } : {}), ...(deps.maxLockfileBytes ? { maxBytes: deps.maxLockfileBytes } : {}) });
90
128
  const evs = watch.toEvents(result, { repo: deps.repo });
129
+ let branch = null;
130
+ try { branch = watch.branchState(root, { run: deps.run }); } catch { branch = { git: true, onDefault: false, liveFingerprints: null }; }
91
131
  let res;
92
132
  if (result.offline) res = { ok: true, skipped: 'offline', sent: 0 };
93
133
  else {
94
134
  try {
95
- res = await events.reportFindings(root, 'threat', evs, { token: upload ? tokenFor(deps) : '', base: deps.base, env: deps.env || process.env, upload });
135
+ // Per lockfile: an earlier threat is fixed only when every lockfile it
136
+ // was found in was read this time and none of them holds it now. One
137
+ // whose lockfile was not read (depth, cap, size, error, moved or
138
+ // deleted) stays open. An old record with no lockfile list needs a
139
+ // complete scan.
140
+ const read = new Set(result.lockfiles.map((l) => l.file));
141
+ const branchHold = holdForBranch(branch);
142
+ const why = { unread: 0, branch: 0 };
143
+ // Locations not read this run are kept by reportFindings itself. Here:
144
+ // an old record with no lockfile list plus the branch rule.
145
+ const hold = (fp, prev) => {
146
+ const lfs = prev && Array.isArray(prev.lockfiles) && prev.lockfiles.length ? prev.lockfiles : null;
147
+ if (!lfs && result.incomplete) { why.unread += 1; return true; }
148
+ if (branchHold && branchHold(fp)) { why.branch += 1; return true; }
149
+ return false;
150
+ };
151
+ res = await events.reportFindings(root, 'threat', evs, { token: upload ? tokenFor(deps) : '', base: deps.base, env: deps.env || process.env, upload, hold, meta: watch.lockfilesByFingerprint(result), read });
152
+ res.heldUnread = why.unread + (res.heldLocations || 0);
153
+ res.heldBranch = why.branch;
96
154
  } catch (e) { res = { ok: false, reason: e.message }; }
97
155
  }
98
- return { result, events: evs, upload: res };
156
+ return { result, events: evs, upload: res, branch };
157
+ }
158
+
159
+ function heldLines(upload, branch) {
160
+ const L = [];
161
+ const u = (upload && upload.heldUnread) || 0;
162
+ const b = (upload && upload.heldBranch) || 0;
163
+ if (u) L.push(` ${u} earlier threat${u === 1 ? ' was' : 's were'} in a lockfile this check did not read. ${u === 1 ? 'it stays' : 'they stay'} open on your dashboard`);
164
+ if (b) {
165
+ const target = (branch && branch.defaultBranch) || 'the default branch';
166
+ L.push(` fixed on this branch, not yet live: ${b} threat${b === 1 ? '' : 's'}. ${b === 1 ? 'it stays' : 'they stay'} open on your dashboard until the fix is merged into ${target}`);
167
+ }
168
+ return L;
169
+ }
170
+
171
+ // One or two lines per package left for a person. Never cut short.
172
+ function manualLines(manual) {
173
+ const L = [];
174
+ if (!manual || !manual.length) return L;
175
+ L.push(` left for you to update (${manual.length}):`);
176
+ for (const a of manual) {
177
+ const to = a.fixed ? ` to ${a.fixed}` : '';
178
+ L.push(` ${a.name} ${a.version}${to} in ${a.lockfile || a.ecosystem}: ${a.why || 'not updated'}`);
179
+ }
180
+ return L;
99
181
  }
100
182
 
101
183
  async function watchCommand(argv, deps = {}) {
102
184
  let opts;
103
- try { opts = parseFlags(argv, ['fix', 'json', 'no-upload']); } catch (e) { console.error(` ${e.message}`); return 2; }
185
+ try { opts = parseFlags(argv, ['fix', 'json', 'no-upload', 'allow-major']); } catch (e) { console.error(` ${e.message}`); return 2; }
104
186
  let root;
105
187
  try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
106
188
  const watch = require('../lib/sentinel-watch');
107
- const { result, upload } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
189
+ const { result, upload, branch } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
108
190
  let fix = null;
109
191
  if (opts.fix && !result.offline && result.affected.length) {
110
- try { fix = watch.applyFix(root, result, { log: (m) => console.error(` ${m}`) }); } catch (e) { fix = { ok: false, reason: String(e.message || e).split('\n')[0] }; }
192
+ try { fix = watch.applyFix(root, result, { run: deps.run, allowMajor: Boolean(opts.allowMajor) }); } catch (e) { fix = { ok: false, reason: String(e.message || e).split('\n')[0] }; }
111
193
  }
112
194
  if (opts.json) {
113
- console.log(JSON.stringify({ ok: !result.affected.some((a) => a.severity === 'critical' || a.severity === 'high'), ...result, upload, fix }, null, 2));
195
+ console.log(JSON.stringify({ ok: !result.affected.some((a) => a.severity === 'critical' || a.severity === 'high'), ...result, upload, branch: branch ? { ...branch, liveFingerprints: undefined } : null, fix }, null, 2));
114
196
  } else {
115
197
  console.log(watch.renderReport(result));
116
198
  if (upload.skipped !== 'offline') console.log(uploadLine(upload, 'the threats'));
199
+ for (const l of heldLines(upload, branch)) console.log(l);
117
200
  if (fix) {
118
201
  console.log('');
119
202
  if (!fix.ok) console.log(` the fix did not run: ${fix.reason}`);
@@ -124,9 +207,12 @@ async function watchCommand(argv, deps = {}) {
124
207
  console.log(` go back with: git checkout ${fix.from || 'main'}`);
125
208
  console.log(' nothing was pushed');
126
209
  }
127
- if (fix.manual && fix.manual.length) {
128
- console.log(` update these yourself: ${fix.manual.map((a) => `${a.name} to ${a.fixed}`).join(', ')}`);
210
+ for (const te of fix.toolErrors || []) {
211
+ console.log(` ${te.tool} could not update ${te.lockfile}. it said:`);
212
+ for (const l of te.lines) console.log(` ${l}`);
213
+ if (te.hint) console.log(` ${te.hint}`);
129
214
  }
215
+ for (const l of manualLines(fix.manual)) console.log(l);
130
216
  }
131
217
  console.log('');
132
218
  }
@@ -281,4 +367,4 @@ async function runScheduledFreeChecks(root, deps = {}) {
281
367
  return out;
282
368
  }
283
369
 
284
- module.exports = { agentsCommand, guardCommand, watchCommand, flushCommand, runScheduledFreeChecks, runAgents, runWatchCheck };
370
+ module.exports = { agentsCommand, guardCommand, watchCommand, flushCommand, runScheduledFreeChecks, runAgents, runWatchCheck, startUpdateNotice };