atris 3.82.7 → 3.82.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/atris.js +5 -2
- package/commands/security-review.js +1 -0
- package/commands/sentinel-bench.js +83 -5
- package/commands/sentinel-powers.js +97 -11
- package/lib/sentinel-bench.js +50 -2
- package/lib/sentinel-events.js +32 -10
- package/lib/sentinel-guard.js +17 -4
- package/lib/sentinel-improve.js +134 -19
- package/lib/sentinel-night.js +893 -0
- package/lib/sentinel-watch.js +940 -98
- package/package.json +1 -1
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
|
-
|
|
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
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// atris sentinel bench [--summary] [--json] [--review] [--sets guard,agents]
|
|
6
6
|
// atris sentinel bench --split <file> --set guard|agents|review
|
|
7
|
-
// atris sentinel improve [--engine claude] [--offline] [--json]
|
|
7
|
+
// atris sentinel improve [--engine claude] [--focus exfil|destroy|disarm|agents] [--offline] [--json]
|
|
8
|
+
// atris sentinel improve --rounds N --parallel K --review codex [--max-minutes 120] [--max-calls N] [--push]
|
|
8
9
|
// atris sentinel improve --schedule [--every 24] [--engine claude] | --unschedule
|
|
9
10
|
|
|
10
11
|
const path = require('path');
|
|
@@ -51,12 +52,21 @@ function benchHelp() {
|
|
|
51
52
|
|
|
52
53
|
async function benchCommand(argv, deps = {}) {
|
|
53
54
|
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
|
+
try { checkFlags(argv, ['--summary', '--json', '--review'], ['--split', '--set', '--sets', '--target', '--seen', '--hunter', '--verifier', '--budget', '--screen']); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
55
56
|
const bench = require('../lib/sentinel-bench');
|
|
56
57
|
const env = deps.env || process.env;
|
|
57
58
|
const root = path.resolve(readFlag(argv, '--target') || deps.root || process.cwd());
|
|
58
59
|
const seenDir = path.resolve(readFlag(argv, '--seen') || bench.seenDirOf(root));
|
|
59
60
|
const holdoutDir = bench.defaultHoldoutDir(env);
|
|
61
|
+
const screen = readFlag(argv, '--screen');
|
|
62
|
+
if (screen) {
|
|
63
|
+
// Internal to the night run: refused ids only, as json.
|
|
64
|
+
let candidates;
|
|
65
|
+
try { candidates = JSON.parse(require('fs').readFileSync(path.resolve(screen), 'utf8')); } catch (e) { console.error(` could not read the items to screen: ${e.message}`); return 1; }
|
|
66
|
+
if (!Array.isArray(candidates)) { console.error(' the items to screen must be a json list'); return 1; }
|
|
67
|
+
console.log(JSON.stringify({ ok: true, ...bench.screenItems(candidates, { holdoutDir, env }) }));
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
60
70
|
const split = readFlag(argv, '--split');
|
|
61
71
|
if (split) {
|
|
62
72
|
const set = readFlag(argv, '--set');
|
|
@@ -97,26 +107,49 @@ function improveHelp() {
|
|
|
97
107
|
atris sentinel improve, one exam-checked change to the sentinel
|
|
98
108
|
|
|
99
109
|
atris sentinel improve [--engine claude] propose one change and judge it
|
|
110
|
+
atris sentinel improve --focus exfil point the proposer at one area:
|
|
111
|
+
exfil, destroy, disarm or agents
|
|
100
112
|
atris sentinel improve --offline skip the published advisory lookup
|
|
101
113
|
atris sentinel improve --schedule [--every 24] [--engine claude]
|
|
102
114
|
run it on a timer, nightly by default
|
|
103
115
|
atris sentinel improve --unschedule turn the timer off
|
|
104
116
|
|
|
117
|
+
a night run, many changes in one sitting:
|
|
118
|
+
|
|
119
|
+
atris sentinel improve --rounds 4 --parallel 3 --review codex
|
|
120
|
+
--rounds N up to N rounds. each starts from what shipped before
|
|
121
|
+
--parallel K K proposers at once, each on its own area (up to 4)
|
|
122
|
+
--review codex codex reviews every change the exam keeps. SHIP keeps
|
|
123
|
+
it. FIX rejects it and its examples join the exam
|
|
124
|
+
--max-minutes M stop after M minutes (120 by default)
|
|
125
|
+
--max-calls C stop after C proposer and reviewer calls
|
|
126
|
+
--push push the two night branches by name when done
|
|
127
|
+
atris sentinel improve --break-lock clear a night run lock whose run is gone
|
|
128
|
+
|
|
129
|
+
shipped rules land on sentinel-improve/night-<date>. new exam items land
|
|
130
|
+
apart on sentinel-exam/<date>. you can review them on their own. the
|
|
131
|
+
run stops early after two rounds with nothing shipped or no held-out gain.
|
|
132
|
+
|
|
105
133
|
claude proposes one change to lib/sentinel-rules.json on a new branch,
|
|
106
134
|
sealed off from the held-out exam. the sentinel bench, fixed code with no
|
|
107
135
|
model, scores it before and after. it is kept only when
|
|
108
136
|
held-out attack catch holds or rises, wrong blocks and false alarms do
|
|
109
137
|
not rise and no seen item that passed now fails. a kept change waits on
|
|
110
|
-
its branch for you. nothing is merged
|
|
138
|
+
its branch for you. nothing is merged or released. nothing is pushed without --push.
|
|
111
139
|
`);
|
|
112
140
|
return 0;
|
|
113
141
|
}
|
|
114
142
|
|
|
115
143
|
async function improveCommand(argv, deps = {}) {
|
|
116
144
|
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; }
|
|
145
|
+
try { checkFlags(argv, ['--json', '--offline', '--schedule', '--unschedule', '--push', '--break-lock'], ['--engine', '--every', '--focus', '--rounds', '--parallel', '--review', '--max-minutes', '--max-calls']); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
118
146
|
const improve = require('../lib/sentinel-improve');
|
|
119
147
|
const env = deps.env || process.env;
|
|
148
|
+
if (argv.includes('--break-lock')) {
|
|
149
|
+
const res = require('../lib/sentinel-night').breakLock(env);
|
|
150
|
+
(res.refused ? console.error : console.log)(` ${res.message}`);
|
|
151
|
+
return res.refused ? 1 : 0;
|
|
152
|
+
}
|
|
120
153
|
const engine = readFlag(argv, '--engine') || 'claude';
|
|
121
154
|
if (!/^[a-z][a-z0-9:_-]{0,30}$/.test(engine)) { console.error(` unknown engine: ${engine}`); return 2; }
|
|
122
155
|
const refused = require('../lib/sentinel-improve').refusedEngine(engine);
|
|
@@ -148,9 +181,14 @@ async function improveCommand(argv, deps = {}) {
|
|
|
148
181
|
console.log(' each run leaves a receipt in ~/.atris/sentinel-improve/receipts. kept changes wait on their branch for you');
|
|
149
182
|
return 0;
|
|
150
183
|
}
|
|
184
|
+
const focus = readFlag(argv, '--focus');
|
|
185
|
+
if (argv.includes('--focus') && !focus) { console.error(' --focus needs exfil, destroy, disarm or agents'); return 2; }
|
|
186
|
+
if (focus && !improve.FOCI.includes(focus)) { console.error(` unknown focus: ${focus}. use exfil, destroy, disarm or agents`); return 2; }
|
|
187
|
+
const nightFlags = ['--rounds', '--parallel', '--review', '--max-minutes', '--max-calls', '--push'];
|
|
188
|
+
if (argv.some((a) => nightFlags.includes(String(a).split('=')[0]))) return nightCommand(argv, { root, engine, env, focus, deps });
|
|
151
189
|
let res;
|
|
152
190
|
try {
|
|
153
|
-
res = await improve.improve({ root, engine, env, offline: argv.includes('--offline'), log: argv.includes('--json') ? () => {} : (m) => console.log(` ${m}`), ...(deps.improveOpts || {}) });
|
|
191
|
+
res = await improve.improve({ root, engine, env, focus, offline: argv.includes('--offline'), log: argv.includes('--json') ? () => {} : (m) => console.log(` ${m}`), ...(deps.improveOpts || {}) });
|
|
154
192
|
} catch (e) { console.error(` improve did not run: ${e.message}`); return 1; }
|
|
155
193
|
if (argv.includes('--json')) {
|
|
156
194
|
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));
|
|
@@ -169,4 +207,44 @@ async function improveCommand(argv, deps = {}) {
|
|
|
169
207
|
return res.error ? 1 : 0;
|
|
170
208
|
}
|
|
171
209
|
|
|
210
|
+
function numFlag(argv, name) {
|
|
211
|
+
const v = readFlag(argv, name);
|
|
212
|
+
if (v === null) return argv.includes(name) ? NaN : undefined;
|
|
213
|
+
return Number(v);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function nightCommand(argv, { root, engine, env, focus, deps }) {
|
|
217
|
+
const improve = require('../lib/sentinel-improve');
|
|
218
|
+
const review = readFlag(argv, '--review');
|
|
219
|
+
if (argv.includes('--review') && !review) { console.error(' --review needs codex'); return 2; }
|
|
220
|
+
if (review && review !== 'codex') { console.error(' --review takes codex only'); return 2; }
|
|
221
|
+
const opts = { root, engine, env, focus, review, push: argv.includes('--push'), offline: argv.includes('--offline') };
|
|
222
|
+
for (const [flag, key] of [['--rounds', 'rounds'], ['--parallel', 'parallel'], ['--max-minutes', 'maxMinutes'], ['--max-calls', 'maxCalls']]) {
|
|
223
|
+
const n = numFlag(argv, flag);
|
|
224
|
+
if (n === undefined) continue;
|
|
225
|
+
if (!Number.isFinite(n) || n <= 0) { console.error(` ${flag} needs a number above 0`); return 2; }
|
|
226
|
+
if (key !== 'maxMinutes' && !Number.isInteger(n)) { console.error(` ${flag} needs a whole number`); return 2; }
|
|
227
|
+
if (key === 'parallel' && n > improve.FOCI.length) { console.error(` --parallel goes up to ${improve.FOCI.length}, one proposer per focus area`); return 2; }
|
|
228
|
+
opts[key] = n;
|
|
229
|
+
}
|
|
230
|
+
const json = argv.includes('--json');
|
|
231
|
+
let res;
|
|
232
|
+
try {
|
|
233
|
+
res = await require('../lib/sentinel-night').night({ ...opts, log: json ? () => {} : (m) => console.log(` ${m}`), ...(deps.nightOpts || {}) });
|
|
234
|
+
} catch (e) { console.error(` improve did not run: ${e.message}`); return 1; }
|
|
235
|
+
if (json) {
|
|
236
|
+
console.log(JSON.stringify({ ok: !res.error, shipped: res.landed, stop: res.stop, night_branch: res.night_branch, exam_branch: res.exam_branch, exam_added: res.exam_added, calls: res.calls, minutes: res.minutes, pushed: res.pushed, error: res.error || null, receipt: res.receipt }, null, 2));
|
|
237
|
+
return res.error ? 1 : 0;
|
|
238
|
+
}
|
|
239
|
+
const home = require('os').homedir();
|
|
240
|
+
console.log('');
|
|
241
|
+
console.log(` ${res.landed} ${res.landed === 1 ? 'change' : 'changes'} shipped in ${res.rounds.length} ${res.rounds.length === 1 ? 'round' : 'rounds'}. stopped because ${res.stop}`);
|
|
242
|
+
if (res.night_branch) console.log(` rules wait on ${res.night_branch}`);
|
|
243
|
+
if (res.exam_branch) console.log(` ${res.exam_added} new exam items wait on ${res.exam_branch}`);
|
|
244
|
+
if (res.pushed.length) console.log(` pushed: ${res.pushed.join(', ')}`);
|
|
245
|
+
console.log(` used ${res.calls} engine calls in ${res.minutes} minutes`);
|
|
246
|
+
console.log(` receipt: ${res.receipt.replace(home, '~')}\n`);
|
|
247
|
+
return res.error ? 1 : 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
172
250
|
module.exports = { benchCommand, improveCommand };
|
|
@@ -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]
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
128
|
-
console.log(`
|
|
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 };
|
package/lib/sentinel-bench.js
CHANGED
|
@@ -158,12 +158,18 @@ function keyFromKeychain() {
|
|
|
158
158
|
return add.status === 0 ? read() : null;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// Where the last key came from: 'keychain' or 'file'. The night run asks
|
|
162
|
+
// for this in the report and refuses a file key: a reviewer that can read
|
|
163
|
+
// files could read a key file too.
|
|
164
|
+
let lastKeySource = null;
|
|
165
|
+
|
|
161
166
|
function holdoutKey(env = process.env) {
|
|
162
|
-
if (env.ATRIS_SENTINEL_KEY_FILE) return keyFromFile(env.ATRIS_SENTINEL_KEY_FILE);
|
|
167
|
+
if (env.ATRIS_SENTINEL_KEY_FILE) { lastKeySource = 'file'; return keyFromFile(env.ATRIS_SENTINEL_KEY_FILE); }
|
|
163
168
|
if (process.platform === 'darwin' && env.ATRIS_SENTINEL_KEYCHAIN !== '0') {
|
|
164
169
|
const k = keyFromKeychain();
|
|
165
|
-
if (k) return k;
|
|
170
|
+
if (k) { lastKeySource = 'keychain'; return k; }
|
|
166
171
|
}
|
|
172
|
+
lastKeySource = 'file';
|
|
167
173
|
return keyFromFile(path.join(homeDir(env), '.atris', 'sentinel-judge', 'holdout.key'));
|
|
168
174
|
}
|
|
169
175
|
|
|
@@ -416,9 +422,47 @@ async function runBench({ target, seenDir, holdoutDir, sets = ['guard', 'agents'
|
|
|
416
422
|
report[half][set] = score;
|
|
417
423
|
}
|
|
418
424
|
}
|
|
425
|
+
report.key_source = lastKeySource;
|
|
419
426
|
return report;
|
|
420
427
|
}
|
|
421
428
|
|
|
429
|
+
// ---------------------------------------------------------------- screen
|
|
430
|
+
|
|
431
|
+
// Lowercase with whitespace collapsed, for overlap checks.
|
|
432
|
+
function normalizeText(v) {
|
|
433
|
+
return String(v || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function itemTexts(set, item) {
|
|
437
|
+
if (set === 'guard') return [item.command !== undefined ? item.command : JSON.stringify(item.input || '')];
|
|
438
|
+
return Object.entries(item.files || {}).map(([f, c]) => `${f} ${typeof c === 'string' ? c : JSON.stringify(c)}`).concat(Object.values(item.files || {}).map((c) => (typeof c === 'string' ? c : JSON.stringify(c))));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// New seen items proposed from outside (the night reviewer) checked against
|
|
442
|
+
// the held-out half. This runs in the judge, the one process that holds it. An item
|
|
443
|
+
// whose normalized text equals, contains or sits inside any held-out item's
|
|
444
|
+
// text is refused. Only the refused ids leave this function, never a
|
|
445
|
+
// held-out item. A held-out set that cannot be read refuses everything.
|
|
446
|
+
function screenItems(candidates, { holdoutDir, env = process.env } = {}) {
|
|
447
|
+
const held = path.resolve(holdoutDir || defaultHoldoutDir(env));
|
|
448
|
+
const texts = [];
|
|
449
|
+
let unreadable = false;
|
|
450
|
+
for (const set of ['guard', 'agents']) {
|
|
451
|
+
try {
|
|
452
|
+
const loaded = loadSet(held, set, { held: true, env });
|
|
453
|
+
for (const item of loaded.items) for (const t of itemTexts(set, item)) { const n = normalizeText(t); if (n) texts.push(n); }
|
|
454
|
+
} catch { unreadable = true; }
|
|
455
|
+
}
|
|
456
|
+
const refused = [];
|
|
457
|
+
for (const c of candidates) {
|
|
458
|
+
if (!c || typeof c.id !== 'string' || (c.set !== 'guard' && c.set !== 'agents')) continue;
|
|
459
|
+
if (unreadable) { refused.push(c.id); continue; }
|
|
460
|
+
const mine = itemTexts(c.set, c.item || {}).map(normalizeText).filter(Boolean);
|
|
461
|
+
if (!mine.length || mine.some((m) => texts.some((h) => h === m || h.includes(m) || m.includes(h)))) refused.push(c.id);
|
|
462
|
+
}
|
|
463
|
+
return { refused, key_source: lastKeySource, unreadable };
|
|
464
|
+
}
|
|
465
|
+
|
|
422
466
|
// What may leave this module: held-out totals only, never an item. With
|
|
423
467
|
// summary, seen items are dropped too.
|
|
424
468
|
function publicReport(report, { summary = false } = {}) {
|
|
@@ -437,6 +481,8 @@ function publicReport(report, { summary = false } = {}) {
|
|
|
437
481
|
holdout_dir: report.holdout_dir,
|
|
438
482
|
// Sealed split sources, paths only. The improver uses them to deny reads.
|
|
439
483
|
sealed_sources: report.sealed_sources || [],
|
|
484
|
+
// How the judge got the held-out key: 'keychain' or 'file'. Never the key.
|
|
485
|
+
key_source: report.key_source || null,
|
|
440
486
|
};
|
|
441
487
|
}
|
|
442
488
|
|
|
@@ -541,6 +587,8 @@ module.exports = {
|
|
|
541
587
|
SETS,
|
|
542
588
|
readHeldText,
|
|
543
589
|
readSealedSources,
|
|
590
|
+
normalizeText,
|
|
591
|
+
screenItems,
|
|
544
592
|
FAKE,
|
|
545
593
|
defaultHoldoutDir,
|
|
546
594
|
expand,
|
package/lib/sentinel-events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
package/lib/sentinel-guard.js
CHANGED
|
@@ -704,13 +704,26 @@ const BLOCK_REASON = {
|
|
|
704
704
|
disarm: ['blocked: this command matches a known way to turn off a protection.', 'An agent ran a command that matches a known way to turn off a protection. Nothing changed.'],
|
|
705
705
|
};
|
|
706
706
|
|
|
707
|
-
// Literal command blocks from the rules file
|
|
708
|
-
//
|
|
709
|
-
//
|
|
707
|
+
// Literal command blocks from the rules file, matched with plain substring
|
|
708
|
+
// checks in linear time, never a regular expression. A block that lists
|
|
709
|
+
// programs reads only the words of each piece of the command that runs one of
|
|
710
|
+
// them, so harmless pieces chained with && or ; never add up to a match. A
|
|
711
|
+
// block with no programs reads the whole command. Fail closed: when a piece can
|
|
712
|
+
// receive code from elsewhere (a pipe, any redirect or file write, a heredoc or
|
|
713
|
+
// here-string, a command substitution, a shell variable, eval, source or a shell
|
|
714
|
+
// string), every block reads the whole command. Per-piece matching is only for
|
|
715
|
+
// plain chains that pass nothing between pieces.
|
|
716
|
+
// Accepted limit: the guard judges one command at a time, so a script written by
|
|
717
|
+
// one command and run by a later, separate command is not connected by any rule.
|
|
718
|
+
const FEEDS_CODE = ['|', '<', '>', '`', '$', 'tee ', 'eval', 'source ', 'sh -c', 'bash -c', 'zsh -c'];
|
|
710
719
|
function checkBlocks(command, cmds) {
|
|
720
|
+
const whole = FEEDS_CODE.some((t) => command.includes(t));
|
|
711
721
|
for (const b of R().commandBlocks) {
|
|
722
|
+
const texts = b.programs && !whole
|
|
723
|
+
? cmds.filter((c) => b.programs.has(c.name)).map((c) => (c.words || [c.name, ...(c.args || [])]).join(' '))
|
|
724
|
+
: [command];
|
|
712
725
|
if (b.programs && !cmds.some((c) => b.programs.has(c.name))) continue;
|
|
713
|
-
if (!b.all.every((sub) =>
|
|
726
|
+
if (!texts.some((t) => b.all.every((sub) => t.includes(sub)))) continue;
|
|
714
727
|
const [reason, detail] = BLOCK_REASON[b.category];
|
|
715
728
|
return block(`${b.category}-block`, b.category === 'disarm' ? 'high' : 'critical', reason, detail);
|
|
716
729
|
}
|