atris 3.71.2 → 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.
- package/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +8 -2
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +263 -11
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/improve.js +3 -2
- package/commands/member.js +28 -3
- package/commands/mission.js +82 -27
- package/commands/pulse.js +89 -5
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/worktree.js +24 -2
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +53 -14
- package/lib/campaign-core.js +98 -17
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +11 -6
- package/lib/campaign-loop.js +173 -32
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +697 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +77 -29
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-code-provenance.js +118 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/pulse.js +2 -1
- package/lib/qr.js +338 -0
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
package/lib/task-receipt.js
CHANGED
|
@@ -43,6 +43,14 @@ function slugifyTaskId(taskId) {
|
|
|
43
43
|
.slice(0, 40) || 'task';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function verifyTimeoutMs() {
|
|
47
|
+
// Match the audit and certification budget, including its one-hour ceiling.
|
|
48
|
+
const raw = Number(process.env.ATRIS_VERIFY_TIMEOUT_MS);
|
|
49
|
+
return Number.isFinite(raw) && raw > 0
|
|
50
|
+
? Math.min(Math.ceil(raw), 60 * 60 * 1000)
|
|
51
|
+
: 10 * 60 * 1000;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
// Runs `command` for `taskId`, writes atris/runs/<date>-task-<id>-<time>.json,
|
|
47
55
|
// and returns { ok, passed, receiptPath, exit, signal, output }. `ok` mirrors
|
|
48
56
|
// `passed`, kept as a separate field so callers reading either name work.
|
|
@@ -56,16 +64,24 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
56
64
|
// support. Put the node running atris first, after the profile has loaded.
|
|
57
65
|
const nodeDir = path.dirname(process.execPath).replace(/'/g, `'\\''`);
|
|
58
66
|
const script = `export PATH='${nodeDir}':"$PATH"\n${cmd}`;
|
|
67
|
+
const timeoutMs = verifyTimeoutMs();
|
|
59
68
|
const result = run('bash', ['-lc', script], {
|
|
60
69
|
cwd: root,
|
|
61
70
|
encoding: 'utf8',
|
|
62
|
-
timeout:
|
|
71
|
+
timeout: timeoutMs,
|
|
63
72
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
const failure = result.error ? {
|
|
74
|
+
error: result.error.message,
|
|
75
|
+
error_code: result.error.code || null,
|
|
76
|
+
timeout_ms: timeoutMs,
|
|
77
|
+
} : {};
|
|
78
|
+
const diagnostic = result.error
|
|
79
|
+
? (result.error.code === 'ETIMEDOUT'
|
|
80
|
+
? `\nverification timed out after ${timeoutMs / 1000} seconds.`
|
|
81
|
+
: '\nverification did not complete.')
|
|
82
|
+
: '';
|
|
83
|
+
const output = tailText(`${result.stdout || ''}${result.stderr || ''}${diagnostic}`);
|
|
84
|
+
const passed = !result.error && result.status === 0;
|
|
69
85
|
const dir = runsDir(root);
|
|
70
86
|
fs.mkdirSync(dir, { recursive: true });
|
|
71
87
|
const now = new Date();
|
|
@@ -84,6 +100,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
84
100
|
exit: result.status,
|
|
85
101
|
signal: result.signal || null,
|
|
86
102
|
output,
|
|
103
|
+
...failure,
|
|
87
104
|
},
|
|
88
105
|
};
|
|
89
106
|
fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2) + '\n', 'utf8');
|
|
@@ -96,6 +113,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
96
113
|
signal: result.signal || null,
|
|
97
114
|
output,
|
|
98
115
|
cmd,
|
|
116
|
+
...(result.error ? { reason: 'verifier_spawn_failed', ...failure } : {}),
|
|
99
117
|
};
|
|
100
118
|
}
|
|
101
119
|
|
package/lib/yes-lock.js
ADDED
|
@@ -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
|
+
};
|