What a file's bytes say is decided once, and a host nobody can ask is not a pass - #124
Conversation
… not a pass Three seams were answering a two-valued question that has three answers, and each one spent the third answer on a green tick. WHAT A BLOB TURNED OUT TO BE. `guard::unicode` had the three-answer reader: byte-order mark first, git's NUL test for binary, and anything else that is not UTF-8 is unreadable and exit 2. Every other reader of the same blobs decoded with `String::from_utf8_lossy` or tested for a NUL and skipped. So a UTF-16 file naming a private repository was replacement characters with NULs between them in the tracked scan, a skipped image in the staged scan, and a decoded document in the codepoint guard, all in one run over one blob. The reader moves to `guard::scope`, beside `read`, because which bytes an operation introduces and what those bytes turned out to be are two halves of one question. The message readers go through it too: a message is text by construction, so bytes that will not decode are a message nobody read, which is exit 2 rather than a skip. What cannot be decoded joins the guard's could-not-look accounting, beside a name the forge would not answer for and outranked by a name that was found. A HOST NOBODY CAN ASK. `gh` answers for github.com and for nothing else, and the guard listed six other forges by hand and dropped every other host in silence: `github.acme.com/acme/secret`, a self-hosted forge, anything nobody had thought of. That is a silent third answer in the guard whose subject is that there is no such thing. Any `host.tld/owner/repo` this tool cannot ask about is unavailable now, which is exit 2, and what quiets one is a declaration rather than a list compiled in: `foreign_hosts`, host globs, at the top of the policy file for the reason `private_owners_from` is there and on a rule where one corner of the tree wants its own. Which hosts a repository cites is policy, which is `parameterize-do-not-enumerate` and what the hand list stood against. An owner whose escaped name would not compile into a pattern was dropped mid-search with nothing printed, so the operator's list said one thing and the guard looked for another. That is a config error at load now, as is a host glob that will not parse. EVERY CHECK READS THE SAME TEXT. `allowed_scripts` decoded under a covering `encoding` rule and stopped the run where nothing declared one. `regexp`, `forbidden_literals` and `require_regexp` reached the tree through a searcher with binary detection off and a lossy sink, so the same UTF-16 file was exit 2 for one check and clean for the other three. One reader in `scan` decides now -- UTF-8 where it is UTF-8, the declared charset where a rule declares one, the byte-order mark otherwise -- and hands text to the engine, which no longer opens files at all. A file nothing decodes goes into the unreadable list, which is reported beside the findings rather than instead of them; binary is the one skip. `require_regexp` gains the most: a file it could not read was a violation about a marker that may well have been in it. Beside the fixes: `redact_matches` was the one field a policy file may write that no document named, so REFERENCE.md documents it and a structural test now holds every public field of the serde-facing structs to being named there in code. ROADMAP said the Python script ships the reconcile step, which `uphold check` has done since it moved into the binary; README says what the script still does. DESIGN's account of which bytes a guard reads was missing the commit messages a push publishes. Closes #112 Closes #113 Closes #114 Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd
|
Warning Review limit reachedNext included review available in 50 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (17)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
❌ Your patch status has failed because the patch coverage (82.59%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #124 +/- ##
==========================================
- Coverage 94.12% 93.96% -0.16%
==========================================
Files 37 37
Lines 13493 13667 +174
==========================================
+ Hits 12700 12842 +142
- Misses 793 825 +32 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…y no longer does Twenty-eight host globs at the top of the policy file, and every one of them was being answered by a list compiled into the binary that nobody could see or edit. Without them `no-private-repo-names-in-files` reports 55 names on hosts `gh` cannot be asked about, which is exit 2 at `pre-push` and at `manual`: a licence URL, a bibliography, a standards reference and a fixture publishing to `example.com` are all `host.tld/a/b`, which is the shape a repository name has and is not one. Four of the entries are documentation rather than citation and are noted as such in the file: `host.tld`, `github.acme.com` and `gitlab.com` are the worked examples the reference and this guard's own tests need to be able to write, and the `*.example.*` family is what every fixture publishes to. A working tree with an older uphold installed will find its `git` and `gh` shims failing closed on this file until the binary carrying `foreign_hosts` is installed, because a shim loads the policy with the uphold that is on PATH and an unknown field means the policy does not load. That is the same trade a new bundled set makes, and the cure is the same: install the build from this branch. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd
An unaskable host was one answer for every name written against it, and exit 2 for all of them made the cure "enumerate every host you cite" in every consuming repository. That is the enumeration ADR 0001 refuses, moved out of the binary and into eighty policy files, which is worse than where it started: a consumer publishing a pull-request body was refused over `claude.ai/code/<id>`, and this tree needed twenty-eight host globs to say its bibliography is a bibliography. The split is by owner, because the owner is what the rule is about. A name under a declared `private_owners` owner, or under the `owner` this policy says the workspace is, on a host `gh` cannot be asked about, is could-not-look and exit 2: that is the name this rule exists to keep off a public target, and silence about it is not a pass. Every other `host.tld/a/b` goes back to the unresolved bucket it was in -- reported on the way past, refused only where the repository has set `refuse_unknown` -- because a DOI, a licence URL and an encyclopaedia article all have the shape of a repository name and none of them is one. `foreign_hosts` keeps its job and gains no new one: it says a host is not a forge at all, and it quiets both rows. Nothing is quieted by default. The twenty-eight globs come back out of this repository's own policy. They were load-bearing only under the wider default, the guard passes at `manual` without them, and a policy field carrying a list nobody needs is the enumeration again wearing a smaller number. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd
This branch sat on integration-2026-09-02, whose five branches have now landed on main as squashes (#116, #117, #123, #124, #126) alongside #129, so git saw the same lines added twice in src/scan.rs, src/shim.rs, src/text.rs, src/config.rs and tests/structural_documentation.rs. Resolved to the tree a rebase of this branch's own commit onto main produces: main's copy of all six, plus this branch's rule-as-a-type refactor on top. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd
…id which fields name a check are the type (#131) * Who is writing a commit is a question of its own, and a path is not a token `prevent-author-mismatch` was declared by hand in 8 policy files across one consuming superproject and its submodules, byte for byte in every one: same id, same built-in, one `pre-commit` stage, no parameters. The guard is already compiled in, so what was missing was a set to carry the declaration. `mismatched-author` is that set, and it is its own rather than a rule inside an existing one. `unowned-push` asks where a ref is going; `host-identity` reads the running machine and then searches committed content for what it read, which is why it ships `stages = []`; `commit-message-residue` is about the text a commit records. This one asks who is writing, read off `git var` before a message exists. Its ceiling is the single `pre-commit` stage, so it cannot grow a second one without a diff on the line that says so. The header writes down what it costs -- a deliberate `--author` is refused as readily as an accidental one -- and the one thing it cannot do: with no global `user.email` there is no expectation to compare against, so it says so and declines. That is the container case verbatim, and a guard that quietly does not run in its own scenario reads like one that ran and found nothing. `no-hardcoded-home-paths` takes the leading `(?:^|[^A-Za-z0-9_.-])` boundary the same fleet had added to its shadow. Without it the pattern matches `/home` wherever those five characters land inside a longer token, and each such finding is a report on a file holding no home path at all. The corpus records both halves: the two ways a real path still opens a match, and the two shapes the boundary now lets through. Not taken: the same shadow's carve-out for a `u` placeholder user. That is a house convention tied to one repository's own record, and a bundled rule exempting a one-letter username would be exempting a real one. `policy/base/sets.lock.json` regenerated from the binary, not edited. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * One table of what published text is judged by, reached from every seam `uphold hook` consulted the literal rules and the text-capable guards and never the prose rules, so every `prose_regexp` rule was dark at the MCP seam -- the whole bundled `prose-shapes` set among them, and those sets declare `command.before = ["gh", "git push"]` precisely because the text they judge is what an agent posts through `mcp__github__*`. `text::failures_in` excludes the prose rules on purpose, and each of the other whole-text seams carried its own compensating call to `prose::over_text` beside it. Four seams assembled the same ingredients by hand, so a rule kind added to three of them was silent in the fourth with nothing anywhere to say so. `text` now owns the answer. `Judged` names every kind of rule a piece of published text is judged by, `Seam::consults_every` says which kinds each of the four seams asks, and `text::judged` is the one assembly the three whole-text seams call. The shim keeps its own loop, because it holds a subject and a per-rule scope that function is not given, but it classifies each rule through `Judged::of` and asks the same table -- which also closes a second hole of the same shape: its editor pass dispatched `regexp` and `require_regexp` to the pattern checker and left `prose_regexp` to fall through to the `exec` consultation, where it was refused as a dispatch hole. Exit codes and report text are unchanged everywhere they already existed. The kinds run in one order, literals then guards then prose, which is the order each seam already printed the kinds it ran. Closes #111 Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * The shim reads the command line the command will read Five ways past this seam, reproduced against the built binary standing on PATH. Four of them ended in exit 0 with nothing printed, which is the shape of failure this tool exists to refuse. A renamed verb was one of them. A `match` list names verbs literally and every command here lets a person rename one, so `git -c alias.p=push p origin HEAD:refs/heads/x` and a persisted `[alias] p = push` both presented the verb `p`, matched nothing, and exec'd a push to a public forge unexamined. Where nothing matches, and only there, the word is resolved once against the command's own alias mechanism and matched again against the expansion. Asked of the real command rather than of whatever PATH resolves, because PATH resolves to this shim. A shell alias and a lookup that failed are could-not-looks and not absences: exit 2, since what a shell runs is not an invocation any table can read and it may well be a push. The refspec collector read one grammar where the matcher reads two. The matcher tries both arities for an option it cannot classify and matches under either; the collector took the bare reading and skipped two words, so `git --attr-source HEAD push origin` checked the remote's nickname while the branch went out unread. Both readings now, and where they disagree about which names are being published the answer is exit 2 rather than a guess. That needed git's own grammar filling in: the value-taking globals it was missing, and the `push` vocabulary, without which the disagreement fires on `git push -f origin topic`. A scope that could not be evaluated stood every checker down. `public-target` is the one predicate that asks somebody else, and a forge that cannot be asked answered `false` -- the same word as "the policy decided these checks do not apply here". So `prevent-unowned-target`, whose own contract is exit 2 on a destination it could not resolve, was skipped exactly where the lookup failed. Three answers now, and the third refuses before the command runs. `unresolved = "run"` on a `[[shim]]` opts back into the old behaviour and is refused at load beside a scope that cannot produce that answer. `--all`, `--mirror` and `--tags` name no refspec and publish many, and the fallback read HEAD, so a mirror push of forty branches was checked as one. They are enumerated with `for-each-ref` and each name is read. A leading `+` is grammar and no part of a name, and leaving it on made the force-push the way past a rule standing in front of the branch it names. `send-pack:*` joins `push:*` in the shipped table and in the checkers that name it: the plumbing publishes the same names from the same positions. The two stand-downs that remain -- an unresolvable subcommand, and `UPHOLD_ALLOW=all` -- keep their behaviour and are now stated as what they are: exit 0, the command runs, and a stderr line saying no checker did. A contract nobody wrote down is one a reader can only learn by reading the source. Also: `git.hooks` takes five names and one of them is `manual`, which is not git's. ADR 0001 and the type's own doc comment said githooks(5). Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * What a file's bytes say is decided once, and a host nobody can ask is not a pass Three seams were answering a two-valued question that has three answers, and each one spent the third answer on a green tick. WHAT A BLOB TURNED OUT TO BE. `guard::unicode` had the three-answer reader: byte-order mark first, git's NUL test for binary, and anything else that is not UTF-8 is unreadable and exit 2. Every other reader of the same blobs decoded with `String::from_utf8_lossy` or tested for a NUL and skipped. So a UTF-16 file naming a private repository was replacement characters with NULs between them in the tracked scan, a skipped image in the staged scan, and a decoded document in the codepoint guard, all in one run over one blob. The reader moves to `guard::scope`, beside `read`, because which bytes an operation introduces and what those bytes turned out to be are two halves of one question. The message readers go through it too: a message is text by construction, so bytes that will not decode are a message nobody read, which is exit 2 rather than a skip. What cannot be decoded joins the guard's could-not-look accounting, beside a name the forge would not answer for and outranked by a name that was found. A HOST NOBODY CAN ASK. `gh` answers for github.com and for nothing else, and the guard listed six other forges by hand and dropped every other host in silence: `github.acme.com/acme/secret`, a self-hosted forge, anything nobody had thought of. That is a silent third answer in the guard whose subject is that there is no such thing. Any `host.tld/owner/repo` this tool cannot ask about is unavailable now, which is exit 2, and what quiets one is a declaration rather than a list compiled in: `foreign_hosts`, host globs, at the top of the policy file for the reason `private_owners_from` is there and on a rule where one corner of the tree wants its own. Which hosts a repository cites is policy, which is `parameterize-do-not-enumerate` and what the hand list stood against. An owner whose escaped name would not compile into a pattern was dropped mid-search with nothing printed, so the operator's list said one thing and the guard looked for another. That is a config error at load now, as is a host glob that will not parse. EVERY CHECK READS THE SAME TEXT. `allowed_scripts` decoded under a covering `encoding` rule and stopped the run where nothing declared one. `regexp`, `forbidden_literals` and `require_regexp` reached the tree through a searcher with binary detection off and a lossy sink, so the same UTF-16 file was exit 2 for one check and clean for the other three. One reader in `scan` decides now -- UTF-8 where it is UTF-8, the declared charset where a rule declares one, the byte-order mark otherwise -- and hands text to the engine, which no longer opens files at all. A file nothing decodes goes into the unreadable list, which is reported beside the findings rather than instead of them; binary is the one skip. `require_regexp` gains the most: a file it could not read was a violation about a marker that may well have been in it. Beside the fixes: `redact_matches` was the one field a policy file may write that no document named, so REFERENCE.md documents it and a structural test now holds every public field of the serde-facing structs to being named there in code. ROADMAP said the Python script ships the reconcile step, which `uphold check` has done since it moved into the binary; README says what the script still does. DESIGN's account of which bytes a guard reads was missing the commit messages a push publishes. Closes #112 Closes #113 Closes #114 Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * This repository names the hosts its own text cites, because the binary no longer does Twenty-eight host globs at the top of the policy file, and every one of them was being answered by a list compiled into the binary that nobody could see or edit. Without them `no-private-repo-names-in-files` reports 55 names on hosts `gh` cannot be asked about, which is exit 2 at `pre-push` and at `manual`: a licence URL, a bibliography, a standards reference and a fixture publishing to `example.com` are all `host.tld/a/b`, which is the shape a repository name has and is not one. Four of the entries are documentation rather than citation and are noted as such in the file: `host.tld`, `github.acme.com` and `gitlab.com` are the worked examples the reference and this guard's own tests need to be able to write, and the `*.example.*` family is what every fixture publishes to. A working tree with an older uphold installed will find its `git` and `gh` shims failing closed on this file until the binary carrying `foreign_hosts` is installed, because a shim loads the policy with the uphold that is on PATH and an unknown field means the policy does not load. That is the same trade a new bundled set makes, and the cure is the same: install the build from this branch. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * A forge API call that carries a body is a verb the shim stands in front of `gh pr edit` was unavailable to an agent whose token lacked read:org, so it ran `gh api -X PATCH repos/OWNER/REPO/pulls/N -F body=@file` -- the same body, the same account, the same public tracker -- and no `match` list named `api`, so the shim exec'd it with nothing printed and exit 0. The same hole stood in front of `gh api -X POST .../issues`, `.../comments`, `.../releases` and every `glab api`. The verb is not one a `match` list can simply name, and that is why it was missing rather than forgotten. One word is both the read side and the write side of a forge, so standing in front of `api` outright would stand in front of every GET on the machine -- including the `gh api repos/OWNER/REPO --jq .visibility` this shim itself runs to answer `public-target`, which is a shim standing in front of its own lookup. Which half an invocation is, is a question about its argv. So `api:*` is matched, and a second question is asked of the one verb that has to answer it: does this call carry a body at all. It does when `-X`/`--method` names something other than GET, or when any field or `--input` is present, which is when gh switches to POST anyway. A GET with no fields is left exactly where it was -- unmatched, unexamined, exec'd. The call is read with the verb's own grammar rather than through the table's flag lists, because they disagree: `-F` is `--body-file` on `gh pr create` and `--field` on `gh api`, so one vocabulary answering for both reads a `key=@file` pair as a body file or a body file as a field. Every field value becomes a text subject, with `@file` read from the file and `@-` from stdin, replayed to the command on the way through the way a `--body-file -` already is. An `--input` file is judged one string value at a time where it is JSON, because the encoding otherwise hides the sentence a prose rule stands in front of behind its escapes, and whole where it is not -- and JSON here means a document that starts with `{` or `[` and parses as one, not YAML's superset reading, under which an ordinary Markdown body with a `Note:` line parses as a mapping and most of it reaches no checker. The endpoint path is the destination. `repos/OWNER/REPO/...`, and GitLab's `projects/OWNER%2FREPO`, resolve to the same `owner/repo` a `--repo` resolves to on every other verb, so `prevent-unowned-target` and a `public-target` scope read a `gh api` call exactly as they read a `gh pr create` one. A path naming no repository -- `gh api graphql`, `gh api user` -- falls through to the table's own resolver, which is the honest bound: the destination of a GraphQL mutation is inside its query and nothing in a path can say. Five tests: a PATCH whose `-F body=@file` trips a text rule refuses at exit 1, a GET with no fields runs, an `--input` JSON is refused for one string value and runs when that value is ordinary, a body bound for an owner this workspace does not have is refused by the destination guard while the same call under its own owner runs, and a GitLab project id is the same destination with its separator escaped. Closes #125 Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * Whose name it is decides what a host nobody can ask about means An unaskable host was one answer for every name written against it, and exit 2 for all of them made the cure "enumerate every host you cite" in every consuming repository. That is the enumeration ADR 0001 refuses, moved out of the binary and into eighty policy files, which is worse than where it started: a consumer publishing a pull-request body was refused over `claude.ai/code/<id>`, and this tree needed twenty-eight host globs to say its bibliography is a bibliography. The split is by owner, because the owner is what the rule is about. A name under a declared `private_owners` owner, or under the `owner` this policy says the workspace is, on a host `gh` cannot be asked about, is could-not-look and exit 2: that is the name this rule exists to keep off a public target, and silence about it is not a pass. Every other `host.tld/a/b` goes back to the unresolved bucket it was in -- reported on the way past, refused only where the repository has set `refuse_unknown` -- because a DOI, a licence URL and an encyclopaedia article all have the shape of a repository name and none of them is one. `foreign_hosts` keeps its job and gains no new one: it says a host is not a forge at all, and it quiets both rows. Nothing is quieted by default. The twenty-eight globs come back out of this repository's own policy. They were load-bearing only under the wider default, the guard passes at `manual` without them, and a policy field carrying a list nobody needs is the enumeration again wearing a smaller number. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd * A rule is one check carrying its own knobs, so the four lists that said which fields name a check are the type `Rule` was thirty-eight `Option` fields in one flat struct, fourteen of them mutually exclusive check selectors and ten more knobs that one check each reads. "Which fields name a check" was written out four times -- an array to count them, a sentence to list them in the error, a reader to answer which one was set, and a third list for the built-in parameters -- and nothing held the four to each other. `Check` is now an enum with one variant per check, carrying only what that check reads: `exclusive` sits in `AllowedScripts`, `ignore_literals` in `ForbiddenLiterals`, the sixteen built-in settings in a `Parameters` that only `Builtin` has, and the two spellings of the literal source are one `Literals` field with two arms. A rule holding two checks, or a knob beside a check that reads none, is not a state that can be built. `Check::of_written` is the one reading that is left, and the only place a file can still say two things. It is where the refusals live now, with their text unchanged: a rule naming two checks, a rule naming none, `forbidden_literals` beside `forbidden_literals_from`, `trivial_comments = false`, and a knob or a parameter written beside a check that does not read it. What the type cannot decide stays a check -- WHICH built-in reads which parameter is a question about a name, so `Parameters::refuse_unread` and `guard::parameters` still meet at load, in one function serving both the wrong-check and the wrong-built-in case rather than the two that used to say it separately. ## Why the conversion is not the deserializer Every refusal above names the rule a reader has to go and edit, and the id is the section header rather than a field, so it does not exist until `parse` has the map key. `Written` is the flat shape a file deserializes into, unchanged and still `deny_unknown_fields`; `parse` reads each one as a check the moment it has an id. An untagged enum would have moved these messages inside serde and lost both the id and the sentence. ## What the rest of the crate sees `Rule` keeps every accessor that had one and gains one per check value, so no caller reads a field that might belong to a different check: `rule.encoding()`, `rule.max_lines()`, `rule.regexp()`, `rule.allowed_scripts()`. `Rule::check() -> Option<CheckKind>` becomes `Rule::kind() -> CheckKind`, total, because there is no rule without a check any more. The old `Check` discriminant is `CheckKind` -- same variants, same order, same `as_str`, same `ALL`. `Rule::synthetic` takes the check itself rather than a kind, so the seven callers that built a rule and then set a field now say which check they mean in one expression. ## Serialization `uphold rules --sets --json` is byte for byte what it was: the enum is `#[serde(untagged)]` and flattened into `Rule`, so a variant's fields serialize as the flat keys a policy file writes, and the document is still derived rather than written by a function naming the fields it knows about. Diffed against the output of the binary built before this change, and against `policy/base/sets.lock.json`, which is unchanged. No `policy/*.toml` was touched. ## The file `src/config.rs` goes from 4657 lines to 3320, and the rule type moves to `src/config/rule.rs` at 1844. The two together are 507 lines more than the one was: the four lists and three validators that went are worth less than the per-variant documentation, which is the same prose in a place a reader meets it beside the check it is about, plus one accessor per check value. ## Tests 759 pass, 25 suites, up from 758 -- one new test, `the_field_a_file_writes_is_ the_check_it_loads_as`, which is the direction the type cannot keep on its own: a mis-wired arm in `of_written` would load a `max_bytes` rule as a `max_lines` one with nothing to notice. Two existing tests changed, both because the schema moved rather than because an assertion did: - `structural_documentation` held `Rule`'s public fields to being named in REFERENCE.md. It holds `Written`'s now, which is the struct a policy file is deserialized into and carries the same field names; and it reads both `src/config.rs` and `src/config/rule.rs`, because a reader of one alone would find none of the other's fields and assert about nothing. `config.rs::Rule` also leaves its undocumented-declaration list, having gained a doc comment. - `every_check_is_named_by_the_field_that_selects_it` asserted `rule.check() == Some(check)`; it asserts `rule.kind() == check`. No test asserting an error message needed changing: every refusal a test names is produced with the same words, from the conversion instead of the validator. `cargo build`, `cargo test`, `cargo clippy --all-targets` with warnings denied, `cargo fmt --check` and `prek run --all-files` are clean. Based on `integration-2026-09-02`, which carries #116, #117, #123, #124 and #126 -- all five touch `src/config.rs` or the shim that reads a rule, and this sits on top of them rather than under any one. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd
… unknown that is no longer a pass (#132) Point the documented pins at 1.14.0 and bump the crate version. Minor, not patch. This release carries names a policy written against 1.13.0 cannot use: the bundled set mismatched-author; api:* as a matchable verb on the gh and glab shim tables, so a forge call that carries a body is read the way gh pr create is; unresolved on a [[shim]] table, whose default refuses before exec where a scope could not be evaluated; and refuse_unknown, foreign_hosts and redact_matches on the private-name family. It also closes four seams that reported a pass they had not established. The MCP hook seam consulted no prose rule, so every prose_regexp was dark exactly where an agent publishes. Five command lines walked past the shim, four of them ending in exit 0 with nothing printed. A blob no charset decodes was read as clean rather than refused. A forge host nobody could ask about produced no finding, no report and no exit code. Landed in this release: #116, #117, #123, #124, #126, #129, #131. A policy written against 1.13.0 loads unchanged, and every new name has to be named or inherited to run. Two things do change for a consumer who only bumps a rev or a ref: the seam fixes above start refusing text and command lines that used to pass unexamined, and the four published Go ids are triggered by the module rather than by the file that changed, at all four stages rather than at pre-commit alone. Claude-Session: https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd
Three seams answered a two-valued question that has three answers, and each one
spent the third answer on a green tick. One reader for what a blob's bytes turn
out to be, one decision about the hosts this tool cannot ask about, and one
decode per file at the top of the scan.
Closes #112
Closes #113
Closes #114
1. One decoder, shared by every reader of a blob
guard::unicode::decode_for_scanwas the three-answer reader and the only one:a byte-order mark is consulted first, a NUL is git's binary test, and anything
else that is not UTF-8 is unreadable and exit 2. It moves to
guard::scopebeside
read, because which bytes an operation introduces and what those bytesturned out to be are two halves of the same question.
guard::namesnow consumes it in all three modes, andguard::messagereads amessage file through a new
scope::read_message, which refuses rather thanskips: a message is text by construction, so bytes that will not decode are a
message nobody read. What cannot be decoded joins the guard's could-not-look
accounting, printed beside a refusal rather than instead of it and outranked by
a name that was actually found.
Tests, in
tests/guard_recovered_halves.rs: a UTF-16 file naming a privaterepository is refused by the tracked guard and by the staged guard, a blob no
charset decodes is exit 2 with the path named, and a commit message that is not
text is exit 2 rather than a message reported clean.
2. A host
ghcannot answer foris_foreign_forge_hostlisted six forges andcandidateskept only github.com,so every other host produced nothing at all: no finding, no report, no exit
code.
github.acme.com/acme/secretand any self-hosted forge were dropped insilence.
Every
host.tld/owner/repooutside github.com is extracted now, and whosename it is decides what the silence means:
private_ownersowner, or the policy's ownownerrefuse_unknownThe first row is the name the rule exists to keep off a public target. The
second is a DOI, a licence URL, an encyclopaedia article -- the shape of a
repository name and not one. Calling those could-not-look made the cure
"enumerate every host you cite" in every consuming repository, which is
parameterize-do-not-enumeratewith the enumeration moved out of the binary andinto eighty policy files (third commit; see the note below).
foreign_hostssays a host is not a forge at all and quiets both rows:Host globs, matched case-insensitively, at the top of the policy file for the
reason
private_owners_fromis there -- a rule arriving from a bundled setcannot be handed a parameter -- and on a rule where one corner of the tree wants
its own list, which replaces the policy's rather than extending it. Both are
validated at load. Nothing is quieted by default. REFERENCE.md's claim that
"what is left fail-open under that default is the
404alone" is now truerather than aspirational.
An owner whose escaped name would not compile into a pattern was dropped
mid-search with nothing printed. That is a config error at load now.
3. Every check reads the same decoded text
allowed_scriptsdecoded under a coveringencodingrule and stopped the runwhere nothing declared one;
regexp,forbidden_literalsandrequire_regexpreached the tree through a searcher with
BinaryDetection::none()andsinks::Lossy. The same UTF-16 file was exit 2 for one check and clean for theother three, in one run.
Scan::text_ofdecides once per file: UTF-8 where it is UTF-8, the declaredcharset where an
encodingrule selects it, the byte-order mark otherwise. Afile nothing can decode goes into the existing
unreadablelist, reportedbeside the findings; binary is the one skip.
engineno longer opens a file atall -- it is handed text -- and
search_files/file_matchesbecomesearch_in/text_matches. The cost on an ordinary tree is onefrom_utf8over bytes that were about to be searched anyway, and the literal check now
decodes each file once instead of once per needle.
require_regexpgains the most: a file it could not read used to be a violationabout a marker that may well have been in it.
Tests in
tests/scan_cli.rscover all three directions.4.
redact_matches, and a test that would have caught itIt was the one public field of the serde-facing structs that no document named:
accepted by the deserializer, read in three places, findable only by reading
src/config.rs, which is the thing ADR 0001 says no field may require. It isdocumented in REFERENCE.md and carries a doc comment now, and
tests/structural_documentation.rsholds every public field ofFiles,Git,CommandWhere,Inherit,Rule,PolicyFileandSetHeaderto being namedin a REFERENCE.md code span. No other field was missing -- 68 fields
checked, one gap.
5 and 6. Doc debt
ROADMAP's enforcement section said
uphold_check.pyships the reconcile step;it is
uphold checkand has been since the loader moved into the binary, so thesection is rewritten around it, including why the move happened and what stayed
in the script. The README file map line describes what the Python still does
(
--explain,--list,--review,--oscal,--init), and the stale./uphold_check.pyinvocation two blocks above it becomesuphold check. ThePython is kept.
DESIGN's "Which bytes a guard reads" omitted the commit messages a push
publishes, which
guard::nameshas read since the pushed-range work.What is deliberate, and what is left
The narrowing is the third commit, and it came from the field. With the
branch binary on PATH,
gh pr createon a consumer whose policy had noforeign_hostsrefused a pull-request body overclaude.ai/code/<id>, and thisrepository needed twenty-eight host globs to say that its bibliography is a
bibliography. Both are the enumeration ADR 0001 refuses, relocated rather than
removed. The owner split above is what the rule can actually justify calling
could-not-look, and it needs no list from anybody.
This repository's own policy therefore carries no
foreign_hostsline. Thetwenty-eight globs went in with the wide default and came back out with it;
uphold guard --stage manualpasses here without them. The unresolved names itreports on the way past -- fifteen
404fixtures and the citation hosts -- arethe note this guard has always printed at exit 0.
policy/base/private-names.tomlgains a comment naming the field, the ownersplit and the shape of the list, comment-only, so
sets.lock.jsonis unchangedand
no-hand-copied-base-rulehas nothing new to see.A binary file stays a skip everywhere, in the scan as in the guards. It has no
lines for a name or a pattern to be written on, which is the distinction between
"there is no text here" and "the text here could not be read".
src/shim.rs,src/hook.rsandsrc/text.rswere out of scope for this branchand are untouched.
src/text.rs:186readsredact_matchesand is consistentwith what is documented here; nothing in the other two needs a change for these
three defects.
Checks
cargo build,cargo test(738 tests, 25 suites, all passing -- 15 new),cargo clippy --all-targetswith warnings denied, andprek run --all-files --hook-stage manual-- coverage floor, both guard stages, the content policy --all clean.
https://claude.ai/code/session_01HEudouCNhFHK6UPEWcWqXd