@webpieces/ai-hook-rules 0.4.684 → 0.4.686

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/l2-doc.js +35 -21
  3. package/src/core/l2-doc.js.map +1 -1
  4. package/src/core/l2-rows.d.ts +8 -5
  5. package/src/core/l2-rows.js +26 -17
  6. package/src/core/l2-rows.js.map +1 -1
  7. package/src/core/rules/feature-branch-guard.d.ts +21 -1
  8. package/src/core/rules/feature-branch-guard.js +21 -1
  9. package/src/core/rules/feature-branch-guard.js.map +1 -1
  10. package/src/core/rules/main-freshness.d.ts +35 -0
  11. package/src/core/rules/main-freshness.js +53 -0
  12. package/src/core/rules/main-freshness.js.map +1 -0
  13. package/src/core/rules/merged-branch-bash-guard.js +1 -1
  14. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  15. package/src/core/rules/merged-branch-message.js +2 -1
  16. package/src/core/rules/merged-branch-message.js.map +1 -1
  17. package/src/core/rules/read-stale-guard.d.ts +5 -3
  18. package/src/core/rules/read-stale-guard.js +10 -24
  19. package/src/core/rules/read-stale-guard.js.map +1 -1
  20. package/src/core/rules/recovery-allowlist.d.ts +32 -1
  21. package/src/core/rules/recovery-allowlist.js +79 -21
  22. package/src/core/rules/recovery-allowlist.js.map +1 -1
  23. package/src/core/rules/shell-segment-scan.d.ts +10 -1
  24. package/src/core/rules/shell-segment-scan.js +9 -1
  25. package/src/core/rules/shell-segment-scan.js.map +1 -1
  26. package/src/core/rules/stale-main-bash-guard.d.ts +73 -47
  27. package/src/core/rules/stale-main-bash-guard.js +109 -83
  28. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  29. package/src/core/rules/stale-main-message.d.ts +4 -3
  30. package/src/core/rules/stale-main-message.js +4 -3
  31. package/src/core/rules/stale-main-message.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"merged-branch-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-message.ts"],"names":[],"mappings":";;;AAAA,mDAAyD;AAEzD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAa,mBAAmB;IASC;IARZ,QAAQ,CAAe;IAExC;;;;;OAKG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;QAC9C,IAAI,CAAC,QAAQ,GAAG,IAAI,4BAAY,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED;;;;;;;OAOG;IACK,UAAU,CAAC,IAAc;QAC7B,MAAM,SAAS,GAAG,IAAI,KAAK,UAAU;YACjC,CAAC,CAAC,8FAA8F;gBAC9F,4EAA4E;YAC9E,CAAC,CAAC,+EAA+E;gBAC/E,gFAAgF;gBAChF,iFAAiF;gBACjF,+BAA+B,CAAC;QACtC,OAAO;YACH,6FAA6F;YAC7F,kDAAkD;YAClD,4GAA4G;YAC5G,SAAS;YACT,yHAAyH;YACzH,4FAA4F;YAC5F,4FAA4F;YAC5F,EAAE;YACF,8FAA8F;YAC9F,4FAA4F;YAC5F,qDAAqD;SACxD,CAAC;IACN,CAAC;IAED,4FAA4F;IACpF,MAAM,CAAC,MAAc,EAAE,QAAgB,EAAE,IAAc,EAAE,YAAoB;QACjF,MAAM,EAAE,GAAG,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,gBAAgB,QAAQ,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,MAAM,KAAK,GAAG,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;QAC1D,MAAM,KAAK,GAAG;YACV,6CAA6C,KAAK,KAAK,MAAM,yCAAyC,EAAE,GAAG;YAC3G,4FAA4F;YAC5F,wFAAwF;YACxF,4FAA4F;YAC5F,oFAAoF;YACpF,GAAG,CAAC,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,sBAAsB,IAAI,CAAC,QAAQ,aAAa,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5F,kFAAkF;YAClF,EAAE;YACF,GAAG,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,EAAE,sBAAsB,CAAC;SACjE,CAAC;QAEF,yFAAyF;QACzF,4FAA4F;QAC5F,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,+BAA+B,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACzH,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,QAAQ,CAAC,MAAc,EAAE,QAAgB,EAAE,OAAiB,SAAS,EAAE,eAAuB,gBAAgB;QAC1G,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,MAAM,CAAC;YAC5D,EAAE;YACF,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YACxB,EAAE;YACF,yFAAyF;SAC5F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;IAED;;;;;;OAMG;IACH,OAAO,CAAC,MAAc,EAAE,QAAgB,EAAE,OAAiB,SAAS,EAAE,eAAuB,gBAAgB;QACzG,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,MAAM,CAAC;YAC5D,EAAE;YACF,2FAA2F;YAC3F,8FAA8F;YAC9F,EAAE;YACF,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YACxB,EAAE;YACF,yFAAyF;SAC5F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;IAED;;;;OAIG;IACH,QAAQ,CAAC,MAAc,EAAE,QAAgB,EAAE,OAAiB,SAAS,EAAE,eAAuB,gBAAgB;QAC1G,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,MAAM,CAAC;YAC5D,EAAE;YACF,wFAAwF;YACxF,wFAAwF;YACxF,EAAE;YACF,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YACxB,EAAE;YACF,yFAAyF;SAC5F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;CACJ;AAhHD,kDAgHC","sourcesContent":["import { TreeRecovery, TreeKind } from './tree-recovery';\n\n/**\n * The \"this branch is already merged, start fresh\" text, shared by the TWO guards that detect the\n * state from the same cached signal (`MainSyncStatus.branchAlreadyMerged`):\n *\n * - feature-branch-guard blocks Write/Edit → {@link MergedBranchMessage.forEdits}\n * - read-stale-guard blocks Read → {@link MergedBranchMessage.forReads}\n *\n * One source of truth on purpose: the recovery steps are instructions the AI follows LITERALLY, so\n * two drifting copies would mean two different behaviors for the same repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree\n * we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove\n * this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.\n *\n * ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —\n * main is checked out in the primary clone). In the primary clone it is a perfectly good move and the\n * allowance list below says so explicitly. The two used to disagree inside a single message — the\n * header forbade it while the allowance list permitted \"git checkout <other-branch>\", and `main` is an\n * other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded\n * its only exit was creating a branch, which the branch cap then refused.\n */\nexport class MergedBranchMessage {\n private readonly recovery: TreeRecovery;\n\n /**\n * `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as\n * `cd <treeRoot> && …`. That form is the only one that is correct across tool calls: the harness\n * RESETS a cwd that left the workspace, so an agent in a linked worktree is back in the primary\n * clone by the time it runs the cure, and a bare `git checkout -b` would branch the WRONG tree.\n */\n constructor(private readonly treeRoot: string = '') {\n this.recovery = new TreeRecovery(treeRoot);\n }\n\n /**\n * The ONE allowance list, shared by every guard that blocks while this state is up.\n *\n * Each guard used to print its own view of the world: this one's narrow bash allowlist, and\n * read-stale-guard's \"EVERY Bash command\". Both statements were true of their own guard and false\n * of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all\n * Bash runs and that most Bash is blocked. One list, printed by both.\n */\n private allowances(kind: TreeKind): string[] {\n const switching = kind === 'worktree'\n ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +\n 'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'\n : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +\n '`pnpm wp-checkout-clean-main` (checkout main, pull it, reap dead branches and ' +\n 'worktrees, sweep orphan directories) is allowed and is the shortest exit; also ' +\n 'git worktree add/remove/prune';\n return [\n 'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',\n ' - the fresh-start / cleanup git commands above',\n ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',\n switching,\n ' - pnpm wp-checkout-clean-main, pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',\n ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',\n ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',\n '',\n 'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',\n '(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',\n 'One list — all three guards print exactly this one.',\n ];\n }\n\n // The diagnosis + cure. Identical for both guards — this is the part that must never drift.\n private common(branch: string, mergedPr: string, kind: TreeKind, worktreePath: string): string[] {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n const where = kind === 'worktree' ? 'worktree' : 'branch';\n const lines = [\n `It looks like you forgot to clean up this ${where} \"${branch}\" — its PR is already merged into main${pr}.`,\n // Name the tree that was judged. With several agents running in parallel worktrees, a guard\n // that reasons from the shell cwd can block a command while citing an UNRELATED agent's\n // branch — observed live. Printing the directory makes a wrong judgement visible instead of\n // baffling, and lets the reader see immediately that it is not the tree they meant.\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),\n 'Your work is in main — do NOT keep working here (you will reconflict with main).',\n '',\n ...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),\n ];\n\n // Only when we KNOW we are in a dead worktree: the branch cure alone leaves the worktree\n // sitting there, spending the worktree budget (branch-creation-guard.maxWorktrees) forever.\n if (kind === 'worktree') {\n lines.push('', 'Then reap this dead worktree:', ...this.recovery.cleanupSteps(kind, branch, worktreePath).slice(-1));\n }\n return lines;\n }\n\n forEdits(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message\n * has to spell out the narrow allowlist — otherwise an agent reads \"blocked\" and believes it is\n * wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the\n * `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit\n * the guard again.\n */\n forBash(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Bash is blocked here because working on a merged branch (booting servers, running builds,',\n 'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Read variant. Says WHY a read (not an edit) is blocked — reading this branch feeds the AI a\n * pre-merge snapshot of the codebase and every plan built on it is built on code main has already\n * moved past — and spells out the escape valves so the agent never believes it is stuck.\n */\n forReads(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',\n 'stale relative to origin/main, and a plan built on it is built on code that has moved.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n}\n"]}
