Repository navigation
fix(policy)!: refuse branch names that are not canonical - #990
Conversation
Policy compared a request's branch with `protected_branches` as written, while the engine, the change-feed handlers and the CLI merge trimmed the name before opening it. A padded spelling such as " main" was judged an unprotected branch and then written to main: mutations, loads, merges into a protected target and deletion of a protected branch all went through for an actor allowed only on unprotected branches. One rule now decides every branch name, `ensure_canonical_branch_name` in omnigraph-policy: an empty name, leading or trailing whitespace and control characters are refused, never corrected. It runs in `PolicyEngine::authorize` and policy validation (`protected_branches`), in the engine's `enforce` and its single `normalize_branch_name`, at the server's shared authorization gate (400 before any rule) and in the CLI merge. No layer trims a branch name.
…9e34d # Conflicts: # docs/rfcs/0055-gq-branch-statements.md
…utes The graph-diff, branch-diff and merge-base routes merged from main called the removed trimming helper and trimmed diff sides themselves. They now take names as written: change routes resolve their branch through `requested_branch` (the shared rule, `main` when omitted), a `/diff` side is refused before lookup, since the change routes' error allowlist would hide the engine's refusal behind a 500, and the engine's diff endpoint refuses rather than trims.
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: merge. I found no blocking defect. Reviewed be8140c193db66b96ca1a681f057bb7a1132617c against 32caae4b67ec256d76d31e4aa87a33f1b24423d3.
This PR closes a policy bypass. Policy compared the supplied name with protected_branches, but the engine then trimmed that name. An actor restricted to unprotected branches could therefore write to protected main through " main". The fix refuses names that are empty, have surrounding whitespace, or contain control characters. Authorization and execution now use the same spelling.
The fix addresses the cause at the shared gates. The policy gate validates both sides of a transition before it evaluates a rule. The engine gate does the same, including with no policy or a custom checker. The shared engine normalizer no longer trims. I traced mutation, load/fork, merge, branch create/delete, Blob writes, full-text rebuild, snapshot reads, stored queries, and diff routes. The early HTTP checks cover paths that resolve a name before the general authorization gate. I found no remaining branch-name trimming path in these callers. Unattached-child writes authorize the recorded target and then require an explicit branch to match. Their existing privacy-preserving 404 can precede a malformed-name error when the actor cannot access the commit.
The design reduces long-term liability. It removes separate normalization rules and makes policy, storage lookup, and CLI merge receipts agree on one name. It adds one public validator and a small typed error. The engine and server adapters preserve their existing error contracts. It adds no stored state, migration, branch registry, or publication path. Five more entry points can use the existing gates instead of adding five local repairs. The tradeoff is a deliberate breaking change: clients must send exact names, and padded protected-branch entries now fail policy validation. The user guide and breaking-change note describe that cost. Rollout should follow the existing qualified-build rule: deploy the server/engine fix with the matching client build. The HTTP header alone does not identify this fix.
I read the complete Lance branch/tag and versioning pages and checked the pinned Lance 11 implementation at ab6b5bbe46009ed78746b444df8db59a8bc5d842. Its branch validator already rejects whitespace and controls. OmniGraph resolves native branch incarnations by exact logical-name equality. This refusal therefore does not hide a valid existing branch. Lance 13 retains the relevant validation rules. This is a narrow compatibility check, not a claim that OmniGraph supports all of Lance 13.
I checked the open PR set for overlap. #992's proposed MCP tools reuse the HTTP handlers and should inherit this rule. #951 and #963 do not require another branch identity. Optional integration note: preserve the checks on names as supplied when #968 rebases its diff preparation changes. That PR touches the same classifier and currently contains trimming in its diff-side bookkeeping. I found no reason to add another abstraction here for those future changes.
The existing Rust owners are appropriate. GQT trims branch headers before execution, and its branch statements already refused these names before this fix. It also has no policy/actor setup for this bypass. A GQT case would not prove the defect. The engine test checks protected heads and the live branch list, while the HTTP test checks the stored main head and the 400 response.
Local regression proof used the existing policy chassis test. I first ran it on the clean head: 1 passed. I then applied only the test-file diff to the base. The test file was byte-identical to the head version; production code remained at the base.
| Commit | Result for the same regression |
|---|---|
Before: 32caae4b67ec256d76d31e4aa87a33f1b24423d3 |
Failed, exit 101: mutate_as(" main") reached a protected branch. The stored main head advanced. 0 passed, 1 failed, 0 ignored. |
After: be8140c193db66b96ca1a681f057bb7a1132617c |
Passed, exit 0. 1 passed, 0 failed, 0 ignored. |
The base stopped at the first padded mutation, so it proves that concrete bypass. The head completed the full regression matrix. Neither result was a compile failure or a zero-test run. Both runs used this command from the isolated checkout:
env RUSTUP_TOOLCHAIN=1.97.1 \
CARGO_TARGET_DIR=/tmp/review-990/target \
RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable' \
RUST_MIN_STACK=16777216 RUSTC_WRAPPER= \
cargo test --workspace \
--exclude omnigraph-gqt --exclude omnigraph-gqt-served \
--exclude omnigraph-dst --locked \
--features omnigraph-engine/failpoints,omnigraph-cluster/failpoints \
-j2 --test policy_engine_chassis \
non_canonical_branch_names_cannot_reach_a_protected_branch \
-- --exact --nocaptureThe workspace selection keeps dependency features consistent; the command executes only this one test. All temporary edits are restored. The checkout is clean at the reviewed head, and no test change was pushed. Local git diff --check 32caae4 HEAD, cargo fmt --all --check, bash scripts/check-agents-md.sh, and scripts/check-docs.py in the documentation Python environment also passed.
CI evidence is separate from the local comparison. Test Workspace passed 4,410 tests and skipped 33. Its checkout was merge commit 1e9a4d8b51f5de050d1c5348a2bb19edc3407fa7. I fetched it and verified that its tree equals the reviewed head: 828ea8ef66ea5cc3935b5bd6bf90e2d8442120d0. The log includes the engine and HTTP regressions, policy validation, CLI merge checks, and the warm-read test. The listed GQT, S3, Azure, format, docs, and clippy checks also passed.
Limits: local execution uses macOS and file storage. The linker warned that __eh_frame exceeded its compact-unwind limit; both tests still ran. The HTTP and CLI tests are CI evidence, not local runs. I did not repeat the full workspace suite or live cloud tests locally. The name checks scan only the supplied string and add no storage operation. I did not benchmark the extra scans on valid names. CI's warm-read test checks zero storage requests for rejected names, but I did not measure request latency or throughput.
|
Recommendation: approve. Reviewed What and whyPolicy compared a request's branch with The fix introduces one public validator,
All Tradeoffs and software liabilityThe fix reduces long-term liability:
The cost is a deliberate breaking change: clients that sent padded names must send the exact name, and a ValidationCI: all 33 checks pass, including Test Workspace (4,410 tests), GQT (ordinary + DST + served), Lint, Format, Fix Regression Gate, and every S3/Azure/format fence. Local regression proof on the isolated checkout at
Local command: CARGO_TARGET_DIR=/tmp/review-990-target \
RUSTFLAGS='--cfg tokio_unstable' RUST_MIN_STACK=16777216 \
cargo test --workspace \
--exclude omnigraph-gqt --exclude omnigraph-gqt-served --exclude omnigraph-dst \
--locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints \
--test policy_engine_chassis \
non_canonical_branch_names_cannot_reach_a_protected_branch -- --exact --nocaptureLimits: local execution uses Linux amd64 and file storage. I did not repeat the full workspace suite, live S3/Azure tests, or served tests locally; CI is that evidence. I did not benchmark the additional I checked the open PR set for overlap. #992's proposed MCP tools reuse the HTTP handlers and inherit this rule. #951 and #963 do not touch branch identity. #968 touches the diff classifier and currently contains trimming in its diff-side bookkeeping; a rebase should preserve the checks on names as supplied. Non-blocking observations
|
Summary
Policy compared a request's branch with
protected_branchesexactly as written, while the engine (normalize_branch_name, in two copies), the change-feed handlers, the graph-diff routes and the CLIbranch mergetrimmed the name before opening it. A padded spelling such as" main"was therefore judged an unprotected branch and then resolved to main. With a policy that allowschange,branch_mergeandbranch_deleteonly on unprotected branches, the new tests show this on the unfixed code:mutate_as,load_asandPOST /mutatewith" main","main "," main ","\tmain"or"main\u{3000}"wrote to main;branch_merge_as("feature", " main")merged into main;branch_delete_as(" release")deleted a protectedreleasebranch.Blob writes and the full-text rebuild were not affected (they normalized before the policy check).
The rule
One rule decides every branch name:
omnigraph_policy::ensure_canonical_branch_namerefuses an empty name, leading or trailing whitespace (str::trim's Unicode set) and control characters. These are the classes the GQ compiler already refused in branch statements. Nothing trims a branch name any more:PolicyEngine::authorizegives no decision on such a name;PolicyConfig::validaterefuses such aprotected_branchesentry.normalize_branch_namerefuses instead of trimming;enforcechecks the scope's names before any policy decision, so every_aswriter refuses as a typed bad request before effects.authorizegate answers 400 before any rule, in every runtime mode; load's fork-if-missing lookup, the change routes (requested_branch) and the/diffsides apply the rule before they look anything up.branch mergeapplies the rule instead of trimming.A branch-name newtype through
PolicyRequestandResourceScopewas considered; the checks at the decision point (authorize), the engine door (enforce/normalize_branch_name) and the HTTP gate give the same property without touching every struct-literal site. Lance'scheck_valid_branchrefuses whitespace and control characters in ref names, so no existing branch becomes unreachable.Breaking: clients that sent padded names must send the exact name, and a policy listing a padded
protected_branchesentry fails validation (changelog.d/branch-names-as-written.breaking.md). Case, zero-width characters and homoglyphs are distinct names, not aliases of a protected branch, and are unchanged.Tests
policy_engine_chassis.rs::non_canonical_branch_names_cannot_reach_a_protected_branch(red before the fix:mutate_as(" main") reached a protected branch), plus padded Blob put/clear inblob_put_and_clear_enforce_change_for_the_actor.auth_policy.rs::non_canonical_branch_names_are_refused_before_policyreplaces the change-route padded test and covers mutate, load, loadfrom, merge, merge dry run, branch create, query, snapshot, commits, changes, baseline,/diff,/branches/diff,/branches/merge-baseand a branch statement (red before the fix:POST /mutate with branch " main" reached protected main: 200).stored_queries.rs::invoke_stored_query_bad_param_is_400gains a padded invocation branch.authorizerefuses padded names on both sides and for unknown actors;protected_branches_must_be_canonical.cli_dataandparity_matrixnow expect the refusal on both arms;warm_read_costpins that a refused name issues no storage request.Docs
docs/user/branching/index.md(new "Branch names" section),docs/user/operations/policy.md(documentsprotected_branches), and decision-log amendments to RFC 0055 and the exact-merge-receipts RFC, whose text described trimming.Test plan
cargo test --workspace --exclude omnigraph-gqt --exclude omnigraph-gqt-served --exclude omnigraph-dst --locked --no-fail-fast --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints: green exceptomnigraph-benchcatalog_cli::served_preflight_rejects_incompatible_selection_before_acquisition, which fails only when its binary runs in parallel on a loaded machine and passes alone; it touches no branch name.cargo test -p omnigraph-gqt --locked --lib --test runner_dispatch(26/26 serially).cargo test -p omnigraph-gqt --locked: the only failures were cases exceeding the 10 s wall-time budget at load averages of 20-60 from other builds; each passes alone or times out identically onorigin/mainat the same load. CI is the clean run.cargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macroand the GQT clippy graph.cargo fmt --all --check,scripts/check-docs.py,scripts/check-agents-md.sh,typos.