Skip to content

Commit 12415a8

Browse files
feat(pm): advisory_read relays an advisory's text sealed to a one-time key the seat alone holds (#29536)
domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J <!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xNgxxB3u6i786dztiDPWQXs)_ Part of #29420 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:中继新增一个只读动作 `advisory_read`。席位进程每读一次就在内存里现生成一对一次性 X25519 密钥,只把公钥随请求送出;中继用本笔独有的 `repository-advisories: read` 令牌读公告,当场用这把公钥封装(只用 Node 自带 `crypto`),只把密文经运行通知回传;席位用内存里的私钥解开,加 `--reveal` 才把明文印到自己的 stdout。 - 为什么改:席位账号直读公告被平台拒绝(403),而公开的运行日志里不能出现公告的任何字段。封装之后,公告文本只在持有私钥的那个席位进程里是明文,私钥从不落盘、不出进程。 - 风险与回滚:只动 `scripts/pm/fleet-write/` 四个脚本、两份技能参考各一行和行数棘轮,不发布任何包,不改工作流;回滚即 revert 本 PR。公开运行上看得到的只有状态码、字节数、分片数和密文本身。 - 先看:`scripts/pm/fleet-write/execute.mjs` 的 `advisoryReadAction`(先过闸再封装再分片,封装失败什么都不发);`scripts/pm/fleet-write/ops.mjs` 的 `sealAdvisoryRead` 和 `advisory_read` 行(行上 ⚖ 注写明发送者闸的两种选择);`scripts/pm/fleet-write/dispatch.mjs` 的 `armAdvisoryReads` 与 `--reveal`。 - 两分钟验证:`node scripts/pm/fleet-write/execute.mjs --self-test`,预期打印 259 个用例在 22 组里全部通过,其中 advisory_read 一组 17 例,标记扫描覆盖 19 次运行无一命中。 - 需要你做的:裁定发送者闸。保留默认 `collaborator`(推荐:发送者必须在该公告的协作者名单里),或把行上的 `senderGate` 改为 `target-write`(对目标仓有写权限即可)。 ## What changes - `scripts/pm/fleet-write/ops.mjs`: the `advisory_read` row and everything it needs. - **The row**: requires `ghsa_id` and `public_key`, nothing optional, travels `alone`, spends `repository-advisories` at its own `grant: 'read'`, and carries `senderGate: 'collaborator'` with a ⚖ comment recording the choice for the maintainer. Its one request is a GET of the advisory, and it carries an `advisoryRead` marker so the executor takes the sealed path. - **The `public_key` field kind (`x25519`)**: base64 SPKI DER of exactly 60 characters, the X25519 prefix pinned by a regular expression. A trial derivation refuses low-order points. - **`sealAdvisoryRead` / `openAdvisoryRead`**, Node `crypto` only: - X25519 ECDH with a fresh ephemeral key per seal; - HKDF-SHA256, whose info binds a scheme label and both raw public keys; - AES-256-GCM with a 12-byte nonce and a 16-byte tag; - associated data of request id, action index and advisory id, so a sealed answer opens for the read that asked for it and no other; - a one-byte version, and the envelope in base64url (61 bytes of overhead); - the shared secret and the derived key are zeroed after use, and an all-zero secret is refused; - the open uses a strict UTF-8 decoder. - **The channel**: `advisoryReadParts` cuts the sealed text into at most 10 notice parts of 4,000 characters each, inside the runner's measured caps (10 notices a step, messages cut at 4,096 characters). That is 40,000 sealed characters, or 29,939 bytes of answer. - **The fences** (`advisoryReadFenceFailures`) cover the read itself, the advisory, and the sender. An unknown gate value fails closed. - **The public-surface texts**: `advisoryReadCall` prints a placeholder path where every other row prints its real one. The landed and over-cap texts carry byte and part counts alone. - `scripts/pm/fleet-write/validate.mjs`: the `x25519` kind's refusal describes the value by type and byte count and never echoes it. Grants are now per row (`OPS[op].grant`, falling back to the permission's default level), so a read's stroke mints `repository-advisories=read` and every other advisory stroke still mints write. The stroke's token-permission map gives write precedence when two rows meet, which `alone` already prevents. A new battery holds 28 cases. - `scripts/pm/fleet-write/execute.mjs`: `advisoryReadAction` runs these steps in order: 1. ONE GET; a call that threw before any answer (`rest`'s status 0) is FAILED saying the request did not reach the platform, row status `not reached`, with no HTTP status, no grant remedy and never the exception's message; 2. the fences, with zero emitted on any failure; 3. the picked fields as JSON; 4. the seal, where a throw or a non-base64url result is a FAILED action with one fixed sentence and no part; 5. the parts, where none means over cap and the action fails on byte counts alone; 6. one notice per part. The row and the summary carry the placeholder call, the status, and the counts. A new battery holds 17 cases. - `scripts/pm/fleet-write/dispatch.mjs`: arming and opening at the seat. - `armAdvisoryReads` makes one key pair per read in this process and puts the public half into the action. A `public_key` supplied in the actions file is refused (exit 2) without being echoed. - `openRunAdvisoryReads` reassembles the run's notice parts and opens them with the private half. A read that does not open is UNVERIFIED (exit 6). - `--reveal` prints the opened text to stdout, and nothing else ever prints it. `--reveal` without a read is exit 2. - `--json` carries state, byte count, field names and parts, never text. - A new battery holds 17 cases, including an end-to-end run through the real executor. - `.claude/skills/pm-dispatch/references/rest-channel.md`: one line naming `advisory_read`. - `.claude/skills/pm-dispatch/references/security-findings.md`: one line pointing a seat's advisory read at the relay. - `scripts/pm/check-skill-line-ratchet.mjs`: those two rows, 81 → 82 and 55 → 56, the measured delta of +1 each. ## Why A seat's own account is refused when it reads an advisory, and the relay's run is public. Without a seal, any read through the relay puts the advisory's text on the run's log and annotations. The card's design keeps the text in plaintext only inside the seat process that asked: the private key exists for one read, in memory, and is never written anywhere. ## Tests | Self-test | `origin/main` 8e505db | this head (453d4e1, main merged) | | --- | --- | --- | | `validate.mjs --self-test` | 275 cases / 15 batteries | 303 / 16 (+28, the new battery) | | `execute.mjs --self-test` | 242 / 21 | 259 / 22 (+17) | | `dispatch.mjs --self-test` | 298 / 26 | 315 / 27 (+17) | Each new battery raises its file's battery floor by one. The card's pinned cases each have a case: - **A malformed `ghsa_id` or `public_key` is refused before any request.** Validate covers wrong length, wrong prefix, not base64, a low-order point and a non-string, each refused without an echo. The executor's refused payloads make zero requests. - **`advisory_read` beside another action is refused.** This holds beside a comment, beside another advisory row, and for two reads together. - **Only this stroke's token carries the grant.** The read stroke mints the five literal grants plus `repository-advisories=read`, never write and never actions. Every other stroke mints no advisory grant. Both are checked through the validator's real process output. - **A GET that never reached the platform says so.** A call that throws, its message carrying the id and the marker, is FAILED with row status `not reached`: no "HTTP 0", no grant remedy, and the exception's message printed nowhere. - **A failed encryption sends nothing back and fails.** A seal that throws, carrying the marker in its message, and a seal that returns the plaintext unsealed are both FAILED, with no part, no notice, and the exception's message printed nowhere. - **The decrypted text matches the GET byte for byte.** This holds for a single part and for a long answer cut into parts. - ⛔ **The marker test.** A marker is planted in the summary, the description, the vulnerabilities and every other field of the answer but its id and state. It is absent from every log line, row, summary and annotation across 19 executor runs (landed, long, every fence, both gates, a GET that never reached the platform, a thrown seal, over cap, refused payloads). It is absent from the sealed answer's characters and its decoded bytes, while the opened text carries it as a positive control. An id sweep likewise finds neither the requested nor the answered advisory id on any public line. **Ablations.** Every mutation was re-run at head 453d4e1 (after the merge of main) with `scripts/ablation-replace.mjs`, in a clean worktree, against the committed blobs: ops `ce0656b3454b`, validate `8ea5abcdd17a`, execute `c328a12cd284`, dispatch `51075987d172`. In every run the anchor read 1 → 0, the self-test exited 1, and the restore was proved (blob == HEAD, `git diff HEAD` empty). The worktree had 0 changed files afterwards. | # | Mutation | Self-test | Red cases | | --- | --- | --- | --- | | A1 | the seal returns the plaintext as base64url (identity) | execute | 3 of 259: byte for byte, "the sealed answer ALONE carries none of the marker", the long answer | | A2 | the landed row appends the answer's summary | execute | 2 of 259: the landed row, THE MARKER TEST | | A3 | the row prints the real path with the id | execute | 3 of 259: the landed row, the fence ids, "NO advisory id" | | A4 | the `x25519` refusal echoes the value | validate | 1 of 303: "NO refusal echoes the value" | | A4b | same mutation | execute | 2 of 259: THE MARKER TEST, the real-process validator case | | A5 | the sender fence opens for any gate | execute | 1 of 259: the collaborator default gate | | A5b | same mutation | validate | 3 of 303: the collaborator gate, the read fence, the fence sentence | | A6 | the row's grant raised from read to write | validate | 4 of 303: the rows, the row, alone, the grant fence | | A7 | the opened text printed without `--reveal` | dispatch | 1 of 315: "the CLI without --reveal … printed NOWHERE" | | A8 | a failed seal falls back to the unsealed plaintext | execute | 1 of 259: "a failed encryption sends NOTHING back and fails" | | A10 | `alone: true` removed from the row | validate | 4 of 303: the rows pin, the row, the alone fence and its message | | A11 | the status-0 branch removed (S3) | execute | 1 of 259: "a GET that never reached the platform … is FAILED saying so" | `eslint` on the four relay scripts and the ratchet: exit 0. ## Acceptance notes - ⚖ **The sender gate is the maintainer's ruling.** The default is `collaborator`, which the card recommends: the sender must appear, matched case-insensitively, in the advisory's own `collaborating_users`. Teams are not read, so a sender who reaches the advisory only through a team is refused. The alternative is `target-write`, set by changing the row's `senderGate`: write on the target repository is enough, the same gate every other relay row uses. Both are tested, and a value that is neither fails closed. The default holds until the ruling. - **The grant is documented but unmeasured.** The row mints `repository-advisories: read`. GitHub documents this GET under the read grant, but only the write grant's read was measured (run 41415522766, recorded in the card). The first live read is the measurement of record. If the platform refuses there, the action fails carrying a remedy that says raising the row to write is the maintainer's call, never a retry under a wider token. The live read is not part of this PR. - **The public run shows lengths.** It carries the HTTP status, the answer's byte count, the sealed character count and the part count. That is a size signal, and the card accepts it. No id, state, title or field crosses. - **There is a capacity limit.** An answer over 29,939 bytes is a FAILED action naming byte counts alone, and nothing is emitted. Raising it would mean more steps or another channel. - **The workflow is unchanged, and its stale comment is its own PR.** The workflow's `permission-repository-advisories` input already passes whatever level the validator writes. Its comment near line 154 of `.github/workflows/fleet-write.yml` is now stale: it still says "THREE consumers" and names `permission_repository_advisories=write`. The file is Tier H and outside this card's surface, so the fix is a later PR of its own. - **S2: the second of #29505 and this PR to land owns one reconciliation in `execute.mjs`.** The rule was that the second to land either keeps `advisoryPlatformText` imported or moves the read's refused arm onto #29505's `advisoryRefusalCause`, then re-runs `node scripts/pm/fleet-write/execute.mjs --self-test` on the merged tree. #29505 landed first, so this PR merged `main` (merge commit 453d4e1, no rebase) and resolved it there. - **The import is back.** #29505 had dropped it, so `advisoryPlatformText` is imported again. - **The read's refused arm stays on the allowlist.** It quotes a platform message only when `ADVISORY_PLATFORM_MESSAGES` names it. `advisoryRefusalCause` withholds a message only when it quotes prose the run already knows, and a refused read knows none: its payload is the id and the key, and no advisory was read. So the move would print whatever message the platform sent, and the read battery's 404-with-marker fixture would reach the row. - **The merged tree passes.** On it, `execute.mjs --self-test` is 259 cases across 22 batteries, and validate and dispatch are unchanged at 303 and 315. - **Part of, not Fixes.** The first live read, with the maintainer, and the sender-gate ruling are acts on the card after this merges, so #29420 stays open after this PR merges. - **No key is stored.** The private half lives only in the dispatching process's memory (a `KeyObject` in a Map). No key and no plaintext is written to disk, env, the payload or GitHub. No dependency was added. - **Two commits carry a model-named trailer.** 7b0268e and 453d4e1 end with a `Co-Authored-By` naming the model, against the model-free trailer rule (`check:commit-card-trailers --range origin/main..HEAD` flags both). This clone had no pre-push hook installed, so nothing refused the push. The branch is not `claude/issue-*`, which makes it shared under AGENTS.md §3, so it was not rewritten. The squash message is taken from this body, so neither trailer lands on `main`. - **No changeset, `skip-changeset`.** `scripts/pm/**` and `.claude/**` do not publish. ## Verification <details><summary>dispatch-gates at 453d4e1: 47 derived, 47 run, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 47 families from the 7 changed paths (1,262 changed lines against merge base 8e505db, under the 3,000 human-merge threshold). Each was run as printed and recorded with its exit code before any pipe. Reconciliation: "✓ dispatch-gates --ran: 47 derived famil(ies) accounted for — 47 run, 0 NOT-MEASURED (a DERIVED zero — all 47 recorded an exit code and none of them is 3)." ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 node scripts/pm/check-harness-current.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-close-cards :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pm-fleet-write-dispatch :: exit 0 pnpm check:pm-fleet-write-execute :: exit 0 pnpm check:pm-fleet-write-validate :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-issue-create :: exit 0 pnpm check:pm-issue-transfer :: exit 0 pnpm check:pm-label-write :: exit 0 pnpm check:pm-post-stamped :: exit 0 pnpm check:pm-record-recognisers :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:pm-skill-ratchet :: exit 0 pnpm check:pm-write-pace :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic (not touched). - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on the changed scripts: exit 0; the repo-wide run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset (nothing publishes here). - [x] 6. `packages/spec` touched: no. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section excepted, as the dispatch protocol requires). Dispatched by `domain:skills#1 · huangyiirene · session_01J8VVAnMUWA9rufXzFaEh4J`; dev = card thread `session_01J8VVAng9QxNxoEzyWkqbW6`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6 --- _Generated by [Claude Code](https://claude.ai/code/session_01J8VVAng9QxNxoEzyWkqbW6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1bdf674 commit 12415a8

