atris 3.71.3 → 3.72.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.
@@ -0,0 +1,530 @@
1
+ 'use strict';
2
+
3
+ // The your-yes lock: a seatbelt that keeps a job started from the orb from
4
+ // publishing, tagging, releasing, posting, sending, or merging to master
5
+ // before the owner says yes. It is not a sandbox. The job runs as the same
6
+ // user on the same machine, so a determined process could still call an API
7
+ // with curl, run a tool by its full path, or edit files this code reads. It
8
+ // stops an agent's mistakes through the commands it names, and it fails
9
+ // closed.
10
+ //
11
+ // How it works:
12
+ // - at start, the mission record keeps a sealed list of the owner steps and
13
+ // what each locks (publish, tag, post, send, merge, deploy)
14
+ // - approvals live in a state file under ~/.atris/state/yes-locks, outside
15
+ // the workspace; a missing, unreadable, or mismatched file means every
16
+ // sealed step is locked
17
+ // - the mission guard puts wrappers for git, npm, yarn, pnpm, npx, pnpx, gh,
18
+ // and atris first on the job's PATH; each asks check() before running
19
+ // - while the job is locked, npm, yarn, pnpm, and gh only run known read and
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
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { spawnSync } = require('child_process');
27
+
28
+ const HELD_EXIT = 77;
29
+ const CATEGORY_WORDS = {
30
+ publish: 'publish or release a package',
31
+ tag: 'push a version tag',
32
+ post: 'post in public',
33
+ send: 'send a message',
34
+ merge: 'change master',
35
+ deploy: 'deploy',
36
+ };
37
+
38
+ // ---- what an owner step locks ------------------------------------------------------
39
+
40
+ const STEP_WORDS = [
41
+ [/\b(publish|publishes|published|release|releases|released|npm)\b/, ['publish', 'tag']],
42
+ [/\b(tag|tags)\b/, ['tag']],
43
+ [/\b(post|posts|tweet|tweets|thread|announce|launch|show hn|hacker news|linkedin|reddit)\b/, ['post']],
44
+ [/\b(send|sends|email|emails|dm|dms|message|messages|text|reply|invite|invites|outreach)\b/, ['send']],
45
+ [/\b(merge|merges|merged|land|lands)\b/, ['merge']],
46
+ [/\b(deploy|deploys|deployed)\b/, ['deploy']],
47
+ ];
48
+
49
+ function stepLocks(text) {
50
+ const t = String(text || '').toLowerCase();
51
+ const out = new Set();
52
+ for (const [re, cats] of STEP_WORDS) if (re.test(t)) cats.forEach(c => out.add(c));
53
+ return [...out];
54
+ }
55
+
56
+ // The sealed list for a bet's steps: one entry per owner step that locks something.
57
+ function sealFor(steps) {
58
+ return (Array.isArray(steps) ? steps : [])
59
+ .map((s, i) => (s && s.who === 'you' ? { step: i + 1, text: String(s.text || ''), categories: stepLocks(s.text) } : null))
60
+ .filter(l => l && l.categories.length);
61
+ }
62
+
63
+ function normalizeSeal(seal) {
64
+ return (Array.isArray(seal) ? seal : [])
65
+ .filter(s => s && Number.isInteger(s.step) && Array.isArray(s.categories) && s.categories.length)
66
+ .map(s => ({ step: s.step, text: String(s.text || ''), categories: [...new Set(s.categories.map(String))].sort() }));
67
+ }
68
+
69
+ // ---- the state file, outside the workspace -----------------------------------------
70
+
71
+ function stateFile(stateDir, missionId) {
72
+ return path.join(stateDir, 'yes-locks', `${String(missionId).replace(/[^A-Za-z0-9._-]/g, '_')}.json`);
73
+ }
74
+
75
+ function readState(stateDir, missionId) {
76
+ try {
77
+ return JSON.parse(fs.readFileSync(stateFile(stateDir, missionId), 'utf8'));
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function writeState(stateDir, missionId, data) {
84
+ const file = stateFile(stateDir, missionId);
85
+ fs.mkdirSync(path.dirname(file), { recursive: true });
86
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
87
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
88
+ fs.renameSync(tmp, file);
89
+ }
90
+
91
+ function writeYesLocks(stateDir, missionId, { betId = null, seal = [] } = {}) {
92
+ 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 });
94
+ return locks;
95
+ }
96
+
97
+ // The locks as they stand. Every sealed step must be in the state file with
98
+ // the same categories; otherwise the file is not trusted and every step is
99
+ // locked, with nothing approved.
100
+ function effectiveLocks(stateDir, missionId, seal) {
101
+ const sealed = normalizeSeal(seal);
102
+ if (!sealed.length) return { locks: [], trusted: true };
103
+ const data = readState(stateDir, missionId);
104
+ const rows = data && Array.isArray(data.locks) ? data.locks : null;
105
+ const same = (a, b) => JSON.stringify([...new Set((a || []).map(String))].sort()) === JSON.stringify(b);
106
+ 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
+ };
115
+ }
116
+
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`;
121
+ }
122
+
123
+ const isOpen = l => Boolean(l.approved_at) && !l.consumed_at;
124
+
125
+ function saveLocks(stateDir, missionId, locks, betId) {
126
+ const prior = readState(stateDir, missionId);
127
+ writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, locks });
128
+ }
129
+
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()) {
133
+ const { locks } = effectiveLocks(stateDir, missionId, seal || sealFromState(stateDir, missionId));
134
+ const lock = locks.find(l => l.step === step);
135
+ if (!lock) return null;
136
+ Object.assign(lock, { approved_at: at, consumed_at: null, approved_command: lock.held_key || null });
137
+ saveLocks(stateDir, missionId, locks);
138
+ return lock;
139
+ }
140
+
141
+ // For readers that have no mission record at hand (the desk), the state file
142
+ // is its own seal: it is only ever used to show and answer cards, never to let
143
+ // a command through.
144
+ function sealFromState(stateDir, missionId) {
145
+ const data = readState(stateDir, missionId);
146
+ return data && Array.isArray(data.locks) ? data.locks : [];
147
+ }
148
+
149
+ function askedYesLocks(stateDir, missionId) {
150
+ const data = readState(stateDir, missionId);
151
+ return data && Array.isArray(data.locks) ? data.locks.filter(l => l.asked_at && !isOpen(l) && !l.consumed_at) : [];
152
+ }
153
+
154
+ // ---- reading commands ---------------------------------------------------------------
155
+
156
+ const MAIN_BRANCHES = new Set(['master', 'main']);
157
+ const RISKY_GIT_CONFIG = /^(alias\..+|remote\..+|push\..+|url\..+|branch\..+\.(remote|pushremote|merge)|core\.hookspath|include\..+|includeif\..+|core\.sshcommand|credential\..+)$/i;
158
+
159
+ // a parsed view of one command: the categories it touches, or a refusal
160
+ const touches = (...cats) => ({ cats: [...new Set(cats)], refuse: null });
161
+ const refused = why => ({ cats: [], refuse: why });
162
+ const CLEAR = { cats: [], refuse: null };
163
+
164
+ function positionals(args, valueFlags) {
165
+ const out = [];
166
+ for (let i = 0; i < args.length; i += 1) {
167
+ const a = String(args[i] || '');
168
+ if (a === '--') { out.push(...args.slice(i + 1).map(String)); break; }
169
+ if (a.startsWith('-')) {
170
+ if (!a.includes('=') && valueFlags.has(a)) i += 1;
171
+ continue;
172
+ }
173
+ out.push(a);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ // npm, yarn, pnpm: global options are skipped first, then only known read
179
+ // and build commands run. Publishing names map to publish; the exec family
180
+ // and anything unknown is refused.
181
+ const NPM_VALUE_FLAGS = new Set(['--registry', '--prefix', '-C', '--userconfig', '--globalconfig', '--cache', '--loglevel', '--workspace', '-w', '--tag', '--access', '--otp', '--scope', '--location', '--include', '--omit', '--before', '--auth-type', '--filter', '--dir', '--cwd']);
182
+ const PKG_READ = {
183
+ npm: ['test', 't', 'tst', 'run', 'run-script', 'rum', 'urn', 'ci', 'clean-install', 'install', 'i', 'in', 'ins', 'inst', 'insta', 'instal', 'isnt', 'isnta', 'isntal', 'isntall', 'add', 'uninstall', 'remove', 'rm', 'r', 'un', 'ls', 'list', 'la', 'll', 'view', 'v', 'info', 'show', 'outdated', 'audit', 'explain', 'why', 'prefix', 'root', 'bin', 'help', 'doctor', 'fund', 'start', 'stop', 'restart', 'rebuild', 'rb', 'init', 'create', 'version', 'dedupe', 'prune', 'update', 'up', 'upgrade', 'find-dupes', 'query'],
184
+ yarn: ['', 'install', 'add', 'remove', 'run', 'test', 'why', 'info', 'list', 'outdated', 'audit', 'upgrade', 'up', 'init', 'version', 'cache', 'check', 'licenses', 'config'],
185
+ pnpm: ['install', 'i', 'add', 'remove', 'rm', 'un', 'uninstall', 'update', 'up', 'upgrade', 'run', 'test', 't', 'list', 'ls', 'll', 'why', 'outdated', 'audit', 'init', 'prune', 'store', 'root', 'bin', 'licenses', 'fetch', 'rebuild', 'rb'],
186
+ };
187
+ const PKG_PUBLISH = new Set(['publish', 'pub', 'publis', 'unpublish', 'deprecate', 'undeprecate', 'dist-tag', 'dist-tags', 'owner', 'access', 'team']);
188
+ const PKG_EXEC = new Set(['exec', 'x', 'dlx', 'create-exec']);
189
+
190
+ 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
+
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.
194
+ function packageWords(args) {
195
+ const words = [];
196
+ for (let i = 0; i < args.length; i += 1) {
197
+ const a = String(args[i] || '');
198
+ if (a === '--') break;
199
+ if (a.startsWith('-')) {
200
+ if (a.includes('=') || PKG_BOOL_FLAGS.has(a)) continue;
201
+ if (NPM_VALUE_FLAGS.has(a)) { i += 1; continue; }
202
+ return { unknownFlag: a, words };
203
+ }
204
+ words.push(a);
205
+ }
206
+ return { unknownFlag: null, words };
207
+ }
208
+
209
+ function readPackageTool(name, args) {
210
+ const { unknownFlag, words } = packageWords(args);
211
+ if (unknownFlag) return refused(`${name} option ${unknownFlag} is not one this lock knows, so it could hide the command; write it as ${unknownFlag}=<value> while owner steps wait`);
212
+ const sub = String(words[0] || '');
213
+ // with unknown options refused, the first word is the command npm runs
214
+ if (PKG_EXEC.has(sub)) return refused(`${name} ${sub} can run any command, so it is closed while owner steps wait`);
215
+ if (PKG_PUBLISH.has(sub) || (name === 'yarn' && sub === 'npm' && words[1] === 'publish')) return touches('publish');
216
+ if (sub === 'pack') return args.includes('--dry-run') ? CLEAR : refused(`${name} pack writes a package file; use --dry-run while owner steps wait`);
217
+ if (sub === 'config' && !/^(get|list|ls)$/.test(String(words[1] || ''))) return refused(`${name} config can change where packages go; only get and list run while owner steps wait`);
218
+ if (PKG_READ[name].includes(sub)) return CLEAR;
219
+ return refused(`${name} ${sub || '(no command)'} is not on the list of commands a locked job may run`);
220
+ }
221
+
222
+ // gh: only read verbs run; writes map to their category; anything else is refused.
223
+ const GH_VALUE_FLAGS = new Set(['-R', '--repo', '--hostname', '-X', '--method', '-f', '-F', '--field', '--raw-field', '--input', '-H', '--header', '-q', '--jq', '-t', '--template', '--cache', '-p', '--preview']);
224
+ const GH_READ = {
225
+ pr: ['view', 'list', 'checks', 'diff', 'status', 'create', 'checkout'],
226
+ issue: ['view', 'list', 'status'],
227
+ run: ['view', 'list', 'watch', 'download'],
228
+ workflow: ['view', 'list'],
229
+ repo: ['view', 'list', 'clone'],
230
+ release: ['view', 'list', 'download'],
231
+ auth: ['status'],
232
+ gist: ['view', 'list'],
233
+ label: ['list'],
234
+ search: ['repos', 'issues', 'prs', 'code', 'commits'],
235
+ };
236
+ const GH_WRITE = {
237
+ release: { create: 'publish', new: 'publish', upload: 'publish', edit: 'publish', delete: 'publish', 'delete-asset': 'publish' },
238
+ pr: { merge: 'merge', comment: 'post', review: 'post', edit: 'post', close: 'post', reopen: 'post', ready: 'post' },
239
+ issue: { create: 'post', new: 'post', comment: 'post', edit: 'post', close: 'post', reopen: 'post' },
240
+ gist: { create: 'post', new: 'post', edit: 'post' },
241
+ };
242
+
243
+ function readGhApi(args) {
244
+ // gh takes the last method given; a short cluster like -iXPUT or -if
245
+ // carries its letters together
246
+ let method = null;
247
+ let writes = false;
248
+ // long options that take a value: their value is never read as a flag
249
+ const LONG_VALUES = new Set(['--method', '--field', '--raw-field', '--input', '--header', '--jq', '--template', '--cache', '--preview', '--hostname', '--repo']);
250
+ // short letters that take a value: the rest of the cluster, or the next word
251
+ const SHORT_VALUES = new Set(['X', 'f', 'F', 'H', 'q', 't', 'p', 'R']);
252
+ for (let i = 0; i < args.length; i += 1) {
253
+ const a = String(args[i]);
254
+ if (a === '--') break;
255
+ if (a.startsWith('--')) {
256
+ const [name, inline] = a.split(/=(.*)/s);
257
+ const value = inline !== undefined ? inline : (LONG_VALUES.has(name) ? String(args[i + 1] || '') : null);
258
+ if (inline === undefined && LONG_VALUES.has(name)) i += 1;
259
+ if (name === '--method') method = String(value || '').toUpperCase();
260
+ if (name === '--field' || name === '--raw-field' || name === '--input') writes = true;
261
+ continue;
262
+ }
263
+ if (/^-[^-]/.test(a)) {
264
+ const letters = a.slice(1);
265
+ for (let k = 0; k < letters.length; k += 1) {
266
+ const ch = letters[k];
267
+ if (!SHORT_VALUES.has(ch)) continue;
268
+ const rest = letters.slice(k + 1);
269
+ const value = rest || String(args[i + 1] || '');
270
+ if (!rest) i += 1;
271
+ if (ch === 'X') method = value.toUpperCase();
272
+ if (ch === 'f' || ch === 'F') writes = true;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ if ((method === null || method === 'GET') && !writes) return CLEAR;
278
+ // the route and every field, since a ref or a tag can be named in a field
279
+ const route = args.slice(1).join(' ').toLowerCase();
280
+ const cats = [];
281
+ const anyRef = /git\/refs\b(?!\/(heads|tags)\/)/.test(route);
282
+ if (/releases?\b/.test(route)) cats.push('publish');
283
+ if (/refs\/tags/.test(route) || anyRef) cats.push('tag');
284
+ if (/\/merges?\b|\/pulls\/[^/\s]+\/merge|refs\/heads|\/branches\//.test(route) || anyRef) cats.push('merge');
285
+ if (/\/issues|\/comments|\/discussions/.test(route)) cats.push('post');
286
+ return cats.length ? touches(...cats) : refused('gh api with a write is not on the list of commands a locked job may run');
287
+ }
288
+
289
+ function readGh(args) {
290
+ const words = positionals(args, GH_VALUE_FLAGS);
291
+ const [group = '', verb = ''] = words;
292
+ if (group === 'api') return readGhApi(args.slice(args.indexOf('api')));
293
+ if (['help', 'version', 'status', 'browse', '--version'].includes(group) || group === '') return CLEAR;
294
+ const write = GH_WRITE[group] && GH_WRITE[group][verb];
295
+ if (write) return touches(write);
296
+ if (GH_READ[group] && GH_READ[group].includes(verb)) return CLEAR;
297
+ return refused(`gh ${group} ${verb} is not on the list of commands a locked job may run`);
298
+ }
299
+
300
+ // atris: the send and post commands touch every category they can reach;
301
+ // everything else is the mission's own machinery and runs.
302
+ function readAtris(args) {
303
+ const words = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
304
+ 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;
310
+ }
311
+
312
+ // ---- git ------------------------------------------------------------------------
313
+
314
+ function parseGit(args) {
315
+ const out = { prefix: [], configs: [], sub: '', rest: [] };
316
+ for (let i = 0; i < args.length; i += 1) {
317
+ const a = String(args[i] || '');
318
+ if (a === '-C' || a === '--git-dir' || a === '--work-tree' || a === '--namespace') { out.prefix.push(a, String(args[i + 1] || '')); i += 1; continue; }
319
+ if (/^--(git-dir|work-tree|namespace)=/.test(a)) { out.prefix.push(a); continue; }
320
+ if (a === '-c') { out.configs.push(String(args[i + 1] || '')); i += 1; continue; }
321
+ if (/^-c./.test(a)) { out.configs.push(a.slice(2)); continue; }
322
+ if (a === '--config-env') { out.configs.push(String(args[i + 1] || '')); i += 1; continue; }
323
+ if (a.startsWith('--config-env=')) { out.configs.push(a.slice(13)); continue; }
324
+ if (a.startsWith('-')) continue;
325
+ out.sub = a;
326
+ out.rest = args.slice(i + 1).map(String);
327
+ break;
328
+ }
329
+ return out;
330
+ }
331
+
332
+ const configKey = c => String(c || '').split('=')[0].trim();
333
+
334
+ // Config set through the environment counts the same as -c.
335
+ function envConfigKeys(env = process.env) {
336
+ const keys = [];
337
+ const n = Number(env.GIT_CONFIG_COUNT);
338
+ if (Number.isInteger(n)) for (let i = 0; i < n; i += 1) keys.push(String(env[`GIT_CONFIG_KEY_${i}`] || ''));
339
+ for (const m of String(env.GIT_CONFIG_PARAMETERS || '').matchAll(/'([^'=]+)/g)) keys.push(m[1]);
340
+ return keys;
341
+ }
342
+
343
+ const PUSH_VALUE_FLAGS = new Set(['--repo', '-o', '--push-option', '--receive-pack', '--exec']);
344
+ const PUSH_REFUSED = ['--all', '--branches', '--mirror', '--tags', '--follow-tags', '--prune'];
345
+ // git takes any unambiguous start of a long option, so --tag means --tags
346
+ const isAbbrevOf = (arg, full) => arg.length >= 3 && full.startsWith(arg);
347
+
348
+ function destCategory(dest, ctx) {
349
+ const d = String(dest || '').replace(/^\+/, '');
350
+ if (!d) return null;
351
+ if (d.includes('*')) return ['merge', 'tag'];
352
+ // git finds a short name under refs/, so heads/master and tags/v1 count
353
+ const rel = d.replace(/^refs\//, '');
354
+ if (rel.startsWith('tags/')) return ['tag'];
355
+ const branch = rel.replace(/^heads\//, '');
356
+ if (MAIN_BRANCHES.has(branch)) return ['merge'];
357
+ if (!d.startsWith('refs/') && ctx.isTag(d) && !ctx.isBranch(d)) return ['tag'];
358
+ return null;
359
+ }
360
+
361
+ function readPush(rest, ctx) {
362
+ const bad = rest.find(a => a.startsWith('--') && PUSH_REFUSED.some(full => isAbbrevOf(a.split('=')[0], full)));
363
+ if (bad) return refused(`git push ${bad} can move master or tags all at once, so it is closed while owner steps wait`);
364
+ const words = positionals(rest, PUSH_VALUE_FLAGS);
365
+ const repoFlag = rest.some(a => a.startsWith('--re') && isAbbrevOf(a.split('=')[0], '--repo'));
366
+ const cats = new Set();
367
+ // every word may be a refspec (with --repo, even the first one)
368
+ for (const w of words) {
369
+ if (w === ':' ) return refused('git push ":" pushes every matching branch, so it is closed while owner steps wait');
370
+ const [src, ...dstParts] = w.split(':');
371
+ const dest = dstParts.length ? dstParts.join(':') : (src.replace(/^\+/, '') === 'HEAD' || src === '@' ? ctx.currentBranch() : src);
372
+ for (const c of destCategory(dest, ctx) || []) cats.add(c);
373
+ }
374
+ // no refspec given: where git would push by itself
375
+ if (words.length <= 1 || (repoFlag && words.length === 0)) {
376
+ const remote = repoFlag ? null : words[0] || null;
377
+ for (const spec of ctx.remotePushSpecs(remote)) {
378
+ const dest = spec.includes(':') ? spec.split(':').pop() : spec;
379
+ for (const c of destCategory(dest, ctx) || []) cats.add(c);
380
+ }
381
+ const target = ctx.pushDest();
382
+ if (target) for (const c of destCategory(target.replace(/^[^/]+\//, ''), ctx) || []) cats.add(c);
383
+ else if (MAIN_BRANCHES.has(ctx.currentBranch())) cats.add('merge');
384
+ if (/^true$/i.test(ctx.config('push.followTags'))) cats.add('tag');
385
+ }
386
+ return touches(...cats);
387
+ }
388
+
389
+ const ONTO_BRANCH = new Set(['merge', 'commit', 'cherry-pick', 'rebase', 'am', 'revert', 'reset']);
390
+
391
+ function readGit(args, ctx, depth = 0) {
392
+ const p = parseGit(args);
393
+ const risky = [...p.configs.map(configKey), ...ctx.envConfigKeys()].find(k => RISKY_GIT_CONFIG.test(k));
394
+ if (risky) return refused(`git config ${risky} can change where a push goes, so it is closed while owner steps wait`);
395
+ const repo = ctx.forRepo(p.prefix);
396
+ const sub = p.sub;
397
+ if (!sub) return CLEAR;
398
+ const alias = repo.alias(sub);
399
+ if (alias !== null && alias !== undefined) {
400
+ 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
+ return readGit([...p.prefix, ...alias.trim().split(/\s+/), ...p.rest], ctx, depth + 1);
402
+ }
403
+ if (sub === 'push') return readPush(p.rest, repo);
404
+ // the low-level ways to update a remote are closed outright
405
+ 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
+ // fetch and pull can write straight into a local branch: HEAD:refs/heads/master
407
+ if (sub === 'fetch' || sub === 'pull') {
408
+ const cats = new Set();
409
+ for (const w of positionals(p.rest, new Set(['--depth', '--deepen', '--shallow-since', '--shallow-exclude', '-j', '--jobs', '--upload-pack', '--negotiation-tip']))) {
410
+ if (!w.includes(':')) continue;
411
+ for (const c of destCategory(w.split(':').pop(), repo) || []) if (c === 'merge') cats.add(c);
412
+ }
413
+ if (sub === 'pull' && MAIN_BRANCHES.has(repo.currentBranch())) cats.add('merge');
414
+ return touches(...cats);
415
+ }
416
+ if ((sub === 'checkout' || sub === 'switch') && p.rest.some(a => /^-(B|C|-force-create)$/.test(a)) && p.rest.some(a => MAIN_BRANCHES.has(a))) return touches('merge');
417
+ if (ONTO_BRANCH.has(sub) && MAIN_BRANCHES.has(repo.currentBranch())) return touches('merge');
418
+ if (sub === 'update-ref') {
419
+ if (p.rest.includes('--stdin')) return refused('git update-ref --stdin can move any branch, so it is closed while owner steps wait');
420
+ const ref = String(positionals(p.rest, new Set(['-m'])).filter(a => a !== '-d')[0] || '');
421
+ return touches(...(destCategory(ref, repo) || []));
422
+ }
423
+ 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');
424
+ return CLEAR;
425
+ }
426
+
427
+ // git questions answered by the real git, in the repo the command names
428
+ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
429
+ const run = a => spawnSync(gitBin || 'git', [...prefix, ...a], { cwd, env, encoding: 'utf8', timeout: 5000 });
430
+ const out = a => { const r = run(a); return r.status === 0 ? String(r.stdout || '').trim() : null; };
431
+ return {
432
+ currentBranch: () => out(['rev-parse', '--abbrev-ref', 'HEAD']) || '',
433
+ isTag: name => Boolean(name) && run(['rev-parse', '-q', '--verify', `refs/tags/${name}`]).status === 0,
434
+ isBranch: name => Boolean(name) && run(['rev-parse', '-q', '--verify', `refs/heads/${name}`]).status === 0,
435
+ pushDest: () => out(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}']),
436
+ remotePushSpecs: remote => (out(['config', '--get-all', `remote.${remote || out(['config', '--get', 'remote.pushDefault']) || 'origin'}.push`]) || '').split('\n').filter(Boolean),
437
+ config: key => out(['config', '--get', key]) || '',
438
+ alias: name => out(['config', '--get', `alias.${name}`]),
439
+ envConfigKeys: () => envConfigKeys(env),
440
+ forRepo: more => (more && more.length ? gitContext(gitBin, cwd, env, [...prefix, ...more]) : gitContext(gitBin, cwd, env, prefix)),
441
+ };
442
+ }
443
+
444
+ function analyze(tool, args = [], ctx = {}) {
445
+ const name = path.basename(String(tool || ''));
446
+ if (name === 'npm' || name === 'yarn' || name === 'pnpm') return readPackageTool(name, args.map(String));
447
+ if (name === 'npx' || name === 'pnpx' || name === 'bunx') return refused(`${name} can run any command, so it is closed while owner steps wait`);
448
+ if (name === 'gh') return readGh(args.map(String));
449
+ if (name === 'atris') return readAtris(args.map(String));
450
+ if (name === 'git') return readGit(args.map(String), ctx);
451
+ return CLEAR;
452
+ }
453
+
454
+ // ---- the check each wrapper makes -------------------------------------------------------
455
+
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.
459
+ function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd = process.cwd(), env = process.env, ctx = null } = {}) {
460
+ const sealed = normalizeSeal(seal);
461
+ if (!missionId || !sealed.length) return null;
462
+ 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.` };
465
+ 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;
468
+ const { locks } = effectiveLocks(stateDir, missionId, sealed);
469
+ 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)) {
482
+ try {
483
+ fs.closeSync(fs.openSync(claimFile(stateDir, missionId, lock), 'wx'));
484
+ claimed.push(lock);
485
+ } catch {
486
+ // another run took this yes first
487
+ for (const c of claimed) { try { fs.unlinkSync(claimFile(stateDir, missionId, c)); } catch { /* gone */ } }
488
+ 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
+ }
490
+ }
491
+ return { consume: claimed.map(l => l.step), claims: claimed.map(l => claimFile(stateDir, missionId, l)) };
492
+ }
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 });
495
+ saveLocks(stateDir, missionId, locks);
496
+ const first = waiting[0];
497
+ const cats = needed.filter(c => !locks.some(l => isOpen(l) && l.categories.includes(c)));
498
+ return {
499
+ 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.`,
502
+ };
503
+ }
504
+
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
+ }
518
+
519
+ module.exports = {
520
+ HELD_EXIT,
521
+ analyze,
522
+ approveYesLock,
523
+ askedYesLocks,
524
+ check,
525
+ consume,
526
+ effectiveLocks,
527
+ sealFor,
528
+ stepLocks,
529
+ writeYesLocks,
530
+ };