@arjunkhera/atlas 0.4.19 → 0.4.21

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -40,6 +40,7 @@ import { afterCommand as testsAfter } from './lib/passes-after.mjs';
40
40
  import { upkeepCommand } from './lib/upkeep.mjs';
41
41
  import { gateCommand, mergeList } from './lib/gate.mjs';
42
42
  import { judgeCommand } from './lib/judge.mjs';
43
+ import { approveCommand } from './lib/approve.mjs';
43
44
 
44
45
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
45
46
  const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
@@ -153,6 +154,7 @@ const HELP = `atlas — the door into a repo's Atlas files
153
154
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
154
155
  atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
155
156
  atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
157
+ --pr <n> --base-ref <branch> with --repo: read the comments of the pull request, and let the owner's signed act for this base and head clear owner
156
158
  --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
157
159
  --body <file> the pull request text, for the "behaviour change: no" of a refactor
158
160
  --json print the answer as JSON
@@ -162,6 +164,17 @@ const HELP = `atlas — the door into a repo's Atlas files
162
164
  --branch <name> the branch; default origin/HEAD
163
165
  --json print the answer as JSON
164
166
 
167
+ atlas approve <pr>... the owner's signed act, on his Mac: show the gate's class and cause for each pull request, then one touch signs every one the gate sent to the owner, and the block goes on each as a comment
168
+ --repo <owner/name> the repo; default the repo that atlas.yaml names
169
+ --key <file> the security key; default ~/.ssh/atlas_signing_key, or the key in ~/.config/atlas/signing.json
170
+ --provider <file> the security key provider; default /usr/lib/ssh-keychain.dylib on a Mac (Touch ID)
171
+ --yes sign with no question; the key still needs the touch
172
+ atlas approve act <kind> sign one other act and print its block: brake-clear --repo --commit, ready --repo --item, lock --item --card <file>, decision --item --words <file>, waiver --repo --pr --head --decision --check
173
+ --out <file> write the block here; default the terminal
174
+ atlas approve verify --file <file> check each signed block of a file against atlas/owner-signers; exit 0 when one counts
175
+ --signers <file> the key file; default atlas/owner-signers under --root
176
+ atlas approve key-check one touch signs a test text, and the key's own public half checks it: does the key count for the gate
177
+
165
178
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
166
179
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
167
180
  atlas judge run <tasks.md> <id> --out <dir> one fresh agent does one sealed task, in a clean clone at the sealed commit
@@ -202,7 +215,8 @@ export const FLAGS = Object.freeze({
202
215
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
203
216
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
204
217
  upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value' },
205
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value' },
218
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value' },
219
+ approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value' },
206
220
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
207
221
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
208
222
  upgrade: { version: 'optional', yes: 'switch' },
@@ -451,7 +465,12 @@ function upkeepVerb(chosen) {
451
465
  }
452
466
 
453
467
  function gateVerb(chosen) {
454
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch }, line);
468
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, baseRef: chosen['base-ref'], head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch }, line);
469
+ }
470
+
471
+ function approveVerb(chosen) {
472
+ const flags = { repo: chosen.repo, commit: chosen.commit, item: chosen.item, card: chosen.card, words: chosen.words, pr: chosen.pr, head: chosen.head, decision: chosen.decision, check: chosen.check };
473
+ return approveCommand({ words: chosen._.slice(1), repo: chosen.repo, root: chosen.root === undefined ? undefined : rootOf(chosen), key: chosen.key, provider: chosen.provider, yes: Boolean(chosen.yes), out: chosen.out, file: chosen.file, signers: chosen.signers, flags }, line);
455
474
  }
456
475
 
457
476
  function judgeVerb(chosen) {
@@ -497,6 +516,7 @@ export async function main(argv = process.argv.slice(2)) {
497
516
  case 'upkeep': return upkeepVerb(chosen);
498
517
  case 'gate': return gateVerb(chosen);
499
518
  case 'judge': return judgeVerb(chosen);
519
+ case 'approve': return approveVerb(chosen);
500
520
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
501
521
  }
502
522
  }
