-
Notifications
You must be signed in to change notification settings - Fork 0
ci: gate that append-only board files never shrink #1167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,275 @@ | ||
| #!/usr/bin/env python3 | ||
| """Fail a PR that SHORTENS an append-only board file. | ||
|
|
||
| The protected files below are append-only logs: every session PREPENDS its | ||
| newest entry at the head. Concurrent sessions therefore collide at exactly the | ||
| same lines, by construction -- and the tempting resolution ("take mine", "take | ||
| theirs", `git checkout --ours`) silently DELETES the other session's entry. The | ||
| loss is invisible in review: the file still parses, still reads well, and the | ||
| only trace is that it got shorter. | ||
|
|
||
| The workspace law (root `CLAUDE.md`; the full statement plus its incident | ||
| receipts live in | ||
| `.claude/knowledge/never-truncate-a-file-you-still-need-to-read.md`): | ||
|
|
||
| an append-only file that got SHORTER is always a defect. | ||
|
|
||
| That check has been manual (`wc -l` after every ledger write). On 2026-09-04 | ||
| three sessions collided within one hour and only a hand-run `wc -l` prevented a | ||
| loss. This makes it mechanical. | ||
|
|
||
| WHAT IT IS NOT | ||
| -------------- | ||
| This gate measures LINE COUNT ONLY. It cannot see a same-length rewrite, a | ||
| reordering, or an entry replaced by another of equal size. It catches the one | ||
| failure mode that is both the most common and the most silent -- a dropped | ||
| prepend -- and claims nothing more. A green run is not proof the file is | ||
| append-only; a red run is proof it is not. | ||
|
|
||
| USAGE | ||
| ----- | ||
| python3 .claude/tools/append_only_gate.py [BASE_REF] # default origin/main | ||
| python3 .claude/tools/append_only_gate.py --self-test | ||
|
|
||
| Exit 0 = no protected file shrank. Exit 1 = at least one did (or the gate could | ||
| not do its job -- an unresolvable base ref fails closed, never silently green). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import subprocess | ||
| import sys | ||
|
|
||
| DEFAULT_BASE = "origin/main" | ||
|
|
||
| # The append-only board files, per root `CLAUDE.md` -- "The governance files are | ||
| # APPEND-ONLY (prepend new entries; never edit past entries except the | ||
| # `**Status:**` / `**Confidence:**` lines)". | ||
| # | ||
| # Deliberately NOT the whole of `.claude/board/`: several files there are | ||
| # genuinely rewritable (the generated `SUPERSESSION-INDEX.md`, working | ||
| # scratch/roadmap files), and protecting them would make the gate fire on | ||
| # correct work -- a gate that objects to everything carries exactly as much | ||
| # information as one that never fires. | ||
| PROTECTED = ( | ||
| ".claude/board/LATEST_STATE.md", | ||
| ".claude/board/EPIPHANIES.md", | ||
| ".claude/board/PR_ARC_INVENTORY.md", | ||
| ".claude/board/STATUS_BOARD.md", | ||
| ".claude/board/ISSUES.md", | ||
| ".claude/board/TECH_DEBT.md", | ||
| ".claude/board/AGENT_LOG.md", | ||
| ".claude/board/INTEGRATION_PLANS.md", | ||
| ) | ||
|
|
||
| REMEDY = """ | ||
| This is almost always a PREPEND CONFLICT resolved by picking a side. | ||
| Two sessions each added a new entry at the head of the same append-only | ||
| file; taking one side dropped the other session's entry entirely. | ||
|
|
||
| DO NOT resolve it by keeping "the newer" or "the bigger" side. | ||
| RESOLVE IT BY KEEPING BOTH ENTRIES -- put both new blocks at the head, | ||
| in whatever order reads correctly, and leave every pre-existing entry | ||
| below them untouched. Nothing below the head should have moved at all. | ||
|
|
||
| To see exactly what went missing: | ||
| git diff {base}...HEAD -- {paths} | ||
|
|
||
| If a shrink is genuinely intended (a deliberate, operator-sanctioned | ||
| removal), say so explicitly in the PR body -- and note that the storno | ||
| convention is to REGRADE an entry in place, never to delete it. | ||
| """ | ||
|
|
||
|
|
||
| class GateError(RuntimeError): | ||
| """The gate could not do its job. Fails closed, never silently green.""" | ||
|
|
||
|
|
||
| def _run(args: list[str]) -> subprocess.CompletedProcess: | ||
| return subprocess.run(args, capture_output=True, text=True) | ||
|
|
||
|
|
||
| def resolve_base(base_ref: str) -> str: | ||
| """Merge-base of HEAD and the base ref. | ||
|
|
||
| A PR branch's base ref moves on after the branch was cut, so a straight | ||
| `git show <base>:<path>` would compare against work the branch never saw -- | ||
| an unrelated session's later prepend would read as "grew", masking a real | ||
| shrink. The merge-base is the file as the branch actually inherited it. | ||
| """ | ||
| proc = _run(["git", "merge-base", "HEAD", base_ref]) | ||
| if proc.returncode != 0: | ||
| raise GateError( | ||
| f"cannot compute merge-base of HEAD and {base_ref!r}: " | ||
| f"{proc.stderr.strip()}\n" | ||
| " (in CI this usually means a shallow checkout -- the workflow " | ||
| "needs `fetch-depth: 0`)" | ||
| ) | ||
| return proc.stdout.strip() | ||
|
|
||
|
|
||
| def line_count_at(rev: str, path: str) -> int | None: | ||
| """Line count of `path` at `rev`, or None when the file does not exist there.""" | ||
| proc = _run(["git", "show", f"{rev}:{path}"]) | ||
| if proc.returncode != 0: | ||
| return None | ||
| return count_lines(proc.stdout) | ||
|
|
||
|
|
||
| def line_count_in_tree(path: str) -> int | None: | ||
| """Line count of the working-tree file, or None when absent. | ||
|
|
||
| Read from the tree rather than from HEAD so the gate is equally usable | ||
| locally on uncommitted work, which is where a session can still fix a | ||
| clobber cheaply. | ||
| """ | ||
| try: | ||
| with open(path, "r", encoding="utf-8", errors="replace") as fh: | ||
| return count_lines(fh.read()) | ||
|
Comment on lines
+127
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
sed -n '1,220p' .claude/tools/append_only_gate.py
printf '%s\n' '--- relevant status/diff summary ---'
git status --short -- .claude/tools/append_only_gate.py
git diff --stat -- .claude/tools/append_only_gate.py
printf '%s\n' '--- symlink and protected-path references ---'
stat -c '%F %n' .claude/tools/append_only_gate.py
rg -n -C 4 'PROTECTED|line_count_in_tree|open\\(|check\\(|evaluate\\(' .claude/tools/append_only_gate.pyRepository: AdaWorldAPI/lance-graph Length of output: 8721 🤖 get_repo_knowledge executed:
Length of output: 10199 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- CLI tail ---'
sed -n '220,320p' .claude/tools/append_only_gate.py
printf '%s\n' '--- direct invocations ---'
rg -n -C 5 'append_only_gate|line_count_in_tree|PROTECTED' .github .claude 2>/dev/null || true
printf '%s\n' '--- protected path index modes ---'
git ls-files -s -- \
.claude/board/LATEST_STATE.md \
.claude/board/EPIPHANIES.md \
.claude/board/PR_ARC_INVENTORY.md \
.claude/board/STATUS_BOARD.md \
.claude/board/ISSUES.md \
.claude/board/TECH_DEBT.md \
.claude/board/AGENT_LOG.md \
.claude/board/INTEGRATION_PLANS.mdRepository: AdaWorldAPI/lance-graph Length of output: 8852 🌐 Web query:
💡 Result: In Git, symbolic links are managed as blob objects containing the target path string rather than the target file's content [1][2][3]. The handling of these links during checkout and when viewing them via Git commands is governed by specific configuration and command behavior [1][2]. Core.symlinks Configuration The core.symlinks configuration setting determines how Git handles symbolic links in the working directory during checkout [4][1]. - When core.symlinks is true (default on most Unix-like systems), Git reconstructs the symbolic link as a functional filesystem-level link [1]. - When core.symlinks is false (often the default on Windows), Git checks out the symlink as a small, plain text file containing only the target path string [4][1]. - This setting is probed during git init or git clone and set automatically based on the capabilities of the filesystem [4][5]. Git Show and Symlink Blobs When you use git show to inspect a symbolic link blob (e.g., git show HEAD:path/to/symlink), Git behaves as follows: - By default, git show displays the content of the blob itself, which is simply the path string the symbolic link points to [2][6]. - Unlike some other filesystem operations, standard git show does not automatically dereference or follow symbolic links to show the content of the target file [6]. - While there have been community discussions and patches to add a --follow-symlinks option to resolve symlinks and show the target's content, this is not a standard feature in the primary git show command [7][6]. Summary of Behavior - Storage: Git stores the link target path, not the target file's content [1][3]. - Checkout: Recreated as a functional symlink if core.symlinks is true; otherwise, as a plain text file [1][2]. - Inspection: git show displays the link target path (the blob's content), not the target file's content [2][6]. Citations:
Reject symbolic links before reading protected paths. When 🤖 Prompt for AI Agents |
||
| except FileNotFoundError: | ||
| return None | ||
|
|
||
|
|
||
| def count_lines(text: str) -> int: | ||
| """Number of lines, counting a final unterminated line. | ||
|
|
||
| `str.count("\\n")` alone under-counts a file with no trailing newline by | ||
| one, which would read as a one-line shrink on an otherwise untouched file. | ||
| """ | ||
| if not text: | ||
| return 0 | ||
| return text.count("\n") + (0 if text.endswith("\n") else 1) | ||
|
|
||
|
|
||
| def evaluate(before: int | None, after: int | None) -> tuple[str, str]: | ||
| """Classify one file. Returns (verdict, human note). | ||
|
|
||
| verdict is one of: "ok", "new", "shrank", "deleted". | ||
| """ | ||
| if before is None and after is None: | ||
| return "ok", "absent at base and in head (nothing to check)" | ||
| if before is None: | ||
| return "new", f"new file, {after} lines (absent at base -- fine)" | ||
| if after is None: | ||
| return "deleted", f"DELETED (was {before} lines at base)" | ||
| if after < before: | ||
| return "shrank", f"{before} -> {after} lines ({after - before})" | ||
| if after == before: | ||
| return "ok", f"{before} lines, unchanged length" | ||
| return "ok", f"{before} -> {after} lines (+{after - before})" | ||
|
|
||
|
|
||
| def check(base_ref: str, paths=PROTECTED) -> int: | ||
| base_sha = resolve_base(base_ref) | ||
| print(f"append-only gate: base {base_ref} -> merge-base {base_sha[:12]}") | ||
|
|
||
| failures = [] | ||
| for path in paths: | ||
| before = line_count_at(base_sha, path) | ||
| after = line_count_in_tree(path) | ||
| verdict, note = evaluate(before, after) | ||
| marker = "FAIL" if verdict in ("shrank", "deleted") else "ok " | ||
| print(f" {marker} {path}: {note}") | ||
| if verdict in ("shrank", "deleted"): | ||
| failures.append((path, verdict, before, after)) | ||
|
|
||
| if not failures: | ||
| print(f"\nOK: no protected append-only file shrank ({len(paths)} checked).") | ||
| return 0 | ||
|
|
||
| print("\n" + "=" * 72) | ||
| print("APPEND-ONLY VIOLATION: a board file got SHORTER.") | ||
| print("=" * 72) | ||
| for path, verdict, before, after in failures: | ||
| if verdict == "deleted": | ||
| print(f"\n {path}\n DELETED (base: {before} lines, head: file absent)") | ||
| else: | ||
| print( | ||
| f"\n {path}\n" | ||
| f" base: {before} lines\n" | ||
| f" head: {after} lines\n" | ||
| f" delta: {after - before} lines LOST" | ||
| ) | ||
| print( | ||
| REMEDY.format( | ||
| base=base_ref, | ||
| paths=" ".join(p for p, _, _, _ in failures), | ||
| ) | ||
| ) | ||
| return 1 | ||
|
|
||
|
|
||
| # -------------------------------------------------------------------------- | ||
| # Self-test. Both halves are required: a gate that cannot fire and a gate that | ||
| # fires on everything carry the same information (zero). These run in-process | ||
| # against `evaluate`, the single place the verdict is decided, so they need no | ||
| # git repo and no fixtures. | ||
| # -------------------------------------------------------------------------- | ||
|
|
||
| SELF_TEST_CASES = [ | ||
| # (name, before, after, expected_verdict) | ||
| ("shortened file FIRES", 500, 480, "shrank"), | ||
| ("one line lost FIRES", 500, 499, "shrank"), | ||
| ("whole file deleted FIRES", 500, None, "deleted"), | ||
| ("grown file is SILENT", 500, 530, "ok"), | ||
| ("unchanged file is SILENT", 500, 500, "ok"), | ||
| ("newly created file is SILENT", None, 42, "new"), | ||
| ("absent both sides is SILENT", None, None, "ok"), | ||
| ] | ||
|
|
||
|
|
||
| def self_test() -> int: | ||
| print("append-only gate self-test") | ||
| print("-" * 72) | ||
| failed = 0 | ||
| for name, before, after, expected in SELF_TEST_CASES: | ||
| verdict, note = evaluate(before, after) | ||
| fires = verdict in ("shrank", "deleted") | ||
| want_fires = expected in ("shrank", "deleted") | ||
| ok = verdict == expected | ||
| print( | ||
| f" [{'PASS' if ok else 'FAIL'}] {name:<32} " | ||
| f"before={str(before):>4} after={str(after):>4} " | ||
| f"-> {verdict:<7} ({'fires' if fires else 'silent'}) | {note}" | ||
| ) | ||
| if not ok: | ||
| failed += 1 | ||
| print(f" expected verdict {expected!r}, got {verdict!r}") | ||
|
|
||
| # Anti-vacuity: the suite must contain BOTH a case that fires and a case | ||
| # that stays silent, or it proves nothing about discrimination. | ||
| fired = sum(1 for _, b, a, _ in SELF_TEST_CASES if evaluate(b, a)[0] in ("shrank", "deleted")) | ||
| silent = len(SELF_TEST_CASES) - fired | ||
| print("-" * 72) | ||
| print(f" discrimination: {fired} case(s) fire, {silent} case(s) stay silent") | ||
| if fired == 0: | ||
| print(" [FAIL] no case fires -- a gate that cannot fire is not a gate") | ||
| failed += 1 | ||
| if silent == 0: | ||
| print(" [FAIL] no case stays silent -- a gate that fires on everything is not a gate") | ||
| failed += 1 | ||
|
|
||
| if failed: | ||
| print(f"\nSELF-TEST FAILED: {failed} problem(s).") | ||
| return 1 | ||
| print(f"\nSELF-TEST PASSED: {len(SELF_TEST_CASES)} cases, both halves proven.") | ||
| return 0 | ||
|
|
||
|
|
||
| def main(argv: list[str]) -> int: | ||
| args = argv[1:] | ||
| if args and args[0] in ("--self-test", "--selftest"): | ||
| return self_test() | ||
| if args and args[0] in ("-h", "--help"): | ||
| print(__doc__) | ||
| return 0 | ||
| base_ref = args[0] if args else DEFAULT_BASE | ||
| try: | ||
| return check(base_ref) | ||
| except GateError as exc: | ||
| print(f"append-only gate: ERROR: {exc}", file=sys.stderr) | ||
| return 1 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main(sys.argv)) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| name: Append-only board files never shrink | ||
| on: | ||
| pull_request: | ||
| paths: | ||
| # The protected files themselves. A PR that does not touch the board | ||
| # cannot shrink it, so there is nothing to check. | ||
| - .claude/board/LATEST_STATE.md | ||
| - .claude/board/EPIPHANIES.md | ||
| - .claude/board/PR_ARC_INVENTORY.md | ||
| - .claude/board/STATUS_BOARD.md | ||
| - .claude/board/ISSUES.md | ||
| - .claude/board/TECH_DEBT.md | ||
| - .claude/board/AGENT_LOG.md | ||
| - .claude/board/INTEGRATION_PLANS.md | ||
| # Broader than the eight above ON PURPOSE: the protected list lives in | ||
| # the script, and a future entry added there must be gated from the | ||
| # commit that adds it -- not from the next commit that happens to touch | ||
| # a file already on the list. | ||
| - .claude/board/** | ||
| # The gate's own two files, so a change to either is checked by itself. | ||
| - .claude/tools/append_only_gate.py | ||
| - .github/workflows/append-only-gate.yml | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| no-shrink: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| # REQUIRED, not a tuning knob. The gate compares each protected file | ||
| # against the MERGE-BASE of this branch and the target -- the version | ||
| # the branch actually inherited, not wherever the target has moved | ||
| # since. A shallow checkout has no merge-base to compute, and the | ||
| # gate fails closed rather than passing on a comparison it could not | ||
| # make. | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Self-test the gate | ||
| # Runs first, and deliberately. If the gate cannot prove it both FIRES | ||
| # on a shortened file and STAYS SILENT on a grown one, its verdict on | ||
| # the real diff is worthless -- so the self-test gates the gate. | ||
| run: python3 .claude/tools/append_only_gate.py --self-test | ||
|
|
||
| - name: Check protected board files did not shrink | ||
| run: | | ||
| python3 .claude/tools/append_only_gate.py \ | ||
| "origin/${{ github.event.pull_request.base.ref }}" | ||
|
Comment on lines
+49
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 4099 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow ---'
cat -n .github/workflows/append-only-gate.yml
printf '%s\n' '--- gate tool ---'
cat -n .claude/tools/append_only_gate.py
printf '%s\n' '--- related workflow references ---'
rg -n --glob '.github/workflows/**' --glob '.claude/tools/**' 'append_only_gate|pull_request_target|workflow_call|protected board|append.only' .Repository: AdaWorldAPI/lance-graph Length of output: 15134 🌐 Web query:
💡 Result: In GitHub Actions, checking out code from a pull request in a privileged context—such as workflows triggered by pull_request_target or workflow_run (when initiated by a pull request)—poses a severe security risk known as a "pwn request" [1][2][3]. Because these workflows run with the base repository's secrets, GITHUB_TOKEN, and access to the default-branch cache, executing malicious code from a fork can lead to repository takeover, secret exfiltration, and supply-chain compromise [4][5][3]. To mitigate this, the actions/checkout action (v7 and later) now refuses to check out fork pull request code by default in these privileged contexts [1][6][2]. Key Security Guardrails and Behaviors: 1. Blocked Checkout: If your workflow uses pull_request_target or workflow_run, actions/checkout will block checkouts of fork pull request head or merge commit refs [1][6]. 2. Opt-in Mechanism: If your workflow requires checking out this code, you must explicitly opt-in by setting allow-unsafe-pr-checkout: true [4][7][2]. This should only be done after thoroughly ensuring that the checked-out code is never executed, built, or interpreted as code within the privileged workflow [4][7]. 3. Recommended Pattern: To safely handle untrusted pull requests, use an unprivileged workflow triggered by pull_request to process the code, build it, or run tests [5][8]. Store any required outputs as artifacts [5][8]. Use a second, privileged workflow triggered by workflow_run to download those artifacts and perform tasks that require secrets or repository write access [5][3][8]. 4. Default Checkout Ref: In standard pull_request workflows, actions/checkout defaults to the merge commit (refs/pull/number/merge) rather than the head of the PR branch [9]. This is generally safer, but regardless of the trigger, you must always treat content from pull requests—such as branch names, PR titles, or file content—as untrusted input that should never be directly executed or injected into shell commands [3][10][11]. Always prioritize the separation of untrusted code processing from privileged operations [5][3]. Opting out of the checkout protection is a deliberate security decision that requires manual review of the workflow to prevent "pwn request" vulnerabilities [4][1][2]. Citations:
Run the enforcement code from a trusted revision. This Move this check to a repository- or organization-controlled required workflow that the pull request cannot modify. Treat the pull request tree as data in that workflow. Do not use 🧰 Tools🪛 zizmor (1.29.0)[error] 54-54: code injection via template expansion (template-injection): may expand into attacker-controllable code (template-injection) 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a PR truncates
.claude/board/IDEAS.md, the broad.claude/board/**path filter starts this workflow, butcheck()iterates onlyPROTECTEDand therefore reports success without examining that file..claude/BOOT.md:78-100and.claude/settings.json:152-175explicitly includeIDEAS.mdamong the eight protected bookkeeping files, whereas this tuple substitutesAGENT_LOG.md, leaving an append-only ledger exposed to exactly the data loss this gate is intended to prevent.Useful? React with 👍 / 👎.