atris 3.82.8 → 3.82.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/sentinel-bench.js +83 -5
- package/lib/sentinel-bench.js +50 -2
- package/lib/sentinel-guard.js +198 -10
- package/lib/sentinel-improve.js +136 -19
- package/lib/sentinel-night.js +910 -0
- package/lib/sentinel-rules.js +15 -3
- package/lib/sentinel-rules.json +10 -1
- package/package.json +1 -1
|
@@ -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 };
|
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-guard.js
CHANGED
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
//
|
|
37
37
|
// Accepted limits: a determined program that hides its intent can still get
|
|
38
38
|
// past fixed rules. Encoded payloads, a script written to disk and then run,
|
|
39
|
-
// and
|
|
40
|
-
//
|
|
39
|
+
// and code piped into an interpreter are not read. Inline code like python -c
|
|
40
|
+
// or node -e is read only by command blocks in the rules file that ask for
|
|
41
|
+
// it (inline, needs_secret, needs_remote), and by the protected path check. The guard is a seatbelt, not a sandbox. Pair it with
|
|
41
42
|
// approval prompts for anything risky.
|
|
42
43
|
//
|
|
43
44
|
// The guard fails open: if it crashes or reads bad input it allows the call
|
|
@@ -135,8 +136,11 @@ function compileRules(raw) {
|
|
|
135
136
|
commandBlocks: (Array.isArray(g.command_blocks) ? g.command_blocks : []).map((b, i) => {
|
|
136
137
|
if (!b || !RULE_CATEGORIES.includes(b.category)) throw new Error(`rules: guard.command_blocks[${i}].category must be exfil, destroy or disarm`);
|
|
137
138
|
const all = list(b.all, `command_blocks[${i}].all`);
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
const needsSecret = b.needs_secret === true;
|
|
140
|
+
const needsRemote = b.needs_remote === true;
|
|
141
|
+
if (!all.length && !needsSecret && !needsRemote) throw new Error(`rules: guard.command_blocks[${i}].all needs at least one substring`);
|
|
142
|
+
const unless = b.unless === undefined ? [] : list(b.unless, `command_blocks[${i}].unless`);
|
|
143
|
+
return { id: String(b.id || i), category: b.category, programs: Array.isArray(b.programs) ? new Set(b.programs.map(String)) : null, all, inline: b.inline === true, needsSecret, needsRemote, unless };
|
|
140
144
|
}),
|
|
141
145
|
};
|
|
142
146
|
}
|
|
@@ -704,13 +708,197 @@ const BLOCK_REASON = {
|
|
|
704
708
|
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
709
|
};
|
|
706
710
|
|
|
707
|
-
// Literal command blocks from the rules file
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
+
// Literal command blocks from the rules file, matched with plain substring
|
|
712
|
+
// checks in linear time, never a regular expression. A block that lists
|
|
713
|
+
// programs reads only the words of each piece of the command that runs one of
|
|
714
|
+
// them, so harmless pieces chained with && or ; never add up to a match. A
|
|
715
|
+
// block with no programs reads the whole command. Fail closed: when a piece can
|
|
716
|
+
// receive code from elsewhere (a pipe, any redirect or file write, a heredoc or
|
|
717
|
+
// here-string, a command substitution, a shell variable, eval, source or a shell
|
|
718
|
+
// string), every block reads the whole command. Per-piece matching is only for
|
|
719
|
+
// plain chains that pass nothing between pieces.
|
|
720
|
+
// Accepted limit: the guard judges one command at a time, so a script written by
|
|
721
|
+
// one command and run by a later, separate command is not connected by any rule.
|
|
722
|
+
//
|
|
723
|
+
// Four optional conditions narrow a block. All are fixed code here, plain
|
|
724
|
+
// string scans and URL parsing in linear time. Nothing from the rules file is
|
|
725
|
+
// ever compiled.
|
|
726
|
+
// inline the piece must run a program with inline code (python -c,
|
|
727
|
+
// node -e, --eval or -p, perl -e, ruby -e, php -r) and the
|
|
728
|
+
// block reads only that code. When the code itself expands a
|
|
729
|
+
// shell value ($ or a command substitution), the code could be
|
|
730
|
+
// anything, so the block reads the whole command instead.
|
|
731
|
+
// needs_secret the text must name a secret: a secret-looking variable
|
|
732
|
+
// ($GITHUB_TOKEN, os.environ['OPENAI_API_KEY'], os.getenv,
|
|
733
|
+
// process.env.X, ENV['X'], $ENV{X}), the whole environment
|
|
734
|
+
// (dict(os.environ), JSON.stringify(process.env)), a secrets
|
|
735
|
+
// file or a key-shaped string. For inline code, what is piped
|
|
736
|
+
// or redirected into that piece counts too.
|
|
737
|
+
// needs_remote the text must hold an http(s) address or a host:port whose
|
|
738
|
+
// host is not this machine and not on the trusted host list.
|
|
739
|
+
// For inline code it must also make a network call (urlopen,
|
|
740
|
+
// requests, fetch, Net::HTTP, LWP, socket and the like).
|
|
741
|
+
// unless literal substrings. If any is in the text, the block does
|
|
742
|
+
// not fire on it.
|
|
743
|
+
// A block with none of them behaves exactly as before.
|
|
744
|
+
// Accepted limit: PHP $_ENV['X'] access is not read as a secret name.
|
|
745
|
+
// Accepted limit: a connection to a bare host with no URL or host:port, like
|
|
746
|
+
// socket.connect(('badhost.net', 443)), is not read as a remote address.
|
|
747
|
+
const FEEDS_CODE = ['|', '<', '>', '`', '$', 'tee ', 'eval', 'source ', 'sh -c', 'bash -c', 'zsh -c'];
|
|
748
|
+
|
|
749
|
+
// Interpreter families and the options that carry inline code.
|
|
750
|
+
const INLINE_SPEC = {
|
|
751
|
+
python: { long: [], short: 'c', takesValue: ['-W', '-X', '-Q'] },
|
|
752
|
+
node: { long: ['--eval', '--print'], short: 'ep', takesValue: ['-r', '--require', '--import', '--loader', '--experimental-loader', '--input-type', '--title', '--env-file'] },
|
|
753
|
+
perl: { long: [], short: 'eE', takesValue: ['-I', '-M', '-m'] },
|
|
754
|
+
ruby: { long: [], short: 'e', takesValue: ['-I', '-r'] },
|
|
755
|
+
php: { long: [], short: 'r', takesValue: ['-d', '-c'] },
|
|
756
|
+
other: { long: ['--eval'], short: 'ce', takesValue: [] },
|
|
757
|
+
};
|
|
758
|
+
function inlineFamily(name) {
|
|
759
|
+
const n = String(name || '').toLowerCase();
|
|
760
|
+
if (/^(?:python|pypy)(?:\d+(?:\.\d+)?)?$/.test(n)) return 'python';
|
|
761
|
+
if (n === 'node' || n === 'nodejs' || n === 'bun') return 'node';
|
|
762
|
+
if (n === 'perl' || n === 'ruby' || n === 'php') return n;
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// The inline code arguments of one piece, or [] when it carries none. Stops
|
|
767
|
+
// at the first plain argument: that is a script file, and what follows it
|
|
768
|
+
// belongs to the script.
|
|
769
|
+
function inlineCode(c) {
|
|
770
|
+
const spec = INLINE_SPEC[inlineFamily(c.name) || 'other'];
|
|
771
|
+
const args = (c.args || []).map(String);
|
|
772
|
+
const out = [];
|
|
773
|
+
for (let k = 0; k < args.length; k++) {
|
|
774
|
+
const a = args[k];
|
|
775
|
+
if (a === '--' || a === '-' || !a.startsWith('-')) break;
|
|
776
|
+
if (a.startsWith('--')) {
|
|
777
|
+
const eq = a.indexOf('=');
|
|
778
|
+
const name = eq === -1 ? a : a.slice(0, eq);
|
|
779
|
+
if (spec.long.includes(name)) { if (eq !== -1) out.push(a.slice(eq + 1)); else if (k + 1 < args.length) out.push(args[++k]); continue; }
|
|
780
|
+
if (spec.takesValue.includes(name) && eq === -1) k += 1;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (spec.takesValue.includes(a)) { k += 1; continue; }
|
|
784
|
+
const letters = a.slice(1);
|
|
785
|
+
// node -pe CODE: print and eval together, the code is the next word.
|
|
786
|
+
if (inlineFamily(c.name) === 'node' && (letters === 'pe' || letters === 'ep')) { if (k + 1 < args.length) out.push(args[++k]); continue; }
|
|
787
|
+
// Read like getopt: the first code letter takes the rest of the word as
|
|
788
|
+
// its code (python3 -c"CODE", perl -e'CODE', perl -ne'CODE'), or the next
|
|
789
|
+
// word when nothing follows it (-c CODE, perl -ne CODE).
|
|
790
|
+
const at = [...letters].findIndex((ch) => spec.short.includes(ch));
|
|
791
|
+
if (at === -1) continue;
|
|
792
|
+
if (at + 1 < letters.length) out.push(letters.slice(at + 1));
|
|
793
|
+
else if (k + 1 < args.length) out.push(args[++k]);
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Environment lookups by name in inline code. Hand-written, linear: one
|
|
799
|
+
// character class per run, no nesting.
|
|
800
|
+
const ENV_NAME_SHAPES = [
|
|
801
|
+
/(?:\benviron|\bENV|\$ENV|\benv)\s*[[{]\s*['"`]?([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
802
|
+
/\b(?:getenv|environ\.get|environ\.pop|environ\.setdefault|ENV\.fetch|env\.get)\s*\(\s*['"`]([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
803
|
+
/\b(?:process|Bun|meta)\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
804
|
+
];
|
|
805
|
+
// The whole environment at once.
|
|
806
|
+
const WHOLE_ENV = [
|
|
807
|
+
/\bos\.environ\b(?!\s*(?:\[|\.get\b|\.pop\b|\.setdefault\b))/,
|
|
808
|
+
/\bprocess\.env\b(?!\s*[.[])/,
|
|
809
|
+
/%ENV\b/,
|
|
810
|
+
/\bENV\.(?:to_h|to_a|each|map|inspect|keys|values)\b/,
|
|
811
|
+
/\bgetenv\(\s*\)/,
|
|
812
|
+
];
|
|
813
|
+
function namesSecret(text) {
|
|
814
|
+
const t = String(text || '');
|
|
815
|
+
if (hasSecretVar(t)) return true;
|
|
816
|
+
for (const re of ENV_NAME_SHAPES) {
|
|
817
|
+
re.lastIndex = 0;
|
|
818
|
+
let m;
|
|
819
|
+
while ((m = re.exec(t))) if (secretVarName(m[1])) return true;
|
|
820
|
+
}
|
|
821
|
+
if (WHOLE_ENV.some((re) => re.test(t))) return true;
|
|
822
|
+
if (R().secretFiles.some((re) => re.test(t))) return true;
|
|
823
|
+
return R().keyShapes.some((re) => { re.lastIndex = 0; return re.test(t); });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// What flows into a piece from elsewhere: files redirected in and the
|
|
827
|
+
// earlier stages of its pipeline.
|
|
828
|
+
// earlier stages of its pipeline. Each pipeline is scanned once per command:
|
|
829
|
+
// `seen` keeps, per pipeline, whether a secret enters before each stage.
|
|
830
|
+
function inputNamesSecret(c, seen) {
|
|
831
|
+
if ((c.reads || []).some((f) => namesSecret(f))) return true;
|
|
832
|
+
const pipe = c.pipe || [];
|
|
833
|
+
let before = seen.get(pipe);
|
|
834
|
+
if (!before) {
|
|
835
|
+
before = [false];
|
|
836
|
+
for (const st of pipe) before.push(before[before.length - 1] || isEnvDump(st) || namesSecret((st.words || [st.name, ...(st.args || [])]).join(' ')));
|
|
837
|
+
seen.set(pipe, before);
|
|
838
|
+
}
|
|
839
|
+
return before[c.index || 0];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// A network call in inline code. needs_remote on inline code wants one of
|
|
843
|
+
// these next to the address, so printing a docs link is not a send.
|
|
844
|
+
const NET_CALLS = ['urlopen', 'requests', 'http.client', 'urllib', 'fetch', 'https.request', 'http.request', 'https.get', 'http.get', 'axios', 'net.connect', 'curl_exec', 'file_get_contents', 'Net::HTTP', 'LWP', 'socket'];
|
|
845
|
+
const callsNet = (text) => NET_CALLS.some((w) => text.includes(w));
|
|
846
|
+
|
|
847
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
|
848
|
+
const URL_SHAPE = /\bhttps?:\/\/[^\s'"`<>(){}\\,;|]+/gi;
|
|
849
|
+
const HOST_PORT = /^([A-Za-z0-9][A-Za-z0-9.-]*|\[[0-9A-Fa-f:.]+\]):(\d{1,5})(?:\/\S*)?$/;
|
|
850
|
+
function isRemote(host, allowHosts) {
|
|
851
|
+
const h = String(host || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
852
|
+
if (!h || LOCAL_HOSTS.has(h)) return false;
|
|
853
|
+
return !hostAllowed(h, allowHosts);
|
|
854
|
+
}
|
|
855
|
+
function namesRemote(text, allowHosts) {
|
|
856
|
+
const t = String(text || '');
|
|
857
|
+
URL_SHAPE.lastIndex = 0;
|
|
858
|
+
let m;
|
|
859
|
+
while ((m = URL_SHAPE.exec(t))) {
|
|
860
|
+
let u;
|
|
861
|
+
try { u = new URL(m[0]); } catch { continue; }
|
|
862
|
+
if (isRemote(u.hostname, allowHosts)) return true;
|
|
863
|
+
}
|
|
864
|
+
for (const tok of t.split(/[\s'"`(),;{}<>=+\\|&]+/)) {
|
|
865
|
+
if (!tok || tok.length > 300 || tok.includes('://')) continue;
|
|
866
|
+
const hp = HOST_PORT.exec(tok);
|
|
867
|
+
if (!hp) continue;
|
|
868
|
+
const host = hp[1];
|
|
869
|
+
if (!host.startsWith('[') && !host.includes('.')) continue;
|
|
870
|
+
if (Number(hp[2]) > 65535) continue;
|
|
871
|
+
if (isRemote(host, allowHosts)) return true;
|
|
872
|
+
}
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function checkBlocks(command, cmds, { allowHosts = R().allowHosts } = {}) {
|
|
877
|
+
const whole = FEEDS_CODE.some((t) => command.includes(t));
|
|
878
|
+
const seen = new Map();
|
|
711
879
|
for (const b of R().commandBlocks) {
|
|
712
880
|
if (b.programs && !cmds.some((c) => b.programs.has(c.name))) continue;
|
|
713
|
-
|
|
881
|
+
// Each entry: the text the block reads, and the piece it came from.
|
|
882
|
+
let texts;
|
|
883
|
+
if (b.inline) {
|
|
884
|
+
texts = [];
|
|
885
|
+
let wholeAdded = false;
|
|
886
|
+
for (const c of cmds) {
|
|
887
|
+
if (b.programs ? !b.programs.has(c.name) : !inlineFamily(c.name)) continue;
|
|
888
|
+
const code = inlineCode(c);
|
|
889
|
+
if (!code.length) continue;
|
|
890
|
+
const expands = code.some((x) => x.includes('$') || x.includes('`'));
|
|
891
|
+
// The whole command is read at most once, however many pieces expand.
|
|
892
|
+
if (expands) { if (!wholeAdded) texts.push({ text: command, piece: c }); wholeAdded = true; } else texts.push({ text: code.join('\n'), piece: c });
|
|
893
|
+
}
|
|
894
|
+
} else if (b.programs && !whole) {
|
|
895
|
+
texts = cmds.filter((c) => b.programs.has(c.name)).map((c) => ({ text: (c.words || [c.name, ...(c.args || [])]).join(' '), piece: null }));
|
|
896
|
+
} else texts = [{ text: command, piece: null }];
|
|
897
|
+
const fires = texts.some(({ text, piece }) => b.all.every((sub) => text.includes(sub))
|
|
898
|
+
&& !b.unless.some((sub) => text.includes(sub))
|
|
899
|
+
&& (!b.needsSecret || namesSecret(text) || (piece && inputNamesSecret(piece, seen)))
|
|
900
|
+
&& (!b.needsRemote || ((!b.inline || callsNet(text)) && namesRemote(text, allowHosts))));
|
|
901
|
+
if (!fires) continue;
|
|
714
902
|
const [reason, detail] = BLOCK_REASON[b.category];
|
|
715
903
|
return block(`${b.category}-block`, b.category === 'disarm' ? 'high' : 'critical', reason, detail);
|
|
716
904
|
}
|
|
@@ -737,7 +925,7 @@ function decideWith(event, { env, gitBranch }) {
|
|
|
737
925
|
return checkExfil(command, cmds, { allowHosts })
|
|
738
926
|
|| checkDestroy(command, cmds, { cwd, homeDirPath: homeDir(env), gitBranch })
|
|
739
927
|
|| checkDisarm(command, cmds, { env, cwd })
|
|
740
|
-
|| checkBlocks(command, cmds)
|
|
928
|
+
|| checkBlocks(command, cmds, { allowHosts })
|
|
741
929
|
|| { allow: true };
|
|
742
930
|
}
|
|
743
931
|
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return checkEdit(tool, input, { env, cwd }) || { allow: true };
|