1
+ {"version":3,"file":"merged-branch-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-message.ts"],"names":[],"mappings":"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","sourcesContent":["import { TreeRecovery, TreeKind } from './tree-recovery';\n\n/**\n * The \"this branch is already merged, start fresh\" text, shared by the TWO guards that detect the\n * state from the same cached signal (`MainSyncStatus.branchAlreadyMerged`):\n *\n * - feature-branch-guard blocks Write/Edit → {@link MergedBranchMessage.forEdits}\n * - read-stale-guard blocks Read → {@link MergedBranchMessage.forReads}\n *\n * One source of truth on purpose: the recovery steps are instructions the AI follows LITERALLY, so\n * two drifting copies would mean two different behaviors for the same repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree\n * we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove\n * this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.\n *\n * ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —\n * main is checked out in the primary clone). In the primary clone it is a perfectly good move and the\n * allowance list below says so explicitly. The two used to disagree inside a single message — the\n * header forbade it while the allowance list permitted \"git checkout <other-branch>\", and `main` is an\n * other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded\n * its only exit was creating a branch, which the branch cap then refused.\n */\nexport class MergedBranchMessage {\n private readonly recovery: TreeRecovery;\n\n /**\n * `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as\n * `cd <treeRoot> && …`. That form is the only one that is correct across tool calls: the harness\n * RESETS a cwd that left the workspace, so an agent in a linked worktree is back in the primary\n * clone by the time it runs the cure, and a bare `git checkout -b` would branch the WRONG tree.\n */\n constructor(private readonly treeRoot: string = '') {\n this.recovery = new TreeRecovery(treeRoot);\n }\n\n /**\n * The ONE allowance list, shared by every guard that blocks while this state is up.\n *\n * Each guard used to print its own view of the world: this one's narrow bash allowlist, and\n * read-stale-guard's \"EVERY Bash command\". Both statements were true of their own guard and false\n * of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all\n * Bash runs and that most Bash is blocked. One list, printed by both.\n */\n private allowances(kind: TreeKind): string[] {\n const switching = kind === 'worktree'\n ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +\n 'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'\n : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +\n '`pnpm wp-checkout-clean-main` (checkout main, pull it, reap dead branches and ' +\n 'worktrees, sweep orphan directories) is allowed and is the shortest exit; also ' +\n 'git worktree add/remove/prune';\n return [\n 'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',\n ' - the fresh-start / cleanup git commands above',\n ' - read-only orientation: git status|log|diff|show|branch',\n ' - gh GENERALLY (pr view, pr close, pr comment, api, run watch — it talks to GitHub, not to this tree), and curl/wget; NOT gh repo clone / pr checkout / run download, curl -o, or any `> file`, which write here',\n switching,\n ' - pnpm wp-checkout-clean-main, pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',\n ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',\n ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',\n '',\n 'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',\n '(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',\n 'One list — all three guards print exactly this one.',\n ];\n }\n\n // The diagnosis + cure. Identical for both guards — this is the part that must never drift.\n private common(branch: string, mergedPr: string, kind: TreeKind, worktreePath: string): string[] {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n const where = kind === 'worktree' ? 'worktree' : 'branch';\n const lines = [\n `It looks like you forgot to clean up this ${where} \"${branch}\" — its PR is already merged into main${pr}.`,\n // Name the tree that was judged. With several agents running in parallel worktrees, a guard\n // that reasons from the shell cwd can block a command while citing an UNRELATED agent's\n // branch — observed live. Printing the directory makes a wrong judgement visible instead of\n // baffling, and lets the reader see immediately that it is not the tree they meant.\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),\n 'Your work is in main — do NOT keep working here (you will reconflict with main).',\n '',\n ...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),\n ];\n\n // Only when we KNOW we are in a dead worktree: the branch cure alone leaves the worktree\n // sitting there, spending the worktree budget (branch-creation-guard.maxWorktrees) forever.\n if (kind === 'worktree') {\n lines.push('', 'Then reap this dead worktree:', ...this.recovery.cleanupSteps(kind, branch, worktreePath).slice(-1));\n }\n return lines;\n }\n\n forEdits(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message\n * has to spell out the narrow allowlist — otherwise an agent reads \"blocked\" and believes it is\n * wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the\n * `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit\n * the guard again.\n */\n forBash(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Bash is blocked here because working on a merged branch (booting servers, running builds,',\n 'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Read variant. Says WHY a read (not an edit) is blocked — reading this branch feeds the AI a\n * pre-merge snapshot of the codebase and every plan built on it is built on code main has already\n * moved past — and spells out the escape valves so the agent never believes it is stuck.\n */\n forReads(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',\n 'stale relative to origin/main, and a plan built on it is built on code that has moved.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n}\n"]}
@@ -36,8 +36,10 @@ import { FixHint } from '../fix-hint';
36
36
  * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash
