@webpieces/ai-hook-rules 0.4.697 → 0.4.699

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/src/core/command-scan.d.ts +21 -9
  3. package/src/core/command-scan.js +18 -16
  4. package/src/core/command-scan.js.map +1 -1
  5. package/src/core/l2-doc.js +27 -2
  6. package/src/core/l2-doc.js.map +1 -1
  7. package/src/core/l2-rows.d.ts +1 -1
  8. package/src/core/l2-rows.js +18 -4
  9. package/src/core/l2-rows.js.map +1 -1
  10. package/src/core/read-only-inspection.js +1 -1
  11. package/src/core/read-only-inspection.js.map +1 -1
  12. package/src/core/rules/build-output-pipe-scan.js +2 -2
  13. package/src/core/rules/build-output-pipe-scan.js.map +1 -1
  14. package/src/core/rules/content-read-scan.js +1 -1
  15. package/src/core/rules/content-read-scan.js.map +1 -1
  16. package/src/core/rules/cure-prefix-scan.d.ts +64 -0
  17. package/src/core/rules/cure-prefix-scan.js +133 -0
  18. package/src/core/rules/cure-prefix-scan.js.map +1 -0
  19. package/src/core/rules/read-stale-guard.d.ts +5 -4
  20. package/src/core/rules/read-stale-guard.js +9 -8
  21. package/src/core/rules/read-stale-guard.js.map +1 -1
  22. package/src/core/rules/recovery-allowlist.js +1 -1
  23. package/src/core/rules/recovery-allowlist.js.map +1 -1
  24. package/src/core/rules/shell-segment-scan.js +1 -1
  25. package/src/core/rules/shell-segment-scan.js.map +1 -1
  26. package/src/core/rules/stale-main-bash-guard.d.ts +28 -0
  27. package/src/core/rules/stale-main-bash-guard.js +45 -1
  28. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  29. package/src/core/rules/stale-main-message.d.ts +19 -8
  30. package/src/core/rules/stale-main-message.js +25 -13
  31. package/src/core/rules/stale-main-message.js.map +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cure-prefix-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/cure-prefix-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment, SegmentJoin } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n// ---------------------------------------------------------------------------\n// COMPOSITION — `<cure> && <work>`, and why `<cure> ; <work>` is a different command.\n//\n// Measured, not guessed. Across three days of fleet guard logs an agent blocked on a stale `main`\n// bundled the cure into the same shell call as the work SIXTEEN times, in two shapes:\n//\n// pnpm wp-checkout-clean-main >/dev/null 2>&1; git log --oneline -1; sed -n '598,612p' …\n// git fetch --prune origin main -q && git pull --ff-only origin main 2>&1 | tail -1 && sed -n …\n//\n// The FIRST is genuinely unsafe and stays blocked. `;` discards the cure's exit code, so the `sed`\n// runs against still-stale content whether the pull hit a conflict, a dirty tree or no network — and\n// in 7 of the 9 observed cases the agent had also written `>/dev/null 2>&1`, so the failure was\n// invisible as well as ignored. The two-step really is safer there, and not for a bureaucratic\n// reason: the NEXT tool call is a fresh guard evaluation that recomputes `localMain` against\n// `originMain`, so a pull that failed re-blocks. An allowed `;` compound never gets that second look.\n//\n// The SECOND is the shell already enforcing exactly the property the guard wants. `&&` short-circuits:\n// if the cure exits non-zero the work never runs. Blocking it bought nothing and cost a round trip,\n// which is why the fleet audit files it as a TOOLING defect rather than an agent one.\n//\n// So this class answers one question — *does this command START with a refresh-main cure, and what\n// operator joins it to the rest?* — and the guard turns that into allow / refuse-and-say-which.\n// ---------------------------------------------------------------------------\n\n/**\n * How a leading refresh-main cure is joined to the work behind it.\n *\n * `none` the command does not start with a cure at all (or is nothing BUT cure, which the\n * row 4 skip list already allowed before this class is ever consulted)\n * `short-circuits` `&&` — the work is skipped when the cure fails\n * `runs-anyway` `;`, `||`, `&`, a newline — the cure's exit code is discarded\n */\nexport type CureJoinKind = 'none' | 'short-circuits' | 'runs-anyway';\n\n/**\n * What the scan found. Data-only, so a class (per CLAUDE.md).\n *\n * `operator` is the literal shell operator, carried because the refusal message must NAME it: an\n * agent that is told \"use `&&`\" without being told which character it actually typed has to diff the\n * two spellings itself, and the whole point of this block is to hand over the one edit that fixes it.\n */\nexport class CurePrefix {\n constructor(readonly kind: CureJoinKind, readonly operator: SegmentJoin) {}\n}\n\nconst NO_CURE_PREFIX = new CurePrefix('none', 'none');\n\n// The commands that ADVANCE local `main` — the one this repo prescribes, and the raw git verb it\n// wraps. Deliberately NOT every `wp-*` bin: a prefix earns the composition allowance because it makes\n// the tree fresh, and `pnpm wp-cleanup` does not.\nconst ADVANCING_BINS: ReadonlySet<string> = new Set(['wp-checkout-clean-main']);\n\n// The refs a pull may name and still be the CURE. `git pull origin some-feature` advances local\n// `main` by merging a feature branch into it — which is a different and worse thing than being stale,\n// and leaves the work reading a `main` that still does not contain `origin/main`. A pull with NO\n// explicit ref takes the current branch's upstream, and the current branch here is `main` by the time\n// this class is consulted, so the bare forms are the cure and are covered by the empty case below.\nconst MAIN_REFS: ReadonlySet<string> = new Set(['main', 'origin/main', 'main:main', 'origin/main:main']);\n\n// `git fetch` may LEAD the prefix — `git fetch --prune origin main && git pull --ff-only origin main`\n// is the shape agents actually type — but it is not itself a cure and never satisfies the prefix on\n// its own. A fetch moves the remote-tracking ref and leaves local `main` exactly as far behind as it\n// was, so `git fetch && <work>` short-circuits on nothing: the work still reads the stale tree. That\n// command gets the ordinary row 6 block, whose message says what a fetch alone does not fix.\nconst ACCOMPANYING_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set(['fetch']);\n\nexport class CurePrefixScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(this.scanner);\n }\n\n /**\n * Classify the LEADING run of segments as a refresh-main cure, and report the operator that joins\n * that run to whatever follows.\n *\n * The run may carry inert company — a leading `cd '<root>'` (which is how every guard renders a\n * remedy that must survive a reset cwd), an `echo`, and the `2>&1 | tail -3` an agent appends by\n * reflex. None of those changes what the cure does, and refusing a command because it was piped\n * into `tail` is the exact defect ShellSegmentScan was written to end.\n */\n classify(command: string): CurePrefix {\n const segments = this.scanner.segmentsWithJoins(command);\n let advanced = false;\n for (const segment of segments) {\n if (this.advancesLocalMain(segment)) { advanced = true; continue; }\n if (this.accompaniesTheCure(segment)) continue;\n // The first segment that is neither. Its join is the verdict — but only once something in\n // the prefix has actually advanced local `main`; otherwise this is an ordinary command\n // that happens to open with a `cd` or a fetch, and row 6 judges it as it always did.\n if (!advanced) return NO_CURE_PREFIX;\n return new CurePrefix(segment.join === '&&' ? 'short-circuits' : 'runs-anyway', segment.join);\n }\n // Nothing but cure and its company — there is no work to protect, and the row 4 skip list has\n // already spoken. Never reached from a blocked path; kept explicit rather than implied.\n return NO_CURE_PREFIX;\n }\n\n /**\n * Does this segment bring local `main` forward — `git pull`, or the `pnpm wp-checkout-clean-main`\n * that wraps it?\n *\n * A `> file` redirect disqualifies it, with `/dev/null` carved out. `>/dev/null 2>&1` on the cure\n * is only ever an agent muting chatter, and under `&&` the exit code — the thing that matters —\n * still governs. A redirect to a REAL path is a write, and a segment that writes the tree is not\n * something to wave a following command through on the strength of.\n */\n private advancesLocalMain(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return false;\n if (this.shell.redirectsToFile(words) && !words.some((w: string): boolean => w.includes('/dev/null'))) {\n return false;\n }\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return gitSub === 'pull' && this.pullsMain(segment);\n return this.scanner.runnerStrippedWords(segment.text)\n .some((word: string): boolean => ADVANCING_BINS.has(path.basename(word)));\n }\n\n /**\n * Does this `git pull` bring `origin/main` in, rather than some other branch?\n *\n * The LAST positional argument is the refspec (`git pull [flags] [remote] [refspec…]`); with no\n * refspec at all the pull takes the current branch's upstream, and the current branch is `main`\n * wherever this class is consulted, so that form is the cure. `git pull origin some-feature`\n * is not: it merges a feature branch into `main` and leaves local `main` still not containing\n * `origin/main`, so the work behind the `&&` would read the same stale tree the block is about.\n */\n private pullsMain(segment: CommandSegment): boolean {\n const args = this.scanner.gitSubcommandArgs(segment.text, 'pull') ?? [];\n // Flags are not positional, and neither is a REDIRECTION token — `2>&1` and `>/dev/null` are\n // the two decorations an agent appends by reflex, and reading `2>&1` as the refspec would\n // reject the single most common spelling of the cure.\n const positional = args.filter((arg: string): boolean => !arg.startsWith('-') && !/[<>]/.test(arg));\n if (positional.length <= 1) return true;\n return MAIN_REFS.has(positional[positional.length - 1]);\n }\n\n /**\n * Segments that ride along with the cure without being it: shell structure, a `cd`/`echo`, a\n * filter the cure was piped into, and a leading `git fetch`. Anything a pipe fed cannot touch the\n * tree, and a `cd` is how every guard renders a remedy that must survive a reset cwd — so none of\n * them turns a cure prefix into work, and none of them is a cure either.\n */\n private accompaniesTheCure(segment: CommandSegment): boolean {\n if (this.shell.classify(segment).role !== 'command') return true;\n const gitSub = this.scanner.gitSubcommandOf(this.shell.effectiveWords(segment.text));\n return gitSub !== null && ACCOMPANYING_GIT_SUBCOMMANDS.has(gitSub);\n }\n}\n"]}
