Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2d42d52
Read the paths and the messages a guard publishes, not only the blobs
HackingGate Aug 12, 2026
9b0b69e
Select from what git tracks, and hand the exec the bytes it was given
HackingGate Aug 12, 2026
6b3f783
Make the clean answer reachable for audit --for-publication
HackingGate Aug 12, 2026
66204df
Ask both pin questions of one answer, and exit 2 where neither fits
HackingGate Aug 12, 2026
c09ca8c
Read the seams a repository publishes, not the ids it happens to pin
HackingGate Aug 12, 2026
93d1140
Fire the seams CI pins and never exercises
HackingGate Aug 12, 2026
9430f17
Describe the seams as they now behave
HackingGate Aug 12, 2026
2ca06f5
Drain the pipe before waiting on what fills it
HackingGate Aug 12, 2026
7335700
Stop the pin walk at a boundary, and at an error
HackingGate Aug 12, 2026
ed75694
Separate "nothing is declared not-text" from "nobody could ask"
HackingGate Aug 12, 2026
ed18867
Refuse the editor path this shim cannot stand in front of
HackingGate Aug 12, 2026
a21cfdb
Say what an inherited shim and a blank checker entry do
HackingGate Aug 12, 2026
6c9e538
Refuse a malformed inherit list, and read a remote as one entry
HackingGate Aug 12, 2026
218fc89
Describe an unreadable file as could-not-look
HackingGate Aug 12, 2026
fc3edd4
Report a fetch that failed, the way the comment said it did
HackingGate Aug 12, 2026
83a54bf
Recognise a remote that names this repository without an owner
HackingGate Aug 12, 2026
56d1157
Reject a remote that is somebody else's, not one that is anonymous
HackingGate Aug 12, 2026
95e94aa
Stop naming a runner's home directory while explaining not to
HackingGate Aug 12, 2026
54ef042
Stop reading a shared build account as somebody's identity
HackingGate Aug 12, 2026
caa9240
Ask the identity corpus a question that does not depend on who runs it
HackingGate Aug 12, 2026
8b81ed3
Refuse a literal owner in every variant, not only the first
HackingGate Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,27 @@ jobs:
# pre-commit and prek bootstrap their own Rust; lefthook has no manifest
# contract and no language to bootstrap, so the binary has to be built and
# put on PATH the way the install instructions tell a consumer to.
- uses: dtolnay/rust-toolchain@stable
#
# Which is why the toolchain is gated rather than installed for the whole
# matrix. "No Rust toolchain needed -- `language: rust` bootstraps" is a
# claim README.md makes to consumers and .pre-commit-hooks.yaml repeats,
# and a step that puts a compiler on PATH before either runner starts is
# the one thing that makes the claim untestable: both legs would find
# cargo already there and pass whether or not the bootstrap works. The
# only leg that may have a toolchain handed to it is the one whose install
# instructions say to build the binary yourself.
#
# The cache is gated on the same condition and not on taste. Swatinem's
# action shells out to cargo to key itself, so on a leg that is
# deliberately without one it is a step that fails for a reason unrelated
# to what this job asks -- and it has nothing to cache there either,
# because pre-commit and prek build inside their own environment
# directories rather than into this workspace's target/.
- if: matrix.tool == 'lefthook'
uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
- if: matrix.tool == 'lefthook'
uses: Swatinem/rust-cache@v2
with:
key: parity-${{ matrix.tool }}

Expand Down
42 changes: 23 additions & 19 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,25 +129,6 @@ repos:
pass_filenames: false
always_run: true
stages: [pre-commit, manual]
# Not pre-commit: one `git ls-remote` per pinned repo is a network round
# trip, and a check that adds one to every commit is a check that gets
# commented out. pre-push is the last local moment before the work is
# shared; manual is how CI reaches it, which turns "whoever pushes next
# finds out" into something a scheduled run finds first.
#
# always_run with no filenames, because at pre-push the runner passes the
# files in the push and .pre-commit-config.yaml is almost never one of
# them. A pin whose upstream tag was deleted after it landed changes no
# file here, so a check gated on the pin file changing is a check that
# never runs against exactly that case.
- id: hook-pins-resolve
name: hook pins name refs that exist
entry: python3 scripts/check_hook_pins.py
language: system
pass_filenames: false
always_run: true
stages: [pre-push, manual]

