atris 3.73.0 → 3.74.0

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/lib/yes-lock.js CHANGED
@@ -18,9 +18,15 @@
18
18
  // and atris first on the job's PATH; each asks check() before running
19
19
  // - while the job is locked, npm, yarn, pnpm, and gh only run known read and
20
20
  // build commands; anything else is refused
21
- // - every category a command touches must be open; a yes opens one step for
22
- // one successful run, then that step closes again
21
+ // - a run woken by a handoff (the fixed handoff seal) runs on an allowlist:
22
+ // only the commands named under "the handoff seal" below run without a
23
+ // yes, and everything else is held
24
+ // - every held command is its own request on the step that holds it, bound
25
+ // to the exact command by a hash; the owner's yes on that card lets that
26
+ // one command start once (the yes is used when it starts, success or not)
27
+ // - a command the lock refuses outright is a request too, on the first step
23
28
 
29
+ const crypto = require('crypto');
24
30
  const fs = require('fs');
25
31
  const path = require('path');
26
32
  const { spawnSync } = require('child_process');
@@ -33,8 +39,46 @@ const CATEGORY_WORDS = {
33
39
  send: 'send a message',
34
40
  merge: 'change master',
35
41
  deploy: 'deploy',
42
+ spend: 'spend money',
36
43
  };
37
44
 
45
+ // A member woken by a handoff always runs under this fixed seal, whatever
46
+ // job handed off to it. Under it only an allowlist of reads, local work, PR
47
+ // and branch-push commands runs without a yes (sealedVerdict below); every
48
+ // other command is one yes card, and a yes lets that one command run once.
49
+ // It still opens PRs and pushes branches; autoland lands those under its own
50
+ // policy. The job that handed off is kept only to show the card on it
51
+ // (parent_mission_id); it never changes what is held.
52
+ const HANDOFF_SEAL_TEXT = 'a handed-off job asks before anything goes out or lands on master';
53
+
54
+ function handoffSeal() {
55
+ return Object.keys(CATEGORY_WORDS).map((category, i) => ({ step: i + 1, text: HANDOFF_SEAL_TEXT, categories: [category] }));
56
+ }
57
+
58
+ const handoffAsk = command => `a handed-off job wants to ${command}`;
59
+
60
+ function handoffParent(stateDir, missionId) {
61
+ const data = readState(stateDir, missionId);
62
+ return (data && data.parent_mission_id) || null;
63
+ }
64
+
65
+ // Yes cards for held handed-off runs: every lock record under the handoff
66
+ // seal, asked and not yet answered, with the job it was handed off from.
67
+ function handoffYesCards(stateDir) {
68
+ let files = [];
69
+ try { files = fs.readdirSync(path.join(stateDir, 'yes-locks')).filter(f => f.endsWith('.json')); } catch { return []; }
70
+ const cards = [];
71
+ for (const name of files) {
72
+ let data = null;
73
+ try { data = JSON.parse(fs.readFileSync(path.join(stateDir, 'yes-locks', name), 'utf8')); } catch { continue; }
74
+ if (!data || !data.mission_id || !Array.isArray(data.locks) || !data.locks.length || !data.locks.every(l => l && l.text === HANDOFF_SEAL_TEXT)) continue;
75
+ for (const lock of askedYesLocks(stateDir, data.mission_id)) {
76
+ cards.push({ mission_id: data.mission_id, parent_mission_id: data.parent_mission_id || null, step: lock.step, hash: lock.hash, command: lock.held_command || null, ask: handoffAsk(lock.held_command || 'run a held command') });
77
+ }
78
+ }
79
+ return cards;
80
+ }
81
+
38
82
  // ---- what an owner step locks ------------------------------------------------------
39
83
 
40
84
  const STEP_WORDS = [
@@ -44,6 +88,7 @@ const STEP_WORDS = [
44
88
  [/\b(send|sends|email|emails|dm|dms|message|messages|text|reply|invite|invites|outreach)\b/, ['send']],
45
89
  [/\b(merge|merges|merged|land|lands)\b/, ['merge']],
46
90
  [/\b(deploy|deploys|deployed)\b/, ['deploy']],
91
+ [/\b(pay|pays|paid|buy|buys|bought|purchase|purchases|spend|spends|subscribe|top up|topup|checkout|order|orders)\b/, ['spend']],
47
92
  ];
48
93
 
49
94
  function stepLocks(text) {
@@ -88,12 +133,39 @@ function writeState(stateDir, missionId, data) {
88
133
  fs.renameSync(tmp, file);
89
134
  }
90
135
 
91
- function writeYesLocks(stateDir, missionId, { betId = null, seal = [] } = {}) {
136
+ function writeYesLocks(stateDir, missionId, { betId = null, seal = [], parentMissionId = null } = {}) {
92
137
  const locks = normalizeSeal(seal).map(s => ({ ...s, asked_at: null, approved_at: null, consumed_at: null }));
93
- writeState(stateDir, missionId, { mission_id: missionId, bet_id: betId, locks });
138
+ writeState(stateDir, missionId, { mission_id: missionId, bet_id: betId, ...(parentMissionId ? { parent_mission_id: parentMissionId } : {}), locks });
94
139
  return locks;
95
140
  }
96
141
 
142
+ // One held command, on the step that holds it: its exact text and folder
143
+ // (key), a short hash of the key that the yes card carries, when it was
144
+ // asked, when the owner said yes, and when that yes was used.
145
+ const commandHash = key => crypto.createHash('sha256').update(String(key)).digest('hex').slice(0, 16);
146
+
147
+ function rowRequests(row) {
148
+ if (row && Array.isArray(row.requests)) return row.requests.filter(r => r && r.key && r.hash).map(r => ({ ...r }));
149
+ return [];
150
+ }
151
+
152
+ // What a step looks like from outside: its newest waiting command, and its
153
+ // yes that is given and not used yet.
154
+ function stepSummary(requests) {
155
+ const pending = requests.filter(r => !r.approved_at && !r.used_at);
156
+ const open = requests.find(r => r.approved_at && !r.used_at) || null;
157
+ const used = requests.filter(r => r.used_at);
158
+ const last = pending[pending.length - 1] || null;
159
+ const lastUsed = used[used.length - 1] || null;
160
+ return {
161
+ asked_at: last ? last.asked_at : null,
162
+ approved_at: open ? open.approved_at : (lastUsed ? lastUsed.approved_at : null),
163
+ consumed_at: !open && lastUsed ? lastUsed.used_at : null,
164
+ ...(last ? { held_command: last.command, held_key: last.key } : {}),
165
+ ...(open ? { approved_command: open.key } : {}),
166
+ };
167
+ }
168
+
97
169
  // The locks as they stand. Every sealed step must be in the state file with
98
170
  // the same categories; otherwise the file is not trusted and every step is
99
171
  // locked, with nothing approved.
@@ -104,38 +176,36 @@ function effectiveLocks(stateDir, missionId, seal) {
104
176
  const rows = data && Array.isArray(data.locks) ? data.locks : null;
105
177
  const same = (a, b) => JSON.stringify([...new Set((a || []).map(String))].sort()) === JSON.stringify(b);
106
178
  const trusted = Boolean(rows) && sealed.every(s => rows.some(l => l && l.step === s.step && same(l.categories, s.categories)));
107
- if (!trusted) return { locks: sealed.map(s => ({ ...s, asked_at: null, approved_at: null, consumed_at: null })), trusted: false };
108
- return {
109
- trusted: true,
110
- locks: sealed.map((s) => {
111
- const l = rows.find(r => r.step === s.step);
112
- return { ...s, asked_at: l.asked_at || null, approved_at: l.approved_at || null, consumed_at: l.consumed_at || null, ...(l.held_command ? { held_command: l.held_command } : {}), ...(l.held_key ? { held_key: l.held_key } : {}), ...(l.approved_command ? { approved_command: l.approved_command } : {}) };
113
- }),
114
- };
179
+ const build = (s, requests) => ({ ...s, requests, ...stepSummary(requests) });
180
+ if (!trusted) return { locks: sealed.map(s => build(s, [])), trusted: false };
181
+ return { trusted: true, locks: sealed.map(s => build(s, rowRequests(rows.find(r => r.step === s.step)))) };
115
182
  }
116
183
 
117
- // A yes is claimed by the one run it allows, with a file only one process
118
- // can create, before that run starts. The claim is dropped if the run fails.
119
- function claimFile(stateDir, missionId, lock) {
120
- return `${stateFile(stateDir, missionId)}.step-${lock.step}.${String(lock.approved_at).replace(/[^0-9A-Za-z]/g, '')}.claim`;
184
+ // A yes is taken by the one run it allows, with a file only one process can
185
+ // create, as that run starts.
186
+ function claimFile(stateDir, missionId, lock, request) {
187
+ return `${stateFile(stateDir, missionId)}.step-${lock.step}.${request.hash}.${String(request.approved_at).replace(/[^0-9A-Za-z]/g, '')}.claim`;
121
188
  }
122
189
 
123
- const isOpen = l => Boolean(l.approved_at) && !l.consumed_at;
124
-
125
190
  function saveLocks(stateDir, missionId, locks, betId) {
126
191
  const prior = readState(stateDir, missionId);
127
- writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, locks });
192
+ const rows = locks.map(l => ({ step: l.step, text: l.text, categories: l.categories, requests: rowRequests(l) }));
193
+ writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, ...(prior && prior.parent_mission_id ? { parent_mission_id: prior.parent_mission_id } : {}), locks: rows });
128
194
  }