@@ -0,0 +1,311 @@
1
+ // `atlas approve`: the owner's signed act (roadmap R72, tracker item 0c68a105;
2
+ // design docs/design-docs/signed-act/). It runs on the owner's Mac. One touch
3
+ // of his security key signs a batch.
4
+ //
5
+ // atlas approve <pr>... show each pull request with the class and
6
+ // the cause from its gate run, sign the ones
7
+ // the gate sent to the owner, and post the
8
+ // signed block on each
9
+ // atlas approve act <kind> ... sign one other act: a brake clear, a ready
10
+ // mark, a lock, a decision or a waiver; print
11
+ // the block
12
+ // atlas approve verify --file f verify the blocks of a file against the
13
+ // key file of a repo
14
+ // atlas approve key-check sign a test text and verify it with the
15
+ // key's own public half: does it need a touch?
16
+ //
17
+ // The signature comes from `ssh-keygen -Y sign` with the owner's security key.
18
+ // A Touch ID key on macOS 26 needs the provider /usr/lib/ssh-keychain.dylib;
19
+ // a hardware key needs an ssh-keygen that reads it. No signing call runs in a
20
+ // test: each one takes `deps`, which a test replaces.
21
+ import { execFileSync, spawnSync } from 'node:child_process';
22
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
23
+ import { homedir, tmpdir, platform } from 'node:os';
24
+ import { join, resolve } from 'node:path';
25
+ import { createInterface } from 'node:readline/promises';
26
+ import { childEnvironment } from '../../work/lib/child-env.mjs';
27
+ import { parseYaml } from '../../shape/check.mjs';
28
+ import { ghApi, pinnedRuns } from './gate.mjs';
29
+ import { GATE_CHECK } from './gate-check.mjs';
30
+ import {
31
+ ACTS, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, SignedActError,
32
+ } from './signed-act.mjs';
33
+
34
+ export class ApproveError extends Error {}
35
+
36
+ const ACTIONS_APP_ID = 15368;
37
+ export const TOUCH_ID_PROVIDER = '/usr/lib/ssh-keychain.dylib';
38
+ export const DEFAULT_KEY = join('.ssh', 'atlas_signing_key');
39
+ // The programs and providers that may sign, by full path only, so a program
40
+ // early on PATH or a provider that an agent wrote cannot see the touch
41
+ // (security review S2). A Homebrew path is writable by the user: the
42
+ // remaining risk is in the design, Security.
43
+ export const SSH_KEYGEN = Object.freeze(['/usr/bin/ssh-keygen', '/opt/homebrew/bin/ssh-keygen', '/usr/local/bin/ssh-keygen']);
44
+ export const PROVIDERS = Object.freeze([TOUCH_ID_PROVIDER, 'internal']);
45
+
46
+ // No control character, no C1 character and no bidi mark of a remote string
47
+ // reaches the owner's terminal (security review S1).
48
+ export const plainText = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ');
49
+
50
+ // The key, the provider, the program and the pinned keys: the flags first,
51
+ // then ~/.config/atlas/signing.json, then the defaults. On a Mac with the
52
+ // Touch ID provider, the provider is set. Only a provider and a program from
53
+ // the lists above may sign.
54
+ export function signingSetup({ key, provider, home = homedir(), os = platform(), exists = existsSync } = {}) {
55
+ let saved = {};
56
+ const file = join(home, '.config', 'atlas', 'signing.json');
57
+ if (exists(file)) {
58
+ try { saved = JSON.parse(readFileSync(file, 'utf8')) ?? {}; } catch { throw new ApproveError(`${file} is not JSON.`); }
59
+ }
60
+ const keyPath = typeof key === 'string' ? key : typeof saved.key === 'string' ? saved.key : join(home, DEFAULT_KEY);
61
+ let chosen = typeof provider === 'string' ? provider : typeof saved.provider === 'string' ? saved.provider : null;
62
+ if (chosen === null && os === 'darwin' && exists(TOUCH_ID_PROVIDER)) chosen = TOUCH_ID_PROVIDER;
63
+ if (chosen && !PROVIDERS.includes(chosen)) throw new ApproveError(`the provider ${plainText(chosen)} is not one of ${PROVIDERS.join(', ')}.`);
64
+ const program = typeof saved.ssh_keygen === 'string' ? saved.ssh_keygen : SSH_KEYGEN.find((path) => exists(path)) ?? SSH_KEYGEN[0];
65
+ if (!SSH_KEYGEN.includes(program)) throw new ApproveError(`ssh_keygen ${plainText(program)} is not one of ${SSH_KEYGEN.join(', ')}.`);
66
+ const pinned = Array.isArray(saved.pinned) ? saved.pinned.filter((one) => typeof one === 'string') : [];
67
+ return { key: resolve(keyPath.replace(/^~(?=\/)/, home)), provider: chosen || null, program, pinned };
68
+ }
69
+
70
+ // The fingerprints the owner trusts on this Mac: his own key's public half,
71
+ // and the keys he pinned in signing.json.
72
+ export function trustedKeys(setup, read = (path) => readFileSync(path, 'utf8')) {
73
+ const out = new Set(setup.pinned ?? []);
74
+ try {
75
+ const own = parseSigners(`owner ${read(`${setup.key}.pub`).trim()}`).keys[0];
76
+ if (own) out.add(own.fingerprint);
77
+ } catch { /* no public half here */ }
78
+ return out;
79
+ }
80
+
81
+ // One signature of `payload` with the owner's key. ssh-keygen asks for the
82
+ // touch; its prompt goes to this terminal.
83
+ export function sshSign(payload, { key, provider, program = SSH_KEYGEN[0] }) {
84
+ const folder = mkdtempSync(join(tmpdir(), 'atlas-approve-'));
85
+ try {
86
+ const file = join(folder, 'act.txt');
87
+ writeFileSync(file, payload);
88
+ const env = { ...childEnvironment(), ...(provider ? { SSH_SK_PROVIDER: provider } : {}) };
89
+ const run = spawnSync(program, ['-Y', 'sign', '-n', NAMESPACE, '-f', key, file], { env, stdio: ['inherit', 'inherit', 'inherit'] });
90
+ if (run.error) throw new ApproveError(`ssh-keygen cannot run: ${run.error.message}`);
91
+ if (run.status !== 0) throw new ApproveError('ssh-keygen did not sign. Touch the key when it asks, and check the key path.');
92
+ return readFileSync(`${file}.sig`, 'utf8');
93
+ } finally { rmSync(folder, { recursive: true, force: true }); }
94
+ }
95
+
96
+ // Raw text from `gh api`, for a job log.
97
+ export function ghText(path) {
98
+ return execFileSync('gh', ['api', path], { encoding: 'utf8', env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
99
+ }
100
+
101
+ async function askYes(question) {
102
+ if (!process.stdin.isTTY) return false;
103
+ const reader = createInterface({ input: process.stdin, output: process.stdout });
104
+ try { return /^y(es)?$/i.test((await reader.question(`${question} [y/N] `)).trim()); } finally { reader.close(); }
105
+ }
106
+
107
+ const DEPS = { gh: ghApi, text: ghText, sign: sshSign, ask: askYes, now: () => new Date() };
108
+
109
+ const repoOk = (repo) => typeof repo === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
110
+
111
+ function repoOf({ repo, root }) {
112
+ if (repo !== undefined && repo !== true) {
113
+ if (!repoOk(repo)) throw new ApproveError('--repo must be <owner>/<name>.');
114
+ return repo;
115
+ }
116
+ const file = join(resolve(root ?? '.'), 'atlas.yaml');
117
+ const facts = existsSync(file) ? parseYaml(readFileSync(file, 'utf8')) ?? {} : {};
118
+ if (!repoOk(facts.repo)) throw new ApproveError('name the repo with --repo <owner>/<name>, or run this in a repo whose atlas.yaml names it.');
119
+ return facts.repo;
120
+ }
121
+
122
+ // The lines of a gate job log that say what the gate found. Each log line
123
+ // starts with a time, which goes.
124
+ export function gateLines(log) {
125
+ const lines = String(log ?? '').split('\n').map((line) => line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z ?/, '').replace(/\r$/, ''));
126
+ const rows = lines.filter((line) => /^ {2}(red|owner|signed|note) /.test(line));
127
+ const verdict = lines.find((line) => /^(RED|OWNER|SUCCESS): /.test(line)) ?? null;
128
+ return { rows, verdict };
129
+ }
130
+
131
+ // What the gate said about one pull request at its head: state, class and
132
+ // cause. The gate run is pinned the way `atlas gate ready` pins it, so a head
133
+ // job named "gate" cannot show the owner a false cause (security review S1).
134
+ // The pull request must go into the default branch, and the act names its
135
+ // merge base.
136
+ export function gateResult(deps, repo, pr) {
137
+ const pull = deps.gh('GET', `repos/${repo}/pulls/${pr}`);
138
+ if (pull?.state !== 'open') return { pr, state: 'closed', why: `#${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}` };
139
+ const head = String(pull.head?.sha ?? '');
140
+ if (!/^[0-9a-f]{40}$/.test(head)) throw new ApproveError(`GitHub gave no head for #${pr}.`);
141
+ const baseRef = String(pull.base?.ref ?? '');
142
+ const base = { pr: Number(pr), head, title: plainText(pull.title).slice(0, 120), base_ref: baseRef };
143
+ const main = String(deps.gh('GET', `repos/${repo}`)?.default_branch ?? '');
144
+ if (!main || baseRef !== main) return { ...base, state: 'other-base', why: `#${pr} goes into ${plainText(baseRef)}, not the default branch ${plainText(main)}: never signed` };
145
+ const mergeBase = String(deps.gh('GET', `repos/${repo}/compare/${encodeURIComponent(baseRef)}...${head}`)?.merge_base_commit?.sha ?? '');
146
+ if (!/^[0-9a-f]{40}$/.test(mergeBase)) throw new ApproveError(`GitHub gave no merge base for #${pr}.`);
147
+ base.merge_base = mergeBase;
148
+ const { kept, refused } = pinnedRuns(deps.gh, repo, { pr: Number(pr), sha: head, baseRef, head: pull.head, checks: [GATE_CHECK] });
149
+ const runs = kept.filter((run) => run.name === GATE_CHECK && run.app?.id === ACTIONS_APP_ID)
150
+ .sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')));
151
+ base.ignored = refused.length;
152
+ const run = runs[0];
153
+ if (!run) return { ...base, state: 'missing', why: `the base gate has not run on this head${refused.length ? `; ${refused.length} other run(s) named gate were ignored` : ''}` };
154
+ if (run.status !== 'completed') return { ...base, state: 'pending', why: 'the gate is still running on this head' };
155
+ if (run.conclusion === 'success') return { ...base, state: 'success', why: 'the gate is green: nothing to sign' };
156
+ let found = { rows: [], verdict: null };
157
+ try { found = gateLines(deps.text(`repos/${repo}/actions/jobs/${run.id}/logs`)); } catch { /* the log is gone or not ready */ }
158
+ const rows = found.rows.map((row) => plainText(row).slice(0, 300));
159
+ const verdict = found.verdict ? plainText(found.verdict).slice(0, 300) : null;
160
+ if (!verdict) return { ...base, state: 'unknown', why: 'the gate failed and its log names no result; open the run' };
161
+ if (verdict.startsWith('RED:')) return { ...base, state: 'red', why: verdict, rows };
162
+ if (verdict.startsWith('OWNER:')) {
163
+ if (rows.some((row) => /^ {2}owner {2}gate-checks /.test(row))) return { ...base, state: 'job-name', why: 'the head adds a job named like a check; a signed act never clears that', rows };
164
+ return { ...base, state: 'owner', why: verdict, rows: rows.filter((row) => /^ {2}owner /.test(row)) };
165
+ }
166
+ return { ...base, state: 'unknown', why: verdict };
167
+ }
168
+
169
+ // The owner's signers of a repo, from the key file on one branch.
170
+ export function signersOn(deps, repo, ref) {
171
+ let file = null;
172
+ try { file = deps.gh('GET', `repos/${repo}/contents/${SIGNERS_FILE}?ref=${encodeURIComponent(ref)}`); } catch { file = null; }
173
+ const text = typeof file?.content === 'string' ? Buffer.from(file.content, 'base64').toString('utf8') : '';
174
+ return parseSigners(text);
175
+ }
176
+
177
+ // `atlas approve <pr>...`. Exit 0 when each signable pull request got its block.
178
+ export async function approvePulls({ prs, repo, root, key, provider, yes = false }, line, deps = DEPS) {
179
+ if (!prs.length) throw new ApproveError('name at least one pull request: atlas approve <pr>...');
180
+ const bad = prs.find((pr) => !/^[1-9][0-9]{0,8}$/.test(String(pr)));
181
+ if (bad !== undefined) throw new ApproveError(`"${bad}" is not a pull request number.`);
182
+ const name = repoOf({ repo, root });
183
+ const results = [...new Set(prs.map(Number))].map((pr) => gateResult(deps, name, pr));
184
+ line(`atlas approve: ${name}, ${results.length} pull request(s). The gate's own words for each:`);
185
+ for (const one of results) {
186
+ line(` #${one.pr}${one.head ? ` at ${one.head.slice(0, 7)}` : ''}${one.base_ref ? ` into ${plainText(one.base_ref)}` : ''} ${one.state.toUpperCase()} ${one.title ?? ''}`.trimEnd());
187
+ line(` ${one.why}`);
188
+ for (const row of (one.rows ?? []).slice(0, 8)) line(` ${row.trim()}`);
189
+ }
190
+ const sign = results.filter((one) => one.state === 'owner');
191
+ if (!sign.length) { line('Nothing to sign: no pull request above waits for the owner.'); return 1; }
192
+ const skipped = results.length - sign.length;
193
+ if (skipped) line(`${skipped} pull request(s) above are not signed: only an OWNER result takes the signed act.`);
194
+ if (!yes && !(await deps.ask(`Sign ${sign.map((one) => `#${one.pr}`).join(', ')} with one touch?`))) { line('Nothing signed.'); return 1; }
195
+ const setup = (deps.setup ?? signingSetup)({ key, provider });
196
+ // Every key on each base must be one the owner trusts on this Mac, so a key
197
+ // that an agent merged shows at the owner's next touch (security review S2).
198
+ const trusted = (deps.trusted ?? trustedKeys)(setup);
199
+ for (const ref of new Set(sign.map((one) => one.base_ref))) {
200
+ const strange = signersOn(deps, name, ref).keys.filter((one) => !trusted.has(one.fingerprint));
201
+ if (strange.length) {
202
+ line(`STOP: ${SIGNERS_FILE} on ${plainText(ref)} holds a key that is not yours: ${strange.map((one) => one.fingerprint).join(', ')}.`);
203
+ line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
204
+ return 1;
205
+ }
206
+ }
207
+ const acts = sign.map((one) => ({ kind: 'merge', repo: name, pr: one.pr, base: one.base_ref, merge_base: one.merge_base, head: one.head }));
208
+ const payload = payloadText(acts, deps.now());
209
+ line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
210
+ const signature = deps.sign(payload, setup);
211
+ const block = { payload, signature };
212
+ // Prove the block against the key file of each base before it is posted.
213
+ for (const ref of new Set(sign.map((one) => one.base_ref))) {
214
+ const { keys } = signersOn(deps, name, ref);
215
+ const proof = verifyBlock(block, keys);
216
+ if (proof.error) { line(`The signature does not count on ${ref}: ${proof.error}`); line(`Nothing was posted. Is your public key in ${SIGNERS_FILE} on ${ref}?`); return 1; }
217
+ line(`The signature counts on ${ref}: key ${proof.key}, touch ${proof.touched ? 'yes' : 'no'}, verified ${proof.verified ? 'yes' : 'no'}.`);
218
+ }
219
+ const text = `${blockText(payload, signature)}\nThe owner signed ${sign.map((one) => `#${one.pr} at ${one.head.slice(0, 7)}`).join(', ')} with \`atlas approve\`. The gate reads this comment and reruns.\n`;
220
+ for (const one of sign) {
221
+ deps.gh('POST', `repos/${name}/issues/${one.pr}/comments`, { body: text });
222
+ line(` posted on #${one.pr}`);
223
+ }
224
+ line('Done. The gate reruns on each comment. Then `atlas gate ready` says whether each one may merge.');
225
+ return 0;
226
+ }
227
+
228
+ // The act that `atlas approve act <kind>` builds from its flags.
229
+ export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
230
+ if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => each !== 'merge').join(', ')}; a pull request takes atlas approve <pr>.`);
231
+ if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
232
+ const need = (flag) => {
233
+ const value = flags[flag];
234
+ if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
235
+ return String(value);
236
+ };
237
+ if (kind === 'brake-clear') return { kind, repo: need('repo'), commit: need('commit') };
238
+ if (kind === 'ready') return { kind, repo: need('repo'), item: need('item') };
239
+ if (kind === 'lock') return { kind, item: need('item'), card: fingerprint(read(need('card'))) };
240
+ if (kind === 'decision') return { kind, item: need('item'), words: fingerprint(read(need('words'))) };
241
+ return { kind, repo: need('repo'), pr: Number(need('pr')), head: need('head'), decision: need('decision'), check: need('check') };
242
+ }
243
+
244
+ // `atlas approve act <kind>`: one act, one touch; the block goes to --out or
245
+ // to the terminal.
246
+ export async function approveAct({ kind, flags, key, provider, yes = false, out }, line, deps = DEPS) {
247
+ const act = actFrom(kind, flags);
248
+ const payload = payloadText([act], deps.now());
249
+ line('atlas approve: this text will be signed:');
250
+ for (const each of payload.trimEnd().split('\n')) line(` ${each}`);
251
+ if (!yes && !(await deps.ask('Sign it with one touch?'))) { line('Nothing signed.'); return 1; }
252
+ const setup = (deps.setup ?? signingSetup)({ key, provider });
253
+ line(`Touch the key now (${setup.key}).`);
254
+ const text = blockText(payload, deps.sign(payload, setup));
255
+ if (out && out !== true) { writeFileSync(resolve(out), text); line(`The signed block is in ${out}.`); } else process.stdout.write(text);
256
+ return 0;
257
+ }
258
+
259
+ // `atlas approve verify --file <f>`: the blocks of a file against a key file.
260
+ export function approveVerify({ file, signers, root }, line) {
261
+ if (!file || file === true) throw new ApproveError('atlas approve verify needs --file <block file>.');
262
+ const keyFile = signers && signers !== true ? resolve(signers) : join(resolve(root ?? '.'), SIGNERS_FILE);
263
+ if (!existsSync(keyFile)) throw new ApproveError(`there is no key file at ${keyFile}.`);
264
+ const { keys, refused } = parseSigners(readFileSync(keyFile, 'utf8'));
265
+ for (const one of refused) line(` ${keyFile} line ${one.line} does not count: ${one.why}`);
266
+ const rows = verifyTexts([readFileSync(resolve(file), 'utf8')], keys);
267
+ if (!rows.length) { line('atlas approve verify: the file holds no signed block.'); return 1; }
268
+ for (const row of rows) {
269
+ if (row.error) { line(` ✗ ${row.error}`); continue; }
270
+ line(` ✓ key ${row.key}, signed ${row.signed_at}, touch yes, verified ${row.verified ? 'yes' : 'no'}`);
271
+ for (const act of row.acts) line(` ${Object.entries(act).map(([field, value]) => `${field} ${value}`).join(', ')}`);
272
+ }
273
+ return rows.some((row) => !row.error) ? 0 : 1;
274
+ }
275
+
276
+ // `atlas approve key-check`: one touch signs a test text, and the key's own
277
+ // public half verifies it. It says whether the key counts for the gate.
278
+ export async function keyCheck({ key, provider }, line, deps = DEPS) {
279
+ const setup = (deps.setup ?? signingSetup)({ key, provider });
280
+ const publicFile = `${setup.key}.pub`;
281
+ if (!existsSync(publicFile)) throw new ApproveError(`there is no public key at ${publicFile}.`);
282
+ const { keys, refused } = parseSigners(`owner ${readFileSync(publicFile, 'utf8').trim()}`);
283
+ if (!keys.length) { line(`The key does not count: ${refused[0]?.why ?? 'it does not read'}.`); return 1; }
284
+ const payload = payloadText([{ kind: 'ready', repo: 'atlas/key-check', item: ['00000000', '0000', '0000', '0000', '000000000000'].join('-') }], deps.now());
285
+ line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
286
+ const proof = verifyBlock({ payload, signature: deps.sign(payload, setup) }, keys);
287
+ if (proof.error) { line(`The key does not count: ${proof.error}`); return 1; }
288
+ // A Touch ID key sets its flags in software, so the flag alone proves no
289
+ // prompt. Ask the owner (security review S2).
290
+ if (!(await deps.ask('Did Touch ID or the key ask for your touch just now?'))) { line('The key does not count: it signed with no prompt. Make it again with -t bio (Touch ID), or use a hardware key.'); return 1; }
291
+ line(`The key counts: ${proof.key}, touch yes, verified ${proof.verified ? 'yes' : 'no'}.`);
292
+ line(`Its line for ${SIGNERS_FILE}:`);
293
+ line(`owner ${keys[0].type} ${keys[0].blob.toString('base64')}`);
294
+ return 0;
295
+ }
296
+
297
+ // The verb. `words` are the words after "approve"; `flags` are the fields of
298
+ // an act.
299
+ export async function approveCommand({ words = [], repo, root, key, provider, yes = false, out, file, signers, flags = {} }, line, deps = DEPS) {
300
+ const common = { key, provider, yes };
301
+ try {
302
+ if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
303
+ if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
304
+ if (words[0] === 'key-check') return await keyCheck(common, line, deps);
305
+ return await approvePulls({ ...common, prs: words, repo, root }, line, deps);
306
+ } catch (error) {
307
+ if (error instanceof ApproveError || error instanceof SignedActError) { line(`atlas approve: ${error.message}`); return 2; }
308
+ throw error;
309
+ }
310
+ }
311
+
@@ -15,7 +15,12 @@
15
15
  // owner a path or a sign of the owner-only list; a head job named like a
16
16
  // check; a refactor claim (until the review job, roadmap R28); a
17
17
  // file too large to scan; the brake (roadmap R13, brake.mjs)
18
- // success no rule holds the pull request
18
+ // success no rule holds the pull request, or the owner signed this head
19
+ //
20
+ // An owner result turns into success when a comment of the pull request holds
21
+ // the owner's signed act for this repo, this pull request and this head
22
+ // (roadmap R72, signed-act.mjs). The key comes from atlas/owner-signers on the
23
+ // base, never from the head.
19
24
  //
20
25
  // Red wins over owner: the pull request must be fixed before the owner signs.
21
26
  // Rules that hold everywhere here:
@@ -29,6 +34,7 @@ import { resolve } from 'node:path';
29
34
  import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
30
35
  import { listChanged } from './changed-tests.mjs';
31
36
  import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
37
+ import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
32
38
  import { brakeState, undoOf, tripText } from './brake.mjs';
33
39
 
34
40
  export const VERSION = 1;
@@ -36,7 +42,7 @@ export const GATE_CHECK = 'gate';
36
42
  export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
37
43
  export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
38
44
  export const RESULTS = Object.freeze(['success', 'owner', 'red']);
39
- export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake']);
45
+ export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed']);
40
46
  export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
41
47
  // A file over this size is not scanned for secrets, and comes to the owner.
42
48
  export const SCAN_LIMIT = 1024 * 1024;
@@ -221,13 +227,15 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
221
227
  // are red on the merge base: [{ check, green }], where `green` is the newest
222
228
  // commit of main on which the check was green. `brake` holds the trips that
223
229
  // GitHub gives ({ ci: [...] }, see brake.mjs); false leaves the brake out, as
224
- // the replay does, because the brake depends on the day of the run.
225
- export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date() }) {
230
+ // the replay does, because the brake depends on the day of the run. `signed`
231
+ // is { repo, pr, baseRef, texts }: the texts are the comments of the pull
232
+ // request, read for a signed act, and baseRef is the name of its base branch.
233
+ export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null }) {
226
234
  const top = resolve(root);
227
235
  const baseSha = commitOf(top, base);
228
236
  if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
229
237
  const headSha = commitOf(top, head);
230
- const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null };
238
+ const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null };
231
239
  if (!headSha) {
232
240
  answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
233
241
  return finish(answer);
@@ -250,9 +258,40 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
250
258
  answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
251
259
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
252
260
  answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
261
+ if (signed) signedRows(top, baseSha, headSha, signed, answer);
253
262
  return finish(answer);
254
263
  }
255
264
 
265
+ // Done line 4 of R72: a valid signed act for this head clears each owner row.
266
+ // It never clears red: the pull request must be fixed first. The key file is
267
+ // read from the base commit, so a key that the pull request adds does not count.
268
+ // The act must name this base branch and this merge base too, so a change of
269
+ // the base after the touch does not carry it (security review S1). A head job
270
+ // named like a check is never cleared: the owner's view of the gate could be
271
+ // that job's forgery (security review S1).
272
+ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer) {
273
+ if (!answer.rows.some((row) => row.kind === 'owner') || answer.rows.some((row) => row.kind === 'red')) return;
274
+ const note = (cause) => answer.rows.push({ kind: 'note', class: 'signed', cause });
275
+ if (texts === null) { note('the comments of the pull request could not be read, so no signed act counts'); return; }
276
+ if (!BRANCH_FORM.test(String(baseRef ?? ''))) { note('the base branch of the pull request is not named, so no signed act counts'); return; }
277
+ const { keys, refused } = parseSigners(textAt(root, baseSha, SIGNERS_FILE) ?? '');
278
+ for (const one of refused.slice(0, 3)) note(`${SIGNERS_FILE} line ${one.line} does not count: ${one.why}`);
279
+ if (!keys.length) { note(`no owner key in ${SIGNERS_FILE} on the base, so no signed act counts`); return; }
280
+ note(`${keys.length} owner key(s) in ${SIGNERS_FILE} on the base: ${keys.map((key) => key.fingerprint).join(', ')}`);
281
+ const verified = verifyTexts(texts, keys);
282
+ const found = findAct(verified, { kind: 'merge', repo, pr: Number(pr), base: baseRef, merge_base: answer.merge_base, head: headSha });
283
+ if (!found) {
284
+ for (const row of verified.filter((each) => each.error).slice(0, 3)) note(`a signed act that does not count: ${row.error}`);
285
+ if (verified.some((each) => !each.error)) note('a valid signed act names another pull request, base, merge base or head; sign this head');
286
+ return;
287
+ }
288
+ const locked = answer.rows.filter((row) => row.kind === 'owner' && row.class === 'gate-checks');
289
+ if (locked.length) { note('a signed act never clears a head job named like a check: remove that job'); return; }
290
+ for (const row of answer.rows) if (row.kind === 'owner') row.kind = 'signed';
291
+ answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified };
292
+ note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
293
+ }
294
+
256
295
  // Line 1: while the brake is on, every pull request of the repo comes to the
257
296
  // owner, except the exact undo of a trip (line 4).
258
297
  function brakeRows(state, undo) {
@@ -281,30 +320,40 @@ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, '
281
320
  export function renderText(answer) {
282
321
  const named = new Set(answer.rows.filter((row) => row.path).map((row) => row.path)).size;
283
322
  const out = [`gate: base ${answer.base.slice(0, 7)}, merge base ${String(answer.merge_base ?? '-').slice(0, 7)}, head ${oneLine(answer.head).slice(0, 7)}; ${named} path(s) named`];
284
- const order = { red: 0, owner: 1, note: 2 };
323
+ const order = { red: 0, owner: 1, signed: 2, note: 3 };
285
324
  for (const row of [...answer.rows].sort((a, b) => order[a.kind] - order[b.kind])) {
286
325
  const where = row.path ? ` ${oneLine(row.path)}${row.line ? `:${row.line}` : ''}` : '';
287
326
  out.push(` ${row.kind.padEnd(6)} ${oneLine(row.class).padEnd(12)}${where} ${oneLine(row.cause)}`);
288
327
  }
289
328
  if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
290
329
  else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
330
+ else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
291
331
  else out.push('SUCCESS: no rule holds this pull request.');
292
332
  return out;
293
333
  }
294
334
 
295
335
  // ------------------------------------------------------------------ the command
296
336
 
297
- // `atlas gate check`. `read` gives { redMain } from GitHub when the job runs
298
- // with --repo. Exit 0 on success, 1 on owner or red, 2 on a usage error.
299
- export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output }, line, read = null) {
337
+ // `atlas gate check`. `read` gives the red main checks from GitHub when the
338
+ // job runs with --repo. `comments` gives the comment texts of the pull request
339
+ // when it also runs with --pr, so a signed act can clear owner. Exit 0 on
340
+ // success, 1 on owner or red, 2 on a usage error.
341
+ export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output, repo = null, pr = null, baseRef = null }, line, read = null, comments = null) {
300
342
  try {
301
343
  if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
302
344
  if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
303
345
  const body = bodyFile && bodyFile !== true ? readFileSync(resolve(bodyFile), 'utf8') : '';
346
+ if (pr !== null && pr !== undefined && !/^[1-9][0-9]{0,8}$/.test(String(pr))) throw Object.assign(new GateCheckError('--pr must be a pull request number.'), { usage: true });
304
347
  // A reader gives the red main checks, or { redMain, brake } with the brake trips too.
305
348
  const got = read ? read() : [];
306
349
  const redMain = Array.isArray(got) ? got : got.redMain ?? [];
307
- const answer = judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {} });
350
+ let signed = null;
351
+ if (comments && repo && pr) {
352
+ let texts = null;
353
+ try { texts = comments(); } catch { texts = null; }
354
+ signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
355
+ }
356
+ const answer = judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed });
308
357
  const text = renderText(answer);
309
358
  if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
310
359
  else for (const each of text) line(each);
package/door/lib/gate.mjs CHANGED
@@ -21,6 +21,7 @@ import { dirname, join } from 'node:path';
21
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  import { parseYaml } from '../../shape/check.mjs';
23
23
  import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
24
+ import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
24
25
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
25
26
 
26
27
  export const BRANCH_RULESET = 'atlas gate';
@@ -287,7 +288,7 @@ export function jobRefusal(job, check, run) {
287
288
  }
288
289
 
289
290
  // The check runs that may count for this pull request, and the ones refused.
290
- function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
291
+ export function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
291
292
  const homes = homesAt(gh, repo, baseRef, checks);
292
293
  // Every pull request, open or closed, from the head branch into another
293
294
  // branch. Read once, when the first run needs it.
@@ -474,6 +475,26 @@ export function redMain({ root, base }, gh, repo, depth = 30) {
474
475
  return found;
475
476
  }
476
477
 
478
+ // The comment texts of one pull request that carry the signed-act marker
479
+ // (roadmap R72). At most ten pages of a hundred, newest kept first, and at most
480
+ // 2 MB of text in all; the gate reads them as data only.
481
+ export function prComments(gh, repo, pr) {
482
+ const texts = [];
483
+ for (let page = 1; page <= 10; page += 1) {
484
+ const rows = gh('GET', `repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`) ?? [];
485
+ for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
486
+ if (!Array.isArray(rows) || rows.length < 100) break;
487
+ }
488
+ const kept = [];
489
+ let bytes = 0;
490
+ for (const text of texts.reverse()) {
491
+ bytes += text.length;
492
+ if (bytes > 2 * 1024 * 1024) break;
493
+ kept.push(text);
494
+ }
495
+ return kept;
496
+ }
497
+
477
498
  // The base workflow files that define a job of one of `names`, read from git.
478
499
  function definedAt(git, sha, names) {
479
500
  const files = new Set();
@@ -516,11 +537,14 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
516
537
  }
517
538
 
518
539
  // `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
519
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
540
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, baseRef, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
520
541
  if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
521
542
  // With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
522
543
  const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
523
- if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo) } }) : null);
544
+ if (what === 'check') {
545
+ const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
546
+ return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo) } }) : null, named && number ? () => prComments(gh, repo, number) : null);
547
+ }
524
548
  if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
525
549
  if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
526
550
  if (what === 'ready') {