|
| 1 | +/* |
| 2 | + * @file Shared branch-switch detection + primary-checkout classification for |
| 3 | + * the two branch-switch guards: |
| 4 | + * |
| 5 | + * - `primary-checkout-branch-guard` — per-repo (fleet dispatcher) enforcer. |
| 6 | + * - `no-primary-branch-switch` — its user-global sibling, wired through the |
| 7 | + * wheelhouse dispatcher so it fires from EVERY repo session. Both block a |
| 8 | + * `git checkout/switch <branch>` / `-b` / `-c` (and the `-` previous-branch |
| 9 | + * shorthand) whose effective working tree is the PRIMARY checkout — never a |
| 10 | + * linked worktree or a submodule — because moving HEAD in a primary |
| 11 | + * checkout yanks the tree out from under a parallel session. The detection, |
| 12 | + * classification, effective-directory resolution, the sanctioned |
| 13 | + * restore-to-default carve-out, and the shared bypass all live here ONCE so |
| 14 | + * the two guards can never drift. Unified bypass (see |
| 15 | + * `branchSwitchBypassAllowed`): because BOTH guards fire on a primary |
| 16 | + * branch-switch, a phrase only one honored would leave the switch |
| 17 | + * un-bypassable — the other guard would still block. So a single shared |
| 18 | + * check honors either phrase, human-turn only, and both guards defer to it. |
| 19 | + * `Allow branch switch` is the canonical shared phrase, and `Allow |
| 20 | + * primary-branch bypass` is primary-checkout-branch-guard's own. |
| 21 | + */ |
| 22 | + |
| 23 | +import path from 'node:path' |
| 24 | + |
| 25 | +import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' |
| 26 | +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' |
| 27 | + |
| 28 | +import { actedOnPath } from './fleet-context.mts' |
| 29 | +import { resolveDefaultBranch } from './git-branch.mts' |
| 30 | +import type { ToolCallPayload } from './payload.mts' |
| 31 | +import { commandsFor } from './shell-command.mts' |
| 32 | +import { spawnTimeoutMs } from './spawn-timeout.mts' |
| 33 | +import { bypassPhrasePresent } from './transcript.mts' |
| 34 | + |
| 35 | +// Pre-flight substrings the dispatcher gates on: every branch-switch command |
| 36 | +// carries the literal `checkout` or `switch` token. Each guard re-declares this |
| 37 | +// as its own `export const triggers` literal (the build-time dispatch scanner |
| 38 | +// reads that literal textually from each hook's index.mts); this is the single |
| 39 | +// canonical value they mirror. |
| 40 | +export const BRANCH_SWITCH_TRIGGERS: readonly string[] = ['checkout', 'switch'] |
| 41 | + |
| 42 | +// The phrases that authorize a primary branch-switch, honored by BOTH guards. |
| 43 | +// `Allow branch switch` is the canonical shared phrase; `Allow primary-branch |
| 44 | +// bypass` is primary-checkout-branch-guard's historical phrase, kept so a |
| 45 | +// message still advertising it authorizes both guards at once. |
| 46 | +export const BRANCH_SWITCH_BYPASS_PHRASES: readonly string[] = [ |
| 47 | + 'Allow branch switch', |
| 48 | + 'Allow primary-branch bypass', |
| 49 | +] |
| 50 | + |
| 51 | +/** |
| 52 | + * True when the user typed either unified bypass phrase in a genuine human |
| 53 | + * turn (bypassPhrasePresent — not the assistant, a tool result, or a |
| 54 | + * peer-agent relay). Both guards call this so a primary switch is never left |
| 55 | + * un-bypassable by one guard honoring a phrase the other ignores. |
| 56 | + */ |
| 57 | +export function branchSwitchBypassAllowed(payload: ToolCallPayload): boolean { |
| 58 | + return bypassPhrasePresent( |
| 59 | + payload.transcript_path, |
| 60 | + BRANCH_SWITCH_BYPASS_PHRASES, |
| 61 | + ) |
| 62 | +} |
| 63 | + |
| 64 | +// A `git checkout` arg list that's a working-tree / file restore rather than a |
| 65 | +// branch switch: `git checkout -- <file>` or `git checkout .`. Conservative — |
| 66 | +// anything ambiguous is treated as a branch (the guard is about NOT moving |
| 67 | +// HEAD in the primary checkout). |
| 68 | +export function looksLikePathRestore(args: readonly string[]): boolean { |
| 69 | + return args.includes('--') || args.includes('.') |
| 70 | +} |
| 71 | + |
| 72 | +// A ref that moves HEAD: a normal branch/commit name, no leading dash, or the |
| 73 | +// `-` shorthand for the previous branch (`git checkout -` / `git switch -`). |
| 74 | +// Without the `-` case, the previous-branch switch slips past the flag filter. |
| 75 | +export function isSwitchTarget(arg: string): boolean { |
| 76 | + return arg === '-' || !arg.startsWith('-') |
| 77 | +} |
| 78 | + |
| 79 | +/** |
| 80 | + * Inspect a single `git` command's args; return the branch operation it |
| 81 | + * performs, or undefined if it's not a branch create/switch. |
| 82 | + */ |
| 83 | +export function branchOpKind( |
| 84 | + args: readonly string[], |
| 85 | +): 'create' | 'switch' | undefined { |
| 86 | + const sub = args.find(a => a === 'checkout' || a === 'switch') |
| 87 | + if (!sub) { |
| 88 | + return undefined |
| 89 | + } |
| 90 | + const rest = args.slice(args.indexOf(sub) + 1) |
| 91 | + // Create-and-switch flags on either subcommand. |
| 92 | + if ( |
| 93 | + rest.includes('-b') || |
| 94 | + rest.includes('-B') || |
| 95 | + rest.includes('-c') || |
| 96 | + rest.includes('-C') |
| 97 | + ) { |
| 98 | + return 'create' |
| 99 | + } |
| 100 | + if (sub === 'switch') { |
| 101 | + // `git switch <name>` (or `git switch -`) — moving to another branch. A |
| 102 | + // bare `git switch` with only flags has no target → ignore. |
| 103 | + const target = rest.find(isSwitchTarget) |
| 104 | + return target ? 'switch' : undefined |
| 105 | + } |
| 106 | + // sub === 'checkout': a branch switch only when there's a target arg that |
| 107 | + // isn't a file-restore form. `--`/`.` guards the file-restore case, so a lone |
| 108 | + // `-` here is the previous-branch shorthand, not a filename. |
| 109 | + if (looksLikePathRestore(rest)) { |
| 110 | + return undefined |
| 111 | + } |
| 112 | + const target = rest.find(isSwitchTarget) |
| 113 | + return target ? 'switch' : undefined |
| 114 | +} |
| 115 | + |
| 116 | +// The three checkout shapes a `git rev-parse --git-dir` result can name. A |
| 117 | +// linked worktree resolves under `.git/worktrees/<name>`, a submodule under |
| 118 | +// `.git/modules/<name>`, and everything else is the repo's own `.git`. |
| 119 | +export type CheckoutKind = 'primary' | 'submodule' | 'worktree' |
| 120 | + |
| 121 | +// True when the git-dir sits in `<repo>/.git/<sub>/…`. Both the absolute form |
| 122 | +// git reports from a worktree or submodule and the relative `.git` form it |
| 123 | +// reports from a repo root are accepted, so the classifier never depends on |
| 124 | +// which of the two git chose. |
| 125 | +function gitDirHasSubtree(gitDir: string, sub: string): boolean { |
| 126 | + const p = normalizePath(gitDir) |
| 127 | + return p.includes(`/.git/${sub}/`) || p.startsWith(`.git/${sub}/`) |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * Classify a `git rev-parse --git-dir` result. A SUBMODULE is its own case: its |
| 132 | + * git-dir lives under the superproject's `.git/modules/`, which contains |
| 133 | + * neither `/.git/worktrees/` nor a plain repo `.git`, so a two-case |
| 134 | + * primary-vs-worktree test answers "primary" and blocks the detached checkout |
| 135 | + * the upstream-references doctrine requires (`git -C upstream/<name> checkout |
| 136 | + * --detach <ref>` is how a gitlink-less reference is pinned). |
| 137 | + */ |
| 138 | +export function checkoutKindForGitDir(gitDir: string): CheckoutKind { |
| 139 | + if (gitDirHasSubtree(gitDir, 'worktrees')) { |
| 140 | + return 'worktree' |
| 141 | + } |
| 142 | + if (gitDirHasSubtree(gitDir, 'modules')) { |
| 143 | + return 'submodule' |
| 144 | + } |
| 145 | + return 'primary' |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * True when `cwd` is the PRIMARY checkout — neither a linked worktree nor a |
| 150 | + * submodule. Branch work in a worktree is the sanctioned path, and a submodule |
| 151 | + * checkout is a different repository entirely, so neither is the guards' |
| 152 | + * business. Fails OPEN (returns false) when git is unavailable / not a repo. |
| 153 | + */ |
| 154 | +export function isPrimaryCheckout(cwd: string): boolean { |
| 155 | + const r = spawnSync('git', ['rev-parse', '--git-dir'], { |
| 156 | + cwd, |
| 157 | + timeout: spawnTimeoutMs(5000), |
| 158 | + }) |
| 159 | + if (r.status !== 0) { |
| 160 | + // Not a git repo, or git unavailable — nothing to guard, fail open. |
| 161 | + return false |
| 162 | + } |
| 163 | + return checkoutKindForGitDir(String(r.stdout).trim()) === 'primary' |
| 164 | +} |
| 165 | + |
| 166 | +// `git -C <path> ...` runs the subcommand in <path>. Extract that path so a |
| 167 | +// branch op aimed at the primary via `-C` is judged by the target, not the |
| 168 | +// possibly worktree, session cwd. |
| 169 | +function dashCDir(args: readonly string[]): string | undefined { |
| 170 | + const i = args.indexOf('-C') |
| 171 | + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined |
| 172 | +} |
| 173 | + |
| 174 | +// The ref a branch op moves HEAD to: the name after `-b/-B/-c/-C` for a create, |
| 175 | +// else the pathspec-less positional target of a switch/checkout. Used to carve |
| 176 | +// out switching TO the default branch (always safe — it's the sanctioned state). |
| 177 | +export function branchTarget(args: readonly string[]): string | undefined { |
| 178 | + const sub = args.find(a => a === 'checkout' || a === 'switch') |
| 179 | + if (!sub) { |
| 180 | + return undefined |
| 181 | + } |
| 182 | + const rest = args.slice(args.indexOf(sub) + 1) |
| 183 | + for (const flag of ['-b', '-B', '-c', '-C']) { |
| 184 | + const i = rest.indexOf(flag) |
| 185 | + if (i >= 0 && i + 1 < rest.length) { |
| 186 | + return rest[i + 1] |
| 187 | + } |
| 188 | + } |
| 189 | + if (looksLikePathRestore(rest)) { |
| 190 | + return undefined |
| 191 | + } |
| 192 | + return rest.find(isSwitchTarget) |
| 193 | +} |
| 194 | + |
| 195 | +export interface BranchOp { |
| 196 | + readonly kind: 'create' | 'switch' |
| 197 | + readonly dashC?: string | undefined |
| 198 | + readonly target?: string | undefined |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * The first `git checkout`/`switch` segment of `command` that MOVES HEAD, with |
| 203 | + * its `-C` target and the ref it moves to — or undefined when the command runs |
| 204 | + * no branch op. Sees through `&&` chains / quoting / `$(…)` substitution via |
| 205 | + * the shared shell parser (commandsFor), so a literal "git checkout" in a grep |
| 206 | + * string never false-fires. |
| 207 | + */ |
| 208 | +export function firstBranchOp(command: string): BranchOp | undefined { |
| 209 | + for (const c of commandsFor(command, 'git')) { |
| 210 | + const kind = branchOpKind(c.args) |
| 211 | + if (kind) { |
| 212 | + const dashC = dashCDir(c.args) |
| 213 | + const target = branchTarget(c.args) |
| 214 | + return { |
| 215 | + kind, |
| 216 | + ...(dashC === undefined ? {} : { dashC }), |
| 217 | + ...(target === undefined ? {} : { target }), |
| 218 | + } |
| 219 | + } |
| 220 | + } |
| 221 | + return undefined |
| 222 | +} |
| 223 | + |
| 224 | +export interface PrimaryBranchOp { |
| 225 | + // The effective working directory the branch op targets (a subshell `cd`, |
| 226 | + // then a `-C <path>` relative to it). |
| 227 | + readonly dir: string |
| 228 | + readonly kind: 'create' | 'switch' |
| 229 | + readonly target: string | undefined |
| 230 | +} |
| 231 | + |
| 232 | +/** |
| 233 | + * The branch op in `command` that BOTH guards act on: one that moves HEAD in a |
| 234 | + * PRIMARY checkout and is NOT the sanctioned restore-to-default. Returns the op |
| 235 | + * \+ its effective directory, or undefined when there is no branch op, the |
| 236 | + * target is a linked worktree / submodule / non-repo, or it is a switch TO the |
| 237 | + * default branch (always safe — the sanctioned state that |
| 238 | + * primary-checkout-on-default-stop-guard REQUIRES; blocking it would deadlock |
| 239 | + * the two guards). |
| 240 | + * |
| 241 | + * Effective dir: honor a subshell `cd` (actedOnPath), THEN a `-C <path>` on the |
| 242 | + * git op relative to that — a worktree cwd cannot launder a switch aimed at the |
| 243 | + * primary via `-C`. |
| 244 | + */ |
| 245 | +export function primaryBranchOp( |
| 246 | + command: string, |
| 247 | + payload: ToolCallPayload, |
| 248 | +): PrimaryBranchOp | undefined { |
| 249 | + const op = firstBranchOp(command) |
| 250 | + if (!op) { |
| 251 | + return undefined |
| 252 | + } |
| 253 | + const baseCwd = actedOnPath(payload) |
| 254 | + const dir = op.dashC ? path.resolve(baseCwd, op.dashC) : baseCwd |
| 255 | + if (!isPrimaryCheckout(dir)) { |
| 256 | + return undefined |
| 257 | + } |
| 258 | + if (op.kind === 'switch' && op.target === resolveDefaultBranch(dir)) { |
| 259 | + return undefined |
| 260 | + } |
| 261 | + return { dir, kind: op.kind, target: op.target } |
| 262 | +} |
0 commit comments