@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +25 -4
- package/door/lib/approve.mjs +311 -0
- package/door/lib/gate-check.mjs +59 -10
- package/door/lib/gate.mjs +27 -3
- package/door/lib/procedure-steps.mjs +122 -0
- package/door/lib/signed-act.mjs +332 -0
- package/door/lib/upkeep.mjs +4 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +2 -1
- package/skills/repo-skills/SKILL.md +14 -10
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
|
+
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
298
|
-
// with --repo.
|
|
299
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
+
}
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -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
|
|
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
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|
|
31
|
-
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|