129
195
 
130
- // The yes covers the command the job was held on (the one the owner saw on
131
- // the card), in the folder it ran from, and nothing else.
132
- function approveYesLock(stateDir, missionId, step, seal, at = new Date().toISOString()) {
196
+ // The yes covers the one command on the card the owner answered: the waiting
197
+ // request with that hash. Without a hash (an older caller), the newest
198
+ // waiting request on the step. null when nothing matches.
199
+ function approveYesLock(stateDir, missionId, step, seal, at = new Date().toISOString(), { hash = null } = {}) {
133
200
  const { locks } = effectiveLocks(stateDir, missionId, seal || sealFromState(stateDir, missionId));
134
201
  const lock = locks.find(l => l.step === step);
135
202
  if (!lock) return null;
136
- Object.assign(lock, { approved_at: at, consumed_at: null, approved_command: lock.held_key || null });
203
+ const waiting = lock.requests.filter(r => !r.approved_at && !r.used_at);
204
+ const target = hash ? waiting.find(r => r.hash === hash) : waiting[waiting.length - 1];
205
+ if (!target) return null;
206
+ target.approved_at = at || new Date().toISOString();
137
207
  saveLocks(stateDir, missionId, locks);
138
- return lock;
208
+ return { ...lock, ...stepSummary(lock.requests), approved: target };
139
209
  }
140
210
 
141
211
  // For readers that have no mission record at hand (the desk), the state file
@@ -146,9 +216,18 @@ function sealFromState(stateDir, missionId) {
146
216
  return data && Array.isArray(data.locks) ? data.locks : [];
147
217
  }
148
218
 
219
+ // Every held command still waiting for a yes: one card each.
149
220
  function askedYesLocks(stateDir, missionId) {
150
221
  const data = readState(stateDir, missionId);
151
- return data && Array.isArray(data.locks) ? data.locks.filter(l => l.asked_at && !isOpen(l) && !l.consumed_at) : [];
222
+ if (!data || !Array.isArray(data.locks)) return [];
223
+ const cards = [];
224
+ for (const row of data.locks) {
225
+ for (const r of rowRequests(row)) {
226
+ if (r.approved_at || r.used_at) continue;
227
+ cards.push({ step: row.step, text: row.text, categories: row.categories, held_command: r.command, held_key: r.key, hash: r.hash, asked_at: r.asked_at });
228
+ }
229
+ }
230
+ return cards;
152
231
  }
153
232
 
154
233
  // ---- reading commands ---------------------------------------------------------------
@@ -189,13 +268,15 @@ const PKG_EXEC = new Set(['exec', 'x', 'dlx', 'create-exec']);
189
268
 
190
269
  const PKG_BOOL_FLAGS = new Set(['-s', '--silent', '-q', '--quiet', '-d', '--loglevel=info', '--verbose', '-y', '--yes', '-f', '--force', '-g', '--global', '-D', '--save-dev', '-P', '--save-prod', '-O', '--save-optional', '-E', '--save-exact', '-B', '--save-bundle', '--no-save', '--save', '--dry-run', '--json', '--ignore-scripts', '--legacy-peer-deps', '--strict-peer-deps', '--prefer-offline', '--prefer-online', '--offline', '--no-audit', '--no-fund', '--workspaces', '--ws', '--include-workspace-root', '--if-present', '--production', '--frozen-lockfile', '--no-frozen-lockfile', '-r', '--recursive', '--color', '--no-color', '--no-progress', '--foreground-scripts', '--long', '-l', '--all', '-a', '--depth=0', '--parseable', '-p', '--version', '-v', '--help', '-h', '--watch', '--coverage', '--immutable', '--check-files', '--pure-lockfile', '--non-interactive', '--no-git-checks', '--dev', '--peer', '--optional', '--exact', '--tilde']);
191
270
 
192
- // Every non-option word before "--", in order. An option this code does not
193
- // know might take the next word as its value, so it must use --key=value.
271
+ // Every non-option word, in order, as the tool reads them: "--" only ends
272
+ // the options, so in `npm -- publish` the command is publish. An option this
273
+ // code does not know might take the next word as its value, so it must use
274
+ // --key=value.
194
275
  function packageWords(args) {
195
276
  const words = [];
196
277
  for (let i = 0; i < args.length; i += 1) {
197
278
  const a = String(args[i] || '');
198
- if (a === '--') break;
279
+ if (a === '--') { words.push(...args.slice(i + 1).map(String)); break; }
199
280
  if (a.startsWith('-')) {
200
281
  if (a.includes('=') || PKG_BOOL_FLAGS.has(a)) continue;
201
282
  if (NPM_VALUE_FLAGS.has(a)) { i += 1; continue; }
@@ -297,16 +378,91 @@ function readGh(args) {
297
378
  return refused(`gh ${group} ${verb} is not on the list of commands a locked job may run`);
298
379
  }
299
380
 
300
- // atris: the send and post commands touch every category they can reach;
301
- // everything else is the mission's own machinery and runs.
381
+ // atris. Audited from lib/known-commands.js (Codex review round 6 on #1483):
382
+ // every verb that spends, posts, sends, publishes, deploys, or lands is held
383
+ // by kind; verbs that do both read and write are held unless the sub-command
384
+ // is a read by name; the run's own machinery and plain reads go; a verb not
385
+ // on any list is held, so a new outward command never slips through.
386
+ const ATRIS_OUTWARD = {
387
+ pay: ['spend'], buy: ['spend'], topup: ['spend'], hire: ['spend'], stripe: ['spend'],
388
+ publish: ['publish'], pack: ['publish'], release: ['publish', 'tag'], push: ['publish'], sync: ['publish'],
389
+ host: ['deploy'], vercel: ['deploy'], supabase: ['deploy'],
390
+ land: ['merge'], autoland: ['merge'],
391
+ msg: ['send'], invite: ['send'], feedback: ['send'], signup: ['send'], join: ['send'],
392
+ social: ['post'], follow: ['post'], unfollow: ['post'], x: ['post'],
393
+ };
394
+ // outward unless the sub-command is a read: what the write would touch
395
+ const ATRIS_MIXED = {
396
+ feed: ['post'], twitter: ['post', 'send'], slack: ['send', 'post'], gmail: ['send'], mail: ['send'], email: ['send'],
397
+ imessage: ['send'], calendar: ['send'], meet: ['send'], drive: ['send'], site: ['publish', 'deploy'], deck: ['publish'],
398
+ app: ['deploy'], apps: ['deploy'], plugin: ['publish'], cloud: ['publish'], github: ['merge', 'publish'], fork: ['publish'], business: ['deploy', 'publish'],
399
+ 'api-key': ['spend'], linear: ['post'], people: ['send'], card: ['publish'], reel: ['publish'],
400
+ };
401
+ const ATRIS_READ_SUBS = new Set(['', 'list', 'ls', 'status', 'show', 'view', 'get', 'read', 'search', 'find', 'info', 'check', 'help', 'preview', 'history', 'log', 'logs', 'inbox', 'unread', 'stats', 'whoami', 'events', 'today', 'upcoming', 'agenda', 'pull', 'download', 'export', 'diff', 'doctor', 'draft', 'drafts']);
402
+ // owner answers and account changes: never from inside a held run
403
+ const ATRIS_REFUSED = new Set(['approve', 'decide', 'login', 'logout', 'switch', 'use', 'sign', 'trust']);
404
+ // the run's own machinery, and plain reads
405
+ const ATRIS_INTERNAL = new Set([
406
+ 'init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'done', 'wish', 'ask', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf',
407
+ 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review',
408
+ 'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'whoami', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade',
409
+ 'version', 'help', 'next', 'atris', 'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'learn', 'lesson', 'taste', 'teach',
410
+ 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'watch', 'live', 'align', 'terminal', 'computer', 'diff', 'youtube', 'x-search',
411
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'campaign', 'agents', 'probe', 'worktree', 'caretaker', 'aeo', 'slop', 'voice', 'strings', 'write',
412
+ 'security-review', 'secure', 'theme', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'recap', 'report', 'clarity', 'interview', 'moves', 'unknowns', 'avail',
413
+ 'scoreboard', 'sync-checkout', 'zen', 'ci', 'balance', 'usage', 'agent-client', 'transactions', 'hires', 'design', 'mcp', 'integrations', 'setup', 'clean-workspace', 'cw',
414
+ 'browse', 'sleep', 'wake', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'engine',
415
+ 'engines', 'link', 'playbook', 'brief', 'inbox', 'friends', 'rsi',
416
+ ]);
417
+
418
+ // Atris verbs that cost credits (Codex review round 9 on #1483), read from
419
+ // each verb's command file and the backend route it calls: hosted chat and
420
+ // Atris 2 turns, cloud computer runs and wakes (billed per hour while
421
+ // awake), image generation, paid search and video processing, AEO writes,
422
+ // improve ticks, cloud missions, and cloud campaign runs. Each test says
423
+ // whether this use of the verb is the paid one.
424
+ const HELP = new Set(['help', '--help', '-h']);
425
+ // an empty or unknown sub is spend, as with npm; only the subs named here are free
426
+ const paidUnless = (...free) => sub => !free.includes(sub) && !HELP.has(sub);
427
+ const ATRIS_PAID = {
428
+ 'x-search': paidUnless(),
429
+ // plain search is local and free; --paid search, and processing a video (the default), cost credits
430
+ youtube: (sub, args) => (sub === 'search' ? args.includes('--paid') : paidUnless('unsave', 'notes', 'teach', 'digest', 'bench', 'watch')(sub)),
431
+ improve: paidUnless('doctor', 'revisions'),
432
+ // init and draft wake the cloud computer and run there
433
+ aeo: paidUnless('scan', 'log', 'status', 'packet', 'proofs', 'discover', 'audit'),
434
+ computer: paidUnless('status', 'doctor'),
435
+ visualize: paidUnless(),
436
+ ask: paidUnless(),
437
+ chat: paidUnless('scan'),
438
+ fast: paidUnless(),
439
+ ax: paidUnless(),
440
+ agent: paidUnless('doctor', 'spawns', 'spawn-status'),
441
+ campaign: paidUnless('start', 'status', 'list', 'show', 'export', 'features', 'keep', 'propose', 'rsi', 'schedule', 'sync', 'wake'),
442
+ // these wake the cloud computer
443
+ wake: paidUnless(),
444
+ align: paidUnless(),
445
+ terminal: paidUnless(),
446
+ pull: paidUnless(),
447
+ 'fleet-report': paidUnless(),
448
+ // the cloud wiki runs a hosted chat job
449
+ wiki: (sub, args) => args.includes('--cloud'),
450
+ ingest: (sub, args) => args.includes('--cloud'),
451
+ query: (sub, args) => args.includes('--cloud'),
452
+ lint: (sub, args) => args.includes('--cloud'),
453
+ };
454
+
302
455
  function readAtris(args) {
303
456
  const words = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
304
457
  const [cmd = '', sub = ''] = words;
305
- if (['gmail', 'mail', 'email'].includes(cmd) && /^(send|reply|forward)$/.test(sub)) return touches('send');
306
- if (cmd === 'slack' && /^(send|dm|post|reply)$/.test(sub)) return touches('send', 'post');
307
- if (cmd === 'imessage' && sub === 'send') return touches('send');
308
- if (cmd === 'twitter' && /^(post|reply|tweet)$/.test(sub)) return touches('post', 'send');
309
- return CLEAR;
458
+ if (ATRIS_REFUSED.has(cmd)) return refused(`atris ${cmd} answers for the owner or changes the account, so it waits for the owner's yes`);
459
+ const cats = [];
460
+ if (ATRIS_OUTWARD[cmd]) cats.push(...ATRIS_OUTWARD[cmd]);
461
+ else if (ATRIS_MIXED[cmd] && !ATRIS_READ_SUBS.has(sub)) cats.push(...ATRIS_MIXED[cmd]);
462
+ if (Object.prototype.hasOwnProperty.call(ATRIS_PAID, cmd) && ATRIS_PAID[cmd](sub, args)) cats.push('spend');
463
+ if (cats.length) return touches(...cats);
464
+ if (cmd === '' || ATRIS_INTERNAL.has(cmd) || ATRIS_MIXED[cmd]) return CLEAR;
465
+ return refused(`atris ${cmd} is not on the list of commands a locked job may run`);
310
466
  }
311
467
 
312
468
  // ---- git ------------------------------------------------------------------------
@@ -371,9 +527,13 @@ function readPush(rest, ctx) {
371
527
  const dest = dstParts.length ? dstParts.join(':') : (src.replace(/^\+/, '') === 'HEAD' || src === '@' ? ctx.currentBranch() : src);
372
528
  for (const c of destCategory(dest, ctx) || []) cats.add(c);
373
529
  }
530
+ // a remote set up as a mirror pushes every branch and tag, whatever is named
531
+ const remote = repoFlag ? null : words[0] || null;
532
+ if (isGitTrue(ctx.config(`remote.${remote || ctx.config('remote.pushDefault') || 'origin'}.mirror`))) {
533
+ return refused(`git push to a mirror remote pushes every branch and tag, so it is closed while owner steps wait`);
534
+ }
374
535
  // no refspec given: where git would push by itself
375
536
  if (words.length <= 1 || (repoFlag && words.length === 0)) {
376
- const remote = repoFlag ? null : words[0] || null;
377
537
  for (const spec of ctx.remotePushSpecs(remote)) {
378
538
  const dest = spec.includes(':') ? spec.split(':').pop() : spec;
379
539
  for (const c of destCategory(dest, ctx) || []) cats.add(c);
@@ -381,11 +541,14 @@ function readPush(rest, ctx) {
381
541
  const target = ctx.pushDest();
382
542
  if (target) for (const c of destCategory(target.replace(/^[^/]+\//, ''), ctx) || []) cats.add(c);
383
543
  else if (MAIN_BRANCHES.has(ctx.currentBranch())) cats.add('merge');
384
- if (/^true$/i.test(ctx.config('push.followTags'))) cats.add('tag');
385
544
  }
545
+ // followTags sends annotated tags along with any push, named branch or not
546
+ if (isGitTrue(ctx.config('push.followTags')) && !rest.includes('--no-follow-tags')) cats.add('tag');
386
547
  return touches(...cats);
387
548
  }
388
549
 
550
+ const isGitTrue = v => /^(true|yes|on|1)$/i.test(String(v || '').trim());
551
+
389
552
  const ONTO_BRANCH = new Set(['merge', 'commit', 'cherry-pick', 'rebase', 'am', 'revert', 'reset']);
390
553
 
391
554
  function readGit(args, ctx, depth = 0) {
@@ -395,12 +558,15 @@ function readGit(args, ctx, depth = 0) {
395
558
  const repo = ctx.forRepo(p.prefix);
396
559
  const sub = p.sub;
397
560
  if (!sub) return CLEAR;
398
- const alias = repo.alias(sub);
561
+ // git never applies an alias named after one of its own commands, so
562
+ // neither does this: a stored alias.push is ignored and push is push
563
+ const alias = isGitBuiltin(sub, repo) ? null : repo.alias(sub);
399
564
  if (alias !== null && alias !== undefined) {
400
565
  if (depth >= 5 || alias.startsWith('!')) return refused(`git ${sub} is an alias that runs a shell command, so it is closed while owner steps wait`);
401
566
  return readGit([...p.prefix, ...alias.trim().split(/\s+/), ...p.rest], ctx, depth + 1);
402
567
  }
403
568
  if (sub === 'push') return readPush(p.rest, repo);
569
+ if (sub === 'config') return readGitConfig(p.rest);
404
570
  // the low-level ways to update a remote are closed outright
405
571
  if (['send-pack', 'http-push', 'receive-pack', 'remote-ext', 'remote-fd'].includes(sub)) return refused(`git ${sub} can update a remote without git push, so it is closed while owner steps wait`);
406
572
  // fetch and pull can write straight into a local branch: HEAD:refs/heads/master
@@ -421,6 +587,44 @@ function readGit(args, ctx, depth = 0) {
421
587
  return touches(...(destCategory(ref, repo) || []));
422
588
  }
423
589
  if (sub === 'branch' && p.rest.some(a => /^(-f|--force|-M|-C|-m|-D|-d|--delete)$/.test(a)) && p.rest.some(a => MAIN_BRANCHES.has(a))) return touches('merge');
590
+ // symbolic-ref NAME REF makes NAME follow REF; pointing master at another
591
+ // branch moves master with every commit there
592
+ if (sub === 'symbolic-ref') {
593
+ const refs = positionals(p.rest, new Set(['-m']));
594
+ const writes = refs.length >= 2 || p.rest.some(a => a === '-d' || a === '--delete');
595
+ if (writes && refs.some(r => isMainRef(r))) return touches('merge');
596
+ }
597
+ return CLEAR;
598
+ }
599
+
600
+ // master, or any ref under it, however it is spelled; any case, since on a
601
+ // case-insensitive disk refs/heads/MASTER is the same file as master
602
+ function isMainRef(name) {
603
+ const rel = String(name || '').toLowerCase().replace(/^\+/, '').replace(/^refs\//, '').replace(/^heads\//, '');
604
+ return [...MAIN_BRANCHES].some(b => rel === b || rel.startsWith(`${b}/`));
605
+ }
606
+
607
+ // The commands git runs itself, before it ever looks at an alias. The real
608
+ // list comes from the git in use; this short one stands in when it cannot be
609
+ // read, and names every command this lock reads.
610
+ const GIT_BUILTINS_FALLBACK = new Set(['push', 'fetch', 'pull', 'merge', 'commit', 'rebase', 'reset', 'update-ref', 'branch', 'checkout', 'switch', 'tag', 'config', 'send-pack', 'http-push', 'receive-pack', 'cherry-pick', 'am', 'revert', 'remote', 'status', 'log', 'diff', 'add', 'rm', 'mv', 'show', 'stash', 'worktree', 'clone', 'init']);
611
+
612
+ function isGitBuiltin(name, ctx) {
613
+ if (GIT_BUILTINS_FALLBACK.has(name)) return true;
614
+ try { return Boolean(ctx && typeof ctx.isBuiltin === 'function' && ctx.isBuiltin(name)); } catch { return false; }
615
+ }
616
+
617
+ // git config: reads run; writing a key that can reroute a push or define an
618
+ // alias, or opening the editor, is held.
619
+ function readGitConfig(rest) {
620
+ const READ_FLAGS = new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l', '--get-color', '--get-colorbool']);
621
+ if (rest.some(a => READ_FLAGS.has(String(a).split('=')[0]))) return CLEAR;
622
+ if (rest.some(a => a === '-e' || a === '--edit')) return refused('git config --edit can write any setting, so it is closed while owner steps wait');
623
+ const words = positionals(rest, new Set(['--file', '-f', '--blob', '--type', '--default', '--comment']));
624
+ if (words[0] === 'get' || words[0] === 'list') return CLEAR;
625
+ const keys = words[0] === 'set' || words[0] === 'unset' || words[0] === 'rename-section' || words[0] === 'remove-section' ? words.slice(1) : words;
626
+ const risky = keys.find(k => RISKY_GIT_CONFIG.test(k) || /^alias(\.|$)/i.test(k));
627
+ if (risky) return refused(`git config ${risky} can change what a git command does, so it is closed while owner steps wait`);
424
628
  return CLEAR;
425
629
  }
426
630
 
@@ -428,6 +632,7 @@ function readGit(args, ctx, depth = 0) {
428
632
  function gitContext(gitBin, cwd, env = process.env, prefix = []) {
429
633
  const run = a => spawnSync(gitBin || 'git', [...prefix, ...a], { cwd, env, encoding: 'utf8', timeout: 5000 });
430
634
  const out = a => { const r = run(a); return r.status === 0 ? String(r.stdout || '').trim() : null; };
635
+ let builtins = null;
431
636
  return {
432
637
  currentBranch: () => out(['rev-parse', '--abbrev-ref', 'HEAD']) || '',
433
638
  isTag: name => Boolean(name) && run(['rev-parse', '-q', '--verify', `refs/tags/${name}`]).status === 0,
@@ -435,12 +640,349 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
435
640
  pushDest: () => out(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}']),
436
641
  remotePushSpecs: remote => (out(['config', '--get-all', `remote.${remote || out(['config', '--get', 'remote.pushDefault']) || 'origin'}.push`]) || '').split('\n').filter(Boolean),
437
642
  config: key => out(['config', '--get', key]) || '',
643
+ configBool: key => out(['config', '--bool', '--get', key]) || '',
644
+ configAll: key => (out(['config', '--get-all', key]) || '').split('\n').filter(Boolean),
645
+ // with insteadOf, pushInsteadOf and pushurl applied, as git push sees them
646
+ remoteUrls: (remote, { push = false } = {}) => (out(['remote', 'get-url', ...(push ? ['--push'] : []), '--all', remote]) || '').split('\n').filter(Boolean),
438
647
  alias: name => out(['config', '--get', `alias.${name}`]),
648
+ isBuiltin: name => {
649
+ if (builtins === null) builtins = new Set((out(['--list-cmds=builtins']) || '').split('\n').filter(Boolean));
650
+ return builtins.has(name);
651
+ },
439
652
  envConfigKeys: () => envConfigKeys(env),
653
+ env: () => env,
654
+ fullRefName: name => out(['rev-parse', '--symbolic-full-name', name]) || '',
440
655
  forRepo: more => (more && more.length ? gitContext(gitBin, cwd, env, [...prefix, ...more]) : gitContext(gitBin, cwd, env, prefix)),
441
656
  };
442
657
  }
443
658
 
659
+ // ---- the handoff seal: an allowlist --------------------------------------------------
660
+ //
661
+ // A run woken by a handoff is trusted least, so it does not get the kind-by-
662
+ // kind reading above. Only the commands named here run without a yes;
663
+ // anything else, including every tool, verb, or option this code does not
664
+ // name, is held as a yes card. Reading commands by kind kept missing outward
665
+ // ones (Codex review round 7 on #1483: business deploy, followTags, a mirror
666
+ // remote, symbolic-ref); an allowlist cannot miss a command it never names.
667
+ // A bet's owner steps still lock by kind (analyze above), since a bet locks
668
+ // only the kinds its steps name.
669
+
670
+ const pass = (args = null) => ({ ok: true, args });
671
+ const hold = why => ({ ok: false, why });
672
+ const isHandoffSeal = sealed => sealed.some(s => s.text === HANDOFF_SEAL_TEXT);
673
+ const PUSH_HINT = 'a branch push runs without a yes as: git push origin <branch>';
674
+
675
+ // git reads, plus local work that cannot move a ref other than the branch it is on
676
+ const GIT_SEALED_READS = new Set(['status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'rev-list', 'merge-base', 'cat-file', 'ls-tree', 'show-ref', 'for-each-ref', 'shortlog', 'check-ignore', 'help', 'version']);
677
+ const GIT_SEALED_LOCAL = new Set(['add', 'rm', 'mv', 'restore']);
678
+ const GIT_SEALED_GLOBALS = new Set(['--no-pager', '-P', '--no-optional-locks', '--literal-pathspecs', '--version', '--help', '-h']);
679
+ // the shape of a branch name git accepts, without the corners that read as something else
680
+ const BRANCH_NAME = /^(?!-)(?!\.)(?!.*\.\.)(?!.*\/\/)(?!.*\/\.)(?!.*@\{)(?!.*\.lock(?:\/|$))[A-Za-z0-9._/-]+(?<![./])$/;
681
+
682
+ // Only -C and a few output switches go before the command. -c,
683
+ // --config-env, and --exec-path are held: config can name a program git runs
684
+ // (core.fsmonitor, core.pager), and git puts its own folder first on PATH for
685
+ // that program, so a git inside it would skip the wrapper.
686
+ function sealedGitPrefix(args) {
687
+ const prefix = [];
688
+ for (let i = 0; i < args.length; i += 1) {
689
+ const a = args[i];
690
+ if (a === '-C') {
691
+ prefix.push(a, String(args[i + 1] === undefined ? '' : args[i + 1]));
692
+ i += 1;
693
+ continue;
694
+ }
695
+ if (GIT_SEALED_GLOBALS.has(a)) { prefix.push(a); continue; }
696
+ if (a.startsWith('-')) return { bad: a };
697
+ return { prefix, sub: a, rest: args.slice(i + 1) };
698
+ }
699
+ return { prefix, sub: '', rest: [] };
700
+ }
701
+
702
+ // The same, set through the environment. GIT_CONFIG_NOSYSTEM only turns the
703
+ // system file off, so it adds nothing git could run.
704
+ function gitEnvProblem(env) {
705
+ const names = Object.keys(env || {}).filter(k => env[k] !== undefined && env[k] !== '');
706
+ return names.find(k => (/^GIT_CONFIG/.test(k) && k !== 'GIT_CONFIG_NOSYSTEM') || k === 'GIT_EXEC_PATH') || null;
707
+ }
708
+
709
+ // A ref a command writes or switches to must be a plain branch name that is
710
+ // not master and does not resolve to master: no @{-1}, @, HEAD~, ^, refs/,
711
+ // and no name that is a symbolic ref to master.
712
+ function targetProblem(name, repo) {
713
+ const n = String(name === undefined ? '' : name);
714
+ if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
715
+ if (isMainRef(n)) return `${n} is master`;
716
+ const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
717
+ if (full && isMainRef(full)) return `${n} resolves to master`;
718
+ return null;
719
+ }
720
+
721
+ function sealedGit(args, ctx) {
722
+ const p = sealedGitPrefix(args);
723
+ if (p.bad) return hold(`git ${p.bad} is not an option a handed-off job may use`);
724
+ const envName = gitEnvProblem(typeof ctx.env === 'function' ? ctx.env() : {});
725
+ if (envName) return hold(`git with ${envName} set can run a program that skips the wrapper`);
726
+ if (!p.sub) return pass();
727
+ if (GIT_SEALED_READS.has(p.sub) || GIT_SEALED_LOCAL.has(p.sub)) return pass();
728
+ // the questions below are asked of the repo the command names
729
+ const repo = ctx.forRepo(p.prefix.filter((a, i, all) => a === '-C' || all[i - 1] === '-C'));
730
+ const rest = p.rest;
731
+ switch (p.sub) {
732
+ case 'push': return sealedPush(p, repo);
733
+ case 'fetch': return sealedFetch(rest, repo);
734
+ case 'commit': return isMainRef(repo.currentBranch()) ? hold('a commit here would land on master') : pass();
735
+ case 'stash': {
736
+ if (rest[0] !== 'branch') return pass();
737
+ const why = targetProblem(positionals(rest.slice(1), new Set())[0], repo);
738
+ return why ? hold(`git stash branch: ${why}`) : pass();
739
+ }
740
+ case 'checkout': case 'switch': return sealedSwitch(p.sub, rest, repo);
741
+ case 'branch': return sealedBranch(rest, repo);
742
+ case 'config': return sealedConfig(rest);
743
+ case 'remote': {
744
+ const op = rest.filter(a => a !== '-v' && a !== '--verbose')[0] || '';
745
+ return op === '' || op === 'get-url' ? pass() : hold(`git remote ${op} changes where git sends things`);
746
+ }
747
+ case 'worktree': return sealedWorktree(rest, repo);
748
+ default: return hold(`git ${p.sub} is not on the list a handed-off job may run`);
749
+ }
750
+ }
751
+
752
+ // One branch, to origin, by name or as HEAD:refs/heads/<branch>, with no
753
+ // option but -u and quiet or verbose. It runs with followTags off, origin's
754
+ // mirror off, and push options cleared, and with the refspec written out in
755
+ // full, so origin's own push lines cannot send it anywhere else.
756
+ function sealedPush(p, repo) {
757
+ let upstream = false;
758
+ const quiet = [];
759
+ const words = [];
760
+ for (const a of p.rest) {
761
+ if (a === '-u' || a === '--set-upstream') { upstream = true; continue; }
762
+ if (['-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress'].includes(a)) { quiet.push(a); continue; }
763
+ if (a.startsWith('-')) return hold(`git push ${a} is not an option a handed-off job may use; ${PUSH_HINT}`);
764
+ words.push(a);
765
+ }
766
+ if (words.length !== 2 || words[0] !== 'origin') return hold(`a handed-off job pushes one branch to origin; ${PUSH_HINT}`);
767
+ const head = words[1].match(/^HEAD:refs\/heads\/(.+)$/);
768
+ const branch = head ? head[1] : words[1];
769
+ if (!head && (branch.includes(':') || branch.startsWith('refs/'))) return hold(`git push ${words[1]} names more than a branch; ${PUSH_HINT}`);
770
+ if (isMainRef(branch)) return hold(`git push to master lands on master; ${PUSH_HINT}`);
771
+ const why = targetProblem(branch, repo);
772
+ if (why) return hold(`git push ${words[1]}: ${why}; ${PUSH_HINT}`);
773
+ if (!head && repo.isTag(branch) && !repo.isBranch(branch)) return hold(`git push ${branch} names a tag; ${PUSH_HINT}`);
774
+ const fetchUrls = repo.remoteUrls('origin');
775
+ const pushUrls = repo.remoteUrls('origin', { push: true });
776
+ if (!fetchUrls.length) return hold('origin is not a remote here');
777
+ if (pushUrls.length !== 1 || pushUrls[0] !== fetchUrls[0]) return hold('origin pushes somewhere other than where it fetches from');
778
+ if (repo.configBool('remote.origin.mirror') === 'true') return hold('origin is set up as a mirror, which pushes every branch and tag');
779
+ const prefix = p.prefix;
780
+ return pass([...prefix, '-c', 'push.followTags=false', '-c', 'remote.origin.mirror=false', '-c', 'push.pushOption=', 'push', '--no-follow-tags',
781
+ ...(upstream ? ['--set-upstream'] : []), ...quiet, 'origin', `${head ? 'HEAD' : `refs/heads/${branch}`}:refs/heads/${branch}`]);
782
+ }
783
+
784
+ const FETCH_FLAGS = new Set(['-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress', '-p', '--prune', '-P', '--prune-tags', '-t', '--tags', '-n', '--no-tags', '--dry-run', '--unshallow', '--no-write-fetch-head', '--write-fetch-head']);
785
+
786
+ function sealedFetch(rest, repo) {
787
+ const words = [];
788
+ for (const a of rest) {
789
+ if (FETCH_FLAGS.has(a) || /^--(depth|deepen|shallow-since|shallow-exclude|filter|jobs)=/.test(a)) continue;
790
+ if (a.startsWith('-')) return hold(`git fetch ${a} is not an option a handed-off job may use`);
791
+ words.push(a);
792
+ }
793
+ const remote = words[0] || repo.config(`branch.${repo.currentBranch()}.remote`) || 'origin';
794
+ if (remote !== 'origin') return hold('a handed-off job fetches from origin only');
795
+ if (words.slice(1).some(w => /[:+*^]/.test(w))) return hold('a fetch refspec can write a local branch');
796
+ // origin's own fetch lines may only fill remote-tracking refs and tags
797
+ for (const spec of repo.configAll('remote.origin.fetch')) {
798
+ const dest = spec.includes(':') ? spec.split(':').pop() : '';
799
+ if (dest && !/^refs\/(remotes|tags)\//.test(dest)) return hold('origin is set up to fetch into local branches');
800
+ }
801
+ return pass();
802
+ }
803
+
804
+ const SWITCH_FLAGS = new Set(['-q', '--quiet', '-f', '--force', '--detach', '-d', '-t', '--track', '--no-track', '--guess', '--no-guess', '-m', '--merge', '--ours', '--theirs', '-p', '--patch', '--progress', '--no-progress', '--overlay', '--no-overlay', '--discard-changes', '--ignore-skip-worktree-bits', '--recurse-submodules', '--no-recurse-submodules', '--ignore-other-worktrees']);
805
+
806
+ function sealedSwitch(sub, rest, repo) {
807
+ const create = sub === 'checkout' ? new Set(['-b', '-B', '--orphan']) : new Set(['-c', '-C', '--create', '--force-create', '--orphan']);
808
+ const words = [];
809
+ let creating = false;
810
+ let paths = false;
811
+ for (let i = 0; i < rest.length; i += 1) {
812
+ const a = rest[i];
813
+ if (a === '--') { paths = true; break; }
814
+ const inline = a.match(/^(--orphan|--create|--force-create)=(.*)$/);
815
+ if (create.has(a) || inline) {
816
+ const name = inline ? inline[2] : String(rest[i + 1] === undefined ? '' : rest[i + 1]);
817
+ if (!inline) i += 1;
818
+ const why = targetProblem(name, repo);
819
+ if (why) return hold(`git ${sub} ${a}: ${why}`);
820
+ creating = true;
821
+ continue;
822
+ }
823
+ if (SWITCH_FLAGS.has(a) || /^--(conflict|track|pathspec-from-file)=/.test(a)) continue;
824
+ if (a.startsWith('-') && a !== '-') return hold(`git ${sub} ${a} is not an option a handed-off job may use`);
825
+ words.push(a);
826
+ }
827
+ if (creating || !words.length) return pass();
828
+ const target = words[0];
829
+ // a checkout with paths restores files from the named commit; it moves no branch
830
+ if (sub === 'checkout' && (paths || words.length > 1)) return pass();
831
+ const why = targetProblem(target, repo);
832
+ return why ? hold(`git ${sub} ${target}: ${why}`) : pass();
833
+ }
834
+
835
+ function sealedBranch(rest, repo) {
836
+ const LIST_VALUE = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort']);
837
+ const LIST = new Set(['-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '-l', '--list', '--show-current', '--color', '--no-color', '--column', '--no-column', '-i', '--ignore-case', '--no-abbrev', '-q', '--quiet', '--omit-empty']);
838
+ const CREATE = new Set(['-t', '--track', '--no-track', '-f', '--force', '--create-reflog', '--recurse-submodules']);
839
+ let listing = false;
840
+ let deleting = false;
841
+ const words = [];
842
+ for (let i = 0; i < rest.length; i += 1) {
843
+ const a = rest[i];
844
+ if (a === '--') { words.push(...rest.slice(i + 1)); break; }
845
+ if (LIST_VALUE.has(a)) { listing = true; i += 1; continue; }
846
+ if (/^--(contains|no-contains|merged|no-merged|points-at|format|sort)=/.test(a)) { listing = true; continue; }
847
+ if (a === '-l' || a === '--list' || a === '--show-current') { listing = true; continue; }
848
+ if (LIST.has(a) || CREATE.has(a) || /^--(color|column|abbrev|track)=/.test(a)) continue;
849
+ if (a === '-d' || a === '-D' || a === '--delete') { deleting = true; continue; }
850
+ if (a.startsWith('-')) return hold(`git branch ${a} is not an option a handed-off job may use`);
851
+ words.push(a);
852
+ }
853
+ if (deleting) {
854
+ const why = words.map(w => targetProblem(w, repo)).find(Boolean);
855
+ return why ? hold(`git branch -d: ${why}`) : pass();
856
+ }
857
+ if (listing || !words.length) return pass();
858
+ // git branch <new> [<start>]: the start can be anything, the name it writes cannot
859
+ const why = targetProblem(words[0], repo);
860
+ return why ? hold(`git branch ${words[0]}: ${why}`) : pass();
861
+ }
862
+
863
+ // git config reads only
864
+ function sealedConfig(rest) {
865
+ if (rest.some(a => /^(--add|--unset|--unset-all|--replace-all|--rename-section|--remove-section|-e|--edit)$/.test(String(a).split('=')[0]))) return hold('git config writes change what git does');
866
+ if (rest.some(a => /^(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|--get-color|--get-colorbool)$/.test(String(a).split('=')[0]))) return pass();
867
+ const words = positionals(rest, new Set(['--file', '-f', '--blob', '--type', '--default']));
868
+ if (words[0] === 'get' || words[0] === 'list' || words.length === 1) return pass();
869
+ return hold('git config writes change what git does');
870
+ }
871
+
872
+ function sealedWorktree(rest, repo) {
873
+ const [op = '', ...more] = rest;
874
+ if (op === 'list') return pass();
875
+ if (op !== 'add') return hold(`git worktree ${op} changes another working folder`);
876
+ const words = [];
877
+ let named = false;
878
+ let detached = false;
879
+ for (let i = 0; i < more.length; i += 1) {
880
+ const a = more[i];
881
+ if (a === '-b' || a === '-B') {
882
+ const name = String(more[i + 1] === undefined ? '' : more[i + 1]);
883
+ i += 1;
884
+ const why = targetProblem(name, repo);
885
+ if (why) return hold(`git worktree add ${a}: ${why}`);
886
+ named = true;
887
+ continue;
888
+ }
889
+ if (a === '--detach' || a === '-d') { detached = true; continue; }
890
+ if (a === '--reason') { i += 1; continue; }
891
+ if (['-f', '--force', '--checkout', '--no-checkout', '--lock', '-q', '--quiet', '--track', '--no-track', '--guess-remote', '--no-guess-remote', '--orphan'].includes(a) || a.startsWith('--reason=')) continue;
892
+ if (a.startsWith('-')) return hold(`git worktree add ${a} is not an option a handed-off job may use`);
893
+ words.push(a);
894
+ }
895
+ if (named || detached) return pass();
896
+ // with no -b, git checks out the named branch, or one named after the folder
897
+ const target = words[1] !== undefined ? words[1] : path.basename(String(words[0] || ''));
898
+ const why = targetProblem(target, repo);
899
+ return why ? hold(`git worktree add ${target}: ${why}`) : pass();
900
+ }
901
+
902
+ // gh: reads, plus opening, reading, and commenting on a PR
903
+ const GH_SEALED = {
904
+ pr: ['view', 'list', 'checks', 'diff', 'status', 'create', 'comment'],
905
+ issue: ['view', 'list', 'status'],
906
+ run: ['view', 'list', 'watch', 'download'],
907
+ workflow: ['view', 'list'],
908
+ repo: ['view', 'list'],
909
+ release: ['view', 'list', 'download'],
910
+ auth: ['status'],
911
+ gist: ['view', 'list'],
912
+ label: ['list'],
913
+ search: ['repos', 'issues', 'prs', 'code', 'commits'],
914
+ };
915
+
916
+ function sealedGh(args) {
917
+ const [group = '', verb = ''] = positionals(args, GH_VALUE_FLAGS);
918
+ if (group === 'api') {
919
+ const read = readGhApi(args.slice(args.indexOf('api')));
920
+ return !read.refuse && !read.cats.length ? pass() : hold('gh api with any method but GET is held');
921
+ }
922
+ if (group === '' || group === 'help' || group === 'version' || group === 'status') return pass();
923
+ if (GH_SEALED[group] && GH_SEALED[group].includes(verb)) return pass();
924
+ return hold(`gh ${group} ${verb} is not on the list a handed-off job may run`.replace(/\s+$/, ''));
925
+ }
926
+
927
+ // npm, yarn, pnpm: install, ci, test, run, ls, view (and the spellings each tool takes)
928
+ const PKG_SEALED = {
929
+ npm: ['install', 'i', 'in', 'ins', 'inst', 'insta', 'instal', 'isnt', 'isnta', 'isntal', 'isntall', 'add', 'ci', 'clean-install', 'ic', 'install-clean', 'isntall-clean', 'test', 't', 'tst', 'run', 'run-script', 'rum', 'urn', 'ls', 'list', 'la', 'll', 'view', 'v', 'info', 'show'],
930
+ yarn: ['install', 'add', 'test', 'run', 'list', 'info'],
931
+ pnpm: ['install', 'i', 'add', 'test', 't', 'run', 'ls', 'list', 'll', 'view', 'info'],
932
+ };
933
+
934
+ function sealedPackage(name, args) {
935
+ // the wrapper runs package scripts through its own shell; these would swap it out
936
+ const shell = args.find(a => /^--(script-shell|shell-emulator)(=|$)/.test(String(a)));
937
+ if (shell) return hold(`${name} ${shell} changes the shell package scripts run in`);
938
+ const { unknownFlag, words } = packageWords(args);
939
+ if (unknownFlag) return hold(`${name} option ${unknownFlag} could hide the command`);
940
+ const sub = String(words[0] || '');
941
+ // no command is held too: bare yarn installs, and a tool may read it as anything
942
+ return PKG_SEALED[name].includes(sub) ? pass() : hold(`${name} ${sub || '(no command)'} is not on the list a handed-off job may run`);
943
+ }
944
+
945
+ // atris: the run's own machinery and plain reads. A verb names every sub it
946
+ // allows (true: every sub stays local; a function: the subs it allows).
947
+ // Anything else is held: business, site, pay, feed, deploy, release, task
948
+ // accept, sweep and review runs (they land work), member run and wake,
949
+ // mission start and run, and every unknown word (which atris reads as a
950
+ // request to start work).
951
+ const ATRIS_SEALED = {
952
+ task: ['list', 'ls', 'show', 'inspect', 'status', 'events', 'where', 'lineage', 'note', 'say', 'claim', 'start', 'result', 'receipt', 'ready', 'done', 'finish', 'new', 'add'],
953
+ member: ['status', 'handoff'],
954
+ mission: ['status', 'tick'],
955
+ slop: true,
956
+ verify: true,
957
+ status: true,
958
+ log: sub => sub !== 'sync',
959
+ youtube: (sub, args) => sub === 'search' && !args.includes('--paid'),
960
+ help: true,
961
+ version: true,
962
+ };
963
+
964
+ function sealedAtris(args, missionId) {
965
+ const [cmd = '', sub = ''] = args;
966
+ if (args.length === 1 && ['--version', '-v', '--help', '-h'].includes(cmd)) return pass();
967
+ const allowed = Object.prototype.hasOwnProperty.call(ATRIS_SEALED, cmd) ? ATRIS_SEALED[cmd] : null;
968
+ if (!allowed) return hold(`atris ${cmd} is not on the list a handed-off job may run`);
969
+ const subOk = allowed === true || ['help', '--help', '-h'].includes(sub)
970
+ || (typeof allowed === 'function' ? allowed(sub, args) : allowed.includes(sub));
971
+ if (!subOk) return hold(`atris ${cmd} ${sub} is not on the list a handed-off job may run`);
972
+ // a tick of another job would run that job's work without this seal
973
+ if (cmd === 'mission' && sub === 'tick' && positionals(args.slice(2), new Set())[0] !== missionId) return hold('atris mission tick runs only this job');
974
+ return pass();
975
+ }
976
+
977
+ function sealedVerdict(tool, args, ctx, missionId) {
978
+ const name = path.basename(String(tool || ''));
979
+ if (name === 'git') return sealedGit(args, ctx);
980
+ if (name === 'gh') return sealedGh(args);
981
+ if (name === 'npm' || name === 'yarn' || name === 'pnpm') return sealedPackage(name, args);
982
+ if (name === 'atris') return sealedAtris(args, missionId);
983
+ return hold(`${name} is not on the list a handed-off job may run`);
984
+ }
985
+
444
986
  function analyze(tool, args = [], ctx = {}) {
445
987
  const name = path.basename(String(tool || ''));
446
988
  if (name === 'npm' || name === 'yarn' || name === 'pnpm') return readPackageTool(name, args.map(String));
@@ -453,72 +995,82 @@ function analyze(tool, args = [], ctx = {}) {
453
995
 
454
996
  // ---- the check each wrapper makes -------------------------------------------------------
455
997
 
456
- // Null: run it. { hold, message }: refuse with that message (the step is
457
- // marked asked, which the phone shows as a yes card). { consume }: run it,
458
- // and on success close those steps.
998
+ // Null: run it. { args }: run it with these words instead (a branch push in a
999
+ // woken run). { hold, message }: refuse with that message (the step is marked
1000
+ // asked, which the phone shows as a yes card). { consume }: run it as written.
459
1001
  function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd = process.cwd(), env = process.env, ctx = null } = {}) {
460
1002
  const sealed = normalizeSeal(seal);
461
1003
  if (!missionId || !sealed.length) return null;
462
1004
  const shown = `${path.basename(String(tool))} ${args.join(' ')}`.trim().slice(0, 120);
463
- const read = analyze(tool, args, ctx || (path.basename(String(tool)) === 'git' ? gitContext(gitBin, cwd, env) : {}));
464
- if (read.refuse) return { hold: true, message: `held: "${shown}": ${read.refuse}. use an allowed command, or finish the other steps and say what you need in your report.` };
1005
+ const gitCtx = () => ctx || (path.basename(String(tool)) === 'git' ? gitContext(gitBin, cwd, env) : {});
1006
+ let read;
1007
+ if (isHandoffSeal(sealed)) {
1008
+ // a woken run: only the allowlist runs without a yes; the rest is one card
1009
+ const verdict = sealedVerdict(tool, args.map(String), gitCtx(), missionId);
1010
+ if (verdict.ok) return verdict.args ? { args: verdict.args } : null;
1011
+ read = refused(verdict.why);
1012
+ } else {
1013
+ read = analyze(tool, args, gitCtx());
1014
+ }
465
1015
  const lockedCats = new Set(sealed.flatMap(s => s.categories));
466
- const needed = read.cats.filter(c => lockedCats.has(c));
467
- if (!needed.length) return null;
1016
+ const needed = read.refuse ? [] : read.cats.filter(c => lockedCats.has(c));
1017
+ if (!read.refuse && !needed.length) return null;
468
1018
  const { locks } = effectiveLocks(stateDir, missionId, sealed);
469
1019
  const key = JSON.stringify({ tool: path.basename(String(tool)), args: args.map(String), cwd: path.resolve(cwd) });
470
- // open for this run: approved, not used, approved for exactly this command, and not claimed by another run
471
- const usable = l => isOpen(l) && l.approved_command === key && !fs.existsSync(claimFile(stateDir, missionId, l));
472
- const use = [];
473
- const waiting = [];
474
- for (const cat of needed) {
475
- const open = locks.find(l => usable(l) && l.categories.includes(cat));
476
- if (open) use.push(open);
477
- else waiting.push(locks.find(l => !usable(l) && l.categories.includes(cat)));
478
- }
479
- if (!waiting.length) {
480
- const claimed = [];
481
- for (const lock of new Set(use)) {
1020
+ const hash = commandHash(key);
1021
+ // the steps that must say yes: one per kind the command touches; a command
1022
+ // the lock refuses outright asks the first step
1023
+ const owners = read.refuse ? [locks[0]] : [...new Set(needed.map(c => locks.find(l => l.categories.includes(c))))];
1024
+ const ready = owners.map(l => l.requests.find(r => r.hash === hash && r.approved_at && !r.used_at) || null);
1025
+ const now = new Date().toISOString();
1026
+ if (ready.every(Boolean)) {
1027
+ // the yes is used as the command starts: one yes, one attempt
1028
+ const claims = [];
1029
+ for (const [n, lock] of owners.entries()) {
1030
+ const file = claimFile(stateDir, missionId, lock, ready[n]);
482
1031
  try {
483
- fs.closeSync(fs.openSync(claimFile(stateDir, missionId, lock), 'wx'));
484
- claimed.push(lock);
1032
+ fs.closeSync(fs.openSync(file, 'wx'));
1033
+ claims.push(file);
485
1034
  } catch {
486
- // another run took this yes first
487
- for (const c of claimed) { try { fs.unlinkSync(claimFile(stateDir, missionId, c)); } catch { /* gone */ } }
1035
+ // another run took this yes first; give back the ones taken here
1036
+ for (const c of claims) { try { fs.unlinkSync(c); } catch { /* gone */ } }
488
1037
  return { hold: true, steps: [lock.step], message: `held: "${shown}": the yes for step ${lock.step} is already being used by another run. one yes covers one run.` };
489
1038
  }
490
1039
  }
491
- return { consume: claimed.map(l => l.step), claims: claimed.map(l => claimFile(stateDir, missionId, l)) };
1040
+ for (const r of ready) r.used_at = now;
1041
+ saveLocks(stateDir, missionId, locks);
1042
+ return { consume: owners.map(l => l.step), claims };
492
1043
  }
493
- const now = new Date().toISOString();
494
- for (const lock of new Set(waiting)) Object.assign(lock, { asked_at: now, approved_at: null, consumed_at: null, approved_command: null, held_command: shown, held_key: key });
1044
+ // held: a request on each step still missing a yes, one per exact command
1045
+ owners.forEach((lock, n) => {
1046
+ if (ready[n]) return;
1047
+ if (!lock.requests.some(r => r.hash === hash && !r.used_at)) lock.requests.push({ hash, key, command: shown, asked_at: now, approved_at: null, used_at: null });
1048
+ });
495
1049
  saveLocks(stateDir, missionId, locks);
496
- const first = waiting[0];
497
- const cats = needed.filter(c => !locks.some(l => isOpen(l) && l.categories.includes(c)));
1050
+ const first = owners[ready.findIndex(r => !r)];
1051
+ const steps = [...new Set(owners.filter((l, n) => !ready[n]).map(l => l.step))];
1052
+ const after = 'a yes card is now on the owner\'s phone. wait for the ping that says yes, then run it again; one yes covers one run.';
1053
+ if (first.text === HANDOFF_SEAL_TEXT) return { hold: true, steps, message: `held: ${handoffAsk(shown)}. ${read.refuse ? `${read.refuse}. ` : ''}${after}` };
1054
+ if (read.refuse) return { hold: true, steps, message: `held: "${shown}": ${read.refuse}. ${after}` };
498
1055
  return {
499
1056
  hold: true,
500
- steps: [...new Set(waiting.map(w => w.step))],
501
- message: `held: "${shown}" would ${cats.map(c => CATEGORY_WORDS[c]).join(' and ')}, and step ${first.step} ("${first.text}") needs the owner's yes first. a yes card is now on the owner's phone. wait for the ping that says yes, then run it again; one yes covers one run.`,
1057
+ steps,
1058
+ message: `held: "${shown}" would ${needed.map(c => CATEGORY_WORDS[c]).join(' and ')}, and step ${first.step} ("${first.text}") needs the owner's yes first. ${after}`,
502
1059
  };
503
1060
  }
504
1061
 
505
- // After the run a yes allowed: on success those steps close; on failure the
506
- // claim is dropped so the same yes can be used again.
507
- function consume(stateDir, missionId, seal, steps, { ok = true, claims = [] } = {}) {
508
- if (!ok) {
509
- for (const c of claims) { try { fs.unlinkSync(c); } catch { /* gone */ } }
510
- return;
511
- }
512
- const { locks, trusted } = effectiveLocks(stateDir, missionId, seal);
513
- if (!trusted) return;
514
- const at = new Date().toISOString();
515
- for (const l of locks) if (steps.includes(l.step) && isOpen(l)) l.consumed_at = at;
516
- saveLocks(stateDir, missionId, locks);
517
- }
1062
+ // After a run a yes allowed. The yes was used when the run started, so a
1063
+ // failed run does not get another go; this is kept for the wrappers that
1064
+ // still call it.
1065
+ function consume() {}
518
1066
 
519
1067
  module.exports = {
520
1068
  HELD_EXIT,
1069
+ handoffParent,
1070
+ handoffSeal,
1071
+ handoffYesCards,
521
1072
  analyze,
1073
+ gitContext,
522
1074
  approveYesLock,
523
1075
  askedYesLocks,
524
1076
  check,