diff --git a/.github/ISSUE_TEMPLATE/bounty.yml b/.github/ISSUE_TEMPLATE/bounty.yml index fabc1d9..85be98b 100644 --- a/.github/ISSUE_TEMPLATE/bounty.yml +++ b/.github/ISSUE_TEMPLATE/bounty.yml @@ -55,7 +55,7 @@ body: options: - "P0 — public repository / 公开仓库" - "P1 — redacted sparepack, interfaces and tests only / 脱敏任务包,只给接口和测试" - - "P2 — ephemeral Codespace sandbox / 一次性 Codespace 沙箱" + - "P2 — temporary trusted Codespace access / 临时受信 Codespace 访问" - "P3 — trust circle, NDA required / 信任圈,需 NDA" validations: required: true diff --git a/.github/scan-allow.txt b/.github/scan-allow.txt index 3a008cf..e220347 100644 --- a/.github/scan-allow.txt +++ b/.github/scan-allow.txt @@ -1,6 +1,6 @@ # Findings the credential scanner reports that have been read and judged safe to publish. # -# Format, one per line: rule-id:path or rule-id:path:line +# Format, one per line: rule-id:path:line # `#` starts a comment. Always leave one saying why — an entry with no reasoning is # indistinguishable from someone silencing an inconvenient check. # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d6287..2418f37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: - name: Ledger invariants run: npm run ledger + - name: Settlement pull request evidence + env: + GITHUB_TOKEN: ${{ github.token }} + run: npm run ledger:prs + - name: Ledger stats check run: npm run stats -- --check @@ -48,6 +53,24 @@ jobs: - name: Credential scan run: npm run scan + - name: Pull request metadata scan + if: github.event_name == 'pull_request' + env: + TITLE: ${{ github.event.pull_request.title }} + BODY: ${{ github.event.pull_request.body }} + run: | + printf '%s\n%s' "$TITLE" "$BODY" | node scripts/scan-text.mjs > pr-metadata-verdict.json + cat pr-metadata-verdict.json + node -e ' + const verdict = require("./pr-metadata-verdict.json"); + if (verdict.block) { + for (const item of verdict.credentials) { + console.error(`::error::${item.label} (${item.severity}) at submitted-text line ${item.line}`); + } + process.exit(1); + } + ' + # Only reaches PRs to this repository. Delivery PRs live in the requester's repo, # where this workflow has no reach — see the note in check-attestation.mjs. - name: Attestation checkboxes diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 2ea7513..ee1d97e 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -30,9 +30,10 @@ jobs: - name: Scan submitted text id: scan env: + TITLE: ${{ github.event.issue.title }} BODY: ${{ github.event.comment.body || github.event.issue.body }} run: | - printf '%s' "$BODY" | node scripts/scan-text.mjs > verdict.json + printf '%s\n%s' "$TITLE" "$BODY" | node scripts/scan-text.mjs > verdict.json cat verdict.json { echo "block=$(node -p "require('./verdict.json').block")" diff --git a/CLAUDE.md b/CLAUDE.md index 45128e8..60314b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Six transaction types only: `grant`, `escrow`, `settle`, `refund`, `split`, `adj `transfer` and never will be** — that is COMPLIANCE red line 5 (TP are not transferable) expressed as a data structure instead of a promise, and `verify.mjs` treats any unrecognised type as tampering. Balances and escrow are checked incrementally at every entry, so a history that dips -negative mid-way and recovers is invalid. Schema and the nine invariants are in `ledger/README.md`. +negative mid-way and recovers is invalid. Schema and the eleven invariants are in `ledger/README.md`. Settling a task by hand: append the entry, run `npm test` and `npm run ledger:write`, then update `profiles/.md`. diff --git a/PRIVACY-TIERS.md b/PRIVACY-TIERS.md index 8334130..5ca8f5d 100644 --- a/PRIVACY-TIERS.md +++ b/PRIVACY-TIERS.md @@ -74,29 +74,40 @@ Some tasks genuinely need the surrounding codebase — debugging an integration --- -## P2 · Ephemeral sandbox +## P2 · Temporary trusted access -The code never touches the worker's disk. +The worker can read and copy the complete visible repository. A Codespace keeps execution +off the worker's local machine, but it does not prevent downloads or retained copies. +Revoking access stops future reads; it cannot recall code the worker already saw. -1. Requester adds the worker as a collaborator, scoped to one dedicated branch via branch protection. +1. Requester grants collaborator access to the repository and uses branch protection to limit changes to one dedicated branch. Read access still covers the repository. 2. Worker opens a **Codespace on their own GitHub account** and runs **their own** Claude Code inside it. 3. Work happens, PR goes up, requester merges. -4. Requester removes the collaborator, deletes the branch. The Codespace expires on its own. +4. Requester removes the collaborator and deletes the branch. This ends future access only. ### Why Codespaces and not a container on the requester's machine -**Neither party is the other's host.** This is the whole point. +**Neither party runs code on the other's hardware.** This protects the worker's credentials, +not the requester's source from a worker who has already been granted access. If the sandbox ran on the requester's hardware, the requester would be root on the box where the worker's `~/.claude/.credentials.json` is mounted. That turns a privacy feature into a credential-harvesting device — strictly worse than not doing it at all. Putting the sandbox on Microsoft's infrastructure makes it a neutral third party to both sides, and personal accounts get 60 free core-hours a month. The cost is that your code passes through GitHub's cloud. For most projects that is already true. For the ones where it is not, see P3. +The Codespace belongs to the worker's account. Branch protection limits writes, not reads, +downloads, terminal commands, or copies to another location. Use P1 or a separate repository +containing only the minimum required code unless the worker may permanently retain everything +they can see. + ### Checklist for the requester -- [ ] Branch protection restricts the worker to the task branch +- [ ] The worker is trusted with a permanent copy of every visible file +- [ ] A minimum-code temporary repository was considered before granting full-repository access +- [ ] Branch protection limits changes to the task branch and is not treated as read isolation +- [ ] Secrets and unrelated sensitive files are removed before access is granted - [ ] No secrets in the repo's Codespaces secrets for that branch - [ ] Repository-level Actions permissions reviewed before granting access -- [ ] Calendar reminder to revoke access after merge +- [ ] Calendar reminder to revoke future access after merge --- @@ -203,29 +214,36 @@ npx sparepack init --- -## P2 · 一次性沙箱 +## P2 · 临时受信访问 -代码不落接单者磁盘。 +接单者可以读取并复制完整的可见仓库。Codespace 只是让执行环境不在接单者的本地机器上, +它不能阻止下载或保留副本。撤权只能阻止后续读取,无法收回接单者已经看过的代码。 -1. 发布者把接单者加为 collaborator,用分支保护限定在一个专用分支上。 +1. 发布者授予接单者仓库 collaborator 权限,并用分支保护把改动限制在专用分支;读取权限仍覆盖仓库。 2. 接单者在**自己的 GitHub 账号下**开 Codespace,在里面跑**他自己的** Claude Code。 3. 干活,提 PR,发布者合并。 -4. 发布者移除 collaborator、删分支。Codespace 自己会过期。 +4. 发布者移除 collaborator、删分支。这只能终止后续访问。 ### 为什么用 Codespaces 而不是发布者机器上的容器 -**双方都不是对方的宿主。** 这就是全部理由。 +**双方都不在对方的硬件上运行代码。** 这保护的是接单者的凭证,不是已经授权给接单者读取的源码。 如果沙箱跑在发布者的硬件上,发布者就是那台机器的 root,而接单者的 `~/.claude/.credentials.json` 正挂在里面。那样一个隐私功能就变成了凭证收割装置,比不做还糟。把沙箱放在微软的基础设施上,对双方而言它都是中立第三方,而且个人账号每月有 60 核时免费额度。 代价是代码要过 GitHub 的云。对大多数项目来说这本来就已经是事实了。不是的那些,看 P3。 +Codespace 属于接单者账号。分支保护限制的是写入,不会限制读取、下载、终端命令或复制到别处。 +除非你能接受接单者永久保留所有可见内容,否则应使用 P1,或单独建立只包含最低必要代码的临时仓库。 + ### 发布者检查清单 -- [ ] 分支保护已把接单者限制在任务分支上 +- [ ] 已确认接单者可以永久持有每一个可见文件的副本 +- [ ] 授予完整仓库访问前,已经考虑过只放最低必要代码的临时仓库 +- [ ] 分支保护只把改动限制在任务分支,没有被当作读取隔离 +- [ ] 授权前已经移除密钥和无关敏感文件 - [ ] 该分支相关的 Codespaces secrets 里没有密钥 - [ ] 授权前复查过仓库级的 Actions 权限 -- [ ] 设好合并后回收权限的提醒 +- [ ] 设好合并后回收后续访问权限的提醒 --- diff --git a/README.md b/README.md index e933be4..cccb4d5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Three things stacked, each usable on its own: |---|---| | [**sparepack**](https://github.com/mxx1111/sparepack) | Cuts a minimal, redacted slice out of a private repo. Allowlist only, function bodies stripped to signatures, real data swapped for fixtures, and you review it file by file before anything is written. | | **The board** | Hands that slice to whoever turns up, under a claim protocol that runs without a human awake. | -| **The ledger** | Append-only, nine invariants, every balance replayed from history by `node ledger/verify.mjs`. Two more invariants and API-verified settlements are in [#17](https://github.com/mxx1111/spare-cycles/pull/17), awaiting its author. | +| **The ledger** | Append-only, eleven invariants, every balance replayed from history by `node ledger/verify.mjs`, with settlement PRs verified against GitHub's API. | This started as a mutual-aid board for people whose Claude Max limit runs out by Wednesday while someone else's rolls over unused. That framing was half right. The supply of people willing to do the work is real — six deliveries, no rewrites. The rest of it did not survive contact: **every task ever posted came from one account**, and the activity profiles of the accounts that arrived look nothing like the community it was built for. @@ -54,7 +54,7 @@ Every task declares a privacy tier: |---|---|---| | **P0 · Public** | An ordinary open-source repo | The code is already public | | **P1 · Redacted pack** | Interfaces, acceptance tests, fake fixtures. No business logic. | Default for private projects | -| **P2 · Ephemeral sandbox** | A Codespace on the requester's repo, revoked when done. Code never touches the worker's disk. | The task genuinely needs the real codebase | +| **P2 · Temporary trusted access** | A worker-controlled Codespace with full, copyable access to the visible repository. Revocation stops future access but cannot recall copies. | The task needs the real codebase and the worker is trusted with it | | **P3 · Trust circle** | Full access under NDA | Established relationships only | P1 is the interesting one. The `sparepack` CLI extracts a minimal reproducible subset from your private repo — allowlist only, nothing is exposed unless you name it — strips function bodies down to signatures, swaps real data for generated fixtures, scans for credentials and PII, and makes you review the output file by file before it writes anything. The worker implements against the tests. Your business logic never leaves your machine. diff --git a/README.zh-CN.md b/README.zh-CN.md index ba65a9b..bf7d40b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,7 @@ |---|---| | [**sparepack**](https://github.com/mxx1111/sparepack) | 从私有仓库切出一个最小的脱敏子集。白名单制,函数体剥成签名,真实数据换成 fixture,写盘前你逐个文件确认。 | | **任务板** | 把这个子集交给出现的任何人,认领协议不需要有人醒着。 | -| **账本** | 只追加,九条不变量,`node ledger/verify.mjs` 从历史重放每一个余额。另外两条不变量和结算的 API 实证在 [#17](https://github.com/mxx1111/spare-cycles/pull/17) 里,等它的作者。 | +| **账本** | 只追加,十一条不变量,`node ledger/verify.mjs` 从历史重放每一个余额,并通过 GitHub API 实证结算 PR。 | 这东西最初是个互助板:有的周你周三就把额度烧穿了,有的周额度剩一大半到期作废,想把这两拨人接上。这个设想对了一半。愿意干活的供给侧是真的——六次交付,零返工。剩下那一半没扛住现实:**这块板子上发过的每一个任务都来自同一个账号**,而实际到来的那些账号的活动画像,和它当初设想的社区毫无相似之处。 @@ -56,7 +56,7 @@ |---|---|---| | **P0 · 公开** | 一个普通的开源仓库 | 代码本来就是公开的 | | **P1 · 脱敏包** | 接口签名、验收测试、假数据。没有业务逻辑。 | 私有项目的默认选择 | -| **P2 · 一次性沙箱** | 发布者仓库上的一个 Codespace,做完即回收。代码不落接单者磁盘。 | 任务确实需要真实代码库 | +| **P2 · 临时受信访问** | 接单者控制的 Codespace,可完整读取和复制可见仓库。撤权只能阻止后续访问,无法收回副本。 | 任务需要真实代码库,且发布者信任接单者持有它 | | **P3 · 信任圈** | NDA 下的完整访问 | 只限已有信任关系 | P1 是有意思的那个。`sparepack` 这个 CLI 从你的私有仓库里切出一个最小可复现子集,白名单制,你不点名的东西一概不暴露;把函数体剥掉只留签名,真实数据换成生成的假数据,扫一遍凭证和个人信息,然后在写盘之前逼你逐个文件过一遍。接单者对着测试写实现,你的业务逻辑压根没离开过你的机器。 diff --git a/docs/index.html b/docs/index.html index 8b3b495..d5b87fc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -387,8 +387,8 @@