37
37
  * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,
38
38
  * for a whole session, while the logs read "read-stale-guard handled". stale-main-bash-guard is the
39
- * State-A Bash counterpart (as merged-branch-bash-guard is State B's) and blocks only CONTENT-reading
40
- * commands, never the cure which is why this guard can stay simple and Read-only.
39
+ * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard
40
+ * blocks `main`, KNOWN BEHIND `origin/main` by the ancestry test below it default-denies Bash and
41
+ * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay
42
+ * simple and Read-only.
41
43
  *
42
44
  * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data
43
45
  * is far worse than one that misses; every unknown resolves to "allow". Note the dual, which is what
@@ -62,6 +64,7 @@ import { FixHint } from '../fix-hint';
62
64
  */
63
65
  export declare class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardConfig> {
64
66
  constructor(config: BranchStateGuardConfig);
67
+ private readonly freshness;
65
68
  readonly description = "Block reads on a branch that is stale to read from \u2014 a `main` behind origin/main, or a feature branch whose PR is already merged.";
66
69
  readonly files: string[];
67
70
  readonly defaultOptions: {
@@ -87,7 +90,6 @@ export declare class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardCon
87
90
  */
88
91
  private checkMergedBranch;
89
92
  private mergedMessage;
90
- private contains;
91
93
  private isConfigFile;
92
94
  private behindCount;
93
95
  private staleMainMessage;
@@ -17,6 +17,7 @@ const l2_matrix_doc_1 = require("../l2-matrix-doc");
17
17
  const l0_fault_codes_1 = require("../l0-fault-codes");
18
18
  const merged_branch_message_1 = require("./merged-branch-message");
19
19
  const stale_main_message_1 = require("./stale-main-message");
20
+ const main_freshness_1 = require("./main-freshness");
20
21
  const tree_recovery_1 = require("./tree-recovery");
21
22
  /**
22
23
  * Blocks READS while the checked-out branch is a stale place to read from. TWO states:
@@ -52,8 +53,10 @@ const tree_recovery_1 = require("./tree-recovery");
52
53
  * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash
53
54
  * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,
54
55
  * for a whole session, while the logs read "read-stale-guard handled". stale-main-bash-guard is the
55
- * State-A Bash counterpart (as merged-branch-bash-guard is State B's) and blocks only CONTENT-reading
56
- * commands, never the cure which is why this guard can stay simple and Read-only.
56
+ * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard
57
+ * blocks `main`, KNOWN BEHIND `origin/main` by the ancestry test below it default-denies Bash and
58
+ * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay
59
+ * simple and Read-only.
57
60
  *
58
61
  * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data
59
62
  * is far worse than one that misses; every unknown resolves to "allow". Note the dual, which is what
@@ -78,6 +81,8 @@ const tree_recovery_1 = require("./tree-recovery");
78
81
  */
79
82
  class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
80
83
  constructor(config) { super(config, 'read-stale-guard', rules_config_1.BRANCH_STATE_GUARD_KEY); }
84
+ // The ancestry test and the cache summary, shared with stale-main-bash-guard — see main-freshness.ts.
85
+ freshness = new main_freshness_1.MainFreshness();
81
86
  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.';
82
87
  files = ['**/*'];
83
88
  defaultOptions = {
@@ -87,7 +92,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
87
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),
88
93
  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.'),
89
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."),
90
- new rules_config_1.Option('Still allowed right now: Bash that does not read repo files (installs, upgrades, builds, tests, the pull itself, git/gh metadata), all Write/Edit, and reading webpieces.config.json. Content-reading Bash (cat/grep/ls/…) is blocked too on a stale main see stale-main-bash-guard.'),
95
+ 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.'),
91
96
  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.'),
92
97
  ]);
93
98
  check(ctx) {
@@ -123,7 +128,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
123
128
  if (status.originMain === '')
124
129
  return this.failOpen(ctx, branch, 'origin-main-unknown', cache);
125
130
  // Escape valve 2 — ancestry, NOT equality. See the class comment.
126
- if (this.contains(ctx.workspaceRoot, status.originMain)) {
131
+ if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {
127
132
  return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
128
133
  }
129
134
  // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed `git pull` is
@@ -187,24 +192,6 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
187
192
  const recovery = new tree_recovery_1.TreeRecovery();
188
193
  return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forReads(branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot);
189
194
  }
190
- // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.
191
- //
192
- // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three
193
- // outcomes apart: 0 = ancestor (up to date), 1 = cleanly NOT an ancestor (genuinely behind),
194
- // anything else = git could not answer (bad/pruned object, not a repo) which must fail OPEN.
195
- // execSync collapses 1 and "git broke" into the same thrown Error, so it cannot make that call.
196
- // Arg-array form also means the commit hash is never parsed by a shell.
197
- contains(workspaceRoot, commit) {
198
- const result = (0, child_process_1.spawnSync)('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {
199
- cwd: workspaceRoot,
200
- encoding: 'utf8',
201
- });
202
- if (result.status === 0)
203
- return true;
204
- if (result.status === 1)
205
- return false;
206
- return true; // unknown/failed → treat as "contained" so the guard allows
207
- }
208
195
  isConfigFile(relativePath) {
209
196
  return relativePath === 'webpieces.config.json';
210
197
  }
@@ -232,8 +219,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
232
219
  return new stale_main_message_1.StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));
