diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 06a1b58e8a2..c1c5e176067 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -70,6 +70,9 @@ * gap between two writes and the back-off marker a platform refusal writes * apply on the runner too (the runner's own home holds the log; nothing is * shared with a seat container, and the twenty-action cap bounds the run). + * No egress proxy stands in front of the runner, so the throttle reads none + * of its answers as the proxy's (`proxyAnswered`, `write-pace.mjs`): a 403 + * here is GitHub's and takes the platform back-off path. * * ## Rule ⑤ on the runner — the executor announces the batch * @@ -223,17 +226,24 @@ * them, then the missing ones — or exactly `{ state }`, the one asked for. * The action LANDS only on its own answer (`confirmAdvisory`): every login * sent listed (`advisoryReadBack`), or the state asked for shown - * (`advisoryStateReadBack`). A non-2xx is a FAILED action carrying - * `ADVISORY_ACCESS_REMEDY`; a 2xx that does not show the result is a FAILED - * action naming what it shows. + * (`advisoryStateReadBack`). A non-2xx is a FAILED action carrying its + * cause and then `ADVISORY_ACCESS_REMEDY`; a 2xx that does not show the + * result is a FAILED action naming what it shows. * * ⛔ The payload reaches this file through the event file (`readEventPayload`, * `validate.mjs`), never through `env:`, and the text this path prints — row, * log line, step summary, annotation — is built from the advisory's id, its * state and its collaborators' logins ALONE (`advisoryReportLandedText`, `advisoryCreateLandedText`, * `advisoryLandedText`, `advisoryStateLandedText`, `advisoryFenceText`, each - * value in its own shape through `advisoryPrintable`), plus a platform - * `message` only when it is one `ADVISORY_PLATFORM_MESSAGES` names. `resultOf`, + * value in its own shape through `advisoryPrintable`), plus, on a refusal, + * its CAUSE (`advisoryRefusalCause`): the platform's `message` and + * `documentation_url` from the refusal's own answer — each capped + * (`ADVISORY_REFUSAL_MESSAGE_CAP`, `ADVISORY_REFUSAL_URL_CAP`) and flattened to + * one table-safe line, never its `errors` or the rest of its body — so the + * reading a seat puts to the maintainer carries the reason, whatever message + * the platform chose. A message or a URL that reproduces any prose of the + * payload or of the advisory the preflight read (`advisoryProseOf`, a + * 16-character window) is WITHHELD and the row says so. `resultOf`, * which reads an answer's `html_url`, never sees an advisory's answer. The * self-test files a report and a draft and serves advisories carrying a marker in * their summary, description and vulnerabilities and in every other field, and finds @@ -368,6 +378,7 @@ import { import { ADVISORY_ACCESS_REMEDY, ADVISORY_OPEN_STATES, + ADVISORY_STATE_SHAPE, ADVISORY_TRANSITIONS, GHSA_ID_SHAPE, advisoryCollaboratorLogins, @@ -377,7 +388,6 @@ import { advisoryFenceFailures, advisoryFenceText, advisoryLandedText, - advisoryPlatformText, advisoryReadBack, advisoryReportLandedText, advisoryReportReadBack, @@ -386,6 +396,7 @@ import { GOVERNED_REVIEW_FILES_PAGE_CAP, GOVERNED_REVIEW_FILES_PAGE_SIZE, GOVERNED_REVIEW_OPS, + LOGIN_SHAPE, OPS, PERMISSIONS, PR_LANDED_STATE, @@ -816,9 +827,10 @@ export async function confirmSubIssue(req, answer, pre, payload, api, t, { sleep * The advisory's fences, AUTHORITATIVE (header: "The four advisory rows"): its own GET, judged against * `ADVISORY_FENCES` — for `advisory_collaborators_add` an open advisory (`ADVISORY_OPEN_STATES`), for * `advisory_state` a transition `ADVISORY_TRANSITIONS` allows — then, for the collaborators, the merge. Returns - * `{ plan, state }` (`plan` null for a state change; `plan.missing` empty is the no-op) to proceed, or `{ fail }`, - * the sentence the row prints, the PATCH NOT SENT. ⛔ That sentence carries nothing of the answer but what - * `advisoryFenceText` prints, and a platform `message` only when `advisoryPlatformText` names it. Never throws. + * `{ plan, state, known }` (`plan` null for a state change; `plan.missing` empty is the no-op; `known` the + * advisory's own text, which a later refusal's cause may never quote) to proceed, or `{ fail }`, the sentence the + * row prints, the PATCH NOT SENT. ⛔ That sentence carries nothing of the answer but what `advisoryFenceText` + * prints and, on a refused read, its cause (`advisoryRefusalCause`). Never throws. */ export async function advisoryPreflight(req, payload, api, t) { const spec = req.advisoryCollaborators ?? req.advisoryState; @@ -829,10 +841,96 @@ export async function advisoryPreflight(req, payload, api, t) { const failures = advisoryFenceFailures({ ghsaId, answer: read, from, to }); if (failures.length) { const refused = !(read.status >= 200 && read.status < 300); - return { fail: `REFUSED at the relay's fence on ${ghsaId}, nothing sent — ${advisoryFenceText(failures)}${refused ? `${advisoryPlatformText(read.detail)}; ${ADVISORY_ACCESS_REMEDY}` : ''}` }; + return { fail: `REFUSED at the relay's fence on ${ghsaId}, nothing sent — ${advisoryFenceText(failures)}${refused ? `; ${advisoryRefusalCause(read, advisoryProseOf(spec))}; ${ADVISORY_ACCESS_REMEDY}` : ''}` }; } - if (to) return { plan: null, state: read.json.state }; - return { plan: advisoryCollaboratorPlan({ existing: advisoryCollaboratorLogins(read.json), requested: spec.add }), state: read.json.state }; + const known = advisoryProseOf(read.json); + if (to) return { plan: null, state: read.json.state, known }; + return { plan: advisoryCollaboratorPlan({ existing: advisoryCollaboratorLogins(read.json), requested: spec.add }), state: read.json.state, known }; +} + +// --------------------------------------------------------------------------- +// An advisory refusal's CAUSE — the platform's own `message` and `documentation_url`, capped, ⛔ never the payload. +// --------------------------------------------------------------------------- + +/** The longest platform `message` an advisory refusal prints, in characters; a longer one is cut and says so. */ +export const ADVISORY_REFUSAL_MESSAGE_CAP = 200; +/** The longest `documentation_url` an advisory refusal prints; a longer one is not printed at all (a cut URL misleads). */ +export const ADVISORY_REFUSAL_URL_CAP = 200; +/** A `documentation_url` the row may print: https, a host, a path, and nothing that could break a row or a table. */ +const REFUSAL_URL_SHAPE = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\/[^\s"'`<>|\\]*$/; +/** How much of a known text has to reappear in the platform's answer before the answer is read as quoting it. */ +const QUOTE_WINDOW = 16; +/** Known text shorter than this is not looked for — and nothing that short carries a finding. */ +const QUOTE_MIN = 8; + +/** + * Every string the payload or an advisory's answer carries that a refusal's cause may never quote — the prose. + * An id, a state or a login is left out: each is printable in its own shape anyway (`advisoryPrintable`). So is + * every URL-shaped word: an advisory's own `url` shares its path with GitHub's documentation (both name + * `security-advisories`), and a link carries no finding — the prose around it stays in. Pure. + */ +export function advisoryProseOf(value, out = []) { + if (typeof value === 'string') { + const prose = value.split(/\s+/).filter((w) => !/^[a-z][a-z0-9+.-]*:\/\//i.test(w)).join(' '); + if (prose.length >= QUOTE_MIN && !GHSA_ID_SHAPE.test(prose) && !LOGIN_SHAPE.test(prose) && !ADVISORY_STATE_SHAPE.test(prose)) out.push(prose); + } else if (Array.isArray(value)) { + for (const v of value) advisoryProseOf(v, out); + } else if (value && typeof value === 'object') { + for (const v of Object.values(value)) advisoryProseOf(v, out); + } + return out; +} + +/** Does `text` reproduce any `QUOTE_WINDOW`-long run of a known string (case-insensitively)? Pure. */ +function quotesKnown(text, known) { + const hay = String(text).toLowerCase(); + for (const k of known) { + const s = String(k).toLowerCase(); + const w = Math.min(QUOTE_WINDOW, s.length); + for (let i = 0; i + w <= s.length; i++) if (hay.includes(s.slice(i, i + w))) return true; + } + return false; +} + +/** One table-safe line: control characters and whitespace runs to one space, a pipe to a slash, a backtick or a double quote to a single quote. Pure. */ +function flattenRefusalText(text) { + return String(text).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().replace(/\|/g, '/').replace(/[`"]/g, "'"); +} + +function decodedOrSelf(url) { + try { + return decodeURIComponent(url); + } catch { + return url; + } +} + +/** + * The cause of a refused advisory request, as the row prints it: the platform's `message` and + * `documentation_url` from the refusal's own answer, each capped and on one line — never the answer's `errors`, + * never its body — so the reading a seat puts to the maintainer carries the platform's reason. `known` is the + * prose the row must never print (`advisoryProseOf` of the payload and of any advisory read this run): a + * message or a URL that reproduces any of it is WITHHELD and the row says so, so a refusal that echoes a summary + * or a description cannot carry it into a log. Pure. + */ +export function advisoryRefusalCause(answer, known = []) { + const raw = typeof answer?.detail === 'string' ? answer.detail : typeof answer?.json?.message === 'string' ? answer.json.message : ''; + const oneLine = flattenRefusalText(raw); + // Status 0 is `rest()`'s fetch that threw: the text is the transport's error, never the platform's answer. + if (answer?.status === 0) { + const said = oneLine === '' || quotesKnown(raw, known) ? '' : `: "${oneLine.length > ADVISORY_REFUSAL_MESSAGE_CAP ? `${oneLine.slice(0, ADVISORY_REFUSAL_MESSAGE_CAP)}…` : oneLine}"`; + return `cause: the request did not reach the platform — the transport failed${said}, so the platform gave no reason`; + } + const doc = typeof answer?.json?.documentation_url === 'string' ? answer.json.documentation_url.trim() : ''; + const docQuotes = doc !== '' && (quotesKnown(doc, known) || quotesKnown(decodedOrSelf(doc), known)); + const docText = + doc === '' ? '' : docQuotes ? ' (its documentation_url quotes the payload or the advisory and is not printed)' : doc.length <= ADVISORY_REFUSAL_URL_CAP && REFUSAL_URL_SHAPE.test(doc) ? ` (documentation: ${doc})` : ' (its documentation_url is not printable)'; + if (oneLine === '') return `cause unread: the platform answered with no message${docText}`; + // Judged on the raw message AND the flattened one, against the known prose as given AND flattened the same way, so a + // quoted run that straddles a pipe, a backtick, a quote or a line break is caught whichever side was rewritten. + if (quotesKnown(raw, known) || quotesKnown(oneLine, [...known, ...known.map(flattenRefusalText)])) return `cause not printed: the platform's message quotes the payload or the advisory, and no row prints either${docText}`; + const capped = oneLine.length > ADVISORY_REFUSAL_MESSAGE_CAP ? `${oneLine.slice(0, ADVISORY_REFUSAL_MESSAGE_CAP)}… (cut at ${ADVISORY_REFUSAL_MESSAGE_CAP} characters)` : oneLine; + return `cause, as the platform gives it: "${capped}"${docText}`; } /** @@ -840,11 +938,13 @@ export async function advisoryPreflight(req, payload, api, t) { * `advisoryCreateReadBack` (a 2xx carrying an id that reads state `draft`), * `advisoryReadBack` (every login sent listed) or `advisoryStateReadBack` (the state asked for shown) — and spelled * by the row texts of `ops.mjs` alone. Returns `{ ok, why, result, ghsaId }`. ⛔ Never `resultOf`, never the - * answer's body; a platform `message` only when `advisoryPlatformText` names it. Never throws. + * answer's body; a refusal names its cause (`advisoryRefusalCause`), which never quotes the body this request + * sent or the advisory the preflight read. Never throws. */ export function confirmAdvisory(req, answer, pre) { const refused = !(answer?.status >= 200 && answer?.status < 300); - const failed = (why) => ({ ok: false, why: refused ? `${why}${advisoryPlatformText(answer?.detail)}; ${ADVISORY_ACCESS_REMEDY}` : `${why} — read the advisory before anything else, ⛔ do not re-send` }); + const known = () => [...advisoryProseOf(req?.body), ...(pre?.known ?? [])]; + const failed = (why) => ({ ok: false, why: refused ? `${why}; ${advisoryRefusalCause(answer, known())}; ${ADVISORY_ACCESS_REMEDY}` : `${why} — read the advisory before anything else, ⛔ do not re-send` }); if (req.advisoryReport) { const v = advisoryReportReadBack(answer); if (!v.ok) return refused ? failed(v.why) : { ok: false, why: v.why }; @@ -1353,7 +1453,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // 17 → 21 with the head fence: a stale head, the embedded copy never trusted, a run naming no pull, a pull unread. "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, + "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": 32, }); // 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. @@ -2244,7 +2344,13 @@ export async function selfTest() { ...extra, }, }); - const refusal = (status, message) => ({ status, json: { message, errors: [{ message: MARKER, value: MARKER }], documentation_url: `https://docs.github.test/${MARKER}` } }); + // A refusal: GitHub's message and documentation_url — the two a row prints, its cause — beside an errors array + // carrying the marker, which no row prints. `doc` defaults to a clean URL; the cases below also serve one that + // quotes the marker. + const DOC = 'https://docs.github.test/rest/security-advisories'; + const refusal = (status, message, doc = DOC) => ({ status, json: { ...(message === undefined ? {} : { message }), errors: [{ message: MARKER, value: MARKER }], documentation_url: doc } }); + const cause = (message) => `cause, as the platform gives it: "${message}" (documentation: ${DOC})`; + const WITHHELD = `cause not printed: the platform's message quotes the payload or the advisory, and no row prints either (documentation: ${DOC})`; const report = (extra = {}) => ({ op: 'advisory_report', summary: `${MARKER} — the summary`, description: `${MARKER} — the description`, ...extra }); // A package NAME cannot carry the marker (its shape refuses spaces), so the draft's marker rides its prose alone. const draft = (extra = {}) => ({ op: 'advisory_create', summary: `${MARKER} — the summary`, description: `${MARKER} — the description`, packages: ['@objectstack/rest'], ...extra }); @@ -2272,7 +2378,25 @@ export async function selfTest() { t('⛔ a 2xx carrying no id in its shape is UNCONFIRMED, never landed: exit 5, ONE write, no annotation, and the sentence says do not re-send', [noId.exit, writes(noId.seen).length, noId.annotations, /UNCONFIRMED: a report may have been filed; ⛔ do not re-send/.test(noId.rows[0]?.result ?? '')], [EXIT_ACTION_FAILED, 1, [], true], noId.rows[0]?.result); const reportRefused = await advRun([report()], { ...allowed, [REPORTS]: refusal(422, 'Validation Failed') }); const reportForbidden = await advRun([report()], { ...allowed, [REPORTS]: refusal(403, MARKER) }); - t('⛔ a refused report is a FAILED action: its status, a platform message only when the relay names it, and the remedy — never a wider token', [reportRefused.exit, reportRefused.rows[0]?.result, reportForbidden.rows[0]?.result], [EXIT_ACTION_FAILED, `FAILED: HTTP 422 (the platform: Validation Failed); ${ADVISORY_ACCESS_REMEDY}`, `FAILED: HTTP 403; ${ADVISORY_ACCESS_REMEDY}`]); + t('⛔ a refused report is a FAILED action: its status, its cause — the platform\'s message and documentation_url, withheld when the message quotes the payload — and the remedy, never a wider token', [reportRefused.exit, reportRefused.rows[0]?.result, reportForbidden.rows[0]?.result], [EXIT_ACTION_FAILED, `FAILED: HTTP 422; ${cause('Validation Failed')}; ${ADVISORY_ACCESS_REMEDY}`, `FAILED: HTTP 403; ${WITHHELD}; ${ADVISORY_ACCESS_REMEDY}`]); + // ⭐ The relay's measured defect: a 403 on a report whose message is none of the six strings the row used to + // know printed NO cause. A made-up message and short link stand in for the platform's; the row now carries both. + const UNLISTED = 'New private vulnerability reports from this account are limited for today'; + const SHORT_LINK = 'https://gh.test/pvr-limit'; + const limited = await advRun([report()], { ...allowed, [REPORTS]: refusal(403, UNLISTED, SHORT_LINK) }); + t('⭐ a refused report whose message the relay never listed names that message and its documentation_url as the cause, in the row, the summary and the log — the reading put to the maintainer carries the reason', [limited.exit, limited.rows[0]?.result, limited.summary.includes(`cause, as the platform gives it: "${UNLISTED}" (documentation: ${SHORT_LINK})`), limited.logs.some((l) => l.includes(UNLISTED) && l.includes(SHORT_LINK))], [EXIT_ACTION_FAILED, `FAILED: HTTP 403; cause, as the platform gives it: "${UNLISTED}" (documentation: ${SHORT_LINK}); ${ADVISORY_ACCESS_REMEDY}`, true, true]); + const encodedDoc = `https://docs.github.test/${encodeURIComponent(MARKER)}`; + const quotingUrl = await advRun([report()], { ...allowed, [REPORTS]: refusal(403, 'Forbidden', encodedDoc) }); + const bareRefusal = await advRun([report()], { ...allowed, [REPORTS]: refusal(403, undefined, '') }); + t('⛔ a documentation_url that quotes the payload — percent-encoded included — is dropped and the message still printed; an answer with no message says the cause is unread', [quotingUrl.rows[0]?.result, bareRefusal.rows[0]?.result], [`FAILED: HTTP 403; cause, as the platform gives it: "Forbidden" (its documentation_url quotes the payload or the advisory and is not printed); ${ADVISORY_ACCESS_REMEDY}`, `FAILED: HTTP 403; cause unread: the platform answered with no message; ${ADVISORY_ACCESS_REMEDY}`]); + const LONG = `${'a limit '.repeat(40)}`.trim(); + const longRefusal = await advRun([report()], { ...allowed, [REPORTS]: refusal(403, `two\nlines | a pipe \`tick\` "quote"`, `${DOC}/${'x'.repeat(ADVISORY_REFUSAL_URL_CAP)}`) }); + t('⛔ the cause is capped and kept to one table-safe line: a long message cut at the cap and saying so, a newline, a pipe, a backtick and a double quote flattened, an over-long or ill-shaped documentation_url not printed', [advisoryRefusalCause({ status: 403, detail: LONG, json: { message: LONG } }), longRefusal.rows[0]?.result, advisoryRefusalCause({ status: 403, json: { message: 'Forbidden', documentation_url: 'javascript:alert(1)' } })], [`cause, as the platform gives it: "${LONG.slice(0, ADVISORY_REFUSAL_MESSAGE_CAP)}… (cut at ${ADVISORY_REFUSAL_MESSAGE_CAP} characters)"`, `FAILED: HTTP 403; cause, as the platform gives it: "two lines / a pipe 'tick' 'quote'" (its documentation_url is not printable); ${ADVISORY_ACCESS_REMEDY}`, `cause, as the platform gives it: "Forbidden" (its documentation_url is not printable)`]); + // A quoted run that straddles a pipe: flattened alone, "|ADVISORY MARKER|" reads "/ADVISORY MARKER/", which no + // 16-character window of the known prose matches — only the raw message reproduces it. + const straddle = await advRun([report({ summary: `the |ADVISORY MARKER| summary` })], { ...allowed, [REPORTS]: refusal(403, 'Field |ADVISORY MARKER| refused') }); + t('⛔ a message is judged raw as well as flattened: a run of the payload that straddles a pipe is withheld, not printed rewritten', straddle.rows[0]?.result, `FAILED: HTTP 403; ${WITHHELD}; ${ADVISORY_ACCESS_REMEDY}`); + t('⛔ status 0 — the fetch threw — never reads as the platform\'s reason: the row says the request did not reach it, and a transport text quoting the payload is dropped', [advisoryRefusalCause({ status: 0, json: null, detail: 'fetch failed' }), advisoryRefusalCause({ status: 0, json: null, detail: `failed on ${MARKER}` }, [MARKER]), advisoryRefusalCause({ status: 0, json: null, detail: '' })], ['cause: the request did not reach the platform — the transport failed: "fetch failed", so the platform gave no reason', 'cause: the request did not reach the platform — the transport failed, so the platform gave no reason', 'cause: the request did not reach the platform — the transport failed, so the platform gave no reason']); // ── advisory_create const drafted = await advRun([draft({ severity: 'high', cwe_ids: ['CWE-863'] })], { ...allowed, [CREATE]: advisory({}, 201) }); @@ -2285,7 +2409,7 @@ export async function selfTest() { t('⛔ a 2xx carrying no id in its shape is UNCONFIRMED, and one whose id reads another state than draft FAILED — never landed: exit 5, ONE write, no annotation, and each sentence says do not re-send', [draftNoId.exit, writes(draftNoId.seen).length, draftNoId.annotations, /UNCONFIRMED: a draft advisory may have been created; ⛔ do not re-send/.test(draftNoId.rows[0]?.result ?? ''), draftTriage.exit, writes(draftTriage.seen).length, draftTriage.annotations, draftTriage.rows[0]?.result], [EXIT_ACTION_FAILED, 1, [], true, EXIT_ACTION_FAILED, 1, [], `FAILED: HTTP 201, but ${GHSA} reads state triage, not draft — ⛔ do not re-send, put the id to the maintainer`], draftNoId.rows[0]?.result); const draftRefused = await advRun([draft()], { ...allowed, [CREATE]: refusal(403, 'Resource not accessible by integration') }); const draftInvalid = await advRun([draft()], { ...allowed, [CREATE]: refusal(422, MARKER) }); - t('⛔ a refused draft is a FAILED action: its status, a platform message only when the relay names it, and the remedy — never a wider token', [draftRefused.exit, draftRefused.rows[0]?.result, draftInvalid.rows[0]?.result], [EXIT_ACTION_FAILED, `FAILED: HTTP 403 (the platform: Resource not accessible by integration); ${ADVISORY_ACCESS_REMEDY}`, `FAILED: HTTP 422; ${ADVISORY_ACCESS_REMEDY}`]); + t('⛔ a refused draft is a FAILED action: its status, its cause — withheld when the message quotes the payload — and the remedy, never a wider token', [draftRefused.exit, draftRefused.rows[0]?.result, draftInvalid.rows[0]?.result], [EXIT_ACTION_FAILED, `FAILED: HTTP 403; ${cause('Resource not accessible by integration')}; ${ADVISORY_ACCESS_REMEDY}`, `FAILED: HTTP 422; ${WITHHELD}; ${ADVISORY_ACCESS_REMEDY}`]); // ── advisory_collaborators_add const added = await advRun([add(['OS-DEV-A', 'os-dev-b'])], { ...allowed, [A_GET]: advisory(), [A_PATCH]: advisory({ collaborating_users: logins('os-dev-a', 'os-dev-b') }) }); @@ -2300,12 +2424,12 @@ export async function selfTest() { t('⛔ a published, closed or withdrawn advisory sends NO PATCH: each refused at the state fence, not sent, exit 5', shut, [0, 1, 2].map(() => [EXIT_ACTION_FAILED, 0, 'not sent', true])); const other = await advRun([add()], { ...allowed, [A_GET]: advisory({ ghsa_id: OTHER }) }); const unread = await advRun([add()], { ...allowed, [A_GET]: refusal(404, 'Not Found') }); - t('⛔ an answer naming another advisory, or a read the platform refuses, sends NO PATCH — the refusal quoting a message the relay names, with the remedy', [notSent(other), notSent(unread), unread.rows[0]?.result.includes('(the platform: Not Found)') && unread.rows[0]?.result.includes(ADVISORY_ACCESS_REMEDY)], [[EXIT_ACTION_FAILED, 0, 'not sent', true], [EXIT_ACTION_FAILED, 0, 'not sent', true], true]); + t('⛔ an answer naming another advisory, or a read the platform refuses, sends NO PATCH — the refusal naming its cause, with the remedy', [notSent(other), notSent(unread), unread.rows[0]?.result.includes(`; ${cause('Not Found')}; ${ADVISORY_ACCESS_REMEDY}`)], [[EXIT_ACTION_FAILED, 0, 'not sent', true], [EXIT_ACTION_FAILED, 0, 'not sent', true], true]); const lacking = await advRun([add(['os-dev-b'])], { ...allowed, [A_GET]: advisory(), [A_PATCH]: advisory() }); const dropping = await advRun([add(['os-dev-b'])], { ...allowed, [A_GET]: advisory(), [A_PATCH]: advisory({ collaborating_users: logins('os-dev-b') }) }); t('⛔ a PATCH answer that does not list a login fails the action: a requested one not added, an existing one dropped — each named, ⛔ never re-sent', [lacking.exit, lacking.rows[0]?.result, dropping.exit, dropping.rows[0]?.result], [EXIT_ACTION_FAILED, 'FAILED: HTTP 200, but the answer lists os-dev-a — os-dev-b not added — read the advisory before anything else, ⛔ do not re-send', EXIT_ACTION_FAILED, 'FAILED: HTTP 200, but the answer lists os-dev-b — os-dev-a dropped — a collaborator before this PATCH and missing from its answer — read the advisory before anything else, ⛔ do not re-send']); const patchRefused = await advRun([add()], { ...allowed, [A_GET]: advisory(), [A_PATCH]: refusal(403, 'Resource not accessible by integration') }); - t('⛔ a refused PATCH is a FAILED action with the remedy', [patchRefused.exit, writes(patchRefused.seen).length, patchRefused.rows[0]?.result], [EXIT_ACTION_FAILED, 1, `FAILED: HTTP 403 (the platform: Resource not accessible by integration); ${ADVISORY_ACCESS_REMEDY}`]); + t('⛔ a refused PATCH is a FAILED action naming its cause, with the remedy', [patchRefused.exit, writes(patchRefused.seen).length, patchRefused.rows[0]?.result], [EXIT_ACTION_FAILED, 1, `FAILED: HTTP 403; ${cause('Resource not accessible by integration')}; ${ADVISORY_ACCESS_REMEDY}`]); // ── advisory_state const accepted = await advRun([move('draft')], { ...allowed, [A_GET]: advisory({ state: 'triage' }), [A_PATCH]: advisory({ state: 'draft' }) }); @@ -2323,7 +2447,7 @@ export async function selfTest() { t('⛔ each illegal transition sends NO PATCH: draft from anything but triage, closed from closed, published or withdrawn — refused at the state fence, not sent, exit 5', illegal, illegal.map(([m]) => [m, EXIT_ACTION_FAILED, 0, 'not sent', true])); const wrongState = await advRun([move('draft')], { ...allowed, [A_GET]: advisory({ state: 'triage' }), [A_PATCH]: advisory({ state: 'triage' }) }); const stateRefused = await advRun([move('closed')], { ...allowed, [A_GET]: advisory(), [A_PATCH]: refusal(422, MARKER) }); - t('⛔ a PATCH answer that does not show the state asked for fails the action, naming what it shows; a refused one carries the remedy', [wrongState.exit, wrongState.rows[0]?.result, stateRefused.exit, stateRefused.rows[0]?.result], [EXIT_ACTION_FAILED, 'FAILED: HTTP 200, but the answer reads state triage, not draft — read the advisory before anything else, ⛔ do not re-send', EXIT_ACTION_FAILED, `FAILED: HTTP 422; ${ADVISORY_ACCESS_REMEDY}`]); + t('⛔ a PATCH answer that does not show the state asked for fails the action, naming what it shows; a refused one whose message quotes the ADVISORY the preflight read is withheld, with the remedy', [wrongState.exit, wrongState.rows[0]?.result, stateRefused.exit, stateRefused.rows[0]?.result], [EXIT_ACTION_FAILED, 'FAILED: HTTP 200, but the answer reads state triage, not draft — read the advisory before anything else, ⛔ do not re-send', EXIT_ACTION_FAILED, `FAILED: HTTP 422; ${WITHHELD}; ${ADVISORY_ACCESS_REMEDY}`]); // ── the validator's gate, inside the executor: zero requests const gated = []; @@ -2336,7 +2460,7 @@ export async function selfTest() { // ── ⛔ the marker sweep: every run above, every channel this file emits on const emitted = swept.map(({ r, paceLines }) => [...(r.logs ?? []), ...(r.lines ?? []), r.summary ?? '', JSON.stringify(r.rows ?? []), JSON.stringify(r.annotations ?? []), ...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 — filed, unconfirmed and refused reports, created, unconfirmed, wrong-state and refused drafts, landed, no-op, fenced, lacking and refused PATCHes, legal and illegal moves, refused payloads — the marker reaches NO log line, row, step summary, annotation or pace-log line', [swept.length, leaks], [41, []], leaks.map((i) => emitted[i].split('\n').find((l) => l.includes('ADVISORY MARKER'))).join(' | ')); + t('⛔ THE MARKER TEST: across every run of this battery — filed, unconfirmed and refused reports, created, unconfirmed, wrong-state and refused drafts, landed, no-op, fenced, lacking and refused PATCHes, legal and illegal moves, refused payloads — the marker reaches NO log line, row, step summary, annotation or pace-log line', [swept.length, leaks], [46, []], leaks.map((i) => emitted[i].split('\n').find((l) => l.includes('ADVISORY MARKER'))).join(' | ')); // Positive controls: the marker was there to leak, and the needle finds it where it is. t('…positive control: every answer served carried the marker, the report\'s and the draft\'s POST carried it to the platform — where it belongs — and the fenced runs made their GET', [swept.every(({ answers }) => JSON.stringify(answers).includes('ADVISORY MARKER') || Object.keys(answers).length === 1), JSON.stringify(writes(filed.seen)[0]?.body).includes('ADVISORY MARKER') && JSON.stringify(writes(drafted.seen)[0]?.body).includes('ADVISORY MARKER'), [other, unread, ...[added, kept, present]].every((r) => r.seen.some((s) => s.call === A_GET))], [true, true, true]); t('…positive control: the generic row printer, given the same answer, WOULD print it — the advisory rows never reach it', resultOf({}, advisory().json).includes('ADVISORY MARKER'), true); diff --git a/scripts/pm/write-pace.mjs b/scripts/pm/write-pace.mjs index d58491d0a6e..07d17a67f3a 100644 --- a/scripts/pm/write-pace.mjs +++ b/scripts/pm/write-pace.mjs @@ -417,10 +417,11 @@ export function decidePace({ records = [], key, nowMs, minGapMs = DEFAULT_MIN_GA * 200 that spends the last unit of quota, which is the one answer that lets a * seat stop BEFORE the first refusal rather than after it. */ -export function stopSignalFrom({ status, headers, body, verdict } = {}, nowMs = Date.now()) { +export function stopSignalFrom({ status, headers, body, verdict, env = process.env } = {}, nowMs = Date.now()) { // ⛔ A refusal the egress PROXY answered never reached GitHub, so nothing in it is a platform back-off // signal — whatever a caller's verdict says. Read first, so a misclassified proxy 403 cannot write a marker. - if (isProxyRefusal(body)) return null; + // On an Actions runner there is no proxy, so every answer there is GitHub's and is judged below. + if (proxyAnswered(body, env)) return null; const code = Number(status); const retryAfterMs = parseRetryAfter(headerValue(headers, 'retry-after'), nowMs); const remaining = headerValue(headers, 'x-ratelimit-remaining'); @@ -446,8 +447,14 @@ export function stopSignalFrom({ status, headers, body, verdict } = {}, nowMs = * discriminant, measured in a cloud seat container on the `/app/**` mint path: * `{"message":"Access to this GitHub API path is not permitted through this * proxy.","documentation_url":"https://docs.anthropic.com/…"}` — the message - * names the proxy, and the `documentation_url` points at Claude Code's docs, - * never at GitHub's. Either mark is enough. Accepts a string or a parsed object. + * names the proxy, and the `documentation_url` points at the proxy's OWN docs + * (`PROXY_DOC_HOSTS`). Either mark is enough. Accepts a string or a parsed object. + * + * ⛔ Both marks are POSITIVE: they name the proxy. "A `documentation_url` that is + * not on github.com" is not one — GitHub's own 403s may point at a short link or + * a changelog post on another host, and reading such an answer as the proxy's + * gives back the slot and writes no marker on a real platform refusal. Pure: the + * body alone. Whether a proxy can be in the path at all is `proxyAnswered`'s. */ export function isProxyRefusal(body) { if (body === null || body === undefined) return false; @@ -455,7 +462,29 @@ export function isProxyRefusal(body) { const message = typeof obj?.message === 'string' ? obj.message : typeof body === 'string' ? body : ''; if (/not permitted through this proxy/i.test(message)) return true; const doc = typeof obj?.documentation_url === 'string' ? obj.documentation_url : ''; - return doc !== '' && !/^https?:\/\/([a-z0-9-]+\.)*github\.com\//i.test(doc); + return doc !== '' && PROXY_DOC_HOSTS.test(doc); +} + +/** The hosts the egress proxy's refusal points its `documentation_url` at — the proxy's docs, never GitHub's. */ +export const PROXY_DOC_HOSTS = /^https?:\/\/([a-z0-9-]+\.)*(anthropic\.com|claude\.com|claude\.ai)\//i; + +/** + * Is this process on a GitHub Actions runner — the relay's executor among them? + * `GITHUB_ACTIONS` is `true` there and unset in a cloud seat container. A runner + * has no egress proxy in front of it, so a 403 it receives is GitHub's. + */ +export function inActionsRunner(env = process.env) { + return env?.GITHUB_ACTIONS === 'true'; +} + +/** + * Did the egress proxy answer this, rather than GitHub? Never on an Actions + * runner (`inActionsRunner`), however the body reads; elsewhere `isProxyRefusal`. + * This is the predicate the throttle acts on: inside the relay a GitHub 403 takes + * the platform path (`stopSignalFrom`), never the proxy's slot give-back. + */ +export function proxyAnswered(body, env = process.env) { + return !inActionsRunner(env) && isProxyRefusal(body); } function safeParse(text) { @@ -1131,7 +1160,8 @@ export function noteResponse({ token, status, headers, body, verdict } = {}, dep const nowMs = now(); // A refusal the egress proxy answered is a ROUTE failure: the request never reached GitHub, so the slot // `paceWrite` recorded for it is given back (no budget spent), no marker is written, and ONE line says so. - if (isProxyRefusal(body)) { + // ⛔ Never on an Actions runner (`proxyAnswered`): no proxy stands there, so its 403 is GitHub's and takes rule ③. + if (proxyAnswered(body, env)) { const key = tokenKey(token); let retracted = false; try { @@ -1161,7 +1191,7 @@ export function noteResponse({ token, status, headers, body, verdict } = {}, dep ); return { proxyRefusal: true, retracted, key, file }; } - const signal = stopSignalFrom({ status, headers, body, verdict }, nowMs); + const signal = stopSignalFrom({ status, headers, body, verdict, env }, nowMs); if (!signal) { releaseWriteLease(file); // rule ④: the response is in hand, the turn is over return null; @@ -1262,7 +1292,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the limits: overrides are for tests, and a malformed one never loosens': 10, 'rule ①: the remainder is slept, and the pause is said out loud': 7, 'rule ②: the 41st is REFUSED, with a prescription': 8, - 'rule ③: what writes a stop marker, how long it lasts, what it refuses': 14, + 'rule ③: what writes a stop marker, how long it lasts, what it refuses': 19, 'the prune: the trailing hour for writes, the expiry for markers': 8, 'the log, --status and the CLI: what lands on disk, and what must never': 14, 'rule ④: the lease — one write in flight, fleet-wide, measured on real processes': 12, @@ -1467,8 +1497,15 @@ export async function selfTest() { stopSignalFrom({ status: 429, headers: { 'retry-after': '60' } }, T0).untilMs - T0, ], [5400_000, STOP_MARKER_MS]); const PROXY_403 = { message: 'Access to this GitHub API path is not permitted through this proxy.', documentation_url: 'https://docs.anthropic.com/en/docs/claude-code/github-actions' }; - t('⭐ the egress PROXY\'s 403 is recognised by its body (measured shape): the message naming the proxy, or a documentation_url that is not GitHub\'s', [isProxyRefusal(PROXY_403), isProxyRefusal({ documentation_url: 'https://docs.anthropic.com/x' }), isProxyRefusal(JSON.stringify(PROXY_403)), isProxyRefusal({ message: 'Bad credentials', documentation_url: 'https://docs.github.com/rest' }), isProxyRefusal({ message: 'You have exceeded a secondary rate limit', documentation_url: 'https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api' }), isProxyRefusal(null)], [true, true, true, false, false, false]); - t('⛔ …and it NEVER writes a marker — even handed a ratelimit verdict or a zero header, the request never reached GitHub', [stopSignalFrom({ status: 403, body: PROXY_403 }, T0), stopSignalFrom({ status: 403, body: PROXY_403, verdict: 'ratelimit' }, T0), stopSignalFrom({ status: 403, body: PROXY_403, headers: { 'x-ratelimit-remaining': '0' } }, T0)], [null, null, null]); + t('⭐ the egress PROXY\'s 403 is recognised by its body (measured shape): the message naming the proxy, or a documentation_url on the proxy\'s own docs', [isProxyRefusal(PROXY_403), isProxyRefusal({ documentation_url: 'https://docs.anthropic.com/x' }), isProxyRefusal(JSON.stringify(PROXY_403)), isProxyRefusal({ message: 'Bad credentials', documentation_url: 'https://docs.github.com/rest' }), isProxyRefusal({ message: 'You have exceeded a secondary rate limit', documentation_url: 'https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api' }), isProxyRefusal(null)], [true, true, true, false, false, false]); + // GitHub's own refusals can point off github.com — a short link, a changelog post. Neither names the proxy. + const GITHUB_403_OFF_HOST = [{ message: 'Forbidden', documentation_url: 'https://gh.io/some-limit' }, { message: 'Forbidden', documentation_url: 'https://github.blog/changelog/2026-10-01-a-limit' }, { documentation_url: 'https://docs.github.com.evil.test/x' }]; + t('⛔ a documentation_url that is merely NOT on github.com does not make a body the proxy\'s: a GitHub short link, a changelog post and a look-alike host are all read as GitHub\'s', GITHUB_403_OFF_HOST.map((b) => isProxyRefusal(b)), [false, false, false]); + t('⛔ …and it NEVER writes a marker — even handed a ratelimit verdict or a zero header, the request never reached GitHub', [stopSignalFrom({ status: 403, body: PROXY_403, env: {} }, T0), stopSignalFrom({ status: 403, body: PROXY_403, verdict: 'ratelimit', env: {} }, T0), stopSignalFrom({ status: 403, body: PROXY_403, headers: { 'x-ratelimit-remaining': '0' }, env: {} }, T0)], [null, null, null]); + // ⭐ The relay runner: no egress proxy stands in front of an Actions runner, so NO body there is the proxy's. + const RUNNER = { GITHUB_ACTIONS: 'true' }; + t('⭐ on an Actions runner nothing is the proxy\'s answer, whatever the body says; in a seat container the body decides', [inActionsRunner(RUNNER), inActionsRunner({}), inActionsRunner({ GITHUB_ACTIONS: 'false' }), proxyAnswered(PROXY_403, RUNNER), proxyAnswered(PROXY_403, {}), proxyAnswered(GITHUB_403_OFF_HOST[0], {})], [true, false, false, false, true, false]); + t('⭐ …so a 403 on the runner takes the PLATFORM path: a ratelimit verdict, a secondary-limit body or a zero header writes the marker even when the body would read as the proxy\'s elsewhere', [stopSignalFrom({ status: 403, body: PROXY_403, verdict: 'ratelimit', env: RUNNER }, T0)?.untilMs, stopSignalFrom({ status: 403, body: { message: 'You have exceeded a secondary rate limit', documentation_url: 'https://docs.anthropic.com/x' }, env: RUNNER }, T0) !== null, stopSignalFrom({ status: 403, body: PROXY_403, headers: { 'x-ratelimit-remaining': '0' }, env: RUNNER }, T0) !== null], [T0 + STOP_MARKER_MS, true, true]); { const pf = join(dir, 'proxy-refusal.jsonl'); const ph = harness(pf, { env: { OS_PM_WRITE_MIN_GAP_MS: '0' } }); @@ -1480,6 +1517,18 @@ export async function selfTest() { const next = await paceWrite({ token: TOKEN, kind: 'fleet-token POST' }, ph.deps); t('…and the next write by that token is allowed at once — nothing was spent, nothing is stopped', next.verdict, 'ok'); } + { + // The relay runner's case: GitHub's 403 there, carrying a documentation_url off github.com. + const rf = join(dir, 'runner-403.jsonl'); + const rh = harness(rf, { env: { OS_PM_WRITE_MIN_GAP_MS: '0', GITHUB_ACTIONS: 'true' } }); + await paceWrite({ token: TOKEN, kind: 'fleet-write POST' }, rh.deps); + const plain = noteResponse({ token: TOKEN, status: 403, body: GITHUB_403_OFF_HOST[0] }, rh.deps); + const kept = readRecordsFrom(rf); + t('⛔ noteResponse on the runner never prints the proxy line: a plain GitHub 403 keeps its slot spent, writes no marker (rule ③ reads no back-off signal in it), releases the lease', [plain, kept.filter((r) => typeof r.t === 'number').length, kept.some((r) => r.stop !== undefined), existsSync(`${rf}.lease`), rh.text().includes('egress PROXY')], [null, 1, false, false, false]); + await paceWrite({ token: TOKEN, kind: 'fleet-write POST' }, rh.deps); + const limited = noteResponse({ token: TOKEN, status: 403, body: PROXY_403, verdict: 'ratelimit' }, rh.deps); + t('⭐ …and a 403 there that carries a back-off signal writes the stop marker — the proxy-shaped body notwithstanding', [limited?.recorded, readRecordsFrom(rf).some((r) => r.stop === T0 + STOP_MARKER_MS), rh.text().includes('egress PROXY')], [true, true, false]); + } const file = join(dir, 'marker.jsonl'); const h = harness(file, { env: { OS_PM_WRITE_MIN_GAP_MS: '0' } });