@@ -17,7 +17,7 @@ import { FixHint } from '../fix-hint';
17
17
  *
18
18
  * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to
19
19
  * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure,
20
- * `git pull --ff-only`, is not a fast-forward on a dirty tree — true, but that is a fact about the
20
+ * an in-place pull, is not a fast-forward on a dirty tree — true, but that is a fact about the
21
21
  * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b <new>
22
22
  * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with
23
23
  * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty
@@ -27,11 +27,12 @@ import { FixHint } from '../fix-hint';
27
27
  * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch.
28
28
  * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop.
29
29
  *
30
- * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,
30
+ * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `pnpm wp-checkout-clean-main`,
31
31
  * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.
32
32
  * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.
33
- * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which
34
- * returns null when the branch IS main — the two guards are complementary, not stacked.)
33
+ * (Every `wp-*` bin is on the L2 skip list, and the pull it wraps is explicitly permitted on main by
34
+ * redirect-how-to-merge-main, which returns null when the branch IS main — the guards are
35
+ * complementary, not stacked.)
35
36
  *
36
37
  * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash
37
38
  * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,
@@ -34,7 +34,7 @@ const tree_recovery_1 = require("./tree-recovery");
34
34
  *
35
35
  * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to
36
36
  * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure,
37
- * `git pull --ff-only`, is not a fast-forward on a dirty tree — true, but that is a fact about the
37
+ * an in-place pull, is not a fast-forward on a dirty tree — true, but that is a fact about the
38
38
  * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b <new>
39
39
  * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with
40
40
  * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty
@@ -44,11 +44,12 @@ const tree_recovery_1 = require("./tree-recovery");
44
44
  * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch.
45
45
  * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop.
46
46
  *
47
- * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,
47
+ * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `pnpm wp-checkout-clean-main`,
48
48
  * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.
49
49
  * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.
50
- * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which
51
- * returns null when the branch IS main — the two guards are complementary, not stacked.)
50
+ * (Every `wp-*` bin is on the L2 skip list, and the pull it wraps is explicitly permitted on main by
51
+ * redirect-how-to-merge-main, which returns null when the branch IS main — the guards are
52
+ * complementary, not stacked.)
52
53
  *
53
54
  * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash
54
55
  * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,
@@ -89,9 +90,9 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
89
90
  hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
90
91
  };
91
92
  fixHint = new fix_hint_1.FixHint('This branch is stale to read from — reading it would give you pre-merge/out-of-date content.', 'Get onto current code before reading anything else:', [
92
- new rules_config_1.Option('On main, behind origin/main → git pull origin main (CLEAN TREE ONLY), or git checkout -b <new-branch> origin/main which works with UNCOMMITTED CHANGES and brings them along. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main, which likewise carries your edits. Then retry the read.', true),
93
+ new rules_config_1.Option('On main, behind origin/main → pnpm wp-checkout-clean-main (CLEAN TREE ONLY), or git checkout -b <new-branch> origin/main which works with UNCOMMITTED CHANGES and brings them along. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main, which likewise carries your edits. Then retry the read.', true),
93
94
  new rules_config_1.Option('If a checkout -b refuses because origin/main changed the same files you edited: git stash (never blocked), redo the checkout, then git stash pop.'),
94
- new rules_config_1.Option("If that pull dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then retry the pull."),
95
+ new rules_config_1.Option("If pnpm wp-checkout-clean-main dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then run it again."),
95
96
  new rules_config_1.Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),
96
97
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
97
98
  ]);
@@ -131,8 +132,8 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
131
132
  if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {
132
133
  return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
133
134
  }