7 files changed

Lines changed: 1195 additions & 67 deletions

File tree

‎.claude/skills/pm-dispatch/references/rest-channel.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
- ✓ `origin/main` 合进 PR head:中继 op `pr_update_branch`(`pull` + 40 位 `expected_head_sha`),真合并提交。
4949
- 挂子卡走中继 op `sub_issue_add`/`sub_issue_remove`(`issue` 父号,`sub_issue` 子号),回读父列表。
5050
- 公告经中继 `advisory_create`(首选)等四操作,规则住 `security-findings.md`;发布归维护者。
51+
- 读公告经中继 `advisory_read`:席位进程一次性密钥,密文回传;`--reveal` 才印明文到 stdout。
5152
- ✓ 落地经中继 `node scripts/pm/fleet-write/dispatch.mjs --repo O/N --actions-file F`;`--via dispatch` 同载。
5253
- ✓ 启动 run 走 op `workflow_dispatch`:解锁物是白名单 run 才启;入表只经 workflow 自己的 PR。
5354
- F = `[{"op":"workflow_dispatch","workflow":"release.yml","ref":"main","inputs":{"refresh_version_pr":true}}]`。

‎.claude/skills/pm-dispatch/references/security-findings.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
- 私卡 = 受影响仓上一份未发布的安全公告,只有管理员、安全经理与受邀协作者可见。
2121
- 首选中继 `advisory_create` 建草稿公告,再 `advisory_collaborators_add` 带具名账号进公告。
22+
- 读公告只走中继 `advisory_read`(席位账号读被拒):一次性密钥只在席位进程,只回密文。
2223
- `advisory_report` 只留给 App 无公告写权的仓:GitHub 按账号限每日新私报,全队共一个 App。
2324
- ⛔ 席位永不以自身账号私报或写公告:GitHub 写只走中继,以 App 身份;发布归维护者。
2425
- 两操作皆被拒 ⇒ 会话专递(选 A):机制只在会话里交维护者,由其建草稿,⛔ 不换账号重发。

