import { BranchStateGuardConfig } from '@webpieces/rules-config'; import type { BashContext, Violation } from '../types'; import { BashRuleBase } from '../rule-base'; import { FixHint } from '../fix-hint'; /** * The BASH half of the merged-branch protection — the gap that let a whole session run on an * already-merged branch. * * feature-branch-guard blocks Write/Edit and read-stale-guard blocks the Read tool when the * checked-out branch's PR is already merged into main, but BOTH are file-scoped: a `runBash()` command * never reaches either. So an agent that only ran shell — `scripts/local.sh start lang` (boots * servers), `cat`/`ls` of repo files, git — sailed through, even though the very same * `branchAlreadyMerged` flag was loaded and logged on the Bash path (the `calls/` stream → * `merged=PR#…`). It was computed and thrown away; nothing consulted it for a block. * * Those two file guards intentionally leave Bash alone ("every cure is a Bash command, so Bash is the * escape hatch — never wedge it"). This guard therefore DEFAULT-DENIES Bash on a merged branch but * allowlists exactly the commands that get you OFF the branch (the fresh-start / cleanup git commands, * switching away, read-only orientation, wp-* cleanup, installs). The redirect it returns names those * same commands, so following it can never re-trip the guard — the agent is redirected, not wedged. * * FAIL-OPEN like its siblings: branch undeterminable, no cache yet, or a cache for a DIFFERENT branch * → allow. The cache is per-branch (`status.branch` is the branch it was computed FOR), so acting on * another branch's snapshot is never allowed. * * On the DELIBERATELY-UNFIXED staleness window: the cache is only as fresh as the last detached * refresh, so for a few seconds after a merge lands mid-session it can still read `merged=NO` and this * guard fails open. That window is tiny and self-closing — agents burst tool calls every few seconds * and every Bash call re-triggers the refresh, so `branchAlreadyMerged` flips within 1–3 calls and the * next command is caught. Closing it synchronously would require the slow `gh pr list` on the blocking * path (the thing the whole cache design avoids) and would mean blocking on stale/uncertain data, * which violates the fail-open principle every one of these guards is built on. Not worth it. */ export declare class MergedBranchBashGuardRule extends BashRuleBase { constructor(config: BranchStateGuardConfig); private readonly scanner; private readonly recoveryList; readonly description: string; readonly defaultOptions: { hangTimeoutMinutes: number; }; readonly fixHint: FixHint; check(ctx: BashContext): readonly Violation[]; private mergedMessage; private cacheSummary; /** * The guard could not ESTABLISH the state it judges on, so it judged nothing. * * A sibling of allow() rather than a reason string passed to it, because the difference has to * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'` * suffix on the free-text reason, which meant an abstention and a real approval were the same * verdict and the abstentions could not be counted — so nobody could tell whether these guards * were protecting anything or quietly standing down. Never block on data you could not * establish; but say out loud, in a field, that you did not establish it. */ private failOpen; private allow; private block; private truncate; private logDecision; private currentBranch; }