134
- // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed `git pull` is
135
- // not a clean fast-forward on a dirty tree. That argument was about the MESSAGE, not the row:
135
+ // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed in-place pull
136
+ // is not a clean fast-forward on a dirty tree. That argument was about the MESSAGE, not the row:
136
137
  // row 6's cure cell has always offered `git checkout -b <new> origin/main` as an alternative,
137
138
  // and THAT works dirty — it carries uncommitted changes onto the new branch and lands you on
138
139
  // current code, which is the whole point. The message now leads with it when the tree is dirty
@@ -1 +1 @@
1
- {"version":3,"file":"read-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/read-stale-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { StaleMainMessage } from './stale-main-message';\nimport { MainFreshness } from './main-freshness';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Blocks READS while the checked-out branch is a stale place to read from. TWO states:\n *\n * A. on `main`, and local main is BEHIND origin/main\n * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved\n * past it and a squash merge means its HEAD is not even an ancestor of main)\n *\n * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read. (feature-branch-guard\n * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the\n * two share one recovery message via MergedBranchMessage.)\n *\n * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to\n * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure,\n * `git pull --ff-only`, is not a fast-forward on a dirty tree — true, but that is a fact about the\n * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b <new>\n * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with\n * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty\n * tree, so the block no longer has to be suppressed to keep the printed cure runnable. State B's valve\n * never had an argument at all — its cure was always the branch form.\n *\n * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch.\n * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which\n * returns null when the branch IS main — the two guards are complementary, not stacked.)\n *\n * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash\n * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,\n * for a whole session, while the logs read \"read-stale-guard handled\". stale-main-bash-guard is the\n * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard\n * blocks — `main`, KNOWN BEHIND `origin/main` by the ancestry test below — it default-denies Bash and\n * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay\n * simple and Read-only.\n *\n * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data\n * is far worse than one that misses; every unknown resolves to \"allow\". Note the dual, which is what\n * the deleted dirty valve violated: never fail open on data you DID establish. A dirty tree is not an\n * unknown — it is a known state with a known cure. The three deliberate escape valves:\n *\n * 1. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 2. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 3. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'read-stale-guard', BRANCH_STATE_GUARD_KEY); }\n\n // The ancestry test and the cache summary, shared with stale-main-bash-guard — see main-freshness.ts.\n private readonly freshness = new MainFreshness();\n\n readonly description = 'Block reads on a branch that is stale to read from — a `main` behind origin/main, or a feature branch whose PR is already merged.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is stale to read from — reading it would give you pre-merge/out-of-date content.',\n 'Get onto current code before reading anything else:',\n [\n new Option('On main, behind origin/main → git pull origin main (CLEAN TREE ONLY), or git checkout -b <new-branch> origin/main which works with UNCOMMITTED CHANGES and brings them along. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main, which likewise carries your edits. Then retry the read.', true),\n new Option('If a checkout -b refuses because origin/main changed the same files you edited: git stash (never blocked), redo the checkout, then git stash pop.'),\n new Option(\"If that pull dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then retry the pull.\"),\n new Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for\n // BOTH states — the merged-branch signal comes out of that same cache.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so\n // the agent can always read-then-edit the file that turns this guard off.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n return branch === 'main'\n ? this.checkStaleMain(ctx, branch)\n : this.checkMergedBranch(ctx, branch);\n }\n\n // State A — on main, possibly behind origin/main.\n private checkStaleMain(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed `git pull` is\n // not a clean fast-forward on a dirty tree. That argument was about the MESSAGE, not the row:\n // row 6's cure cell has always offered `git checkout -b <new> origin/main` as an alternative,\n // and THAT works dirty — it carries uncommitted changes onto the new branch and lands you on\n // current code, which is the whole point. The message now leads with it when the tree is dirty\n // (StaleMainMessage.forReads), so the cure an agent reads is one it can actually run.\n // Residual, same as row 8: if origin/main touched the files you edited, git refuses the switch\n // — `git stash` is on the skip list and clears it. Two steps worst case, never a dead end.\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every\n * plan built from it is built on code origin/main has moved past.\n *\n * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state\n * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,\n * which is the fail-open direction for free.\n *\n * NO DIRTY-TREE VALVE. `git checkout -b <new> origin/main` carries uncommitted changes onto the\n * fresh branch, so the work comes with you and there is nothing to rescue by reading. When it does\n * NOT (an overlapping change landed in main, so git refuses the switch), `git stash` is on the L2\n * skip list and is never blocked: stash → branch → pop. The valve that used to sit here was drift\n * from the documented design, not a decision — this docblock described the strict behaviour for\n * releases while the code failed open.\n */\n private checkMergedBranch(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation. (A branch the refresh has\n // not seen yet is the `status === null` case above — still fail-open.)\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n // NO DIRTY VALVE — and this one never had an argument behind it at all. Row 8's cure is\n // `git fetch origin main && git checkout -b <new> origin/main`, which carries uncommitted work\n // with you, so a dirty tree traps nobody. The valve was code drift from the documented design;\n // read-stale-guard's own class comment said so while the code did the opposite.\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(\n ctx,\n branch,\n `already-merged PR#${pr}`,\n this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr),\n cache,\n );\n }\n\n // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree\n // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is\n // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a\n // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n const recovery = new TreeRecovery();\n return new MergedBranchMessage(workspaceRoot).forReads(\n branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n // StaleMainMessage's remaining consumer. It used to be shared with stale-main-bash-guard so the two\n // halves of the State-A block could never prescribe different cures; that guard now blocks on the\n // BRANCH (row 5) rather than on staleness and carries its own message, so this is the only caller.\n private staleMainMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return this.freshness.summarize(status);\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, ctx.relativePath, message + pointer)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
1
+ {"version":3,"file":"read-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/read-stale-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { StaleMainMessage } from './stale-main-message';\nimport { MainFreshness } from './main-freshness';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Blocks READS while the checked-out branch is a stale place to read from. TWO states:\n *\n * A. on `main`, and local main is BEHIND origin/main\n * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved\n * past it and a squash merge means its HEAD is not even an ancestor of main)\n *\n * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read. (feature-branch-guard\n * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the\n * two share one recovery message via MergedBranchMessage.)\n *\n * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to\n * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure,\n * an in-place pull, is not a fast-forward on a dirty tree — true, but that is a fact about the\n * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b <new>\n * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with\n * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty\n * tree, so the block no longer has to be suppressed to keep the printed cure runnable. State B's valve\n * never had an argument at all — its cure was always the branch form.\n *\n * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch.\n * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `pnpm wp-checkout-clean-main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (Every `wp-*` bin is on the L2 skip list, and the pull it wraps is explicitly permitted on main by\n * redirect-how-to-merge-main, which returns null when the branch IS main — the guards are\n * complementary, not stacked.)\n *\n * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash\n * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,\n * for a whole session, while the logs read \"read-stale-guard handled\". stale-main-bash-guard is the\n * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard\n * blocks — `main`, KNOWN BEHIND `origin/main` by the ancestry test below — it default-denies Bash and\n * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay\n * simple and Read-only.\n *\n * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data\n * is far worse than one that misses; every unknown resolves to \"allow\". Note the dual, which is what\n * the deleted dirty valve violated: never fail open on data you DID establish. A dirty tree is not an\n * unknown — it is a known state with a known cure. The three deliberate escape valves:\n *\n * 1. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 2. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 3. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'read-stale-guard', BRANCH_STATE_GUARD_KEY); }\n\n // The ancestry test and the cache summary, shared with stale-main-bash-guard — see main-freshness.ts.\n private readonly freshness = new MainFreshness();\n\n readonly description = 'Block reads on a branch that is stale to read from — a `main` behind origin/main, or a feature branch whose PR is already merged.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is stale to read from — reading it would give you pre-merge/out-of-date content.',\n 'Get onto current code before reading anything else:',\n [\n new Option('On main, behind origin/main → pnpm wp-checkout-clean-main (CLEAN TREE ONLY), or git checkout -b <new-branch> origin/main which works with UNCOMMITTED CHANGES and brings them along. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main, which likewise carries your edits. Then retry the read.', true),\n new Option('If a checkout -b refuses because origin/main changed the same files you edited: git stash (never blocked), redo the checkout, then git stash pop.'),\n new Option(\"If pnpm wp-checkout-clean-main dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then run it again.\"),\n new Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for\n // BOTH states — the merged-branch signal comes out of that same cache.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so\n // the agent can always read-then-edit the file that turns this guard off.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n return branch === 'main'\n ? this.checkStaleMain(ctx, branch)\n : this.checkMergedBranch(ctx, branch);\n }\n\n // State A — on main, possibly behind origin/main.\n private checkStaleMain(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed in-place pull\n // is not a clean fast-forward on a dirty tree. That argument was about the MESSAGE, not the row:\n // row 6's cure cell has always offered `git checkout -b <new> origin/main` as an alternative,\n // and THAT works dirty — it carries uncommitted changes onto the new branch and lands you on\n // current code, which is the whole point. The message now leads with it when the tree is dirty\n // (StaleMainMessage.forReads), so the cure an agent reads is one it can actually run.\n // Residual, same as row 8: if origin/main touched the files you edited, git refuses the switch\n // — `git stash` is on the skip list and clears it. Two steps worst case, never a dead end.\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every\n * plan built from it is built on code origin/main has moved past.\n *\n * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state\n * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,\n * which is the fail-open direction for free.\n *\n * NO DIRTY-TREE VALVE. `git checkout -b <new> origin/main` carries uncommitted changes onto the\n * fresh branch, so the work comes with you and there is nothing to rescue by reading. When it does\n * NOT (an overlapping change landed in main, so git refuses the switch), `git stash` is on the L2\n * skip list and is never blocked: stash → branch → pop. The valve that used to sit here was drift\n * from the documented design, not a decision — this docblock described the strict behaviour for\n * releases while the code failed open.\n */\n private checkMergedBranch(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation. (A branch the refresh has\n // not seen yet is the `status === null` case above — still fail-open.)\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n // NO DIRTY VALVE — and this one never had an argument behind it at all. Row 8's cure is\n // `git fetch origin main && git checkout -b <new> origin/main`, which carries uncommitted work\n // with you, so a dirty tree traps nobody. The valve was code drift from the documented design;\n // read-stale-guard's own class comment said so while the code did the opposite.\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(\n ctx,\n branch,\n `already-merged PR#${pr}`,\n this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr),\n cache,\n );\n }\n\n // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree\n // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is\n // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a\n // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n const recovery = new TreeRecovery();\n return new MergedBranchMessage(workspaceRoot).forReads(\n branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n // StaleMainMessage's remaining consumer. It used to be shared with stale-main-bash-guard so the two\n // halves of the State-A block could never prescribe different cures; that guard now blocks on the\n // BRANCH (row 5) rather than on staleness and carries its own message, so this is the only caller.\n private staleMainMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return this.freshness.summarize(status);\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, ctx.relativePath, message + pointer)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
@@ -50,7 +50,7 @@ class RecoveryAllowlist {
50
50
  * the empty command because its default is the other way round.)
51
51
  */
52
52
  isFullyRecovery(ctx) {
53
- const segments = this.scanner.segmentsWithPipes(ctx.command);
53
+ const segments = this.scanner.segmentsWithJoins(ctx.command);
54
54
  if (segments.length === 0)
55
55
  return false;
56
56
  const content = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);
@@ -1 +1 @@
1
- {"version":3,"file":"recovery-allowlist.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/recovery-allowlist.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport type { BashContext } from '../types';\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan, SegmentVerdict } from './shell-segment-scan';\nimport { ContentReadScan } from './content-read-scan';\n\n// ---------------------------------------------------------------------------\n// THE SKIP LIST — L2 row 4, as one implementation.\n//\n// \"These get you OUT, or tell you where you are.\" That is the whole principle: a command on this list\n// is not \"working here\", so it is safe in every state where working here is not.\n//\n// It was written for state B (a merged branch) and lived inside merged-branch-bash-guard. Row 5 —\n// on `main` — needs the identical question answered, and the answer must not be a second copy: two\n// skip lists drift, and the half that drifts is the half that wedges a session on its own cure. So the\n// list is ONE class, and both guards ask it.\n//\n// This is the DEFAULT-DENY half of L2. Its sibling ContentReadScan is the default-ALLOW blocklist used\n// where only stale CONTENT is the hazard. The guards docblock is right that the two polarities cannot\n// collapse into one function — but the allowlist itself was never the reason they could not, and\n// sharing it is what makes row 5's `B` half affordable at all.\n//\n// WHAT BELONGS ON THIS LIST — one question, asked of the COMMAND, not of your sympathy for it:\n// **does it read or write repo content?** If it cannot do either, it cannot be \"working here\", so it\n// belongs here regardless of what else it does. That is why `curl`/`wget` and `gh` are on it: they talk\n// to a network, not to the working tree, and a session parked in an L2 state still has to be able to\n// close a PR, comment on one, or fetch a URL. It is also why the exclusions are the FORMS that write a\n// local file (`curl -o`, `gh repo clone`, `gh pr checkout`, any `> file` redirect) rather than whole\n// programs: the write is the hazard, not the binary.\n//\n// EVERY segment must pass. One `… && scripts/local.sh start` in a chain denies the whole command,\n// because the chain runs it. Segments are judged by ROLE first: shell STRUCTURE (`for … in`, `do`,\n// `done`) invokes nothing and output SHAPING (`| tail -40`) cannot touch the repo, so neither may veto\n// a chain — judging the raw string instead is what once made the guard reject\n// `git fetch origin main 2>&1 | tail -5`, a command its own redirect had just prescribed.\n// ---------------------------------------------------------------------------\nexport class RecoveryAllowlist {\n private readonly scanner: CommandScanner;\n private readonly shell: ShellSegmentScan;\n\n constructor(scanner: CommandScanner) {\n this.scanner = scanner;\n this.shell = new ShellSegmentScan(scanner);\n }\n\n /**\n * Is EVERY segment of this command one that gets you out, or tells you where you are?\n *\n * An empty command is `false` — nothing to be sure about, and the default here is deny. (Note this\n * is one of the three places the two Bash guards genuinely differ; the stale-main blocklist allows\n * the empty command because its default is the other way round.)\n */\n isFullyRecovery(ctx: BashContext): boolean {\n const segments = this.scanner.segmentsWithPipes(ctx.command);\n if (segments.length === 0) return false;\n const content = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n return segments.every((segment: CommandSegment): boolean => this.isRecoverySegment(segment, content));\n }\n\n private isRecoverySegment(segment: CommandSegment, content: ContentReadScan): boolean {\n const verdict = this.shell.classify(segment);\n if (verdict.role === 'structure') return true;\n // Inert / piped-into filters are fine EXCEPT when they name a workspace path: `git status |\n // cat src/foo.ts` still hands the agent file content out of a tree it should not be reading.\n if (verdict.role === 'shaping') return content.readsStaleContent(segment) === null;\n\n // A read that names NOTHING in this tree cannot be affected by which branch this tree is on.\n // `ls -la ~/.claude/projects/ | grep -i foo` was blocked as \"this branch is merged\" — the\n // command touches no repo at all. Only CONTENT READERS qualify, so a build, a server or a git\n // write never slips through on the strength of its paths.\n if (content.readsOnlyOutsideContent(segment)) return true;\n\n // A segment that reads CONTENT out of this tree is never \"getting you out\", whatever command\n // it is spelled as. This must come BEFORE the git allowlist, because `show` and `grep` are on\n // that list for their metadata/upstream forms and would otherwise wave through the two git\n // spellings of a local content read: `git show HEAD:package.json` and `git grep TODO`. Their\n // upstream forms (`git show origin/main:…`, `git grep TODO origin/main`) read the CURRENT tree\n // and ContentReadScan already returns null for those, so they stay allowed — which is the\n // point, since reading upstream is exactly what a blocked agent should be doing.\n if (content.readsStaleContent(segment) !== null) return false;\n\n const gitSub = this.scanner.gitSubcommandOf(verdict.words);\n if (gitSub !== null) return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);\n if (this.isGh(verdict)) return true;\n if (this.isNetworkClient(verdict)) return true;\n return this.isPackageRecovery(verdict);\n }\n\n /**\n * `gh` GENERALLY — it talks to GitHub, not to the working tree.\n *\n * This used to be an allowlist of read-only actions (`gh pr view|list|status|checks`, `gh run\n * view`), which fell over the moment an agent needed `gh pr close`, `gh pr comment` or `gh api`\n * from a parked session: those change something on GitHub and NOTHING in this tree, so the branch\n * state cannot be an argument against them. The exclusions are therefore the `gh` subcommands that\n * write LOCAL files — a clone, a checkout, a download — plus any segment carrying a `> file`\n * redirect.\n *\n * gh commands that are wrong for OTHER reasons stay wrong: `gh pr create`/`push` is governed by\n * pr-creation-or-push-guard and `gh pr merge` by pr-merge-guard. Those are separate policies and\n * this list was never what enforced them.\n *\n * YES, THIS ONE SUBLIST IS A BLOCKLIST, and that is the opposite polarity to the guard around it.\n * The argument against a blocklist elsewhere is that the hazardous set is UNBOUNDED — any program\n * can write a file as a side effect of doing something else, so no enumeration could ever be\n * complete. `gh`'s surface is not: it is one vendor's CLI, its verbs are documented, and writing\n * into the working tree is the rare exception rather than the ambient default. A new `gh`\n * subcommand that clones or downloads is a known, greppable maintenance point — `GH_LOCAL_FILE_WRITES`\n * — where \"every future program that might write\" is not.\n */\n private isGh(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || path.basename(words[0]) !== 'gh') return false;\n if (this.shell.redirectsToFile(words)) return false;\n const top = words[1];\n if (top === undefined) return false;\n const action = words[2];\n const localWrites = GH_LOCAL_FILE_WRITES.get(top);\n if (localWrites === undefined) return true;\n return action === undefined || !localWrites.has(action);\n }\n\n /**\n * `curl` / `wget` — a network fetch reads a URL, not this repo.\n *\n * Excluded: the forms that name a local FILE to write (`curl -o`, `curl -O`, `wget -O`, an\n * `--output-dir`/`-P`, or a `> file` redirect), because those are how a fetch becomes a write into\n * the tree. A bare `wget <url>` still drops its download into the cwd; that CREATES an untracked\n * file rather than modifying tracked content, which is the line this list draws everywhere else.\n */\n private isNetworkClient(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || !NETWORK_CLIENTS.has(path.basename(words[0]))) return false;\n if (this.shell.redirectsToFile(words)) return false;\n return !words.some((word: string): boolean => OUTPUT_FILE_FLAGS.has(word));\n }\n\n // pnpm/npm/yarn recovery bins: the `wp-*` cleanup/gated commands and package installs (a chained\n // install that isInstallerCommand — the pure-install bypass — did not catch reaches here).\n private isPackageRecovery(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || !PACKAGE_MANAGERS.has(words[0])) return false;\n return words.slice(1).some((word: string): boolean =>\n /^wp-[a-z-]+$/.test(word) || PACKAGE_INSTALL_VERBS.has(word));\n }\n}\n\nconst ALLOWED_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([\n 'status', 'log', 'diff', 'show', 'branch', 'checkout', 'switch', 'worktree', 'fetch', 'pull',\n 'rev-parse', 'rev-list', 'merge-base', 'ls-files', 'ls-tree', 'cat-file', 'for-each-ref',\n 'symbolic-ref', 'describe', 'name-rev', 'reflog', 'shortlog', 'remote', 'config', 'stash', 'tag',\n 'blame', 'whatchanged', 'cherry',\n // `grep` is here ONLY for its upstream form (`git grep TODO origin/main`), which reads the CURRENT\n // tree and is what a blocked agent should be reaching for. The local form is rejected one check\n // earlier by ContentReadScan, as is `show <local-rev>:<path>`.\n 'grep',\n]);\n\n// The `gh` subcommands that write LOCAL files — the only ones the branch state has anything to say\n// about. Everything else `gh` does happens on GitHub's side. (`gh pr create` / `gh pr merge` are absent\n// on purpose: they are remote calls, and their policies live in pr-creation-or-push-guard and\n// pr-merge-guard, which run whatever this list says.)\nconst GH_LOCAL_FILE_WRITES: ReadonlyMap<string, ReadonlySet<string>> = new Map([\n ['repo', new Set(['clone', 'fork', 'sync'])],\n ['pr', new Set(['checkout'])],\n ['run', new Set(['download'])],\n ['release', new Set(['download'])],\n ['gist', new Set(['clone'])],\n ['codespace', new Set(['cp'])],\n ['attestation', new Set(['download'])],\n]);\n\n// Network clients: they read a URL, not the tree.\nconst NETWORK_CLIENTS: ReadonlySet<string> = new Set(['curl', 'wget']);\n// The flags that turn a fetch into a local file write. `-O` is curl's remote-name AND wget's\n// output-document; both write, so one entry covers both.\nconst OUTPUT_FILE_FLAGS: ReadonlySet<string> = new Set([\n '-o', '--output', '-O', '--remote-name', '--remote-name-all', '--output-dir', '--create-dirs',\n '--output-document', '-P', '--directory-prefix',\n]);\n\nconst PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'npx', 'pnpx', 'yarn']);\nconst PACKAGE_INSTALL_VERBS: ReadonlySet<string> = new Set(['install', 'ci', 'add', 'i']);\n"]}
1
+ {"version":3,"file":"recovery-allowlist.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/recovery-allowlist.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport type { BashContext } from '../types';\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan, SegmentVerdict } from './shell-segment-scan';\nimport { ContentReadScan } from './content-read-scan';\n\n// ---------------------------------------------------------------------------\n// THE SKIP LIST — L2 row 4, as one implementation.\n//\n// \"These get you OUT, or tell you where you are.\" That is the whole principle: a command on this list\n// is not \"working here\", so it is safe in every state where working here is not.\n//\n// It was written for state B (a merged branch) and lived inside merged-branch-bash-guard. Row 5 —\n// on `main` — needs the identical question answered, and the answer must not be a second copy: two\n// skip lists drift, and the half that drifts is the half that wedges a session on its own cure. So the\n// list is ONE class, and both guards ask it.\n//\n// This is the DEFAULT-DENY half of L2. Its sibling ContentReadScan is the default-ALLOW blocklist used\n// where only stale CONTENT is the hazard. The guards docblock is right that the two polarities cannot\n// collapse into one function — but the allowlist itself was never the reason they could not, and\n// sharing it is what makes row 5's `B` half affordable at all.\n//\n// WHAT BELONGS ON THIS LIST — one question, asked of the COMMAND, not of your sympathy for it:\n// **does it read or write repo content?** If it cannot do either, it cannot be \"working here\", so it\n// belongs here regardless of what else it does. That is why `curl`/`wget` and `gh` are on it: they talk\n// to a network, not to the working tree, and a session parked in an L2 state still has to be able to\n// close a PR, comment on one, or fetch a URL. It is also why the exclusions are the FORMS that write a\n// local file (`curl -o`, `gh repo clone`, `gh pr checkout`, any `> file` redirect) rather than whole\n// programs: the write is the hazard, not the binary.\n//\n// EVERY segment must pass. One `… && scripts/local.sh start` in a chain denies the whole command,\n// because the chain runs it. Segments are judged by ROLE first: shell STRUCTURE (`for … in`, `do`,\n// `done`) invokes nothing and output SHAPING (`| tail -40`) cannot touch the repo, so neither may veto\n// a chain — judging the raw string instead is what once made the guard reject\n// `git fetch origin main 2>&1 | tail -5`, a command its own redirect had just prescribed.\n// ---------------------------------------------------------------------------\nexport class RecoveryAllowlist {\n private readonly scanner: CommandScanner;\n private readonly shell: ShellSegmentScan;\n\n constructor(scanner: CommandScanner) {\n this.scanner = scanner;\n this.shell = new ShellSegmentScan(scanner);\n }\n\n /**\n * Is EVERY segment of this command one that gets you out, or tells you where you are?\n *\n * An empty command is `false` — nothing to be sure about, and the default here is deny. (Note this\n * is one of the three places the two Bash guards genuinely differ; the stale-main blocklist allows\n * the empty command because its default is the other way round.)\n */\n isFullyRecovery(ctx: BashContext): boolean {\n const segments = this.scanner.segmentsWithJoins(ctx.command);\n if (segments.length === 0) return false;\n const content = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n return segments.every((segment: CommandSegment): boolean => this.isRecoverySegment(segment, content));\n }\n\n private isRecoverySegment(segment: CommandSegment, content: ContentReadScan): boolean {\n const verdict = this.shell.classify(segment);\n if (verdict.role === 'structure') return true;\n // Inert / piped-into filters are fine EXCEPT when they name a workspace path: `git status |\n // cat src/foo.ts` still hands the agent file content out of a tree it should not be reading.\n if (verdict.role === 'shaping') return content.readsStaleContent(segment) === null;\n\n // A read that names NOTHING in this tree cannot be affected by which branch this tree is on.\n // `ls -la ~/.claude/projects/ | grep -i foo` was blocked as \"this branch is merged\" — the\n // command touches no repo at all. Only CONTENT READERS qualify, so a build, a server or a git\n // write never slips through on the strength of its paths.\n if (content.readsOnlyOutsideContent(segment)) return true;\n\n // A segment that reads CONTENT out of this tree is never \"getting you out\", whatever command\n // it is spelled as. This must come BEFORE the git allowlist, because `show` and `grep` are on\n // that list for their metadata/upstream forms and would otherwise wave through the two git\n // spellings of a local content read: `git show HEAD:package.json` and `git grep TODO`. Their\n // upstream forms (`git show origin/main:…`, `git grep TODO origin/main`) read the CURRENT tree\n // and ContentReadScan already returns null for those, so they stay allowed — which is the\n // point, since reading upstream is exactly what a blocked agent should be doing.\n if (content.readsStaleContent(segment) !== null) return false;\n\n const gitSub = this.scanner.gitSubcommandOf(verdict.words);\n if (gitSub !== null) return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);\n if (this.isGh(verdict)) return true;\n if (this.isNetworkClient(verdict)) return true;\n return this.isPackageRecovery(verdict);\n }\n\n /**\n * `gh` GENERALLY — it talks to GitHub, not to the working tree.\n *\n * This used to be an allowlist of read-only actions (`gh pr view|list|status|checks`, `gh run\n * view`), which fell over the moment an agent needed `gh pr close`, `gh pr comment` or `gh api`\n * from a parked session: those change something on GitHub and NOTHING in this tree, so the branch\n * state cannot be an argument against them. The exclusions are therefore the `gh` subcommands that\n * write LOCAL files — a clone, a checkout, a download — plus any segment carrying a `> file`\n * redirect.\n *\n * gh commands that are wrong for OTHER reasons stay wrong: `gh pr create`/`push` is governed by\n * pr-creation-or-push-guard and `gh pr merge` by pr-merge-guard. Those are separate policies and\n * this list was never what enforced them.\n *\n * YES, THIS ONE SUBLIST IS A BLOCKLIST, and that is the opposite polarity to the guard around it.\n * The argument against a blocklist elsewhere is that the hazardous set is UNBOUNDED — any program\n * can write a file as a side effect of doing something else, so no enumeration could ever be\n * complete. `gh`'s surface is not: it is one vendor's CLI, its verbs are documented, and writing\n * into the working tree is the rare exception rather than the ambient default. A new `gh`\n * subcommand that clones or downloads is a known, greppable maintenance point — `GH_LOCAL_FILE_WRITES`\n * — where \"every future program that might write\" is not.\n */\n private isGh(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || path.basename(words[0]) !== 'gh') return false;\n if (this.shell.redirectsToFile(words)) return false;\n const top = words[1];\n if (top === undefined) return false;\n const action = words[2];\n const localWrites = GH_LOCAL_FILE_WRITES.get(top);\n if (localWrites === undefined) return true;\n return action === undefined || !localWrites.has(action);\n }\n\n /**\n * `curl` / `wget` — a network fetch reads a URL, not this repo.\n *\n * Excluded: the forms that name a local FILE to write (`curl -o`, `curl -O`, `wget -O`, an\n * `--output-dir`/`-P`, or a `> file` redirect), because those are how a fetch becomes a write into\n * the tree. A bare `wget <url>` still drops its download into the cwd; that CREATES an untracked\n * file rather than modifying tracked content, which is the line this list draws everywhere else.\n */\n private isNetworkClient(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || !NETWORK_CLIENTS.has(path.basename(words[0]))) return false;\n if (this.shell.redirectsToFile(words)) return false;\n return !words.some((word: string): boolean => OUTPUT_FILE_FLAGS.has(word));\n }\n\n // pnpm/npm/yarn recovery bins: the `wp-*` cleanup/gated commands and package installs (a chained\n // install that isInstallerCommand — the pure-install bypass — did not catch reaches here).\n private isPackageRecovery(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || !PACKAGE_MANAGERS.has(words[0])) return false;\n return words.slice(1).some((word: string): boolean =>\n /^wp-[a-z-]+$/.test(word) || PACKAGE_INSTALL_VERBS.has(word));\n }\n}\n\nconst ALLOWED_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([\n 'status', 'log', 'diff', 'show', 'branch', 'checkout', 'switch', 'worktree', 'fetch', 'pull',\n 'rev-parse', 'rev-list', 'merge-base', 'ls-files', 'ls-tree', 'cat-file', 'for-each-ref',\n 'symbolic-ref', 'describe', 'name-rev', 'reflog', 'shortlog', 'remote', 'config', 'stash', 'tag',\n 'blame', 'whatchanged', 'cherry',\n // `grep` is here ONLY for its upstream form (`git grep TODO origin/main`), which reads the CURRENT\n // tree and is what a blocked agent should be reaching for. The local form is rejected one check\n // earlier by ContentReadScan, as is `show <local-rev>:<path>`.\n 'grep',\n]);\n\n// The `gh` subcommands that write LOCAL files — the only ones the branch state has anything to say\n// about. Everything else `gh` does happens on GitHub's side. (`gh pr create` / `gh pr merge` are absent\n// on purpose: they are remote calls, and their policies live in pr-creation-or-push-guard and\n// pr-merge-guard, which run whatever this list says.)\nconst GH_LOCAL_FILE_WRITES: ReadonlyMap<string, ReadonlySet<string>> = new Map([\n ['repo', new Set(['clone', 'fork', 'sync'])],\n ['pr', new Set(['checkout'])],\n ['run', new Set(['download'])],\n ['release', new Set(['download'])],\n ['gist', new Set(['clone'])],\n ['codespace', new Set(['cp'])],\n ['attestation', new Set(['download'])],\n]);\n\n// Network clients: they read a URL, not the tree.\nconst NETWORK_CLIENTS: ReadonlySet<string> = new Set(['curl', 'wget']);\n// The flags that turn a fetch into a local file write. `-O` is curl's remote-name AND wget's\n// output-document; both write, so one entry covers both.\nconst OUTPUT_FILE_FLAGS: ReadonlySet<string> = new Set([\n '-o', '--output', '-O', '--remote-name', '--remote-name-all', '--output-dir', '--create-dirs',\n '--output-document', '-P', '--directory-prefix',\n]);\n\nconst PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'npx', 'pnpx', 'yarn']);\nconst PACKAGE_INSTALL_VERBS: ReadonlySet<string> = new Set(['install', 'ci', 'add', 'i']);\n"]}
@@ -32,7 +32,7 @@ class ShellSegmentScan {
32
32
  return new SegmentVerdict('command', words);
33
33
  if (ALWAYS_INERT.has(head))
34
34
  return new SegmentVerdict('shaping', words);
35
- if (segment.pipedInto && OUTPUT_FILTERS.has(head))
35
+ if (segment.join === '|' && OUTPUT_FILTERS.has(head))
36
36
  return new SegmentVerdict('shaping', words);
37
37
  return new SegmentVerdict('command', words);
38
38
  }