- id: catalog-tests
name: catalog and checker tests
entry: python3 -m unittest discover -s tests
Expand Down Expand Up @@ -190,6 +171,29 @@ repos:
pass_filenames: false
always_run: true
stages: [pre-push]
# The pin check used to be a second hook, `hook-pins-resolve`, running a
# Python script that asked whether every `rev:` still named a ref that
# exists while the `no-stale-hook-pins` guard asked whether the pin had
# fallen behind. Two checkers over one answer are two verdicts free to
# disagree, and they did: the guard counted a pin it could not reach as
# passed while the script called the same pin unresolvable. The guard asks
# both questions now, over one `git ls-remote`, and a pin it could not
# check is exit 2 -- so the script is gone and the guard stages carry it.
#
# It stays off pre-commit for the reason the script did: one network round
# trip per pinned repository in front of every commit is a check that gets
# commented out. pre-push is the last local moment before the work is
# shared; manual is how CI and a scheduled run reach it, which is what
# turns "whoever pushes next finds out" into something a schedule finds
# first -- and losing that sweep is why this entry exists rather than
# leaving `guards-pre-push` alone.
- id: guards-manual
name: guards (manual sweep)
entry: cargo run --quiet -- guard --stage manual
language: system
pass_filenames: false
always_run: true
stages: [manual]
- id: engine-tests
name: scan engine tests
entry: cargo test --quiet
Expand Down
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ opinion nobody asked for. The rules already active here are:
- `catalog-reference-current`
- `catalog-tests`
- `catalog-validate`
- `hook-pins-resolve`
- `no-stale-hook-pins`
- `prevent-ai-author`
- `prevent-public-push`
Expand Down
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ not look — see [`explicit-unknown`](principles/explicit-unknown.toml).
```sh
uphold scan # content rules over the tree
uphold scan --text - # a commit message, release note, PR body
uphold rules --effective # every rule inheritance resolved to, and where each runs
uphold guard --stage pre-push # the guards for that git hook
uphold shim gh pr create ... # stand in front of a command, then exec
uphold audit --for-publication # before flipping private -> public
Expand All @@ -115,19 +116,27 @@ tables — an absent table is a place the rule does not run. Full field referenc

**`uphold scan`** evaluates content rules over the repository's own files,
using ripgrep's search libraries, so a pattern written against `rg` keeps
meaning what it meant. `--text -` runs it over prose that never becomes a file.
meaning what it meant. "Its own files" is **what git tracks**, not a directory
walk: a tracked file some ignore pattern also matches is still pushed and still
cloned, and walking the tree hid exactly those from every rule. A selected file
that cannot be read is **not** reported clean — it is named, with its reason, and
the run exits `2`. `--text -` runs it over prose that never becomes a file. `uphold rules --effective` prints what
inheritance actually resolved to, so nothing has to re-derive it.
Comment on lines +119 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Describe unreadable tracked files as could-not-look.

Line 121 says that a rule that cannot inspect a file “reports it clean.” This contradicts the exit-2 contract in Lines 89-90. If the scan cannot inspect a selected tracked file, document an explicit could-not-look result and the affected path.

As per coding guidelines, “When continuing cannot satisfy the contract safely, detect the condition at the earliest reliable boundary and return an explicit failure with evidence.”

🧰 Tools
🪛 LanguageTool

[grammar] ~121-~121: Use a hyphen to join words.
Context: ...hat cannot see a file reports it clean. --text - runs it over prose that never becomes ...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 119 - 123, Update the README explanation of
tracked-file inspection to state that an unreadable selected tracked file
produces an explicit “could-not-look” result, includes the affected path, and
follows the documented exit-2 contract; remove the claim that such files are
reported clean.

Source: Coding guidelines


**`uphold guard --stage STAGE`** reads an *act* rather than a tree: the
message about to be recorded, the identity about to be stamped, the range about
to be pushed. Eleven built-in guards, registered by `git.hooks`.
`UPHOLD_ALLOW=<id>` overrides one invocation.
to be pushed. Eleven built-in guards, registered by `git.hooks`. A file's
**name** is committed text too, and at a push the guards also read the commit
**messages** the push publishes. `UPHOLD_ALLOW=<id>` overrides one invocation.

**`uphold shim`** stands in front of a command, checks what the invocation
is about to publish, and execs through. A pull-request body reaches a public API
without passing a single hook; so does a branch name, an issue title, and a
commit written under `--no-verify`. Put a link named for the command on PATH
ahead of the real one — that is what a multicall binary is for, and why there is
no installer.
no installer. Where the body is composed in an **editor**, the shim makes itself
the editor and checks what the editor leaves in the file when it closes — so
there is no invocation whose published text goes unread.

## The catalog

Expand Down Expand Up @@ -188,7 +197,7 @@ python3 scripts/validate.py # schema and relationship validation
python3 scripts/build_reference.py # rebuild the generated files after edits
python3 -m unittest discover -s tests
./uphold_check.py # this repo's own declaration
python3 scripts/check_hook_pins.py # every rev: names a ref that exists
cargo run --quiet -- guard --stage manual # every pin still names a ref
```

```text
Expand Down
1 change: 0 additions & 1 deletion REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ opinion nobody asked for. The rules already active here are:
- `catalog-reference-current`
- `catalog-tests`
- `catalog-validate`
- `hook-pins-resolve`
- `no-stale-hook-pins`
- `prevent-ai-author`
- `prevent-public-push`
Expand Down
20 changes: 15 additions & 5 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,18 @@ This repository defines **why and when** a rule exists; the seams implement it.
The same rule should not acquire a second, drifting definition merely because it
is enforced at another seam.

`check_hook_pins.py` and `no-stale-hook-pins` ask opposite questions of the same
answer: whether a pin is behind the newest upstream tag, and whether the tag it
names exists at all. A pin bumped ahead of a release that was never cut fails at
hook-init, before any hook runs, so nothing downstream of the clone can report
it.
`no-stale-hook-pins` asks both halves of one question of one answer: whether a
pin has fallen behind the newest upstream tag, and whether the ref it names
exists at all. They were two checkers for a while -- a `check_hook_pins.py`
script beside the guard -- and that arrangement is the drift this section warns
about: the two read the same `rev:` lines, reached the same remote, and were
free to return different verdicts. They did. The guard counted a pin whose
remote it could not reach as passed, while the script called the same pin
unresolvable, so which answer a repository got depended on which seam ran. One
`git ls-remote` now answers both, and a pin that could not be checked is exit 2
rather than either verdict.

A pin bumped ahead of a release that was never cut still fails at hook-init,
before any hook runs, so nothing downstream of the clone can report it -- which
is why the guard is installed at pre-push and at the manual stage, the last two
moments that are still upstream of somebody else's clone.
Loading
Loading