atris 3.82.5 → 3.82.7

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
@@ -634,6 +634,8 @@ function showHelpAll() {
634
634
  console.log(' slop - Deterministic slop detector: frontend/prose tells, dead code (slop dead --exports)');
635
635
  console.log(' sentinel - Security audit: engines hunt for holes, a different model verifies each one');
636
636
  console.log(' free checks: sentinel agents, sentinel watch, sentinel guard install');
637
+ console.log(' exam and self-improvement: sentinel bench, sentinel improve');
638
+ console.log(' fix a confirmed finding on its own branch: sentinel fix <id>');
637
639
  console.log(' re-check old findings against today\'s code: sentinel recheck');
638
640
  console.log(' voice - Scan binary voice tells or judge reply shape and plainness');
639
641
  console.log(' recover - Explain stopped missions; --apply performs safe recovery only');
@@ -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);
@@ -860,6 +865,18 @@ function printAuditHelp() {
860
865
  results go to your atris dashboard when you are logged in. add --no-upload
861
866
  to keep them on this machine. secret values and file contents never leave.
862
867
 
868
+ the exam and the loop that improves the sentinel (in an atris checkout):
869
+
870
+ atris sentinel bench [--summary] score the guard and agent check on a seen and a held-out half
871
+ atris sentinel improve [--engine claude] one engine proposes a change, the exam decides if it stays
872
+ atris sentinel improve --schedule run the improve loop nightly
873
+
874
+ turning a confirmed finding into a fix:
875
+
876
+ atris sentinel fix list confirmed findings you can fix
877
+ atris sentinel fix <id> [path] smallest fix plus a regression test on a new branch
878
+ atris sentinel fix help how a fix is proved before it is committed
879
+
863
880
  reviews on a timer:
864
881
 
865
882
  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 };