四件可以自己动手核实的事Four 账本谁都能从零重算Anyone can recompute the ledger

-27 条记录、9 条不变量、一条命令。余额不是维护者说了算,是从完整历史重放出来的。快照和重算对不上,以重算为准。 -27 entries, 9 invariants, one command. Balances are not asserted by the maintainer, they are replayed from the full history. If the snapshot disagrees with the replay, the replay wins. +27 条记录、11 条不变量、一条命令。余额不是维护者说了算,是从完整历史重放出来的。快照和重算对不上,以重算为准。 +27 entries, 11 invariants, one command. Balances are not asserted by the maintainer, they are replayed from the full history. If the snapshot disagrees with the replay, the replay wins.

@@ -529,8 +529,8 @@

隐私分级Privacy tiers

脱敏任务包 · 私有项目的默认选择Redacted pack · the default for private work 接口、验收测试、假数据。业务逻辑不出门。Interfaces, acceptance tests, synthetic fixtures. No business logic.
P2
- 一次性沙箱Ephemeral sandbox - 接单者在自己账号下开 Codespace,代码不落他的磁盘。双方都不是对方的宿主——这是关键。The worker opens a Codespace on their own account; code never touches their disk. Neither party is the other's host — that is the point.
+ 临时受信访问Temporary trusted access + 接单者控制 Codespace,可以复制所有可见代码。撤权只能阻止后续访问,不能收回副本。The worker controls the Codespace and can copy all visible code. Revocation ends future access but cannot recall copies.
P3
信任圈Trust circle NDA 下的完整访问,只给已有关系的人。Full access under NDA, for people you already know.
@@ -628,8 +628,8 @@

