atris 3.73.1 → 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/commands/member.js +141 -7
- package/commands/mission.js +156 -65
- package/commands/slop.js +8 -2
- package/lib/falsifier-probe.js +2 -1
- package/lib/gm-bet.js +84 -25
- package/lib/gm-phone-page.js +386 -98
- package/lib/gm-phone.js +27 -3
- package/lib/handoff-wake.js +180 -0
- package/lib/member-up.js +18 -3
- package/lib/mission-protected-lane.js +38 -6
- package/lib/spawn-env.js +92 -3
- package/lib/task-proof.js +2 -1
- package/lib/task-receipt.js +3 -2
- package/lib/yes-lock.js +633 -81
- package/package.json +1 -1
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
|
-
// -
|
|
22
|
-
//
|
|
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
|
-
|
|
108
|
-
return {
|
|
109
|
-
|
|
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
|
|
118
|
-
//
|
|
119
|
-
function claimFile(stateDir, missionId, lock) {
|
|
120
|
-
return `${stateFile(stateDir, missionId)}.step-${lock.step}.${String(
|
|
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
|
-
|
|
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
|
|
131
|
-
//
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
//
|
|
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
|
|
301
|
-
//
|
|
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 (
|
|
306
|
-
|
|
307
|
-
if (cmd
|
|
308
|
-
if (cmd
|
|
309
|
-
|
|
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
|
-
|
|
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. {
|
|
457
|
-
//
|
|
458
|
-
//
|
|
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
|
|
464
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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(
|
|
484
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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 =
|
|
497
|
-
const
|
|
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
|
|
501
|
-
message: `held: "${shown}" would ${
|
|
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
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
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,
|