@arjunkhera/atlas 0.4.19 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +22 -2
- 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/signed-act.mjs +332 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -0
- package/skills/upkeep/SKILL.md +4 -5
- package/work/lib/activity.mjs +9 -4
- package/work/lib/delivery.mjs +5 -1
- package/work/lib/questions.mjs +5 -19
- package/work/lib/verbs.mjs +13 -19
- package/work/mcp.mjs +1 -4
- package/work/lib/sweep.mjs +0 -189
|
@@ -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/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -112,6 +112,7 @@ set. A test keeps the two lists equal.
|
|
|
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
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 |
|
|
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.
|
package/skills/upkeep/SKILL.md
CHANGED
|
@@ -21,8 +21,8 @@ Design: `docs/design-docs/upkeep-routine/` in the Atlas repo.
|
|
|
21
21
|
|
|
22
22
|
The run never calls these verbs. They are the owner's acts.
|
|
23
23
|
|
|
24
|
-
1. `item_lock`, `item_propose`, `item_split
|
|
25
|
-
2. `decision_record`, `question_answer`, `question_ask
|
|
24
|
+
1. `item_lock`, `item_propose`, `item_split`.
|
|
25
|
+
2. `decision_record`, `question_answer`, `question_ask`.
|
|
26
26
|
3. `item_drop`, `item_park`, `item_resume`, `item_reopen`.
|
|
27
27
|
4. `product_add`, `product_edit`, `repo_add`, `repo_edit`, `setup`.
|
|
28
28
|
|
|
@@ -62,9 +62,8 @@ Pass the product to every read. Show no other product.
|
|
|
62
62
|
that a tracker item links, or that names an item id. Comment the item id
|
|
63
63
|
and its state, and close the issue as completed. Owner rule, 21 October
|
|
64
64
|
2026: an issue is not kept open once its item exists.
|
|
65
|
-
4. **Read only, report only.** Call `
|
|
66
|
-
|
|
67
|
-
report. Do not act on them.
|
|
65
|
+
4. **Read only, report only.** Call `needs_me` for the product. Put any
|
|
66
|
+
question older than seven days in the report. Do not act on it.
|
|
68
67
|
5. **Red merges.** When `atlas.yaml` lists `required_checks`, run
|
|
69
68
|
`atlas gate` with the verb `audit`, `--repo` and `--since` set to
|
|
70
69
|
yesterday's date. Put each merge it names in the report, under "Left for
|
package/work/lib/activity.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const ACTIVITY_READ_VERBS = Object.freeze(['item_activity']);
|
|
|
17
17
|
export const VOICES = Object.freeze(['agent', 'owner']);
|
|
18
18
|
// The item verbs whose writes carry a diff (step 9). item_propose writes a
|
|
19
19
|
// "created" diff when it makes the item.
|
|
20
|
-
export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen'
|
|
20
|
+
export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen']);
|
|
21
21
|
export const ENTRY_VERBS = Object.freeze(['item_propose', ...LOG_VERBS]);
|
|
22
22
|
const MAX_COMMENT = 4000;
|
|
23
23
|
const PREVIEW = 300;
|
|
@@ -207,9 +207,14 @@ export function createActivityVerbs(core) {
|
|
|
207
207
|
if (row.fields?.item !== item.id) continue;
|
|
208
208
|
rows.push({ at: row.fields.marked_at, kind: 'mark', mark_id: row.id, mark_kind: row.fields.kind, verdict: row.fields.verdict ?? null });
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
// A piece the feed cannot read keeps its row, with the reason, so a
|
|
211
|
+
// split never hides a piece (roadmap R70 line 1).
|
|
212
|
+
const pieces = await Promise.all((Array.isArray(fields.split_children) ? fields.split_children : []).map((id) => client.getEntity(id).catch((error) => ({ unreadable: id, reason: oneLine(String(error?.message ?? error), 200) }))));
|
|
211
213
|
for (const piece of pieces) {
|
|
212
|
-
if (!piece)
|
|
214
|
+
if (!piece || piece.unreadable) {
|
|
215
|
+
rows.push({ at: null, kind: 'piece', item: piece?.unreadable ?? null, title: null, split_order: null, unreadable: true, reason: piece?.reason ?? 'the graph returned nothing' });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
213
218
|
// The piece's date is the split that made it, not its last write.
|
|
214
219
|
const made = Object.values(piece.fields?.atlas_receipts ?? {}).filter((receipt) => receipt?.verb === 'item_split').map((receipt) => receipt.at).sort()[0] ?? piece.created_at ?? null;
|
|
215
220
|
rows.push({ at: made, kind: 'piece', item: piece.id, title: oneLine(piece.title ?? '', 200), split_order: piece.fields?.split_order ?? null });
|
|
@@ -239,7 +244,7 @@ function lineOf(row) {
|
|
|
239
244
|
case 'question': return oneLine(`${when} question (${row.status}) ${row.text}`);
|
|
240
245
|
case 'decision': return oneLine(`${when} decision (${row.decision_kind}) "${row.text}"`);
|
|
241
246
|
case 'mark': return oneLine(`${when} mark ${row.mark_kind}${row.verdict ? `: ${row.verdict}` : ''}`);
|
|
242
|
-
case 'piece': return oneLine(`${when} piece ${row.title} · ${row.item}`);
|
|
247
|
+
case 'piece': return row.unreadable ? oneLine(`${when} piece ${row.item} could not be read: ${row.reason}`) : oneLine(`${when} piece ${row.title} · ${row.item}`);
|
|
243
248
|
case 'link': return oneLine(`${when} link (${row.link_kind}) ${row.url}`);
|
|
244
249
|
default: return oneLine(`${when} ${row.kind}`);
|
|
245
250
|
}
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -809,7 +809,11 @@ export function createDeliveryVerbs(core) {
|
|
|
809
809
|
const { last, days } = movementOf({ item, marks: own, pulls: mine, legacy, readAt });
|
|
810
810
|
const state = fields.lifecycle === 'done' ? 'delivered' : (fields.delivery_state === 'reopened' ? 'reopened' : fields.lifecycle);
|
|
811
811
|
|
|
812
|
-
|
|
812
|
+
// Only active work feeds "merged, not marked". A done item's other
|
|
813
|
+
// pull requests (its design, its lock) are history, not a gap; a
|
|
814
|
+
// reopened item is active again, so its pull requests come back
|
|
815
|
+
// (roadmap R70 line 5).
|
|
816
|
+
const inScope = fields.lifecycle === 'active';
|
|
813
817
|
for (const pull of (inScope ? open : [])) notMarked.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, pr: pull.url, title: pull.title, merged_at: pull.merged_at, days: daysBetween(pull.merged_at, readAt), matched_by: pull.matched_by, note: 'merged, not marked' });
|
|
814
818
|
for (const mark of deliveries) if (!since || String(mark.fields.marked_at ?? '') >= since) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: mark.id, verdict: correctedBy.has(mark.id) ? 'corrected' : (mark.fields.verdict ?? 'delivered'), ...(correctedBy.has(mark.id) ? { corrected_by: correctedBy.get(mark.id) } : {}), delivered_at: mark.fields.marked_at, prs: (mark.fields.prs ?? []).map((pull) => pull.url), deployed_commit: mark.fields.deployed?.commit ?? null, gate_warnings: mark.fields.gate_warnings ?? [] });
|
|
815
819
|
if (legacy && (!since || String(legacy.marked_at) >= since)) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: null, verdict: 'legacy', delivered_at: legacy.marked_at, prs: [], deployed_commit: null, source: legacy.source });
|
package/work/lib/questions.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, CLEARED_LIFECYCLES, blocker
|
|
|
16
16
|
// any other: the owner's words, dated, listing the items (design finding R7).
|
|
17
17
|
export const DECISION_KINDS = Object.freeze(['decision', 'priority', 'answer', 'sweep']);
|
|
18
18
|
export const QUESTION_STATUS = Object.freeze(['open', 'answered']);
|
|
19
|
-
export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record'
|
|
19
|
+
export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record']);
|
|
20
20
|
export const QUESTION_READ_VERBS = Object.freeze(['needs_me']);
|
|
21
21
|
|
|
22
22
|
// R8: the row budget. Three visible lines; everything else folds under it.
|
|
@@ -131,10 +131,9 @@ export function createQuestionVerbs(core) {
|
|
|
131
131
|
return entity;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
//
|
|
135
|
-
// the sweep's real ceiling
|
|
136
|
-
//
|
|
137
|
-
// so a long list costs one read each.
|
|
134
|
+
// A sweep record of slice L5 lists every active item of a product, so this
|
|
135
|
+
// cap is the sweep's real ceiling (architect review of Batch 15, finding 5).
|
|
136
|
+
// Every id is verified, so a long list costs one read each.
|
|
138
137
|
async function itemsTouched(value, reg, fallback) {
|
|
139
138
|
if (value === undefined) return fallback ? [fallback] : [];
|
|
140
139
|
const ids = stringList(value, 'items', { max: 200 });
|
|
@@ -148,7 +147,7 @@ export function createQuestionVerbs(core) {
|
|
|
148
147
|
const type = await typeNamed('receipt');
|
|
149
148
|
const prior = (await findCreated(type, requestId))[0];
|
|
150
149
|
if (prior) return prior;
|
|
151
|
-
const entity = await create(type, requestId, meta?.verb ?? '
|
|
150
|
+
const entity = await create(type, requestId, meta?.verb ?? 'decision_record', {
|
|
152
151
|
digest: meta?.digest,
|
|
153
152
|
title: `Receipt · ${oneLine(view || 'answer', 80)} · ${(question ?? decision).id.slice(0, 8)}`,
|
|
154
153
|
body: clean.text,
|
|
@@ -260,19 +259,6 @@ export function createQuestionVerbs(core) {
|
|
|
260
259
|
return { verb: 'decision_record', decision_id: decision.id, version: decision.version, kind: decision.fields.kind, date: decision.fields.date, items: decision.fields.items ?? [], receipt_id: receipt?.id ?? null, redactions: receipt?.fields.redactions ?? 0, ...(prior ? { replayed_from_graph: true } : {}) };
|
|
261
260
|
},
|
|
262
261
|
|
|
263
|
-
// Keep the rendered answer the owner acted on. D2: refuses with no decision to attach to.
|
|
264
|
-
async receipt_keep(args, requestId, meta) {
|
|
265
|
-
requireOwner('receipt keep');
|
|
266
|
-
const decision = await recordOfKind(args.decision_id, 'decision', 'decision_id');
|
|
267
|
-
const rendered = text(args.rendered, 'rendered', { max: MAX_RENDERED });
|
|
268
|
-
const view = text(args.view, 'view', { optional: true, max: 200 }) ?? '';
|
|
269
|
-
const type = await typeNamed('receipt');
|
|
270
|
-
const prior = await landed(type, requestId, meta);
|
|
271
|
-
if (prior) { await edge(decision, prior, 'references', requestId); return { verb: 'receipt_keep', receipt_id: prior.id, version: prior.version, decision_id: decision.id, redactions: prior.fields.redactions ?? 0, replayed_from_graph: true }; }
|
|
272
|
-
const receipt = await writeReceipt({ decision, question: null, rendered, view, requestId, meta, product: decision.fields?.product ?? '' });
|
|
273
|
-
return { verb: 'receipt_keep', receipt_id: receipt.id, version: receipt.version, decision_id: decision.id, redactions: receipt.fields.redactions ?? 0 };
|
|
274
|
-
},
|
|
275
|
-
|
|
276
262
|
// D3: the "Needs me" view. Computed at question time, per product, never stored.
|
|
277
263
|
async needs_me(args = {}) {
|
|
278
264
|
const reg = await registry();
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// Slice L3 adds the done and reopen verbs and the "In flight" view from
|
|
5
5
|
// delivery.mjs, through the same door again. Slice L4 adds the code-read
|
|
6
6
|
// brief, the six-part answer and the "Built, pending, possible" view from
|
|
7
|
-
// capability.mjs: three read verbs that write nothing. Slice L5
|
|
8
|
-
// sweep
|
|
9
|
-
//
|
|
7
|
+
// capability.mjs: three read verbs that write nothing. Slice L5 added the
|
|
8
|
+
// one-time sweep and its two verbs. The sweep was done, and the verbs left
|
|
9
|
+
// the server on 26 October 2026 (roadmap R70 line 3).
|
|
10
10
|
// Design: the Atlas M1 study, pages D1, D2, D3 and D6,
|
|
11
11
|
// lock block L1, L2 and L3. Every write carries request_id and writer. The
|
|
12
12
|
// server, not the caller, supplies the session. Unknown ids are refused.
|
|
@@ -19,7 +19,6 @@ import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, str
|
|
|
19
19
|
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS, oneLine, scrubSecrets } from './questions.mjs';
|
|
20
20
|
import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
|
|
21
21
|
import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
|
|
22
|
-
import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
|
|
23
22
|
import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
|
|
24
23
|
import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
|
|
25
24
|
import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
|
|
@@ -452,15 +451,15 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
452
451
|
const reg = await registry();
|
|
453
452
|
const product = await requireProduct(args.product, reg);
|
|
454
453
|
const repos = await requireRepos(args.repos, reg, product.fields.registry_id);
|
|
455
|
-
if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc
|
|
454
|
+
if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc? }');
|
|
455
|
+
// A design doc once carried a hash of its criteria. The definition of
|
|
456
|
+
// done is the scope text now, and the verb keeps its own fingerprint.
|
|
457
|
+
// A new lock never takes a hash; an old record that holds one still
|
|
458
|
+
// reads (roadmap R70 line 4).
|
|
459
|
+
if (args.scope.acceptance_criteria_hash !== undefined) refuse('scope.acceptance_criteria_hash is for old records only: a new lock takes no hash. Put the definition of done in scope.text, and the verb keeps its fingerprint');
|
|
456
460
|
const scope = { text: text(args.scope.text, 'scope.text') };
|
|
457
461
|
if (args.scope.design_doc !== undefined) scope.design_doc = url(args.scope.design_doc, 'scope.design_doc');
|
|
458
|
-
|
|
459
|
-
text(args.scope.acceptance_criteria_hash, 'scope.acceptance_criteria_hash', { max: 64 });
|
|
460
|
-
if (!/^[a-f0-9]{64}$/.test(args.scope.acceptance_criteria_hash)) refuse('scope.acceptance_criteria_hash must be a 64-hex sha256');
|
|
461
|
-
scope.acceptance_criteria_hash = args.scope.acceptance_criteria_hash;
|
|
462
|
-
}
|
|
463
|
-
const scopeHash = scope.acceptance_criteria_hash ?? sha256(scope.text);
|
|
462
|
+
const scopeHash = sha256(scope.text);
|
|
464
463
|
const stage = oneOf(args.stage, 'stage', STAGES, fields.lifecycle === 'active' && fields.stage ? fields.stage : 'build');
|
|
465
464
|
const change = {
|
|
466
465
|
lifecycle: 'active', stage, product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, fields.tier ?? 'standard'),
|
|
@@ -743,16 +742,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
743
742
|
// reopen verbs and the four views share this door, this journal, this
|
|
744
743
|
// registry and this writer stamp. The delivery verbs also get the two
|
|
745
744
|
// read-only readers: GitHub through the gh CLI, and one GET against the
|
|
746
|
-
// deployed app. Slice L4 adds three read verbs, which write nothing.
|
|
747
|
-
// L5 adds item_adopt, the one verb that gives an existing item a product,
|
|
748
|
-
// and sweep_check, which reads the sweep record against the "In flight"
|
|
749
|
-
// view. The sweep record itself is written by decision_record with kind
|
|
750
|
-
// "sweep": slice L5 adds no second way to write a decision.
|
|
745
|
+
// deployed app. Slice L4 adds three read verbs, which write nothing.
|
|
751
746
|
const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated };
|
|
752
747
|
Object.assign(verbs, createQuestionVerbs(core));
|
|
753
748
|
Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
|
|
754
749
|
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
755
|
-
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
756
750
|
Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
|
|
757
751
|
const activity = createActivityVerbs(core);
|
|
758
752
|
Object.assign(verbs, activity.verbs);
|
|
@@ -761,8 +755,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
761
755
|
const stepIns = createStepInVerbs(core);
|
|
762
756
|
Object.assign(verbs, stepIns.verbs);
|
|
763
757
|
|
|
764
|
-
const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...
|
|
765
|
-
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...
|
|
758
|
+
const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
|
|
759
|
+
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
|
|
766
760
|
|
|
767
761
|
async function run(verb, args = {}) {
|
|
768
762
|
if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
|