‎scripts/pm/check-skill-line-ratchet.mjs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,11 @@ export const CEILINGS = new Map([
11951195
// quotes the maintainer verbatim — 「可以,按方案改中继,能发这个操作的账号名单我稍后点名。」 and
11961196
// 「你写清楚issue就可以,让车道开发」 — names this file and the one line, and confirms the raise the seat stated in
11971197
// its comment 6572738609. Measured delta +1 (80 → 81 lines); landed count, headroom 0.
1198-
['.claude/skills/pm-dispatch/references/rest-channel.md', 81],
1198+
// 81 → 82 (#29420): one line naming the sealed advisory read, `advisory_read`. The ruling of record is that card's
1199+
// body, filed under the maintainer's verbatim 「按一次性密钥立卡」 and 「并派发」, which names this file and "one line
1200+
// naming the op, with the ratchet row raised by the measured delta only". Measured delta +1 (81 → 82 lines); landed
1201+
// count, headroom 0.
1202+
['.claude/skills/pm-dispatch/references/rest-channel.md', 82],
11991203
// Lowered 84 → 77 by the rules-only rewrite of the seven core references
12001204
// (maintainer ruling: provenance narratives, incident post-mortems and rationale
12011205
// tails leave the corpus; one rule per ≤120-byte line, no rule already stated in
@@ -1300,7 +1304,11 @@ export const CEILINGS = new Map([
13001304
// untranslated 「同意方案,选 B,立卡」; the raises below cite it as "#29111".
13011305
// A NEW file, so an added row that funds nothing — set at the landed line
13021306
// count, headroom 0, same convention as above.
1303-
['.claude/skills/pm-dispatch/references/security-findings.md', 55],
1307+
// 55 → 56 (#29420): one line pointing a seat's read of an advisory at the sealed relay read, `advisory_read`, where
1308+
// §3 had it read under its own account (a cloud seat's account is refused). The ruling of record is that card's
1309+
// body, filed under the maintainer's verbatim 「按一次性密钥立卡」 and 「并派发」, which names this file "by the
1310+
// measured delta only". Measured delta +1 (55 → 56 lines); landed count, headroom 0.
1311+
['.claude/skills/pm-dispatch/references/security-findings.md', 56],
13041312
['.claude/skills/pm-dispatch/references/external-contributions.md', 22],
13051313
['.claude/skills/pm-dispatch/references/reading-discipline.md', 37],
13061314
// Lowered 51 → 47 by #25712 item 4: objectui stopped being a sister repo, so

0 commit comments

Comments
 (0)