@@ -2233,6 +2233,9 @@ module.exports = {
2233
2233
  runRecheck,
2234
2234
  isHostedEngine,
2235
2235
  hostedAllowedFiles,
2236
+ sensitiveFile,
2237
+ isRepoRel,
2238
+ TEST_PATH,
2236
2239
  latestRun,
2237
2240
  repoName,
2238
2241
  shouldFailAudit,
@@ -27,6 +27,60 @@ const AREA_LABEL = {
27
27
  mcp: 'mcp servers',
28
28
  hidden: 'hidden instructions',
29
29
  };
30
+ // The hand-written regular expressions below stay in code, out of the
31
+ // proposer's reach. The rules file (the agents part) holds only literal
32
+ // supplements the proposer may extend: extra override substrings (matched
33
+ // lowercase with plain includes) and extra secret environment names. They
34
+ // are data, never code. They are matched with string operations.
35
+ const RULES_FILE = path.join(__dirname, 'sentinel-rules.json');
36
+
37
+ const SECRET_KEY_NAME = /(?:^|_|-)(?:api[_-]?key|key|token|secret|password|passwd|pat|credential|credentials|auth)(?:$|_|-)/i;
38
+ const DOWNLOAD = '(?:curl|wget|fetch|iwr|Invoke-WebRequest|irm|Invoke-RestMethod)\\b';
39
+ const DOWNLOAD_RUN = [
40
+ new RegExp(`${DOWNLOAD}[^\\n|;&]*\\|\\s*(?:sudo\\s+)?(?:env\\s+)?(?:ba|z|da|k|fi)?sh\\b`, 'i'),
41
+ new RegExp(`${DOWNLOAD}[^\\n|;&]*\\|\\s*(?:sudo\\s+)?(?:python[0-9.]*|node|perl|ruby|php|iex|Invoke-Expression)\\b`, 'i'),
42
+ new RegExp(`(?:ba|z|k)?sh\\s+<\\(\\s*${DOWNLOAD}`, 'i'),
43
+ new RegExp(`(?:ba|z|k)?sh\\s+-c\\s+["']?\\$\\(\\s*${DOWNLOAD}`, 'i'),
44
+ new RegExp(`source\\s+<\\(\\s*${DOWNLOAD}`, 'i'),
45
+ ];
46
+ const EVAL_FETCHED = [
47
+ new RegExp(`\\beval\\s+["']?\\$\\(\\s*${DOWNLOAD}`, 'i'),
48
+ new RegExp(`\\beval\\s+["']?\`\\s*${DOWNLOAD}`, 'i'),
49
+ /\beval\s*\(\s*(?:await\s+)?\(?\s*(?:await\s+)?fetch\s*\(/i,
50
+ /\bexec\s*\(\s*(?:urllib\.request\.)?urlopen\s*\(/i,
51
+ /\bexec\s*\(\s*requests\.get\s*\(/i,
52
+ ];
53
+ const SKIP_FLAGS = /(?:^|\s)(?:--dangerously-skip-permissions|--dangerously-bypass-approvals-and-sandbox|--yolo|--permission-mode[ =]bypassPermissions|--approval-mode[ =]yolo|--allow-dangerously-skip-permissions)(?=\s|$|["'])/;
54
+ const OVERRIDE = [
55
+ /\b(?:ignore|disregard|forget|override|bypass)\s+(?:all\s+|any\s+|every\s+)?(?:of\s+)?(?:the\s+|your\s+|my\s+)?(?:previous|prior|above|earlier|preceding|original|system|other)\s+(?:instructions?|prompts?|rules|directions|guidelines|messages)\b/i,
56
+ /\byou\s+are\s+now\s+(?:in\s+)?(?:developer|dan|jailbreak|god)\s+mode\b/i,
57
+ /\b(?:do\s+not|don't|never)\s+(?:tell|inform|show)\s+the\s+user\s+(?:about|that|what|anything|this|you\b|we\b)/i,
58
+ /\b(?:do\s+not|don't|never)\s+mention\s+(?:this|it)\s+to\s+the\s+user\b/i,
59
+ /\bwithout\s+(?:telling|informing|alerting)\s+the\s+user\b/i,
60
+ /\bhide\s+this\s+from\s+the\s+user\b/i,
61
+ /\bnew\s+system\s+prompt\s*:/i,
62
+ ];
63
+ const COMMENT_DIRECTIVE = /\b(?:ignore\s+(?:the|all|any|previous|prior|above)|disregard|secretly|silently|do\s+not\s+tell|don't\s+tell|without\s+telling|curl|wget|exfiltrate|send\s+(?:the|all|any|your)\s+\w+\s+to|system\s+prompt|you\s+are\s+(?:now|an?|the)|you\s+must|always\s+run|run\s+the\s+following|execute\s+(?:the|this))\b/i;
64
+
65
+ // The literal supplements from the rules file. A proposer may add plain
66
+ // phrases and names here; the checks below match them with string ops.
67
+ function compileRules(raw) {
68
+ const a = (raw && raw.agents) || {};
69
+ const list = (v, name) => { if (v === undefined) return []; if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) throw new Error(`rules: agents.${name} must be a list of strings`); return v; };
70
+ return {
71
+ overrideSubstrings: list(a.override_substrings, 'override_substrings').map((x) => x.toLowerCase()),
72
+ secretEnvNames: new Set(list(a.secret_env_names, 'secret_env_names')),
73
+ };
74
+ }
75
+
76
+ let DEFAULT_RULES = null;
77
+ let ACTIVE_RULES = null;
78
+ function R() {
79
+ if (ACTIVE_RULES) return ACTIVE_RULES;
80
+ if (!DEFAULT_RULES) DEFAULT_RULES = compileRules(JSON.parse(fs.readFileSync(RULES_FILE, 'utf8')));
81
+ return DEFAULT_RULES;
82
+ }
83
+
30
84
  const MAX_FILE_BYTES = 512 * 1024;
31
85
  const MAX_FILES = 4000;
32
86
  const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build', '.trash', '.Trash']);
@@ -158,7 +212,6 @@ function secretHits(text) {
158
212
  // "API_KEY": "abc..." in JSON, and API_KEY = "abc..." in TOML. Only keys
159
213
  // whose name says secret, and only values that look real (not $VAR, not a
160
214
  // placeholder, not short).
161
- const SECRET_KEY_NAME = /(?:^|_|-)(?:api[_-]?key|key|token|secret|password|passwd|pat|credential|credentials|auth)(?:$|_|-)/i;
162
215
  function envBlockHits(text) {
163
216
  let scan;
164
217
  try { scan = require('./security-scan'); } catch { return []; }
@@ -167,7 +220,7 @@ function envBlockHits(text) {
167
220
  let m;
168
221
  while ((m = re.exec(text))) {
169
222
  const [, key, value] = m;
170
- if (!SECRET_KEY_NAME.test(key)) continue;
223
+ if (!SECRET_KEY_NAME.test(key) && !R().secretEnvNames.has(key)) continue;
171
224
  if (/^\$|\$\{|^<|^\{\{|^env:|^op:\/\/|^\/|^~|^https?:\/\//i.test(value)) continue;
172
225
  if (/\s/.test(value)) continue;
173
226
  const q = scan.secretQuality(value, {}, m[0]);
@@ -179,21 +232,6 @@ function envBlockHits(text) {
179
232
 
180
233
  // Download piped into a shell or interpreter, process substitution of a
181
234
  // download, and eval of fetched content.
182
- const DOWNLOAD = '(?:curl|wget|fetch|iwr|Invoke-WebRequest|irm|Invoke-RestMethod)\\b';
183
- const DOWNLOAD_RUN = [
184
- new RegExp(`${DOWNLOAD}[^\\n|;&]*\\|\\s*(?:sudo\\s+)?(?:env\\s+)?(?:ba|z|da|k|fi)?sh\\b`, 'i'),
185
- new RegExp(`${DOWNLOAD}[^\\n|;&]*\\|\\s*(?:sudo\\s+)?(?:python[0-9.]*|node|perl|ruby|php|iex|Invoke-Expression)\\b`, 'i'),
186
- new RegExp(`(?:ba|z|k)?sh\\s+<\\(\\s*${DOWNLOAD}`, 'i'),
187
- new RegExp(`(?:ba|z|k)?sh\\s+-c\\s+["']?\\$\\(\\s*${DOWNLOAD}`, 'i'),
188
- new RegExp(`source\\s+<\\(\\s*${DOWNLOAD}`, 'i'),
189
- ];
190
- const EVAL_FETCHED = [
191
- new RegExp(`\\beval\\s+["']?\\$\\(\\s*${DOWNLOAD}`, 'i'),
192
- new RegExp(`\\beval\\s+["']?\`\\s*${DOWNLOAD}`, 'i'),
193
- /\beval\s*\(\s*(?:await\s+)?\(?\s*(?:await\s+)?fetch\s*\(/i,
194
- /\bexec\s*\(\s*(?:urllib\.request\.)?urlopen\s*\(/i,
195
- /\bexec\s*\(\s*requests\.get\s*\(/i,
196
- ];
197
235
 
198
236
  function downloadRunHits(text) {
199
237
  const out = [];
@@ -205,7 +243,6 @@ function downloadRunHits(text) {
205
243
  return out;
206
244
  }
207
245
 
208
- const SKIP_FLAGS = /(?:^|\s)(?:--dangerously-skip-permissions|--dangerously-bypass-approvals-and-sandbox|--yolo|--permission-mode[ =]bypassPermissions|--approval-mode[ =]yolo|--allow-dangerously-skip-permissions)(?=\s|$|["'])/;
209
246
 
210
247
  function flagHits(text) {
211
248
  const out = [];
@@ -239,36 +276,23 @@ function invisibleHits(text) {
239
276
  return out;
240
277
  }
241
278
 
242
- const OVERRIDE = [
243
- /\b(?:ignore|disregard|forget|override|bypass)\s+(?:all\s+|any\s+|every\s+)?(?:of\s+)?(?:the\s+|your\s+|my\s+)?(?:previous|prior|above|earlier|preceding|original|system|other)\s+(?:instructions?|prompts?|rules|directions|guidelines|messages)\b/i,
244
- /\byou\s+are\s+now\s+(?:in\s+)?(?:developer|dan|jailbreak|god)\s+mode\b/i,
245
- // Secrecy, not ordinary guidance: "do not tell the user about this", but
246
- // not "do not tell the user to restart".
247
- /\b(?:do\s+not|don't|never)\s+(?:tell|inform|show)\s+the\s+user\s+(?:about|that|what|anything|this|you\b|we\b)/i,
248
- /\b(?:do\s+not|don't|never)\s+mention\s+(?:this|it)\s+to\s+the\s+user\b/i,
249
- /\bwithout\s+(?:telling|informing|alerting)\s+the\s+user\b/i,
250
- /\bhide\s+this\s+from\s+the\s+user\b/i,
251
- /\bnew\s+system\s+prompt\s*:/i,
252
- ];
253
279
 
254
280
  function overrideHits(text) {
255
281
  const out = [];
282
+ const extra = R().overrideSubstrings;
256
283
  text.split('\n').forEach((line, i) => {
257
- for (const re of OVERRIDE) {
258
- const m = line.match(re);
259
- if (!m) continue;
260
- // Quoted or in code: a doc about prompt attacks, most likely.
261
- const at = m.index;
262
- const before = line.slice(0, at);
263
- const quoted = /["'`“‘]\s*$/.test(before) || (before.split('`').length % 2 === 0) || /^\s*>/.test(line);
264
- out.push({ line: i + 1, rule: quoted ? 'override-phrase-quoted' : 'override-phrase' });
265
- break;
266
- }
284
+ let at = -1;
285
+ for (const re of OVERRIDE) { const m = line.match(re); if (m) { at = m.index; break; } }
286
+ if (at === -1 && extra.length) { const low = line.toLowerCase(); for (const sub of extra) { const k = low.indexOf(sub); if (k !== -1) { at = k; break; } } }
287
+ if (at === -1) return;
288
+ // Quoted or in code: a doc about prompt attacks, most likely.
289
+ const before = line.slice(0, at);
290
+ const quoted = /["'`“‘]\s*$/.test(before) || (before.split('`').length % 2 === 0) || /^\s*>/.test(line);
291
+ out.push({ line: i + 1, rule: quoted ? 'override-phrase-quoted' : 'override-phrase' });
267
292
  });
268
293
  return out;
269
294
  }
270
295
 
271
- const COMMENT_DIRECTIVE = /\b(?:ignore\s+(?:the|all|any|previous|prior|above)|disregard|secretly|silently|do\s+not\s+tell|don't\s+tell|without\s+telling|curl|wget|exfiltrate|send\s+(?:the|all|any|your)\s+\w+\s+to|system\s+prompt|you\s+are\s+(?:now|an?|the)|you\s+must|always\s+run|run\s+the\s+following|execute\s+(?:the|this))\b/i;
272
296
  function hiddenCommentHits(text) {
273
297
  const out = [];
274
298
  // Comments inside fenced code blocks are examples, not hidden text.
@@ -500,7 +524,15 @@ function grade(findings) {
500
524
  return 'pass';
501
525
  }
502
526
 
503
- function runAgentCheck(root, { home = false, homeDir = os.homedir() } = {}) {
527
+ // rules: a rules object to judge instead of the file beside this one.
528
+ function runAgentCheck(root, { home = false, homeDir = os.homedir(), rules = null } = {}) {
529
+ if (!rules) return runAgentCheckWith(root, { home, homeDir });
530
+ const prev = ACTIVE_RULES;
531
+ ACTIVE_RULES = compileRules(rules);
532
+ try { return runAgentCheckWith(root, { home, homeDir }); } finally { ACTIVE_RULES = prev; }
533
+ }
534
+
535
+ function runAgentCheckWith(root, { home, homeDir }) {
504
536
  const targets = collectTargets(root, { home, homeDir });
505
537
  const findings = [];
506
538
  for (const t of targets) {
@@ -576,6 +608,8 @@ function renderReport(result, { color = false } = {}) {
576
608
  }
577
609
 
578
610
  module.exports = {
611
+ compileRules,
612
+ RULES_FILE,
579
613
  RULES,
580
614
  checkFile,
581
615
  runAgentCheck,