diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index a83c7b1..523f8a3 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -101,12 +101,12 @@ care. | `policyVersion` | which policy authorized the envelope; part of permit identity | | `repositoryId` | the one repository this job may ever touch | | `parentPullRequestId` | the protected parent feature pull request | -| `protectedParentRef` | the parent integration ref, which no operation may write | +| `protectedParentRef` | the parent integration ref, which no operation may write; canonical `refs/heads/` | | `parentHeadSha` | the exact commit the job is bound to | | `findingSource` | where the finding came from; a provider-neutral label, inert | | `findingId` | the finding being repaired | | `findingHeadSha` | the commit the finding was verified against | -| `repairBranch` | the isolated repair branch | +| `repairBranch` | the isolated repair branch; canonical `refs/heads/` | | `repairWorktreeId` | the isolated repair worktree | | `authorizedPaths` | exact repository-relative paths, not a prefix or glob | | `authorizedCommandClasses` | verification classes, never command strings | @@ -121,12 +121,57 @@ offending names in declaration order. Two structural invariants are enforced as configuration validity rather than as a runtime check that could be forgotten: -- `repairBranch !== protectedParentRef`. A job whose repair branch *is* the - protected parent is not a quarantined repair; it is a direct write to - protected history wearing a repair job's name. +- `repairBranch` and `protectedParentRef` are **different branch refs** under + C1's canonical comparison rule. A job whose repair branch *is* the protected + parent is not a quarantined repair; it is a direct write to protected history + wearing a repair job's name. This compares canonical ref *names*; establishing + that two accepted names resolve to distinct targets in a repository is the + later trusted execution boundary's obligation, described below. - `independentValidatorId !== repairAgentId`. A repair agent that is its own validator defeats the quarantine the whole pipeline exists to enforce. +### Branch refs have exactly one accepted spelling + +"Different branch refs", not "different strings". Git resolves `main`, +`heads/main`, and `refs/heads/main` to one and the same ref, so a boundary that +compares ref strings has three names for one authority target. Configuring +`protectedParentRef: 'refs/heads/main'` beside `repairBranch: 'main'` would +otherwise read as a quarantined repair, and a `repair.push` naming `main` would +pass every check and produce an `ExecutionPermit` whose ref denotes the protected +branch. + +C1 cannot ask git which ref a shorthand resolves to — it runs no git, spawns no +subprocess, opens no file, and observes no repository, and the answer depends on +what exists in a repository at the moment the name is used. So C1 does not +resolve; it **narrows**. `readCanonicalBranchRef` accepts exactly one spelling +and refuses every other spelling of the same branch as malformed: + +- the literal, case-sensitive prefix `refs/heads/`, followed by a non-empty name +- name segments separated by single `/`, each non-empty +- segment characters drawn only from `A-Z`, `a-z`, `0-9`, `-`, `_`, and `.` +- no segment beginning or ending with `.`, no `..` anywhere, and no segment + ending in `.lock` in any ASCII case + +The property that buys is a property of ref *names*, not of repository state: +**two accepted refs are the same canonical ref name if and only if they are equal +strings.** That is what closes caller-controlled textual aliasing and what makes +the distinctness invariant mean something at this layer. It is not a claim that +two unequal canonical names denote two distinct targets in a repository; see +*What canonical ref names do and do not prove* below. The conservative ASCII +character set is part of the guarantee, not a convenience — it removes Unicode +normalisation, under which an NFC and an NFD spelling of one name are unequal +strings a filesystem-backed loose ref can resolve to a single ref, and it removes +`~`, `^`, `:`, `?`, `*`, `[`, `\`, `@{`, and whitespace in one rule. Nothing is +normalised, prefixed, or case-folded on the way in: a value is accepted exactly +as supplied or refused. + +The same reader is applied to **every** security-relevant ref position — the two +job fields, and the `ref`, `sourceRef`, and `targetRef` request operands — so +validation can never compare a canonical configured value against an +uncanonical request operand. A supplied operand that is not canonical is refused +`REF_MALFORMED` before any comparison, rather than compared as though it were a +different branch. + One relationship is enforced at authorization time, because it is about freshness rather than shape: `findingHeadSha` must equal `parentHeadSha`, or every operation is denied `FINDING_SHA_STALE`. A repair derived from a finding @@ -135,6 +180,169 @@ PR 004 remains the owner of `CURRENT` versus `STALE` for evidence; this is the narrower structural check that the job's own two SHAs agree, which C1 can decide without importing that kernel or producing a second answer to PR 004's question. +### What canonical ref names do and do not prove + +Stated precisely, because overclaiming here would be worse than not checking. + +**Proved.** An accepted value is a string in the one canonical `refs/heads/` +shape, and two accepted values that remain unequal under C1's documented +comparison rule are two different canonical ref names. Caller-supplied textual +aliasing is closed within that structural authority: the originally proven bypass +— configuring or requesting `main`, `heads/main`, and `refs/heads/main` against +one another so the protected parent could be presented as a different branch — is +refused as `REF_MALFORMED` before any comparison, and a configured pair that +collides under the comparison rule invalidates the job rather than authorizing it. + +**Not proved, and not claimed.** That two different canonical ref names are two +distinct branch targets in a repository. C1 does not establish repository-resolved +ref identity, does not detect whether an accepted ref is symbolic, does not +resolve a symbolic ref's target, does not determine whether two distinct canonical +names ultimately dereference to the same repository target, and observes no live +repository state. Two independent reasons stand: + +- **Symbolic refs.** A repository may hold a canonical-looking ref — say + `refs/heads/repair` — that is itself a symbolic ref to `refs/heads/main`. + Whether such a ref exists, and what it points at, is repository state at the + moment the name is used. C1 runs no git, spawns no subprocess, opens no file, + and observes no repository, so no string comparison it performs can decide it. +- **Filesystem identity.** Git stores loose refs as files, so on a + case-insensitive filesystem `refs/heads/Main` and `refs/heads/main` can be one + ref while comparing unequal. C1 observes no filesystem, so it refuses the + ambiguous case instead of pretending it away: the job's two configured refs are + additionally compared with ASCII case folded, and a pair that differs only by + case is rejected as malformed configuration. + +The case fold is a conservative refusal, not a resolution. It narrows one +filesystem-dependent collision that is characterisable from the strings alone; it +establishes nothing about symbolic refs, which are not decidable from a string at +all. + +**A future trusted repository/Git execution boundary must close the rest.** C1 +establishes structural canonical ref-name authority; it cannot establish live +repository identity, cannot bind the target a mutation will actually reach, and +cannot enforce anything across a concurrent change. Before acting on any authority +an `ExecutionPermit` records — not only operations that write a ref — that +boundary must satisfy the requirements below, and must **fail closed** — refuse +the operation — wherever a required identity cannot be safely established, +wherever resolution cycles or is otherwise indeterminate, or wherever an effective +identity is or dereferences to a ref the operand's role is not authorized to +denote. + +*The protected-parent rule is role-bound.* Protected-parent identity is forbidden +only where it is unauthorized for the operand's role — which is every role but +one. The effective mutation target of a `repair.commit` or a `repair.push`, and a +`repair.change_request` `sourceRef`, must each be the authorized repair ref, so +for all three an effective identity that is or dereferences to the protected +parent is a refusal. A `repair.change_request` `targetRef` is the single operand +whose *required* effective identity **is** the protected parent ref — the same +operand C1's string layer already singles out as the only one that may name it — +so for that role, and only that role, reaching the protected parent is the +authorized outcome and reaching anything else is the refusal. Stated role-blind +instead, the rule would forbid the one direction the quarantine depends on. No +role widens past this: an operand authorized to denote the protected parent as a +change-request *target* acquires no authority to denote it anywhere else, and the +exemption never reaches an operand that would mutate the parent. + +*Which identity is compared.* The isolation question is about the **effective +ref-name referent** — the terminal ref reached by resolving a symbolic-ref chain — +not about commit-object identity. A freshly created repair branch may legitimately +point at the **same commit object** as the protected parent until its first repair +commit, so distinct commit OIDs are neither necessary nor sufficient: two +different branch refs may share one commit OID, and commit-object equality does not +make two refs the same authority target. The boundary must therefore compare +effective ref-name referents, and must not rest the check on whether two refs +currently resolve to the same commit. A terminal ref-name *spelling* is not by +itself repository ref identity: where a repository applies its own ref-identity +semantics — for instance a case-insensitive ref store under which +`refs/heads/Main` and `refs/heads/main` are one ref — two terminal names that are +not equal strings may still be the same repository ref, so the boundary must +decide whether two effective referents are the same or distinct under that +repository's actual ref-identity semantics rather than by terminal-name string +(in)equality alone, and must fail closed wherever the required distinctness cannot +be safely proven under those semantics. What that comparison must *yield* is fixed +by the operand's role: for an operand whose required identity is the authorized +repair ref, a symbolic or effective ref-name identity that aliases the protected +parent must be detected and rejected; for the one operand whose required identity +is the protected parent ref — the `repair.change_request` `targetRef` — the alias +to detect and reject is the converse one, an effective identity that is not the +protected parent ref. + +*Binding the effective mutation target.* A resolved ref *name* is not the target a +mutation will advance, and the boundary must bind the two before it acts: + +- **`repair.commit`.** A commit advances the branch reached through the authorized + worktree's effective `HEAD` referent, not whatever ref name the request carried. + The boundary must bind that effective `HEAD` referent to the authorized repair + ref and refuse to commit if the worktree is detached, attached to the protected + parent, attached to any other ref, or its safe binding cannot be established. +- **`repair.push`.** A push carries both a source and a destination ref, and the + authorized repair ref governs **both**. The boundary must bind the push's + **effective source ref** and its **effective destination ref** — each by its + effective ref-name referent, not by commit-object identity — to the authorized + repair ref, and must not let a caller-selected source or destination refspec + redirect either half. The source must be **present**: an absent source, the + deletion refspec `:refs/heads/…`, is not a `repair.push` at all but a + `branch.delete`, which is denied, so a destination that still names the repair + ref does not make it authorized. No alternate branch, tag, or commit-ish may + stand in for the authorized repair ref on either half. The receiving/mutation + side must fail closed if either effective half is the protected parent, is not + provably the authorized repair ref, or ceases to be between the check and the + push — the authorized source-to-destination relationship must hold through to + that consuming boundary, not only at an earlier pre-check. An ordinary + `refs/heads/repair:refs/heads/repair` push, whose effective source and + destination are both the authorized repair ref, remains authorized. + +*Operands that set direction without mutating a ref.* The obligation is not +limited to ref-mutating operations. `repair.change_request` mutates no ref, but +its `sourceRef` and `targetRef` fix the effective direction of the stacked +validation request, which the quarantine requires to run **from** the repair +branch **to** the protected parent. The boundary must establish that the effective +source identity is the authorized repair ref and the effective target identity is +the protected parent ref, reject a symbolic or effective alias that changes that +direction, and fail closed if either effective identity cannot be safely +established. Because this operation performs no ref update to guard, the boundary +that consumes these identities is the change-request/provider creation — or +update — request itself, and the established source and target identities must be +**bound through to that provider request**: the provider must create the request +from exactly the authorized effective source and target. Provider-side resolution +of the supplied ref names is not itself forbidden — a create/update API may have +to resolve the source and target names against its own authoritative repository +state — but it must yield exactly those authorized effective identities: it must +not let re-resolution, ambient repository state, or any substitution cause the +request to be created from, or to consume, a **materially different** effective +source or target than the one authorized. Resolution that preserves the exact +authorized source-to-target relationship conforms; resolution that would consume +a materially different effective identity does not. If that authorized +source-to-target relationship cannot be maintained through to the provider +request — because an effective identity has changed, cannot be safely +re-established, or cannot be shown equivalent to the authorized one at that +boundary — the boundary must fail closed and create no change request. + +*Concurrency is not closed by a pre-check.* A resolve-then-check-then-act +sequence is **not** an atomic security guarantee: an effective ref or referent can +change between the comparison and the moment the identity is consumed, so a name +observed as an ordinary repair ref can become symbolic to the protected parent — +or a target can cease to denote it — after the check and before the act. The +invariant must be enforced **at the actual trusted execution boundary that +consumes each identity, not only where a ref is mutated**, by a mechanism whose +semantics prevent an unchecked identity change between the comparison and that +consumption — not by an earlier client-side observation the boundary later trusts. +That consuming boundary differs by operation and the obligation is identical at +each: for `repair.commit` it is the commit mutation boundary, for `repair.push` +the push receiving/mutation boundary, and for `repair.change_request` — which +mutates no ref — the change-request/provider creation boundary at which the source +and target identities are actually consumed. An operation whose authorized +effective-identity relationship cannot be held through to its consuming boundary +must fail closed. + +This document states the required invariant, not an implementation: it names no +git command, lock, or transaction mechanism, and it claims no more atomicity than +the eventual executor's own primitives can actually provide. + +Writing that obligation down adds no runtime git authority to C1 and grants no new +authority anywhere: C1 gains no git invocation, no filesystem access, no +subprocess, and no network, and remains pure TypeScript. + ## Operations are structured, not named A generic action name is not sufficient for Cockpit write authority. There is no @@ -150,6 +358,15 @@ authority cannot be checked against an exact operand has no place in the model. | `repair.push` | ref, non-force | ref is exactly the repair branch and the push is not forced | | `repair.change_request` | source ref, target ref | repair branch → protected parent ref | +Every ref operand — the `repair.commit` and `repair.push` ref, and the +`repair.change_request` source and target refs alike — is read through the same +canonical branch-ref reader the job envelope uses, so "exactly the repair branch" +is a claim about a canonical ref name and not about a caller's chosen spelling. It +is not a claim about the effective ref-name referent that name reaches in a +repository, about which ref a commit or push would actually advance, or about the +effective direction of a change request — all of which only the later trusted +execution boundary can establish. + `repair.change_request` is the **only** operation that may name the protected parent ref, and only as a change-request *target*. Opening a change request against a ref does not mutate it: the parent stays untouched until an operator @@ -188,7 +405,7 @@ Every refusal carries a stable, machine-readable reason: `OPERATION_UNREADABLE`, `JOB_ENVELOPE_INVALID`, `JOB_MISMATCH`, `REPOSITORY_MISMATCH`, `PARENT_PULL_REQUEST_MISMATCH`, `PARENT_HEAD_MISMATCH`, `FINDING_SHA_STALE`, `OPERAND_MISSING`, `PATH_MALFORMED`, `PATH_NOT_AUTHORIZED`, -`WORKTREE_NOT_AUTHORIZED`, `COMMAND_CLASS_NOT_AUTHORIZED`, +`WORKTREE_NOT_AUTHORIZED`, `COMMAND_CLASS_NOT_AUTHORIZED`, `REF_MALFORMED`, `PROTECTED_REF_MUTATION`, `REF_NOT_REPAIR_BRANCH`, `CHANGE_REQUEST_TARGET_INVALID`, `FORCE_PUSH_FORBIDDEN`. @@ -354,6 +571,20 @@ actually holds: Forgery therefore buys nothing, and a permit widens no authority — it records authority already derived from trusted configuration. +A permit is also **not a repository-safety finding**. That a ref operand — a +`repair.commit` or `repair.push` ref, or a `repair.change_request` `sourceRef` or +`targetRef` — passed C1's canonical syntax validation says nothing about the +effective ref-name referent it reaches in the repository the operation would +touch, about which ref a commit or push would actually advance, or about the +effective direction of a change request, so a permit must never be read as proof +that repository-level ref identity, the effective mutation target, or the +change-request direction is safe. The trusted execution boundary that acts on a +permit binds the effective identity, resolves it, and enforces it at the boundary +that actually consumes that identity — the mutation/receiving boundary for a +`repair.commit` or `repair.push`, and the change-request/provider creation boundary +for a `repair.change_request` — and fails closed; see *What canonical ref names do +and do not prove* above. + ### Single use Single use is stated structurally. `singleUse` is typed as the literal `true` and @@ -474,7 +705,15 @@ The mandatory AgentBridge pattern is preserved: C1 implements none of that workflow. It encodes only the minimal authority invariants that stop a later layer from bypassing the quarantine by accident: -- The protected parent ref is never a write target of any operation. +- The protected parent ref is never a write target of any operation, under any + *spelling*: refs are canonical everywhere, so a caller cannot present a textual + alias of the parent as a different branch. Repository-dependent aliasing — a + canonical repair ref that is symbolic to the parent, a worktree `HEAD` or push + destination whose effective target is the parent, a change-request source or + target whose effective direction is reversed, or an effective ref that changes + after a pre-check — is not visible to a pure string boundary, and is the later + trusted execution boundary's to bind, resolve, or reject before it acts on any + authority a permit records. - Filesystem-shaped operations are bound to the repair worktree, so an edit cannot land in the parent's checkout. - The stacked change request must run from the repair branch to the protected @@ -522,6 +761,12 @@ patterns PR 004, PR 005, and PR 006 established: real object and a cut branch name can name a different ref. **C1 truncates nothing at all, because C1 has no prose field.** An oversized list is likewise rejected, not shortened. +- **Refs are narrowed, never repaired.** A non-canonical branch ref is refused, + not rewritten into the canonical spelling. Repairing a spelling would be + choosing an authority target on the caller's behalf, which is exactly the + decision the boundary exists to refuse. The reader is a pure function of a + primitive string captured by a single own-property read, so it introduces no + second observation of untrusted state and no validation TOCTOU. - **List entries are own elements.** Every entry of an authorization list is read as an **own** indexed property, so only an element the supplied object reports as its own can become an authorized path or command class. For any diff --git a/src/domain/index.ts b/src/domain/index.ts index 394d912..aaf8a35 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -142,6 +142,7 @@ export { findInvalidRepairJobFields, isVerificationCommandClass, JOB_BOUNDS, + readCanonicalBranchRef, readRepairJobAuthorization, readRepositoryRelativePath, REPAIR_JOB_FIELD_ORDER, diff --git a/src/domain/job-authorization.ts b/src/domain/job-authorization.ts index f0f9a60..dcb7e57 100644 --- a/src/domain/job-authorization.ts +++ b/src/domain/job-authorization.ts @@ -136,6 +136,8 @@ export const JOB_AUTHORIZATION_REASON = objectFreeze({ WORKTREE_NOT_AUTHORIZED: 'WORKTREE_NOT_AUTHORIZED', /** The verification class is unmodeled or not in the job's authorized set. */ COMMAND_CLASS_NOT_AUTHORIZED: 'COMMAND_CLASS_NOT_AUTHORIZED', + /** A ref operand was supplied but is not a canonical `refs/heads/` ref. */ + REF_MALFORMED: 'REF_MALFORMED', /** The operation names the protected parent ref as a write target. */ PROTECTED_REF_MUTATION: 'PROTECTED_REF_MUTATION', /** The ref operand is not the job's isolated repair branch. */ @@ -264,6 +266,12 @@ function checkOperands( if (request.worktreeId !== job.repairWorktreeId) { return JOB_AUTHORIZATION_REASON.WORKTREE_NOT_AUTHORIZED; } + // A non-canonical spelling is refused for being unusable, before any + // comparison: `main` and `heads/main` may both denote `refs/heads/main`, + // so comparing either as a distinct string is exactly the bypass. + if (request.refMalformed) { + return JOB_AUTHORIZATION_REASON.REF_MALFORMED; + } if (request.ref === null) { return JOB_AUTHORIZATION_REASON.OPERAND_MISSING; } @@ -281,6 +289,9 @@ function checkOperands( if (request.force) { return JOB_AUTHORIZATION_REASON.FORCE_PUSH_FORBIDDEN; } + if (request.refMalformed) { + return JOB_AUTHORIZATION_REASON.REF_MALFORMED; + } if (request.ref === null) { return JOB_AUTHORIZATION_REASON.OPERAND_MISSING; } @@ -293,6 +304,13 @@ function checkOperands( return null; } case JOB_OPERATION.REPAIR_CHANGE_REQUEST: { + // Both ends are narrowed to the canonical spelling before either is + // compared, so source/target separation is separation of branches rather + // than of strings: an alias of the protected parent cannot be presented + // as the source, and an alias of the repair branch cannot be the target. + if (request.sourceRefMalformed || request.targetRefMalformed) { + return JOB_AUTHORIZATION_REASON.REF_MALFORMED; + } if (request.sourceRef === null || request.targetRef === null) { return JOB_AUTHORIZATION_REASON.OPERAND_MISSING; } diff --git a/src/domain/job-operation.ts b/src/domain/job-operation.ts index ccb6144..abe5b5c 100644 --- a/src/domain/job-operation.ts +++ b/src/domain/job-operation.ts @@ -29,6 +29,7 @@ import { append, containsValue, + readCanonicalBranchRef, readExactIdentifier, readOwnProperty, readRepositoryRelativePath, @@ -246,11 +247,17 @@ export interface JobOperationRequest { readonly path?: string; /** Verification class operand, for `verification.run`. */ readonly commandClass?: string; - /** Ref operand, for `repair.commit` and `repair.push`. */ + /** + * Ref operand, for `repair.commit` and `repair.push`. + * + * Read through the same canonical branch-ref reader the job envelope uses, so + * an alternate spelling of a configured ref cannot be compared against it as + * if it were a different branch. + */ readonly ref?: string; - /** Change-request source ref operand. */ + /** Change-request source ref operand. Canonical branch ref. */ readonly sourceRef?: string; - /** Change-request target ref operand. */ + /** Change-request target ref operand. Canonical branch ref. */ readonly targetRef?: string; /** Force flag for a push. Anything that is not exactly absent or `false` is force. */ readonly force?: boolean; @@ -279,8 +286,14 @@ export interface NormalizedJobOperation { readonly pathMalformed: boolean; readonly commandClass: string | null; readonly ref: string | null; + /** True when a `ref` was supplied but is not a canonical branch ref. */ + readonly refMalformed: boolean; readonly sourceRef: string | null; + /** True when a `sourceRef` was supplied but is not a canonical branch ref. */ + readonly sourceRefMalformed: boolean; readonly targetRef: string | null; + /** True when a `targetRef` was supplied but is not a canonical branch ref. */ + readonly targetRefMalformed: boolean; /** Fails closed: only an absent or literally `false` value is not force. */ readonly force: boolean; } @@ -298,8 +311,11 @@ const UNREADABLE_OPERATION: NormalizedJobOperation = objectFreeze({ pathMalformed: false, commandClass: null, ref: null, + refMalformed: false, sourceRef: null, + sourceRefMalformed: false, targetRef: null, + targetRefMalformed: false, force: true, }); @@ -331,6 +347,17 @@ export function readJobOperation(request: JobOperationRequest): NormalizedJobOpe const rawPath = readOwnProperty(record, 'path'); const path = readRepositoryRelativePath(rawPath); + // Each ref operand is read once, own-only, and narrowed to the one canonical + // branch-ref spelling. A supplied value that is not canonical becomes `null` + // and is flagged, so it is refused for being unusable rather than compared — + // as a distinct string — against a canonical value it may in fact alias. + const rawRef = readOwnProperty(record, 'ref'); + const ref = readCanonicalBranchRef(rawRef); + const rawSourceRef = readOwnProperty(record, 'sourceRef'); + const sourceRef = readCanonicalBranchRef(rawSourceRef); + const rawTargetRef = readOwnProperty(record, 'targetRef'); + const targetRef = readCanonicalBranchRef(rawTargetRef); + return objectFreeze({ readable: true, requestId: readExactIdentifier(readOwnProperty(record, 'requestId')), @@ -343,9 +370,12 @@ export function readJobOperation(request: JobOperationRequest): NormalizedJobOpe path, pathMalformed: path === null && rawPath !== undefined, commandClass: readExactIdentifier(readOwnProperty(record, 'commandClass')), - ref: readExactIdentifier(readOwnProperty(record, 'ref')), - sourceRef: readExactIdentifier(readOwnProperty(record, 'sourceRef')), - targetRef: readExactIdentifier(readOwnProperty(record, 'targetRef')), + ref, + refMalformed: ref === null && rawRef !== undefined, + sourceRef, + sourceRefMalformed: sourceRef === null && rawSourceRef !== undefined, + targetRef, + targetRefMalformed: targetRef === null && rawTargetRef !== undefined, force: readForceFlag(readOwnProperty(record, 'force')), }); } diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 4a39216..0705e34 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -266,6 +266,14 @@ const CODE_DOT = 0x2e; const CODE_TILDE = 0x7e; const CODE_DELETE = 0x7f; const CODE_SPACE = 0x20; +const CODE_HYPHEN = 0x2d; +const CODE_UNDERSCORE = 0x5f; +const CODE_DIGIT_0 = 0x30; +const CODE_DIGIT_9 = 0x39; +const CODE_UPPER_A = 0x41; +const CODE_UPPER_Z = 0x5a; +const CODE_LOWER_A = 0x61; +const CODE_LOWER_Z = 0x7a; /** Is `value[start, end)` the segment `.git`, in any ASCII case? */ function isDotGitSegment(value: string, start: number, end: number): boolean { @@ -386,6 +394,272 @@ export function readRepositoryRelativePath(value: unknown): string | null { return value; } +/** + * The one accepted spelling of a branch ref. + * + * Fully qualified, lower case, and matched literally. Git resolves the shorthand + * `main`, the partially qualified `heads/main`, and the fully qualified + * `refs/heads/main` to one and the same ref, so a boundary that compares ref + * *strings* has three spellings for one authority target unless it fixes the + * spelling first. C1 fixes it here. + */ +const BRANCH_REF_PREFIX = 'refs/heads/'; + +/** Is this the code of a character C1 accepts inside a branch name? */ +function isBranchNameCharacter(code: number): boolean { + return ( + (code >= CODE_LOWER_A && code <= CODE_LOWER_Z) || + (code >= CODE_UPPER_A && code <= CODE_UPPER_Z) || + (code >= CODE_DIGIT_0 && code <= CODE_DIGIT_9) || + code === CODE_HYPHEN || + code === CODE_UNDERSCORE || + code === CODE_DOT + ); +} + +/** Does `value` begin with the literal, case-sensitive {@link BRANCH_REF_PREFIX}? */ +function hasBranchRefPrefix(value: string): boolean { + const prefixLength = BRANCH_REF_PREFIX.length; + if (value.length <= prefixLength) { + return false; + } + for (let index = 0; index < prefixLength; index += 1) { + // An unreadable code reads as -1 and cannot equal a prefix character. + if (charCodeAt(value, index) !== charCodeAt(BRANCH_REF_PREFIX, index)) { + return false; + } + } + return true; +} + +/** Is `value[start, end)` a segment ending in `.lock`, in any ASCII case? */ +function endsWithDotLockSuffix(value: string, start: number, end: number): boolean { + if (end - start < 5) { + return false; + } + if (charCodeAt(value, end - 5) !== CODE_DOT) { + return false; + } + // 0x20 folds ASCII upper case to lower case; only these four positions matter. + const l = charCodeAt(value, end - 4) | 0x20; + const o = charCodeAt(value, end - 3) | 0x20; + const c = charCodeAt(value, end - 2) | 0x20; + const k = charCodeAt(value, end - 1) | 0x20; + return l === 0x6c && o === 0x6f && c === 0x63 && k === 0x6b; +} + +/** + * Read a branch ref in the one canonical spelling C1 accepts. + * + * **Why one spelling, rather than a resolver.** Git's own shorthand rules make + * `main`, `heads/main`, and `refs/heads/main` three names for one branch, and + * the ambiguity is not decidable from the string alone: whether `main` resolves + * to a branch, a tag, or a remote-tracking ref depends on what exists in a + * repository at the moment the name is used. C1 is pure TypeScript by + * construction — it runs no git, spawns no subprocess, opens no file, and + * observes no repository — so it cannot ask which ref a shorthand denotes, and a + * boundary that guesses would be guessing about authority. + * + * So C1 does not resolve; it **narrows**. Exactly one spelling is accepted, and + * every other spelling of the same branch is refused as malformed rather than + * silently treated as a different ref. The property that buys is precise, and it + * is a property of ref *names* rather than of repository state: + * + * > Two accepted refs are the same canonical ref name if and only if they are + * > equal strings — up to the ASCII-case caveat below. + * + * That is what makes `repairBranch !== protectedParentRef` mean "two different + * canonical ref names" instead of "two different strings", which is what closes + * caller-controlled textual aliasing. Before this, a job configured with + * `protectedParentRef: 'refs/heads/main'` and `repairBranch: 'main'` was accepted + * as a quarantined repair, and a `repair.push` naming `main` passed every check + * and produced an `ExecutionPermit` whose ref denotes the protected branch. + * + * Accepted, and nothing else: + * + * - a value that survives {@link readExactIdentifier}, so the identifier bound + * applies and nothing is trimmed or truncated + * - the literal, case-sensitive prefix `refs/heads/`, followed by a non-empty + * name; `Refs/Heads/x`, `heads/x`, `x`, `refs/tags/x`, `refs/remotes/…`, and a + * bare `refs/heads/` are all refused + * - name segments separated by single `/`, each non-empty, so `//`, a leading + * `/`, and a trailing `/` are refused + * - segment characters drawn only from `A-Z`, `a-z`, `0-9`, `-`, `_`, and `.` + * - no segment beginning or ending with `.`, no `..` anywhere, and no segment + * ending in `.lock` in any ASCII case — the ref-name forms git itself refuses + * + * The conservative character set is deliberate and is part of the guarantee. + * Restricting names to ASCII removes Unicode normalisation entirely: without it + * an NFC and an NFD spelling of one branch name are unequal strings that a + * filesystem-backed loose ref can resolve to a single ref, which is the same + * aliasing failure in a different alphabet. It also removes `~`, `^`, `:`, `?`, + * `*`, `[`, `\`, `@{`, and whitespace — every character git rejects in a ref + * name, plus the revision-syntax operators that make `x^{}` and `x@{1}` name + * something other than `x`. A branch name outside this set is refused, never + * rewritten. + * + * **What this does not prove**, and must not be claimed to: that two unequal + * accepted refs are two distinct branch targets in a repository. What is proved + * is a structural canonical ref-name representation and this module's own + * documented string-comparison rules; repository-resolved ref identity is not + * established here. Two residues stand: + * + * - **Symbolic refs.** A repository may hold a canonical-looking ref — say + * `refs/heads/repair` — that is itself a symbolic ref to `refs/heads/main`. + * Whether such a ref exists, and what it dereferences to, is repository state + * at the moment the name is used. C1 does not detect that a ref is symbolic, + * does not resolve a symbolic ref's target, and cannot tell whether two + * distinct canonical names ultimately dereference to one repository target. + * - **Filesystem identity.** Git stores loose refs as files, so on a + * case-insensitive filesystem `refs/heads/Main` and `refs/heads/main` can be + * one ref while comparing unequal here. That residue is handled where it + * matters — {@link mayDenoteSameBranchRef} compares the job's two configured + * refs case-insensitively, so such a pair is refused as configuration — rather + * than pretended away here. C1 observes no filesystem and cannot do better + * than refuse the ambiguous case. + * + * The symbolic-ref residue is not narrowable from a string at all, and neither is + * the effective target a mutation would reach. A later trusted repository/Git + * execution boundary must, before acting on any authority an `ExecutionPermit` + * records, resolve the requested ref's effective ref-name referent against the + * actual repository — the terminal ref reached through a symbolic-ref chain, not + * commit-object identity, since a fresh repair branch may legitimately share the + * protected parent's commit OID — and bind each effective identity the operation + * acts on to the authorized repair ref. A terminal ref-name spelling is not by + * itself repository ref identity: where a repository applies its own ref-identity + * semantics — for instance a case-insensitive ref store treating `refs/heads/Main` + * and `refs/heads/main` as one ref — terminal names that are not equal strings may + * still be the same repository ref, so the boundary must judge sameness or + * distinctness under that repository's actual ref-identity semantics rather than by + * terminal-name string (in)equality alone, and fail closed where the required + * distinctness cannot be safely proven. A commit advances one mutation target: the + * worktree's effective `HEAD` referent, the ref it will actually advance. A push + * binds two effective identities in distinct roles — its destination ref, the + * receiving/mutation target the push advances, and its source ref, the input the + * push consumes to select what is sent — each of which must be the authorized + * repair ref, so an absent (deletion) or redirected source is refused. It must + * **fail closed** — refusing the + * operation — if any such effective identity is or dereferences to the + * protected parent, if it changes between comparison and update, or if it cannot + * be safely established; a resolve-then-act pre-check is not itself atomic, so the + * invariant is enforced at the boundary that actually consumes each identity — the + * mutation/receiving boundary for a commit or push, and the + * change-request/provider creation boundary for a change request. That refusal is + * bound to the operand's role rather than being a blanket ban on the protected + * parent's identity: a `repair.change_request` `targetRef` is *required* to reach + * the protected parent ref, and fails closed when it reaches anything else. Because + * that operation mutates no ref, the identity it consumes is bound at its provider + * create/update request: the effective source must remain the authorized repair ref + * and the effective target the protected parent ref through to that request. The + * provider may resolve those ref names at its own boundary, but must not let + * re-resolution or ambient repository state substitute a materially different + * effective identity for either end, and the boundary must fail closed if the + * authorized relationship cannot be maintained or shown equivalent there. + * Nothing here acquires git invocation, filesystem access, a subprocess, or + * network to decide it. See `docs/architecture/C1-repair-job-authority.md`, + * "What canonical ref names do and do not prove". + * + * The value is returned exactly as supplied, or not at all. No normalisation, + * no prefixing, no case folding: a boundary that repaired the spelling would be + * choosing an authority target on the caller's behalf. + */ +export function readCanonicalBranchRef(value: unknown): string | null { + const identifier = readExactIdentifier(value); + if (identifier === null) { + return null; + } + if (!hasBranchRefPrefix(identifier)) { + return null; + } + + const length = identifier.length; + let segmentStart = BRANCH_REF_PREFIX.length; + for (let index = segmentStart; index <= length; index += 1) { + const atEnd = index === length; + const code = atEnd ? CODE_SLASH : charCodeAt(identifier, index); + + if (!atEnd && code !== CODE_SLASH) { + // An unreadable code reads as -1, which is not a name character. + if (!isBranchNameCharacter(code)) { + return null; + } + // `..` is a revision-range operator and git refuses it in a ref name. + if (code === CODE_DOT && charCodeAt(identifier, index - 1) === CODE_DOT) { + return null; + } + continue; + } + + const segmentLength = index - segmentStart; + if (segmentLength === 0) { + return null; + } + if (charCodeAt(identifier, segmentStart) === CODE_DOT) { + return null; + } + if (charCodeAt(identifier, index - 1) === CODE_DOT) { + return null; + } + if (endsWithDotLockSuffix(identifier, segmentStart, index)) { + return null; + } + segmentStart = index + 1; + } + + return identifier; +} + +/** + * Could these two accepted canonical ref names collapse into one ref? + * + * Both arguments are already-validated canonical refs, so this is exact string + * equality widened by one conservative allowance: ASCII case. Git stores loose + * refs as files, and on a case-insensitive filesystem `refs/heads/Main` and + * `refs/heads/main` can be the same ref while comparing unequal. C1 observes no + * filesystem and cannot tell which kind it will run on, so it treats such a pair + * as possibly-identical and the job that configures one is refused. + * + * Conservative in the safe direction: it answers `true` — refuse — whenever it + * cannot establish that the two *names* are distinct, including for a character + * it could not read at all. Only ASCII case is folded, because the canonical + * reader admits no other alphabet. + * + * A `false` result means only that the two names are distinct under this rule. + * It is **not** a finding that they denote distinct targets in a repository: this + * compares strings and resolves nothing, so a canonical name that is a symbolic + * ref to the other still answers `false` here, and — since two different branch + * refs may legitimately share one commit object — commit-object equality is not + * the question either. Repository-resolved identity — whether the effective + * referents reached by resolving symbolic-ref chains are the same or distinct + * under the repository's own ref-identity semantics, which a terminal ref-name + * spelling alone does not settle — is the later trusted repository/Git execution + * boundary's to establish; see {@link readCanonicalBranchRef}. + */ +function mayDenoteSameBranchRef(left: string, right: string): boolean { + if (left === right) { + return true; + } + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + const leftCode = charCodeAt(left, index); + const rightCode = charCodeAt(right, index); + if (leftCode === -1 || rightCode === -1) { + // Unreadable: cannot establish distinctness, so refuse the pair. + return true; + } + const foldedLeft = + leftCode >= CODE_UPPER_A && leftCode <= CODE_UPPER_Z ? leftCode | 0x20 : leftCode; + const foldedRight = + rightCode >= CODE_UPPER_A && rightCode <= CODE_UPPER_Z ? rightCode | 0x20 : rightCode; + if (foldedLeft !== foldedRight) { + return false; + } + } + return true; +} + /** * Read a bounded list of untrusted values, all-or-nothing. * @@ -475,7 +749,8 @@ export interface RepairJobAuthorization { /** The protected parent feature pull request this repair is stacked under. */ readonly parentPullRequestId: string; /** - * The protected parent integration ref. + * The protected parent integration ref, in the canonical `refs/heads/` + * spelling {@link readCanonicalBranchRef} defines. * * **No job operation may ever write to it.** It appears in exactly one * authorizable position: as the *target* of the stacked validation change @@ -497,12 +772,26 @@ export interface RepairJobAuthorization { */ readonly findingHeadSha: string; /** - * The isolated repair branch. + * The isolated repair branch, in the canonical `refs/heads/` spelling + * {@link readCanonicalBranchRef} defines. + * + * Must be a different canonical ref name from {@link protectedParentRef}. A + * job whose repair branch is the protected parent ref is not a quarantined + * repair; it is a direct write to protected history wearing a repair job's + * name, and it is rejected as malformed configuration rather than evaluated. * - * Must differ from {@link protectedParentRef}. A job whose repair branch is - * the protected parent ref is not a quarantined repair; it is a direct write - * to protected history wearing a repair job's name, and it is rejected as - * malformed configuration rather than evaluated. + * "Different branch ref", not "different string": both refs are read through + * the canonical reader, so an alternate spelling of the protected parent — + * `main`, `heads/main` — cannot pass as an isolated repair branch, and a pair + * that differs only by ASCII case is refused too because a case-insensitive + * filesystem can store the two as one loose ref. + * + * That closes caller-controlled textual aliasing only. It does not establish + * that the two names resolve to distinct targets in a repository — a canonical + * repair ref that is symbolic to the parent is invisible to a pure string + * boundary — which the later trusted repository/Git execution boundary must + * resolve or reject before it acts on any authority a permit records. See + * {@link readCanonicalBranchRef}. */ readonly repairBranch: string; /** The isolated repair worktree. Filesystem-shaped operations are bound to it. */ @@ -616,12 +905,14 @@ export function readRepairJobAuthorization(job: RepairJobAuthorization): RepairJ const policyVersion = readExactIdentifier(readOwnProperty(record, 'policyVersion')); const repositoryId = readExactIdentifier(readOwnProperty(record, 'repositoryId')); const parentPullRequestId = readExactIdentifier(readOwnProperty(record, 'parentPullRequestId')); - const protectedParentRef = readExactIdentifier(readOwnProperty(record, 'protectedParentRef')); + const protectedParentRef = readCanonicalBranchRef( + readOwnProperty(record, 'protectedParentRef'), + ); const parentHeadSha = readExactIdentifier(readOwnProperty(record, 'parentHeadSha')); const findingSource = readExactIdentifier(readOwnProperty(record, 'findingSource')); const findingId = readExactIdentifier(readOwnProperty(record, 'findingId')); const findingHeadSha = readExactIdentifier(readOwnProperty(record, 'findingHeadSha')); - const repairBranch = readExactIdentifier(readOwnProperty(record, 'repairBranch')); + const repairBranch = readCanonicalBranchRef(readOwnProperty(record, 'repairBranch')); const repairWorktreeId = readExactIdentifier(readOwnProperty(record, 'repairWorktreeId')); const authorizedPaths = readList( readOwnProperty(record, 'authorizedPaths'), @@ -666,9 +957,17 @@ export function readRepairJobAuthorization(job: RepairJobAuthorization): RepairJ if (findingHeadSha === null) { append(invalidFields, 'findingHeadSha'); } - // The repair branch must be distinguishable from the protected parent ref, or - // the isolation the whole quarantine depends on does not exist. - if (repairBranch === null || repairBranch === protectedParentRef) { + // The repair branch must be a *different branch ref* from the protected parent + // ref, or the isolation the whole quarantine depends on does not exist. Both + // refs are canonical here, so unequal strings are different canonical ref names + // — except for the ASCII-case pair a case-insensitive filesystem can collapse + // into one loose ref, which `mayDenoteSameBranchRef` refuses as well. This is a + // name-level check: repository-resolved identity, including a canonical ref + // that is symbolic to the parent, is the later trusted execution boundary's. + if ( + repairBranch === null || + (protectedParentRef !== null && mayDenoteSameBranchRef(repairBranch, protectedParentRef)) + ) { append(invalidFields, 'repairBranch'); } if (repairWorktreeId === null) { diff --git a/tests/domain/execution-permit.test.ts b/tests/domain/execution-permit.test.ts index 865e77b..fc63a6f 100644 --- a/tests/domain/execution-permit.test.ts +++ b/tests/domain/execution-permit.test.ts @@ -176,9 +176,9 @@ describe('cross-operation replay', () => { it('does not verify for a different ref', () => { const permit = issue(buildJob(), buildPush()); - const job = buildJob({ repairBranch: 'repair/job-0001-b' }); + const job = buildJob({ repairBranch: 'refs/heads/repair/job-0001-b' }); - expect(permitAuthorizes(permit, job, buildPush({ ref: 'repair/job-0001-b' }))).toBe(false); + expect(permitAuthorizes(permit, job, buildPush({ ref: 'refs/heads/repair/job-0001-b' }))).toBe(false); }); it('does not verify for a different verification class', () => { diff --git a/tests/domain/job-authorization-invariants.test.ts b/tests/domain/job-authorization-invariants.test.ts index 60e2ce1..968bdbc 100644 --- a/tests/domain/job-authorization-invariants.test.ts +++ b/tests/domain/job-authorization-invariants.test.ts @@ -13,6 +13,7 @@ import { JOB_BOUNDS, JOB_OPERATION, operatorMergeAuthorizes, + readCanonicalBranchRef, readJobOperation, REPAIR_AUTHORIZABLE_OPERATIONS, resolveJobOperation, @@ -39,6 +40,7 @@ import { NON_OBJECTS, PARENT_PR_A, PARENT_REF, + PARENT_REF_ALIASES, PRIVILEGED_LABELS, REPAIR_BRANCH, REPAIR_WORKTREE, @@ -86,8 +88,8 @@ describe('merge is operator-only, permanently', () => { buildJob({ repairAgentId: 'root', independentValidatorId: 'system' }), buildJob({ findingSource: 'agentbridge-internal' }), buildJob({ authorizedPaths: [], authorizedCommandClasses: [] }), - buildJob({ protectedParentRef: 'main' }), - buildJob({ repairBranch: 'main', protectedParentRef: 'main' }), + buildJob({ protectedParentRef: 'refs/heads/main' }), + buildJob({ repairBranch: 'refs/heads/main', protectedParentRef: 'refs/heads/main' }), ]; const operands: readonly Partial[] = [ {}, @@ -1235,3 +1237,347 @@ describe('bounds stay aligned with the neighbouring boundaries', () => { expect(readJobOperation({ requestId: overLimit }).requestId).not.toBe(atLimit); }); }); + +/* ------------------------------------------------------------------------- + * C1-A04: Git-equivalent branch-ref spellings + * + * Git resolves `main`, `heads/main`, and `refs/heads/main` to one ref. A + * boundary that compares ref *strings* therefore has three names for one + * authority target unless it fixes the spelling first, and the quarantine + * invariant — "the repair branch and the protected parent are distinct actual + * branches" — degrades into "the two strings are unequal". + * ------------------------------------------------------------------------- */ + +/** Spellings of one and the same branch. One simple, one nested. */ +const ALIAS_FAMILIES: readonly { + readonly branch: string; + readonly spellings: readonly string[]; +}[] = [ + { + branch: 'refs/heads/main', + spellings: ['main', 'heads/main', 'refs/heads/main'], + }, + { + branch: 'refs/heads/feature/pr-042-parent', + spellings: [ + 'feature/pr-042-parent', + 'heads/feature/pr-042-parent', + 'refs/heads/feature/pr-042-parent', + ], + }, +]; + +/** The operations that mutate a ref, and so must never accept an alias. */ +const REF_WRITE_OPERATIONS: readonly string[] = [ + JOB_OPERATION.REPAIR_COMMIT, + JOB_OPERATION.REPAIR_PUSH, +]; + +/** Alias spellings of the fixture repair branch. */ +const REPAIR_BRANCH_ALIASES: readonly string[] = ['repair/job-0001', 'heads/repair/job-0001']; + +function refRequest(operation: string, ref: string): JobOperationRequest { + return buildRequest({ operation, worktreeId: REPAIR_WORKTREE, ref, force: false }); +} + +describe('the canonical branch-ref reader', () => { + it('accepts only the fully qualified refs/heads/ spelling', () => { + expect(readCanonicalBranchRef('refs/heads/main')).toBe('refs/heads/main'); + expect(readCanonicalBranchRef(PARENT_REF)).toBe(PARENT_REF); + expect(readCanonicalBranchRef('refs/heads/repair/c1-a04_ref.alias-1')).toBe( + 'refs/heads/repair/c1-a04_ref.alias-1', + ); + }); + + it('refuses every other spelling of the same branch', () => { + for (const family of ALIAS_FAMILIES) { + for (const spelling of family.spellings) { + if (spelling === family.branch) { + expect(readCanonicalBranchRef(spelling), spelling).toBe(spelling); + continue; + } + expect(readCanonicalBranchRef(spelling), spelling).toBeNull(); + } + } + for (const alias of [...PARENT_REF_ALIASES, ...REPAIR_BRANCH_ALIASES]) { + expect(readCanonicalBranchRef(alias), alias).toBeNull(); + } + }); + + it('refuses partially qualified, differently rooted, and mis-cased prefixes', () => { + for (const value of [ + 'refs/heads/', + 'refs/head/main', + 'refs/tags/main', + 'refs/remotes/origin/main', + 'Refs/Heads/main', + 'REFS/HEADS/main', + '/refs/heads/main', + 'refs/heads//main', + 'refs/heads/main/', + ' refs/heads/main', + 'refs/heads/main ', + 'refs/heads/main\n', + ]) { + expect(readCanonicalBranchRef(value), JSON.stringify(value)).toBeNull(); + } + }); + + it('refuses the ref-name forms git itself refuses, and the revision operators', () => { + for (const value of [ + 'refs/heads/.hidden', + 'refs/heads/main.', + 'refs/heads/feature/.x', + 'refs/heads/a..b', + 'refs/heads/../../etc/passwd', + 'refs/heads/main.lock', + 'refs/heads/main.LOCK', + 'refs/heads/feature/x.lock', + 'refs/heads/main@{1}', + 'refs/heads/main^{}', + 'refs/heads/main~1', + 'refs/heads/ma in', + 'refs/heads/ma:in', + 'refs/heads/ma?in', + 'refs/heads/ma*in', + 'refs/heads/ma[in', + 'refs/heads/ma\\in', + // Non-ASCII is refused outright: the precomposed and decomposed spellings + // below are unequal strings that a loose ref can resolve to a single ref. + 'refs/heads/café', + 'refs/heads/café', + ]) { + expect(readCanonicalBranchRef(value), JSON.stringify(value)).toBeNull(); + } + }); + + it('fails closed on hostile values without throwing', () => { + for (const value of NON_OBJECTS) { + expect(() => readCanonicalBranchRef(value)).not.toThrow(); + expect(readCanonicalBranchRef(value)).toBeNull(); + } + expect(readCanonicalBranchRef({ toString: () => 'refs/heads/main' })).toBeNull(); + expect(readCanonicalBranchRef(['refs/heads/main'])).toBeNull(); + expect(readCanonicalBranchRef(revokedProxy())).toBeNull(); + // The identifier bound applies, and rejects rather than truncating. + expect( + readCanonicalBranchRef('refs/heads/' + 'a'.repeat(JOB_BOUNDS.MAX_IDENTIFIER_LENGTH)), + ).toBeNull(); + }); +}); + +describe('an alias spelling can never separate a repair branch from its parent', () => { + it('rejects the verified A04 configuration outright', () => { + // The reported exploit exactly: two spellings, one branch. + const job = buildJob({ protectedParentRef: 'refs/heads/main', repairBranch: 'main' }); + + expect(findInvalidRepairJobFields(job)).toContain('repairBranch'); + expect(readRepairJobAuthorization(job).snapshot).toBeNull(); + + for (const operation of REF_WRITE_OPERATIONS) { + const decision = authorizeJobOperation(job, refRequest(operation, 'main')); + + expect(decision.decision, operation).toBe(JOB_AUTHORIZATION.DENY); + expect(decision.reason, operation).toBe(JOB_AUTHORIZATION_REASON.JOB_ENVELOPE_INVALID); + expect(decision.mayExecuteOnce, operation).toBe(false); + expect(decision.permit, operation).toBeNull(); + } + }); + + it('rejects every alias pairing of one branch, for every ref-writing operation', () => { + for (const family of ALIAS_FAMILIES) { + for (const protectedParentRef of family.spellings) { + for (const repairBranch of family.spellings) { + const job = buildJob({ protectedParentRef, repairBranch }); + const label = `${protectedParentRef} | ${repairBranch}`; + + // The two refs denote one branch, so this is never a quarantined job. + // Whichever field is the offending one — a non-canonical spelling is + // reported against itself, a canonical collision against + // `repairBranch` — the envelope is refused and nothing is snapshotted. + expect(readRepairJobAuthorization(job).snapshot, label).toBeNull(); + if (protectedParentRef === family.branch) { + expect(findInvalidRepairJobFields(job), label).toContain('repairBranch'); + } else { + expect(findInvalidRepairJobFields(job), label).toContain('protectedParentRef'); + } + + for (const operation of REF_WRITE_OPERATIONS) { + for (const ref of family.spellings) { + const decision = authorizeJobOperation(job, refRequest(operation, ref)); + const attempt = `${label} -> ${operation} ${ref}`; + + expect(decision.decision, attempt).not.toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.mayExecuteOnce, attempt).toBe(false); + expect(decision.permit, attempt).toBeNull(); + } + } + } + } + } + }); + + it('rejects a repair branch that differs from the parent only by ASCII case', () => { + // Git stores loose refs as files, so on a case-insensitive filesystem these + // pairs can be one ref. C1 observes no filesystem, so it refuses the pair. + const pairs: readonly (readonly [string, string])[] = [ + ['refs/heads/main', 'refs/heads/Main'], + ['refs/heads/Main', 'refs/heads/main'], + ['refs/heads/feature/pr-042-parent', 'refs/heads/Feature/PR-042-Parent'], + ]; + + for (const [protectedParentRef, repairBranch] of pairs) { + const job = buildJob({ protectedParentRef, repairBranch }); + const label = `${protectedParentRef} | ${repairBranch}`; + + expect(findInvalidRepairJobFields(job), label).toContain('repairBranch'); + for (const operation of REF_WRITE_OPERATIONS) { + const decision = authorizeJobOperation(job, refRequest(operation, repairBranch)); + + expect(decision.mayExecuteOnce, label).toBe(false); + expect(decision.permit, label).toBeNull(); + } + } + }); +}); + +describe('an alias spelling in a request is refused, never compared', () => { + it('denies a commit or push naming an alias of the repair branch', () => { + for (const alias of REPAIR_BRANCH_ALIASES) { + for (const operation of REF_WRITE_OPERATIONS) { + const decision = authorizeJobOperation(buildJob(), refRequest(operation, alias)); + const label = `${operation} ${alias}`; + + expect(decision.reason, label).toBe(JOB_AUTHORIZATION_REASON.REF_MALFORMED); + expect(decision.decision, label).toBe(JOB_AUTHORIZATION.DENY); + expect(decision.mayExecuteOnce, label).toBe(false); + expect(decision.permit, label).toBeNull(); + } + } + }); + + it('denies a commit or push naming an alias of the protected parent', () => { + for (const alias of PARENT_REF_ALIASES) { + for (const operation of REF_WRITE_OPERATIONS) { + const decision = authorizeJobOperation(buildJob(), refRequest(operation, alias)); + const label = `${operation} ${alias}`; + + expect(decision.reason, label).toBe(JOB_AUTHORIZATION_REASON.REF_MALFORMED); + expect(decision.mayExecuteOnce, label).toBe(false); + expect(decision.permit, label).toBeNull(); + } + } + + // The canonical spelling of the parent is still refused, and still refused + // as an escape attempt rather than as a malformed operand. + for (const operation of REF_WRITE_OPERATIONS) { + expect( + authorizeJobOperation(buildJob(), refRequest(operation, PARENT_REF)).reason, + operation, + ).toBe(JOB_AUTHORIZATION_REASON.PROTECTED_REF_MUTATION); + } + }); + + it('never carries an alias operand into a normalized request', () => { + for (const alias of [...PARENT_REF_ALIASES, ...REPAIR_BRANCH_ALIASES]) { + const normalized = readJobOperation(refRequest(JOB_OPERATION.REPAIR_PUSH, alias)); + + expect(normalized.ref, alias).toBeNull(); + expect(normalized.refMalformed, alias).toBe(true); + } + }); +}); + +describe('change-request source and target separation survives aliasing', () => { + it('refuses an alias on either end', () => { + const job = buildJob(); + const cases: readonly (readonly [string, string])[] = [ + ['repair/job-0001', PARENT_REF], + ['heads/repair/job-0001', PARENT_REF], + [REPAIR_BRANCH, 'feature/pr-042-parent'], + [REPAIR_BRANCH, 'heads/feature/pr-042-parent'], + ['repair/job-0001', 'feature/pr-042-parent'], + ]; + + for (const [sourceRef, targetRef] of cases) { + const decision = authorizeJobOperation( + job, + buildRequest({ operation: JOB_OPERATION.REPAIR_CHANGE_REQUEST, sourceRef, targetRef }), + ); + const label = `${sourceRef} -> ${targetRef}`; + + expect(decision.reason, label).toBe(JOB_AUTHORIZATION_REASON.REF_MALFORMED); + expect(decision.decision, label).toBe(JOB_AUTHORIZATION.DENY); + expect(decision.permit, label).toBeNull(); + } + }); + + it('never models a change request whose source and target are one branch', () => { + for (const family of ALIAS_FAMILIES) { + for (const sourceRef of family.spellings) { + for (const targetRef of family.spellings) { + const decision = authorizeJobOperation( + buildJob({ protectedParentRef: family.branch, repairBranch: sourceRef }), + buildRequest({ + operation: JOB_OPERATION.REPAIR_CHANGE_REQUEST, + sourceRef, + targetRef, + }), + ); + const label = `${sourceRef} -> ${targetRef}`; + + expect(decision.decision, label).not.toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.mayExecuteOnce, label).toBe(false); + expect(decision.permit, label).toBeNull(); + } + } + } + }); +}); + +describe('legitimate distinct canonical refs still authorize the bounded operations', () => { + it('allows a push, a commit, and a stacked change request', () => { + const job = buildJob(); + + const push = authorizeJobOperation(job, refRequest(JOB_OPERATION.REPAIR_PUSH, REPAIR_BRANCH)); + expect(push.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(push.reason).toBe(JOB_AUTHORIZATION_REASON.WITHIN_JOB_ENVELOPE); + expect(push.permit?.operands.ref).toBe(REPAIR_BRANCH); + + const commit = authorizeJobOperation( + job, + refRequest(JOB_OPERATION.REPAIR_COMMIT, REPAIR_BRANCH), + ); + expect(commit.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(commit.permit?.operands.ref).toBe(REPAIR_BRANCH); + + const changeRequest = authorizeJobOperation( + job, + buildRequest({ + operation: JOB_OPERATION.REPAIR_CHANGE_REQUEST, + sourceRef: REPAIR_BRANCH, + targetRef: PARENT_REF, + }), + ); + expect(changeRequest.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(changeRequest.permit?.operands.sourceRef).toBe(REPAIR_BRANCH); + expect(changeRequest.permit?.operands.targetRef).toBe(PARENT_REF); + }); + + it('allows a nested repair branch stacked under a nested protected parent', () => { + const repairBranch = 'refs/heads/repair/c1-a04-ref-alias'; + const job = buildJob({ + protectedParentRef: 'refs/heads/feature/pr-042-parent', + repairBranch, + }); + + expect(findInvalidRepairJobFields(job)).toHaveLength(0); + const decision = authorizeJobOperation( + job, + refRequest(JOB_OPERATION.REPAIR_PUSH, repairBranch), + ); + + expect(decision.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.permit?.operands.ref).toBe(repairBranch); + }); +}); diff --git a/tests/domain/job-authorization.test.ts b/tests/domain/job-authorization.test.ts index d2acde3..b928a56 100644 --- a/tests/domain/job-authorization.test.ts +++ b/tests/domain/job-authorization.test.ts @@ -312,7 +312,12 @@ describe('refs: the repair branch is not the protected parent', () => { }); it('denies a push to any third ref', () => { - for (const ref of ['main', 'develop', 'release/1.0', 'repair/job-0002']) { + for (const ref of [ + 'refs/heads/main', + 'refs/heads/develop', + 'refs/heads/release/1.0', + 'refs/heads/repair/job-0002', + ]) { const decision = authorizeJobOperation(buildJob(), buildPush({ ref })); expect(decision.reason, ref).toBe(JOB_AUTHORIZATION_REASON.REF_NOT_REPAIR_BRANCH); } @@ -389,7 +394,7 @@ describe('refs: the repair branch is not the protected parent', () => { buildRequest({ operation: 'repair.change_request', sourceRef: REPAIR_BRANCH, - targetRef: 'main', + targetRef: 'refs/heads/main', }), ), JOB_AUTHORIZATION_REASON.CHANGE_REQUEST_TARGET_INVALID, diff --git a/tests/domain/repair-job-fixtures.ts b/tests/domain/repair-job-fixtures.ts index 53a0eac..6224689 100644 --- a/tests/domain/repair-job-fixtures.ts +++ b/tests/domain/repair-job-fixtures.ts @@ -19,10 +19,27 @@ export const JOB_B = 'job-0002'; export const POLICY_VERSION = 'cockpit-policy-v1'; -export const PARENT_REF = 'feature/pr-042-parent'; -export const REPAIR_BRANCH = 'repair/job-0001'; +/** + * Refs are canonical `refs/heads/` spellings everywhere in these fixtures. + * That is the only spelling C1 accepts, so a fixture in any other spelling would + * be testing an invalid envelope rather than a configured job. + */ +export const PARENT_REF = 'refs/heads/feature/pr-042-parent'; +export const REPAIR_BRANCH = 'refs/heads/repair/job-0001'; export const REPAIR_WORKTREE = 'worktree-job-0001'; +/** + * Alternate spellings git resolves to the same ref as {@link PARENT_REF}. + * + * None of these may ever be accepted as a repair branch or a ref operand: each + * one denotes the protected parent, and C1 must not be able to mistake it for a + * different branch merely because the strings differ. + */ +export const PARENT_REF_ALIASES: readonly string[] = [ + 'feature/pr-042-parent', + 'heads/feature/pr-042-parent', +]; + export const AUTHORIZED_PATH = 'src/domain/policy-gate.ts'; export const SECOND_AUTHORIZED_PATH = 'tests/domain/policy-gate.test.ts'; export const UNAUTHORIZED_PATH = 'src/domain/actions.ts';