@@ -1 +1 @@
1
- {"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.pipedInto && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n /**\n * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n *\n * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the\n * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but\n * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what\n * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy\n * gets wrong.\n */\n redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
1
+ {"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.join === '|' && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n /**\n * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n *\n * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the\n * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but\n * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what\n * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy\n * gets wrong.\n */\n redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
@@ -90,6 +90,13 @@ import { FixHint } from '../fix-hint';
90
90
  * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this
91
91
  * tree), `curl`/`wget`, every `wp-*` bin, installs.
92
92
  *
93
+ * ── ROWS 12/13: the cure may be COMPOSED with the work, but only with `&&` ───────────────────────
94
+ *
95
+ * `pnpm wp-checkout-clean-main && cat src/app.ts` is allowed and `pnpm wp-checkout-clean-main ; cat
96
+ * src/app.ts` is not, and the difference is the shell's rather than this guard's: `&&` short-circuits,
97
+ * so the work cannot run when the cure failed — the exact property the block is here to guarantee. `;`
98
+ * discards the exit code and runs the work anyway. See cure-prefix-scan.ts for the measured shapes.
99
+ *
93
100
  * There is no dirty-tree valve here and none in read-stale-guard either: the cure is
94
101
  * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
95
102
  * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
@@ -101,6 +108,7 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
101
108
  private readonly switches;
102
109
  private readonly recoveryList;
103
110
  private readonly freshness;
111
+ private readonly curePrefix;
104
112
  readonly description: string;
105
113
  readonly defaultOptions: {
106
114
  hangTimeoutMinutes: number;
@@ -120,6 +128,21 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
120
128
  * `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.
121
129
  */
122
130
  private checkFreshness;
131
+ /**
132
+ * ROWS 12/13 — `<cure> && <work>` is allowed; `<cure> ; <work>` is not.
133
+ *
134
+ * The distinction is the shell's, not this guard's invention. `&&` short-circuits, so the work
135
+ * cannot run when the cure exits non-zero — which is precisely the property the block exists to
136
+ * guarantee, already enforced by the interpreter. Refusing it bought nothing and cost a round
137
+ * trip, and the fleet audit files that as a TOOLING defect.
138
+ *
139
+ * `;` discards the exit code and runs the work regardless, and it was measured with
140
+ * `>/dev/null 2>&1` on the cure in 7 of 9 observed cases — so the failure was invisible as well as
141
+ * ignored. The two-step is genuinely safer there: the NEXT tool call is a fresh evaluation that
142
+ * recomputes `localMain` against `originMain`, so a pull that failed re-blocks. An allowed `;`
143
+ * compound never gets that second look.
144
+ */
145
+ private judgeComposition;
123
146
  /**
124
147
  * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same
125
148
  * command, or null. The pull is looked for across the WHOLE command, not the matched segment,
@@ -149,6 +172,11 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
149
172
  * command is as likely to write as to read.)
150
173
  */
151
174
  private staleMainMessage;
175
+ /**
176
+ * ROW 13's deny. It NAMES the operator the agent typed, because the fix is a one-character edit
177
+ * and an agent told only "use `&&`" has to diff the two spellings itself to find where.
178
+ */
179
+ private compositionMessage;
152
180
  private pairingMessage;
153
181
  /**
154
182
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.
@@ -17,6 +17,7 @@ const tree_recovery_1 = require("./tree-recovery");
17
17
  const branch_switch_scan_1 = require("./branch-switch-scan");
18
18
  const recovery_allowlist_1 = require("./recovery-allowlist");
19
19
  const main_freshness_1 = require("./main-freshness");
20
+ const cure_prefix_scan_1 = require("./cure-prefix-scan");
20
21
  /**
21
22
  * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:
22
23
  * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that
@@ -105,6 +106,13 @@ const main_freshness_1 = require("./main-freshness");
105
106
  * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this
106
107
  * tree), `curl`/`wget`, every `wp-*` bin, installs.
107
108
  *
109
+ * ── ROWS 12/13: the cure may be COMPOSED with the work, but only with `&&` ───────────────────────
110
+ *
111
+ * `pnpm wp-checkout-clean-main && cat src/app.ts` is allowed and `pnpm wp-checkout-clean-main ; cat
112
+ * src/app.ts` is not, and the difference is the shell's rather than this guard's: `&&` short-circuits,
113
+ * so the work cannot run when the cure failed — the exact property the block is here to guarantee. `;`
114
+ * discards the exit code and runs the work anyway. See cure-prefix-scan.ts for the measured shapes.
115
+ *
108
116
  * There is no dirty-tree valve here and none in read-stale-guard either: the cure is
109
117
  * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
110
118
  * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
@@ -119,6 +127,8 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
119
127
  recoveryList = new recovery_allowlist_1.RecoveryAllowlist(this.scanner);
120
128
  // The ancestry test and the cache summary, shared with read-stale-guard — see main-freshness.ts.
121
129
  freshness = new main_freshness_1.MainFreshness();
130
+ // ROWS 12/13 — `<cure> && <work>` vs `<cure> ; <work>`. See cure-prefix-scan.ts.
131
+ curePrefix = new cure_prefix_scan_1.CurePrefixScan(this.scanner);
122
132
  description = 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +
123
133
  'the same command), and — once local main is KNOWN to be behind origin/main — block Bash ' +
124
134
  'there, allowlisting only the commands that get you off it. A main that is current, or whose ' +
@@ -137,7 +147,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
137
147
  // hint that cannot look.
138
148
  new rules_config_1.Option(this.recovery.updateMainSteps('unknown').join('\n')
139
149
  + '\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),
140
- new rules_config_1.Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD has a duplicate line run git fetch --prune origin main first.'),
150
+ new rules_config_1.Option('Already on main: pnpm wp-checkout-clean-main (then re-run) — it pulls main and takes the trash out in the one command this repo prescribes. You may chain your command onto it with && (pnpm wp-checkout-clean-main && <your command>), which is skipped if the pull fails; a ; instead runs your command anyway and is refused.'),
141
151
  new rules_config_1.Option('Still allowed: every BASH command, on a main that is current or whose freshness is not known — this guard only closes once local main is known to be BEHIND origin/main. (Write/Edit on main is a different policy and is blocked by feature-branch-guard however current main is.) In that state you keep the Read tool while main is current (read-stale-guard closes it when main falls behind, because stale reads are worthless) plus everything that gets you OUT or tells you where you are: git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs, and reading webpieces.config.json.'),
142
152
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
143
153
  ]);
@@ -196,6 +206,30 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
196
206
  return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
197
207
  }
198
208
  // ESTABLISHED BEHIND. Now — and only now — the default-deny polarity applies.
209
+ return this.judgeComposition(ctx, branch, cache);
210
+ }
211
+ /**
212
+ * ROWS 12/13 — `<cure> && <work>` is allowed; `<cure> ; <work>` is not.
213
+ *
214
+ * The distinction is the shell's, not this guard's invention. `&&` short-circuits, so the work
215
+ * cannot run when the cure exits non-zero — which is precisely the property the block exists to
216
+ * guarantee, already enforced by the interpreter. Refusing it bought nothing and cost a round
217
+ * trip, and the fleet audit files that as a TOOLING defect.
218
+ *
219
+ * `;` discards the exit code and runs the work regardless, and it was measured with
220
+ * `>/dev/null 2>&1` on the cure in 7 of 9 observed cases — so the failure was invisible as well as
221
+ * ignored. The two-step is genuinely safer there: the NEXT tool call is a fresh evaluation that
222
+ * recomputes `localMain` against `originMain`, so a pull that failed re-blocks. An allowed `;`
223
+ * compound never gets that second look.
224
+ */
225
+ judgeComposition(ctx, branch, cache) {
226
+ const prefix = this.curePrefix.classify(ctx.command);
227
+ if (prefix.kind === 'short-circuits') {
228
+ return this.allow(ctx, branch, 'cure-prefixed, && short-circuits the work', cache);
229
+ }
230
+ if (prefix.kind === 'runs-anyway') {
231
+ return this.block(ctx, branch, 'cure-prefixed, work runs anyway', this.compositionMessage(prefix), cache);
232
+ }
199
233
  return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);
200
234
  }
201
235
  /**
@@ -247,6 +281,16 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
247
281
  + 'your reads true as well as moving you off main.\n'
248
282
  + `Start a branch (uncommitted work comes with you):\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;
249
283
  }
284
+ /**
285
+ * ROW 13's deny. It NAMES the operator the agent typed, because the fix is a one-character edit
286
+ * and an agent told only "use `&&`" has to diff the two spellings itself to find where.
287
+ */
288
+ compositionMessage(prefix) {
289
+ return `Your cure is joined with \`${prefix.operator}\` — the work runs even if the pull fails. `
290
+ + 'Use `&&` so it is skipped:\n'
291
+ + '\n pnpm wp-checkout-clean-main && <your command>\n\n'
292
+ + 'Or run the cure alone and re-issue your command in the next call.';
293
+ }
250
294
  pairingMessage(ctx) {
251
295
  const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\n');
252
296
  // Deliberately SHORT. The incident that bought this guard (a main 157 commits behind; the