From 52f1a79a98520d5a3dc2eb2ce8a662ea36cc6a56 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Sun, 16 Aug 2026 17:29:17 +0200 Subject: [PATCH 1/9] fix: reject Git-equivalent repair refs --- docs/architecture/C1-repair-job-authority.md | 72 +++- src/domain/index.ts | 1 + src/domain/job-authorization.ts | 18 + src/domain/job-operation.ts | 42 ++- src/domain/repair-job.ts | 248 ++++++++++++- tests/domain/execution-permit.test.ts | 4 +- .../job-authorization-invariants.test.ts | 350 +++++++++++++++++- tests/domain/job-authorization.test.ts | 9 +- tests/domain/repair-job-fixtures.ts | 21 +- 9 files changed, 733 insertions(+), 32 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index a83c7b1..db504d1 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,58 @@ 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` denote **different branches**. 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. - `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 branches", 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: **two accepted refs denote the same branch if and only if +they are equal strings.** That is what makes the distinctness invariant mean +something. 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. + +**What this does not prove**, and must not be claimed to: that two unequal +accepted refs are two distinct refs on every filesystem. 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. + 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 @@ -150,6 +196,10 @@ 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 is read through the same canonical branch-ref reader the job +envelope uses, so "exactly the repair branch" is a claim about a branch and not +about a spelling. + `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 +238,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`. @@ -474,7 +524,9 @@ 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 an alias of the parent cannot be + presented as a different branch. - 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 +574,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..a0c03fe 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,208 @@ 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: + * + * > Two accepted refs denote the same branch if and only if they are equal + * > strings — up to the ASCII-case caveat below. + * + * That is what makes `repairBranch !== protectedParentRef` mean "two different + * branches" instead of "two different strings". 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 refs *on every filesystem*. 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 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 branch refs denote the same branch? + * + * 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 refs 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. + */ +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 +685,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 +708,19 @@ export interface RepairJobAuthorization { */ readonly findingHeadSha: string; /** - * The isolated repair branch. + * The isolated repair branch, in the canonical `refs/heads/` spelling + * {@link readCanonicalBranchRef} defines. + * + * Must denote a different branch 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", 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. */ readonly repairBranch: string; /** The isolated repair worktree. Filesystem-shaped operations are bound to it. */ @@ -616,12 +834,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 +886,15 @@ 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* 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 branches — except + // for the ASCII-case pair a case-insensitive filesystem can collapse into one + // loose ref, which `mayDenoteSameBranchRef` refuses as well. + 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'; From c743b65217cee372ba259cd4a1890469d529eed9 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 19 Aug 2026 23:34:31 +0200 Subject: [PATCH 2/9] docs: clarify symbolic ref resolution boundary --- docs/architecture/C1-repair-job-authority.md | 108 ++++++++++++++----- src/domain/repair-job.ts | 100 +++++++++++------ 2 files changed, 153 insertions(+), 55 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index db504d1..cc8833b 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -121,15 +121,18 @@ offending names in declaration order. Two structural invariants are enforced as configuration validity rather than as a runtime check that could be forgotten: -- `repairBranch` and `protectedParentRef` denote **different branches**. 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 branches", not "different strings". Git resolves `main`, +"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 @@ -149,14 +152,18 @@ and refuses every other spelling of the same branch as malformed: - no segment beginning or ending with `.`, no `..` anywhere, and no segment ending in `.lock` in any ASCII case -The property that buys: **two accepted refs denote the same branch if and only if -they are equal strings.** That is what makes the distinctness invariant mean -something. 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 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 @@ -165,14 +172,6 @@ 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. -**What this does not prove**, and must not be claimed to: that two unequal -accepted refs are two distinct refs on every filesystem. 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. - 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 @@ -181,6 +180,55 @@ 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.** Before +exercising any ref-mutating authority represented by an `ExecutionPermit`, that +boundary must resolve the requested ref against the actual repository, resolve or +reject repository-dependent symbolic refs, and establish that the repair ref's +resolved target is not the protected parent's. It must **fail closed** — refuse +the operation — if the requested repair ref resolves or dereferences to the +protected parent, or if safe target identity cannot be established at all. + +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 @@ -197,8 +245,10 @@ authority cannot be checked against an exact operand has no place in the model. | `repair.change_request` | source ref, target ref | repair branch → protected parent ref | Every ref operand is read through the same canonical branch-ref reader the job -envelope uses, so "exactly the repair branch" is a claim about a branch and not -about a spelling. +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 what that +name resolves to in a repository, 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 @@ -404,6 +454,13 @@ 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 `repair.commit` or +`repair.push` ref operand passed C1's canonical syntax validation says nothing +about what that ref resolves to in the repository the operation would touch, so a +permit must never be read as proof that repository-level ref resolution is safe. +The trusted execution boundary that acts on a permit performs its own resolution +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 @@ -525,8 +582,11 @@ 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, under any - spelling: refs are canonical everywhere, so an alias of the parent cannot be - presented as a different branch. + *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 — is not visible to a pure + string boundary, and is the later trusted execution boundary's to resolve or + reject before any ref-mutating operation runs. - 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 diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index a0c03fe..47a1fc9 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -462,17 +462,18 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * * 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: + * 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 denote the same branch if and only if they are equal - * > strings — up to the ASCII-case caveat below. + * > 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 - * branches" instead of "two different strings". 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. + * 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: * @@ -498,13 +499,34 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * rewritten. * * **What this does not prove**, and must not be claimed to: that two unequal - * accepted refs are two distinct refs *on every filesystem*. 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. + * 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. A later + * trusted repository/Git execution boundary must resolve the requested ref + * against the actual repository and **fail closed** — refusing the operation — + * before exercising any ref-mutating authority carried by an `ExecutionPermit`, + * if the repair ref resolves or dereferences to the protected parent or if safe + * target identity cannot be established. 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 @@ -557,7 +579,7 @@ export function readCanonicalBranchRef(value: unknown): string | null { } /** - * Could these two accepted branch refs denote the same branch? + * 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 @@ -567,9 +589,16 @@ export function readCanonicalBranchRef(value: unknown): string | null { * 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 refs 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. + * 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. Repository-resolved identity is + * the later trusted repository/Git execution boundary's to establish; see + * {@link readCanonicalBranchRef}. */ function mayDenoteSameBranchRef(left: string, right: string): boolean { if (left === right) { @@ -711,16 +740,23 @@ export interface RepairJobAuthorization { * The isolated repair branch, in the canonical `refs/heads/` spelling * {@link readCanonicalBranchRef} defines. * - * Must denote a different branch 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 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. * - * "Different branch", 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 + * "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 any ref-mutating operation runs. See + * {@link readCanonicalBranchRef}. */ readonly repairBranch: string; /** The isolated repair worktree. Filesystem-shaped operations are bound to it. */ @@ -886,11 +922,13 @@ export function readRepairJobAuthorization(job: RepairJobAuthorization): RepairJ if (findingHeadSha === null) { append(invalidFields, 'findingHeadSha'); } - // The repair branch must be a *different branch* from the protected parent + // 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 branches — except - // for the ASCII-case pair a case-insensitive filesystem can collapse into one - // loose ref, which `mayDenoteSameBranchRef` refuses as well. + // 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)) From fc49e9aa81f949807dd9d5b2acc4afbd792032e1 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Thu, 20 Aug 2026 01:17:08 +0200 Subject: [PATCH 3/9] docs: complete execution boundary ref contract --- docs/architecture/C1-repair-job-authority.md | 103 +++++++++++++++---- src/domain/repair-job.ts | 30 ++++-- 2 files changed, 102 insertions(+), 31 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index cc8833b..957efeb 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -217,13 +217,63 @@ filesystem-dependent collision that is characterisable from the strings alone; i 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.** Before -exercising any ref-mutating authority represented by an `ExecutionPermit`, that -boundary must resolve the requested ref against the actual repository, resolve or -reject repository-dependent symbolic refs, and establish that the repair ref's -resolved target is not the protected parent's. It must **fail closed** — refuse -the operation — if the requested repair ref resolves or dereferences to the -protected parent, or if safe target identity cannot be established 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 the protected 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 detect and reject a +symbolic or effective ref-name identity that aliases the protected parent, and +must not rest the check on whether two refs currently resolve to the same commit. + +*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`.** The authorized repair ref identifies a source, not a + destination. The boundary must bind the push's **effective destination ref** to + the authorized repair ref and must not let a caller-selected destination refspec + redirect the push; the receiving/mutation side must fail closed if the effective + destination is the protected parent or cannot be proven to be the authorized + repair ref. + +*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. + +*Concurrency is not closed by a pre-check.* A resolve-then-check-then-mutate +sequence is **not** an atomic security guarantee: the effective ref or referent +can change between the comparison and the update, so a name observed as an +ordinary repair ref can become symbolic to the protected parent before the +mutation lands. The invariant must be enforced **at the actual mutation/receiving +boundary**, by a mechanism whose semantics prevent an unchecked identity change +between comparison and update — not by an earlier client-side observation this +boundary later trusts. + +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 @@ -244,11 +294,14 @@ 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 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 what that -name resolves to in a repository, which only the later trusted execution boundary -can establish. +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 @@ -454,12 +507,17 @@ 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 `repair.commit` or -`repair.push` ref operand passed C1's canonical syntax validation says nothing -about what that ref resolves to in the repository the operation would touch, so a -permit must never be read as proof that repository-level ref resolution is safe. -The trusted execution boundary that acts on a permit performs its own resolution -and fails closed; see *What canonical ref names do and do not prove* above. +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 target, resolves the effective identity, enforces it at +the mutation/receiving boundary, and fails closed; see *What canonical ref names +do and do not prove* above. ### Single use @@ -584,9 +642,12 @@ invariants that stop a later layer from bypassing the quarantine by accident: - 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 — is not visible to a pure - string boundary, and is the later trusted execution boundary's to resolve or - reject before any ref-mutating operation runs. + 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 diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 47a1fc9..bf64b8d 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -518,13 +518,20 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * 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. A later - * trusted repository/Git execution boundary must resolve the requested ref - * against the actual repository and **fail closed** — refusing the operation — - * before exercising any ref-mutating authority carried by an `ExecutionPermit`, - * if the repair ref resolves or dereferences to the protected parent or if safe - * target identity cannot be established. Nothing here acquires git invocation, - * filesystem access, a subprocess, or network to decide it. See + * 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 the effective mutation target it will + * actually advance: the worktree's effective `HEAD` referent for a commit, and the + * effective destination ref for a push. It must **fail closed** — refusing the + * operation — if that 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-mutate pre-check is not itself atomic, so the + * invariant is enforced at the mutation/receiving boundary. 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". * @@ -596,8 +603,11 @@ export function readCanonicalBranchRef(value: unknown): string | null { * 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. Repository-resolved identity is - * the later trusted repository/Git execution boundary's to establish; see + * 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, meaning the effective + * ref-name referent reached by resolving a symbolic-ref chain, is the later + * trusted repository/Git execution boundary's to establish; see * {@link readCanonicalBranchRef}. */ function mayDenoteSameBranchRef(left: string, right: string): boolean { @@ -755,7 +765,7 @@ export interface RepairJobAuthorization { * 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 any ref-mutating operation runs. See + * resolve or reject before it acts on any authority a permit records. See * {@link readCanonicalBranchRef}. */ readonly repairBranch: string; From afba343d1ca2bb8c27e50dd5e8964fc039b84034 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Thu, 20 Aug 2026 14:37:40 +0200 Subject: [PATCH 4/9] docs: make protected parent role-aware --- docs/architecture/C1-repair-job-authority.md | 30 +++++++++++++++++--- src/domain/repair-job.ts | 17 ++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index 957efeb..cca523e 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -225,7 +225,23 @@ 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 the protected parent. +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 — @@ -233,9 +249,15 @@ not about commit-object identity. A freshly created repair branch may legitimate 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 detect and reject a -symbolic or effective ref-name identity that aliases the protected parent, and -must not rest the check on whether two refs currently resolve to the same commit. +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. 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: diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index bf64b8d..c8ffd1a 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -527,13 +527,16 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * protected parent's commit OID — and bind the effective mutation target it will * actually advance: the worktree's effective `HEAD` referent for a commit, and the * effective destination ref for a push. It must **fail closed** — refusing the - * operation — if that 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-mutate pre-check is not itself atomic, so the - * invariant is enforced at the mutation/receiving boundary. 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". + * operation — if that effective mutation target is or dereferences to the + * protected parent, if it changes between comparison and update, or if it cannot + * be safely established; a resolve-then-mutate pre-check is not itself atomic, so + * the invariant is enforced at the mutation/receiving boundary. 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. + * 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 From bf78cf668e6b93701c83ea0a3537c185aaca40e8 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Thu, 20 Aug 2026 18:38:31 +0200 Subject: [PATCH 5/9] docs: bind change requests at provider boundary Co-Authored-By: Claude Opus 4.8 --- docs/architecture/C1-repair-job-authority.md | 46 ++++++++++++++------ src/domain/repair-job.ts | 14 ++++-- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index cca523e..51b16c8 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -282,16 +282,34 @@ branch **to** the protected parent. The boundary must establish that the effecti 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. - -*Concurrency is not closed by a pre-check.* A resolve-then-check-then-mutate -sequence is **not** an atomic security guarantee: the effective ref or referent -can change between the comparison and the update, so a name observed as an -ordinary repair ref can become symbolic to the protected parent before the -mutation lands. The invariant must be enforced **at the actual mutation/receiving -boundary**, by a mechanism whose semantics prevent an unchecked identity change -between comparison and update — not by an earlier client-side observation this -boundary later trusts. +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, and must not +independently re-resolve the ref names, derive the source or target from ambient +repository state, or otherwise act on an identity that has changed since it was +established. If that authorized source-to-target relationship cannot be maintained +through to the provider request — because an effective identity has changed, or +cannot be safely re-established 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 @@ -537,9 +555,11 @@ 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 target, resolves the effective identity, enforces it at -the mutation/receiving boundary, and fails closed; see *What canonical ref names -do and do not prove* above. +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 diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index c8ffd1a..a1a5e31 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -529,11 +529,19 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * effective destination ref for a push. It must **fail closed** — refusing the * operation — if that effective mutation target is or dereferences to the * protected parent, if it changes between comparison and update, or if it cannot - * be safely established; a resolve-then-mutate pre-check is not itself atomic, so - * the invariant is enforced at the mutation/receiving boundary. That refusal is + * 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. + * 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 must not independently re-resolve those refs or derive them from ambient + * repository state, and the boundary must fail closed if that relationship cannot be + * maintained 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". From 7810d6e337e3b664526bb8017f0f80e12ad2b312 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Thu, 20 Aug 2026 19:43:09 +0200 Subject: [PATCH 6/9] docs: allow identity-preserving provider resolution Co-Authored-By: Claude Opus 4.8 --- docs/architecture/C1-repair-job-authority.md | 20 +++++++++++++------- src/domain/repair-job.ts | 9 +++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index 51b16c8..7d07781 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -286,13 +286,19 @@ established. Because this operation performs no ref update to guard, the boundar 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, and must not -independently re-resolve the ref names, derive the source or target from ambient -repository state, or otherwise act on an identity that has changed since it was -established. If that authorized source-to-target relationship cannot be maintained -through to the provider request — because an effective identity has changed, or -cannot be safely re-established at that boundary — the boundary must fail closed -and create no change 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 diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index a1a5e31..8287b5b 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -538,10 +538,11 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * 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 must not independently re-resolve those refs or derive them from ambient - * repository state, and the boundary must fail closed if that relationship cannot be - * maintained there. + * 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". From 077933d3a3df574c8a40c8074e0c2d918445cced Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Thu, 20 Aug 2026 22:20:47 +0200 Subject: [PATCH 7/9] C1: bind repair.push source to authorized ref --- docs/architecture/C1-repair-job-authority.md | 22 ++++++++++++++------ src/domain/repair-job.ts | 6 ++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index 7d07781..bc9fabc 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -267,12 +267,22 @@ mutation will advance, and the boundary must bind the two before it acts: 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`.** The authorized repair ref identifies a source, not a - destination. The boundary must bind the push's **effective destination ref** to - the authorized repair ref and must not let a caller-selected destination refspec - redirect the push; the receiving/mutation side must fail closed if the effective - destination is the protected parent or cannot be proven to be the authorized - repair ref. +- **`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 diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 8287b5b..8c726d0 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -525,8 +525,10 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * 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 the effective mutation target it will - * actually advance: the worktree's effective `HEAD` referent for a commit, and the - * effective destination ref for a push. It must **fail closed** — refusing the + * actually advance: the worktree's effective `HEAD` referent for a commit, and, + * for a push, both the effective source and destination refs — each the authorized + * repair ref, so an absent (deletion) or redirected source is refused. It must + * **fail closed** — refusing the * operation — if that effective mutation target 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 From 6812607a81dda46e3cf5ca3c3105b9bfe7fa6322 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Thu, 20 Aug 2026 23:42:38 +0200 Subject: [PATCH 8/9] C1: distinguish repair.push source role Co-Authored-By: Claude Opus 4.8 --- src/domain/repair-job.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 8c726d0..272885b 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -524,12 +524,15 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * 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 the effective mutation target it will - * actually advance: the worktree's effective `HEAD` referent for a commit, and, - * for a push, both the effective source and destination refs — each the authorized + * protected parent's commit OID — and bind each effective identity the operation + * acts on to the authorized repair ref. 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 that effective mutation target is or dereferences to 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 From 467ccc39b7c497c904aa527f9d3d50940d2593c7 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Fri, 21 Aug 2026 03:06:31 +0200 Subject: [PATCH 9/9] C1-A04 clarify effective repository ref identity Co-Authored-By: Claude Opus 4.8 --- docs/architecture/C1-repair-job-authority.md | 10 +++++++++- src/domain/repair-job.ts | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/architecture/C1-repair-job-authority.md b/docs/architecture/C1-repair-job-authority.md index bc9fabc..523f8a3 100644 --- a/docs/architecture/C1-repair-job-authority.md +++ b/docs/architecture/C1-repair-job-authority.md @@ -251,7 +251,15 @@ 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. What that comparison must *yield* is fixed +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 diff --git a/src/domain/repair-job.ts b/src/domain/repair-job.ts index 272885b..0705e34 100644 --- a/src/domain/repair-job.ts +++ b/src/domain/repair-job.ts @@ -525,7 +525,14 @@ function endsWithDotLockSuffix(value: string, start: number, end: number): boole * 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 commit advances one mutation target: the + * 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 @@ -622,10 +629,11 @@ export function readCanonicalBranchRef(value: unknown): string | null { * 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, meaning the effective - * ref-name referent reached by resolving a symbolic-ref chain, is the later - * trusted repository/Git execution boundary's to establish; see - * {@link readCanonicalBranchRef}. + * 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) {