账本:不用信我,自己算The ledg All invariants hold.

- ledger.jsonl 只追加:不改行、不删行、不调顺序,更正一律是新增的冲正条目。balances.json 是派生产物,两者对不上时以重算为准。校验器检查九条不变量,其中余额非负和托管非负是逐条增量检查的——中途变成负数、后面又补回来的历史一样判无效。 - ledger.jsonl is append-only: never edit a line, never delete one, never reorder. Corrections are new compensating entries. balances.json is derived output, and if the two disagree the recomputation wins. The verifier checks nine invariants, and the two non-negativity ones are checked incrementally — a history that dips negative in the middle and recovers is still invalid. + ledger.jsonl 只追加:不改行、不删行、不调顺序,更正一律是新增的冲正条目。balances.json 是派生产物,两者对不上时以重算为准。校验器检查十一条不变量,其中余额非负和托管非负是逐条增量检查的——中途变成负数、后面又补回来的历史一样判无效。 + ledger.jsonl is append-only: never edit a line, never delete one, never reorder. Corrections are new compensating entries. balances.json is derived output, and if the two disagree the recomputation wins. The verifier checks eleven invariants, and the two non-negativity ones are checked incrementally — a history that dips negative in the middle and recovers is still invalid.

红线 5 长什么样What red line 5 looks like