233
220
  }
234
221
  cacheSummary(status) {
235
- const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';
236
- return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} merged=${merged} ts=${status.timestamp}`;
222
+ return this.freshness.summarize(status);
237
223
  }
238
224
  /**
239
225
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.
@@ -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, spawnSync } 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 { 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) and blocks only CONTENT-reading\n * commands, never the cure — which is why this guard can stay 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 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: Bash that does not read repo files (installs, upgrades, builds, tests, the pull itself, git/gh metadata), all Write/Edit, and reading webpieces.config.json. Content-reading Bash (cat/grep/ls/…) is blocked too on a stale main — see stale-main-bash-guard.'),\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.contains(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 // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.\n //\n // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three\n // outcomes apart: 0 = ancestor (up to date), 1 = cleanly NOT an ancestor (genuinely behind),\n // anything else = git could not answer (bad/pruned object, not a repo) which must fail OPEN.\n // execSync collapses 1 and \"git broke\" into the same thrown Error, so it cannot make that call.\n // Arg-array form also means the commit hash is never parsed by a shell.\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true; // unknown/failed → treat as \"contained\" so the guard allows\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 const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} merged=${merged} ts=${status.timestamp}`;\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 * `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"]}
@@ -13,6 +13,37 @@ export declare class RecoveryAllowlist {
13
13
  */
14
14
  isFullyRecovery(ctx: BashContext): boolean;
15
15
  private isRecoverySegment;
16
- private isGhInspection;
16
+ /**
17
+ * `gh` GENERALLY — it talks to GitHub, not to the working tree.
18
+ *
19
+ * This used to be an allowlist of read-only actions (`gh pr view|list|status|checks`, `gh run
20
+ * view`), which fell over the moment an agent needed `gh pr close`, `gh pr comment` or `gh api`
21
+ * from a parked session: those change something on GitHub and NOTHING in this tree, so the branch
22
+ * state cannot be an argument against them. The exclusions are therefore the `gh` subcommands that
23
+ * write LOCAL files — a clone, a checkout, a download — plus any segment carrying a `> file`
24
+ * redirect.
25
+ *
26
+ * gh commands that are wrong for OTHER reasons stay wrong: `gh pr create`/`push` is governed by
27
+ * pr-creation-or-push-guard and `gh pr merge` by pr-merge-guard. Those are separate policies and
28
+ * this list was never what enforced them.
29
+ *
30
+ * YES, THIS ONE SUBLIST IS A BLOCKLIST, and that is the opposite polarity to the guard around it.
31
+ * The argument against a blocklist elsewhere is that the hazardous set is UNBOUNDED — any program
32
+ * can write a file as a side effect of doing something else, so no enumeration could ever be
33
+ * complete. `gh`'s surface is not: it is one vendor's CLI, its verbs are documented, and writing
34
+ * into the working tree is the rare exception rather than the ambient default. A new `gh`
35
+ * subcommand that clones or downloads is a known, greppable maintenance point — `GH_LOCAL_FILE_WRITES`
36
+ * — where "every future program that might write" is not.
37
+ */
38
+ private isGh;
39
+ /**
40
+ * `curl` / `wget` — a network fetch reads a URL, not this repo.
41
+ *
42
+ * Excluded: the forms that name a local FILE to write (`curl -o`, `curl -O`, `wget -O`, an
43
+ * `--output-dir`/`-P`, or a `> file` redirect), because those are how a fetch becomes a write into
44
+ * the tree. A bare `wget <url>` still drops its download into the cwd; that CREATES an untracked
45
+ * file rather than modifying tracked content, which is the line this list draws everywhere else.
46
+ */
47
+ private isNetworkClient;
17
48
  private isPackageRecovery;
18
49
  }
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RecoveryAllowlist = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
4
6
  const shell_segment_scan_1 = require("./shell-segment-scan");
5
7
  const content_read_scan_1 = require("./content-read-scan");
6
8
  // ---------------------------------------------------------------------------
@@ -19,6 +21,14 @@ const content_read_scan_1 = require("./content-read-scan");
19
21
  // collapse into one function — but the allowlist itself was never the reason they could not, and
20
22
  // sharing it is what makes row 5's `B` half affordable at all.
21
23
  //
24
+ // WHAT BELONGS ON THIS LIST — one question, asked of the COMMAND, not of your sympathy for it:
25
+ // **does it read or write repo content?** If it cannot do either, it cannot be "working here", so it
26
+ // belongs here regardless of what else it does. That is why `curl`/`wget` and `gh` are on it: they talk
27
+ // to a network, not to the working tree, and a session parked in an L2 state still has to be able to
28
+ // close a PR, comment on one, or fetch a URL. It is also why the exclusions are the FORMS that write a
29
+ // local file (`curl -o`, `gh repo clone`, `gh pr checkout`, any `> file` redirect) rather than whole
30
+ // programs: the write is the hazard, not the binary.
31
+ //
22
32
  // EVERY segment must pass. One `… && scripts/local.sh start` in a chain denies the whole command,
23
33
  // because the chain runs it. Segments are judged by ROLE first: shell STRUCTURE (`for … in`, `do`,
24
34
  // `done`) invokes nothing and output SHAPING (`| tail -40`) cannot touch the repo, so neither may veto
@@ -72,26 +82,64 @@ class RecoveryAllowlist {
72
82
  const gitSub = this.scanner.gitSubcommandOf(verdict.words);
73
83
  if (gitSub !== null)
74
84
  return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);
75
- if (this.isGhInspection(verdict))
85
+ if (this.isGh(verdict))
86
+ return true;
87
+ if (this.isNetworkClient(verdict))
76
88
  return true;
77
89
  return this.isPackageRecovery(verdict);
78
90
  }
79
- // Read-only / status `gh` invocations used for orientation, INCLUDING `gh run view|list|watch` —
80
- // watching CI is precisely what you do while parked. gh writes (pr create/merge, run
81
- // cancel/rerun, api POSTs) are governed by pr-creation-or-push-guard / pr-merge-guard and are NOT
82
- // allowlisted here.
83
- isGhInspection(verdict) {
91
+ /**
92
+ * `gh` GENERALLY it talks to GitHub, not to the working tree.
93
+ *
94
+ * This used to be an allowlist of read-only actions (`gh pr view|list|status|checks`, `gh run
95
+ * view`), which fell over the moment an agent needed `gh pr close`, `gh pr comment` or `gh api`
96
+ * from a parked session: those change something on GitHub and NOTHING in this tree, so the branch
97
+ * state cannot be an argument against them. The exclusions are therefore the `gh` subcommands that
98
+ * write LOCAL files — a clone, a checkout, a download — plus any segment carrying a `> file`
99
+ * redirect.
100
+ *
101
+ * gh commands that are wrong for OTHER reasons stay wrong: `gh pr create`/`push` is governed by
102
+ * pr-creation-or-push-guard and `gh pr merge` by pr-merge-guard. Those are separate policies and
103
+ * this list was never what enforced them.
104
+ *
105
+ * YES, THIS ONE SUBLIST IS A BLOCKLIST, and that is the opposite polarity to the guard around it.
106
+ * The argument against a blocklist elsewhere is that the hazardous set is UNBOUNDED — any program
107
+ * can write a file as a side effect of doing something else, so no enumeration could ever be
108
+ * complete. `gh`'s surface is not: it is one vendor's CLI, its verbs are documented, and writing
109
+ * into the working tree is the rare exception rather than the ambient default. A new `gh`
110
+ * subcommand that clones or downloads is a known, greppable maintenance point — `GH_LOCAL_FILE_WRITES`
111
+ * — where "every future program that might write" is not.
112
+ */
113
+ isGh(verdict) {
84
114
  const words = verdict.words;
85
- if (words.length === 0 || words[0] !== 'gh')
115
+ if (words.length === 0 || path.basename(words[0]) !== 'gh')
116
+ return false;
117
+ if (this.shell.redirectsToFile(words))
86
118
  return false;
87
119
  const top = words[1];
88
120
  if (top === undefined)
89
121
  return false;
90
122
  const action = words[2];
91
- const readActions = GH_READ_ACTIONS.get(top);
92
- if (readActions !== undefined)
93
- return action !== undefined && readActions.has(action);
94
- return GH_READ_TOPLEVEL.has(top);
123
+ const localWrites = GH_LOCAL_FILE_WRITES.get(top);
124
+ if (localWrites === undefined)
125
+ return true;
126
+ return action === undefined || !localWrites.has(action);
127
+ }
128
+ /**
129
+ * `curl` / `wget` — a network fetch reads a URL, not this repo.
130
+ *
131
+ * Excluded: the forms that name a local FILE to write (`curl -o`, `curl -O`, `wget -O`, an
132
+ * `--output-dir`/`-P`, or a `> file` redirect), because those are how a fetch becomes a write into
133
+ * the tree. A bare `wget <url>` still drops its download into the cwd; that CREATES an untracked
134
+ * file rather than modifying tracked content, which is the line this list draws everywhere else.
135
+ */
136
+ isNetworkClient(verdict) {
137
+ const words = verdict.words;
138
+ if (words.length === 0 || !NETWORK_CLIENTS.has(path.basename(words[0])))
139
+ return false;
140
+ if (this.shell.redirectsToFile(words))
141
+ return false;
142
+ return !words.some((word) => OUTPUT_FILE_FLAGS.has(word));
95
143
  }
96
144
  // pnpm/npm/yarn recovery bins: the `wp-*` cleanup/gated commands and package installs (a chained
97
145
  // install that isInstallerCommand — the pure-install bypass — did not catch reaches here).
@@ -113,17 +161,27 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([
113
161
  // earlier by ContentReadScan, as is `show <local-rev>:<path>`.
114
162
  'grep',
115
163
  ]);
116
- // Read-only actions per `gh` topic. `run` is here because `gh run view <id>` was blocked outright in
117
- // the field while `gh pr view` beside it succeeded both are read-only, and CI watching is the normal
118
- // thing to do while parked. The WRITE actions of the same topics (pr create/merge/close, run
119
- // cancel/rerun/delete) are simply absent, so they still fall through to the block.
120
- const GH_READ_ACTIONS = new Map([
121
- ['pr', new Set(['list', 'view', 'status', 'checks', 'diff'])],
122
- ['run', new Set(['list', 'view', 'watch'])],
123
- ['issue', new Set(['list', 'view', 'status'])],
164
+ // The `gh` subcommands that write LOCAL files the only ones the branch state has anything to say
165
+ // about. Everything else `gh` does happens on GitHub's side. (`gh pr create` / `gh pr merge` are absent
166
+ // on purpose: they are remote calls, and their policies live in pr-creation-or-push-guard and
167
+ // pr-merge-guard, which run whatever this list says.)
168
+ const GH_LOCAL_FILE_WRITES = new Map([
169
+ ['repo', new Set(['clone', 'fork', 'sync'])],
170
+ ['pr', new Set(['checkout'])],
171
+ ['run', new Set(['download'])],
172
+ ['release', new Set(['download'])],
173
+ ['gist', new Set(['clone'])],
174
+ ['codespace', new Set(['cp'])],
175
+ ['attestation', new Set(['download'])],
176
+ ]);
177
+ // Network clients: they read a URL, not the tree.
178
+ const NETWORK_CLIENTS = new Set(['curl', 'wget']);
179
+ // The flags that turn a fetch into a local file write. `-O` is curl's remote-name AND wget's
180
+ // output-document; both write, so one entry covers both.
181
+ const OUTPUT_FILE_FLAGS = new Set([
182
+ '-o', '--output', '-O', '--remote-name', '--remote-name-all', '--output-dir', '--create-dirs',
183
+ '--output-document', '-P', '--directory-prefix',
124
184
  ]);
125
- // Read-only top-level `gh` commands.
126
- const GH_READ_TOPLEVEL = new Set(['status', 'auth', 'browse', 'repo', 'search']);
127
185
  const PACKAGE_MANAGERS = new Set(['pnpm', 'npm', 'npx', 'pnpx', 'yarn']);
128
186
  const PACKAGE_INSTALL_VERBS = new Set(['install', 'ci', 'add', 'i']);
129
187
  //# sourceMappingURL=recovery-allowlist.js.map
@@ -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 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// 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.isGhInspection(verdict)) return true;\n return this.isPackageRecovery(verdict);\n }\n\n // Read-only / status `gh` invocations used for orientation, INCLUDING `gh run view|list|watch` —\n // watching CI is precisely what you do while parked. gh writes (pr create/merge, run\n // cancel/rerun, api POSTs) are governed by pr-creation-or-push-guard / pr-merge-guard and are NOT\n // allowlisted here.\n private isGhInspection(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || words[0] !== 'gh') return false;\n const top = words[1];\n if (top === undefined) return false;\n const action = words[2];\n const readActions = GH_READ_ACTIONS.get(top);\n if (readActions !== undefined) return action !== undefined && readActions.has(action);\n return GH_READ_TOPLEVEL.has(top);\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// Read-only actions per `gh` topic. `run` is here because `gh run view <id>` was blocked outright in\n// the field while `gh pr view` beside it succeeded — both are read-only, and CI watching is the normal\n// thing to do while parked. The WRITE actions of the same topics (pr create/merge/close, run\n// cancel/rerun/delete) are simply absent, so they still fall through to the block.\nconst GH_READ_ACTIONS: ReadonlyMap<string, ReadonlySet<string>> = new Map([\n ['pr', new Set(['list', 'view', 'status', 'checks', 'diff'])],\n ['run', new Set(['list', 'view', 'watch'])],\n ['issue', new Set(['list', 'view', 'status'])],\n]);\n// Read-only top-level `gh` commands.\nconst GH_READ_TOPLEVEL: ReadonlySet<string> = new Set(['status', 'auth', 'browse', 'repo', 'search']);\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.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"]}
@@ -51,5 +51,14 @@ export declare class ShellSegmentScan {
51
51
  */
52
52
  effectiveWords(segmentText: string): readonly string[];
53
53
  private stripKeywords;
54
- private redirectsToFile;
54
+ /**
55
+ * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.
56
+ *
57
+ * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the
58
+ * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but
59
+ * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what
60
+ * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy
61
+ * gets wrong.
62
+ */
63
+ redirectsToFile(words: readonly string[]): boolean;
55
64
  }
@@ -51,7 +51,15 @@ class ShellSegmentScan {
51
51
  i++;
52
52
  return words.slice(i);
53
53
  }
54
- // `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.
54
+ /**
55
+ * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.
56
+ *
57
+ * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the
58
+ * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but
59
+ * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what
60
+ * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy
61
+ * gets wrong.
62
+ */
55
63
  redirectsToFile(words) {
56
64
  return words.some((word) => REDIRECT_TO_FILE.test(word));
57
65
  }