Repository navigation
Commit 12415a8
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
- scripts/pm
- fleet-write
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| 51 | + | |
51 | 52 | | |
52 | 53 | | |
53 | 54 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
22 | 23 | | |
23 | 24 | | |
24 | 25 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1195 | 1195 | | |
1196 | 1196 | | |
1197 | 1197 | | |
1198 | | - | |
| 1198 | + | |
| 1199 | + | |
| 1200 | + | |
| 1201 | + | |
| 1202 | + | |
1199 | 1203 | | |
1200 | 1204 | | |
1201 | 1205 | | |
| |||
1300 | 1304 | | |
1301 | 1305 | | |
1302 | 1306 | | |
1303 | | - | |
| 1307 | + | |
| 1308 | + | |
| 1309 | + | |
| 1310 | + | |
| 1311 | + | |
1304 | 1312 | | |
1305 | 1313 | | |
1306 | 1314 | | |
| |||
0 commit comments