@@ -649,8 +649,8 @@

红线 5 长什么样What red line 5 look
这条不变量是被撞出来的One invariant exists because it broke

- 时间戳必须是观测到的事件时间——issue 的 createdAt、PR 的 mergedAt——而不是敲一个看起来合理的值。这条写成规则是因为它被违反过:最早的八条记录填的是 12:00、12:01……14:00 这种编造值,单调递增检查全过(编造的递增数字确实在递增),直到一个真实时间戳排在编造值后面,结算直接死锁。现在校验器会拒绝任何未来时间——那是「这个值是敲出来的」唯一能被机器识别的证据。整个更正过程写在账本文件头里。 - A timestamp must be an observed event time — the issue's createdAt, the PR's mergedAt — not a plausible-looking value someone typed. This is a rule because it was broken: the first eight entries were filled in with tidy invented times, 12:00, 12:01, … 14:00. Monotonicity passed, because invented increasing numbers do increase. It only surfaced when a real timestamp arrived behind an invented one and settlement deadlocked. The verifier now rejects any future-dated entry — the one machine-detectable proof that a time was typed rather than observed. The correction is documented in the ledger's header. + 时间戳必须是观测到的账本事件时间,而不是敲一个看起来合理的值。结算可以晚于交付 PR 合并,但不能早于它;CI 会用 GitHub 的 mergedAt 或已接受的 closedAt 检查这个顺序。最早的八条记录曾填入编造值,直到真实时间落在它们后面才让结算死锁;完整更正过程写在账本文件头里。 + A timestamp must be an observed ledger-event time, not a plausible-looking value someone typed. Settlement may follow delivery, but it cannot precede the PR's mergedAt or accepted closedAt; CI checks that ordering through GitHub. The first eight entries once used invented values and deadlocked when a real event arrived behind them; the correction is documented in the ledger header.

@@ -948,8 +948,8 @@

账本上的位置Where everyone stands

- 合计:发放 220 TP,结算 70 TP,托管中 140(3 个开放任务)。账本 27 条记录,9 条不变量全过。发放额从 50 涨到 100,是维护者为给板子供资发放的一笔——规则改动全过程都公开记录,那笔钱当天全部进了托管,维护者余额仍是 0。 - Total: 220 TP issued, 70 TP settled, 140 in escrow across 3 open tasks. 27 ledger entries, all nine invariants holding. Issuance rose from 50 to 100 because the maintainer funded the board — the rule change and the whole of it are on the record, all of it reached escrow the same day, and the maintainer balance is still zero. + 合计:发放 220 TP,结算 70 TP,托管中 140(3 个开放任务)。账本 27 条记录,11 条不变量全过。发放额从 50 涨到 100,是维护者为给板子供资发放的一笔——规则改动全过程都公开记录,那笔钱当天全部进了托管,维护者余额仍是 0。 + Total: 220 TP issued, 70 TP settled, 140 in escrow across 3 open tasks. 27 ledger entries, all eleven invariants holding. Issuance rose from 50 to 100 because the maintainer funded the board — the rule change and the whole of it are on the record, all of it reached escrow the same day, and the maintainer balance is still zero.

