atris 3.82.9 → 3.82.11

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
@@ -638,6 +638,7 @@ function showHelpAll() {
638
638
  console.log(' exam and self-improvement: sentinel bench, sentinel improve');
639
639
  console.log(' fix a confirmed finding on its own branch: sentinel fix <id>');
640
640
  console.log(' re-check old findings against today\'s code: sentinel recheck');
641
+ console.log(' signed security record for reviewers: sentinel record, sentinel record export');
641
642
  console.log(' voice - Scan binary voice tells or judge reply shape and plainness');
642
643
  console.log(' recover - Explain stopped missions; --apply performs safe recovery only');
643
644
  console.log(' task - Local agent task plane (atomic claims, TODO import)');
@@ -724,6 +724,7 @@ async function auditRecheckCommand(argv, deps = {}) {
724
724
  const fail = audit.shouldFailRun(result.dir, result.records);
725
725
  if (opts.json) console.log(JSON.stringify({ ok: !fail, dir: result.dir, meta: result.meta, findings: result.records }, null, 2));
726
726
  else console.log(renderRecheckLanding(result));
727
+ addRunToRecord(root, result.dir, tell, deps);
727
728
  if (spend && spend.blocked) tell(` stopped at the approved limit of ${spend.max} credits. ${spend.blocked} model call${spend.blocked === 1 ? '' : 's'} did not run. those findings stay open`);
728
729
  if (opts.upload && token) {
729
730
  try { await uploadAfterRun(result.dir, { token, quiet: opts.json, cloud, root, env: deps.env || process.env }); } catch (e) { tell(` warning: could not save the summary to your atris account: ${e.message}. the re-check itself is done`); }
@@ -731,6 +732,17 @@ async function auditRecheckCommand(argv, deps = {}) {
731
732
  return fail ? 1 : 0;
732
733
  }
733
734
 
735
+ // Every finished review and recheck adds a signed entry to the security
736
+ // record. A failure warns and never changes the exit code.
737
+ function addRunToRecord(root, dir, tell, deps = {}) {
738
+ const rec = require('../lib/sentinel-record');
739
+ let res;
740
+ try { res = rec.appendSafe(root, rec.entryFromRun(dir), { recordHome: deps.recordHome, homeDir: deps.homeDir, env: deps.env }); } catch (e) { res = { ok: false, reason: e.message }; }
741
+ const w = rec.warnLine(res);
742
+ if (w) tell(w);
743
+ return res;
744
+ }
745
+
734
746
  async function auditCommand(argv, deps = {}) {
735
747
  if (argv[0] === 'help' || argv.includes('-h') || argv.includes('--help')) return printAuditHelp();
736
748
  const audit = require('../lib/security-audit');
@@ -752,6 +764,7 @@ async function auditCommand(argv, deps = {}) {
752
764
  return (argv[0] === 'bench' ? sb.benchCommand : sb.improveCommand)(argv.slice(1), deps);
753
765
  }
754
766
  if (argv[0] === 'fix') return require('./sentinel-fix').fixCommand(argv.slice(1), deps);
767
+ if (argv[0] === 'record') return require('./sentinel-record').recordCommand(argv.slice(1), deps);
755
768
  if (argv[0] === 'upload') return auditUploadCommand(argv.slice(1), deps);
756
769
  if (argv[0] === 'recheck') return auditRecheckCommand(argv.slice(1), deps);
757
770
  if (argv[0] === 'schedule') return auditScheduleCommand(argv.slice(1), deps);
@@ -802,6 +815,7 @@ async function auditCommand(argv, deps = {}) {
802
815
  const fail = !opts.planOnly && audit.shouldFailRun(result.dir, result.records);
803
816
  if (opts.json) console.log(JSON.stringify({ ok: !fail, dir: result.dir, meta: result.meta, findings: result.records }, null, 2));
804
817
  else console.log(renderAuditLanding(result));
818
+ if (!opts.planOnly) addRunToRecord(root, result.dir, tell, deps);
805
819
  if (spend && spend.blocked) tell(` stopped at the approved limit of ${spend.max} credits. ${spend.blocked} model call${spend.blocked === 1 ? '' : 's'} did not run and wait for the next run`);
806
820
  if (opts.upload && token && !opts.planOnly) {
807
821
  try { await uploadAfterRun(result.dir, { token, quiet: opts.json, cloud, root, env: deps.env || process.env }); } catch (e) { tell(` warning: could not save the summary to your atris account: ${e.message}. the review itself is done`); }
@@ -872,6 +886,15 @@ function printAuditHelp() {
872
886
  atris sentinel improve [--engine claude] one engine proposes a change, the exam decides if it stays
873
887
  atris sentinel improve --schedule run the improve loop nightly
874
888
 
889
+ the signed security record (hand it to a customer's security reviewer):
890
+
891
+ atris sentinel record [path] what was checked, what is open and fixed, how long fixes took
892
+ atris sentinel record key the public key and fingerprint reviewers compare against
893
+ atris sentinel record export --out record.html
894
+ one file that checks its own signatures in the browser
895
+ atris sentinel record verify FILE check an exported record offline
896
+ atris sentinel record backfill add past reviews, marked as added later
897
+
875
898
  turning a confirmed finding into a fix:
876
899
 
877
900
  atris sentinel fix list confirmed findings you can fix
@@ -222,6 +222,14 @@ async function fixCommand(argv, deps = {}) {
222
222
  }
223
223
  }
224
224
 
225
+ {
226
+ const rec = require('../lib/sentinel-record');
227
+ const o = { recordHome: deps.recordHome, homeDir: deps.homeDir, env: deps.env, record: deps.record };
228
+ let r;
229
+ try { r = rec.appendSafe(root, results.map((x) => rec.entryFromFix(x.record, x.res)), o); } catch (e) { r = { ok: false, reason: e.message }; }
230
+ const w = rec.warnLine(r);
231
+ if (w) console.log(w);
232
+ }
225
233
  const done = results.filter((x) => x.res.ok);
226
234
  if (done.length) {
227
235
  const events = require('../lib/sentinel-events');
@@ -78,6 +78,22 @@ function startUpdateNotice(argv = [], { env = process.env, check, timeoutMs = 15
78
78
  };
79
79
  }
80
80
 
81
+ // ---------------------------------------------------------------- record
82
+
83
+ // Each check adds a signed entry to the security record
84
+ // (lib/sentinel-record.js). A failure is a warning line, never an exit code.
85
+ function addToRecord(root, build, deps = {}) {
86
+ const rec = require('../lib/sentinel-record');
87
+ const o = { recordHome: deps.recordHome, homeDir: deps.homeDir, env: deps.env, record: deps.record };
88
+ if (deps.record === false) return { ok: true, skipped: true };
89
+ try { return rec.appendSafe(root, build(rec, o), o); } catch (e) { return { ok: false, reason: String(e.message || e).split('\n')[0] }; }
90
+ }
91
+
92
+ function recordWarn(res, json) {
93
+ const w = require('../lib/sentinel-record').warnLine(res);
94
+ if (w) (json ? console.error : console.log)(w);
95
+ }
96
+
81
97
  // ---------------------------------------------------------------- agents
82
98
 
83
99
  async function runAgents(root, { home = false, upload = true, deps = {} } = {}) {
@@ -90,7 +106,8 @@ async function runAgents(root, { home = false, upload = true, deps = {} } = {})
90
106
  try {
91
107
  res = await events.reportFindings(root, 'agent_check', evs, { token: upload ? tokenFor(deps) : '', base: deps.base, env: deps.env || process.env, upload });
92
108
  } catch (e) { res = { ok: false, reason: e.message }; }
93
- return { result, events: evs, upload: res };
109
+ const record = addToRecord(root, (rec, o) => rec.entryFromAgents(root, result, o), deps);
110
+ return { result, events: evs, upload: res, record };
94
111
  }
95
112
 
96
113
  async function agentsCommand(argv, deps = {}) {
@@ -98,7 +115,8 @@ async function agentsCommand(argv, deps = {}) {
98
115
  try { opts = parseFlags(argv, ['home', 'json', 'no-upload']); } catch (e) { console.error(` ${e.message}`); return 2; }
99
116
  let root;
100
117
  try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
101
- const { result, upload } = await runAgents(root, { home: Boolean(opts.home), upload: !opts.noUpload, deps });
118
+ const { result, upload, record } = await runAgents(root, { home: Boolean(opts.home), upload: !opts.noUpload, deps });
119
+ recordWarn(record, opts.json);
102
120
  if (opts.json) {
103
121
  console.log(JSON.stringify({ ok: result.grade !== 'fail', grade: result.grade, areas: result.areas, files: result.files, findings: result.findings, upload }, null, 2));
104
122
  } else {
@@ -153,7 +171,8 @@ async function runWatchCheck(root, { upload = true, deps = {} } = {}) {
153
171
  res.heldBranch = why.branch;
154
172
  } catch (e) { res = { ok: false, reason: e.message }; }
155
173
  }
156
- return { result, events: evs, upload: res, branch };
174
+ const record = addToRecord(root, (rec, o) => rec.entryFromWatch(root, result, { ...o, branch }), deps);
175
+ return { result, events: evs, upload: res, branch, record };
157
176
  }
158
177
 
159
178
  function heldLines(upload, branch) {
@@ -186,7 +205,8 @@ async function watchCommand(argv, deps = {}) {
186
205
  let root;
187
206
  try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
188
207
  const watch = require('../lib/sentinel-watch');
189
- const { result, upload, branch } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
208
+ const { result, upload, branch, record } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
209
+ recordWarn(record, opts.json);
190
210
  let fix = null;
191
211
  if (opts.fix && !result.offline && result.affected.length) {
192
212
  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] }; }
@@ -279,6 +299,7 @@ async function guardCommand(argv, deps = {}) {
279
299
  const blocks = inst.recentBlocks({ env });
280
300
  const events = require('../lib/sentinel-events');
281
301
  const waiting = events.queueSize(env);
302
+ recordWarn(addToRecord(root, (rec, o) => rec.entryFromGuard(root, o), { ...deps, env }), opts.json);
282
303
  if (opts.json) { console.log(JSON.stringify({ ...st, blocked_today: blocks.length, waiting_to_send: waiting }, null, 2)); return 0; }
283
304
  console.log('');
284
305
  if (st.installed) console.log(` the sentinel guard is on for ${where}`);
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ // atris sentinel record: the signed security record a reviewer can check.
4
+ //
5
+ // atris sentinel record [show] [path] plain summary
6
+ // atris sentinel record key [path] public key and fingerprint
7
+ // atris sentinel record export [path] --out FILE [--json]
8
+ // atris sentinel record verify FILE offline check of an export
9
+ // atris sentinel record backfill [path] add past review runs, marked as added later
10
+ //
11
+ // The guarantee and its limits sit at the top of lib/sentinel-record.js.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ function help() {
17
+ console.log(`
18
+ atris sentinel record, a signed security record you can hand to a reviewer
19
+
20
+ atris sentinel record [path] what sentinel checked, what is open and fixed
21
+ atris sentinel record key [path] this project's public key and its fingerprint
22
+ atris sentinel record export [path] --out record.html
23
+ one html file a reviewer opens and checks in the browser
24
+ atris sentinel record export [path] --out record.json --json
25
+ atris sentinel record verify FILE check an exported record offline
26
+ atris sentinel record backfill [path] add past reviews, marked as added later with their dates
27
+
28
+ every review, recheck, agent check, watch, guard status and fix adds an
29
+ entry. each entry holds the hash of the one before it and is signed with
30
+ a key kept in ~/.atris. an edited, moved or removed entry shows.
31
+
32
+ what it proves: the record came from this key and was not changed.
33
+ what it does not prove: that the checks were thorough or the code is safe.
34
+ entries hold plain sentences, counts, file:line places and short ids only.
35
+ `);
36
+ return 0;
37
+ }
38
+
39
+ function parse(argv) {
40
+ const opts = { paths: [] };
41
+ for (let i = 0; i < argv.length; i += 1) {
42
+ const a = argv[i];
43
+ if (a === '--out') { opts.out = argv[++i]; if (!opts.out) throw new Error('--out needs a file'); }
44
+ else if (a === '--json') opts.json = true;
45
+ else if (a.startsWith('-')) throw new Error(`unknown flag: ${a}`);
46
+ else opts.paths.push(a);
47
+ }
48
+ return opts;
49
+ }
50
+
51
+ function rootOf(opts, deps) {
52
+ const root = path.resolve(opts.paths[0] || deps.root || process.cwd());
53
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${root} is not a folder`);
54
+ return root;
55
+ }
56
+
57
+ function libOpts(deps) {
58
+ const o = {};
59
+ if (deps.recordHome) o.recordHome = deps.recordHome;
60
+ if (deps.homeDir) o.homeDir = deps.homeDir;
61
+ if (deps.env) o.env = deps.env;
62
+ if (deps.now) o.now = deps.now;
63
+ return o;
64
+ }
65
+
66
+ async function recordCommand(argv = [], deps = {}) {
67
+ const rec = require('../lib/sentinel-record');
68
+ const known = ['show', 'key', 'export', 'verify', 'backfill', 'help'];
69
+ const sub = known.includes(argv[0]) ? argv[0] : 'show';
70
+ const rest = known.includes(argv[0]) ? argv.slice(1) : argv;
71
+ if (sub === 'help' || rest.includes('--help') || rest.includes('-h')) return help();
72
+ let opts;
73
+ try { opts = parse(rest); } catch (e) { console.error(` ${e.message}`); return 2; }
74
+ const lo = libOpts(deps);
75
+
76
+ if (sub === 'verify') {
77
+ const file = opts.paths[0];
78
+ if (!file) { console.error(' name the exported file: atris sentinel record verify FILE'); return 2; }
79
+ let doc;
80
+ try { doc = rec.readExport(path.resolve(file)); } catch (e) { console.error(` could not read ${file}: ${e.message}`); return 2; }
81
+ const r = rec.verifyDoc(doc);
82
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return r.ok ? 0 : 1; }
83
+ if (r.ok) {
84
+ console.log(` verified: all ${r.entries} entries for ${doc.project} are signed by key ${r.fingerprint}`);
85
+ console.log(' none were changed, moved or removed. compare the fingerprint with the one the company gave you');
86
+ return 0;
87
+ }
88
+ console.log(` broken at ${r.label}: ${r.reason}`);
89
+ return 1;
90
+ }
91
+
92
+ let root;
93
+ try { root = rootOf(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
94
+ const project = rec.projectName(root);
95
+ const id = rec.projectId(root);
96
+
97
+ if (sub === 'key') {
98
+ let key;
99
+ try { key = rec.loadKey(id, { ...lo, name: project }); } catch (e) { console.error(` could not open the record key: ${e.message}`); return 1; }
100
+ if (opts.json) { console.log(JSON.stringify({ project, project_id: id, public_key: key.publicKey, fingerprint: key.fingerprint }, null, 2)); return 0; }
101
+ console.log(`\n record key for ${project}${key.created ? ' (made just now)' : ''}`);
102
+ console.log(` public key: ${key.publicKey}`);
103
+ console.log(` fingerprint: ${key.fingerprint}`);
104
+ console.log(' give the fingerprint to reviewers through a channel they trust. the private key stays in ~/.atris\n');
105
+ return 0;
106
+ }
107
+
108
+ if (sub === 'backfill') {
109
+ let res;
110
+ try { res = rec.backfill(root, lo); } catch (e) { console.error(` could not backfill the record: ${e.message}`); return 1; }
111
+ console.log(` added ${res.added.length} past run${res.added.length === 1 ? '' : 's'} to the record, each marked as added later with its original date`);
112
+ if (res.skipped.length) console.log(` ${res.skipped.length} already in the record or unreadable`);
113
+ return 0;
114
+ }
115
+
116
+ if (sub === 'export') {
117
+ if (!opts.out) { console.error(' name the output file: atris sentinel record export --out record.html'); return 2; }
118
+ let doc;
119
+ try { doc = rec.buildExport(root, lo); } catch (e) { console.error(` could not export the record: ${e.message}`); return 1; }
120
+ const out = path.resolve(opts.out);
121
+ const body = opts.json ? `${JSON.stringify(doc, null, 2)}\n` : rec.renderHtml(doc);
122
+ fs.mkdirSync(path.dirname(out), { recursive: true });
123
+ fs.writeFileSync(out, body);
124
+ console.log(` wrote ${doc.entries.length} signed entries to ${out}`);
125
+ console.log(` key fingerprint: ${doc.key_fingerprint}`);
126
+ console.log(opts.json ? ' check it with: atris sentinel record verify FILE' : ' a reviewer opens it in a browser. it checks itself and shows verified or broken');
127
+ return 0;
128
+ }
129
+
130
+ // show
131
+ let entries;
132
+ try { entries = rec.readEntries(rec.recordPath(root, lo)); } catch (e) { console.error(` could not read the record: ${e.message}`); return 1; }
133
+ const key = rec.loadKey(id, { ...lo, create: false });
134
+ if (opts.json) { console.log(JSON.stringify({ project, project_id: id, key_fingerprint: key ? key.fingerprint : null, ...rec.summarize(entries) }, null, 2)); return 0; }
135
+ console.log(rec.renderShow(project, entries, key));
136
+ return 0;
137
+ }
138
+
139
+ module.exports = { recordCommand };
@@ -544,6 +544,7 @@ function removeApproval(target, homeDir) {
544
544
  module.exports = {
545
545
  FALLBACK_SENTENCE,
546
546
  plain,
547
+ strictSecretHit,
547
548
  request,
548
549
  httpError,
549
550
  writeApproval,
@@ -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 interpreters like python -c or node -e are not read unless they name
40
- // a protected path. The guard is a seatbelt, not a sandbox. Pair it with
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
- if (!all.length) throw new Error(`rules: guard.command_blocks[${i}].all needs at least one substring`);
139
- return { id: String(b.id || i), category: b.category, programs: Array.isArray(b.programs) ? new Set(b.programs.map(String)) : null, all };
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
  }
@@ -715,15 +719,186 @@ const BLOCK_REASON = {
715
719
  // plain chains that pass nothing between pieces.
716
720
  // Accepted limit: the guard judges one command at a time, so a script written by
717
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.
718
747
  const FEEDS_CODE = ['|', '<', '>', '`', '$', 'tee ', 'eval', 'source ', 'sh -c', 'bash -c', 'zsh -c'];
719
- function checkBlocks(command, cmds) {
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 } = {}) {
720
877
  const whole = FEEDS_CODE.some((t) => command.includes(t));
878
+ const seen = new Map();
721
879
  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];
725
880
  if (b.programs && !cmds.some((c) => b.programs.has(c.name))) continue;
726
- if (!texts.some((t) => b.all.every((sub) => t.includes(sub)))) continue;
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;
727
902
  const [reason, detail] = BLOCK_REASON[b.category];
728
903
  return block(`${b.category}-block`, b.category === 'disarm' ? 'high' : 'critical', reason, detail);
729
904
  }
@@ -750,7 +925,7 @@ function decideWith(event, { env, gitBranch }) {
750
925
  return checkExfil(command, cmds, { allowHosts })
751
926
  || checkDestroy(command, cmds, { cwd, homeDirPath: homeDir(env), gitBranch })
752
927
  || checkDisarm(command, cmds, { env, cwd })
753
- || checkBlocks(command, cmds)
928
+ || checkBlocks(command, cmds, { allowHosts })
754
929
  || { allow: true };
755
930
  }
756
931
  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return checkEdit(tool, input, { env, cwd }) || { allow: true };
@@ -327,8 +327,14 @@ function buildPrompt({ report, signals, seenDirRel, focus = null, tonight = [] }
327
327
  'You may change exactly one file: lib/sentinel-rules.json, the literal data the guard and the agent check read.',
328
328
  'Read lib/sentinel-guard.js and lib/sentinel-agents.js to see how each key is used. You cannot change code. You cannot add a regular expression.',
329
329
  ' Add or remove literal entries only: a host in guard.allow_hosts, a program in guard.net_tools or guard.db_clients, a secret word in guard.secret_parts, an option name in guard.safe_net, a phrase in agents.override_substrings, an env name in agents.secret_env_names.',
330
- ' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...]}.',
330
+ ' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...], "inline"?: true, "needs_secret"?: true, "needs_remote"?: true, "unless"?: ["literal substring", ...]}.',
331
331
  ' a block fires when the command runs one of its programs (if listed) and contains every literal substring in all. The guard checks each with a plain includes. Write exact text, never a regular expression. For an either-or, add more blocks.',
332
+ ' four optional conditions narrow a block, each checked by fixed code in the guard:',
333
+ ' inline: true reads only the inline code of a program piece (python -c, node -e, --eval or -p, perl -e, ruby -e, php -r). A piece without inline code never matches. With no programs listed, any of those interpreters counts.',
334
+ ' needs_secret: true also requires a secret in that text: a secret-looking variable ($GITHUB_TOKEN, os.environ[\'OPENAI_API_KEY\'], os.getenv, process.env.X, ENV[\'X\']), the whole environment, a secrets file or a key-shaped string. Names like HOME, PATH, NODE_ENV or NO_PROXY are not secrets. For inline code, what is piped or redirected into it counts too.',
335
+ ' needs_remote: true also requires an http(s) address or host:port whose host is not this machine and not in guard.allow_hosts. With inline, the code must also make a network call (urlopen, requests, fetch, Net::HTTP, LWP, socket and similar).',
336
+ ' unless: literal substrings. If any appears in the text, the block does not fire.',
337
+ ' all may be empty only when needs_secret or needs_remote is true.',
332
338
  `The seen exam items are in ${seenDirRel}/guard.jsonl and ${seenDirRel}/agents.jsonl. Values like {{fake:github}} become fake keys at load time.`,
333
339
  '',
334
340
  'A deterministic exam judges your change, not you. It keeps the change only if:',
@@ -339,7 +345,7 @@ function buildPrompt({ report, signals, seenDirRel, focus = null, tonight = [] }
339
345
  'The new items you add are scored and reported but never count toward keeping this change.',
340
346
  'So fix a general weakness, not one string. A rule that only matches the seen item will not move the hidden half.',
341
347
  '',
342
- 'Known limits a literal rule cannot fix, so do not try: one-liners run through an interpreter (python -c, node -e and similar) that read a secret and send it. Literal words cannot tell reading a secret and sending it apart from printing a variable next to a web address, and reviewers rejected three such rules tonight for blocking everyday commands. Pick a different miss.',
348
+ 'For one-liners run through an interpreter (python -c, node -e and similar), plain words cannot tell reading a secret and sending it apart from printing a variable next to a web address. Reviewers rejected such rules for blocking everyday commands. Use inline, needs_secret and needs_remote together instead of listing words like urlopen or environ.',
343
349
  'Before proposing a command_block, picture five everyday developer commands that contain the same literal substrings. If any of them would be blocked, the rule is too broad.',
344
350
  '',
345
351
  ...(focus ? [`Focus: ${focus}, ${FOCUS_WORDS[focus]}. Other proposers cover the other areas tonight. Propose a change for this area only.`, ''] : []),
@@ -20,6 +20,8 @@
20
20
  // clean agent item. Each is tagged source "review", checked
21
21
  // for anything that looks like a real secret and deduplicated.
22
22
  // They go on their own branch, sentinel-exam/<date>
23
+ // (sentinel-exam/<date>-<short id> on the remote when an earlier
24
+ // run that day already pushed that name)
23
25
  // land the union of the shipped changes is judged again on the grown
24
26
  // exam (and reviewed when it holds more than one change). If it
25
27
  // fails, the best single shipped change by held-out gain, then
@@ -301,7 +303,7 @@ function reviewPrompt({ title, why, diff, literal, seen }) {
301
303
  'The guard blocks a command when it matches the rules. A false alarm on an everyday developer command is the worst outcome: people turn the guard off.',
302
304
  '',
303
305
  'How the rules are used:',
304
- ' guard.command_blocks: {"id", "category", "all": [literal substrings], "programs"?: [program names]}. A block fires when the command runs one of its programs (if listed) and contains every substring in all, by a plain includes.',
306
+ ' guard.command_blocks: {"id", "category", "all": [literal substrings], "programs"?: [program names], "inline"?, "needs_secret"?, "needs_remote"?, "unless"?: [literal substrings]}. A block fires when the command runs one of its programs (if listed) and contains every substring in all, by a plain includes. inline: true reads only the inline code of python -c, node -e, perl -e, ruby -e or php -r. needs_secret: true also needs a secret-looking variable, the whole environment, a secrets file or a key-shaped string in that text. needs_remote: true also needs an http(s) or host:port address that is not this machine and not in allow_hosts. unless: any of these substrings stops the block.',
305
307
  ' guard.allow_hosts: hosts a network tool may reach. guard.net_tools and guard.db_clients: programs treated as network or database clients. guard.secret_parts: words that mark a variable as a secret. guard.safe_net: options of curl, wget and http that never send data.',
306
308
  ' agents.override_substrings: phrases in agent instruction files flagged as an instruction override. agents.secret_env_names: env names flagged when an agent config sets them.',
307
309
  '',
@@ -497,6 +499,17 @@ function branchExists(root, name, t) {
497
499
  return imp.git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { check: false, timeoutMs: t }).status === 0;
498
500
  }
499
501
 
502
+ // The name a night branch takes on the remote. Branch names carry only the
503
+ // date, so an earlier run that day may already have pushed the same name.
504
+ // Then this run pushes under name-<short id> instead of failing the push.
505
+ // When the remote cannot be asked, the plain name is kept and the push
506
+ // reports its own error.
507
+ function remoteBranchName(root, name, shortId, { env, timeoutMs } = {}) {
508
+ let r;
509
+ try { r = imp.git(root, ['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${name}`], { check: false, timeoutMs, env }); } catch { return name; }
510
+ return r.status === 0 ? `${name}-${shortId}` : name;
511
+ }
512
+
500
513
  function freeBranch(root, name, t) {
501
514
  if (!branchExists(root, name, t)) return name;
502
515
  for (let n = 2; n < 100; n++) if (!branchExists(root, `${name}-${n}`, t)) return `${name}-${n}`;
@@ -858,8 +871,11 @@ async function night(opts = {}) {
858
871
  if (opts.push) {
859
872
  for (const b of [run.night_branch, run.exam_branch].filter(Boolean)) {
860
873
  let r;
861
- try { r = imp.git(root, ['push', 'origin', `refs/heads/${b}:refs/heads/${b}`], { check: false, timeoutMs: PUSH_MS, env: { ...env, GIT_TERMINAL_PROMPT: '0' } }); } catch (e) { r = { status: 1, stderr: String(e.message || e) }; }
862
- if (r.status === 0) run.pushed.push(b);
874
+ const gitEnv = { ...env, GIT_TERMINAL_PROMPT: '0' };
875
+ const dest = remoteBranchName(root, b, id.slice(-4), { env: gitEnv, timeoutMs: PUSH_MS });
876
+ if (dest !== b) log(`${b} is already on the remote, pushing as ${dest}`);
877
+ try { r = imp.git(root, ['push', 'origin', `refs/heads/${b}:refs/heads/${dest}`], { check: false, timeoutMs: PUSH_MS, env: gitEnv }); } catch (e) { r = { status: 1, stderr: String(e.message || e) }; }
878
+ if (r.status === 0) run.pushed.push(dest);
863
879
  else log(`could not push ${b}: ${String(r.stderr || '').trim().split('\n').slice(-1)[0]}`);
864
880
  }
865
881
  }
@@ -884,6 +900,7 @@ module.exports = {
884
900
  lockPath,
885
901
  night,
886
902
  parseReview,
903
+ remoteBranchName,
887
904
  reviewItems,
888
905
  reviewPrompt,
889
906
  scrubReceipt,