atris 3.82.7 → 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) {
@@ -3378,8 +3379,10 @@ if (command === 'guide') {
3378
3379
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3379
3380
  } else if (command === 'sentinel') {
3380
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));
3381
3384
  Promise.resolve().then(() => require('../commands/security-review').securityReviewCommand(['audit', ...process.argv.slice(3)]))
3382
- .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
3385
+ .then(async (code) => { try { await sentinelNotice.print(); } catch {} exitAfterStdoutDrain(typeof code === 'number' ? code : 0); })
3383
3386
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
3384
3387
  } else if (command === 'security-review' || command === 'secure') {
3385
3388
  // Security review: deterministic secrets/PII/code-risk scan (no LLM). Exit 1 = HIGH finding,
@@ -859,6 +859,7 @@ function printAuditHelp() {
859
859
  atris sentinel agents --home also check ~/.claude and ~/.codex
860
860
  atris sentinel watch [path] known attacks in your dependencies (osv database)
861
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
862
863
  atris sentinel guard install [--global] stop risky agent commands before they run
863
864
  atris sentinel guard test see which sample commands the guard stops
864
865
 
@@ -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 };
@@ -225,31 +225,53 @@ function writeState(root, kind, open, env) {
225
225
  }
226
226
 
227
227
  // The open events of this run, plus a resolved copy of each finding the
228
- // last upload reported open that is gone now.
229
- function withResolved(root, kind, openEvents, { env = process.env, now = new Date() } = {}) {
228
+ // last upload reported open that is gone now. hold(fp) true keeps a gone
229
+ // finding open: it is not sent as resolved and stays in the state. A
230
+ // later run can resolve it (a fix that is only on a side branch).
231
+ //
232
+ // read: the locations (lockfiles) this run read. A finding's recorded
233
+ // locations are merged, never replaced: one leaves the set only when this
234
+ // run read it and it no longer holds the finding. A finding resolves only
235
+ // when its set is empty.
236
+ function withResolved(root, kind, openEvents, { env = process.env, now = new Date(), hold = null, meta = null, read = null } = {}) {
230
237
  const before = readState(root, kind, env);
231
238
  const current = new Set(openEvents.map((e) => e.fingerprint));
232
239
  const resolved = [];
240
+ const held = [];
241
+ const heldLocations = [];
242
+ const next = {};
243
+ const unreadOf = (prev) => (read && prev && Array.isArray(prev.lockfiles) ? prev.lockfiles.filter((l) => !read.has(l)) : []);
233
244
  for (const [fp, prev] of Object.entries(before)) {
234
245
  if (current.has(fp)) continue;
246
+ const left = unreadOf(prev);
247
+ if (left.length) { held.push(fp); heldLocations.push(fp); next[fp] = { ...prev, lockfiles: left }; continue; }
248
+ if (hold && hold(fp, prev)) { held.push(fp); next[fp] = prev; continue; }
235
249
  resolved.push(makeEvent({ kind, repo: prev.repo, severity: prev.severity, title: prev.title, detail: prev.detail, status: 'resolved', fingerprint: fp, occurredAt: now }));
236
250
  }
237
- const next = {};
238
- for (const e of openEvents) next[e.fingerprint] = { repo: e.repo, severity: e.severity, title: e.title, detail: e.detail || '' };
239
- return { events: [...openEvents, ...resolved], resolved, commit: () => writeState(root, kind, next, env) };
251
+ // meta: local-only facts kept beside a finding (never uploaded), like
252
+ // which lockfiles held it.
253
+ for (const e of openEvents) {
254
+ const m = (meta && meta.get(e.fingerprint)) || {};
255
+ const entry = { repo: e.repo, severity: e.severity, title: e.title, detail: e.detail || '', ...m };
256
+ if (Array.isArray(m.lockfiles)) entry.lockfiles = [...new Set([...m.lockfiles, ...unreadOf(before[e.fingerprint])])];
257
+ next[e.fingerprint] = entry;
258
+ }
259
+ return { events: [...openEvents, ...resolved], resolved, held, heldLocations, commit: () => writeState(root, kind, next, env) };
240
260
  }
241
261
 
242
262
  // Upload one run's findings of one kind and move the state on success.
243
- async function reportFindings(root, kind, openEvents, { token, base, env = process.env, upload = true, extra = [] } = {}) {
244
- const plan = withResolved(root, kind, openEvents, { env });
245
- if (!upload) return { ok: true, skipped: 'no-upload', sent: 0, resolved: plan.resolved.length };
246
- if (!token) return { ok: false, skipped: 'not-logged-in', reason: 'not logged in', sent: 0, resolved: plan.resolved.length };
263
+ async function reportFindings(root, kind, openEvents, { token, base, env = process.env, upload = true, extra = [], hold = null, meta = null, read = null } = {}) {
264
+ const plan = withResolved(root, kind, openEvents, { env, hold, meta, read });
265
+ const held = plan.held.length;
266
+ const heldLocations = plan.heldLocations.length;
267
+ if (!upload) return { ok: true, skipped: 'no-upload', sent: 0, resolved: plan.resolved.length, held, heldLocations };
268
+ if (!token) return { ok: false, skipped: 'not-logged-in', reason: 'not logged in', sent: 0, resolved: plan.resolved.length, held, heldLocations };
247
269
  const res = await postEvents([...extra, ...plan.events], { token, base });
248
270
  if (res.ok) {
249
271
  try { plan.commit(); } catch {}
250
272
  try { await flushQueue({ token, base, env }); } catch {}
251
273
  }
252
- return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length };
274
+ return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length, held, heldLocations };
253
275
  }
254
276
 
255
277
  // ---------------------------------------------------------------- review fixes
@@ -317,6 +317,9 @@ function buildPrompt({ report, signals, seenDirRel }) {
317
317
  'The new items you add are scored and reported but never count toward keeping this change.',
318
318
  'So fix a general weakness, not one string. A rule that only matches the seen item will not move the hidden half.',
319
319
  '',
320
+ 'Known limits a literal rule cannot fix, so do not try: one-liners run through an interpreter (python -c, node -e and similar) that read a secret and send it. Literal words cannot tell reading a secret and sending it apart from printing a variable next to a web address, and reviewers rejected three such rules tonight for blocking everyday commands. Pick a different miss.',
321
+ 'Before proposing a command_block, picture five everyday developer commands that contain the same literal substrings. If any of them would be blocked, the rule is too broad.',
322
+ '',
320
323
  'Seen items the current code gets wrong:',
321
324
  ...(missesForPrompt(report).map((l) => ` ${l}`)),
322
325
  '',
@@ -615,8 +618,9 @@ function foreignFiles(tree) {
615
618
  }
616
619
 
617
620
  // Files the run itself may leave in a copy besides tracked, committed ones.
618
- // Today nothing: the candidate and the receipt draft live outside the copy.
619
- const KNOWN_OUTPUT = new Set();
621
+ // The candidate and the receipt draft live outside the copy. Running the exam
622
+ // records the engine choice in the copy's own state file, which is ours.
623
+ const KNOWN_OUTPUT = new Set(['.atris/state/engines.json']);
620
624
 
621
625
  // Live processes this run started. A run is alive by definition. For its
622
626
  // own copy the check is that every child it spawned has exited. Fail-closed: