From d031f75ac06e091a6f196e62f3fa046700e82620 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:17:29 +0000 Subject: [PATCH 1/6] =?UTF-8?q?wip(pm):=20advisory=5Fread=20relay=20op=20?= =?UTF-8?q?=E2=80=94=20the=20table=20row,=20the=20key=20kind=20and=20the?= =?UTF-8?q?=20seal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- scripts/pm/fleet-write/ops.mjs | 384 ++++++++++++++++++++++++++-- scripts/pm/fleet-write/validate.mjs | 219 ++++++++++++++-- 2 files changed, 564 insertions(+), 39 deletions(-) diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index 0639457f02f..e4c1c5218ed 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -167,9 +167,11 @@ * `/contents/**` and `/git/**`; `REFUSED_PATH_FAMILIES` refuses both for every * row. The workflow-run dispatch (`workflow_dispatch`) and the failed-jobs * re-run (`run_rerun_failed`), both below, are `actions: write`, the two rows - * spending it; the four advisory rows (`advisory_report`, `advisory_create`, - * `advisory_collaborators_add` and `advisory_state`, below) are - * `repository-advisories: write`, the four rows spending it; the sender gate's + * spending it; the four advisory rows that write (`advisory_report`, + * `advisory_create`, `advisory_collaborators_add` and `advisory_state`, below) + * are `repository-advisories: write`, and the one that reads (`advisory_read`, + * below) spends the same grant at `read` alone (its row's `grant`), the five + * rows spending it; the sender gate's * `GET /repos/{o}/{r}/collaborators/{login}/permission` is `metadata: read`. * `PERMISSIONS` is that set and nothing wider. The workflow declares the five * grants every stroke gets as literal `permission-*` inputs on @@ -378,7 +380,9 @@ * collaborators see an unpublished advisory. A seat account must not write to * GitHub itself (the suspension `fleet-token.mjs` records), so a seat files a * finding and moves its state through the relay, and reads the advisory under - * its own account once it is a collaborator. The maintainer's scope, verbatim + * its own account once it is a collaborator — a read a cloud seat turned out + * unable to make, so it reads through the relay too, sealed (`advisory_read`, + * the next section). The maintainer's scope, verbatim * as the body of issue 29160 (the card that filed these rows) records it: * 「按这个范围更新 #29160:上报正文经中继并读事件文件,加人、接受、关闭只改状态,读取用席位自己的账号,发布留给我。先测令牌。」 * @@ -404,8 +408,9 @@ * report's and the draft's bodies are the row's, holding no state (the * endpoint creates a draft and takes none), and the two PATCH bodies are the * executor's (`validate.mjs --self-test` pins all four). Listing advisories, - * reading or editing an advisory's text, removing a collaborator, the CVE - * request and the temporary fork are out of reach of every row: + * editing an advisory's text, removing a collaborator, the CVE request and the + * temporary fork are out of reach of every row, and reading an advisory's text + * is one row's alone, which seals it (`advisory_read`, the next section): * `REFUSED_PATH_FAMILIES` refuses each `security-advisories` path but one * advisory's (`ADVISORY_PATH_SHAPE`), the reports endpoint * (`ADVISORY_REPORTS_PATH_SHAPE`) and the bare collection @@ -494,6 +499,74 @@ * of what it admits: any fleet account with write on the target can add * itself to an advisory whose id it holds. * + * ## The one row that READS an advisory — `advisory_read`, sealed to a key the seat alone holds + * + * The scope above has a seat read an advisory under its own account. The live + * run after 29160 merged showed a cloud seat cannot: its `gh` speaks with a + * token holding no repository-advisories permission (403, "Resource not + * accessible by integration"), and its egress proxy replaces every + * `Authorization` header. The App can read one, but only inside the relay's + * run, whose log, annotations and step summary anyone may read. So the read + * leaves the run as ciphertext alone, by the maintainer's naming, verbatim as + * the body of issue 29420 records it: 「按一次性密钥立卡」. + * + * row request the payload carries + * `advisory_read` `GET …/security-advisories/{id}`, and nothing else `ghsa_id` and `public_key` + * + * The seat (`dispatch.mjs`) generates an X25519 key pair in its own process + * for each read, puts the public half in the action, and keeps the private + * half in memory — never exported, never written, gone when the process + * exits. No key is stored anywhere: not in the repository, not as a secret. + * `public_key` is its own closed kind (`FIELDS`, kind `x25519`): base64 SPKI + * DER of exactly `ADVISORY_READ_PUBLIC_KEY_CHARS` characters in its one + * spelling, a key a shared secret can be derived with — judged by + * `advisoryReadKeyFailure` before any request, a refusal naming the key and + * the byte count alone. + * + * The executor reads the advisory, judges `ADVISORY_READ_FENCES` on that + * answer, and seals `ADVISORY_READ_FIELDS` of it as the platform answered + * them (`advisoryReadPlaintext`) with `sealAdvisoryRead`: a fresh X25519 key + * of its own, HKDF-SHA256 over the shared secret, AES-256-GCM under a random + * nonce, with the request id, the action's index and the id as associated + * data, so a sealed answer opens for the read that asked for it and no other. + * Node's own `crypto`, and nothing else. The sealed answer leaves the run on + * ONE channel, the one a seat container can read: notice annotations on the + * job's check run, at most `ADVISORY_READ_MAX_PARTS` of + * `ADVISORY_READ_PART_CHARS` characters (`advisoryReadParts`), inside the + * runner's measured caps — ten notices a step and a message cut at 4,096 + * characters (`actions/runner`, `ExecutionContext.cs`). An answer too large + * for them is a FAILED action naming byte counts alone. The seat reassembles + * the parts, opens them with its private key (`openAdvisoryRead`), and prints + * the text to its own stdout, and only on `--reveal`. + * + * ⛔ What crosses the public run: the HTTP status, the byte and part counts, + * and the ciphertext. NOT the id — the row's call is `ADVISORY_READ_CALL`, a + * placeholder where every other row prints its path — nor the state, the + * platform's `message` unless `ADVISORY_PLATFORM_MESSAGES` names it, or any + * field of the answer. A seal that fails is a FAILED action that prints + * nothing of the answer and emits no part. The action LANDS only on a 200 + * whose answer passes every fence and whose seal succeeded and fit. It writes + * nothing, so `dispatch.mjs` has no byte read-back for it: opening the parts + * IS its read-back. + * + * The grant: the row's `grant` is `read`, so its stroke's token carries + * `repository-advisories: read` and never write. GitHub documents this GET + * under a read grant; what was measured is the write grant's read (run + * 41415522766, above), so the first live read is the measurement of record. + * A refusal there is a FAILED action carrying `ADVISORY_READ_ACCESS_REMEDY`: + * raising the row to write is the maintainer's ruling, ⛔ never a retry under + * a wider token. + * + * The sender gate, recorded beside the row as the card asks: every stroke + * passes the target-write gate first, and the executor then holds a read to + * the row's `senderGate`, one of `ADVISORY_READ_SENDER_GATES`. It is + * `collaborator`, the card's recommendation: the sender must be a login the + * advisory's own `collaborating_users` lists, compared case-insensitively (a + * team is not read), or nothing is sealed — holding an id is not reading its + * text. The other value, `target-write`, keeps the gate the four rows above + * keep. The maintainer rules which stands, on the pull request; switching is + * that one value, and an unknown one fails closed. + * * ## The platform's `client_payload` ceilings — pinned, with their source * * "The maximum number of top-level properties is 10. The total size of the @@ -507,6 +580,8 @@ * reject with a 422 is refused locally with the reason. */ +import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto'; + import { GOVERNED_REPOS } from '../check-governed-merges.mjs'; /** @@ -1069,6 +1144,250 @@ export function advisoryCreateLandedText({ ghsaId, state }) { return `created ${advisoryPrintable(ghsaId, GHSA_ID_SHAPE, 'advisory id')} · state ${advisoryPrintable(state, ADVISORY_STATE_SHAPE, 'advisory state')} — add whoever works it with advisory_collaborators_add`; } +// --------------------------------------------------------------------------- +// `advisory_read` (header: "The one row that READS an advisory") — the key, the seal, the parts, the fences. +// --------------------------------------------------------------------------- + +/** The one key type `public_key` names: X25519, as base64 SPKI DER. */ +export const ADVISORY_READ_KEY_TYPE = 'x25519'; +/** X25519's SPKI DER header: the 12 bytes before the 32-byte key, `MCowBQYDK2VuAyEA` in base64. */ +const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); +const X25519_RAW_BYTES = 32; +/** `public_key`'s length: 44 bytes of DER, 60 base64 characters with their one pad. */ +export const ADVISORY_READ_PUBLIC_KEY_CHARS = 60; +/** `public_key`'s one spelling: the header, then the key, its last character's two spare bits clear, then the pad. */ +export const ADVISORY_READ_PUBLIC_KEY_SHAPE = /^MCowBQYDK2VuAyEA[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$/; + +const allZero = (buf) => buf.every((b) => b === 0); + +/** + * Why `value` is not a `public_key`, or null when it is one: a string of exactly + * `ADVISORY_READ_PUBLIC_KEY_CHARS` characters in `ADVISORY_READ_PUBLIC_KEY_SHAPE`, parsed as an X25519 key, with + * which a trial derivation yields a non-zero secret — a low-order point, the all-zero key among them, is refused + * here. ⛔ The reason never carries the value: the validator prints it into a public log. Never throws. + */ +export function advisoryReadKeyFailure(value) { + if (typeof value !== 'string') return 'is not a string'; + if (value.length !== ADVISORY_READ_PUBLIC_KEY_CHARS) return `is not ${ADVISORY_READ_PUBLIC_KEY_CHARS} characters long`; + if (!ADVISORY_READ_PUBLIC_KEY_SHAPE.test(value)) return 'is not an X25519 public key in base64 SPKI DER, its one spelling'; + let key; + try { + key = createPublicKey({ key: Buffer.from(value, 'base64'), format: 'der', type: 'spki' }); + } catch { + return 'does not parse as a public key'; + } + if (key.asymmetricKeyType !== ADVISORY_READ_KEY_TYPE) return 'is not an X25519 key'; + let shared = null; + try { + shared = diffieHellman({ privateKey: generateKeyPairSync(ADVISORY_READ_KEY_TYPE).privateKey, publicKey: key }); + return allZero(shared) ? 'is a key no secret can be derived with (a low-order point)' : null; + } catch { + return 'is a key no secret can be derived with (a low-order point)'; + } finally { + shared?.fill(0); + } +} + +/** The sealed answer's format version — its first byte. */ +export const ADVISORY_READ_SEAL_VERSION = 1; +/** The scheme, named once for a reader: what `sealAdvisoryRead` does and `openAdvisoryRead` undoes. */ +export const ADVISORY_READ_SCHEME = 'X25519 key agreement · HKDF-SHA256 · AES-256-GCM, a fresh ephemeral key and nonce per seal'; +const ADVISORY_READ_INFO = 'objectstack fleet-write advisory_read v1'; +const ADVISORY_READ_NONCE_BYTES = 12; +const ADVISORY_READ_TAG_BYTES = 16; +/** What a seal adds to the plaintext: the version byte, the ephemeral key, the nonce and the tag — 61 bytes. */ +export const ADVISORY_READ_SEAL_OVERHEAD = 1 + X25519_RAW_BYTES + ADVISORY_READ_NONCE_BYTES + ADVISORY_READ_TAG_BYTES; + +const rawX25519 = (key) => key.export({ format: 'der', type: 'spki' }).subarray(X25519_SPKI_PREFIX.length); +const x25519FromRaw = (raw) => createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' }); +/** The AES key, from the shared secret and both public keys; an all-zero secret is refused, never used. */ +function advisoryReadAesKey(shared, ephemeralRaw, recipientRaw) { + if (allZero(shared)) throw new Error('the shared secret is all zeros'); + const info = Buffer.concat([Buffer.from(ADVISORY_READ_INFO, 'utf8'), ephemeralRaw, recipientRaw]); + return Buffer.from(hkdfSync('sha256', shared, Buffer.alloc(0), info, 32)); +} + +/** + * The associated data a sealed answer is bound to: the request id, the action's index and the advisory's id. The + * seat knows all three, so it opens the answer to ITS read and no other; none of them is sent. Pure. + */ +export function advisoryReadAad({ requestId, action, ghsaId }) { + return Buffer.from(`${ADVISORY_READ_INFO} request=${requestId} action=${action} ghsa_id=${ghsaId}`, 'utf8'); +} + +/** + * Seal `plaintext` (a string, sealed as its UTF-8 bytes) to `publicKey` (a `public_key` value) under `aad`: + * version ‖ ephemeral X25519 key ‖ nonce ‖ ciphertext ‖ tag, as base64url without padding. The shared secret + * and the AES key are zeroed after use. Throws on any failure, with a message that carries nothing of the + * plaintext — and the executor prints its own fixed sentence, never the message. + */ +export function sealAdvisoryRead({ plaintext, publicKey, aad }) { + const recipient = createPublicKey({ key: Buffer.from(publicKey, 'base64'), format: 'der', type: 'spki' }); + const ephemeral = generateKeyPairSync(ADVISORY_READ_KEY_TYPE); + const shared = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipient }); + const ephemeralRaw = rawX25519(ephemeral.publicKey); + let key = null; + try { + key = advisoryReadAesKey(shared, ephemeralRaw, rawX25519(recipient)); + const nonce = randomBytes(ADVISORY_READ_NONCE_BYTES); + const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: ADVISORY_READ_TAG_BYTES }); + cipher.setAAD(aad); + const body = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]); + return Buffer.concat([Buffer.from([ADVISORY_READ_SEAL_VERSION]), ephemeralRaw, nonce, body, cipher.getAuthTag()]).toString('base64url'); + } finally { + shared.fill(0); + key?.fill(0); + } +} + +/** Why a sealed answer did not open — the one sentence, which carries nothing of what was sealed. */ +export const ADVISORY_READ_OPEN_FAILED = 'the sealed answer does not open with this key — tampered, truncated, or sealed for another read'; +const strictUtf8 = new TextDecoder('utf-8', { fatal: true }); + +/** + * Open a sealed answer with `privateKey` (the seat's in-memory `KeyObject`) under `aad`: the plaintext string, + * byte for byte what was sealed. Throws `ADVISORY_READ_OPEN_FAILED` for anything else — a sealed answer that is + * not canonical base64url, of another version, too short, tampered with, sealed to another key or bound to + * another read. The private key is read, never exported. + */ +export function openAdvisoryRead({ sealed, privateKey, aad }) { + const box = typeof sealed === 'string' && /^[A-Za-z0-9_-]+$/.test(sealed) ? Buffer.from(sealed, 'base64url') : null; + if (!box || box.toString('base64url') !== sealed || box.length < ADVISORY_READ_SEAL_OVERHEAD || box[0] !== ADVISORY_READ_SEAL_VERSION) throw new Error(ADVISORY_READ_OPEN_FAILED); + const ephemeralRaw = box.subarray(1, 1 + X25519_RAW_BYTES); + const nonce = box.subarray(1 + X25519_RAW_BYTES, 1 + X25519_RAW_BYTES + ADVISORY_READ_NONCE_BYTES); + const body = box.subarray(1 + X25519_RAW_BYTES + ADVISORY_READ_NONCE_BYTES, box.length - ADVISORY_READ_TAG_BYTES); + const tag = box.subarray(box.length - ADVISORY_READ_TAG_BYTES); + let shared = null; + let key = null; + let opened = null; + try { + shared = diffieHellman({ privateKey, publicKey: x25519FromRaw(ephemeralRaw) }); + key = advisoryReadAesKey(shared, ephemeralRaw, rawX25519(createPublicKey(privateKey))); + const decipher = createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: ADVISORY_READ_TAG_BYTES }); + decipher.setAAD(aad); + decipher.setAuthTag(tag); + opened = strictUtf8.decode(Buffer.concat([decipher.update(body), decipher.final()])); + } catch { + opened = null; + } finally { + shared?.fill(0); + key?.fill(0); + } + if (opened === null) throw new Error(ADVISORY_READ_OPEN_FAILED); + return opened; +} + +/** + * The fields of an advisory's answer `advisory_read` seals, copied as the platform answered them — the text the + * card names (summary, description, vulnerabilities) and what places it (the ids, the state, the severity, the + * weakness ids, the credits). Not `collaborating_users`, whose user objects are kilobytes of URLs each, nor any + * URL, timestamp or private fork. + */ +export const ADVISORY_READ_FIELDS = Object.freeze(['ghsa_id', 'cve_id', 'state', 'severity', 'summary', 'description', 'vulnerabilities', 'cwe_ids', 'credits']); + +/** What is sealed: `ADVISORY_READ_FIELDS` of the answer that it carries, in that order, as one JSON text. Pure. */ +export function advisoryReadPlaintext(json) { + const picked = {}; + for (const field of ADVISORY_READ_FIELDS) if (Object.hasOwn(json, field)) picked[field] = json[field]; + return JSON.stringify(picked); +} + +/** The characters one notice part carries — under the runner's 4,096-character cut, with room for its prefix. */ +export const ADVISORY_READ_PART_CHARS = 4000; +/** The most parts a read emits — the runner keeps ten notices a step, and a read's stroke emits no other. */ +export const ADVISORY_READ_MAX_PARTS = 10; +/** The most sealed characters a read can return, and so the largest answer: 40,000 characters, 29,939 bytes of plaintext. */ +export const ADVISORY_READ_MAX_SEALED_CHARS = ADVISORY_READ_PART_CHARS * ADVISORY_READ_MAX_PARTS; + +/** A sealed answer cut into its parts, in order; null when it is empty or over `ADVISORY_READ_MAX_SEALED_CHARS`. Pure. */ +export function advisoryReadParts(sealed) { + if (typeof sealed !== 'string' || sealed.length === 0 || sealed.length > ADVISORY_READ_MAX_SEALED_CHARS) return null; + const parts = []; + for (let at = 0; at < sealed.length; at += ADVISORY_READ_PART_CHARS) parts.push(sealed.slice(at, at + ADVISORY_READ_PART_CHARS)); + return parts; +} + +/** + * The sender gates a read may be held to (header) — the row's `senderGate` is one of them. `collaborator`: the + * sender must be on the advisory's own `collaborating_users`. `target-write`: the gate every stroke already + * passed, write, maintain or admin on the target, and nothing more. + */ +export const ADVISORY_READ_SENDER_GATES = Object.freeze({ + collaborator: "a sender the advisory's own collaborating_users lists", + 'target-write': 'a sender with write, maintain or admin on the target', +}); + +/** + * The fences on a read, each judged on the executor's own GET — `ctx` is `{ ghsaId, answer, sender, gate }`. A + * fence whose read did not answer an advisory FAILS, naming that. ⛔ No sentence carries the answer's id, its state + * or any field of it: an answer naming another advisory is said to, and no more. + */ +const readGateOf = (c) => (Object.hasOwn(ADVISORY_READ_SENDER_GATES, c.gate) ? c.gate : null); +export const ADVISORY_READ_FENCES = Object.freeze([ + Object.freeze({ + fence: 'read', + wants: () => "the advisory's own GET answering 200 with an advisory", + fails: (c) => (advisoryRead(c) ? null : `the advisory's GET answered HTTP ${c.answer?.status ?? 'none'}${c.answer?.status === 200 ? ' with no advisory' : ''}`), + }), + Object.freeze({ + fence: 'advisory', + wants: () => 'the advisory the action names, by its ghsa_id', + fails: (c) => { + if (!advisoryRead(c)) return 'not judged, because the advisory was not read'; + return c.answer.json.ghsa_id === c.ghsaId ? null : 'the answer names another advisory'; + }, + }), + Object.freeze({ + fence: 'sender', + wants: (c) => ADVISORY_READ_SENDER_GATES[readGateOf(c)] ?? 'a sender gate the table names', + fails: (c) => { + const gate = readGateOf(c); + if (gate === null) return 'the row names no sender gate the table knows, so no read is sealed'; + if (gate === 'target-write') return null; + if (!advisoryRead(c)) return 'not judged, because the advisory was not read'; + const sender = typeof c.sender === 'string' ? c.sender.toLowerCase() : null; + return sender && advisoryCollaboratorLogins(c.answer.json).some((l) => l.toLowerCase() === sender) ? null : `${advisoryPrintable(c.sender, LOGIN_SHAPE, 'login')} is not a collaborator on it`; + }, + }), +]); + +/** The read fences `ctx` fails: `[{ fence, wants, got }]` in `ADVISORY_READ_FENCES` order; `[]` when all hold. Pure. */ +export function advisoryReadFenceFailures(ctx = {}) { + return ADVISORY_READ_FENCES.map((f) => ({ fence: f.fence, wants: f.wants(ctx), got: f.fails(ctx) })).filter((f) => f.got !== null); +} + +/** The sentence a read fence refusal prints — every failing fence, what was read, what the relay seals for. Pure. */ +export function advisoryReadFenceText(failures) { + return failures.map((f) => `fence \`${f.fence}\`: ${f.got}, and the relay seals only for ${f.wants}`).join('; '); +} + +/** The call a read's row and log print — the path with the id left out, ⛔ never the path itself (header). Pure. */ +export function advisoryReadCall(repo) { + return `GET /repos/${repo}/security-advisories/{ghsa_id}`; +} + +/** What a refused read prints after its status: the grant is the first live read's to measure, ⛔ never widened by hand. */ +export const ADVISORY_READ_ACCESS_REMEDY = + 'the relay token carries repository-advisories READ for this stroke alone — GitHub documents this read under it, and the ' + + 'first live read measures it; put this reading to the maintainer, whose ruling it is to raise the row to write — ⛔ never ' + + 're-send it under a wider or un-narrowed token'; + +/** What a read whose seal failed prints: nothing of the answer, and no part emitted. */ +export const ADVISORY_READ_SEAL_FAILED = 'the seal failed, so nothing of the answer was emitted — ⛔ the text never leaves another way; re-send the read with a fresh key'; + +/** What a read too large for the channel prints: the counts, and nothing of the answer. Pure. */ +export function advisoryReadOverCapText({ plaintextBytes, sealedChars }) { + return ( + `the sealed answer is ${sealedChars} character(s) (${plaintextBytes} byte(s) of the answer), over the ${ADVISORY_READ_MAX_SEALED_CHARS} the run's ` + + `notices carry (${ADVISORY_READ_MAX_PARTS} of ${ADVISORY_READ_PART_CHARS}) — nothing was emitted; ⛔ the text never leaves another way, put the reading to the maintainer` + ); +} + +/** The row a landed read prints: counts alone — ⛔ no id, no state, no field. Pure. */ +export function advisoryReadLandedText({ plaintextBytes, sealedChars, parts }) { + return `sealed ${plaintextBytes} byte(s) of the answer to the seat's one-time key — ${sealedChars} character(s) in ${parts} notice part(s), which only that seat's process opens`; +} + /** The seat-side transport selector and its three values. */ export const TRANSPORT_ENV = 'OS_FLEET_TRANSPORT'; export const TRANSPORTS = Object.freeze(['direct', 'dispatch', 'auto']); @@ -1145,7 +1464,11 @@ export const TARGET_REPO_SHAPE = new RegExp(`^${TARGET_OWNER}/[A-Za-z0-9_.-]+$`) * values are then judged against the row's `closedInputs` entry; * `ghsa` one repository advisory's id, `GHSA_ID_SHAPE` exactly — it is * spelled into a path; a refusal names the shape and never echoes - * the value, which a public run log would otherwise carry. + * the value, which a public run log would otherwise carry; + * `x25519` one X25519 public key, base64 SPKI DER in its one spelling, + * judged by `advisoryReadKeyFailure` (header: "The one row that + * READS an advisory") — a refusal names the key, the byte count and + * what the value failed to be, and never echoes it. * * Two modifiers, both for the advisory rows (header: "The four rows that * touch a security advisory"): `shape` holds each item of a `list` to a @@ -1189,6 +1512,8 @@ export const FIELDS = Object.freeze({ ghsa_id: Object.freeze({ kind: 'ghsa' }), collaborators: Object.freeze({ kind: 'list', max: MAX_LOGINS_PER_ACTION, itemMax: MAX_LOGIN_CHARS, shape: LOGIN_SHAPE, itemWhat: 'a GitHub login', quiet: true }), advisory_state: Object.freeze({ kind: 'enum', values: ADVISORY_SETTABLE_STATES, quiet: true, named: ADVISORY_REFUSED_STATES }), + // The seat's one-time public key for ONE read (header: "The one row that READS an advisory") — its own kind. + public_key: Object.freeze({ kind: 'x25519' }), }); /** @@ -1203,16 +1528,19 @@ export const FIELDS = Object.freeze({ * maintainer's ruling, comment 6104822731 (header). `actions: write` is spent * by the `workflow_dispatch` and `run_rerun_failed` rows alone, and minted * only for a stroke carrying one of them (`STROKE_SCOPED_PERMISSIONS`). - * `repository-advisories: write` is spent by the four advisory rows alone, - * and minted only for a stroke carrying one of them, the same way. + * `repository-advisories: write` is spent by the four advisory rows that + * write alone, and minted only for a stroke carrying one of them, the same + * way; `advisory_read` spends that grant at `read` (its row's `grant`), so a + * read's stroke is minted the lower level and never write. */ export const PERMISSIONS = Object.freeze({ issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', actions: 'write', 'repository-advisories': 'write' }); /** * The permissions minted ONLY for a stroke carrying an op that spends them — * `actions: write`, the `workflow_dispatch` and `run_rerun_failed` rows' - * alone, and `repository-advisories: write`, the four advisory rows' alone; - * each of those rows travels alone in its stroke. `validate.mjs` + * alone, and `repository-advisories`, the five advisory rows' alone — at + * write, or at the row's `grant` (`advisory_read`'s `read`); each of those + * rows travels alone in its stroke. `validate.mjs` * computes a stroke's permission set (`tokenPermissionsOf`) and writes each of * these to `$GITHUB_OUTPUT` as `permission_=` or empty (a hyphen * in the name spelled `_`); the workflow's mint step reads that output for @@ -1317,6 +1645,12 @@ export function transferRemedy(source, target) { /** * The closed op list. Each row: * `permission` the App permission the row spends (a key of `PERMISSIONS`); + * `grant` (optional) the level the row spends it at, when lower than + * `PERMISSIONS` holds — `advisory_read`'s `read` alone, on a + * stroke-scoped permission (header above); + * `senderGate` (optional) the sender gate a read is held to beside the + * target's — `advisory_read`'s, one of + * `ADVISORY_READ_SENDER_GATES` (header above); * `required` keys that must be present; * `optional` keys that may be; anything else on the action is refused; * `atLeastOne` (optional) of these keys, at least one must be present; @@ -1325,8 +1659,8 @@ export function transferRemedy(source, target) { * `alone` (optional) true for a row whose stroke carries exactly that * one action — `workflow_dispatch`'s and `run_rerun_failed`'s, * the two rows spending `actions: write`, and - * `advisory_collaborators_add`'s, the one spending - * `repository-advisories: write` (header above); + * the five advisory rows', the ones spending + * `repository-advisories` (header above); * `closedInputs` (optional) the per-workflow closed inputs — * `WORKFLOW_DISPATCH_INPUTS`, `workflow_dispatch`'s alone: the * action's `inputs` must be exactly the entry its `workflow` @@ -1349,7 +1683,7 @@ export function transferRemedy(source, target) { * the hierarchy rows' `issue` and `sub_issue`: an issue is * never its own sub-issue (header above); * `requests` the request descriptors the executor issues, in order: - * `{ verb, path, body?, idempotent404?, graphql?, updateBranch?, rerunFailed?, subIssue?, fromAnswer?, assigned?, labelled? }` + * `{ verb, path, body?, idempotent404?, graphql?, updateBranch?, rerunFailed?, subIssue?, advisoryRead?, fromAnswer?, assigned?, labelled? }` * — a `graphql` descriptor names the mutation and the pull (or * the issue and target) whose node ids it needs; a pull's * descriptor also carries `landed`, its `PR_LANDED_STATE` @@ -1370,7 +1704,10 @@ export function transferRemedy(source, target) { * before the PATCH, whose merged list is the body it sends, * and whose answer it reads back (`advisoryPreflight` / * `confirmAdvisoryCollaborators`, execute.mjs) — it carries no - * `body` either; a `fromAnswer` descriptor names the + * `body` either; an `advisoryRead` descriptor carries the + * advisory's id and the seat's public key, whose GET the + * executor fences, seals and emits as notice parts and prints + * nothing else of (`advisoryReadAction`, execute.mjs); a `fromAnswer` descriptor names the * field of its action's FIRST request's answer that fills the * `answerPlaceholder` in its path (`fillFromAnswer`, * execute.mjs); an `assigned` descriptor lists the logins its @@ -1627,6 +1964,20 @@ export const OPS = Object.freeze({ alone: true, requests: (a, repo) => [{ verb: 'PATCH', path: `/repos/${repo}/security-advisories/${a.ghsa_id}`, advisoryState: { ghsaId: a.ghsa_id, to: a.advisory_state } }], }), + // The one row that READS an advisory — see the header. ONE GET, nothing written; the executor seals the answer to the + // seat's one-time key and prints nothing of it, not even the id. It spends repository-advisories at READ alone and + // travels alone. ⚖ The sender gate, recorded here as the card asks and open for the maintainer's ruling on the pull + // request: `collaborator` — the card's recommendation, the sender on the advisory's own collaborating_users — or + // `target-write`, the gate the four rows above keep (`ADVISORY_READ_SENDER_GATES`). Switching it is this one value. + advisory_read: Object.freeze({ + permission: 'repository-advisories', + grant: 'read', + senderGate: 'collaborator', + required: Object.freeze(['ghsa_id', 'public_key']), + optional: Object.freeze([]), + alone: true, + requests: (a, repo) => [{ verb: 'GET', path: `/repos/${repo}/security-advisories/${a.ghsa_id}`, advisoryRead: { ghsaId: a.ghsa_id, publicKey: a.public_key } }], + }), }); export const OP_NAMES = Object.freeze(Object.keys(OPS)); @@ -1677,7 +2028,8 @@ export const REFUSED_PATH_FAMILIES = Object.freeze([ // Every `security-advisories` path but exactly ONE advisory's (`ADVISORY_PATH_SHAPE`), the private-report endpoint // (`ADVISORY_REPORTS_PATH_SHAPE`) and the bare collection (`ADVISORY_CREATE_PATH_SHAPE`, the create of a draft): // anything after the collection but those two, an advisory's CVE request and its temporary fork stay out of reach - // of any row. The collection's list is the same path under `GET`, which no row sends (the self-test pins the verbs). + // of any row. The collection's list is the same path under `GET`, which no row sends (the self-test pins the verbs); + // the one GET a row sends is `advisory_read`'s, of one advisory's path. /\/security-advisories(?!\/(?:GHSA(-[0-9a-z]{4}){3}|reports)$|$)/, /^\/orgs\//, /^\/admin\//, diff --git a/scripts/pm/fleet-write/validate.mjs b/scripts/pm/fleet-write/validate.mjs index 87ee83b72bd..8ea5abcdd17 100644 --- a/scripts/pm/fleet-write/validate.mjs +++ b/scripts/pm/fleet-write/validate.mjs @@ -89,6 +89,17 @@ * the action may act in — are facts this file cannot see: the executor * judges them on its own read before the PATCH (`ADVISORY_FENCES` in * `ops.mjs`); + * - a stroke carrying `advisory_read` carries exactly that one action too, + * and its token carries `repository-advisories: read` — the row's `grant` + * — never write. It names `ghsa_id` and `public_key`, the seat's one-time + * X25519 key, its own kind: base64 SPKI DER of exactly 60 characters in + * its one spelling, a key a secret can be derived with + * (`advisoryReadKeyFailure` in `ops.mjs`), refused here before any + * request; a refusal names the key, the byte count and what the value + * failed to be, ⛔ never the value. Any other key — a `summary`, a + * `private_key`, a `state` — is refused by name. The fences on the READ — + * readable, the one named, a sender the gate admits — are the executor's, + * on its own GET (`ADVISORY_READ_FENCES` in `ops.mjs`); * - a `pr_create` may carry `assignees` and `labels`, the typed lists * `assign` and `labels_add` take — judged here as every list is — and the * op table turns each into a later request of the SAME action, sent by @@ -107,7 +118,9 @@ * for every other. So the second row rides the workflow's existing input and * the workflow names neither op. The advisory rows' grant is the second such * line, `permission_repository_advisories=write` for a stroke carrying one of - * them and empty for every other. The + * them and empty for every other — and `permission_repository_advisories=read` + * for an `advisory_read` stroke, the one row whose `grant` is lower than + * `PERMISSIONS` holds. The * workflow reads those outputs and never the payload, and `--self-test` pins * the list to ONE name for every other op, two for a transfer, the grant to * the one row, and the workflow lines that read both. @@ -182,6 +195,29 @@ import { MAX_ADVISORY_SUMMARY_BYTES, MAX_NPM_PACKAGE_CHARS, NPM_PACKAGE_SHAPE, + ADVISORY_READ_FENCES, + ADVISORY_READ_FIELDS, + ADVISORY_READ_KEY_TYPE, + ADVISORY_READ_MAX_PARTS, + ADVISORY_READ_MAX_SEALED_CHARS, + ADVISORY_READ_OPEN_FAILED, + ADVISORY_READ_PART_CHARS, + ADVISORY_READ_PUBLIC_KEY_CHARS, + ADVISORY_READ_PUBLIC_KEY_SHAPE, + ADVISORY_READ_SEAL_OVERHEAD, + ADVISORY_READ_SEAL_VERSION, + ADVISORY_READ_SENDER_GATES, + advisoryReadAad, + advisoryReadCall, + advisoryReadFenceFailures, + advisoryReadFenceText, + advisoryReadKeyFailure, + advisoryReadLandedText, + advisoryReadOverCapText, + advisoryReadParts, + advisoryReadPlaintext, + openAdvisoryRead, + sealAdvisoryRead, advisoryCollaboratorPlan, advisoryCreateLandedText, advisoryCreateReadBack, @@ -304,6 +340,12 @@ export function validateField(name, value, spec, where) { if (typeof value !== 'string' || !GHSA_ID_SHAPE.test(value)) return `${at} must be one repository advisory's id — GHSA and three groups of four lowercase letters or digits — got ${typeof value === 'string' ? `a string of ${value.length} character(s)` : Array.isArray(value) ? 'an array' : value === null ? 'null' : typeof value} that is not one; it is spelled into a path, so it is refused here, before the dispatch, and never echoed`; return null; } + if (spec.kind === 'x25519') { + // ⛔ Never echoed: a refusal reaches a public run log, and a value in this key may be prose put in the wrong one. + const why = advisoryReadKeyFailure(value); + if (why) return `${at} must be one X25519 public key, base64 SPKI DER of exactly ${ADVISORY_READ_PUBLIC_KEY_CHARS} characters — got ${describeQuietly(value)} that ${why}; it is refused here, before any request, and never echoed`; + return null; + } if (spec.kind === 'inputs') { if (!isPlainObject(value)) return `${at} must be a JSON object of input name → boolean, got ${Array.isArray(value) ? 'an array' : quote(value)}`; const names = Object.keys(value); @@ -392,10 +434,14 @@ export function closedInputsErrors(action, declared, where) { * such a stroke carries that op alone, names ONE second repository, never * `repo` itself, and each card once. The ones whose token carries a grant no * other row spends (`alone`, `workflow_dispatch`'s and `run_rerun_failed`'s, - * the two that spend `actions: write`): such a stroke carries exactly that - * one action. Runs only over actions that passed + * the two that spend `actions: write`, and the five advisory rows', which + * spend `repository-advisories`): such a stroke carries exactly that one + * action. Runs only over actions that passed * `validateAction`. Pure. */ +const levelOf = (op) => OPS[op].grant ?? PERMISSIONS[OPS[op].permission]; +const grantOf = (op) => `${OPS[op].permission} ${levelOf(op)}`; + export function strokeErrors(repo, actions) { const errors = []; const solo = actions.map((a, i) => ({ a, i })).filter(({ a }) => OPS[a.op].alone); @@ -404,8 +450,8 @@ export function strokeErrors(repo, actions) { for (const [i, a] of actions.entries()) { errors.push( a.op === op - ? `actions[${i}]: a stroke carries exactly ONE \`${op}\` and nothing else (this stroke carries ${actions.length} actions) — its token carries ${OPS[op].permission} write for that one request; send each in its own dispatch` - : `actions[${i}] is \`${a.op}\` in a stroke that carries a \`${op}\` — its token carries ${OPS[op].permission} write, so that stroke carries the \`${op}\` alone; send \`${a.op}\` in its own dispatch`, + ? `actions[${i}]: a stroke carries exactly ONE \`${op}\` and nothing else (this stroke carries ${actions.length} actions) — its token carries ${grantOf(op)} for that one request; send each in its own dispatch` + : `actions[${i}] is \`${a.op}\` in a stroke that carries a \`${op}\` — its token carries ${grantOf(op)}, so that stroke carries the \`${op}\` alone; send \`${a.op}\` in its own dispatch`, ); } } @@ -455,17 +501,24 @@ export function tokenRepositoriesOf(payload) { /** * The grants the relay's token must carry for a JUDGED payload: every entry of * `PERMISSIONS` but the stroke-scoped ones, plus each stroke-scoped grant some - * action's op spends. `{ …five grants }` for every stroke but a - * `workflow_dispatch` or `run_rerun_failed` one, which adds `actions: write`, - * and an advisory row's (`advisory_report`, `advisory_create`, - * `advisory_collaborators_add`, `advisory_state`), which adds - * `repository-advisories: write`. Pure. + * action's op spends, at the level its row spends it — the row's `grant` + * when it names one, else `PERMISSIONS`' level; the higher of two. `{ …five + * grants }` for every stroke but a `workflow_dispatch` or `run_rerun_failed` + * one, which adds `actions: write`; an advisory row that writes + * (`advisory_report`, `advisory_create`, `advisory_collaborators_add`, + * `advisory_state`), which adds `repository-advisories: write`; and + * `advisory_read`, which adds `repository-advisories: read`. Pure. */ export function tokenPermissionsOf(payload) { - const spent = new Set(payload.actions.map((a) => OPS[a.op].permission)); + const spent = new Map(); + for (const a of payload.actions) { + const { permission } = OPS[a.op]; + spent.set(permission, spent.get(permission) === 'write' ? 'write' : levelOf(a.op)); + } const out = {}; for (const [name, level] of Object.entries(PERMISSIONS)) { - if (!STROKE_SCOPED_PERMISSIONS.includes(name) || spent.has(name)) out[name] = level; + if (!STROKE_SCOPED_PERMISSIONS.includes(name)) out[name] = level; + else if (spent.has(name)) out[name] = spent.get(name); } return out; } @@ -926,10 +979,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the hierarchy rows: sub_issue_add and sub_issue_remove name a parent and a child by NUMBER — two different positive integers and nothing that widens them; one POST to the parent's sub_issues and one DELETE to its sub_issue, the body left to the executor; issues write, riding any stroke on the literal grants; the fences on the two cards, each accepted and refused — an issue on the repository, never a pull or a transferred card, the child free of any other parent or listed by this one; and the read-back's judgment by the child's id": 23, "the event file: the workflow carries client_payload in no step's env, and the payload is read from GITHUB_EVENT_PATH": 6, "the advisory rows: advisory_report, advisory_create, advisory_collaborators_add and advisory_state — closed keys, a malformed id or package refused before any request, a refusal that names the key and the byte count and never echoes a value, published refused by name and reachable by no row; one request each, the draft's body naming no state and the two PATCH bodies left to the executor; each a stroke of its own, repository-advisories write minted for it and EMPTY for every other; the bare collection reached by the draft's POST alone and every other security-advisories path refused; the fences, the transitions, the merge and the four read-backs, printing nothing of an answer but the id, the state and the logins": 51, + "advisory_read: ghsa_id and public_key alone — the key its own kind, an X25519 key in base64 SPKI DER; a malformed key or id refused before any request and never echoed; ONE GET of one advisory; a stroke of its own whose token alone carries repository-advisories READ; the seal opening byte for byte for its own read and key alone, the parts inside the runner's caps, the sender gate's two settings and the fences, printing no id, state or field": 28, }); // Raised 11 → 12 with the run_rerun_failed battery, 12 → 13 with the hierarchy rows' battery, 13 → 15 with the event -// file's battery and the advisory rows' battery: one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 15; +// file's battery and the advisory rows' battery, 15 → 16 with advisory_read's: one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 16; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -975,6 +1029,13 @@ export async function selfTest() { const SHA = '0123456789abcdef0123456789abcdef01234567'; /** An advisory id in the shape GitHub spells one — a fixture, naming no real advisory. */ const GHSA = 'GHSA-abcd-2345-wxyz'; + const { generateKeyPairSync } = await import('node:crypto'); + /** A one-time X25519 key pair as the seat makes one per read; `spki` is the public half `public_key` carries. */ + const keyPair = () => { + const k = generateKeyPairSync('x25519'); + return { publicKey: k.publicKey, privateKey: k.privateKey, spki: k.publicKey.export({ format: 'der', type: 'spki' }).toString('base64') }; + }; + const READER = keyPair(); /** One `$GITHUB_OUTPUT` line of a judged payload, by its name — the scoped grants are two lines, so none is read by position. */ const outputLine = (payload, name) => githubOutputLines(payload).split('\n').find((l) => l.startsWith(`${name}=`)) ?? null; @@ -1074,6 +1135,7 @@ export async function selfTest() { advisory_create: { summary: 's', description: 'd', packages: ['p'] }, advisory_collaborators_add: { ghsa_id: GHSA, collaborators: ['u'] }, advisory_state: { ghsa_id: GHSA, advisory_state: 'draft' }, + advisory_read: { ghsa_id: GHSA, public_key: READER.spki }, }; t('every op in the table has a minimal shape this validator accepts', OP_NAMES.filter((op) => !validatePayload(one({ op, ...minimal[op] })).ok), []); t('…and the minimal-shape ledger names every op, no more and no less', Object.keys(minimal).sort(), [...OP_NAMES].sort()); @@ -1099,7 +1161,7 @@ export async function selfTest() { const sample = { issue: 7, pull: 7, comment_id: 7, body: 'b', title: 't', head: 'h', base: 'main', labels: ['a b'], assignees: ['u'], reviewers: ['u'], team_reviewers: ['t'], state: 'closed', state_reason: 'completed', expected_head_sha: SHA, target_repo: `${TARGET_OWNER}/objectui`, workflow: WORKFLOW_DISPATCH_ALLOWLIST[0], ref: WORKFLOW_DISPATCH_REF, run_id: 7, sub_issue: 8, ghsa_id: GHSA, collaborators: ['u'], - summary: 's', description: 'd', severity: 'low', cwe_ids: ['CWE-79'], advisory_state: 'closed', packages: ['p'], + summary: 's', description: 'd', severity: 'low', cwe_ids: ['CWE-79'], advisory_state: 'closed', packages: ['p'], public_key: READER.spki, }; const everyRequest = OP_NAMES.flatMap((op) => { const spec = OPS[op]; @@ -1116,7 +1178,7 @@ export async function selfTest() { t('…an empty list spells no request, and a governedReview row is never a row whose spelled requests change: pr_create and pr_ready keep the request lists they always had', [governedReviewRequests('o/r', 7), OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r').length, OPS.pr_ready.requests({ op: 'pr_ready', pull: 7 }, 'o/r').length], [[], 1, 1]); t('⛔ no request path reaches a refused family', everyRequest.filter((r) => REFUSED_PATH_FAMILIES.some((re) => re.test(r.path))).map((r) => `${r.op} ${r.path}`), []); t('⛔ the table\'s one PUT is pr_update_branch\'s update-branch, an action endpoint — no PUT reaches /labels or /assignees, the whole-set replaces', [everyRequest.filter((r) => r.verb === 'PUT').map((r) => `${r.op} ${r.path}`), everyRequest.some((r) => r.verb === 'PUT' && /\/(labels|assignees)$/.test(r.path))], [[`pr_update_branch /repos/${TARGET_OWNER}/objectstack/pulls/7/update-branch`], false]); - t('every verb is one of POST, PATCH, PUT, DELETE', everyRequest.every((r) => ['POST', 'PATCH', 'PUT', 'DELETE'].includes(r.verb))); + t("every verb is one of POST, PATCH, PUT, DELETE — but advisory_read's GET of one advisory, the one GET any row sends, which writes nothing", [everyRequest.filter((r) => r.verb !== 'GET').every((r) => ['POST', 'PATCH', 'PUT', 'DELETE'].includes(r.verb)), everyRequest.filter((r) => r.verb === 'GET').map((r) => `${r.op} ${r.path}`)], [true, [`advisory_read /repos/${TARGET_OWNER}/objectstack/security-advisories/${GHSA}`]]); t('pr_update_branch spends pull-requests — where GitHub\'s ledger lists update-branch, server-to-server, no additional permission — and carries the sha in its body', [OPS.pr_update_branch.permission, OPS.pr_update_branch.requests({ op: 'pr_update_branch', pull: 7, expected_head_sha: SHA }, 'o/r')[0].body], ['pull-requests', { expected_head_sha: SHA }]); t('every GraphQL descriptor names an allowed mutation and its query spells that name', everyRequest.filter((r) => r.graphql).every((r) => ALLOWED_MUTATIONS.includes(r.graphql.mutation) && r.graphql.query.includes(r.graphql.mutation))); t('pr_create forces draft: true whatever the action said', OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r')[0].body.draft, true); @@ -1246,6 +1308,7 @@ export async function selfTest() { automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, workflow_dispatch: { workflow: WORKFLOW_DISPATCH_ALLOWLIST[0], ref: WORKFLOW_DISPATCH_REF }, run_rerun_failed: { run_id: 1 }, sub_issue_add: { issue: 1, sub_issue: 2 }, sub_issue_remove: { issue: 1, sub_issue: 2 }, advisory_report: { summary: 's', description: 'd' }, advisory_create: { summary: 's', description: 'd', packages: ['p'] }, advisory_collaborators_add: { ghsa_id: GHSA, collaborators: ['u'] }, advisory_state: { ghsa_id: GHSA, advisory_state: 'draft' }, + advisory_read: { ghsa_id: GHSA, public_key: READER.spki }, }; const narrow = OP_NAMES.filter((op) => !OPS[op].secondRepo); t('transfer is the ONLY row that reaches a second repository', OP_NAMES.filter((op) => OPS[op].secondRepo), ['transfer']); @@ -1288,7 +1351,7 @@ export async function selfTest() { t('an inputs key on a file whose entry is null is refused BY NAME — the relay sends none there', refuses(one(start({ inputs: { reason: true } })), `actions[0].inputs: ${FILE} declares no inputs`)); t('⛔ a workflow_dispatch beside any other op is refused — the stroke carries that one action alone', refuses(fixturePayload({ actions: [start(), { op: 'comment', issue: 7, body: 'x' }] }), 'actions[1] is `comment` in a stroke that carries a `workflow_dispatch`')); t('⛔ two workflow_dispatch in one stroke are refused too', refuses(fixturePayload({ actions: [start(), start()] }), 'a stroke carries exactly ONE `workflow_dispatch` and nothing else')); - t('the only rows that travel alone or widen the token: workflow_dispatch, run_rerun_failed and the four advisory rows (alone) and transfer (second repository)', [OP_NAMES.filter((op) => OPS[op].alone), OP_NAMES.filter((op) => OPS[op].secondRepo)], [['workflow_dispatch', 'run_rerun_failed', 'advisory_report', 'advisory_create', 'advisory_collaborators_add', 'advisory_state'], ['transfer']]); + t('the only rows that travel alone or widen the token: workflow_dispatch, run_rerun_failed and the five advisory rows (alone) and transfer (second repository)', [OP_NAMES.filter((op) => OPS[op].alone), OP_NAMES.filter((op) => OPS[op].secondRepo)], [['workflow_dispatch', 'run_rerun_failed', 'advisory_report', 'advisory_create', 'advisory_collaborators_add', 'advisory_state', 'advisory_read'], ['transfer']]); t('…and actions is spent by the workflow_dispatch and run_rerun_failed rows and NOTHING else — each of them alone in its stroke', [OP_NAMES.filter((op) => OPS[op].permission === 'actions'), OP_NAMES.filter((op) => OPS[op].permission === 'actions').every((op) => OPS[op].alone === true)], [['workflow_dispatch', 'run_rerun_failed'], true]); const plain = validatePayload(fixturePayload()).payload; const run = validatePayload(one(start())).payload; @@ -1402,6 +1465,7 @@ export async function selfTest() { automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, sub_issue_add: { issue: 1, sub_issue: 2 }, sub_issue_remove: { issue: 1, sub_issue: 2 }, advisory_report: { summary: 's', description: 'd' }, advisory_create: { summary: 's', description: 'd', packages: ['p'] }, advisory_collaborators_add: { ghsa_id: GHSA, collaborators: ['u'] }, advisory_state: { ghsa_id: GHSA, advisory_state: 'draft' }, + advisory_read: { ghsa_id: GHSA, public_key: READER.spki }, }; const spendsNoActions = OP_NAMES.filter((op) => OPS[op].permission !== 'actions'); t('…the ledger of ops spending no actions grant is every op but the two that do, no more and no less', [Object.keys(minimalOf).sort(), [...spendsNoActions].sort()], [[...spendsNoActions].sort(), [...spendsNoActions].sort()]); @@ -1547,7 +1611,8 @@ export async function selfTest() { battery("the advisory rows: advisory_report, advisory_create, advisory_collaborators_add and advisory_state — closed keys, a malformed id or package refused before any request, a refusal that names the key and the byte count and never echoes a value, published refused by name and reachable by no row; one request each, the draft's body naming no state and the two PATCH bodies left to the executor; each a stroke of its own, repository-advisories write minted for it and EMPTY for every other; the bare collection reached by the draft's POST alone and every other security-advisories path refused; the fences, the transitions, the merge and the four read-backs, printing nothing of an answer but the id, the state and the logins"); { const REPO = `${TARGET_OWNER}/objectstack`; - const ADVISORY_OPS = ['advisory_report', 'advisory_create', 'advisory_collaborators_add', 'advisory_state']; + // The five rows spending repository-advisories: the four that write, then the one that reads (its own battery below). + const ADVISORY_OPS = ['advisory_report', 'advisory_create', 'advisory_collaborators_add', 'advisory_state', 'advisory_read']; const report = (extra = {}) => ({ op: 'advisory_report', summary: 'A short summary.', description: 'What, where, how to reproduce.', ...extra }); const draft = (extra = {}) => ({ op: 'advisory_create', summary: 'A short summary.', description: 'What, where, how to reproduce.', packages: ['@objectstack/rest'], ...extra }); const add = (extra = {}) => ({ op: 'advisory_collaborators_add', ghsa_id: GHSA, collaborators: ['os-dev-a'], ...extra }); @@ -1557,7 +1622,7 @@ export async function selfTest() { // Spaces and punctuation: prose, never a login's, an id's, a state's or a weakness id's shape. const PROSE = 'ADVISORY PROSE MARKER, in the wrong key'; // The rows and their requests. - t('the rows: report takes summary and description, optionally severity and cwe_ids; the draft the same plus packages; the other two take ghsa_id with collaborators or advisory_state; all four spend repository-advisories alone, travel alone, reach no second repository and close no inputs', ADVISORY_OPS.map((op) => [OPS[op].required, OPS[op].optional, OPS[op].permission, OPS[op].alsoSpends ?? null, OPS[op].alone, OPS[op].secondRepo ?? null, OPS[op].closedInputs ?? null]), [[['summary', 'description'], ['severity', 'cwe_ids'], 'repository-advisories', null, true, null, null], [['summary', 'description', 'packages'], ['severity', 'cwe_ids'], 'repository-advisories', null, true, null, null], [['ghsa_id', 'collaborators'], [], 'repository-advisories', null, true, null, null], [['ghsa_id', 'advisory_state'], [], 'repository-advisories', null, true, null, null]]); + t('the rows: report takes summary and description, optionally severity and cwe_ids; the draft the same plus packages; the other two take ghsa_id with collaborators or advisory_state, and the read ghsa_id with public_key; all five spend repository-advisories alone — the four that write at write, the read at read — travel alone, reach no second repository and close no inputs', ADVISORY_OPS.map((op) => [OPS[op].required, OPS[op].optional, OPS[op].permission, OPS[op].grant ?? null, OPS[op].alsoSpends ?? null, OPS[op].alone, OPS[op].secondRepo ?? null, OPS[op].closedInputs ?? null]), [[['summary', 'description'], ['severity', 'cwe_ids'], 'repository-advisories', null, null, true, null, null], [['summary', 'description', 'packages'], ['severity', 'cwe_ids'], 'repository-advisories', null, null, true, null, null], [['ghsa_id', 'collaborators'], [], 'repository-advisories', null, null, true, null, null], [['ghsa_id', 'advisory_state'], [], 'repository-advisories', null, null, true, null, null], [['ghsa_id', 'public_key'], [], 'repository-advisories', 'read', null, true, null, null]]); t('the fields: summary and description byte-capped strings — 1024 and the body cap; severity GitHub\'s four, quiet; cwe_ids a quiet capped list of CWE ids; ghsa_id its own kind; collaborators a quiet login list capped like assignees; advisory_state a quiet enum of draft and closed with published refused by name', [FIELDS.summary, FIELDS.description.max === FIELDS.body.max, [...FIELDS.severity.values], FIELDS.severity.quiet, [FIELDS.cwe_ids.kind, FIELDS.cwe_ids.max, FIELDS.cwe_ids.shape === CWE_ID_SHAPE, FIELDS.cwe_ids.quiet], FIELDS.ghsa_id, [FIELDS.collaborators.max === FIELDS.assignees.max, FIELDS.collaborators.itemMax === FIELDS.assignees.itemMax, FIELDS.collaborators.shape === LOGIN_SHAPE, FIELDS.collaborators.quiet], [[...FIELDS.advisory_state.values], FIELDS.advisory_state.quiet, Object.keys(FIELDS.advisory_state.named)]], [{ kind: 'str', max: MAX_ADVISORY_SUMMARY_BYTES }, true, ['critical', 'high', 'medium', 'low'], true, ['list', 10, true, true], { kind: 'ghsa' }, [true, true, true, true], [['draft', 'closed'], true, ['published']]]); const filed = OPS.advisory_report.requests(report(), REPO); const filedFull = OPS.advisory_report.requests(report({ severity: 'high', cwe_ids: ['CWE-79', 'CWE-352'] }), REPO); @@ -1642,7 +1707,7 @@ export async function selfTest() { return v.ok ? [outputLine(v.payload, 'permission_repository_advisories'), tokenPermissionsOf(v.payload)['repository-advisories'] ?? null] : `refused: ${v.errors.join(' | ')}`; }; t('⛔ fence grant, refused side: every other op — each alone in its stroke, the actions strokes among them, and the stroke of all that share one token — writes an EMPTY permission_repository_advisories and mints no such grant', [others.filter((op) => JSON.stringify(advisoryLine([{ op, ...minimalOf[op] }])) !== JSON.stringify(['permission_repository_advisories=', null])), advisoryLine(others.filter((op) => !OPS[op].secondRepo && !OPS[op].alone).map((op) => ({ op, ...minimalOf[op] })))], [[], ['permission_repository_advisories=', null]]); - t('repository-advisories is spent by the four rows and NOTHING else — as their permission, never as an alsoSpends', [OP_NAMES.filter((op) => OPS[op].permission === 'repository-advisories'), OP_NAMES.filter((op) => (OPS[op].alsoSpends ?? []).includes('repository-advisories'))], [ADVISORY_OPS, []]); + t('repository-advisories is spent by the five rows and NOTHING else — as their permission, never as an alsoSpends', [OP_NAMES.filter((op) => OPS[op].permission === 'repository-advisories'), OP_NAMES.filter((op) => (OPS[op].alsoSpends ?? []).includes('repository-advisories'))], [ADVISORY_OPS, []]); const repoRoot = resolve(SELF_PATH, '../../../..'); const workflowText = readFileSync(resolve(repoRoot, '.github/workflows/fleet-write.yml'), 'utf8'); const mintsFromOutput = [...workflowText.matchAll(/^\s+permission-repository-advisories:\s*\$\{\{\s*steps\.validate\.outputs\.permission_repository_advisories\s*\}\}\s*$/gm)].length; @@ -1652,7 +1717,7 @@ export async function selfTest() { const refusedTails = ['/', '?state=draft', '?per_page=100', '/x', `/${GHSA}/cve`, `/${GHSA}/forks`, `/${GHSA}/`, `/${GHSA.toUpperCase()}`, '/GHSA-abcd-2345', `/${GHSA}x`, '/reports/', '/reportsx', `/reports/${GHSA}`, '/reports/x/cve', 'x']; t('⛔ every other security-advisories path is caught by a refused family: the collection with a trailing slash or a query (the list\'s spellings), anything after it but one advisory or the reports endpoint, the CVE request, the temporary fork, anything after the reports endpoint, and any id that is not exactly one advisory\'s', refusedTails.filter((tail) => !REFUSED_PATH_FAMILIES.some((re) => re.test(`/repos/${REPO}/security-advisories${tail}`))), []); t('…while the three paths a row reaches are caught by none: the bare collection, the reports endpoint and one advisory\'s', ['', '/reports', `/${GHSA}`].filter((tail) => REFUSED_PATH_FAMILIES.some((re) => re.test(`/repos/${REPO}/security-advisories${tail}`))), []); - const fillWith = { ...draft({ severity: 'critical', cwe_ids: ['CWE-79'] }), ...add(), ...move('closed') }; + const fillWith = { ...draft({ severity: 'critical', cwe_ids: ['CWE-79'] }), ...add(), ...move('closed'), public_key: READER.spki }; const every = OP_NAMES.flatMap((op) => { const a = { op }; for (const k of [...OPS[op].required, ...OPS[op].optional]) { @@ -1661,7 +1726,7 @@ export async function selfTest() { } return OPS[op].requests(a, REPO).map((r) => ({ op, ...r })); }); - t('⛔ …and the ONLY security-advisories requests any row makes are the report\'s POST, the draft\'s POST to the bare collection — never a GET, the list — and the two PATCHes of one advisory', every.filter((r) => r.path.includes('security-advisories')).map((r) => `${r.op} ${r.verb} ${r.path}`), [`advisory_report POST /repos/${REPO}/security-advisories/reports`, `advisory_create POST /repos/${REPO}/security-advisories`, `advisory_collaborators_add PATCH /repos/${REPO}/security-advisories/${GHSA}`, `advisory_state PATCH /repos/${REPO}/security-advisories/${GHSA}`]); + t('⛔ …and the ONLY security-advisories requests any row makes are the report\'s POST, the draft\'s POST to the bare collection — never a GET, the list — the two PATCHes of one advisory and the read\'s GET of that one advisory', every.filter((r) => r.path.includes('security-advisories')).map((r) => `${r.op} ${r.verb} ${r.path}`), [`advisory_report POST /repos/${REPO}/security-advisories/reports`, `advisory_create POST /repos/${REPO}/security-advisories`, `advisory_collaborators_add PATCH /repos/${REPO}/security-advisories/${GHSA}`, `advisory_state PATCH /repos/${REPO}/security-advisories/${GHSA}`, `advisory_read GET /repos/${REPO}/security-advisories/${GHSA}`]); // ⛔ published: reachable by no row — not as a value, not through any body. const everyValue = OP_NAMES.flatMap((op) => [...OPS[op].required, ...OPS[op].optional] @@ -1711,6 +1776,112 @@ export async function selfTest() { t('⛔ the platform\'s message is quoted only when it is one the relay names; any other — prose among them — is not printed', [advisoryPlatformText('Resource not accessible by integration'), advisoryPlatformText(PROSE), advisoryPlatformText(undefined), ADVISORY_PLATFORM_MESSAGES.includes('Not Found')], [' (the platform: Resource not accessible by integration)', '', '', true]); } + // ── advisory_read: the one row that reads an advisory, sealed ───────────── + battery("advisory_read: ghsa_id and public_key alone — the key its own kind, an X25519 key in base64 SPKI DER; a malformed key or id refused before any request and never echoed; ONE GET of one advisory; a stroke of its own whose token alone carries repository-advisories READ; the seal opening byte for byte for its own read and key alone, the parts inside the runner's caps, the sender gate's two settings and the fences, printing no id, state or field"); + { + const REPO = `${TARGET_OWNER}/objectstack`; + const read = (extra = {}) => ({ op: 'advisory_read', ghsa_id: GHSA, public_key: READER.spki, ...extra }); + const add = { op: 'advisory_collaborators_add', ghsa_id: GHSA, collaborators: ['os-dev-a'] }; + const one = (action) => fixturePayload({ actions: [action] }); + const errorsOf = (action) => validatePayload(one(action)).errors; + const PROSE = 'ADVISORY PROSE MARKER, in the wrong key'; + const KEY_REFUSAL = 'actions[0].public_key must be one X25519 public key'; + const { createPublicKey } = await import('node:crypto'); + const derOf = (raw) => Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), raw]).toString('base64'); + const PREFIX = 'MCowBQYDK2VuAyEA'; + // The row, its fields and its one request. + t('the row: ghsa_id and public_key, nothing optional; it spends repository-advisories at READ (its grant), travels alone, widens nothing else; public_key is its own kind and ghsa_id the existing one', [OPS.advisory_read.required, OPS.advisory_read.optional, OPS.advisory_read.permission, OPS.advisory_read.grant, OPS.advisory_read.alone, OPS.advisory_read.alsoSpends ?? null, OPS.advisory_read.secondRepo ?? null, FIELDS.public_key, FIELDS.ghsa_id], [['ghsa_id', 'public_key'], [], 'repository-advisories', 'read', true, null, null, { kind: 'x25519' }, { kind: 'ghsa' }]); + t('⚖ the sender gate, recorded beside the row for the maintainer to rule on: two settings, collaborator — the card\'s recommendation — standing until that ruling, and no other row carrying one', [Object.keys(ADVISORY_READ_SENDER_GATES), OPS.advisory_read.senderGate, OP_NAMES.filter((op) => OPS[op].senderGate !== undefined)], [['collaborator', 'target-write'], 'collaborator', ['advisory_read']]); + const asked = OPS.advisory_read.requests(read(), REPO); + t("its one request is a GET of one advisory's path — no body, no write — carrying the id and the key to the executor, and reaching no refused family", [asked.length, asked[0].verb, asked[0].path, 'body' in asked[0], asked[0].advisoryRead, ADVISORY_PATH_SHAPE.test(asked[0].path), REFUSED_PATH_FAMILIES.filter((re) => re.test(asked[0].path)).length], [1, 'GET', `/repos/${REPO}/security-advisories/${GHSA}`, false, { ghsaId: GHSA, publicKey: READER.spki }, true, 0]); + // The key: its one spelling, judged before any request. + t('the key: X25519, 60 base64 characters of SPKI DER in one shape — and twenty fresh key pairs, as the seat makes them, each pass the whole judgment', [ADVISORY_READ_KEY_TYPE, ADVISORY_READ_PUBLIC_KEY_CHARS, READER.spki.length, ADVISORY_READ_PUBLIC_KEY_SHAPE.test(READER.spki), READER.spki.startsWith(PREFIX), Array.from({ length: 20 }, () => advisoryReadKeyFailure(keyPair().spki)).filter((why) => why !== null)], ['x25519', 60, 60, true, true, []]); + const accepted = validatePayload(one(read())); + t('accepted: one read with a fresh key, normalised to exactly what was sent', [accepted.ok, accepted.payload?.actions[0]], [true, read()], accepted.errors.join(' | ')); + const ed = generateKeyPairSync('ed25519').publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); + const x448 = generateKeyPairSync('x448').publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); + const pem = READER.publicKey.export({ format: 'pem', type: 'spki' }); + const raw = READER.publicKey.export({ format: 'der', type: 'spki' }).subarray(12); + // The curve's low-order points (RFC 7748 §6.1 names the need to refuse them): 0, 1, the two of order 8, p − 1, p and p + 1. + const lowOrder = ['00', '01', 'e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800', '5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157', 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f'].map((hex) => derOf(Buffer.from(hex.padEnd(64, '0'), 'hex'))); + const malformedKeys = [ + 17, null, true, [READER.spki], { key: READER.spki }, '', 'x', PROSE, READER.spki.slice(0, 59), `${READER.spki}A`, `${READER.spki.slice(0, 59)}A`, ` ${READER.spki.slice(1)}`, `${READER.spki} `, + `${PREFIX}${'-'.repeat(42)}A=`, `${PREFIX}${'A'.repeat(42)}B=`, ed, x448, pem, raw.toString('base64'), raw.toString('base64url'), Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), raw]).toString('hex'), ...lowOrder, + ]; + t('⛔ a malformed public_key is refused before any request, naming the key: not a string (a number, null, a boolean, an array, an object), empty, short, prose, one character short or long, unpadded, padded with a space either side, the base64url alphabet, a spare bit set, an Ed25519 key, an X448 key, a PEM, the raw key in base64 or base64url, the DER in hex, and every low-order point of the curve', malformedKeys.filter((public_key) => !refuses(one(read({ public_key })), KEY_REFUSAL)).map((v) => malformedKeys.indexOf(v)), []); + t("⛔ …each low-order point is refused as one, on the trial derivation — the shape alone would admit it", lowOrder.map((k) => [ADVISORY_READ_PUBLIC_KEY_SHAPE.test(k), advisoryReadKeyFailure(k)]), lowOrder.map(() => [true, 'is a key no secret can be derived with (a low-order point)'])); + const echoes = malformedKeys.filter((v) => typeof v === 'string' && v.length >= 8).filter((v) => errorsOf(read({ public_key: v })).some((e) => e.includes(v) || e.includes(v.slice(-16, -1)) || e.includes(v.slice(16, 32)))); + t('⛔ …and NO refusal echoes the value, in whole or in part — it is described by its type and byte count, and what it failed to be', [echoes.length, errorsOf(read({ public_key: PROSE })), errorsOf(read({ public_key: 17 }))], [0, [`${KEY_REFUSAL}, base64 SPKI DER of exactly 60 characters — got a string of ${Buffer.byteLength(PROSE)} byte(s) that is not 60 characters long; it is refused here, before any request, and never echoed`], [`${KEY_REFUSAL}, base64 SPKI DER of exactly 60 characters — got number that is not a string; it is refused here, before any request, and never echoed`]]); + const malformedIds = ['ghsa-abcd-2345-wxyz', 'GHSA-ABCD-2345-WXYZ', 'GHSA-abcd-2345', `${GHSA}/cve`, `../${GHSA}`, '', 17, null, PROSE]; + t("⛔ a malformed ghsa_id beside a valid key is refused before any request, naming the shape, and never echoed", [malformedIds.filter((ghsa_id) => !refuses(one(read({ ghsa_id })), "actions[0].ghsa_id must be one repository advisory's id")).length, errorsOf(read({ ghsa_id: PROSE })).some((e) => e.includes('ADVISORY PROSE'))], [0, false]); + const takes = `is not a key \`advisory_read\` takes (it takes ghsa_id, public_key)`; + t('⛔ refused BY NAME beside a read: a private key, a key, the fields it would read, the keys of the other advisory rows, a state, a body and an issue — an id and a key, and nothing else', ['private_key', 'key', 'fields', 'summary', 'description', 'vulnerabilities', 'collaborators', 'advisory_state', 'state', 'body', 'issue', 'reveal'].filter((key) => !refuses(one(read({ [key]: key === 'issue' ? 7 : 'x' })), `actions[0].${key} ${takes}`)), []); + t('⛔ refused: either required key missing — a key is never defaulted, nor an id', [refuses(one({ op: 'advisory_read', ghsa_id: GHSA }), 'actions[0].public_key is required'), refuses(one({ op: 'advisory_read', public_key: READER.spki }), 'actions[0].ghsa_id is required')], [true, true]); + // Alone in its stroke, and the grant. + t('⛔ fence alone, refused: a read beside a comment or beside another advisory row, and two reads — naming the action that does not belong and the read grant its token carries', [refuses(fixturePayload({ actions: [read(), { op: 'comment', issue: 7, body: 'x' }] }), 'actions[1] is `comment` in a stroke that carries a `advisory_read` — its token carries repository-advisories read'), refuses(fixturePayload({ actions: [add, read()] }), 'in a stroke that carries a `advisory_collaborators_add`'), refuses(fixturePayload({ actions: [read(), read({ public_key: keyPair().spki })] }), 'a stroke carries exactly ONE `advisory_read` and nothing else'), strokeErrors(REPO, [read()])], [true, true, true, []]); + const readStroke = validatePayload(one(read())).payload; + const addStroke = validatePayload(one(add)).payload; + t("⭐ fence grant: a read's stroke mints the five literal grants plus repository-advisories READ — never write, never actions — for ONE repository, and the GitHub output spells it permission_repository_advisories=read; a writing row's stroke still mints write", [tokenPermissionsOf(readStroke), tokenRepositoriesOf(readStroke), outputLine(readStroke, 'permission_repository_advisories'), outputLine(readStroke, 'permission_actions'), tokenPermissionsOf(addStroke)['repository-advisories']], [{ issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', 'repository-advisories': 'read' }, [REPO], 'permission_repository_advisories=read', 'permission_actions=', 'write']); + t('⛔ the grant ledger: advisory_read alone names a grant, on a stroke-scoped permission, at a level no higher than PERMISSIONS holds — so no row can mint a grant the table does not carry', [OP_NAMES.filter((op) => OPS[op].grant !== undefined), OP_NAMES.filter((op) => OPS[op].grant !== undefined).every((op) => STROKE_SCOPED_PERMISSIONS.includes(OPS[op].permission) && (OPS[op].grant === 'read' || (OPS[op].grant === 'write' && PERMISSIONS[OPS[op].permission] === 'write')))], [['advisory_read'], true]); + // The seal. + const aad = advisoryReadAad({ requestId: 'fw-20261102T000000Z-abc123', action: 1, ghsaId: GHSA }); + const TEXT = JSON.stringify({ ghsa_id: GHSA, summary: `${PROSE} — 漢字, é, 🎉, a quote " and a backslash \\`, description: 'line one\nline two
and a separator', vulnerabilities: [{ package: { ecosystem: 'npm', name: 'p' } }] }); + const sealed = sealAdvisoryRead({ plaintext: TEXT, publicKey: READER.spki, aad }); + const box = Buffer.from(sealed, 'base64url'); + const opened = openAdvisoryRead({ sealed, privateKey: READER.privateKey, aad }); + t('⭐ the seal opens to its plaintext BYTE FOR BYTE — CJK, an accent, an emoji, a quote, a backslash, a newline and a line separator among it — and is the version, the ephemeral key, the nonce, the ciphertext and the tag, in unpadded base64url', [opened === TEXT, Buffer.compare(Buffer.from(opened, 'utf8'), Buffer.from(TEXT, 'utf8')), box.length - Buffer.byteLength(TEXT, 'utf8'), ADVISORY_READ_SEAL_OVERHEAD, box[0], ADVISORY_READ_SEAL_VERSION, /^[A-Za-z0-9_-]+$/.test(sealed)], [true, 0, 61, 61, 1, 1, true]); + const identity = Buffer.from(TEXT, 'utf8').toString('base64url'); + t('⛔ the sealed answer carries no plaintext — not the marker in its characters, nor in its decoded bytes, nor any 12-byte run of the plaintext; positive control: an unsealed encoding of the same text carries it decoded, so the needle finds what is there', [sealed.includes('ADVISORY'), box.includes(Buffer.from('ADVISORY PROSE')), Array.from({ length: Buffer.byteLength(TEXT) - 12 }, (_, i) => Buffer.from(TEXT).subarray(i, i + 12)).some((run) => box.includes(run)), Buffer.from(identity, 'base64url').includes(Buffer.from('ADVISORY PROSE'))], [false, false, false, true]); + const again = Buffer.from(sealAdvisoryRead({ plaintext: TEXT, publicKey: READER.spki, aad }), 'base64url'); + t('⛔ two seals of one text to one key share nothing but the version — a fresh ephemeral key and a fresh nonce each time — and both open', [again.subarray(1, 33).equals(box.subarray(1, 33)), again.subarray(33, 45).equals(box.subarray(33, 45)), again.subarray(45).equals(box.subarray(45)), openAdvisoryRead({ sealed: again.toString('base64url'), privateKey: READER.privateKey, aad }) === TEXT], [false, false, false, true]); + const opens = (s, { key = READER.privateKey, data = aad } = {}) => { + try { + openAdvisoryRead({ sealed: s, privateKey: key, aad: data }); + return 'opened'; + } catch (e) { + return e.message === ADVISORY_READ_OPEN_FAILED ? 'refused' : `threw: ${e.message}`; + } + }; + const flip = (at) => { + const b = Buffer.from(box); + b[at] ^= 1; + return b.toString('base64url'); + }; + const otherRead = (over) => advisoryReadAad({ requestId: 'fw-20261102T000000Z-abc123', action: 1, ghsaId: GHSA, ...over }); + t("⛔ it opens for its own read and key ALONE: another seat's key, another request id, another action, another advisory, a bit flipped in the version, the ephemeral key, the nonce, the ciphertext or the tag, one byte cut, a pad added, a character outside base64url, empty and a non-string are each refused with the one sentence that carries nothing sealed", [opens(sealed, { key: keyPair().privateKey }), opens(sealed, { data: otherRead({ requestId: 'fw-20261102T000000Z-abc124' }) }), opens(sealed, { data: otherRead({ action: 2 }) }), opens(sealed, { data: otherRead({ ghsaId: 'GHSA-wxyz-2345-abcd' }) }), opens(flip(0)), opens(flip(1)), opens(flip(33)), opens(flip(45)), opens(flip(box.length - 1)), opens(box.subarray(0, -1).toString('base64url')), opens(`${sealed}=`), opens(`${sealed.slice(0, -1)}+`), opens(''), opens(null), opens(sealed)], [...Array(14).fill('refused'), 'opened']); + const seals = (publicKey) => { + try { + sealAdvisoryRead({ plaintext: TEXT, publicKey, aad }); + return 'sealed'; + } catch { + return 'threw'; + } + }; + t('⛔ a seal to a low-order point throws — the shared secret is all zeros or underived, never used — and so does a seal to no key at all', [...lowOrder.map(seals), seals(PROSE), seals(READER.spki)], [...lowOrder.map(() => 'threw'), 'threw', 'sealed']); + // What is sealed, and the channel's parts. + const served = { ghsa_id: GHSA, url: 'https://api.github.test/x', html_url: 'https://github.test/x', cve_id: null, summary: PROSE, description: PROSE, severity: 'high', state: 'draft', identifiers: [], author: { login: 'a' }, vulnerabilities: [{ package: { ecosystem: 'npm', name: 'p' }, vulnerable_version_range: '< 1.0.0', patched_versions: null, vulnerable_functions: [] }], cvss: { score: 9.8 }, cwes: [], cwe_ids: ['CWE-79'], credits: [{ login: 'a', type: 'finder' }], credits_detailed: [], collaborating_users: [{ login: 'os-dev-a', url: 'https://api.github.test/users/os-dev-a' }], collaborating_teams: [], private_fork: null }; + const picked = JSON.parse(advisoryReadPlaintext(served)); + t("the plaintext: exactly ADVISORY_READ_FIELDS the answer carries, in that order and as the platform answered them — the ids, the state, the severity, the summary, the description, the vulnerabilities, the weakness ids and the credits; never a URL, the collaborators, the CVSS, a fork or a timestamp; a field the answer lacks is absent, never invented", [Object.keys(picked), ADVISORY_READ_FIELDS.every((f) => JSON.stringify(picked[f]) === JSON.stringify(served[f])), advisoryReadPlaintext({ summary: 'S', html_url: 'h' })], [['ghsa_id', 'cve_id', 'state', 'severity', 'summary', 'description', 'vulnerabilities', 'cwe_ids', 'credits'], true, '{"summary":"S"}']); + const cut = 'abcdefghij'.repeat(1234); + const largest = sealAdvisoryRead({ plaintext: 'x'.repeat(ADVISORY_READ_MAX_SEALED_CHARS * 0.75 - ADVISORY_READ_SEAL_OVERHEAD), publicKey: READER.spki, aad }); + const overOne = sealAdvisoryRead({ plaintext: 'x'.repeat(ADVISORY_READ_MAX_SEALED_CHARS * 0.75 - ADVISORY_READ_SEAL_OVERHEAD + 1), publicKey: READER.spki, aad }); + t("the parts: 4,000 characters each, at most ten — 40,000 sealed characters, so 29,939 bytes of answer — in order and whole; an empty or larger sealed answer has no parts, so nothing is emitted", [ADVISORY_READ_PART_CHARS, ADVISORY_READ_MAX_PARTS, ADVISORY_READ_MAX_SEALED_CHARS, advisoryReadParts('a'.repeat(4000)).length, advisoryReadParts('a'.repeat(4001)).map((p) => p.length), advisoryReadParts(cut).join('') === cut, [largest.length, advisoryReadParts(largest)?.length], [overOne.length, advisoryReadParts(overOne)], advisoryReadParts('')], [4000, 10, 40000, 1, [4000, 1], true, [40000, 10], [40002, null], null]); + // The fences on the read, judged on the executor's own GET. + const answer = (json, status = 200) => ({ status, json }); + const advisory = (extra = {}) => ({ ghsa_id: GHSA, state: 'draft', summary: PROSE, description: PROSE, collaborating_users: [{ login: 'os-dev-a' }], ...extra }); + const fenced = (a, ctx = {}) => advisoryReadFenceFailures({ ghsaId: GHSA, answer: a, sender: 'os-dev-a', gate: 'collaborator', ...ctx }).map((f) => f.fence); + t('the fences are read, advisory and sender; accepted: a collaborator — matched case-insensitively — under the collaborator gate, and anyone the target admits under target-write', [ADVISORY_READ_FENCES.map((f) => f.fence), fenced(answer(advisory())), fenced(answer(advisory()), { sender: 'OS-Dev-A' }), fenced(answer(advisory()), { sender: 'os-dev-b', gate: 'target-write' }), fenced(answer(advisory({ state: 'closed' })))], [['read', 'advisory', 'sender'], [], [], [], []]); + t('⛔ fence sender, collaborator gate: a sender not on collaborating_users, an advisory with none or null, a team the sender belongs to (teams are not read), and no sender at all — each refused at the sender fence alone', [fenced(answer(advisory()), { sender: 'os-dev-b' }), fenced(answer(advisory({ collaborating_users: [] }))), fenced(answer(advisory({ collaborating_users: null }))), fenced(answer(advisory({ collaborating_users: [], collaborating_teams: [{ slug: 'os-dev-a' }] }))), fenced(answer(advisory()), { sender: undefined })], [['sender'], ['sender'], ['sender'], ['sender'], ['sender']]); + t('⛔ an unknown gate fails CLOSED — a misspelt, empty or absent setting refuses even a collaborator', ['anyone', 'Collaborator', '', undefined].map((gate) => fenced(answer(advisory()), { gate })), [['sender'], ['sender'], ['sender'], ['sender']]); + t('⛔ fence read: a 404, a 403, an array and null are never an advisory — the other fences not judged, under either gate', [[answer({ message: 'Not Found' }, 404), answer({ message: 'Forbidden' }, 403), answer([advisory()]), answer(null)].map((a) => fenced(a)), fenced(answer(null, 404), { gate: 'target-write' })], [[['read', 'advisory', 'sender'], ['read', 'advisory', 'sender'], ['read', 'advisory', 'sender'], ['read', 'advisory', 'sender']], ['read', 'advisory']]); + const leaky = advisoryReadFenceFailures({ ghsaId: GHSA, answer: answer(advisory({ ghsa_id: 'GHSA-wxyz-2345-abcd', state: PROSE })), sender: PROSE, gate: 'collaborator' }); + const leakyText = advisoryReadFenceText(leaky); + t("⛔ a read's fence sentence names no advisory id — neither the one asked for nor the one answered — no state and no prose: another advisory is said to be one, and a sender outside a login's shape is a placeholder", [leaky.map((f) => f.fence), [GHSA, 'GHSA-wxyz-2345-abcd', 'ADVISORY PROSE', 'draft'].filter((needle) => leakyText.includes(needle)), leakyText], [['advisory', 'sender'], [], "fence `advisory`: the answer names another advisory, and the relay seals only for the advisory the action names, by its ghsa_id; fence `sender`: (not a login) is not a collaborator on it, and the relay seals only for a sender the advisory's own collaborating_users lists"]); + t("⛔ the call, the landed row and the over-cap sentence print counts and the id's placeholder — never an id", [advisoryReadCall(REPO), advisoryReadLandedText({ plaintextBytes: 812, sealedChars: 1164, parts: 1 }), advisoryReadOverCapText({ plaintextBytes: 31000, sealedChars: 41415 })], [`GET /repos/${REPO}/security-advisories/{ghsa_id}`, "sealed 812 byte(s) of the answer to the seat's one-time key — 1164 character(s) in 1 notice part(s), which only that seat's process opens", "the sealed answer is 41415 character(s) (31000 byte(s) of the answer), over the 40000 the run's notices carry (10 of 4000) — nothing was emitted; ⛔ the text never leaves another way, put the reading to the maintainer"]); + // Positive control for the key refusals: the public half of a real pair parses back to the same key. + t('…positive control: the key a seat sends is the public half of its pair, byte for byte', createPublicKey({ key: Buffer.from(READER.spki, 'base64'), format: 'der', type: 'spki' }).export({ format: 'der', type: 'spki' }).toString('base64'), READER.spki); + } + // ── the floor, BEFORE the verdict ───────────────────────────────────────── const declared = Object.keys(SELF_TEST_BATTERIES); const floor = []; @@ -1747,7 +1918,9 @@ export async function selfTest() { 'each minted for its stroke alone, every other actions path still refused, and an issue-hierarchy link named by two different issue numbers, ' + 'fenced on its two cards and landed only when the parent\'s list reads the child back, the payload read from the event file and never a step\'s env, ' + 'and four advisory rows — a report, a draft, an advisory\'s collaborators and its state, each alone in its stroke — nothing echoed from a refusal, published refused by name and reachable by no row, ' + - 'repository-advisories write minted for that one stroke, the bare collection reached by the draft\'s POST alone, and no other advisory path.', + 'repository-advisories write minted for that one stroke, the bare collection reached by the draft\'s POST alone, and no other advisory path; ' + + 'and one that reads — advisory_read, alone in its stroke, its token at repository-advisories READ, the seat\'s one-time key judged before any request ' + + 'and never echoed, its answer sealed to that key and opening for that read alone.', ); selfTestReachedVerdict = true; return 0; From 80392c9627baba10b3eea268b66fface8faad50a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:23:21 +0000 Subject: [PATCH 2/6] =?UTF-8?q?wip(pm):=20advisory=5Fread=20=E2=80=94=20th?= =?UTF-8?q?e=20executor's=20sealed=20read=20and=20its=20marker=20battery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- scripts/pm/fleet-write/dispatch.mjs | 241 +++++++++++++++++++++++++--- scripts/pm/fleet-write/execute.mjs | 237 ++++++++++++++++++++++++++- 2 files changed, 455 insertions(+), 23 deletions(-) diff --git a/scripts/pm/fleet-write/dispatch.mjs b/scripts/pm/fleet-write/dispatch.mjs index 4a7450605df..864a4c76a9c 100644 --- a/scripts/pm/fleet-write/dispatch.mjs +++ b/scripts/pm/fleet-write/dispatch.mjs @@ -279,12 +279,31 @@ * reading state `draft` — and the run * names that id in an annotation this file reads and hands on as * `result.annotations.advisories` (`--json`: `advisories`), so the seat - * follows with `advisory_collaborators_add` and then reads the report under - * its own account. `advisory_collaborators_add` and `advisory_state` carry + * follows with `advisory_collaborators_add` and then reads the advisory + * with `advisory_read`. `advisory_collaborators_add` and `advisory_state` carry * no body and are confirmed inside the run on their own PATCH answer (every * login sent listed, the state asked for shown) after the executor's fences * on the advisory. A run that failed (a fence refused it, or the answer did - * not show the result) is exit 5 like any failed run, ⛔ never re-sent. + * not show the result) is exit 5 like any failed run, ⛔ never re-sent; + * - `advisory_read` has NO byte read-back either, and needs none: it writes + * nothing. Its outcome is the sealed answer, and reading it back IS the + * outcome. This file makes the read's key pair (`armAdvisoryReads`: one + * X25519 pair per read action, the public half put in the action, the + * private half a `KeyObject` held in this process's memory alone — never + * exported, never written, never printed, and gone when the process exits; + * a `public_key` already in the actions file is refused, because it would + * mean a private key kept somewhere). After a `success` run it reads the + * read's notice parts off the run's annotations (`readRunAnnotations`), + * reassembles them (`assembleAdvisoryReads`: parts 1..n each exactly once) + * and opens them with that key (`openRunAdvisoryReads`, `ops.mjs`'s + * `openAdvisoryRead`) — handed on as `result.reads`. A read whose parts are + * missing, ambiguous, tampered with or sealed for another key or read is + * `unverified`, exit 6, and nothing of it is printed. ⛔ The opened text is + * printed to THIS process's stdout and only with `--reveal`; never to + * stderr, a log, a file or `--json`, which carries its byte count and + * field names alone. A run that failed (a fence refused the read, the + * seal failed or did not fit) is exit 5; a read writes nothing, so it may + * be re-sent with a fresh key. * * ## The started run — the one outcome only the seat can name * @@ -454,7 +473,7 @@ */ import { spawnSync } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; +import { generateKeyPairSync, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -465,7 +484,25 @@ import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, proxyRoute, read import { ROSTER_MARKER } from '../fleet-roster.mjs'; import { classifyHttp } from '../label-write.mjs'; import { DEFAULT_BATCH_GAP_MS, DEFAULT_BATCH_THRESHOLD, EXIT_WRITE_PACE_REFUSED, fmtDuration, isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from '../write-pace.mjs'; -import { CONTAINER_SESSION_ENV, CONTAINER_SESSION_SHAPE, FIELDS, MAX_REQUEST_ID_CHARS, OPS, OP_NAMES, RELAY_EVENT_TYPE, RELAY_REPO, SESSION_ENV, SESSION_SHAPE, TRANSPORTS, TRANSPORT_ENV } from './ops.mjs'; +import { + ADVISORY_READ_KEY_TYPE, + ADVISORY_READ_MAX_PARTS, + ADVISORY_READ_PART_CHARS, + CONTAINER_SESSION_ENV, + CONTAINER_SESSION_SHAPE, + FIELDS, + MAX_REQUEST_ID_CHARS, + OPS, + OP_NAMES, + RELAY_EVENT_TYPE, + RELAY_REPO, + SESSION_ENV, + SESSION_SHAPE, + TRANSPORTS, + TRANSPORT_ENV, + advisoryReadAad, + openAdvisoryRead, +} from './ops.mjs'; import { EVENT_PATH_ENV, refusalText, validatePayload } from './validate.mjs'; const SELF_PATH = fileURLToPath(import.meta.url); @@ -914,6 +951,9 @@ export const RUN_READ_BACK_WINDOW_MS = 60_000; * nothing is `unverified` — an object that was not found was not measured. * The self-test pins that this table names exactly `BODY_OPS`. */ +// ⛔ No locator for `advisory_read`: it has NO byte read-back, because it writes nothing — no body, no object. Its +// read-back is the seat opening the sealed answer the run's notices carry with the key only this process holds +// (`openRunAdvisoryReads`); an answer that does not open is `unverified` (header, "How the callers"). // ⛔ No locator for `advisory_report`, on purpose — nor for `advisory_create`, the same shape: its prose rides `summary` // and `description`, never `body`, so it is not in `BODY_OPS`, and a byte-for-byte read-back cannot apply to it — the seat cannot read a private report before it // is a collaborator, and the executor must not print the answer that holds the stored bytes. Its confirmation is the @@ -1114,6 +1154,133 @@ export function parseRelayAdvisoryAnnotation(message) { return { action: Number(m[1]), op: m[2], ghsaId: m[3] }; } +/** + * The ops whose answer comes back SEALED (`ops.mjs`, "The one row that READS an advisory") — derived from the table: + * the rows that require a `public_key`. The executor emits a read's sealed answer as notice parts, each + * `fleet-write action= op= part=/ sealed=` and nothing else — no id, no state, no field — and the + * seat opens them with the key it holds (`openRunAdvisoryReads`). + */ +export const ADVISORY_READ_OPS = Object.freeze(OP_NAMES.filter((op) => OPS[op].required.includes('public_key'))); + +const RELAY_ADVISORY_READ_SHAPE = new RegExp(`^${RELAY_ANNOTATION_PREFIX} action=([1-9][0-9]*) op=([a-z_]+) part=([1-9][0-9]*)/([1-9][0-9]*) sealed=([A-Za-z0-9_-]{1,${ADVISORY_READ_PART_CHARS}})$`); + +/** The message of one notice part of a read's sealed answer. Pure. */ +export function relayAdvisoryReadAnnotationMessage({ action, op, part, of, sealed }) { + return `${RELAY_ANNOTATION_PREFIX} action=${action} op=${op} part=${part}/${of} sealed=${sealed}`; +} + +/** A read part's `message` as `{ action, op, part, of, sealed }`, or null for anything else. Pure; never throws. */ +export function parseRelayAdvisoryReadAnnotation(message) { + const m = RELAY_ADVISORY_READ_SHAPE.exec(typeof message === 'string' ? message : ''); + if (!m || !ADVISORY_READ_OPS.includes(m[2])) return null; + const part = Number(m[3]); + const of = Number(m[4]); + if (part > of || of > ADVISORY_READ_MAX_PARTS) return null; + return { action: Number(m[1]), op: m[2], part, of, sealed: m[5] }; +} + +/** + * Make each read's key pair, in THIS process (header, "How the callers"): one X25519 pair per `advisory_read` + * action, its public half put in the action as `public_key`, its private half a `KeyObject` kept in the returned + * `keys` map (action index → key) — ⛔ never exported, written or printed; it dies with the process. A `public_key` + * already in an action is REFUSED, never used: a key made elsewhere means a private key kept elsewhere. The value is + * never echoed. Returns `{ actions, keys, refused }`; `actions` is a new array, the file's own untouched. Pure but + * for the key generation, which `generate` stands in for in the self-test. + */ +export function armAdvisoryReads(actions, { generate = () => generateKeyPairSync(ADVISORY_READ_KEY_TYPE) } = {}) { + const keys = new Map(); + const refused = []; + if (!Array.isArray(actions)) return { actions, keys, refused }; + const armed = actions.map((a, i) => { + if (a === null || typeof a !== 'object' || Array.isArray(a) || !ADVISORY_READ_OPS.includes(a.op)) return a; + if (Object.hasOwn(a, 'public_key')) { + refused.push( + `fleet-write/dispatch: REFUSED — actions[${i}].public_key is set in the actions file: an ${a.op}'s key pair is made by this door for that one read, and its private half never leaves this process, so a key from a file (which means a private key kept somewhere) is refused, not used. Drop the key; the door makes one.`, + ); + return a; + } + const pair = generate(); + keys.set(i + 1, pair.privateKey); + return { ...a, public_key: pair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64') }; + }); + return { actions: armed, keys, refused }; +} + +/** + * Each read action's sealed answer, reassembled from the run's parts: `[{ action, op, sealed, parts, why }]` in the + * stroke's order — `sealed` the parts 1..n joined, when every one is there EXACTLY once under one n (a part repeated + * with the same text is the same part); otherwise `sealed` null and `why` naming what is missing or ambiguous. A + * reader never picks between two answers. Pure. + */ +export function assembleAdvisoryReads(payload, parts) { + const actions = Array.isArray(payload?.actions) ? payload.actions : []; + const out = []; + for (const [i, a] of actions.entries()) { + if (!ADVISORY_READ_OPS.includes(a?.op)) continue; + const mine = (Array.isArray(parts) ? parts : []).filter((p) => p.action === i + 1 && p.op === a.op); + const row = { action: i + 1, op: a.op, sealed: null, parts: 0, why: '' }; + if (mine.length === 0) { + out.push({ ...row, why: 'no part of its sealed answer is on the run' }); + continue; + } + const counts = new Set(mine.map((p) => p.of)); + if (counts.size > 1) { + out.push({ ...row, why: `its parts disagree on their count (${[...counts].sort((x, y) => x - y).join(', ')})` }); + continue; + } + const of = mine[0].of; + const byPart = new Map(); + let clash = false; + for (const p of mine) { + if (byPart.has(p.part) && byPart.get(p.part) !== p.sealed) clash = true; + byPart.set(p.part, p.sealed); + } + const missing = Array.from({ length: of }, (_, k) => k + 1).filter((k) => !byPart.has(k)); + if (clash) out.push({ ...row, why: 'two different parts carry one part number' }); + else if (missing.length) out.push({ ...row, why: `part(s) ${missing.join(', ')} of ${of} are not on the run` }); + else out.push({ ...row, sealed: Array.from({ length: of }, (_, k) => byPart.get(k + 1)).join(''), parts: of }); + } + return out; +} + +/** + * Open each read with the key this process made for it (`keys`, from `armAdvisoryReads`): `[{ action, op, state, + * plaintext, bytes, fields, parts, why }]` — `state` `opened` with the plaintext, byte for byte what the executor + * sealed, its UTF-8 byte count and its top-level field names; or `unverified`, `plaintext` null, `why` saying why + * (no key in this process, parts missing, or an answer that does not open — tampered with, or sealed for another + * key or read). ⛔ Nothing here prints; the caller prints the plaintext on `--reveal` alone. Never throws. + */ +export function openRunAdvisoryReads(payload, parts, keys = new Map()) { + return assembleAdvisoryReads(payload, parts).map((r) => { + const shut = (why) => ({ action: r.action, op: r.op, state: 'unverified', plaintext: null, bytes: null, fields: null, parts: r.parts, why }); + if (r.sealed === null) return shut(r.why); + const privateKey = keys.get(r.action); + if (!privateKey) return shut("this process holds no key for it — a read opens only in the process that made its key"); + let plaintext; + try { + plaintext = openAdvisoryRead({ sealed: r.sealed, privateKey, aad: advisoryReadAad({ requestId: payload.request_id, action: r.action, ghsaId: payload.actions[r.action - 1].ghsa_id }) }); + } catch (e) { + return shut(e.message); + } + let fields = null; + try { + const json = JSON.parse(plaintext); + if (json !== null && typeof json === 'object' && !Array.isArray(json)) fields = Object.keys(json); + } catch { + fields = null; + } + return { action: r.action, op: r.op, state: 'opened', plaintext, bytes: Buffer.byteLength(plaintext, 'utf8'), fields, parts: r.parts, why: '' }; + }); +} + +/** The line a read's outcome earns on stderr — counts and field names, ⛔ never the text. Pure. */ +export function advisoryReadLine(row, { runId = '?', reveal = false } = {}) { + if (row.state === 'opened') { + return `fleet-write: run ${runId}'s notices carry action ${row.action} ${row.op}'s sealed answer in ${row.parts} part(s) — opened with this process's one-time key: ${row.bytes} byte(s), fields ${(row.fields ?? []).join(', ') || 'none'}. ${reveal ? 'The text follows on stdout.' : "Not printed: pass --reveal to print it to this process's stdout (never a file)."}`; + } + return `fleet-write: run ${runId}'s sealed answer for action ${row.action} ${row.op} is UNVERIFIED — ${row.why}; ⛔ nothing of it is printed. A read writes nothing, so it may be re-sent with a fresh key; a run whose parts do not open twice goes to the maintainer.`; +} + /** * The parsed advisory rows a stroke may use: a row whose action index holds an action of its op, one id per * action — two different ids for one action are neither, and both are `ignored`. Pure. @@ -1339,9 +1506,10 @@ async function listAll(api, path, t, maxPages = 10) { /** * The relay annotations on a run's check runs — its jobs, then each job's - * check-run annotations, every message through `parseRelayAnnotation` (the + * check-run annotations, every message through `parseRelayAnnotation`, + * `parseRelayAdvisoryAnnotation` and `parseRelayAdvisoryReadAnnotation` (the * rest of the check run's annotations are ignored). Returns `{ state, parsed, - * why }`: `read`, or `unread` naming the call that did not answer, with + * advisories, reads, why }`: `read`, or `unread` naming the call that did not answer, with * whatever was parsed before it. Reads only — never paced; never throws. */ export async function readRunAnnotations(runId, deps = {}) { @@ -1349,34 +1517,40 @@ export async function readRunAnnotations(runId, deps = {}) { const t = { fetch: deps.fetch, token: deps.token }; const said = (r) => `${r.call} -> HTTP ${r.status}${r.detail ? ` (${r.detail})` : ''}`; const jobs = await rest(api, `/repos/${RELAY_REPO}/actions/runs/${runId}/jobs?per_page=100`, {}, t); - if (jobs.status !== 200 || !Array.isArray(jobs.json?.jobs)) return { state: 'unread', parsed: [], advisories: [], why: said(jobs) }; + if (jobs.status !== 200 || !Array.isArray(jobs.json?.jobs)) return { state: 'unread', parsed: [], advisories: [], reads: [], why: said(jobs) }; const parsed = []; const advisories = []; + const reads = []; for (const job of jobs.json.jobs) { const id = checkRunIdOf(job); if (id === null) continue; const r = await rest(api, `/repos/${RELAY_REPO}/check-runs/${id}/annotations?per_page=100`, {}, t); - if (r.status !== 200 || !Array.isArray(r.json)) return { state: 'unread', parsed, advisories, why: said(r) }; + if (r.status !== 200 || !Array.isArray(r.json)) return { state: 'unread', parsed, advisories, reads, why: said(r) }; for (const a of r.json) { const row = parseRelayAnnotation(a?.message); if (row) parsed.push(row); const filed = parseRelayAdvisoryAnnotation(a?.message); if (filed) advisories.push(filed); + const part = parseRelayAdvisoryReadAnnotation(a?.message); + if (part) reads.push(part); } } - return { state: 'read', parsed, advisories, why: '' }; + return { state: 'read', parsed, advisories, reads, why: '' }; } /** * The annotations a stroke's success run carries, matched to its actions and * said: one line per row, per ignored row, per annotated action no row names, * and one when the read did not answer. A stroke with no annotated op reads - * nothing (`state: 'none'`). Returns `{ state, rows, ignored, why }`. + * nothing (`state: 'none'`). Returns `{ state, rows, ignored, advisories, + * reads, why }` — `reads` the parsed parts of a read's sealed answer, never + * logged here: `sendFleetWrite` opens them. */ async function runAnnotationsFor(payload, run, deps) { const annotated = payload.actions.map((a, i) => ({ action: i + 1, op: a.op })).filter((a) => Object.hasOwn(ANNOTATED_OPS, a.op)); const filing = payload.actions.map((a, i) => ({ action: i + 1, op: a.op })).filter((a) => ADVISORY_ANNOTATED_OPS.includes(a.op)); - if (!annotated.length && !filing.length) return { state: 'none', rows: [], ignored: [], advisories: [], why: '' }; + const reading = payload.actions.some((a) => ADVISORY_READ_OPS.includes(a.op)); + if (!annotated.length && !filing.length && !reading) return { state: 'none', rows: [], ignored: [], advisories: [], reads: [], why: '' }; const read = await readRunAnnotations(run.id, deps); const { rows, ignored } = matchRunAnnotations(payload, read.parsed); const filed = matchRunAdvisories(payload, read.advisories ?? []); @@ -1393,7 +1567,7 @@ async function runAnnotationsFor(payload, run, deps) { for (const a of filing) { if (!filed.rows.some((r) => r.action === a.action)) log(`fleet-write: no annotation on run ${run.id} names action ${a.action} ${a.op}'s advisory id — the run's summary row holds it, which a seat container cannot read, and the seat cannot list the repository's advisories: ask the maintainer for the id; ⛔ do not re-send the ${a.op === 'advisory_create' ? 'draft' : 'report'}.`); } - return { state: read.state, rows, ignored, advisories: filed.rows, why: read.why }; + return { state: read.state, rows, ignored, advisories: filed.rows, reads: read.reads ?? [], why: read.why }; } /** @@ -1580,6 +1754,9 @@ export function runReadBackLine(row) { * body could not be read back) · `no-run` · `timeout` · `refused` (the dispatch itself). `readBack` is `readBackStroke`'s * answer, present once the run succeeded, and so is `annotations` — `{ state, rows, ignored, why }`, the run's * annotations matched to the stroke (`state` `none` when it carries no annotated op). Never throws on an HTTP status. + * `reads` is `openRunAdvisoryReads`' answer for an `advisory_read` stroke, opened with `deps.advisoryReadKeys` (the + * keys `armAdvisoryReads` made in this process); a read that does not open makes the state `unverified`. The log + * says a read's counts alone (`advisoryReadLine`); ⛔ the plaintext is in `reads` and nowhere else this prints. */ export async function sendFleetWrite(payload, deps = {}) { const api = deps.api ?? DEFAULT_API; @@ -1646,6 +1823,11 @@ export async function sendFleetWrite(payload, deps = {}) { // ── the run's annotations — the numbers it created or moved, where a seat CAN read them (header) ───── const annotations = await runAnnotationsFor(payload, run, { api, fetch: deps.fetch, token: deps.token, log }); + // ── a read's sealed answer — opened with the key only this process holds; its read-back, and its only one (header) ───── + const reads = openRunAdvisoryReads(payload, annotations.reads ?? [], deps.advisoryReadKeys); + for (const row of reads) log(advisoryReadLine(row, { runId: run.id, reveal: deps.reveal === true })); + const unopened = reads.find((r) => r.state !== 'opened'); + if (unopened) return { ...done, state: 'unverified', ok: false, annotations, reads, detail: `conclusion success, but UNVERIFIED — action ${unopened.action} (${unopened.op}): ${unopened.why}` }; // ── read it back — the run's success is the executor's, not the write's ───── const readBack = await readBackStroke(payload, { dispatchedAt, annotations: annotations.rows }, { api, fetch: deps.fetch, token: deps.token, judge: deps.judge, sleep, log }); @@ -1655,13 +1837,13 @@ export async function sendFleetWrite(payload, deps = {}) { for (const row of runs.rows) log(runReadBackLine(row)); if (readBack.state === 'not-stored') { const first = readBack.rows.find((r) => r.verdict === 'not-stored'); - return { ...done, state: 'failure', ok: false, notStored: true, readBack, runs, annotations, detail: `conclusion success, but NOT STORED — action ${first.action} (${first.op} ${first.where}) first differs from the bytes sent at byte ${first.offset}` }; + return { ...done, state: 'failure', ok: false, notStored: true, readBack, runs, annotations, reads, detail: `conclusion success, but NOT STORED — action ${first.action} (${first.op} ${first.where}) first differs from the bytes sent at byte ${first.offset}` }; } if (readBack.state === 'unverified' || runs.state === 'unverified') { const first = [...readBack.rows, ...runs.rows].find((r) => r.verdict === 'unverified'); - return { ...done, state: 'unverified', ok: false, readBack, runs, annotations, detail: `conclusion success, but UNVERIFIED — action ${first.action} (${first.op}): ${first.why ?? first.cls}` }; + return { ...done, state: 'unverified', ok: false, readBack, runs, annotations, reads, detail: `conclusion success, but UNVERIFIED — action ${first.action} (${first.op}): ${first.why ?? first.cls}` }; } - return { ...done, state: 'success', ok: true, readBack, runs, annotations, detail: `conclusion ${run.conclusion}` }; + return { ...done, state: 'success', ok: true, readBack, runs, annotations, reads, detail: `conclusion ${run.conclusion}` }; } /** The exit a tool takes from a result that is not `success`. */ @@ -3128,7 +3310,7 @@ export async function selfTest() { // --------------------------------------------------------------------------- export function parseArgs(argv) { - const opts = { repo: null, actionsFile: null, session: null, requestId: null, dryRun: false, json: false, selfTest: false, help: false, route: false, errors: [] }; + const opts = { repo: null, actionsFile: null, session: null, requestId: null, dryRun: false, json: false, reveal: false, selfTest: false, help: false, route: false, errors: [] }; const args = [...argv]; const value = (flag) => { const v = args.shift(); @@ -3145,6 +3327,7 @@ export function parseArgs(argv) { else if (a === '--route') opts.route = true; else if (a === '--dry-run') opts.dryRun = true; else if (a === '--json') opts.json = true; + else if (a === '--reveal') opts.reveal = true; else if (a === '--repo') opts.repo = value(a); else if (a === '--actions-file') opts.actionsFile = value(a); else if (a === '--session') opts.session = value(a); @@ -3165,6 +3348,7 @@ const USAGE = [ 'usage:', ' node scripts/pm/fleet-write/dispatch.mjs --repo owner/name --actions-file actions.json [--session session_…] [--request-id id] [--json]', ' node scripts/pm/fleet-write/dispatch.mjs --repo owner/name --actions-file actions.json --dry-run', + ' node scripts/pm/fleet-write/dispatch.mjs --repo owner/name --actions-file read.json [--reveal] # one advisory_read: { op, ghsa_id }', ' node scripts/pm/fleet-write/dispatch.mjs --route # ONE JSON line: the transport this environment resolves to, and why', ' node scripts/pm/fleet-write/dispatch.mjs --self-test', '', @@ -3176,6 +3360,9 @@ const USAGE = [ ' refused (2) before anything is packed; the fleet roster\'s generated body is the one exemption. A pull request\'s', ` issue_patch body may instead end with ${PR_BODY_TRAILER_SHAPE} on its last non-empty line: the target is then`, ' read from the platform, a dry run included — an issue refused (2), an unread target refused (3).', + ' An advisory_read names ghsa_id alone: this process makes its one-time key pair, sends the public half, and opens', + ' the sealed answer with the private half, which never leaves memory. --reveal prints the opened text to stdout, and', + ' nothing else ever prints it; a public_key in the actions file is refused (2).', ` Exits: ${EXIT_OK} run succeeded, every body reads back as sent and every started run is listed · ${EXIT_USAGE} usage / payload refused · ${EXIT_PREREQUISITE} prerequisite ·`, ` ${EXIT_NOT_STORED} NOT STORED (the run succeeded, a body reads back otherwise — go READ, never retry) · ${EXIT_PLATFORM_REFUSAL} dispatch refused or run failed ·`, ` ${EXIT_UNCONFIRMED} UNCONFIRMED (no run, no completion within the ceiling, a body not read back, or a started run not listed) · ${EXIT_WRITE_PACE_REFUSED} throttle refused`, @@ -3246,6 +3433,20 @@ export async function main(argv, deps = {}) { console.error('fleet-write/dispatch: nothing was sent.'); return EXIT_USAGE; } + // A read's one-time key pair, made HERE, in memory (header, "How the callers"): the public half goes in the action; + // the private half stays in `armed.keys` and dies with this process. A process re-executed through the proxy below + // makes its own; this one's is discarded with it. + const armed = armAdvisoryReads(actions); + if (armed.refused.length > 0) { + for (const line of armed.refused) console.error(line); + console.error('fleet-write/dispatch: nothing was sent.'); + return EXIT_USAGE; + } + if (opts.reveal && armed.keys.size === 0) { + console.error("fleet-write/dispatch: --reveal prints an advisory_read's opened answer, and this stroke carries no advisory_read. Nothing was sent."); + return EXIT_USAGE; + } + actions = armed.actions; const session = opts.session ?? sessionFrom(env); if (!session || !SESSION_SHAPE.test(session)) { console.error( @@ -3301,7 +3502,9 @@ export async function main(argv, deps = {}) { if (rearmed !== null) return rearmed; } const tool = 'scripts/pm/fleet-write/dispatch.mjs'; - const result = await sendFleetWrite(packed.payload, { token, ...(deps.send ?? {}) }); + const result = await sendFleetWrite(packed.payload, { token, advisoryReadKeys: armed.keys, reveal: opts.reveal, ...(deps.send ?? {}) }); + // ⛔ The opened text reaches THIS process's stdout, and only on --reveal — never stderr, a log, a file or --json. + if (opts.reveal) for (const r of result.reads ?? []) if (r.state === 'opened') console.log(r.plaintext); if (!result.ok && (result.state === 'no-run' || result.state === 'timeout')) console.error(unconfirmedText(result, tool)); if (result.notStored) console.error(notStoredText(result, tool)); if (result.state === 'unverified') console.error(unverifiedText(result, tool)); @@ -3317,6 +3520,8 @@ export async function main(argv, deps = {}) { read_back: (result.readBack?.rows ?? []).map((r) => ({ action: r.action, op: r.op, where: r.where, verdict: r.verdict, class: r.cls, first_difference_byte: r.offset ?? null, sent_bytes: r.sentBytes ?? null, stored_bytes: r.storedBytes ?? null, found_by: r.foundBy ?? null, assignees: r.assignees ?? null, assignees_missing: r.assigneesMissing ?? null })), annotations: (result.annotations?.rows ?? []).map((a) => ({ action: a.action, op: a.op, number: a.number, url: a.url })), advisories: (result.annotations?.advisories ?? []).map((a) => ({ action: a.action, op: a.op, ghsa_id: a.ghsaId })), + // A read's outcome, its counts and field names alone — ⛔ never its text, which --reveal alone prints, above. + advisory_reads: (result.reads ?? []).map((r) => ({ action: r.action, op: r.op, state: r.state, bytes: r.bytes, fields: r.fields, parts: r.parts, why: r.why || null })), runs: (result.runs?.rows ?? []).map((r) => ({ action: r.action, op: r.op, workflow: r.workflow, verdict: r.verdict, class: r.cls, candidates: r.candidates ?? 0, run: r.run ? { id: r.run.id, url: r.run.url, status: r.run.status, conclusion: r.run.conclusion, created_at: r.run.created_at, actor: r.run.actor } : null })), }), ); diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 06a1b58e8a2..bd28eaf04ef 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -241,6 +241,35 @@ * landed this. The GET is an unpaced read; the POST and the PATCH are paced * like every write verb. * + * ## The read — `advisory_read`, sealed before anything leaves (`advisoryReadAction`) + * + * ONE `GET /repos/{repo}/security-advisories/{ghsa_id}`, nothing written. The + * executor judges `ADVISORY_READ_FENCES` on that answer — a 200 advisory, the + * one the action names, and a sender the row's `senderGate` admits (the + * default, `collaborator`: the sender on the advisory's own + * `collaborating_users`; `ops.mjs` records the ruling the maintainer owes) — + * and on any failure seals nothing and emits nothing: a FAILED action, exit 5, + * whose row names the fences and the status alone. Otherwise it seals + * `ADVISORY_READ_FIELDS` of the answer to the action's `public_key` + * (`advisoryReadPlaintext`, `sealAdvisoryRead`, bound to the request id, the + * action's index and the id) and emits the sealed answer as notice parts + * (`advisoryReadParts`, `relayAdvisoryReadAnnotationMessage`) — the ONE + * channel back, the one a seat container can read. A seal that throws is a + * FAILED action with `ADVISORY_READ_SEAL_FAILED`, ⛔ never the exception's + * message, and no part; one too large for the run's ten notices is a FAILED + * action naming its counts, and no part. It LANDS only on a 200 whose fences + * hold and whose seal succeeded and fit. + * + * ⛔ What this path prints — the row, the log line, the step summary — is the + * HTTP status, the counts and `advisoryReadCall`, the call with the id left + * out. Never the id, the state, the platform's message unless + * `ADVISORY_PLATFORM_MESSAGES` names it, or any field of the answer; the + * notices carry the ciphertext alone. The self-test serves the marker in the + * summary, the description and the vulnerabilities and finds it in no line, + * row, summary or annotation, nor in the sealed answer's bytes, and opens the + * parts back to the GET's fields byte for byte; the ablation is recorded in + * the pull request that landed this. + * * ## The assignees and labels legs — `pr_create` with `assignees` / `labels`, the requests that read an earlier answer * * The op table spells each leg's path with a placeholder and `fromAnswer: @@ -338,7 +367,8 @@ * 5 an action FAILED; the summary names it and which earlier ones landed — * a `run_rerun_failed` refused at a fence among them, its POST not sent, * and an `advisory_collaborators_add` or `advisory_state` refused at a - * fence, its PATCH not sent. + * fence, its PATCH not sent, and an `advisory_read` refused at a fence, + * whose seal failed or did not fit — nothing of it emitted. * 10 the write throttle refused (its prescription was printed), or a stroke's * rule ⑤ batch could not be recorded; zero writes in that second case. */ @@ -356,11 +386,14 @@ import { classifyHttp } from '../label-write.mjs'; import { announceBatch, EXIT_WRITE_PACE_REFUSED, fmtDuration, isWriteMethod, noteResponse, paceFilePath, paceWrite, releaseWriteLease, unrecordableText } from '../write-pace.mjs'; import { ADVISORY_ANNOTATED_OPS, + ADVISORY_READ_OPS, ANNOTATED_OPS, parseRelayAdvisoryAnnotation, + parseRelayAdvisoryReadAnnotation, parseRelayAnnotation, RELAY_ANNOTATION_PREFIX, relayAdvisoryAnnotationMessage, + relayAdvisoryReadAnnotationMessage, relayAnnotationMessage, strokeBudgetRefusal, strokePacing, @@ -368,6 +401,19 @@ import { import { ADVISORY_ACCESS_REMEDY, ADVISORY_OPEN_STATES, + ADVISORY_READ_ACCESS_REMEDY, + ADVISORY_READ_FIELDS, + ADVISORY_READ_SEAL_FAILED, + advisoryReadAad, + advisoryReadCall, + advisoryReadFenceFailures, + advisoryReadFenceText, + advisoryReadLandedText, + advisoryReadOverCapText, + advisoryReadParts, + advisoryReadPlaintext, + openAdvisoryRead, + sealAdvisoryRead, ADVISORY_TRANSITIONS, GHSA_ID_SHAPE, advisoryCollaboratorLogins, @@ -867,6 +913,39 @@ export function confirmAdvisory(req, answer, pre) { return { ok: true, why: '', result: advisoryLandedText({ ghsaId, state: answer.json.state ?? pre.state, logins: v.listed, added: pre.plan.missing }) }; } +/** + * The read (header: "The read"): its GET, its fences, the seal and the parts. Returns `{ ok, status, result, why, + * notes }` — `notes` the notice parts to emit, `{ action, op, part, of, sealed }` each, on a landing alone; `result` + * the row's text; `why` the failure's. ⛔ No string it returns carries the id, the state or any field of the answer: + * the fences speak through `advisoryReadFenceText`, a refusal's message through `advisoryPlatformText`, a failed seal + * through `ADVISORY_READ_SEAL_FAILED` — never the exception. `seal` and `senderGate` are the self-test's doors (a seal + * that throws, the other gate); the CLI passes neither. Never throws. + */ +export async function advisoryReadAction(req, { payload, index, sender, api, t, seal = sealAdvisoryRead, senderGate = OPS.advisory_read.senderGate }) { + const { ghsaId, publicKey } = req.advisoryRead; + const r = await rest(api, req.path, { method: 'GET' }, t); + const failures = advisoryReadFenceFailures({ ghsaId, answer: r, sender, gate: senderGate }); + if (failures.length) { + const refused = !(r.status >= 200 && r.status < 300); + const why = `REFUSED at the relay's fence on the advisory the action names, nothing sealed or emitted — ${advisoryReadFenceText(failures)}${refused ? `${advisoryPlatformText(r.detail)}; ${ADVISORY_READ_ACCESS_REMEDY}` : ''}`; + return { ok: false, status: r.status, why, notes: [] }; + } + const plaintext = advisoryReadPlaintext(r.json); + let sealed; + try { + sealed = seal({ plaintext, publicKey, aad: advisoryReadAad({ requestId: payload.request_id, action: index, ghsaId }) }); + } catch { + return { ok: false, status: r.status, why: ADVISORY_READ_SEAL_FAILED, notes: [] }; + } + const plaintextBytes = Buffer.byteLength(plaintext, 'utf8'); + const sealedChars = typeof sealed === 'string' ? sealed.length : 0; + if (typeof sealed !== 'string' || !/^[A-Za-z0-9_-]+$/.test(sealed)) return { ok: false, status: r.status, why: ADVISORY_READ_SEAL_FAILED, notes: [] }; + const parts = advisoryReadParts(sealed); + if (!parts) return { ok: false, status: r.status, why: advisoryReadOverCapText({ plaintextBytes, sealedChars }), notes: [] }; + const notes = parts.map((chunk, k) => ({ action: index, op: 'advisory_read', part: k + 1, of: parts.length, sealed: chunk })); + return { ok: true, status: r.status, result: advisoryReadLandedText({ plaintextBytes, sealedChars, parts: parts.length }), notes }; +} + /** The one thing a seat needs from an answer: the id / number / url of what was written — or that a bodiless 204 accepted it. */ export function resultOf(req, json, status = null) { if (status === 204 && (json === null || json === undefined)) return 'accepted (HTTP 204, no body)'; @@ -943,7 +1022,8 @@ export function annotationFor({ action, op, req, json, payload, landed = null }) /** The workflow command that turns a note into a `notice` annotation on this job's check run. Pure. */ export function annotationCommand(note) { - const message = typeof note.ghsaId === 'string' ? relayAdvisoryAnnotationMessage(note) : relayAnnotationMessage(note); + // A read's part carries its sealed chunk and nothing else (header: "The read"); a filed advisory its id; a card its number. + const message = typeof note.sealed === 'string' ? relayAdvisoryReadAnnotationMessage(note) : typeof note.ghsaId === 'string' ? relayAdvisoryAnnotationMessage(note) : relayAnnotationMessage(note); return `::notice title=${escapeCommandProperty(`${RELAY_ANNOTATION_PREFIX} ${note.op}`)}::${escapeCommandData(message)}`; } @@ -1234,6 +1314,24 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF log(` ✓ action ${i + 1} ${action.op}: ${call} -> not sent · ${rows[rows.length - 1].result}`); continue; } + // The read (header): its GET fenced, its answer sealed to the seat's key and emitted as notice parts — nothing else + // of it printed, the id included: its row's call is the path with the id left out. + if (req.advisoryRead) { + const call = advisoryReadCall(payload.repo); + const read = await advisoryReadAction(req, { payload, index: i + 1, sender, api, t, ...(deps.advisoryRead ?? {}) }); + rows.push({ action: i + 1, op: action.op, call, status: read.status, result: read.ok ? scrub(read.result, [token]) : `FAILED: ${scrub(read.why, [token])}` }); + if (!read.ok) { + stoppedAt = { action: i + 1, op: action.op, why: scrub(read.why, [token]) }; + log(` ✗ action ${i + 1} ${action.op}: ${call} -> ${stoppedAt.why}`); + break; + } + log(` ✓ action ${i + 1} ${action.op}: ${call} -> HTTP ${read.status} · ${rows[rows.length - 1].result}`); + for (const note of read.notes) { + annotations.push(note); + log(annotationCommand(note)); + } + continue; + } // The advisory PATCHes (header): the advisory's fences, judged HERE on its own GET before the PATCH — refused, NOT SENT. const adv = req.advisoryCollaborators || req.advisoryState ? await advisoryPreflight(req, payload, api, t) : null; if (adv?.fail) { @@ -1354,10 +1452,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the run_rerun_failed: the five fences judged HERE on the run and its pull, read immediately before ONE bodiless POST — the pull's current head by its own GET, never the run's embedded copy, and a run naming no pull refused by name; each refused with the POST not sent and accepted on the measured run; a run, pull, baseline or fence that fails is zero writes; the 201 confirmed only by reading attempt 2 back with its failed jobs queued or in progress, naming their job ids; attempt 2 never seen, a failed job completed, or a job list unread is FAILED; no annotation": 21, "the hierarchy link: sub_issue_add and sub_issue_remove read the parent's and the child's own answers, then the child's parent (add) or the parent's whole list (remove), immediately before ONE write whose body is the child's REST id from its own answer — never its number, never replace_parent; a pull, a transferred or unread card, a child under another parent, one the parent does not list, or a read that is neither answer is zero writes; a child already under this parent is a no-op landed on the list; the answer confirmed only when the parent's list reads the child back (or no longer does) inside the window; a refused write, a list that never settles or never reads whole is FAILED; no annotation, and the rows ride a stroke beside other ops": 20, "the advisory rows: advisory_report and advisory_create are ONE POST of their body each, landed on a 2xx carrying an id — a draft's reading state draft — that the row and a notice annotation name; advisory_collaborators_add and advisory_state read the advisory first and send ONE PATCH whose body is the executor's — every existing login kept with the missing ones added, an all-present list a no-op, or the one state a legal transition reaches — or nothing at all on a fence: an unread, other or wrong-state advisory or an illegal move; each lands only when its answer shows the result; ⛔ the marker in a report's or a draft's summary, description and packages and in every advisory answer's summary, description and vulnerabilities reaches no log line, row, step summary or annotation": 27, + "advisory_read: ONE GET after the sender gate and nothing written; the read, advisory and sender fences judged on that answer — collaborator by default, target-write by switch, an unknown gate closed — then the answer sealed to the seat's key and emitted as notice parts the reader parses back, opening byte for byte for that key and read alone; a failed or unsealed seal and an over-cap answer FAILED with nothing emitted; ⛔ the marker in the summary, description and vulnerabilities, and the advisory's id, reach no log line, row, step summary or annotation, and the sealed answer alone carries no marker": 16, }); // Raised 17 → 18 with the governed-review follow-up battery, 18 → 19 with the run_rerun_failed battery, 19 → 20 with the -// hierarchy link battery, 20 → 21 with the advisory rows' battery: one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 21; +// hierarchy link battery, 20 → 21 with the advisory rows' battery, 21 → 22 with advisory_read's: one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 22; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -2366,6 +2465,132 @@ export async function selfTest() { t('⛔ …and the real processes, reading the payload from an event file as the runner does: the validator accepting and refusing a report and the executor stopping at its prerequisite and its refusal print the marker NOWHERE — stdout, stderr, GITHUB_OUTPUT or the step summary', cliRuns.map((r) => [r.status, r.text.includes('ADVISORY MARKER')]), [[EXIT_OK, false], [EXIT_REFUSED, false], [EXIT_PREREQUISITE, false], [EXIT_REFUSED, false]], cliRuns.map((r) => r.text.slice(0, 200)).join(' | ')); } + // ── advisory_read ─────────────────────────────────────────────────────── + battery("advisory_read: ONE GET after the sender gate and nothing written; the read, advisory and sender fences judged on that answer — collaborator by default, target-write by switch, an unknown gate closed — then the answer sealed to the seat's key and emitted as notice parts the reader parses back, opening byte for byte for that key and read alone; a failed or unsealed seal and an over-cap answer FAILED with nothing emitted; ⛔ the marker in the summary, description and vulnerabilities, and the advisory's id, reach no log line, row, step summary or annotation, and the sealed answer alone carries no marker"); + { + const { generateKeyPairSync } = await import('node:crypto'); + // Made-up ids: the fixture names no real advisory. + const GHSA = 'GHSA-abcd-2345-wxyz'; + const OTHER = 'GHSA-wxyz-2345-abcd'; + // Spaces and punctuation: prose, never an id's, a state's or a login's shape. + const MARKER = 'ADVISORY MARKER: how the flaw is reached, never to be logged'; + const ADV = `/repos/${REPO}/security-advisories/${GHSA}`; + const A_GET = `GET ${ADV}`; + const CALL = `GET /repos/${REPO}/security-advisories/{ghsa_id}`; + const pair = () => { + const k = generateKeyPairSync('x25519'); + return { privateKey: k.privateKey, spki: k.publicKey.export({ format: 'der', type: 'spki' }).toString('base64') }; + }; + const SEAT = pair(); + const read = (extra = {}) => ({ op: 'advisory_read', ghsa_id: GHSA, public_key: SEAT.spki, ...extra }); + // An advisory answer carrying the marker in its summary, description and vulnerabilities and in every other field + // but its id and its state — the sender on its collaborators, so the default gate admits the read. + const advisory = (extra = {}, status = 200) => ({ + status, + json: { + ghsa_id: GHSA, cve_id: MARKER, url: `https://api.github.test${ADV}?q=${MARKER}`, html_url: `https://github.test/${REPO}/security/advisories/${GHSA}#${MARKER}`, + summary: `${MARKER} — the summary, 漢字 and 🎉`, description: `${MARKER} — the description\nline two, a quote " and a backslash \\`, severity: 'high', state: 'draft', message: MARKER, + author: { login: 'os-dev-z', name: MARKER }, identifiers: [{ type: 'GHSA', value: GHSA }, { type: 'CVE', value: MARKER }], + vulnerabilities: [{ package: { ecosystem: 'npm', name: MARKER }, vulnerable_version_range: MARKER, patched_versions: MARKER, vulnerable_functions: [MARKER] }], + cvss: { vector_string: MARKER, score: 9.8 }, cwes: [{ cwe_id: 'CWE-79', name: MARKER }], cwe_ids: ['CWE-79'], credits: [{ login: 'os-dev-z', type: MARKER }], credits_detailed: [{ user: { login: 'os-dev-z', name: MARKER }, type: MARKER }], + collaborating_users: [{ login: 'OS-Support-AI', name: MARKER, bio: MARKER }], collaborating_teams: [{ name: MARKER, slug: MARKER }], private_fork: { full_name: MARKER }, + ...extra, + }, + }); + const sealedOf = (r) => (r.annotations ?? []).filter((n) => n.op === 'advisory_read').sort((a, b) => a.part - b.part).map((n) => n.sealed).join(''); + const opens = (r, { key = SEAT.privateKey, action = 1, ghsaId = GHSA } = {}) => { + try { + return openAdvisoryRead({ sealed: sealedOf(r), privateKey: key, aad: advisoryReadAad({ requestId: 'fw-test-1', action, ghsaId }) }); + } catch (e) { + return `refused: ${e.message}`; + } + }; + const picked = (json) => JSON.stringify(Object.fromEntries(ADVISORY_READ_FIELDS.filter((f) => Object.hasOwn(json, f)).map((f) => [f, json[f]]))); + const notices = (r) => r.logs.filter((l) => l.startsWith('::notice ')); + // Every run of this battery feeds the sweep at its end, with its pace log captured. + const swept = []; + const readRun = async (actions, answers, { sender, ...extra } = {}) => { + const paceLines = []; + const file = join(dir, `pace-read-${paceCase++}.jsonl`); + const r = await run(base(actions), answers, { file, pace: { ...paceFor(file), log: (l) => paceLines.push(l) }, ...(sender ? { sender } : {}), ...extra }); + swept.push({ r, paceLines, answers }); + return r; + }; + // ⭐ Landed. + const served = advisory(); + const landed = await readRun([read()], { ...allowed, [A_GET]: served }); + t('⭐ a read is ONE GET of the advisory under the App token after the sender gate, ZERO writes, exit 0 — and its row prints the call with the id LEFT OUT, the status and the counts', [landed.exit, landed.seen.map((s) => s.call), landed.seen.map((s) => s.auth), writes(landed.seen).length, landed.rows.map((x) => [x.op, x.call, x.status]), /^sealed \d+ byte\(s\) of the answer to the seat's one-time key — \d+ character\(s\) in 1 notice part\(s\), which only that seat's process opens$/.test(landed.rows[0]?.result ?? '')], [EXIT_OK, [PERM, A_GET], [`Bearer ${TOKEN}`, `Bearer ${TOKEN}`], 0, [['advisory_read', CALL, 200]], true]); + const opened = opens(landed); + t('⭐ the seat\'s key opens the parts to the GET answer BYTE FOR BYTE — the summary, the description and the vulnerabilities as the platform answered them, CJK, an emoji, a newline, a quote and a backslash among them', [opened === picked(served.json), Buffer.compare(Buffer.from(opened, 'utf8'), Buffer.from(picked(served.json), 'utf8')), ['summary', 'description', 'vulnerabilities'].map((f) => JSON.stringify(JSON.parse(opened)[f]) === JSON.stringify(served.json[f])), Object.keys(JSON.parse(opened))], [true, 0, [true, true, true], [...ADVISORY_READ_FIELDS]]); + const parsedBack = notices(landed).map((l) => parseRelayAdvisoryReadAnnotation(l.slice(l.indexOf('::', 2) + 2))); + t("the parts are notice commands titled `fleet-write advisory_read`, the reader's own parser reads back exactly what was emitted, and each message is inside the runner's 4,096-character cut", [notices(landed).map((l) => l.slice(0, l.indexOf('::', 2) + 2)), parsedBack, notices(landed).every((l) => l.length - l.indexOf('::', 2) - 2 <= 4096), ADVISORY_READ_OPS], [['::notice title=fleet-write advisory_read::'], landed.annotations, true, ['advisory_read']]); + const sealedText = sealedOf(landed); + t('⛔ the sealed answer ALONE carries none of the marker — not in its characters, not in its decoded bytes — though the text it opens to does (the positive control)', [sealedText.includes('ADVISORY'), Buffer.from(sealedText, 'base64url').includes(Buffer.from('ADVISORY MARKER')), opened.includes('ADVISORY MARKER')], [false, false, true]); + t("⛔ it opens for the seat's key and THIS read alone: another key pair, another action index and another advisory id are each refused", [opens(landed, { key: pair().privateKey }), opens(landed, { action: 2 }), opens(landed, { ghsaId: OTHER })].map((x) => x.startsWith('refused: ')), [true, true, true]); + // Many parts. + const long = advisory({ description: `${MARKER} ${'the long description, line by line.\n'.repeat(600)}` }); + const many = await readRun([read()], { ...allowed, [A_GET]: long }); + t('a long answer is cut into its parts, in order, each part numbered k/n with one n, every message inside the cut, and still opens byte for byte', [many.exit, many.annotations.length > 1, many.annotations.map((n) => `${n.part}/${n.of}`).join(' ') === many.annotations.map((_, k) => `${k + 1}/${many.annotations.length}`).join(' '), notices(many).every((l) => l.length - l.indexOf('::', 2) - 2 <= 4096), opens(many) === picked(long.json)], [EXIT_OK, true, true, true, true]); + // ⛔ The sender gate. + const PERM_B = `GET /repos/${REPO}/collaborators/os-dev-b/permission`; + const writerB = { [PERM_B]: { status: 200, json: { permission: 'write', role_name: 'write', user: { login: 'os-dev-b' } } } }; + const stranger = await readRun([read()], { ...writerB, [A_GET]: advisory() }, { sender: 'os-dev-b' }); + t('⛔ the default gate is collaborator: a sender with write on the target who is NOT on the advisory\'s collaborating_users is refused after the ONE GET — nothing sealed, no part, exit 5, the row naming the sender fence', [stranger.exit, stranger.seen.map((s) => s.call), stranger.annotations.length, notices(stranger).length, stranger.rows[0]?.status, stranger.rows[0]?.result], [EXIT_ACTION_FAILED, [PERM_B, A_GET], 0, 0, 200, "FAILED: REFUSED at the relay's fence on the advisory the action names, nothing sealed or emitted — fence `sender`: os-dev-b is not a collaborator on it, and the relay seals only for a sender the advisory's own collaborating_users lists"]); + const switched = await readRun([read()], { ...writerB, [A_GET]: advisory() }, { sender: 'os-dev-b', advisoryRead: { senderGate: 'target-write' } }); + const unknown = await readRun([read()], { ...allowed, [A_GET]: advisory() }, { advisoryRead: { senderGate: 'anyone' } }); + t('⚖ switched to target-write — the other setting the maintainer may rule for — the same sender is sealed for; an unknown setting fails CLOSED, refusing even a collaborator', [switched.exit, switched.annotations.length > 0, unknown.exit, unknown.annotations.length, unknown.rows[0]?.result.includes('fence `sender`: the row names no sender gate the table knows')], [EXIT_OK, true, EXIT_ACTION_FAILED, 0, true]); + // ⛔ The read and advisory fences. + const forbidden = await readRun([read()], { ...allowed, [A_GET]: { status: 403, json: { message: 'Resource not accessible by integration', documentation_url: MARKER } } }); + const missing = await readRun([read()], { ...allowed, [A_GET]: { status: 404, json: { message: MARKER } } }); + const elsewhere = await readRun([read()], { ...allowed, [A_GET]: advisory({ ghsa_id: OTHER }) }); + t('⛔ fence read and advisory: a 403 quoting the one message the relay knows plus the read remedy, a 404 whose message is prose (not quoted), and an answer naming another advisory — each FAILED with nothing sealed, no part, and neither id in its row', [[forbidden, missing, elsewhere].map((r) => [r.exit, r.annotations.length, r.rows[0]?.status, [GHSA, OTHER].some((id) => JSON.stringify(r.rows).includes(id))]), forbidden.rows[0]?.result.includes('(the platform: Resource not accessible by integration); the relay token carries repository-advisories READ for this stroke alone'), missing.rows[0]?.result.includes('the platform'), elsewhere.rows[0]?.result.includes('fence `advisory`: the answer names another advisory')], [[[EXIT_ACTION_FAILED, 0, 403, false], [EXIT_ACTION_FAILED, 0, 404, false], [EXIT_ACTION_FAILED, 0, 200, false]], true, false, true]); + // ⛔ A failed seal, and one too large. + const thrown = await readRun([read()], { ...allowed, [A_GET]: advisory() }, { advisoryRead: { seal: () => { throw new Error(`seal blew up holding ${MARKER}`); } } }); + const unsealed = await readRun([read()], { ...allowed, [A_GET]: advisory() }, { advisoryRead: { seal: ({ plaintext }) => plaintext } }); + t("⛔ a failed encryption sends NOTHING back and fails: a seal that throws — its message carrying the marker — and one that hands back the plaintext unsealed are each FAILED with the one fixed sentence, no part and no notice, the exception's message printed nowhere", [thrown, unsealed].map((r) => [r.exit, r.annotations.length, notices(r).length, r.rows[0]?.result]), [thrown, unsealed].map(() => [EXIT_ACTION_FAILED, 0, 0, `FAILED: ${ADVISORY_READ_SEAL_FAILED}`])); + const huge = advisory({ description: `${MARKER} ${'x'.repeat(31_000)}` }); + const over = await readRun([read()], { ...allowed, [A_GET]: huge }); + t('⛔ an answer too large for the run\'s ten notices is FAILED naming its counts alone — nothing emitted', [over.exit, over.annotations.length, notices(over).length, /^FAILED: the sealed answer is \d+ character\(s\) \(\d+ byte\(s\) of the answer\), over the 40000 the run's notices carry \(10 of 4000\) — nothing was emitted/.test(over.rows[0]?.result ?? '')], [EXIT_ACTION_FAILED, 0, 0, true]); + // ⛔ Refused before any request. + const gated = []; + for (const actions of [[read({ public_key: MARKER })], [read({ public_key: `${SEAT.spki.slice(0, 59)}A` })], [read({ ghsa_id: `${GHSA}/cve` })], [read({ summary: MARKER })], [read(), { op: 'comment', issue: 1, body: 'x' }], [read(), read({ public_key: pair().spki })], [{ op: 'advisory_read', ghsa_id: GHSA }]]) { + const r = await readRun(actions, { ...allowed, [A_GET]: advisory() }); + gated.push([r.exit, r.seen.length]); + } + t('⛔ refused before ANY request, the sender gate included: a key that is prose or one character off, a malformed id, a field it would read, a read beside a comment, two reads, and a read with no key — exit 2', gated, gated.map(() => [EXIT_REFUSED, 0])); + // ⛔ The sweep: every run above, every channel this file emits on — no marker, and no advisory id. + const emitted = swept.map(({ r, paceLines }) => [...(r.logs ?? []), ...(r.lines ?? []), r.summary ?? '', JSON.stringify(r.rows ?? []), ...(r.annotations ?? []).map((n) => relayAdvisoryReadAnnotationMessage(n)), ...paceLines].join('\n')); + const leaks = emitted.map((text, i) => (text.includes('ADVISORY MARKER') ? i : null)).filter((i) => i !== null); + t('⛔ THE MARKER TEST: across every run of this battery — landed, long, refused at the sender, read and advisory fences, the switched and unknown gates, a seal that throws, one that does not seal, one too large, refused payloads — the marker served in the summary, the description and the vulnerabilities (and every other field) reaches NO log line, row, step summary, annotation or pace-log line', [swept.length, leaks], [18, []], leaks.map((i) => emitted[i].split('\n').find((l) => l.includes('ADVISORY MARKER'))).join(' | ')); + const named = emitted.map((text, i) => ([GHSA, OTHER].some((id) => text.includes(id)) ? i : null)).filter((i) => i !== null); + t("⛔ …and NO advisory id: neither the one asked for nor the one answered appears on any line, row, summary or annotation of any run — the call's placeholder stands where every other row prints its path", named, [], named.map((i) => emitted[i].split('\n').find((l) => l.includes(GHSA) || l.includes(OTHER))).join(' | ')); + t('…positive control: every answer served carried the marker and the id, the request carried the id to the platform — where it belongs — and the generic row printer, given the same answer, WOULD print both', [swept.every(({ answers }) => JSON.stringify(answers).includes('ADVISORY MARKER') && JSON.stringify(answers).includes(GHSA)), landed.seen.some((s) => s.call === A_GET), resultOf({}, served.json).includes('ADVISORY MARKER') && resultOf({}, served.json).includes(GHSA)], [true, true, true]); + // The real processes, the payload from an event file as the runner reads it, every byte of their output read. + const readEvent = (name, payload) => { + const f = join(dir, name); + writeFileSync(f, JSON.stringify({ action: 'fleet-write', client_payload: payload }), 'utf8'); + return f; + }; + const VALID = readEvent('event-read.json', base([read()])); + const REFUSED = readEvent('event-read-refused.json', base([read({ summary: MARKER, public_key: MARKER })])); + const cli = (script, args, extra) => { + const out = join(dir, `cli-read-out-${paceCase++}.txt`); + const summary = join(dir, `cli-read-summary-${paceCase++}.md`); + writeFileSync(out, '', 'utf8'); + writeFileSync(summary, '', 'utf8'); + const r = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', env: { ...process.env, GITHUB_TOKEN: '', GH_TOKEN: '', HTTPS_PROXY: '', https_proxy: '', GITHUB_OUTPUT: out, GITHUB_STEP_SUMMARY: summary, OS_PM_WRITE_PACE_FILE: join(dir, 'cli-read-pace.jsonl'), ...extra } }); + return { status: r.status, output: readReal(out, 'utf8'), text: `${r.stdout}${r.stderr}${readReal(out, 'utf8')}${readReal(summary, 'utf8')}` }; + }; + const VALIDATE = fileURLToPath(new URL('./validate.mjs', import.meta.url)); + const cliRuns = [ + cli(VALIDATE, ['--from-event', '--github-output'], { [EVENT_PATH_ENV]: VALID }), + cli(VALIDATE, ['--from-event', '--github-output'], { [EVENT_PATH_ENV]: REFUSED }), + cli(SELF_PATH, [], { [EVENT_PATH_ENV]: VALID, [SENDER_ENV]: 'os-dev-a' }), + cli(SELF_PATH, [], { [EVENT_PATH_ENV]: REFUSED, [SENDER_ENV]: 'os-dev-a' }), + ]; + t("⛔ …and the real processes: the validator accepting a read — its output minting repository-advisories READ, actions nothing — and refusing one, and the executor stopping at its prerequisite and its refusal, print neither the marker nor the id NOWHERE — stdout, stderr, GITHUB_OUTPUT or the step summary", [cliRuns.map((r) => [r.status, r.text.includes('ADVISORY MARKER'), r.text.includes(GHSA)]), cliRuns[0].output.includes('permission_repository_advisories=read\n'), cliRuns[0].output.includes('permission_actions=\n')], [[[EXIT_OK, false, false], [EXIT_REFUSED, false, false], [EXIT_PREREQUISITE, false, false], [EXIT_REFUSED, false, false]], true, true], cliRuns.map((r) => r.text.slice(0, 200)).join(' | ')); + } + // ── the CLI ───────────────────────────────────────────────────────────── battery('the CLI: the event file, environment inputs, the payload refusal, the exit ladder'); { @@ -2436,7 +2661,9 @@ export async function selfTest() { "a failed-jobs re-run fenced on the run and its pull's own current head, read just before its one POST (zero writes on any fence), and landing only when attempt 2 reads back with its failed jobs queued or in progress, " + "an issue-hierarchy link fenced on its two cards, sent as the child's REST id and landing only when the parent's list reads it back, " + "the payload read from the event file, four advisory rows — a report landed on the id it was given, a draft on its id reading state draft, collaborators and a state fenced on the advisory's own read and landed on the PATCH answer — " + - 'whose marker in a summary, a description and a vulnerability reached no log line, row, summary or annotation, one summary row per request, ' + + 'whose marker in a summary, a description and a vulnerability reached no log line, row, summary or annotation, ' + + "an advisory read fenced on its GET — the sender a collaborator by default — and sealed to the seat's one-time key, its notice parts opening byte for byte " + + 'for that key and read alone, no part on a failed seal, the marker and the id on no public surface and the marker not in the sealed answer, one summary row per request, ' + "one annotation per created or moved card carrying the number the platform answered, read back by the reader's own parser, a stroke at or " + 'above the batch threshold announced as ONE rule ⑤ batch and paced at the batch gap (one the job cannot hold refused with zero requests), and a known ' + 'token that came back out of NO summary, log or error.', From b01f92ae5f1e70e2ce1e9db6b8087512d698041d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:26:09 +0000 Subject: [PATCH 3/6] =?UTF-8?q?wip(pm):=20advisory=5Fread=20=E2=80=94=20th?= =?UTF-8?q?e=20seat's=20one-time=20key,=20the=20sealed=20read-back=20and?= =?UTF-8?q?=20--reveal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- scripts/pm/fleet-write/dispatch.mjs | 136 +++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/scripts/pm/fleet-write/dispatch.mjs b/scripts/pm/fleet-write/dispatch.mjs index 864a4c76a9c..51075987d17 100644 --- a/scripts/pm/fleet-write/dispatch.mjs +++ b/scripts/pm/fleet-write/dispatch.mjs @@ -1928,11 +1928,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the run_rerun_failed stroke at the seat: it packs as ONE action carrying run_id alone and travels in ONE dispatch; beside any other action, or with a key that widens it, it is refused before anything is sent; it creates no content, carries no body and names no workflow, so nothing is read back here — a success run is the executor's confirmation; a run that failed at the relay's fence is exit 5 after ONE dispatch, never re-sent": 6, "the hierarchy link at the seat: an epic's children pack as sub_issue_add actions carrying the parent and the child by number alone, beside other issue ops in ONE dispatch; a self-link, an id or a replace_parent is refused before anything is sent; neither row creates content, carries a body or names a workflow, so the stroke is paced at rule ①'s gap and nothing is read back here — a success run is the executor's confirmation; a run that failed at the relay's fence is exit 5 after ONE dispatch, never re-sent": 5, "the advisory rows at the seat: a report, a draft, an advisory's collaborators and its state each pack as ONE action in a stroke of its own and travel in ONE dispatch; beside another action, with a malformed id or package, published or a key that carries content, refused before anything is sent and never echoing a value; the report and the draft create content but carry no body, so nothing is read back — by construction, said where the read-back is decided — and each one's id comes back as an advisory annotation the seat reads and hands on; the two id rows read nothing back; a run that failed is exit 5 after ONE dispatch, never re-sent": 14, + "advisory_read at the seat: the key pair made in this process per read — its public half in the action, its private half a KeyObject never exported, written or printed, and a key from the file refused; no byte read-back, said where the read-back is decided; the run's notice parts read back exactly, reassembled 1..n once each and opened with that key byte for byte, end to end through the real executor; what does not open is UNVERIFIED (6) with nothing printed; the text reaches this process's stdout on --reveal alone, never stderr or --json": 17, }); // Raised 21 → 22 with the run_rerun_failed seat battery, 22 → 23 with the signature battery, 23 → 24 with the // hierarchy link seat battery, 24 → 25 with the pull request body battery, 25 → 26 with the advisory rows' seat -// battery: one battery, one row. -const SELF_TEST_BATTERY_FLOOR = 26; +// battery, 26 → 27 with advisory_read's seat battery: one battery, one row. +const SELF_TEST_BATTERY_FLOOR = 27; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -2231,7 +2232,7 @@ export async function selfTest() { } return { status: 404, headers, json: async () => ({ message: 'Not Found' }) }; }; - const drive = async (script, { file, ceilings, stroke = payload } = {}) => { + const drive = async (script, { file, ceilings, stroke = payload, keys } = {}) => { const seen = []; const logs = []; let nowMs = T0; @@ -2249,6 +2250,7 @@ export async function selfTest() { }, log: (l) => logs.push(l), ceilings: ceilings ?? { startMs: 90_000, ceilingMs: 300_000, pollMs: 5_000, notes: [] }, + ...(keys ? { advisoryReadKeys: keys } : {}), }); return { ...r, seen, logs, pace }; } catch (e) { @@ -2810,6 +2812,132 @@ export async function selfTest() { t('the CLI: exit 0, and --json hands the advisory id on as advisories, the card annotations empty', [code, json?.advisories, json?.annotations], [EXIT_OK, [{ action: 1, op: 'advisory_report', ghsa_id: GHSA }], []]); } + // ── advisory_read at the seat ─────────────────────────────────────────── + battery("advisory_read at the seat: the key pair made in this process per read — its public half in the action, its private half a KeyObject never exported, written or printed, and a key from the file refused; no byte read-back, said where the read-back is decided; the run's notice parts read back exactly, reassembled 1..n once each and opened with that key byte for byte, end to end through the real executor; what does not open is UNVERIFIED (6) with nothing printed; the text reaches this process's stdout on --reveal alone, never stderr or --json"); + { + const REPO = 'objectstack-ai/objectstack'; + // A made-up id: the fixture names no real advisory. + const GHSA = 'GHSA-abcd-2345-wxyz'; + const MARKER = 'ADVISORY MARKER: never in a seat line'; + const { createPublicKey } = await import('node:crypto'); + const { executeFleetWrite } = await import('./execute.mjs'); + const { ADVISORY_READ_FIELDS, ADVISORY_READ_OPEN_FAILED, advisoryReadParts, sealAdvisoryRead } = await import('./ops.mjs'); + const READ = { op: 'advisory_read', ghsa_id: GHSA }; + const served = { + ghsa_id: GHSA, cve_id: null, url: `https://api.github.test/repos/${REPO}/security-advisories/${GHSA}`, html_url: `https://github.test/${REPO}/security/advisories/${GHSA}`, + summary: `${MARKER} — the summary, 漢字 and 🎉`, description: `${MARKER} — the description\nline two, a quote " and a backslash \\`, severity: 'high', state: 'draft', + vulnerabilities: [{ package: { ecosystem: 'npm', name: '@objectstack/rest' }, vulnerable_version_range: `< 9.9.9 ${MARKER}`, patched_versions: null, vulnerable_functions: [] }], + cwe_ids: ['CWE-79'], credits: [{ login: 'os-dev-z', type: 'finder' }], collaborating_users: [{ login: 'os-dev-a' }], collaborating_teams: [], private_fork: null, + }; + const picked = JSON.stringify(Object.fromEntries(ADVISORY_READ_FIELDS.filter((f) => Object.hasOwn(served, f)).map((f) => [f, served[f]]))); + // The key pair: made here, its public half in the action, its private half a KeyObject the map holds. + const armed = armAdvisoryReads([READ, { op: 'comment', issue: 1, body: 'x' }]); + const key = armed.keys.get(1); + t('the key pair is made HERE, per read: the read gains a public_key the validator accepts, the private half is a KeyObject held in memory by action index, other actions and the file\'s own array are untouched', [armed.refused, Object.keys(armed.actions[0]), armed.actions[0].public_key.length, validatePayload({ request_id: 'fw-test-1', repo: REPO, session: SESSION, actions: [armed.actions[0]] }).ok, [...armed.keys.keys()], key?.type, key?.asymmetricKeyType, armed.actions[1], Object.hasOwn(READ, 'public_key')], [[], ['op', 'ghsa_id', 'public_key'], 60, true, [1], 'private', 'x25519', { op: 'comment', issue: 1, body: 'x' }, false]); + const twice = [armAdvisoryReads([READ]), armAdvisoryReads([READ])]; + t('⛔ a fresh pair every time: two arms of one read never share a key, and the public half sent is the private half\'s own', [twice[0].actions[0].public_key === twice[1].actions[0].public_key, createPublicKey(key).export({ format: 'der', type: 'spki' }).toString('base64') === armed.actions[0].public_key], [false, true]); + const own = readReal(SELF_PATH, 'utf8'); + const body = own.slice(0, own.indexOf('export async function selfTest')); + t("⛔ the private key is never exported, written or printed: this file's code (above its self-test) never exports a private key, its one static fs import reads alone — no write exists to reach a file — and the keys travel in a Map that is no module state", [/privateKey\s*\.\s*export\s*\(/.test(body), /^import \{ readFileSync \} from 'node:fs';$/m.test(body), /^import [^\n]*\b(writeFileSync|appendFileSync|createWriteStream)\b[^\n]*from 'node:fs'/m.test(body), /^(?:export )?(?:const|let) [A-Za-z]*[Kk]eys? = new Map/m.test(body)], [false, true, false, false]); + const supplied = armAdvisoryReads([{ ...READ, public_key: MARKER }]); + t("⛔ a public_key already in the actions file is REFUSED, never used — a key made elsewhere means a private key kept elsewhere — and the refusal never echoes it", [supplied.refused.length, supplied.keys.size, supplied.refused[0]?.includes('actions[0].public_key is set in the actions file'), supplied.refused.some((l) => l.includes('ADVISORY MARKER'))], [1, 0, true, false]); + // Derived from the table. + const stroke = packRequest({ repo: REPO, session: SESSION, actions: [armed.actions[0]], requestId: 'fw-test-1' }).payload; + t('derived from the table: advisory_read is the one op whose answer comes back sealed — no body op, no run op, no content, no card or advisory annotation, no read-back locator or target, and ZERO writes in its stroke', [ADVISORY_READ_OPS, [BODY_OPS, RUN_OPS, CONTENT_CREATING_OPS, ADVISORY_ANNOTATED_OPS].map((list) => list.includes('advisory_read')), Object.hasOwn(ANNOTATED_OPS, 'advisory_read'), 'advisory_read' in READ_BACK_LOCATORS, readBackTargets(stroke), strokePacing(stroke).writes], [['advisory_read'], [false, false, false, false], false, false, [], 0]); + const at = own.indexOf('export const READ_BACK_LOCATORS'); + t("⛔ that advisory_read has NO byte read-back is said WHERE the read-back is decided, and in the header's account of the callers", [own.slice(Math.max(0, at - 1200), at).includes('No locator for `advisory_read`: it has NO byte read-back, because it writes nothing'), own.includes('`advisory_read` has NO byte read-back either, and needs none: it writes')], [true, true]); + // The parts: one spelling, read back exactly. + const full = 'A'.repeat(4000); + const PART = relayAdvisoryReadAnnotationMessage({ action: 1, op: 'advisory_read', part: 10, of: 10, sealed: full }); + t("a part is one spelling the reader reads back exactly, inside the runner's 4,096-character cut at its longest — and nothing else: another op, part 0, a part past its count, a count past ten, a chunk outside base64url or over 4,000 characters, trailing text, a filed advisory's message and null", [PART.length <= 4096, parseRelayAdvisoryReadAnnotation(PART), ['advisory_report', 'comment'].map((op) => parseRelayAdvisoryReadAnnotation(relayAdvisoryReadAnnotationMessage({ action: 1, op, part: 1, of: 1, sealed: 'AA' }))), ['part=0/1', 'part=2/1', 'part=1/11', 'part=11/11'].map((p) => parseRelayAdvisoryReadAnnotation(`fleet-write action=1 op=advisory_read ${p} sealed=AA`)), ['AA+', 'AA=', 'A A', `${full}A`].map((sealed) => parseRelayAdvisoryReadAnnotation(relayAdvisoryReadAnnotationMessage({ action: 1, op: 'advisory_read', part: 1, of: 1, sealed }))), parseRelayAdvisoryReadAnnotation(`${relayAdvisoryReadAnnotationMessage({ action: 1, op: 'advisory_read', part: 1, of: 1, sealed: 'AA' })} x`), parseRelayAdvisoryReadAnnotation(relayAdvisoryAnnotationMessage({ action: 1, op: 'advisory_report', ghsaId: GHSA })), parseRelayAdvisoryReadAnnotation(null)], [true, { action: 1, op: 'advisory_read', part: 10, of: 10, sealed: full }, [null, null], [null, null, null, null], [null, null, null, null], null, null, null]); + const p = (part, of, sealed, action = 1, op = 'advisory_read') => ({ action, op, part, of, sealed }); + t('reassembly: parts 1..n each EXACTLY once under one n — out of order is joined in order, a part repeated with the same text is the same part; a missing part, two texts under one number, counts that disagree or no part at all are no answer, said; another action\'s or op\'s parts are not this one\'s', [assembleAdvisoryReads(stroke, [p(2, 2, 'CD'), p(1, 2, 'AB'), p(1, 2, 'AB')]), ...[[p(1, 3, 'AB'), p(3, 3, 'EF')], [p(1, 2, 'AB'), p(1, 2, 'XY'), p(2, 2, 'CD')], [p(1, 2, 'AB'), p(2, 3, 'CD')], [], [p(1, 1, 'AB', 2), p(1, 1, 'AB', 1, 'advisory_report')]].map((parts) => assembleAdvisoryReads(stroke, parts)[0].why)], [[{ action: 1, op: 'advisory_read', sealed: 'ABCD', parts: 2, why: '' }], 'part(s) 2 of 3 are not on the run', 'two different parts carry one part number', 'its parts disagree on their count (2, 3)', 'no part of its sealed answer is on the run', 'no part of its sealed answer is on the run']); + // ⭐ End to end: the REAL executor reads and seals, its notice lines become the run's annotations, the seat opens them. + const PERM_A = `GET /repos/${REPO}/collaborators/os-dev-a/permission`; + const A_GET = `GET /repos/${REPO}/security-advisories/${GHSA}`; + const executed = async (payload, answer = { status: 200, json: served }) => { + const lines = []; + const answers = { [PERM_A]: { status: 200, json: { permission: 'write', role_name: 'write' } }, [A_GET]: answer }; + const r = await executeFleetWrite({ payload, sender: 'os-dev-a', token: TOKEN }, { + fetch: async (url, init) => { + const a = answers[`${init?.method ?? 'GET'} ${new URL(url).pathname}`] ?? { status: 404, json: { message: 'Not Found' } }; + return { status: a.status, headers: new Headers({ 'x-ratelimit-remaining': '4999' }), json: async () => a.json }; + }, + log: (l) => lines.push(l), + }); + // What the runner turns each notice command into: an annotation whose message is the command's data. + const notes = lines.filter((l) => l.startsWith('::notice ')).map((l) => ({ path: '.github', annotation_level: 'notice', title: 'fleet-write advisory_read', message: l.slice(l.indexOf('::', 2) + 2) })); + return { ...r, notes }; + }; + const relayed = await executed(stroke); + const runOf = (notes, keys = armed.keys, extra = {}) => drive({ runs: () => [RUN('completed', 'success')], notes: () => notes, ...extra }, { stroke, keys }); + const ok = await runOf({ status: 200, json: [RUNNER_NOTICE, ...relayed.notes] }); + const sentRead = ok.seen.filter((s) => s.call === DISPATCH); + t("⭐ END TO END — the real executor's notices opened by the key this process made: ONE dispatch carrying the armed public key, nothing read at the target, success, exit 0, and the text opened BYTE FOR BYTE the GET's fields", [relayed.exit, relayed.notes.length, sentRead.length, sentRead[0]?.body?.client_payload?.actions?.[0]?.public_key === armed.actions[0].public_key, ok.seen.filter((s) => !s.call.includes(`/repos/${RELAY_REPO}/`)).length, ok.state, exitForResult(ok), ok.reads?.map((r) => [r.state, r.parts, r.fields]), ok.reads?.[0]?.plaintext === picked, Buffer.compare(Buffer.from(ok.reads?.[0]?.plaintext ?? '', 'utf8'), Buffer.from(picked, 'utf8'))], [0, 1, 1, true, 0, 'success', EXIT_OK, [['opened', 1, [...ADVISORY_READ_FIELDS.filter((f) => Object.hasOwn(served, f))]]], true, 0]); + t('⛔ …and the seat\'s lines say the counts and the field names, and that --reveal prints the text — never the marker, never the id', [ok.logs.some((l) => /action 1 advisory_read's sealed answer in 1 part\(s\) — opened with this process's one-time key: \d+ byte\(s\), fields ghsa_id, cve_id, state/.test(l) && l.includes('pass --reveal')), ok.logs.some((l) => l.includes('ADVISORY MARKER') || l.includes(GHSA))], [true, false]); + // ⛔ What does not open is UNVERIFIED, exit 6, and prints nothing of it. + const stranger = armAdvisoryReads([READ]); + const otherSeal = await executed(packRequest({ repo: REPO, session: SESSION, actions: stranger.actions, requestId: 'fw-test-1' }).payload); + const tampered = relayed.notes.map((n) => ({ ...n, message: n.message.replace(/sealed=(.)/, (m, c) => `sealed=${c === 'A' ? 'B' : 'A'}`) })); + const big = await executed(stroke, { status: 200, json: { ...served, description: `${MARKER} ${'y'.repeat(9_000)}` } }); + const unread = [ + await runOf({ status: 200, json: relayed.notes }, new Map()), + await runOf({ status: 200, json: [RUNNER_NOTICE] }), + await runOf({ status: 200, json: big.notes.slice(1) }), + await runOf({ status: 200, json: tampered }), + await runOf({ status: 200, json: otherSeal.notes }), + await runOf({ status: 500, json: { message: 'down' } }), + ]; + t('⛔ UNVERIFIED, exit 6, plaintext null and nothing of it printed: no key in this process (a re-run elsewhere cannot open it), no part on the run, a part missing, a part tampered with, parts sealed to another seat\'s key, and annotations that cannot be read', [big.notes.length > 1, unread.map((r) => [r.state, exitForResult(r), r.reads?.[0]?.state, r.reads?.[0]?.plaintext ?? null, r.logs.some((l) => l.includes('ADVISORY MARKER') || l.includes(GHSA))])], [true, unread.map(() => ['unverified', EXIT_UNCONFIRMED, 'unverified', null, false])]); + t("…each saying why: no key, no part, the part missing, and the one sentence for an answer that does not open", [unread[0].reads[0].why.startsWith('this process holds no key for it'), unread[1].reads[0].why, unread[2].reads[0].why.startsWith('part(s) 1 of '), unread[3].reads[0].why, unread[4].reads[0].why, unread[5].reads[0].why], [true, 'no part of its sealed answer is on the run', true, ADVISORY_READ_OPEN_FAILED, ADVISORY_READ_OPEN_FAILED, 'no part of its sealed answer is on the run']); + const refusedRun = await drive({ runs: () => [RUN('completed', 'failure')] }, { stroke, keys: armed.keys }); + t('⛔ a run that failed — a fence refused the read, the seal failed or did not fit — is exit 5 after ONE dispatch, nothing opened; a read writes nothing, so a fresh key may send it again', [refusedRun.state, exitForResult(refusedRun), refusedRun.seen.filter((s) => s.call === DISPATCH).length, refusedRun.reads ?? null], ['failure', EXIT_PLATFORM_REFUSAL, 1, null]); + // The CLI: the plaintext on stdout, and only with --reveal. + writeFileSync(join(dir, 'advisory-read.json'), JSON.stringify([READ]), 'utf8'); + writeFileSync(join(dir, 'advisory-read-keyed.json'), JSON.stringify([{ ...READ, public_key: MARKER }]), 'utf8'); + const cli = async (args) => { + let nowMs = T0; + const clock = { now: () => nowMs, elapsed: () => nowMs - T0 }; + const seen = []; + // The run's notices as the executor would emit them, sealed to whatever key the CLI made and dispatched. + const notes = () => { + const sent = seen.find((s) => s.call === DISPATCH)?.body?.client_payload; + const action = sent?.actions?.[0]; + if (!action?.public_key) return { status: 200, json: [] }; + const sealed = sealAdvisoryRead({ plaintext: picked, publicKey: action.public_key, aad: advisoryReadAad({ requestId: sent.request_id, action: 1, ghsaId: action.ghsa_id }) }); + const parts = advisoryReadParts(sealed); + return { status: 200, json: parts.map((chunk, k) => ({ path: '.github', annotation_level: 'notice', title: 'fleet-write advisory_read', message: relayAdvisoryReadAnnotationMessage({ action: 1, op: 'advisory_read', part: k + 1, of: parts.length, sealed: chunk }) })) }; + }; + const out = []; + const errs = []; + const [log, err] = [console.log, console.error]; + console.log = (l) => out.push(String(l)); + console.error = (l) => errs.push(String(l)); + let code; + try { + code = await main(args, { + env: { GITHUB_TOKEN: TOKEN, [SESSION_ENV]: SESSION }, + send: { fetch: platform({ runs: () => [RUN('completed', 'success')], notes }, seen, clock), pace: paceFor(join(dir, `pace-cli-${paceCase++}.jsonl`)), now: clock.now, sleep: async (ms) => { nowMs += ms; }, log: (l) => errs.push(String(l)), ceilings: { startMs: 90_000, ceilingMs: 300_000, pollMs: 5_000, notes: [] } }, + }); + } finally { + [console.log, console.error] = [log, err]; + } + return { code, out, errs, seen }; + }; + const base = ['--repo', REPO, '--actions-file', join(dir, 'advisory-read.json'), '--request-id', 'fw-test-1']; + const quiet = await cli([...base, '--json']); + const quietJson = quiet.out.length ? JSON.parse(quiet.out[quiet.out.length - 1]) : null; + t('⛔ the CLI without --reveal: exit 0, stdout ONE --json line whose advisory_reads carry the state, counts and field names alone — the text printed NOWHERE, stdout or stderr', [quiet.code, quiet.out.length, quietJson?.advisory_reads?.map((r) => [r.action, r.op, r.state, r.bytes === Buffer.byteLength(picked, 'utf8'), r.parts, r.why]), [...quiet.out, ...quiet.errs].some((l) => l.includes('ADVISORY MARKER'))], [EXIT_OK, 1, [[1, 'advisory_read', 'opened', true, 1, null]], false]); + const loud = await cli([...base, '--reveal']); + t('⭐ the CLI with --reveal: exit 0, and the opened text is THIS process\'s stdout, byte for byte the GET\'s fields — and still never a stderr line', [loud.code, loud.out, loud.errs.some((l) => l.includes('ADVISORY MARKER'))], [EXIT_OK, [picked], false]); + const loudJson = await cli([...base, '--reveal', '--json']); + t('…with --json beside it, the text is its own stdout line and the --json line still carries none of it', [loudJson.code, loudJson.out.length, loudJson.out[0] === picked, loudJson.out[1]?.includes('ADVISORY MARKER'), JSON.parse(loudJson.out[1] ?? '{}').advisory_reads?.[0]?.state], [EXIT_OK, 2, true, false, 'opened']); + const keyed = await cli(['--repo', REPO, '--actions-file', join(dir, 'advisory-read-keyed.json'), '--request-id', 'fw-test-1']); + writeFileSync(join(dir, 'comment-reveal.json'), JSON.stringify([{ op: 'comment', issue: 19701, body: 'x' }]), 'utf8'); + const misplaced = await cli(['--repo', REPO, '--actions-file', join(dir, 'comment-reveal.json'), '--request-id', 'fw-test-1', '--reveal']); + t('⛔ refused before anything is sent, exit 2: a public_key in the actions file (never echoed), and --reveal on a stroke carrying no advisory_read', [keyed.code, keyed.seen.length, keyed.errs.some((l) => l.includes('ADVISORY MARKER')), misplaced.code, misplaced.seen.length], [EXIT_USAGE, 0, false, EXIT_USAGE, 0]); + } + // ── the hierarchy link at the seat ────────────────────────────────────── battery("the hierarchy link at the seat: an epic's children pack as sub_issue_add actions carrying the parent and the child by number alone, beside other issue ops in ONE dispatch; a self-link, an id or a replace_parent is refused before anything is sent; neither row creates content, carries a body or names a workflow, so the stroke is paced at rule ①'s gap and nothing is read back here — a success run is the executor's confirmation; a run that failed at the relay's fence is exit 5 after ONE dispatch, never re-sent"); { @@ -3299,6 +3427,8 @@ export async function selfTest() { "a started workflow run read back from the target's run list within a bounded window, UNCONFIRMED (6) when it is not there; " + "a batched stroke waited for as long as the runner is told to pause, and one the relay job cannot hold refused before it leaves; " + "an advisory report or draft never read back, by construction, its id handed on from the run's advisory annotation; " + + "an advisory read whose one-time key pair this process made and never let out, its sealed answer reassembled from the run's notices and opened byte for byte, " + + 'UNVERIFIED (6) when it does not open, and printed to stdout on --reveal alone; ' + 'and every relay sender, discovered from the source, answering an accepted dispatch with no run, or none completed, UNCONFIRMED (6) with ZERO direct writes, under auto as under dispatch (the no-run conformance).', ); selfTestReachedVerdict = true; From 1bae3e4011834e29567b763b8c0e5c8ca3e59e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:45:50 +0000 Subject: [PATCH 4/6] docs(pm-dispatch): name advisory_read in the REST channel and point a seat's advisory read at it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- .../skills/pm-dispatch/references/rest-channel.md | 1 + .../pm-dispatch/references/security-findings.md | 1 + scripts/pm/check-skill-line-ratchet.mjs | 12 ++++++++++-- scripts/pm/fleet-write/ops.mjs | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/rest-channel.md b/.claude/skills/pm-dispatch/references/rest-channel.md index 27cea59382f..d91595678d7 100644 --- a/.claude/skills/pm-dispatch/references/rest-channel.md +++ b/.claude/skills/pm-dispatch/references/rest-channel.md @@ -48,6 +48,7 @@ - ✓ `origin/main` 合进 PR head:中继 op `pr_update_branch`(`pull` + 40 位 `expected_head_sha`),真合并提交。 - 挂子卡走中继 op `sub_issue_add`/`sub_issue_remove`(`issue` 父号,`sub_issue` 子号),回读父列表。 - 公告经中继 `advisory_create`(首选)等四操作,规则住 `security-findings.md`;发布归维护者。 +- 读公告经中继 `advisory_read`:席位进程一次性密钥,密文回传;`--reveal` 才印明文到 stdout。 - ✓ 落地经中继 `node scripts/pm/fleet-write/dispatch.mjs --repo O/N --actions-file F`;`--via dispatch` 同载。 - ✓ 启动 run 走 op `workflow_dispatch`:解锁物是白名单 run 才启;入表只经 workflow 自己的 PR。 - F = `[{"op":"workflow_dispatch","workflow":"release.yml","ref":"main","inputs":{"refresh_version_pr":true}}]`。 diff --git a/.claude/skills/pm-dispatch/references/security-findings.md b/.claude/skills/pm-dispatch/references/security-findings.md index 5bd05f0e70c..5b7442e0ba0 100644 --- a/.claude/skills/pm-dispatch/references/security-findings.md +++ b/.claude/skills/pm-dispatch/references/security-findings.md @@ -19,6 +19,7 @@ - 私卡 = 受影响仓上一份未发布的安全公告,只有管理员、安全经理与受邀协作者可见。 - 首选中继 `advisory_create` 建草稿公告,再 `advisory_collaborators_add` 带具名账号进公告。 +- 读公告只走中继 `advisory_read`(席位账号读被拒):一次性密钥只在席位进程,只回密文。 - `advisory_report` 只留给 App 无公告写权的仓:GitHub 按账号限每日新私报,全队共一个 App。 - ⛔ 席位永不以自身账号私报或写公告:GitHub 写只走中继,以 App 身份;发布归维护者。 - 两操作皆被拒 ⇒ 会话专递(选 A):机制只在会话里交维护者,由其建草稿,⛔ 不换账号重发。 diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index b620b4e87d5..2e42b3b9a52 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1195,7 +1195,11 @@ export const CEILINGS = new Map([ // quotes the maintainer verbatim — 「可以,按方案改中继,能发这个操作的账号名单我稍后点名。」 and // 「你写清楚issue就可以,让车道开发」 — names this file and the one line, and confirms the raise the seat stated in // its comment 6572738609. Measured delta +1 (80 → 81 lines); landed count, headroom 0. - ['.claude/skills/pm-dispatch/references/rest-channel.md', 81], + // 81 → 82 (#29420): one line naming the sealed advisory read, `advisory_read`. The ruling of record is that card's + // body, filed under the maintainer's verbatim 「按一次性密钥立卡」 and 「并派发」, which names this file and "one line + // naming the op, with the ratchet row raised by the measured delta only". Measured delta +1 (81 → 82 lines); landed + // count, headroom 0. + ['.claude/skills/pm-dispatch/references/rest-channel.md', 82], // Lowered 84 → 77 by the rules-only rewrite of the seven core references // (maintainer ruling: provenance narratives, incident post-mortems and rationale // tails leave the corpus; one rule per ≤120-byte line, no rule already stated in @@ -1300,7 +1304,11 @@ export const CEILINGS = new Map([ // untranslated 「同意方案,选 B,立卡」; the raises below cite it as "#29111". // A NEW file, so an added row that funds nothing — set at the landed line // count, headroom 0, same convention as above. - ['.claude/skills/pm-dispatch/references/security-findings.md', 55], + // 55 → 56 (#29420): one line pointing a seat's read of an advisory at the sealed relay read, `advisory_read`, where + // §3 had it read under its own account (a cloud seat's account is refused). The ruling of record is that card's + // body, filed under the maintainer's verbatim 「按一次性密钥立卡」 and 「并派发」, which names this file "by the + // measured delta only". Measured delta +1 (55 → 56 lines); landed count, headroom 0. + ['.claude/skills/pm-dispatch/references/security-findings.md', 56], ['.claude/skills/pm-dispatch/references/external-contributions.md', 22], ['.claude/skills/pm-dispatch/references/reading-discipline.md', 37], // Lowered 51 → 47 by #25712 item 4: objectui stopped being a sister repo, so diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index e4c1c5218ed..7d2ba294c8d 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -1192,7 +1192,7 @@ export function advisoryReadKeyFailure(value) { export const ADVISORY_READ_SEAL_VERSION = 1; /** The scheme, named once for a reader: what `sealAdvisoryRead` does and `openAdvisoryRead` undoes. */ export const ADVISORY_READ_SCHEME = 'X25519 key agreement · HKDF-SHA256 · AES-256-GCM, a fresh ephemeral key and nonce per seal'; -const ADVISORY_READ_INFO = 'objectstack fleet-write advisory_read v1'; +const ADVISORY_READ_INFO = 'fleet-write:advisory_read:v1'; const ADVISORY_READ_NONCE_BYTES = 12; const ADVISORY_READ_TAG_BYTES = 16; /** What a seal adds to the plaintext: the version byte, the ephemeral key, the nonce and the tag — 61 bytes. */ From d2ee86ca4d9a420001996f2d8df79e78762b4f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:46:59 +0000 Subject: [PATCH 5/6] test(pm): advisory_read's byte-for-byte case reads a refused open as a failing case, not a crash Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- scripts/pm/fleet-write/execute.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index bd28eaf04ef..9c70c7cdeb7 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -2521,7 +2521,14 @@ export async function selfTest() { const landed = await readRun([read()], { ...allowed, [A_GET]: served }); t('⭐ a read is ONE GET of the advisory under the App token after the sender gate, ZERO writes, exit 0 — and its row prints the call with the id LEFT OUT, the status and the counts', [landed.exit, landed.seen.map((s) => s.call), landed.seen.map((s) => s.auth), writes(landed.seen).length, landed.rows.map((x) => [x.op, x.call, x.status]), /^sealed \d+ byte\(s\) of the answer to the seat's one-time key — \d+ character\(s\) in 1 notice part\(s\), which only that seat's process opens$/.test(landed.rows[0]?.result ?? '')], [EXIT_OK, [PERM, A_GET], [`Bearer ${TOKEN}`, `Bearer ${TOKEN}`], 0, [['advisory_read', CALL, 200]], true]); const opened = opens(landed); - t('⭐ the seat\'s key opens the parts to the GET answer BYTE FOR BYTE — the summary, the description and the vulnerabilities as the platform answered them, CJK, an emoji, a newline, a quote and a backslash among them', [opened === picked(served.json), Buffer.compare(Buffer.from(opened, 'utf8'), Buffer.from(picked(served.json), 'utf8')), ['summary', 'description', 'vulnerabilities'].map((f) => JSON.stringify(JSON.parse(opened)[f]) === JSON.stringify(served.json[f])), Object.keys(JSON.parse(opened))], [true, 0, [true, true, true], [...ADVISORY_READ_FIELDS]]); + const openedJson = (() => { + try { + return JSON.parse(opened); + } catch { + return null; + } + })(); + t('⭐ the seat\'s key opens the parts to the GET answer BYTE FOR BYTE — the summary, the description and the vulnerabilities as the platform answered them, CJK, an emoji, a newline, a quote and a backslash among them', [opened === picked(served.json), Buffer.compare(Buffer.from(opened, 'utf8'), Buffer.from(picked(served.json), 'utf8')), ['summary', 'description', 'vulnerabilities'].map((f) => JSON.stringify(openedJson?.[f]) === JSON.stringify(served.json[f])), Object.keys(openedJson ?? {})], [true, 0, [true, true, true], [...ADVISORY_READ_FIELDS]]); const parsedBack = notices(landed).map((l) => parseRelayAdvisoryReadAnnotation(l.slice(l.indexOf('::', 2) + 2))); t("the parts are notice commands titled `fleet-write advisory_read`, the reader's own parser reads back exactly what was emitted, and each message is inside the runner's 4,096-character cut", [notices(landed).map((l) => l.slice(0, l.indexOf('::', 2) + 2)), parsedBack, notices(landed).every((l) => l.length - l.indexOf('::', 2) - 2 <= 4096), ADVISORY_READ_OPS], [['::notice title=fleet-write advisory_read::'], landed.annotations, true, ['advisory_read']]); const sealedText = sealedOf(landed); From 7b0268e20c22c26067d8d6f1590cb790bc935d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:35:38 +0000 Subject: [PATCH 6/6] fix(pm): advisory_read says a GET that never reached the platform did not, not "HTTP 0" On status 0 (`rest`'s word for a call that threw before any answer) the read row now says the request did not reach the platform, with the row status `not reached`. It no longer prints "HTTP 0" or appends the read-grant remedy, because the platform judged nothing. The exception's message is still printed nowhere, since it can carry the path and so the id. A self-test case pins this, and the run joins the marker and id sweep (19 runs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- scripts/pm/fleet-write/execute.mjs | 20 ++++++++++++++++---- scripts/pm/fleet-write/ops.mjs | 11 +++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 9c70c7cdeb7..b296bccff5f 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -243,8 +243,11 @@ * * ## The read — `advisory_read`, sealed before anything leaves (`advisoryReadAction`) * - * ONE `GET /repos/{repo}/security-advisories/{ghsa_id}`, nothing written. The - * executor judges `ADVISORY_READ_FENCES` on that answer — a 200 advisory, the + * ONE `GET /repos/{repo}/security-advisories/{ghsa_id}`, nothing written. A + * GET that never reached the platform (the call threw: `rest`'s status 0) is a + * FAILED action with `ADVISORY_READ_UNREACHED` and the row status + * `not reached` — no HTTP status, no grant remedy, ⛔ never the exception's + * message. On an answer, the executor judges `ADVISORY_READ_FENCES` on it — a 200 advisory, the * one the action names, and a sender the row's `senderGate` admits (the * default, `collaborator`: the sender on the advisory's own * `collaborating_users`; `ops.mjs` records the ruling the maintainer owes) — @@ -404,6 +407,8 @@ import { ADVISORY_READ_ACCESS_REMEDY, ADVISORY_READ_FIELDS, ADVISORY_READ_SEAL_FAILED, + ADVISORY_READ_UNREACHED, + ADVISORY_READ_UNREACHED_STATUS, advisoryReadAad, advisoryReadCall, advisoryReadFenceFailures, @@ -924,6 +929,9 @@ export function confirmAdvisory(req, answer, pre) { export async function advisoryReadAction(req, { payload, index, sender, api, t, seal = sealAdvisoryRead, senderGate = OPS.advisory_read.senderGate }) { const { ghsaId, publicKey } = req.advisoryRead; const r = await rest(api, req.path, { method: 'GET' }, t); + // Status 0 is `rest`'s word for a call that threw before any answer: the platform judged nothing, so the row names no + // HTTP status and no grant remedy — and ⛔ never the exception's message, which can carry the path and so the id. + if (r.status === 0) return { ok: false, status: ADVISORY_READ_UNREACHED_STATUS, why: ADVISORY_READ_UNREACHED, notes: [] }; const failures = advisoryReadFenceFailures({ ghsaId, answer: r, sender, gate: senderGate }); if (failures.length) { const refused = !(r.status >= 200 && r.status < 300); @@ -1452,7 +1460,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the run_rerun_failed: the five fences judged HERE on the run and its pull, read immediately before ONE bodiless POST — the pull's current head by its own GET, never the run's embedded copy, and a run naming no pull refused by name; each refused with the POST not sent and accepted on the measured run; a run, pull, baseline or fence that fails is zero writes; the 201 confirmed only by reading attempt 2 back with its failed jobs queued or in progress, naming their job ids; attempt 2 never seen, a failed job completed, or a job list unread is FAILED; no annotation": 21, "the hierarchy link: sub_issue_add and sub_issue_remove read the parent's and the child's own answers, then the child's parent (add) or the parent's whole list (remove), immediately before ONE write whose body is the child's REST id from its own answer — never its number, never replace_parent; a pull, a transferred or unread card, a child under another parent, one the parent does not list, or a read that is neither answer is zero writes; a child already under this parent is a no-op landed on the list; the answer confirmed only when the parent's list reads the child back (or no longer does) inside the window; a refused write, a list that never settles or never reads whole is FAILED; no annotation, and the rows ride a stroke beside other ops": 20, "the advisory rows: advisory_report and advisory_create are ONE POST of their body each, landed on a 2xx carrying an id — a draft's reading state draft — that the row and a notice annotation name; advisory_collaborators_add and advisory_state read the advisory first and send ONE PATCH whose body is the executor's — every existing login kept with the missing ones added, an all-present list a no-op, or the one state a legal transition reaches — or nothing at all on a fence: an unread, other or wrong-state advisory or an illegal move; each lands only when its answer shows the result; ⛔ the marker in a report's or a draft's summary, description and packages and in every advisory answer's summary, description and vulnerabilities reaches no log line, row, step summary or annotation": 27, - "advisory_read: ONE GET after the sender gate and nothing written; the read, advisory and sender fences judged on that answer — collaborator by default, target-write by switch, an unknown gate closed — then the answer sealed to the seat's key and emitted as notice parts the reader parses back, opening byte for byte for that key and read alone; a failed or unsealed seal and an over-cap answer FAILED with nothing emitted; ⛔ the marker in the summary, description and vulnerabilities, and the advisory's id, reach no log line, row, step summary or annotation, and the sealed answer alone carries no marker": 16, + "advisory_read: ONE GET after the sender gate and nothing written; the read, advisory and sender fences judged on that answer — collaborator by default, target-write by switch, an unknown gate closed — then the answer sealed to the seat's key and emitted as notice parts the reader parses back, opening byte for byte for that key and read alone; a failed or unsealed seal and an over-cap answer FAILED with nothing emitted; ⛔ the marker in the summary, description and vulnerabilities, and the advisory's id, reach no log line, row, step summary or annotation, and the sealed answer alone carries no marker": 17, }); // Raised 17 → 18 with the governed-review follow-up battery, 18 → 19 with the run_rerun_failed battery, 19 → 20 with the // hierarchy link battery, 20 → 21 with the advisory rows' battery, 21 → 22 with advisory_read's: one battery, one row. @@ -2551,6 +2559,10 @@ export async function selfTest() { const missing = await readRun([read()], { ...allowed, [A_GET]: { status: 404, json: { message: MARKER } } }); const elsewhere = await readRun([read()], { ...allowed, [A_GET]: advisory({ ghsa_id: OTHER }) }); t('⛔ fence read and advisory: a 403 quoting the one message the relay knows plus the read remedy, a 404 whose message is prose (not quoted), and an answer naming another advisory — each FAILED with nothing sealed, no part, and neither id in its row', [[forbidden, missing, elsewhere].map((r) => [r.exit, r.annotations.length, r.rows[0]?.status, [GHSA, OTHER].some((id) => JSON.stringify(r.rows).includes(id))]), forbidden.rows[0]?.result.includes('(the platform: Resource not accessible by integration); the relay token carries repository-advisories READ for this stroke alone'), missing.rows[0]?.result.includes('the platform'), elsewhere.rows[0]?.result.includes('fence `advisory`: the answer names another advisory')], [[[EXIT_ACTION_FAILED, 0, 403, false], [EXIT_ACTION_FAILED, 0, 404, false], [EXIT_ACTION_FAILED, 0, 200, false]], true, false, true]); + // ⛔ A GET that never reached the platform: the call threw, so `rest` answers status 0 — its message carrying the id and the marker. + const unreached = await readRun([read()], { ...allowed, [A_GET]: { throws: `fetch failed for ${ADV} holding ${MARKER}` } }); + const unreachedText = [...unreached.logs, ...(unreached.lines ?? []), unreached.summary ?? '', JSON.stringify(unreached.rows)].join('\n'); + t("⛔ a GET that never reached the platform (the call threw) is FAILED saying so: its row names no HTTP status and no grant remedy — no grant was judged — nothing sealed, no part, and the exception's message printed nowhere", [unreached.exit, unreached.seen.map((s) => s.call), unreached.annotations.length, notices(unreached).length, unreached.rows.map((x) => [x.status, x.result]), /HTTP 0\b/u.test(unreachedText), unreachedText.includes(ADVISORY_READ_ACCESS_REMEDY), unreachedText.includes('fetch failed')], [EXIT_ACTION_FAILED, [PERM, A_GET], 0, 0, [[ADVISORY_READ_UNREACHED_STATUS, `FAILED: ${ADVISORY_READ_UNREACHED}`]], false, false, false], unreachedText); // ⛔ A failed seal, and one too large. const thrown = await readRun([read()], { ...allowed, [A_GET]: advisory() }, { advisoryRead: { seal: () => { throw new Error(`seal blew up holding ${MARKER}`); } } }); const unsealed = await readRun([read()], { ...allowed, [A_GET]: advisory() }, { advisoryRead: { seal: ({ plaintext }) => plaintext } }); @@ -2568,7 +2580,7 @@ export async function selfTest() { // ⛔ The sweep: every run above, every channel this file emits on — no marker, and no advisory id. const emitted = swept.map(({ r, paceLines }) => [...(r.logs ?? []), ...(r.lines ?? []), r.summary ?? '', JSON.stringify(r.rows ?? []), ...(r.annotations ?? []).map((n) => relayAdvisoryReadAnnotationMessage(n)), ...paceLines].join('\n')); const leaks = emitted.map((text, i) => (text.includes('ADVISORY MARKER') ? i : null)).filter((i) => i !== null); - t('⛔ THE MARKER TEST: across every run of this battery — landed, long, refused at the sender, read and advisory fences, the switched and unknown gates, a seal that throws, one that does not seal, one too large, refused payloads — the marker served in the summary, the description and the vulnerabilities (and every other field) reaches NO log line, row, step summary, annotation or pace-log line', [swept.length, leaks], [18, []], leaks.map((i) => emitted[i].split('\n').find((l) => l.includes('ADVISORY MARKER'))).join(' | ')); + t('⛔ THE MARKER TEST: across every run of this battery — landed, long, refused at the sender, read and advisory fences, the switched and unknown gates, a GET that never reached the platform, a seal that throws, one that does not seal, one too large, refused payloads — the marker served in the summary, the description and the vulnerabilities (and every other field) reaches NO log line, row, step summary, annotation or pace-log line', [swept.length, leaks], [19, []], leaks.map((i) => emitted[i].split('\n').find((l) => l.includes('ADVISORY MARKER'))).join(' | ')); const named = emitted.map((text, i) => ([GHSA, OTHER].some((id) => text.includes(id)) ? i : null)).filter((i) => i !== null); t("⛔ …and NO advisory id: neither the one asked for nor the one answered appears on any line, row, summary or annotation of any run — the call's placeholder stands where every other row prints its path", named, [], named.map((i) => emitted[i].split('\n').find((l) => l.includes(GHSA) || l.includes(OTHER))).join(' | ')); t('…positive control: every answer served carried the marker and the id, the request carried the id to the platform — where it belongs — and the generic row printer, given the same answer, WOULD print both', [swept.every(({ answers }) => JSON.stringify(answers).includes('ADVISORY MARKER') && JSON.stringify(answers).includes(GHSA)), landed.seen.some((s) => s.call === A_GET), resultOf({}, served.json).includes('ADVISORY MARKER') && resultOf({}, served.json).includes(GHSA)], [true, true, true]); diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index 7d2ba294c8d..ce0656b3454 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -1375,6 +1375,17 @@ export const ADVISORY_READ_ACCESS_REMEDY = /** What a read whose seal failed prints: nothing of the answer, and no part emitted. */ export const ADVISORY_READ_SEAL_FAILED = 'the seal failed, so nothing of the answer was emitted — ⛔ the text never leaves another way; re-send the read with a fresh key'; +/** + * What a read whose GET never reached the platform prints — `rest` answers status 0 when the call threw before any + * answer — and the status its row carries in place of a number. The platform judged nothing, so there is no HTTP + * status to print and no grant to remedy (`ADVISORY_READ_ACCESS_REMEDY` is a refusal's, and none came); ⛔ nor the + * exception's message, which can carry the request's path and so the id. + */ +export const ADVISORY_READ_UNREACHED = + "REFUSED before any fence: the advisory's GET did not reach the platform (the runner's call threw before any answer), so no grant, " + + 'advisory or sender was judged and nothing was sealed or emitted — re-send the read with a fresh key'; +export const ADVISORY_READ_UNREACHED_STATUS = 'not reached'; + /** What a read too large for the channel prints: the counts, and nothing of the answer. Pure. */ export function advisoryReadOverCapText({ plaintextBytes, sealedChars }) { return (