diff --git a/ledger/README.md b/ledger/README.md index 3b3b67f..30adcee 100644 --- a/ledger/README.md +++ b/ledger/README.md @@ -31,7 +31,7 @@ Append-only. One JSON object per line in `ledger.jsonl`. Never edit a line, neve |---|---|---| | `grant` | system → user balance | `to`, `reason` | | `escrow` | requester balance → escrow | `from`, `ref` | -| `settle` | escrow → worker balance | `to`, `ref`, `pr` | +| `settle` | escrow → worker balance | `to`, `ref`, canonical GitHub `pr` URL | | `refund` | escrow → requester balance | `to`, `ref`, `reason` | | `split` | escrow → both, as two entries | `to`, `ref`, `by`, `reason` | | `adjust` | maintainer correction | `to`, `by`, `reason` | @@ -48,9 +48,10 @@ User-to-user transfers do not exist in this schema. This is red line 5 from [COM ## Timestamps must be observed, not typed -`ts` is the time the event actually happened, taken from the source of truth: `createdAt` on -the issue for an `escrow`, `closedAt` for a `refund`, `mergedAt` on the pull request for a -`settle`. Pull it with `gh` rather than typing something plausible. +`ts` is the time the ledger event actually happened. Use a source-of-truth timestamp when one +exists: `createdAt` on the issue for an `escrow` or `closedAt` for a `refund`. A settlement can +happen after its delivery PR closes, but never before it; CI checks that ordering against the +PR's `mergedAt` or accepted `closedAt` rather than trusting a typed claim. This is stated as a rule because it was broken. Entries 1–8 were originally filled in with tidy invented values — 12:00, 12:01, … 14:00 — none of which corresponded to anything. The @@ -60,7 +61,7 @@ See the header of `ledger.jsonl` for the correction. ## Invariants -Checked by `verify.mjs` on every run: +Checked locally and in CI: 1. `seq` starts at 1, increases by exactly 1, no gaps or repeats 2. `ts` never goes backwards, and is never in the future (5 min clock-skew tolerance) @@ -71,8 +72,12 @@ Checked by `verify.mjs` on every run: 7. Every `settle`/`refund`/`split` references a `ref` that had an `escrow` 8. Escrow released for a `ref` never exceeds escrow taken for that `ref` 9. Total TP in circulation equals the sum of all `grant` and `adjust` amounts +10. A `refund` returns only to the requester who funded that issue's escrow +11. Every `settle` names an existing closed or merged GitHub PR whose event time is no later than `ts` and whose body references the task -Invariants 5 and 6 are checked **incrementally**, not just at the end. A history that dips negative in the middle and recovers is still invalid. +Invariants 1-10 are enforced by `verify.mjs`. Invariant 11 is enforced by `pr-evidence.mjs` +against GitHub's API in CI. Invariants 5 and 6 are checked **incrementally**, not just at the +end. A history that dips negative in the middle and recovers is still invalid. 第 5 和第 6 条是**逐条增量检查**的,不是只看最终结果。中途出现负数、后面又补回来的历史一样判为无效。 diff --git a/ledger/pr-evidence.mjs b/ledger/pr-evidence.mjs new file mode 100644 index 0000000..c77ad1a --- /dev/null +++ b/ledger/pr-evidence.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const PR_URL = /^https:\/\/github\.com\/([A-Za-z\d](?:[A-Za-z\d-]{0,38}))\/([A-Za-z\d._-]+)\/pull\/([1-9]\d*)$/ + +export function parsePullRequestUrl(value) { + const match = typeof value === 'string' ? PR_URL.exec(value) : null + return match ? { owner: match[1], repo: match[2], number: Number(match[3]) } : null +} + +function referencesTask(body, ref, pullRepo, boardRepo) { + const issue = ref.slice(1) + const escapedRepo = boardRepo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const fullReference = new RegExp( + `(?:https:\\/\\/github\\.com\\/${escapedRepo}\\/issues\\/${issue}|${escapedRepo}#${issue})(?!\\d)`, + 'i', + ) + if (fullReference.test(body)) return true + + return pullRepo.toLowerCase() === boardRepo.toLowerCase() && new RegExp(`(^|\\D)#${issue}(?!\\d)`).test(body) +} + +export async function verifyPullRequestEvidence( + entries, + { fetchImpl = fetch, token = process.env.GITHUB_TOKEN, boardRepo = process.env.GITHUB_REPOSITORY ?? 'mxx1111/spare-cycles' } = {}, +) { + const errors = [] + + for (const entry of entries.filter((item) => item.type === 'settle')) { + const parsed = parsePullRequestUrl(entry.pr) + if (!parsed) { + errors.push(`line ${entry.seq}: settle evidence must be a canonical GitHub pull request URL`) + continue + } + + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } + const response = await fetchImpl( + `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`, + { headers }, + ) + if (!response.ok) { + errors.push(`line ${entry.seq}: pull request evidence returned HTTP ${response.status}`) + continue + } + + const pull = await response.json() + const eventAt = pull.merged_at ?? pull.closed_at + if (!eventAt) { + errors.push(`line ${entry.seq}: pull request evidence is still open`) + } else { + const eventTime = Date.parse(eventAt) + const settleTime = Date.parse(entry.ts) + if (!Number.isFinite(eventTime) || !Number.isFinite(settleTime) || eventTime > settleTime) { + errors.push(`line ${entry.seq}: settle timestamp must not precede the pull request mergedAt or closedAt`) + } + } + + const pullRepo = `${parsed.owner}/${parsed.repo}` + if (!referencesTask(pull.body ?? '', entry.ref, pullRepo, boardRepo)) { + errors.push(`line ${entry.seq}: pull request does not reference ${boardRepo}${entry.ref}`) + } + } + + return errors +} + +async function main() { + const pathArg = process.argv.slice(2).find((arg) => !arg.startsWith('--')) + const ledgerPath = pathArg ? resolve(pathArg) : join(HERE, 'ledger.jsonl') + const entries = readFileSync(ledgerPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('//')) + .map((line) => JSON.parse(line)) + const errors = await verifyPullRequestEvidence(entries) + + if (errors.length) { + for (const error of errors) console.error(error) + process.exit(1) + } + console.log(`Verified ${entries.filter((entry) => entry.type === 'settle').length} settlement pull request(s).`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main() diff --git a/ledger/pr-evidence.test.mjs b/ledger/pr-evidence.test.mjs new file mode 100644 index 0000000..c62670b --- /dev/null +++ b/ledger/pr-evidence.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { parsePullRequestUrl, verifyPullRequestEvidence } from './pr-evidence.mjs' + +const entry = { + seq: 1, + ts: '2026-08-18T10:20:46Z', + type: 'settle', + to: 'worker', + amount: 10, + ref: '#7', + pr: 'https://github.com/requester/project/pull/12', +} + +function response(body, status = 200) { + return { ok: status === 200, status, json: async () => body } +} + +test('only canonical GitHub pull request URLs are accepted', () => { + assert.deepEqual(parsePullRequestUrl(entry.pr), { owner: 'requester', repo: 'project', number: 12 }) + assert.equal(parsePullRequestUrl('not-a-url'), null) + assert.equal(parsePullRequestUrl('https://example.com/requester/project/pull/12'), null) +}) + +test('merged settlement evidence must match its task and precede settlement', async () => { + const fetchImpl = async () => response({ + merged_at: '2026-08-18T10:19:46Z', + closed_at: '2026-08-18T10:19:46Z', + body: 'Task: https://github.com/mxx1111/spare-cycles/issues/7', + }) + assert.deepEqual(await verifyPullRequestEvidence([entry], { fetchImpl }), []) +}) + +test('closed unmerged evidence remains valid when its close event was accepted', async () => { + const localEntry = { ...entry, pr: 'https://github.com/mxx1111/spare-cycles/pull/12' } + const fetchImpl = async () => response({ merged_at: null, closed_at: entry.ts, body: 'Closes #7' }) + assert.deepEqual(await verifyPullRequestEvidence([localEntry], { fetchImpl }), []) +}) + +test('open, unrelated, stale, and missing pull request evidence is rejected', async () => { + const cases = [ + [{ merged_at: null, closed_at: null, body: 'Task: mxx1111/spare-cycles#7' }, 'still open'], + [{ merged_at: entry.ts, closed_at: entry.ts, body: 'Unrelated work' }, 'does not reference'], + [{ merged_at: '2026-08-18T10:21:46Z', closed_at: entry.ts, body: 'Task: mxx1111/spare-cycles#7' }, 'timestamp'], + [{ merged_at: 'not-a-timestamp', closed_at: entry.ts, body: 'Task: mxx1111/spare-cycles#7' }, 'timestamp'], + ] + + for (const [body, expected] of cases) { + const errors = await verifyPullRequestEvidence([entry], { fetchImpl: async () => response(body) }) + assert.ok(errors.some((error) => error.includes(expected)), errors.join('\n')) + } + + const invalidEntryTime = await verifyPullRequestEvidence([{ ...entry, ts: 'not-a-timestamp' }], { + fetchImpl: async () => response({ merged_at: entry.ts, closed_at: entry.ts, body: 'Closes #7' }), + }) + assert.ok(invalidEntryTime.some((error) => error.includes('timestamp')), invalidEntryTime.join('\n')) + + const missing = await verifyPullRequestEvidence([entry], { fetchImpl: async () => response({}, 404) }) + assert.ok(missing.some((error) => error.includes('HTTP 404')), missing.join('\n')) +}) diff --git a/ledger/verify.mjs b/ledger/verify.mjs index ef71a31..d19e2e4 100644 --- a/ledger/verify.mjs +++ b/ledger/verify.mjs @@ -6,6 +6,8 @@ import { readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { parsePullRequestUrl } from './pr-evidence.mjs' + const HERE = dirname(fileURLToPath(import.meta.url)) // An explicit path lets the test suite point at fixture ledgers. @@ -112,12 +114,17 @@ function checkShape(e, expectedSeq, prevTs) { fail(where, `"ref" must look like "#123", got ${JSON.stringify(e.ref)}`) } + if (e.pr !== undefined && !parsePullRequestUrl(e.pr)) { + fail(where, `"pr" must be a canonical GitHub pull request URL, got ${JSON.stringify(e.pr)}`) + } + return Number.isNaN(ts) ? prevTs : ts } function replay(entries) { const balances = new Map() const escrow = new Map() // ref -> TP currently held + const escrowOwner = new Map() // ref -> requester who funded it const escrowTaken = new Map() // ref -> total ever escrowed const escrowFreed = new Map() // ref -> total ever released let issued = 0 // grant + adjust, i.e. TP created from nothing @@ -144,7 +151,13 @@ function replay(entries) { if (bal(e.from) < e.amount) { fail(where, `${e.from} escrowed ${e.amount} TP with only ${bal(e.from)} available`) } + const owner = escrowOwner.get(e.ref) + if (owner && owner !== e.from) { + fail(where, `${e.ref} escrow is already owned by ${owner}, not ${e.from}`) + break + } add(e.from, -e.amount) + escrowOwner.set(e.ref, e.from) escrow.set(e.ref, (escrow.get(e.ref) ?? 0) + e.amount) escrowTaken.set(e.ref, (escrowTaken.get(e.ref) ?? 0) + e.amount) break @@ -158,6 +171,11 @@ function replay(entries) { fail(where, `${e.type} for ${e.ref} but nothing was ever escrowed against it`) break } + const owner = escrowOwner.get(e.ref) + if (e.type === 'refund' && e.to !== owner) { + fail(where, `${e.type} for ${e.ref} must return to escrow owner ${owner}, not ${e.to}`) + break + } // Invariant 6 and 8: an issue cannot pay out more than it took in. if (held < e.amount) { fail(where, `${e.type} of ${e.amount} TP for ${e.ref} but only ${held} TP is held in escrow`) diff --git a/ledger/verify.test.mjs b/ledger/verify.test.mjs index 5b4258d..0a4770c 100644 --- a/ledger/verify.test.mjs +++ b/ledger/verify.test.mjs @@ -30,6 +30,7 @@ function verify(entries) { } catch (err) { stdout = err.stdout code = err.status + if (!stdout) throw new Error(err.stderr || err.message) } finally { rmSync(dir, { recursive: true, force: true }) } @@ -84,6 +85,15 @@ test('a timed-out task refunds the requester in full', () => { assert.equal(r.in_escrow, 0) }) +test('a refund cannot be redirected to a different account', () => { + const r = verify([ + grant(1, 'alice', 50), + escrow(2, 'alice', 30, '#1', later(1)), + { seq: 3, ts: later(2), type: 'refund', to: 'bob', amount: 30, ref: '#1', reason: 'claim timeout' }, + ]) + assertRejected(r, 'must return to escrow owner alice') +}) + test('an arbitrated split divides escrow between both parties', () => { const r = verify([ grant(1, 'alice', 80), @@ -207,6 +217,15 @@ test('settle without a PR link is rejected', () => { assertRejected(r, 'requires "pr"') }) +test('settle with fabricated PR evidence is rejected', () => { + const r = verify([ + grant(1, 'alice', 50), + escrow(2, 'alice', 30, '#1', later(1)), + { seq: 3, ts: later(2), type: 'settle', to: 'bob', amount: 30, ref: '#1', pr: 'not-a-url' }, + ]) + assertRejected(r, 'GitHub pull request URL') +}) + test('adjust without an authorizing maintainer is rejected', () => { const r = verify([{ seq: 1, ts: T, type: 'adjust', to: 'alice', amount: 10, reason: 'correcting #4' }]) assertRejected(r, 'requires "by"') diff --git a/package.json b/package.json index 513f9dc..fc52364 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "node": ">=22" }, "scripts": { - "test": "node --test ledger/verify.test.mjs scripts/pricing-rules.test.mjs scripts/render-stats.test.mjs", + "test": "node --test ledger/verify.test.mjs ledger/pr-evidence.test.mjs scripts/pricing-rules.test.mjs scripts/render-stats.test.mjs scripts/scan-repo.test.mjs", "ledger": "node ledger/verify.mjs", + "ledger:prs": "node ledger/pr-evidence.mjs", "ledger:write": "node ledger/verify.mjs --write", "stats": "node scripts/render-stats.mjs", "scan": "node scripts/scan-repo.mjs" diff --git a/scripts/render-stats.mjs b/scripts/render-stats.mjs index ca5826f..643466f 100755 --- a/scripts/render-stats.mjs +++ b/scripts/render-stats.mjs @@ -126,8 +126,8 @@ function renderSettledStat() { // Format Summary text in highlights (03 账本谁都能从零重算) function renderRecomputeSummary() { - return `${entryCount} 条记录、9 条不变量、一条命令。余额不是维护者说了算,是从完整历史重放出来的。快照和重算对不上,以重算为准。 -${entryCount} entries, 9 invariants, one command. Balances are not asserted by the maintainer, they are replayed from the full history. If the snapshot disagrees with the replay, the replay wins.` + return `${entryCount} 条记录、11 条不变量、一条命令。余额不是维护者说了算,是从完整历史重放出来的。快照和重算对不上,以重算为准。 +${entryCount} entries, 11 invariants, one command. Balances are not asserted by the maintainer, they are replayed from the full history. If the snapshot disagrees with the replay, the replay wins.` } // Format Leaderboard balances table @@ -189,8 +189,8 @@ ${rows.join('\n')} function renderTotalsNote() { const settledTotal = settledEntries.reduce((sum, e) => sum + e.amount, 0) return `

- 合计:发放 ${totalIssued} TP,结算 ${settledTotal} TP,托管中 ${inEscrow}(${openTaskCount} 个开放任务)。账本 ${entryCount} 条记录,9 条不变量全过。发放额从 50 涨到 100,是维护者为给板子供资发放的一笔——规则改动全过程都公开记录,那笔钱当天全部进了托管,维护者余额仍是 0。 - Total: ${totalIssued} TP issued, ${settledTotal} TP settled, ${inEscrow} in escrow across ${openTaskCount} open tasks. ${entryCount} ledger entries, all nine invariants holding. Issuance rose from 50 to 100 because the maintainer funded the board — the rule change and the whole of it are on the record, all of it reached escrow the same day, and the maintainer balance is still zero. + 合计:发放 ${totalIssued} TP,结算 ${settledTotal} TP,托管中 ${inEscrow}(${openTaskCount} 个开放任务)。账本 ${entryCount} 条记录,11 条不变量全过。发放额从 50 涨到 100,是维护者为给板子供资发放的一笔——规则改动全过程都公开记录,那笔钱当天全部进了托管,维护者余额仍是 0。 + Total: ${totalIssued} TP issued, ${settledTotal} TP settled, ${inEscrow} in escrow across ${openTaskCount} open tasks. ${entryCount} ledger entries, all eleven invariants holding. Issuance rose from 50 to 100 because the maintainer funded the board — the rule change and the whole of it are on the record, all of it reached escrow the same day, and the maintainer balance is still zero.

` } diff --git a/scripts/scan-repo.mjs b/scripts/scan-repo.mjs index 3c281b7..c588250 100644 --- a/scripts/scan-repo.mjs +++ b/scripts/scan-repo.mjs @@ -9,7 +9,7 @@ // log nobody opens. The excerpt is masked by the scanner — a check that printed the secret // it found would be worse than no check. -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { appendFileSync, existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -21,7 +21,7 @@ const ALLOW_FILE = join(ROOT, '.github', 'scan-allow.txt') const inCI = Boolean(process.env.GITHUB_ACTIONS) /** - * Entries are `rule-id:path` or `rule-id:path:line`, one per line, `#` for comments. + * Entries are `rule-id:path:line`, one per line, `#` for comments. * Add one only after reading that specific line and concluding it is safe to publish — * never to quiet a check that has become inconvenient. */ @@ -35,12 +35,20 @@ function loadAllowList() { ) } -function isAllowed(finding, allow) { - return ( - allow.has(`${finding.ruleId}:${finding.path}:${finding.line}`) || - allow.has(`${finding.ruleId}:${finding.path}`) || - allow.has(`${finding.ruleId}:*`) - ) +export function isAllowed(finding, allow) { + return allow.has(`${finding.ruleId}:${finding.path}:${finding.line}`) +} + +export function listTrackedFiles(root) { + return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean) +} + +export function escapeCommandData(value) { + return String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A') +} + +export function escapeCommandProperty(value) { + return escapeCommandData(value).replace(/:/g, '%3A').replace(/,/g, '%2C') } function report(finding) { @@ -48,59 +56,63 @@ function report(finding) { const message = `${finding.label} (${finding.severity}) — ${finding.excerpt}. See COMPLIANCE.md red line 1.` console.log( inCI - ? `::${level} file=${finding.path},line=${finding.line}::${message}` + ? `::${level} file=${escapeCommandProperty(finding.path)},line=${finding.line}::${escapeCommandData(message)}` : ` ${finding.severity.padEnd(8)} ${finding.path}:${finding.line} ${message}`, ) } -const allow = loadAllowList() -const files = execSync('git ls-files', { cwd: ROOT, encoding: 'utf8' }).trim().split('\n').filter(Boolean) +function main() { + const allow = loadAllowList() + const files = listTrackedFiles(ROOT) -let blocking = 0 -let warnings = 0 -let suppressed = 0 -let scanned = 0 + let blocking = 0 + let warnings = 0 + let suppressed = 0 + let scanned = 0 -for (const file of files) { - let text - try { - text = readFileSync(join(ROOT, file), 'utf8') - } catch { - continue // unreadable, or deleted since the index was written - } - if (text.includes('\x00')) continue // binary - scanned++ + for (const file of files) { + let text + try { + text = readFileSync(join(ROOT, file), 'utf8') + } catch (error) { + throw new Error(`Refusing to skip tracked file ${JSON.stringify(file)}: ${error.message}`) + } + if (text.includes('\x00')) continue // binary + scanned++ - for (const finding of scanText(text, { path: file })) { - if (isAllowed(finding, allow)) { - suppressed++ - continue + for (const finding of scanText(text, { path: file })) { + if (isAllowed(finding, allow)) { + suppressed++ + continue + } + report(finding) + if (BLOCKING.has(finding.severity)) blocking++ + else warnings++ } - report(finding) - if (BLOCKING.has(finding.severity)) blocking++ - else warnings++ } -} -const summary = - `Scanned ${scanned} file(s): ${blocking} blocking, ${warnings} warning` + - (suppressed ? `, ${suppressed} suppressed by .github/scan-allow.txt` : '') -console.log(summary) + const summary = + `Scanned ${scanned} file(s): ${blocking} blocking, ${warnings} warning` + + (suppressed ? `, ${suppressed} suppressed by .github/scan-allow.txt` : '') + console.log(summary) -if (process.env.GITHUB_STEP_SUMMARY) { - appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - `### Credential scan\n\n${summary}\n\nCredentials and personal data block the build; ` + - `internal topology only warns.\n`, - ) -} + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### Credential scan\n\n${summary}\n\nCredentials and personal data block the build; ` + + `internal topology only warns.\n`, + ) + } -if (blocking) { - console.error( - `\nRefusing to pass: ${blocking} credential or personal-data finding(s) in tracked files.\n` + - `Remove the value and rotate it — deleting the line does not rotate a leaked key, and the\n` + - `old value stays in git history either way.\n` + - `If the scanner is wrong, add the specific finding to .github/scan-allow.txt with a comment saying why.`, - ) - process.exit(1) + if (blocking) { + console.error( + `\nRefusing to pass: ${blocking} credential or personal-data finding(s) in tracked files.\n` + + `Remove the value and rotate it — deleting the line does not rotate a leaked key, and the\n` + + `old value stays in git history either way.\n` + + `If the scanner is wrong, add the exact finding to .github/scan-allow.txt with a comment saying why.`, + ) + process.exit(1) + } } + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main() diff --git a/scripts/scan-repo.test.mjs b/scripts/scan-repo.test.mjs new file mode 100644 index 0000000..e188fd1 --- /dev/null +++ b/scripts/scan-repo.test.mjs @@ -0,0 +1,33 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { escapeCommandData, escapeCommandProperty, isAllowed, listTrackedFiles } from './scan-repo.mjs' + +test('tracked filenames use NUL framing and preserve Unicode and newlines', () => { + const root = mkdtempSync(join(tmpdir(), 'sc-scan-')) + const filename = '秘密\nvalue.txt' + try { + execFileSync('git', ['init', '-q'], { cwd: root }) + writeFileSync(join(root, filename), 'safe') + execFileSync('git', ['add', '--', filename], { cwd: root }) + assert.deepEqual(listTrackedFiles(root), [filename]) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('allowlist entries suppress only one exact finding', () => { + const finding = { ruleId: 'private-key', path: 'fixture.txt', line: 7 } + assert.equal(isAllowed(finding, new Set(['private-key:fixture.txt:7'])), true) + assert.equal(isAllowed(finding, new Set(['private-key:fixture.txt'])), false) + assert.equal(isAllowed(finding, new Set(['private-key:*'])), false) +}) + +test('GitHub annotation fields cannot inject workflow commands', () => { + assert.equal(escapeCommandProperty('bad\n::error file=x,y:z'), 'bad%0A%3A%3Aerror file=x%2Cy%3Az') + assert.equal(escapeCommandData('message%\r\n::warning::x'), 'message%25%0D%0A::warning::x') +})