@webpieces/ai-hook-rules 0.4.509 → 0.4.511

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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/bin/shim.d.ts +2 -0
  3. package/src/bin/shim.js +46 -19
  4. package/src/bin/shim.js.map +1 -1
  5. package/src/core/build-context.d.ts +2 -1
  6. package/src/core/build-context.js +5 -2
  7. package/src/core/build-context.js.map +1 -1
  8. package/src/core/effective-tree.d.ts +75 -0
  9. package/src/core/effective-tree.js +136 -0
  10. package/src/core/effective-tree.js.map +1 -0
  11. package/src/core/read-only-inspection.d.ts +34 -0
  12. package/src/core/read-only-inspection.js +88 -0
  13. package/src/core/read-only-inspection.js.map +1 -0
  14. package/src/core/rules/content-read-scan.d.ts +29 -4
  15. package/src/core/rules/content-read-scan.js +54 -9
  16. package/src/core/rules/content-read-scan.js.map +1 -1
  17. package/src/core/rules/feature-branch-guard.js +1 -1
  18. package/src/core/rules/feature-branch-guard.js.map +1 -1
  19. package/src/core/rules/merged-branch-bash-guard.js +8 -2
  20. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  21. package/src/core/rules/merged-branch-message.d.ts +8 -0
  22. package/src/core/rules/merged-branch-message.js +17 -1
  23. package/src/core/rules/merged-branch-message.js.map +1 -1
  24. package/src/core/rules/read-stale-guard.js +2 -2
  25. package/src/core/rules/read-stale-guard.js.map +1 -1
  26. package/src/core/rules/stale-main-bash-guard.js +2 -2
  27. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  28. package/src/core/rules/stale-main-message.d.ts +12 -0
  29. package/src/core/rules/stale-main-message.js +17 -1
  30. package/src/core/rules/stale-main-message.js.map +1 -1
  31. package/src/core/rules/tree-recovery.d.ts +16 -0
  32. package/src/core/rules/tree-recovery.js +28 -7
  33. package/src/core/rules/tree-recovery.js.map +1 -1
  34. package/src/core/runner.js +92 -78
  35. package/src/core/runner.js.map +1 -1
  36. package/src/core/types.d.ts +12 -1
  37. package/src/core/types.js +14 -1
  38. package/src/core/types.js.map +1 -1
  39. package/templates/ai-hook.sh +5 -5
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EffectiveTreeResolver = exports.EffectiveTree = void 0;
4
+ exports.atRoot = atRoot;
5
+ const tslib_1 = require("tslib");
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const child_process_1 = require("child_process");
8
+ const rules_config_1 = require("@webpieces/rules-config");
9
+ const command_scan_1 = require("./command-scan");
10
+ const shell_segment_scan_1 = require("./rules/shell-segment-scan");
11
+ /** Data-only (per CLAUDE.md, classes for data). */
12
+ class EffectiveTree {
13
+ /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */
14
+ shellCwd;
15
+ /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */
16
+ effectiveCwd;
17
+ /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */
18
+ root;
19
+ /** The root that owns webpieces.config.json — where config and excludePaths come from. */
20
+ governedRoot;
21
+ kind;
22
+ /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
23
+ redirected;
24
+ constructor(shellCwd, effectiveCwd, root, governedRoot, kind) {
25
+ this.shellCwd = shellCwd;
26
+ this.effectiveCwd = effectiveCwd;
27
+ this.root = root;
28
+ this.governedRoot = governedRoot;
29
+ this.kind = kind;
30
+ this.redirected = path.resolve(root) !== path.resolve(shellCwd);
31
+ }
32
+ }
33
+ exports.EffectiveTree = EffectiveTree;
34
+ class EffectiveTreeResolver {
35
+ scanner;
36
+ worktrees = new rules_config_1.WorktreeService();
37
+ shell;
38
+ constructor(scanner = new command_scan_1.CommandScanner()) {
39
+ this.scanner = scanner;
40
+ this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
41
+ }
42
+ resolve(command, shellCwd, governedRoot) {
43
+ const effectiveCwd = this.effectiveCwd(command, shellCwd);
44
+ const kindAndRoot = this.classify(effectiveCwd, governedRoot);
45
+ return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);
46
+ }
47
+ /**
48
+ * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
49
+ *
50
+ * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,
51
+ * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing
52
+ * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the
53
+ * guards. `cd a && cd b && git …` resolves left to right, matching the shell.
54
+ *
55
+ * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the
56
+ * guards handle it: `echo "cd sub && git push"` is ONE opaque segment whose first word is `echo`,
57
+ * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.
58
+ */
59
+ effectiveCwd(command, shellCwd) {
60
+ let effective = shellCwd;
61
+ for (const segment of this.scanner.commandSegments(command)) {
62
+ const words = this.shell.effectiveWords(segment);
63
+ if (words[0] !== 'cd' && words[0] !== 'pushd')
64
+ break;
65
+ if (words[1] !== undefined)
66
+ effective = path.resolve(effective, words[1]);
67
+ }
68
+ return effective;
69
+ }
70
+ classify(effectiveCwd, governedRoot) {
71
+ // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and
72
+ // the nested-clone check keeps its exact previous behaviour.
73
+ if (this.isInside(effectiveCwd, governedRoot)) {
74
+ const gitRoot = gitToplevel(effectiveCwd);
75
+ if (gitRoot === null)
76
+ return new TreeClassification('primary', governedRoot);
77
+ if (path.resolve(gitRoot) === path.resolve(governedRoot))
78
+ return new TreeClassification('primary', governedRoot);
79
+ return new TreeClassification('foreign', gitRoot);
80
+ }
81
+ // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a
82
+ // worktree nested under another resolves to the innermost one.
83
+ const owner = this.owningWorktree(effectiveCwd, governedRoot);
84
+ if (owner !== null) {
85
+ return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);
86
+ }
87
+ const gitRoot = gitToplevel(effectiveCwd);
88
+ if (gitRoot === null)
89
+ return new TreeClassification('outside', governedRoot);
90
+ if (path.resolve(gitRoot) === path.resolve(governedRoot))
91
+ return new TreeClassification('primary', governedRoot);
92
+ return new TreeClassification('foreign', gitRoot);
93
+ }
94
+ owningWorktree(dir, governedRoot) {
95
+ let best = null;
96
+ for (const tree of this.worktrees.listWorktrees(governedRoot)) {
97
+ if (!this.isInside(dir, tree.path))
98
+ continue;
99
+ if (best === null || tree.path.length > best.path.length)
100
+ best = tree;
101
+ }
102
+ return best;
103
+ }
104
+ /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
105
+ isInside(dir, root) {
106
+ const relative = path.relative(path.resolve(root), path.resolve(dir));
107
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
108
+ }
109
+ }
110
+ exports.EffectiveTreeResolver = EffectiveTreeResolver;
111
+ /** Data-only carrier for the two values classify() decides together. */
112
+ class TreeClassification {
113
+ kind;
114
+ root;
115
+ constructor(kind, root) {
116
+ this.kind = kind;
117
+ this.root = root;
118
+ }
119
+ }
120
+ /** The steering prefix every remedy needs: `cd` does not persist between tool calls, so a bare
121
+ * remedy runs in whatever directory the NEXT call starts in — which is never the tree we judged. */
122
+ // webpieces-disable no-function-outside-class -- one-line path/string formatter shared by the guards' message builders; a class around it would be ceremony
123
+ function atRoot(root, command) {
124
+ return `cd ${root} && ${command}`;
125
+ }
126
+ // The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`
127
+ // IS the expected "not a repo" answer (spawnSync does not throw on a non-zero exit), so no try/catch.
128
+ // webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers
129
+ function gitToplevel(dir) {
130
+ const r = (0, child_process_1.spawnSync)('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
131
+ if (r.status !== 0)
132
+ return null;
133
+ const root = (r.stdout ?? '').trim();
134
+ return root !== '' ? root : null;
135
+ }
136
+ //# sourceMappingURL=effective-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { WorktreeService, Worktree } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: an agent's Bash tool does NOT persist `cd` between calls (verified —\n * a standalone `cd <worktree>` followed by `pwd` in the next call reports the primary clone again).\n * So an agent working in a linked worktree writes self-contained `cd <worktree> && …` commands, and\n * the shell cwd the PreToolUse hook is handed is ALWAYS the primary clone. Every guard that reasons\n * from that cwd judges the wrong tree on every single call. Three field sightings in one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. If that sits under the governed root, the tree is the governed root unless git says the\n * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`) → foreign.\n * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —\n * it is the same project, just another checkout — so guards run, keyed on THAT tree's branch and\n * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel\n * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.\n * 4. Anything else that is a git repo → foreign (out of scope, hands-off, as before).\n * 5. Not a git repo at all (`cd /tmp && …`) → OUTSIDE. The guards still run — an absolute path back\n * into the repo must still be judged — but nothing the command names relative to `/tmp` is\n * workspace content, which is what ContentReadScan uses `effectiveCwd` for.\n */\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly worktrees = new WorktreeService();\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and\n // the nested-clone check keeps its exact previous behaviour.\n if (this.isInside(effectiveCwd, governedRoot)) {\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('primary', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a\n // worktree nested under another resolves to the innermost one.\n const owner = this.owningWorktree(effectiveCwd, governedRoot);\n if (owner !== null) {\n return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);\n }\n\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('outside', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n private owningWorktree(dir: string, governedRoot: string): Worktree | null {\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(governedRoot)) {\n if (!this.isInside(dir, tree.path)) continue;\n if (best === null || tree.path.length > best.path.length) best = tree;\n }\n return best;\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/** The steering prefix every remedy needs: `cd` does not persist between tool calls, so a bare\n * remedy runs in whatever directory the NEXT call starts in — which is never the tree we judged. */\n// webpieces-disable no-function-outside-class -- one-line path/string formatter shared by the guards' message builders; a class around it would be ceremony\nexport function atRoot(root: string, command: string): string {\n return `cd ${root} && ${command}`;\n}\n\n// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`\n// IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit), so no try/catch.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers\nfunction gitToplevel(dir: string): string | null {\n const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n if (r.status !== 0) return null;\n const root = (r.stdout ?? '').trim();\n return root !== '' ? root : null;\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import { CommandScanner } from './command-scan';
2
+ /**
3
+ * Is this shell command pure INSPECTION — can it be trusted to change nothing?
4
+ *
5
+ * WHY this exists: when webpieces.config.json cannot be loaded (unparseable, or invalid against the
6
+ * installed validator) every bash command is denied, because with no config there are no guards and
7
+ * fail-closed is the only safe answer. That is right for work — and catastrophic for recovery, because
8
+ * the denial also took out `cat`, `grep` and `sed -n` against webpieces.config.json itself. The guard
9
+ * blocked the exact tools needed to see and fix the problem it was reporting, which is a dead end an
10
+ * agent cannot escape from inside the session. (Reproduced live: mid-merge, the config legitimately
11
+ * held `<<<<<<< HEAD` markers and every attempt to look at it was refused.)
12
+ *
13
+ * The rest of the system already models the escape hatch correctly — reading AND editing
14
+ * webpieces.config.json is always allowed (hook-core's config bypass, the stale-shim recovery
15
+ * carve-out, ContentReadScan's escape-hatch paths). This closes the one place that did not honour it.
16
+ *
17
+ * The bar is deliberately paranoid, because this is a bypass of ALL guards:
18
+ * - every invoked segment's command word must be an allowlisted inspector;
19
+ * - `git`/`gh` are excluded outright, even their read-only subcommands — the guards exist to police
20
+ * git, and "read-only git" is not a line worth drawing while flying blind;
21
+ * - any output redirect to a file (`> x`, `>> x`) makes the command a writer;
22
+ * - the in-place/mutating flags of otherwise-read-only tools (`sed -i`, `find -delete`) are refused.
23
+ * Anything not provably inert stays blocked.
24
+ */
25
+ export declare class ReadOnlyInspectionScan {
26
+ private readonly scanner;
27
+ private readonly shell;
28
+ constructor(scanner?: CommandScanner);
29
+ /** True only when EVERY segment of the command is provably inert. Empty command → false. */
30
+ isReadOnlyInspection(command: string): boolean;
31
+ private segmentIsInert;
32
+ private redirectsToFile;
33
+ private hasMutatingFlag;
34
+ }
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReadOnlyInspectionScan = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
6
+ const command_scan_1 = require("./command-scan");
7
+ const shell_segment_scan_1 = require("./rules/shell-segment-scan");
8
+ /**
9
+ * Is this shell command pure INSPECTION — can it be trusted to change nothing?
10
+ *
11
+ * WHY this exists: when webpieces.config.json cannot be loaded (unparseable, or invalid against the
12
+ * installed validator) every bash command is denied, because with no config there are no guards and
13
+ * fail-closed is the only safe answer. That is right for work — and catastrophic for recovery, because
14
+ * the denial also took out `cat`, `grep` and `sed -n` against webpieces.config.json itself. The guard
15
+ * blocked the exact tools needed to see and fix the problem it was reporting, which is a dead end an
16
+ * agent cannot escape from inside the session. (Reproduced live: mid-merge, the config legitimately
17
+ * held `<<<<<<< HEAD` markers and every attempt to look at it was refused.)
18
+ *
19
+ * The rest of the system already models the escape hatch correctly — reading AND editing
20
+ * webpieces.config.json is always allowed (hook-core's config bypass, the stale-shim recovery
21
+ * carve-out, ContentReadScan's escape-hatch paths). This closes the one place that did not honour it.
22
+ *
23
+ * The bar is deliberately paranoid, because this is a bypass of ALL guards:
24
+ * - every invoked segment's command word must be an allowlisted inspector;
25
+ * - `git`/`gh` are excluded outright, even their read-only subcommands — the guards exist to police
26
+ * git, and "read-only git" is not a line worth drawing while flying blind;
27
+ * - any output redirect to a file (`> x`, `>> x`) makes the command a writer;
28
+ * - the in-place/mutating flags of otherwise-read-only tools (`sed -i`, `find -delete`) are refused.
29
+ * Anything not provably inert stays blocked.
30
+ */
31
+ class ReadOnlyInspectionScan {
32
+ scanner;
33
+ shell;
34
+ constructor(scanner = new command_scan_1.CommandScanner()) {
35
+ this.scanner = scanner;
36
+ this.shell = new shell_segment_scan_1.ShellSegmentScan(this.scanner);
37
+ }
38
+ /** True only when EVERY segment of the command is provably inert. Empty command → false. */
39
+ isReadOnlyInspection(command) {
40
+ const segments = this.scanner.segmentsWithPipes(command);
41
+ if (segments.length === 0)
42
+ return false;
43
+ return segments.every((segment) => this.segmentIsInert(segment));
44
+ }
45
+ segmentIsInert(segment) {
46
+ const words = this.shell.effectiveWords(segment.text);
47
+ if (words.length === 0)
48
+ return true; // pure shell structure (`done`, `fi`)
49
+ if (this.redirectsToFile(words))
50
+ return false; // `… > file` writes, whatever the command is
51
+ const head = path.basename(words[0]);
52
+ if (!INSPECTORS.has(head))
53
+ return false;
54
+ return !this.hasMutatingFlag(head, words.slice(1));
55
+ }
56
+ // `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which only rewire fds and are the single
57
+ // most common decoration an agent appends to a diagnostic command.
58
+ redirectsToFile(words) {
59
+ return words.some((word) => REDIRECT_TO_FILE.test(word));
60
+ }
61
+ // Read-only tools that grow teeth with one flag: `sed -i` rewrites in place, `find -delete`/`-exec`
62
+ // runs arbitrary commands. Matching is prefix-based so `-i.bak` and `-inplace` are caught too.
63
+ hasMutatingFlag(head, args) {
64
+ const mutating = MUTATING_FLAGS[head];
65
+ if (!mutating)
66
+ return false;
67
+ return args.some((arg) => mutating.some((flag) => arg.startsWith(flag)));
68
+ }
69
+ }
70
+ exports.ReadOnlyInspectionScan = ReadOnlyInspectionScan;
71
+ // Commands whose entire job is showing what is already there. Kept to viewers/searchers/formatters:
72
+ // no package managers, no build or test runners, no interpreters, no git.
73
+ const INSPECTORS = new Set([
74
+ 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'tac', 'rev', 'strings', 'xxd', 'od',
75
+ 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',
76
+ 'ls', 'find', 'tree', 'wc', 'diff', 'cmp', 'file', 'stat', 'realpath', 'readlink',
77
+ 'basename', 'dirname', 'sort', 'uniq', 'cut', 'tr', 'column', 'fold', 'expand',
78
+ 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'which', 'test', '[',
79
+ ]);
80
+ // Per-command flags that turn an inspector into a mutator.
81
+ const MUTATING_FLAGS = {
82
+ sed: ['-i', '--in-place'],
83
+ find: ['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fls'],
84
+ awk: ['-i'],
85
+ sort: ['-o', '--output'],
86
+ };
87
+ const REDIRECT_TO_FILE = /^\d*>>?(?!&)/;
88
+ //# sourceMappingURL=read-only-inspection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-only-inspection.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/read-only-inspection.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * Is this shell command pure INSPECTION — can it be trusted to change nothing?\n *\n * WHY this exists: when webpieces.config.json cannot be loaded (unparseable, or invalid against the\n * installed validator) every bash command is denied, because with no config there are no guards and\n * fail-closed is the only safe answer. That is right for work — and catastrophic for recovery, because\n * the denial also took out `cat`, `grep` and `sed -n` against webpieces.config.json itself. The guard\n * blocked the exact tools needed to see and fix the problem it was reporting, which is a dead end an\n * agent cannot escape from inside the session. (Reproduced live: mid-merge, the config legitimately\n * held `<<<<<<< HEAD` markers and every attempt to look at it was refused.)\n *\n * The rest of the system already models the escape hatch correctly — reading AND editing\n * webpieces.config.json is always allowed (hook-core's config bypass, the stale-shim recovery\n * carve-out, ContentReadScan's escape-hatch paths). This closes the one place that did not honour it.\n *\n * The bar is deliberately paranoid, because this is a bypass of ALL guards:\n * - every invoked segment's command word must be an allowlisted inspector;\n * - `git`/`gh` are excluded outright, even their read-only subcommands — the guards exist to police\n * git, and \"read-only git\" is not a line worth drawing while flying blind;\n * - any output redirect to a file (`> x`, `>> x`) makes the command a writer;\n * - the in-place/mutating flags of otherwise-read-only tools (`sed -i`, `find -delete`) are refused.\n * Anything not provably inert stays blocked.\n */\nexport class ReadOnlyInspectionScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(this.scanner);\n }\n\n /** True only when EVERY segment of the command is provably inert. Empty command → false. */\n isReadOnlyInspection(command: string): boolean {\n const segments = this.scanner.segmentsWithPipes(command);\n if (segments.length === 0) return false;\n return segments.every((segment: CommandSegment): boolean => this.segmentIsInert(segment));\n }\n\n private segmentIsInert(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return true; // pure shell structure (`done`, `fi`)\n if (this.redirectsToFile(words)) return false; // `… > file` writes, whatever the command is\n const head = path.basename(words[0]);\n if (!INSPECTORS.has(head)) return false;\n return !this.hasMutatingFlag(head, words.slice(1));\n }\n\n // `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which only rewire fds and are the single\n // most common decoration an agent appends to a diagnostic command.\n private redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n\n // Read-only tools that grow teeth with one flag: `sed -i` rewrites in place, `find -delete`/`-exec`\n // runs arbitrary commands. Matching is prefix-based so `-i.bak` and `-inplace` are caught too.\n private hasMutatingFlag(head: string, args: readonly string[]): boolean {\n const mutating = MUTATING_FLAGS[head];\n if (!mutating) return false;\n return args.some((arg: string): boolean => mutating.some((flag: string): boolean => arg.startsWith(flag)));\n }\n}\n\n// Commands whose entire job is showing what is already there. Kept to viewers/searchers/formatters:\n// no package managers, no build or test runners, no interpreters, no git.\nconst INSPECTORS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'tac', 'rev', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff', 'cmp', 'file', 'stat', 'realpath', 'readlink',\n 'basename', 'dirname', 'sort', 'uniq', 'cut', 'tr', 'column', 'fold', 'expand',\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'which', 'test', '[',\n]);\n\n// Per-command flags that turn an inspector into a mutator.\nconst MUTATING_FLAGS: Readonly<Record<string, readonly string[]>> = {\n sed: ['-i', '--in-place'],\n find: ['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fls'],\n awk: ['-i'],\n sort: ['-o', '--output'],\n};\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
@@ -20,7 +20,28 @@ export declare class ContentReadScan {
20
20
  private readonly scanner;
21
21
  private readonly workspaceRoot;
22
22
  private readonly shell;
23
- constructor(scanner: CommandScanner, workspaceRoot: string);
23
+ private readonly baseDir;
24
+ /**
25
+ * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which
26
+ * is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.
27
+ * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat
28
+ * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,
29
+ * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.
30
+ * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).
31
+ */
32
+ constructor(scanner: CommandScanner, workspaceRoot: string, effectiveCwd?: string);
33
+ /**
34
+ * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —
35
+ * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.
36
+ *
37
+ * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an
38
+ * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing
39
+ * about a read that never touches the tree is affected by which branch the tree is on. The
40
+ * "content reader" restriction is what keeps this from becoming a general escape hatch: a build,
41
+ * a server or a git write is not a content reader and never qualifies, however its paths look.
42
+ */
43
+ readsOnlyOutsideContent(segment: CommandSegment): boolean;
44
+ private isContentReader;
24
45
  /**
25
46
  * The command word that reads stale workspace content, or null when this segment does not.
26
47
  * The returned string is only a log/diagnostic label.
@@ -38,14 +59,18 @@ export declare class ContentReadScan {
38
59
  private gitContentRead;
39
60
  private pathOperands;
40
61
  /**
41
- * Is this operand a path inside the workspace? Relative paths are (the agent's cwd is the repo
42
- * or a subdir of it); an absolute path counts only when it is actually under workspaceRoot, so
43
- * `/etc/hosts`, `~/notes.md` and `/tmp/x` are not this repo's problem.
62
+ * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the
63
+ * directory the command actually runs in (`baseDir`), so it counts only when that directory is
64
+ * itself in the tree the old code assumed every relative path meant "inside the repo", which is
65
+ * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An
66
+ * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,
67
+ * `~/notes.md` and `/tmp/x` are not this repo's problem.
44
68
  *
45
69
  * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and
46
70
  * a stat per operand on the blocking hook path is exactly the cost these guards avoid.
47
71
  */
48
72
  private isWorkspacePath;
73
+ private isInWorkspace;
49
74
  private isEscapeHatchPath;
50
75
  private baseName;
51
76
  }
@@ -25,10 +25,42 @@ class ContentReadScan {
25
25
  scanner;
26
26
  workspaceRoot;
27
27
  shell;
28
- constructor(scanner, workspaceRoot) {
28
+ baseDir;
29
+ /**
30
+ * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which
31
+ * is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.
32
+ * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat
33
+ * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,
34
+ * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.
35
+ * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).
36
+ */
37
+ constructor(scanner, workspaceRoot, effectiveCwd) {
29
38
  this.scanner = scanner;
30
39
  this.workspaceRoot = workspaceRoot;
31
40
  this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
41
+ this.baseDir = effectiveCwd ?? workspaceRoot;
42
+ }
43
+ /**
44
+ * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —
45
+ * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.
46
+ *
47
+ * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an
48
+ * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing
49
+ * about a read that never touches the tree is affected by which branch the tree is on. The
50
+ * "content reader" restriction is what keeps this from becoming a general escape hatch: a build,
51
+ * a server or a git write is not a content reader and never qualifies, however its paths look.
52
+ */
53
+ readsOnlyOutsideContent(segment) {
54
+ return this.isContentReader(segment) && this.readsStaleContent(segment) === null;
55
+ }
56
+ // Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?
57
+ isContentReader(segment) {
58
+ const words = this.shell.effectiveWords(segment.text);
59
+ if (words.length === 0)
60
+ return false;
61
+ if (this.scanner.gitSubcommandOf(words) !== null)
62
+ return false;
63
+ return CONTENT_READERS.has(this.baseName(words[0]));
32
64
  }
33
65
  /**
34
66
  * The command word that reads stale workspace content, or null when this segment does not.
@@ -51,8 +83,10 @@ class ContentReadScan {
51
83
  const operands = this.pathOperands(command, words.slice(1));
52
84
  if (operands.length === 0) {
53
85
  // No path given: either it reads stdin (fine — and doubly fine when piped into), or it
54
- // walks the cwd, which IS the stale tree (`ls`, a bare `rg pattern`).
55
- return !segment.pipedInto && CWD_WALKERS.has(command) ? command : null;
86
+ // walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`
87
+ // walks /tmp, which this repo's staleness has nothing to do with.
88
+ const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);
89
+ return walksTree ? command : null;
56
90
  }
57
91
  return operands.some((operand) => this.isWorkspacePath(operand)) ? command : null;
58
92
  }
@@ -86,9 +120,12 @@ class ContentReadScan {
86
120
  return positional;
87
121
  }
88
122
  /**
89
- * Is this operand a path inside the workspace? Relative paths are (the agent's cwd is the repo
90
- * or a subdir of it); an absolute path counts only when it is actually under workspaceRoot, so
91
- * `/etc/hosts`, `~/notes.md` and `/tmp/x` are not this repo's problem.
123
+ * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the
124
+ * directory the command actually runs in (`baseDir`), so it counts only when that directory is
125
+ * itself in the tree the old code assumed every relative path meant "inside the repo", which is
126
+ * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An
127
+ * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,
128
+ * `~/notes.md` and `/tmp/x` are not this repo's problem.
92
129
  *
93
130
  * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and
94
131
  * a stat per operand on the blocking hook path is exactly the cost these guards avoid.
@@ -96,13 +133,21 @@ class ContentReadScan {
96
133
  isWorkspacePath(operand) {
97
134
  if (operand.startsWith('~'))
98
135
  return false;
99
- if (!path.isAbsolute(operand))
100
- return !this.isEscapeHatchPath(operand);
101
- const relative = path.relative(this.workspaceRoot, operand);
136
+ // The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`
137
+ // stays readable from any directory — never wedge the file that turns the guard off.
138
+ if (this.isEscapeHatchPath(operand))
139
+ return false;
140
+ const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);
141
+ const relative = path.relative(this.workspaceRoot, absolute);
102
142
  if (relative.startsWith('..'))
103
143
  return false;
104
144
  return !this.isEscapeHatchPath(relative);
105
145
  }
146
+ // The cwd-walk question: is the directory this command runs in inside the tree being judged?
147
+ isInWorkspace(dir) {
148
+ const relative = path.relative(this.workspaceRoot, path.resolve(dir));
149
+ return !relative.startsWith('..');
150
+ }
106
151
  // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the
107
152
  // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation
108
153
  // data this guard writes itself, not source that upstream has moved past.
@@ -1 +1 @@
1
- {"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd, which IS the stale tree (`ls`, a bare `rg pattern`).\n return !segment.pipedInto && CWD_WALKERS.has(command) ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? Relative paths are (the agent's cwd is the repo\n * or a subdir of it); an absolute path counts only when it is actually under workspaceRoot, so\n * `/etc/hosts`, `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n if (!path.isAbsolute(operand)) return !this.isEscapeHatchPath(operand);\n const relative = path.relative(this.workspaceRoot, operand);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
1
+ {"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":";;;;AAAA,mDAA6B;AAG7B,6DAAwD;AAExD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAa,eAAe;IAaH;IACA;IAbJ,KAAK,CAAmB;IACxB,OAAO,CAAS;IAEjC;;;;;;;OAOG;IACH,YACqB,OAAuB,EACvB,aAAqB,EACtC,YAAqB;QAFJ,YAAO,GAAP,OAAO,CAAgB;QACvB,kBAAa,GAAb,aAAa,CAAQ;QAGtC,IAAI,CAAC,KAAK,GAAG,IAAI,qCAAgB,CAAC,OAAO,CAAC,CAAC;QAC3C,IAAI,CAAC,OAAO,GAAG,YAAY,IAAI,aAAa,CAAC;IACjD,CAAC;IAED;;;;;;;;;OASG;IACH,uBAAuB,CAAC,OAAuB;QAC3C,OAAO,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;IACrF,CAAC;IAED,oGAAoG;IAC5F,eAAe,CAAC,OAAuB;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACtD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACrC,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,KAAK,CAAC,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QAC/D,OAAO,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACxD,CAAC;IAED;;;;;;;OAOG;IACH,iBAAiB,CAAC,OAAuB;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACtD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAEpC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QACnD,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAE/D,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACxC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAE/C,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5D,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,uFAAuF;YACvF,wFAAwF;YACxF,kEAAkE;YAClE,MAAM,SAAS,GAAG,CAAC,OAAO,CAAC,SAAS,IAAI,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACrG,OAAO,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;QACtC,CAAC;QACD,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAe,EAAW,EAAE,CAAC,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IACvG,CAAC;IAED;;;;OAIG;IACK,cAAc,CAAC,MAAc,EAAE,KAAwB;QAC3D,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QACxD,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACpD,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,GAAW,EAAW,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QAChF,6FAA6F;QAC7F,IAAI,MAAM,KAAK,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAW,EAAW,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QAClG,OAAO,OAAO,MAAM,EAAE,CAAC;IAC3B,CAAC;IAED,iGAAiG;IACjG,wFAAwF;IAChF,YAAY,CAAC,OAAe,EAAE,IAAuB;QACzD,MAAM,UAAU,GAAa,EAAE,CAAC;QAChC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACrB,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,SAAS,CAAuB,gCAAgC;YACzF,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACzB,CAAC;QACD,IAAI,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACpF,OAAO,UAAU,CAAC;IACtB,CAAC;IAED;;;;;;;;;;OAUG;IACK,eAAe,CAAC,OAAe;QACnC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;QAC1C,iGAAiG;QACjG,qFAAqF;QACrF,IAAI,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC;YAAE,OAAO,KAAK,CAAC;QAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC1F,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAC7D,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QAC5C,OAAO,CAAC,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAC7C,CAAC;IAED,6FAA6F;IACrF,aAAa,CAAC,GAAW;QAC7B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QACtE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IAED,6FAA6F;IAC7F,iGAAiG;IACjG,0EAA0E;IAClE,iBAAiB,CAAC,QAAgB;QACtC,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACjD,OAAO,UAAU,KAAK,uBAAuB,IAAI,UAAU,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC;IAC1F,CAAC;IAED,8FAA8F;IACtF,QAAQ,CAAC,IAAY;QACzB,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC/B,CAAC;CACJ;AA1ID,0CA0IC;AAED,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC;IACjD,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI;IAC1E,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI;IACrE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CACrC,CAAC,CAAC;AAEH,oGAAoG;AACpG,6DAA6D;AAC7D,MAAM,WAAW,GAAwB,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;AAE5F,4EAA4E;AAC5E,MAAM,aAAa,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n private readonly baseDir: string;\n\n /**\n * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which\n * is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.\n * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat\n * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,\n * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.\n * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).\n */\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n effectiveCwd?: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n this.baseDir = effectiveCwd ?? workspaceRoot;\n }\n\n /**\n * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —\n * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.\n *\n * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an\n * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing\n * about a read that never touches the tree is affected by which branch the tree is on. The\n * \"content reader\" restriction is what keeps this from becoming a general escape hatch: a build,\n * a server or a git write is not a content reader and never qualifies, however its paths look.\n */\n readsOnlyOutsideContent(segment: CommandSegment): boolean {\n return this.isContentReader(segment) && this.readsStaleContent(segment) === null;\n }\n\n // Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?\n private isContentReader(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return false;\n if (this.scanner.gitSubcommandOf(words) !== null) return false;\n return CONTENT_READERS.has(this.baseName(words[0]));\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`\n // walks /tmp, which this repo's staleness has nothing to do with.\n const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);\n return walksTree ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the\n * directory the command actually runs in (`baseDir`), so it counts only when that directory is\n * itself in the tree — the old code assumed every relative path meant \"inside the repo\", which is\n * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An\n * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,\n * `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n // The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`\n // stays readable from any directory — never wedge the file that turns the guard off.\n if (this.isEscapeHatchPath(operand)) return false;\n const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);\n const relative = path.relative(this.workspaceRoot, absolute);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // The cwd-walk question: is the directory this command runs in inside the tree being judged?\n private isInWorkspace(dir: string): boolean {\n const relative = path.relative(this.workspaceRoot, path.resolve(dir));\n return !relative.startsWith('..');\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
@@ -126,7 +126,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
126
126
  // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new
127
127
  // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.
128
128
  alreadyMergedMessage(workspaceRoot, branch, mergedPr) {
129
- return new merged_branch_message_1.MergedBranchMessage().forEdits(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
129
+ return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forEdits(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
130
130
  }
131
131
  conflictMessage(conflictFiles, openPr) {
132
132
  const files = conflictFiles.length > 0