@arjunkhera/atlas 0.4.20 → 0.4.22

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.20",
3
+ "version": "0.4.22",
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);
@@ -140,6 +141,7 @@ const HELP = `atlas — the door into a repo's Atlas files
140
141
 
141
142
  atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, and in a pull request its tracker item
142
143
  atlas upkeep replay --root <repo> what the doc-sources rule would have asked of the last merges; writes nothing
144
+ atlas upkeep procedures --root <repo> run the procedure steps marked atlas-run; --base <ref> runs only the procedures a change touches
143
145
  --last <n> how many merges on the first-parent line; default 30
144
146
  --event <file> the GitHub pull request event; adds the item check
145
147
  --json print JSON
@@ -153,6 +155,7 @@ const HELP = `atlas — the door into a repo's Atlas files
153
155
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
154
156
  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
157
  atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
158
+ --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
159
  --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
157
160
  --body <file> the pull request text, for the "behaviour change: no" of a refactor
158
161
  --json print the answer as JSON
@@ -162,6 +165,17 @@ const HELP = `atlas — the door into a repo's Atlas files
162
165
  --branch <name> the branch; default origin/HEAD
163
166
  --json print the answer as JSON
164
167
 
168
+ 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
169
+ --repo <owner/name> the repo; default the repo that atlas.yaml names
170
+ --key <file> the security key; default ~/.ssh/atlas_signing_key, or the key in ~/.config/atlas/signing.json
171
+ --provider <file> the security key provider; default /usr/lib/ssh-keychain.dylib on a Mac (Touch ID)
172
+ --yes sign with no question; the key still needs the touch
173
+ 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
174
+ --out <file> write the block here; default the terminal
175
+ atlas approve verify --file <file> check each signed block of a file against atlas/owner-signers; exit 0 when one counts
176
+ --signers <file> the key file; default atlas/owner-signers under --root
177
+ 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
178
+
165
179
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
166
180
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
167
181
  atlas judge run <tasks.md> <id> --out <dir> one fresh agent does one sealed task, in a clean clone at the sealed commit
@@ -201,8 +215,9 @@ export const FLAGS = Object.freeze({
201
215
  view: { product: 'value', name: 'value', out: 'value' },
202
216
  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
217
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
204
- 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
+ upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
219
+ 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' },
220
+ 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
221
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
207
222
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
208
223
  upgrade: { version: 'optional', yes: 'switch' },
@@ -447,11 +462,16 @@ function jobsVerb(chosen) {
447
462
  }
448
463
 
449
464
  function upkeepVerb(chosen) {
450
- return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last }, rootOf(chosen), line);
465
+ return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last, base: chosen.base }, rootOf(chosen), line);
451
466
  }
452
467
 
453
468
  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);
469
+ 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);
470
+ }
471
+
472
+ function approveVerb(chosen) {
473
+ 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 };
474
+ 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
475
  }
456
476
 
457
477
  function judgeVerb(chosen) {
@@ -497,6 +517,7 @@ export async function main(argv = process.argv.slice(2)) {
497
517
  case 'upkeep': return upkeepVerb(chosen);
498
518
  case 'gate': return gateVerb(chosen);
499
519
  case 'judge': return judgeVerb(chosen);
520
+ case 'approve': return approveVerb(chosen);
500
521
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
501
522
  }
502
523
  }
@@ -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') {
@@ -0,0 +1,122 @@
1
+ // CI runs the procedures (roadmap R40, item 38b2a0fe). Target design 4.6: CI
2
+ // runs each procedure step that a clean machine can run.
3
+ //
4
+ // A procedure marks such a step with a fenced block whose info string holds
5
+ // the tag `atlas-run`:
6
+ //
7
+ // ```sh atlas-run
8
+ // npm test
9
+ // ```
10
+ //
11
+ // A step that needs what a clean machine does not have names it after the
12
+ // tag: `atlas-run needs=secret/NPM_TOKEN` or `atlas-run needs=device/macos`.
13
+ // Such a step is listed as "not run here", never as passed.
14
+ //
15
+ // `atlas upkeep procedures` runs the marked steps of each procedure that
16
+ // atlas.yaml `procedures:` names. With --base, it runs only the procedures a
17
+ // pull request changes: its own folder, or a path its `sources:` names. Each
18
+ // block runs in bash, from the repo root, with `set -euo pipefail`. It reads
19
+ // no repo kind, so a repo with no deploy target uses it unchanged.
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fenceOpens, fenceCloses } from './markdown.mjs';
24
+ import { docUnits, covers, changedPaths } from './doc-sources.mjs';
25
+
26
+ export const TAG = 'atlas-run';
27
+ const SHELLS = new Set(['sh', 'bash', 'shell']);
28
+ const NEEDS = new Set(['secret', 'device', 'network']);
29
+ // The bound on one block. A step that runs longer is a failure. It sits
30
+ // below the 10 minutes of the CI job, so the step fails with a reason.
31
+ export const STEP_TIMEOUT_MS = 8 * 60 * 1000;
32
+
33
+ // Line 1: the marked steps of one procedure text. Each step is one fenced
34
+ // block. `problems` holds a tag that cannot run as written.
35
+ export function markedSteps(text) {
36
+ const steps = [];
37
+ const problems = [];
38
+ const lines = String(text ?? '').split('\n');
39
+ for (let i = 0; i < lines.length; i += 1) {
40
+ const mark = fenceOpens(lines[i]);
41
+ if (!mark) continue;
42
+ const info = lines[i].trim().slice(mark.length).trim().split(/\s+/).filter(Boolean);
43
+ const start = i;
44
+ const body = [];
45
+ for (i += 1; i < lines.length && !fenceCloses(lines[i], mark); i += 1) body.push(lines[i]);
46
+ if (!info.includes(TAG)) continue;
47
+ const line = start + 1;
48
+ const lang = info[0] === TAG ? null : info[0];
49
+ if (!SHELLS.has(lang)) { problems.push({ line, why: `the block is tagged ${TAG} but its language is ${lang ?? 'missing'}; use sh or bash` }); continue; }
50
+ if (i >= lines.length) { problems.push({ line, why: 'the block never closes' }); continue; }
51
+ const needs = [];
52
+ let bad = null;
53
+ for (const word of info.slice(info.indexOf(TAG) + 1)) {
54
+ const m = /^needs=(.+)$/.exec(word);
55
+ if (!m) { bad = `"${word}" after ${TAG} is not needs=<kind>/<name>`; break; }
56
+ for (const one of m[1].split(',')) {
57
+ const [kind, name] = one.split('/');
58
+ if (!NEEDS.has(kind) || !name) { bad = `"${one}" is not one of ${[...NEEDS].map((k) => `${k}/<name>`).join(', ')}`; break; }
59
+ needs.push({ kind, name });
60
+ }
61
+ if (bad) break;
62
+ }
63
+ if (bad) { problems.push({ line, why: bad }); continue; }
64
+ const script = body.join('\n').trim();
65
+ if (!script) { problems.push({ line, why: 'the block is empty' }); continue; }
66
+ steps.push({ line, script, needs });
67
+ }
68
+ return { steps, problems };
69
+ }
70
+
71
+ // The procedures to run. With `changed`, only those a pull request changes:
72
+ // a path in the procedure's folder, or a path its `sources:` names.
73
+ export function proceduresToRun(root, changed = null) {
74
+ const units = docUnits(root).filter((one) => one.kind === 'procedure');
75
+ if (changed === null) return units;
76
+ return units.filter((one) => changed.some((path) => path.startsWith(`${one.unit}/`)
77
+ || (one.named?.sources ?? []).some((source) => covers(root, source, path))));
78
+ }
79
+
80
+ const needsText = (needs) => needs.map((one) => `${one.kind} ${one.name}`).join(', ');
81
+
82
+ // Line 2 and line 3: run each marked step of each chosen procedure. A step
83
+ // that needs a secret, a device or the network is "not run here".
84
+ export function runProcedures(root, { base = null, run = runStep } = {}) {
85
+ const changed = base === null ? null : changedPaths(root, `${base}...HEAD`);
86
+ const chosen = proceduresToRun(root, changed);
87
+ const rows = [];
88
+ for (const unit of chosen) {
89
+ const { steps, problems } = markedSteps(readFileSync(join(root, unit.where), 'utf8'));
90
+ for (const one of problems) rows.push({ procedure: unit.unit, where: `${unit.where}:${one.line}`, state: 'bad tag', why: one.why });
91
+ if (!steps.length && !problems.length) rows.push({ procedure: unit.unit, where: unit.where, state: 'no step', why: `it marks no step with ${TAG}` });
92
+ for (const step of steps) {
93
+ const where = `${unit.where}:${step.line}`;
94
+ if (step.needs.length) { rows.push({ procedure: unit.unit, where, state: 'not run here', why: `needs ${needsText(step.needs)}` }); continue; }
95
+ const result = run(root, step.script);
96
+ rows.push({ procedure: unit.unit, where, state: result.ok ? 'passed' : 'failed', why: result.why, tail: result.tail });
97
+ }
98
+ }
99
+ const red = rows.filter((row) => row.state === 'failed' || row.state === 'bad tag');
100
+ return { procedures: chosen.map((one) => one.unit), all: changed === null, rows, green: red.length === 0 };
101
+ }
102
+
103
+ export function runStep(root, script) {
104
+ const out = spawnSync('bash', ['-c', `set -euo pipefail\n${script}`], { cwd: root, encoding: 'utf8', timeout: STEP_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
105
+ const tail = `${out.stdout ?? ''}${out.stderr ?? ''}`.trimEnd().split('\n').slice(-20).join('\n');
106
+ if (out.error?.code === 'ETIMEDOUT') return { ok: false, why: `it ran past ${STEP_TIMEOUT_MS / 60000} minutes`, tail };
107
+ if (out.error) return { ok: false, why: out.error.message, tail };
108
+ return out.status === 0 ? { ok: true, why: 'exit 0', tail } : { ok: false, why: `exit ${out.status ?? out.signal}`, tail };
109
+ }
110
+
111
+ // `atlas upkeep procedures`.
112
+ export function proceduresCommand({ base = null, json = false }, root, line) {
113
+ const out = runProcedures(root, { base });
114
+ if (json) { process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); return out.green ? 0 : 1; }
115
+ line(`procedures: ${out.procedures.length} to run${out.all ? '' : ` (the ones this change touches, from ${base})`}${out.procedures.length ? `: ${out.procedures.join(', ')}` : ''}`);
116
+ for (const row of out.rows) {
117
+ line(` ${row.state.padEnd(12)} ${row.where} ${row.why}`);
118
+ if (row.state === 'failed' && row.tail) for (const one of row.tail.split('\n')) line(` | ${one}`);
119
+ }
120
+ line(`procedure steps: ${out.green ? 'GREEN' : 'RED'}`);
121
+ return out.green ? 0 : 1;
122
+ }
@@ -0,0 +1,332 @@
1
+ // The signed act: the owner's one act that an agent cannot make (roadmap R72,
2
+ // tracker item 0c68a105; design docs/design-docs/signed-act/).
3
+ //
4
+ // Agents work under the owner's own GitHub login, so GitHub cannot tell his
5
+ // act from theirs. The signed act is an SSH signature made with a security
6
+ // key: a hardware key, or a Secure Enclave key that needs Touch ID. The
7
+ // signature carries the "user present" flag of the key, so a valid one proves
8
+ // a touch. This file holds the parts that need no GitHub:
9
+ //
10
+ // parseSigners(text) the public keys of atlas/owner-signers
11
+ // payloadText(acts, signedAt) the exact text that one touch signs
12
+ // parseBlocks(text) the signed blocks in a comment or a file
13
+ // verifyBlock(block, signers) the acts of one block, or the reason it fails
14
+ // findAct(verified, want) the first verified act that names `want`
15
+ //
16
+ // The verifier reads the SSH signature format (OpenSSH PROTOCOL.sshsig) and
17
+ // the security key form (PROTOCOL.u2f) with node:crypto alone. It runs no
18
+ // program, so the gate job needs nothing but Node.
19
+ //
20
+ // Rules that hold everywhere here:
21
+ // 1. Only a security key counts: sk-ssh-ed25519 or sk-ecdsa-sha2-nistp256.
22
+ // A plain key can sign with no touch, so it is refused.
23
+ // 2. A signature counts only with the "user present" flag. A key line with
24
+ // `verify-required` also needs the "user verified" flag.
25
+ // 3. The key must be one of the signers that the caller read from the base.
26
+ // 4. It fails closed: any field that does not read is a refusal.
27
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
28
+
29
+ export const NAMESPACE = 'atlas-signed-act';
30
+ export const FORMAT = 'atlas signed act 1';
31
+ export const SIGNERS_FILE = 'atlas/owner-signers';
32
+ export const BEGIN = '-----BEGIN ATLAS SIGNED ACT-----';
33
+ export const END = '-----END ATLAS SIGNED ACT-----';
34
+ const SIG_BEGIN = '-----BEGIN SSH SIGNATURE-----';
35
+ const SIG_END = '-----END SSH SIGNATURE-----';
36
+ export const MARKER = '<!-- atlas:signed-act -->';
37
+ // One touch signs at most this many acts, and a text holds at most this many blocks.
38
+ export const MAX_ACTS = 50;
39
+ export const MAX_BLOCKS = 20;
40
+ const MAX_TEXT = 256 * 1024;
41
+
42
+ // The two security key types. The names are joined from pieces, so the
43
+ // package holds no text that reads as an email address (test/package D3).
44
+ const AT_OPENSSH = (name) => [name, 'openssh.com'].join('@');
45
+ export const ED25519_SK = AT_OPENSSH('sk-ssh-ed25519');
46
+ export const ECDSA_SK = AT_OPENSSH('sk-ecdsa-sha2-nistp256');
47
+ export const KEY_TYPES = Object.freeze([ED25519_SK, ECDSA_SK]);
48
+ const FLAG_USER_PRESENT = 0x01;
49
+ const FLAG_USER_VERIFIED = 0x04;
50
+ const FLAG_EXTENSIONS = 0x80;
51
+
52
+ export class SignedActError extends Error {}
53
+
54
+ // ------------------------------------------------------------------ the acts
55
+
56
+ const REPO = '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+';
57
+ const SHA = '[0-9a-f]{40}';
58
+ const ID = '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}';
59
+ const HASH = '[0-9a-f]{64}';
60
+ const NUMBER = '[1-9][0-9]{0,8}';
61
+ // A branch name: plain characters only, so a line never holds a space.
62
+ const BRANCH = '[A-Za-z0-9._/-]{1,100}';
63
+ export const BRANCH_FORM = new RegExp(`^${BRANCH}$`);
64
+
65
+ // Each kind of act, its fields, and the exact form of its line. The order of
66
+ // the fields is the order of the words after the kind. A check name can hold
67
+ // spaces, so it is the last field of a waiver and takes the rest of the line.
68
+ export const ACTS = Object.freeze({
69
+ // A pull request head that the gate sent to the owner (done line 4). It
70
+ // names the base branch and the merge base too, so a change of the base
71
+ // after the touch does not carry the act onto a diff the owner never saw
72
+ // (security review S1, 26 October 2026).
73
+ merge: { fields: ['repo', 'pr', 'base', 'merge_base', 'head'], form: `(${REPO}) (${NUMBER}) (${BRANCH}) (${SHA}) (${SHA})` },
74
+ // The end of a brake before its time (R13 line 6). The commit is the one
75
+ // that set the brake, so one clear never ends a later brake.
76
+ 'brake-clear': { fields: ['repo', 'commit'], form: `(${REPO}) (${SHA})` },
77
+ // A ready mark for the runner (R25 line 3). It holds no nonce: R25 must
78
+ // say what stops a replay when an item is reopened, before its reader ships.
79
+ ready: { fields: ['repo', 'item'], form: `(${REPO}) (${ID})` },
80
+ // An owner lock: the item and the fingerprint of its card (R27 line 2).
81
+ lock: { fields: ['item', 'card'], form: `(${ID}) (${HASH})` },
82
+ // A decision: the item and the fingerprint of the owner's words.
83
+ decision: { fields: ['item', 'words'], form: `(${ID}) (${HASH})` },
84
+ // A waiver of one red check on one pull request head (R17 line 6).
85
+ waiver: { fields: ['repo', 'pr', 'head', 'decision', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${ID}) ([^\\s].{0,99})` },
86
+ });
87
+
88
+ // The fingerprint that a lock or a decision act names: sha256 of the text,
89
+ // with line ends made plain.
90
+ export const fingerprint = (text) => createHash('sha256').update(String(text).replace(/\r\n?/g, '\n')).digest('hex');
91
+
92
+ // One act line from its fields, checked against its form.
93
+ export function actLine(act) {
94
+ const spec = ACTS[act?.kind];
95
+ if (!spec) throw new SignedActError(`there is no act "${act?.kind}". Use ${Object.keys(ACTS).join(', ')}.`);
96
+ const line = `act ${act.kind} ${spec.fields.map((field) => String(act[field] ?? '')).join(' ')}`;
97
+ if (!new RegExp(`^act ${act.kind} ${spec.form}$`).test(line)) throw new SignedActError(`the ${act.kind} act does not read: ${line.slice(0, 200)}`);
98
+ return line;
99
+ }
100
+
101
+ // The exact text that one touch signs. It ends with one line end.
102
+ export function payloadText(acts, signedAt) {
103
+ if (!Array.isArray(acts) || !acts.length) throw new SignedActError('a signed act names at least one act.');
104
+ if (acts.length > MAX_ACTS) throw new SignedActError(`one touch signs at most ${MAX_ACTS} acts.`);
105
+ const at = new Date(signedAt);
106
+ if (Number.isNaN(at.getTime())) throw new SignedActError('the time of the signature does not read.');
107
+ return `${[FORMAT, `signed ${at.toISOString()}`, ...acts.map(actLine)].join('\n')}\n`;
108
+ }
109
+
110
+ // The acts of a payload text, or a refusal. Every line must read.
111
+ export function parsePayload(text) {
112
+ const lines = String(text).split('\n');
113
+ if (lines.at(-1) === '') lines.pop();
114
+ if (lines[0] !== FORMAT) throw new SignedActError(`the first line is not "${FORMAT}".`);
115
+ const at = /^signed (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/.exec(lines[1] ?? '');
116
+ if (!at || Number.isNaN(Date.parse(at[1]))) throw new SignedActError('the second line is not "signed <time>".');
117
+ const rows = lines.slice(2);
118
+ if (!rows.length) throw new SignedActError('the block names no act.');
119
+ if (rows.length > MAX_ACTS) throw new SignedActError(`the block names more than ${MAX_ACTS} acts.`);
120
+ const acts = rows.map((line) => {
121
+ const kind = /^act ([a-z-]+) /.exec(line)?.[1];
122
+ const spec = kind && Object.hasOwn(ACTS, kind) ? ACTS[kind] : null;
123
+ const hit = spec && new RegExp(`^act ${kind} ${spec.form}$`).exec(line);
124
+ if (!hit) throw new SignedActError(`an act line does not read: ${line.slice(0, 120)}`);
125
+ const act = { kind };
126
+ spec.fields.forEach((field, index) => { act[field] = field === 'pr' ? Number(hit[index + 1]) : hit[index + 1]; });
127
+ return act;
128
+ });
129
+ return { signed_at: at[1], acts };
130
+ }
131
+
132
+ // ------------------------------------------------------------------ the SSH wire form
133
+
134
+ function reader(buffer) {
135
+ let at = 0;
136
+ const need = (n) => { if (at + n > buffer.length) throw new SignedActError('the signature is cut short.'); };
137
+ return {
138
+ u8() { need(1); return buffer[at++]; },
139
+ u32() { need(4); const value = buffer.readUInt32BE(at); at += 4; return value; },
140
+ raw(n) { need(n); const out = buffer.subarray(at, at + n); at += n; return out; },
141
+ string() { const n = this.u32(); return this.raw(n); },
142
+ text() { return this.string().toString('latin1'); },
143
+ done() { return at === buffer.length; },
144
+ };
145
+ }
146
+
147
+ const sshString = (value) => {
148
+ const body = Buffer.isBuffer(value) ? value : Buffer.from(String(value), 'latin1');
149
+ const size = Buffer.alloc(4);
150
+ size.writeUInt32BE(body.length);
151
+ return Buffer.concat([size, body]);
152
+ };
153
+
154
+ // The node:crypto key of one security key blob, and its application.
155
+ function keyOf(blob) {
156
+ const read = reader(blob);
157
+ const type = read.text();
158
+ if (type === ED25519_SK) {
159
+ const raw = read.string();
160
+ const application = read.text();
161
+ if (raw.length !== 32 || !read.done()) throw new SignedActError('the ed25519 key does not read.');
162
+ return { type, application, key: createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') }, format: 'jwk' }) };
163
+ }
164
+ if (type === ECDSA_SK) {
165
+ const curve = read.text();
166
+ const point = read.string();
167
+ const application = read.text();
168
+ if (curve !== 'nistp256' || point.length !== 65 || point[0] !== 4 || !read.done()) throw new SignedActError('the ecdsa key does not read.');
169
+ return { type, application, key: createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }, format: 'jwk' }) };
170
+ }
171
+ throw new SignedActError(`the key type ${type.slice(0, 60)} is not a security key, so it needs no touch.`);
172
+ }
173
+
174
+ export const keyFingerprint = (blob) => `SHA256:${createHash('sha256').update(blob).digest('base64').replace(/=+$/, '')}`;
175
+
176
+ // The keys of an allowed-signers text: one key on each line,
177
+ // `<name> [options] <type> <base64> [comment]`. The one option that counts is
178
+ // `verify-required`. `no-touch-required` and a plain key are refused, with
179
+ // the line number, and the other lines still count.
180
+ export function parseSigners(text) {
181
+ const keys = [];
182
+ const refused = [];
183
+ String(text ?? '').split('\n').forEach((raw, index) => {
184
+ const line = raw.trim();
185
+ if (!line || line.startsWith('#')) return;
186
+ const words = line.split(/\s+/);
187
+ const name = words.shift();
188
+ let options = '';
189
+ if (!KEY_TYPES.includes(words[0]) && !/^(ssh-|ecdsa-|sk-)/.test(words[0] ?? '')) options = words.shift() ?? '';
190
+ const [type, base64] = words;
191
+ const opts = options ? options.split(',') : [];
192
+ const why = (reason) => refused.push({ line: index + 1, why: reason });
193
+ if (opts.some((opt) => opt === 'no-touch-required')) return why('no-touch-required: a signature with no touch proves nothing');
194
+ const unknown = opts.filter((opt) => opt !== 'verify-required');
195
+ if (unknown.length) return why(`an option that the gate does not read: ${unknown[0].slice(0, 40)}`);
196
+ if (!KEY_TYPES.includes(type)) return why(`${String(type).slice(0, 40)} is not a security key`);
197
+ let blob;
198
+ try { blob = Buffer.from(base64 ?? '', 'base64'); keyOf(blob); } catch (error) { return why(error.message); }
199
+ if (blob.toString('base64').replace(/=+$/, '') !== String(base64).replace(/=+$/, '')) return why('the key is not plain base64');
200
+ keys.push({ name, type, blob, verify_required: opts.includes('verify-required'), fingerprint: keyFingerprint(blob) });
201
+ });
202
+ return { keys, refused };
203
+ }
204
+
205
+ // The parts of an armored SSH signature.
206
+ function sshsigParts(armored) {
207
+ const body = String(armored).replace(SIG_BEGIN, '').replace(SIG_END, '').replace(/\s+/g, '');
208
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) throw new SignedActError('the SSH signature is not base64.');
209
+ const read = reader(Buffer.from(body, 'base64'));
210
+ if (read.raw(6).toString('latin1') !== 'SSHSIG') throw new SignedActError('the signature does not start with SSHSIG.');
211
+ if (read.u32() !== 1) throw new SignedActError('the signature is not version 1.');
212
+ const key = read.string();
213
+ const namespace = read.text();
214
+ const reserved = read.string();
215
+ const hash = read.text();
216
+ const signature = read.string();
217
+ if (!read.done()) throw new SignedActError('the signature has data after its end.');
218
+ return { key, namespace, reserved, hash, signature };
219
+ }
220
+
221
+ // Verify one SSH signature of `message` against the signers. The answer names
222
+ // the key and the flags, or throws the reason.
223
+ export function verifySignature(message, armored, signers, namespace = NAMESPACE) {
224
+ const parts = sshsigParts(armored);
225
+ if (parts.namespace !== namespace) throw new SignedActError(`the signature is for "${parts.namespace.slice(0, 40)}", not "${namespace}".`);
226
+ if (parts.reserved.length) throw new SignedActError('the reserved field of the signature is not empty.');
227
+ if (!['sha256', 'sha512'].includes(parts.hash)) throw new SignedActError(`the hash ${parts.hash.slice(0, 20)} is not sha256 or sha512.`);
228
+ const signer = signers.find((each) => each.blob.equals(parts.key));
229
+ if (!signer) throw new SignedActError(`the key ${keyFingerprint(parts.key)} is not in ${SIGNERS_FILE} on the base.`);
230
+ const { type, application, key } = keyOf(signer.blob);
231
+ // The text that the key signed: PROTOCOL.sshsig, then PROTOCOL.u2f.
232
+ const signed = Buffer.concat([Buffer.from('SSHSIG', 'latin1'), sshString(namespace), sshString(''), sshString(parts.hash), sshString(createHash(parts.hash).update(message).digest())]);
233
+ const read = reader(parts.signature);
234
+ const sigType = read.text();
235
+ if (sigType !== type) throw new SignedActError(`the signature type ${sigType.slice(0, 60)} does not match the key.`);
236
+ const inner = read.string();
237
+ const flags = read.u8();
238
+ const counter = read.u32();
239
+ if (!read.done()) throw new SignedActError('the security key signature has data after its end.');
240
+ if (flags & FLAG_EXTENSIONS) throw new SignedActError('the security key signature carries extensions, which the gate does not read.');
241
+ const counterBytes = Buffer.alloc(4);
242
+ counterBytes.writeUInt32BE(counter);
243
+ const original = Buffer.concat([createHash('sha256').update(application).digest(), Buffer.from([flags]), counterBytes, createHash('sha256').update(signed).digest()]);
244
+ let good;
245
+ if (type === ED25519_SK) {
246
+ if (inner.length !== 64) throw new SignedActError('the ed25519 signature does not read.');
247
+ good = cryptoVerify(null, original, key, inner);
248
+ } else {
249
+ const ecdsa = reader(inner);
250
+ // A canonical mpint only: no needless leading zero, and never negative.
251
+ // Nothing relies on one signature per text; ECDSA allows two.
252
+ const fixed = (n) => {
253
+ if (!n.length || (n[0] & 0x80) || (n[0] === 0 && (n.length === 1 || !(n[1] & 0x80)))) throw new SignedActError('the ecdsa signature does not read.');
254
+ const bytes = n[0] === 0 ? n.subarray(1) : n;
255
+ if (bytes.length > 32) throw new SignedActError('the ecdsa signature does not read.');
256
+ return Buffer.concat([Buffer.alloc(32 - bytes.length), bytes]);
257
+ };
258
+ const r = fixed(ecdsa.string());
259
+ const s = fixed(ecdsa.string());
260
+ if (!ecdsa.done()) throw new SignedActError('the ecdsa signature does not read.');
261
+ good = cryptoVerify('sha256', original, { key, dsaEncoding: 'ieee-p1363' }, Buffer.concat([r, s]));
262
+ }
263
+ if (!good) throw new SignedActError('the signature does not match the text.');
264
+ const touched = Boolean(flags & FLAG_USER_PRESENT);
265
+ const verified = Boolean(flags & FLAG_USER_VERIFIED);
266
+ if (!touched) throw new SignedActError('the key made this signature with no touch.');
267
+ if (signer.verify_required && !verified) throw new SignedActError('the key line says verify-required, and the key did not verify the owner (no Touch ID or PIN).');
268
+ return { key: signer.fingerprint, name: signer.name, touched, verified, counter };
269
+ }
270
+
271
+ // ------------------------------------------------------------------ blocks
272
+
273
+ // The block that a comment or a file carries: the marker, the payload and
274
+ // the signature, in a fence so GitHub shows it as it is.
275
+ export const blockText = (payload, signature) => [MARKER, '```text', BEGIN, payload.replace(/\n$/, ''), END, signature.trim(), '```', ''].join('\n');
276
+
277
+ // Each signed block of a text: { payload, signature }. Line ends are made
278
+ // plain, so a copy from a page with CR LF still reads. A text with no marker
279
+ // holds no block. The scan is linear: each marker is found once with indexOf.
280
+ export function parseBlocks(text) {
281
+ const raw = String(text ?? '');
282
+ if (!raw.includes(MARKER)) return [];
283
+ const plain = raw.slice(0, MAX_TEXT).replace(/\r\n?/g, '\n');
284
+ const out = [];
285
+ let from = 0;
286
+ while (out.length < MAX_BLOCKS) {
287
+ const begin = plain.indexOf(`${BEGIN}\n`, from);
288
+ if (begin < 0) break;
289
+ const end = plain.indexOf(`\n${END}\n`, begin);
290
+ if (end < 0) break;
291
+ const sigStart = end + END.length + 2;
292
+ const sigEnd = plain.indexOf(SIG_END, sigStart);
293
+ if (sigEnd < 0) break;
294
+ from = sigEnd + SIG_END.length;
295
+ const signature = plain.slice(sigStart, from);
296
+ if (!signature.startsWith(`${SIG_BEGIN}\n`) || !/^[A-Za-z0-9+/=\n]+$/.test(signature.slice(SIG_BEGIN.length + 1, -SIG_END.length))) continue;
297
+ out.push({ payload: `${plain.slice(begin + BEGIN.length + 1, end)}\n`, signature });
298
+ }
299
+ return out;
300
+ }
301
+
302
+ // One block, verified: { acts, signed_at, key, verified }, or { error }.
303
+ export function verifyBlock(block, signers) {
304
+ try {
305
+ const parsed = parsePayload(block.payload);
306
+ const proof = verifySignature(Buffer.from(block.payload, 'utf8'), block.signature, signers);
307
+ return { ...parsed, ...proof };
308
+ } catch (error) {
309
+ if (error instanceof SignedActError) return { error: error.message };
310
+ throw error;
311
+ }
312
+ }
313
+
314
+ // Every block of each text, verified. `texts` is a list of strings, such as
315
+ // the comments of a pull request.
316
+ export function verifyTexts(texts, signers) {
317
+ const rows = [];
318
+ for (const text of texts) for (const block of parseBlocks(text)) rows.push(verifyBlock(block, signers));
319
+ return rows;
320
+ }
321
+
322
+ // The first verified act that matches every field of `want`, with its block.
323
+ // Repo names match with no case, as GitHub does.
324
+ export function findAct(verified, want) {
325
+ const same = (field, a, b) => (field === 'repo' ? String(a).toLowerCase() === String(b).toLowerCase() : String(a) === String(b));
326
+ for (const row of verified) {
327
+ if (row.error) continue;
328
+ const act = row.acts.find((each) => Object.entries(want).every(([field, value]) => same(field, each[field], value)));
329
+ if (act) return { act, signed_at: row.signed_at, key: row.key, verified: row.verified };
330
+ }
331
+ return null;
332
+ }
@@ -23,6 +23,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
23
23
  import { join, dirname, posix, resolve } from 'node:path';
24
24
  import { listChanged } from './changed-tests.mjs';
25
25
  import { docSourceFindings, replay } from './doc-sources.mjs';
26
+ import { proceduresCommand } from './procedure-steps.mjs';
26
27
 
27
28
  // R39 line 4 gates only after the replay of line 3. The replay of the last 30
28
29
  // merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
@@ -246,9 +247,10 @@ export function upkeepCheck(root, { event = null } = {}) {
246
247
  return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
247
248
  }
248
249
 
249
- export function upkeepCommand({ what, event: eventFile, json = false, last }, root, line) {
250
+ export function upkeepCommand({ what, event: eventFile, json = false, last, base }, root, line) {
250
251
  if (what === 'replay') return replayCommand({ last, json }, root, line);
251
- if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, or atlas upkeep replay.`);
252
+ if (what === 'procedures') return proceduresCommand({ base: base ?? null, json }, root, line);
253
+ if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, atlas upkeep replay or atlas upkeep procedures.`);
252
254
  let event = null;
253
255
  if (eventFile !== undefined) {
254
256
  try { event = JSON.parse(readFileSync(resolve(eventFile), 'utf8')); } catch (error) { throw new Error(`cannot read the event file ${eventFile}: ${error.message}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -110,8 +110,9 @@ set. A test keeps the two lists equal.
110
110
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
111
111
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
112
112
  | `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
113
- | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates |
113
+ | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
114
114
  | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
115
+ | `atlas approve` | Never from a session. Only the owner runs it, on his Mac, with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way |
115
116
  | `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
116
117
 
117
118
  A person merges every file that `atlas tooling` writes.
@@ -27,19 +27,24 @@ the form the repo already uses for that kind of thing.
27
27
  deploy. A rule says how to judge a change. They have different approval
28
28
  rules, so split a skill that holds both.
29
29
  4. **Write the header Atlas reads,** for a skill file. It names the type,
30
- the commands and files, and the code it covers. It says how CI runs
31
- it, if CI can. Today the Atlas check reads only `name` and
32
- `description`, and finds the skill when `procedures` in `atlas.yaml`
33
- names it. Later upkeep checks read the other keys, so keep each true.
34
- 5. **Keep it to this repo.** A step that holds how to work in general
30
+ the commands and files, and the code it covers in `sources:`. The
31
+ Atlas check finds the skill when `procedures` in `atlas.yaml` names
32
+ it. `atlas upkeep check` reads `sources:`.
33
+ 5. **Tag each step a clean machine can run.** Put its commands in a
34
+ fenced block. Open it with ```` ```sh atlas-run ````. CI runs each
35
+ such block when a pull request changes the skill or its sources. A
36
+ step that needs a secret, a device or the network gets
37
+ `needs=secret/<name>`, `needs=device/<name>` or `needs=network/<name>`
38
+ after the tag. CI lists it as "not run here".
39
+ 6. **Keep it to this repo.** A step that holds how to work in general
35
40
  belongs in Atlas. Do not put it here. Propose it to Atlas with the
36
41
  `atlas:feedback` skill.
37
- 6. **Follow the repo's own rules.** Read its entry file first. Some repos
42
+ 7. **Follow the repo's own rules.** Read its entry file first. Some repos
38
43
  ask for one area folder for each change, or a status file updated
39
44
  when the state changes. Keep to them.
40
- 7. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
45
+ 8. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
41
46
  a fresh worktree, before you ask for the merge.
42
- 8. **Write the pull request text.** Say what you found, what you kept,
47
+ 9. **Write the pull request text.** Say what you found, what you kept,
43
48
  and the gap you closed.
44
49
 
45
50
  ## The header of a skill file
@@ -51,9 +56,8 @@ description: <when to use it, in one or two sentences>
51
56
  type: task # or rule
52
57
  names: # each command and file the steps name
53
58
  - <command or path>
54
- covers: # the code this skill describes
59
+ sources: # the code this skill describes
55
60
  - <path>
56
- ci: <how CI runs it, or "cannot run in CI">
57
61
  ---
58
62
  ```
59
63