@webpieces/ai-hook-rules 0.4.623 → 0.4.625
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.
- package/README.md +40 -33
- package/package.json +2 -2
- package/src/adapters/hook-core.js +7 -15
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/hook-registration.d.ts +121 -57
- package/src/bin/hook-registration.js +122 -92
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/managed-env.d.ts +17 -13
- package/src/bin/managed-env.js +17 -13
- package/src/bin/managed-env.js.map +1 -1
- package/src/bin/setup.d.ts +1 -2
- package/src/bin/setup.js +32 -50
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-audit-log.js +12 -1
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-deny-reason.js +10 -8
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.js +3 -3
- package/src/bin/shim.js.map +1 -1
- package/src/bin/upgrade-shim.d.ts +2 -2
- package/src/bin/upgrade-shim.js +58 -30
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/decision-log.d.ts +3 -3
- package/src/core/decision-log.js +8 -8
- package/src/core/decision-log.js.map +1 -1
- package/src/core/effective-tree.d.ts +5 -2
- package/src/core/effective-tree.js +1 -1
- package/src/core/effective-tree.js.map +1 -1
- package/src/core/l0-matrix.js +9 -9
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l1-doc.js +29 -68
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.d.ts +17 -9
- package/src/core/l1-rows.js +18 -13
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/log-stream.d.ts +6 -4
- package/src/core/log-stream.js +6 -4
- package/src/core/log-stream.js.map +1 -1
- package/src/core/log-streams.d.ts +13 -3
- package/src/core/log-streams.js +15 -5
- package/src/core/log-streams.js.map +1 -1
- package/src/core/runner.d.ts +1 -2
- package/src/core/runner.js +29 -24
- package/src/core/runner.js.map +1 -1
- package/src/core/version-sync.d.ts +67 -0
- package/src/core/version-sync.js +148 -0
- package/src/core/version-sync.js.map +1 -0
- package/src/core/webpieces-versions.d.ts +83 -0
- package/src/core/webpieces-versions.js +169 -0
- package/src/core/webpieces-versions.js.map +1 -0
- package/templates/ai-hook.sh +15 -4
- package/templates/claude-settings-hook.json +3 -13
- package/src/bin/guarantee-root.d.ts +0 -95
- package/src/bin/guarantee-root.js +0 -297
- package/src/bin/guarantee-root.js.map +0 -1
- package/src/core/coordinator-worktree.d.ts +0 -61
- package/src/core/coordinator-worktree.js +0 -94
- package/src/core/coordinator-worktree.js.map +0 -1
- package/templates/guarantee-root.sh +0 -113
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* L-1 — the layer BELOW L0: guarantee the shell stays somewhere the RELATIVE guard hooks can launch.
|
|
3
|
-
*
|
|
4
|
-
* ─── Why a layer below L0 exists at all ────────────────────────────────────────────────────────────
|
|
5
|
-
* From the Claude Code hooks reference: exit 2 is the blocking channel, exit 0 carries the JSON
|
|
6
|
-
* decision, and ANY OTHER exit is a "non-blocking error. Execution continues; the action proceeds" —
|
|
7
|
-
* including "File missing or not executable: Error logged; tool proceeds."
|
|
8
|
-
*
|
|
9
|
-
* A HOOK THAT FAILS TO LAUNCH IS A SILENT ALLOW. Not a block, not an error the AI sees.
|
|
10
|
-
*
|
|
11
|
-
* Every layer L0-L4 assumes the hook process ran at all. This file is what makes that assumption true.
|
|
12
|
-
*
|
|
13
|
-
* ─── Why the guard hooks become RELATIVE ───────────────────────────────────────────────────────────
|
|
14
|
-
* `.claude/settings.json` registers hooks as `sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh"`,
|
|
15
|
-
* and `$CLAUDE_PROJECT_DIR` NEVER moves — proven from four separate worktrees' own logs, every line
|
|
16
|
-
* reading `root=<worktree> projectDir=<primary>`. So every tree is governed by the PRIMARY's shim and
|
|
17
|
-
* the PRIMARY's binary, forever: a worktree can never be judged by the release its own branch pins, and
|
|
18
|
-
* measuring one tree while running another's binary is the non-convergent "two-tree straddle" recorded
|
|
19
|
-
* in shim.ts (an agent gave up after four cures).
|
|
20
|
-
*
|
|
21
|
-
* The fix is to re-root the WHOLE hook, not to measure differently. The hooks reference says "the hook
|
|
22
|
-
* runs in the cwd value from the JSON input", so a RELATIVE command resolves against the tool call's
|
|
23
|
-
* own tree — each tree runs its own shim, its own binary, its own pin. One version, one tree.
|
|
24
|
-
*
|
|
25
|
-
* That is only safe if the relative path is guaranteed to resolve. Hence this file.
|
|
26
|
-
*
|
|
27
|
-
* ─── Why this is a SEPARATE checked-in file from ai-hook.sh ────────────────────────────────────────
|
|
28
|
-
* 1. It is the ONE file that must stay $CLAUDE_PROJECT_DIR-anchored, so it is the one file that cannot
|
|
29
|
-
* be upgraded per-tree. Its surface is therefore kept minimal: a path check. No drift scraper, no
|
|
30
|
-
* allowlist, no config load, no binary — nothing that needs to change often.
|
|
31
|
-
* 2. Its stability profile is the OPPOSITE of ai-hook.sh's. The shim changes most releases; a `cd`
|
|
32
|
-
* validator should converge and stop. Sharing one file forces the stable half to inherit the
|
|
33
|
-
* volatile half's churn — which is today's problem, one level up.
|
|
34
|
-
* 3. A NEW FILE IS INVISIBLE TO OLD RELEASES; A NEW FLAG BREAKS THEM. Measured 2026-08-06: registering
|
|
35
|
-
* `ai-hook.sh --force-root` made the installed shim read `--force-root` as a BIN NAME, fail to find
|
|
36
|
-
* it, and emit fault U — denying `ls` and the very `cd` that would have fixed it:
|
|
37
|
-
* 16:32:20 --force-root Bash tree=primary fault=U DENY-UNDECLARED ls
|
|
38
|
-
* A separate file is simply not registered until a release that ships it, so old releases are safe.
|
|
39
|
-
*
|
|
40
|
-
* ─── The invariant, maintained inductively ─────────────────────────────────────────────────────────
|
|
41
|
-
* A session always starts at a tree root (primary for the coordinator, the worktree for a subagent).
|
|
42
|
-
* The only thing that moves the shell is a `cd`, and (measured 2026-08-02, effective-tree.ts) a `cd`
|
|
43
|
-
* that stays INSIDE the workspace PERSISTS to later calls, while a `cd` that LEAVES it is RESET by the
|
|
44
|
-
* harness before the next call.
|
|
45
|
-
*
|
|
46
|
-
* If every `cd` that would leave a tree root is refused, "the shell is at a tree root" is an
|
|
47
|
-
* INDUCTIVE INVARIANT — so this hook never inspects cwd as a state, only the command.
|
|
48
|
-
*
|
|
49
|
-
* That is also why it is registered for Bash ALONE: no other tool can move the shell.
|
|
50
|
-
*
|
|
51
|
-
* ─── The predicate, in three tests ─────────────────────────────────────────────────────────────────
|
|
52
|
-
* 1. destination holds `.git` → ALLOW. Complete by construction: the primary clone has a .git DIR,
|
|
53
|
-
* every linked worktree (nested or sibling) has a .git FILE, and a nested foreign clone under
|
|
54
|
-
* repositories/** has its own .git DIR. The first two are where the relative hooks launch; the
|
|
55
|
-
* third is a tree we deliberately do not govern.
|
|
56
|
-
* 2. destination is OUTSIDE $CLAUDE_PROJECT_DIR → ALLOW. The harness resets it before the next call,
|
|
57
|
-
* so at most ONE call runs at a path we do not govern — and there is nothing there to guard.
|
|
58
|
-
* 3. otherwise (inside a governed tree, no .git — `tools/`, `dataform/`, `packages/…`) → DENY. This
|
|
59
|
-
* is the only region that is both STICKY and UNGUARDED.
|
|
60
|
-
*
|
|
61
|
-
* It reads NO config. `excludePaths` governs which FILES are enforced, not whether the hook may run,
|
|
62
|
-
* and it cannot be parsed here anyway (this is sh, pre-config, pre-binary). The one measured divergence
|
|
63
|
-
* is `tools/**`: exempt by excludePaths, denied here — correctly, because the real hooks genuinely
|
|
64
|
-
* cannot launch there and we genuinely want them to. Cost: 4 calls in a 2,236-call sample.
|
|
65
|
-
*
|
|
66
|
-
* ─── Nothing to recover from ───────────────────────────────────────────────────────────────────────
|
|
67
|
-
* A denied `cd` NEVER EXECUTES — PreToolUse denies the whole tool call before the shell moves — so the
|
|
68
|
-
* shell is still at a root. There is no bad state and no cure command that needs allowlisting, which is
|
|
69
|
-
* what keeps this hook from ever being able to wedge a session.
|
|
70
|
-
*
|
|
71
|
-
* Measured cost of the deny: 47 of 2,236 real Bash calls (2.10%), or 9.7% of all `cd` commands.
|
|
72
|
-
*/
|
|
73
|
-
export declare const GUARANTEE_ROOT_MARKER = ".claude/webpieces/guarantee-root.sh";
|
|
74
|
-
export declare function guaranteeRootPath(projectRoot: string): string;
|
|
75
|
-
/**
|
|
76
|
-
* Write (or overwrite) the committed L-1 hook. Idempotent — the installer and `wp-upgrade-shim` both
|
|
77
|
-
* call it, and re-running either simply re-arms the file. Twin of setup.ts's writeShim().
|
|
78
|
-
*/
|
|
79
|
-
export declare function writeGuaranteeRoot(projectRoot: string): void;
|
|
80
|
-
/**
|
|
81
|
-
* The POSIX-sh source. Byte-identical to `templates/guarantee-root.sh`, locked by a unit test, exactly
|
|
82
|
-
* as renderShim()/templates/ai-hook.sh are — so the file a consumer commits and the file this release
|
|
83
|
-
* expects can never silently diverge.
|
|
84
|
-
*/
|
|
85
|
-
export declare function renderGuaranteeRoot(): string;
|
|
86
|
-
/**
|
|
87
|
-
* True when a committed guarantee-root.sh EXISTS but no longer equals renderGuaranteeRoot(). Missing
|
|
88
|
-
* file → false: a repo that has not adopted L-1 yet is not "stale", it is simply still on the two-hook
|
|
89
|
-
* registration, and `wp-install-ai-hooks` is what moves it forward.
|
|
90
|
-
*
|
|
91
|
-
* `root` is the tree whose committed copy this BINARY governs — resolved from the running module's own
|
|
92
|
-
* location by governingShimRoot(), never from cwd and never from $CLAUDE_PROJECT_DIR, for the same
|
|
93
|
-
* reason the shim's own check is anchored that way: it makes the two-tree straddle unconstructible.
|
|
94
|
-
*/
|
|
95
|
-
export declare function committedGuaranteeRootStale(root: string | null): boolean;
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.GUARANTEE_ROOT_MARKER = void 0;
|
|
4
|
-
exports.guaranteeRootPath = guaranteeRootPath;
|
|
5
|
-
exports.writeGuaranteeRoot = writeGuaranteeRoot;
|
|
6
|
-
exports.renderGuaranteeRoot = renderGuaranteeRoot;
|
|
7
|
-
exports.committedGuaranteeRootStale = committedGuaranteeRootStale;
|
|
8
|
-
const tslib_1 = require("tslib");
|
|
9
|
-
const fs = tslib_1.__importStar(require("fs"));
|
|
10
|
-
const path = tslib_1.__importStar(require("path"));
|
|
11
|
-
const rules_config_1 = require("@webpieces/rules-config");
|
|
12
|
-
const log_streams_1 = require("../core/log-streams");
|
|
13
|
-
const to_error_1 = require("../core/to-error");
|
|
14
|
-
/**
|
|
15
|
-
* L-1 — the layer BELOW L0: guarantee the shell stays somewhere the RELATIVE guard hooks can launch.
|
|
16
|
-
*
|
|
17
|
-
* ─── Why a layer below L0 exists at all ────────────────────────────────────────────────────────────
|
|
18
|
-
* From the Claude Code hooks reference: exit 2 is the blocking channel, exit 0 carries the JSON
|
|
19
|
-
* decision, and ANY OTHER exit is a "non-blocking error. Execution continues; the action proceeds" —
|
|
20
|
-
* including "File missing or not executable: Error logged; tool proceeds."
|
|
21
|
-
*
|
|
22
|
-
* A HOOK THAT FAILS TO LAUNCH IS A SILENT ALLOW. Not a block, not an error the AI sees.
|
|
23
|
-
*
|
|
24
|
-
* Every layer L0-L4 assumes the hook process ran at all. This file is what makes that assumption true.
|
|
25
|
-
*
|
|
26
|
-
* ─── Why the guard hooks become RELATIVE ───────────────────────────────────────────────────────────
|
|
27
|
-
* `.claude/settings.json` registers hooks as `sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh"`,
|
|
28
|
-
* and `$CLAUDE_PROJECT_DIR` NEVER moves — proven from four separate worktrees' own logs, every line
|
|
29
|
-
* reading `root=<worktree> projectDir=<primary>`. So every tree is governed by the PRIMARY's shim and
|
|
30
|
-
* the PRIMARY's binary, forever: a worktree can never be judged by the release its own branch pins, and
|
|
31
|
-
* measuring one tree while running another's binary is the non-convergent "two-tree straddle" recorded
|
|
32
|
-
* in shim.ts (an agent gave up after four cures).
|
|
33
|
-
*
|
|
34
|
-
* The fix is to re-root the WHOLE hook, not to measure differently. The hooks reference says "the hook
|
|
35
|
-
* runs in the cwd value from the JSON input", so a RELATIVE command resolves against the tool call's
|
|
36
|
-
* own tree — each tree runs its own shim, its own binary, its own pin. One version, one tree.
|
|
37
|
-
*
|
|
38
|
-
* That is only safe if the relative path is guaranteed to resolve. Hence this file.
|
|
39
|
-
*
|
|
40
|
-
* ─── Why this is a SEPARATE checked-in file from ai-hook.sh ────────────────────────────────────────
|
|
41
|
-
* 1. It is the ONE file that must stay $CLAUDE_PROJECT_DIR-anchored, so it is the one file that cannot
|
|
42
|
-
* be upgraded per-tree. Its surface is therefore kept minimal: a path check. No drift scraper, no
|
|
43
|
-
* allowlist, no config load, no binary — nothing that needs to change often.
|
|
44
|
-
* 2. Its stability profile is the OPPOSITE of ai-hook.sh's. The shim changes most releases; a `cd`
|
|
45
|
-
* validator should converge and stop. Sharing one file forces the stable half to inherit the
|
|
46
|
-
* volatile half's churn — which is today's problem, one level up.
|
|
47
|
-
* 3. A NEW FILE IS INVISIBLE TO OLD RELEASES; A NEW FLAG BREAKS THEM. Measured 2026-08-06: registering
|
|
48
|
-
* `ai-hook.sh --force-root` made the installed shim read `--force-root` as a BIN NAME, fail to find
|
|
49
|
-
* it, and emit fault U — denying `ls` and the very `cd` that would have fixed it:
|
|
50
|
-
* 16:32:20 --force-root Bash tree=primary fault=U DENY-UNDECLARED ls
|
|
51
|
-
* A separate file is simply not registered until a release that ships it, so old releases are safe.
|
|
52
|
-
*
|
|
53
|
-
* ─── The invariant, maintained inductively ─────────────────────────────────────────────────────────
|
|
54
|
-
* A session always starts at a tree root (primary for the coordinator, the worktree for a subagent).
|
|
55
|
-
* The only thing that moves the shell is a `cd`, and (measured 2026-08-02, effective-tree.ts) a `cd`
|
|
56
|
-
* that stays INSIDE the workspace PERSISTS to later calls, while a `cd` that LEAVES it is RESET by the
|
|
57
|
-
* harness before the next call.
|
|
58
|
-
*
|
|
59
|
-
* If every `cd` that would leave a tree root is refused, "the shell is at a tree root" is an
|
|
60
|
-
* INDUCTIVE INVARIANT — so this hook never inspects cwd as a state, only the command.
|
|
61
|
-
*
|
|
62
|
-
* That is also why it is registered for Bash ALONE: no other tool can move the shell.
|
|
63
|
-
*
|
|
64
|
-
* ─── The predicate, in three tests ─────────────────────────────────────────────────────────────────
|
|
65
|
-
* 1. destination holds `.git` → ALLOW. Complete by construction: the primary clone has a .git DIR,
|
|
66
|
-
* every linked worktree (nested or sibling) has a .git FILE, and a nested foreign clone under
|
|
67
|
-
* repositories/** has its own .git DIR. The first two are where the relative hooks launch; the
|
|
68
|
-
* third is a tree we deliberately do not govern.
|
|
69
|
-
* 2. destination is OUTSIDE $CLAUDE_PROJECT_DIR → ALLOW. The harness resets it before the next call,
|
|
70
|
-
* so at most ONE call runs at a path we do not govern — and there is nothing there to guard.
|
|
71
|
-
* 3. otherwise (inside a governed tree, no .git — `tools/`, `dataform/`, `packages/…`) → DENY. This
|
|
72
|
-
* is the only region that is both STICKY and UNGUARDED.
|
|
73
|
-
*
|
|
74
|
-
* It reads NO config. `excludePaths` governs which FILES are enforced, not whether the hook may run,
|
|
75
|
-
* and it cannot be parsed here anyway (this is sh, pre-config, pre-binary). The one measured divergence
|
|
76
|
-
* is `tools/**`: exempt by excludePaths, denied here — correctly, because the real hooks genuinely
|
|
77
|
-
* cannot launch there and we genuinely want them to. Cost: 4 calls in a 2,236-call sample.
|
|
78
|
-
*
|
|
79
|
-
* ─── Nothing to recover from ───────────────────────────────────────────────────────────────────────
|
|
80
|
-
* A denied `cd` NEVER EXECUTES — PreToolUse denies the whole tool call before the shell moves — so the
|
|
81
|
-
* shell is still at a root. There is no bad state and no cure command that needs allowlisting, which is
|
|
82
|
-
* what keeps this hook from ever being able to wedge a session.
|
|
83
|
-
*
|
|
84
|
-
* Measured cost of the deny: 47 of 2,236 real Bash calls (2.10%), or 9.7% of all `cd` commands.
|
|
85
|
-
*/
|
|
86
|
-
exports.GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';
|
|
87
|
-
// webpieces-disable no-function-outside-class -- L-1 sibling of shim.ts's shimPath(); this module is deliberately dependency-free module-scope functions so it stays callable from a tree too broken to build a DI container
|
|
88
|
-
function guaranteeRootPath(projectRoot) {
|
|
89
|
-
return path.join(projectRoot, '.claude', 'webpieces', 'guarantee-root.sh');
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Write (or overwrite) the committed L-1 hook. Idempotent — the installer and `wp-upgrade-shim` both
|
|
93
|
-
* call it, and re-running either simply re-arms the file. Twin of setup.ts's writeShim().
|
|
94
|
-
*/
|
|
95
|
-
// webpieces-disable no-function-outside-class -- L-1 sibling of guaranteeRootPath(); this module is deliberately dependency-free module-scope functions
|
|
96
|
-
function writeGuaranteeRoot(projectRoot) {
|
|
97
|
-
const target = guaranteeRootPath(projectRoot);
|
|
98
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
99
|
-
fs.writeFileSync(target, renderGuaranteeRoot(), { mode: 0o755 });
|
|
100
|
-
// writeFileSync's mode is only applied when creating the file; force it on overwrite too.
|
|
101
|
-
fs.chmodSync(target, 0o755);
|
|
102
|
-
}
|
|
103
|
-
// Deny REASON constraint, inherited from the shim: the text is interpolated into a `REASON="…"` shell
|
|
104
|
-
// assignment and then printf'd into a JSON string, so it may contain NO double quotes and NO
|
|
105
|
-
// backslashes. Single quotes only — do not "improve" them.
|
|
106
|
-
const DENY_NOT_LITERAL = 'This cd target is not a literal path, so the guards cannot tell where the shell will end up. '
|
|
107
|
-
+ 'Use a literal absolute path: cd /abs/path && <your command>. '
|
|
108
|
-
+ 'A $VAR, ~, $(...) or backtick is never expanded by the guard.';
|
|
109
|
-
const DENY_NO_TARGET = 'A bare cd (or cd -) moves the shell somewhere the guards cannot predict - a bare cd goes to '
|
|
110
|
-
+ 'your home directory, where the webpieces hooks do not exist and every later tool call would '
|
|
111
|
-
+ 'run UNGUARDED. Name the directory: cd /abs/path && <your command>.';
|
|
112
|
-
// `%s` is the destination, then the project root. Kept to one short paragraph on purpose: L0 already
|
|
113
|
-
// ran a message diet and these denies regress straight back to a wall of text if each one argues.
|
|
114
|
-
const DENY_SUBDIR = 'The webpieces guard hooks are registered RELATIVE (.claude/webpieces/ai-hook.sh) so that each '
|
|
115
|
-
+ 'git tree is governed by its own release. %s has no .claude/webpieces/ai-hook.sh, so a shell '
|
|
116
|
-
+ 'parked there launches NO hooks at all and every later tool call runs UNGUARDED - and a cd that '
|
|
117
|
-
+ 'stays inside the project PERSISTS to your next call. Run it from the tree root instead: '
|
|
118
|
-
+ 'cd %s && <your command>. Tools that take their own directory (git -C, pnpm -C, pnpm --filter, '
|
|
119
|
-
+ 'nx) need no cd at all.';
|
|
120
|
-
/**
|
|
121
|
-
* The cd audit trail. A THIRD parallel writer joins guards+rules on every Bash call, so it gets its
|
|
122
|
-
* own file under the same session/agent/hook key LogStream uses — one writer per directory, so an
|
|
123
|
-
* append can never interleave with another's (macOS PIPE_BUF is 512 bytes and real log lines exceed it).
|
|
124
|
-
*
|
|
125
|
-
* Unlike the shim's RESOLVE_LOG_DIR_SH this needs NO worktree resolution: L-1 is $CLAUDE_PROJECT_DIR-
|
|
126
|
-
* anchored by definition, so that tree's `.webpieces` is always the right home. That is the one upside
|
|
127
|
-
* of being the hook that cannot follow the tree, and it is kept DELIBERATELY.
|
|
128
|
-
*
|
|
129
|
-
* The cost is stated rather than hidden: for a call inside a linked worktree, L-1's line lands under
|
|
130
|
-
* $CLAUDE_PROJECT_DIR while L0's lands under `worktrees/<name>/`, so the two halves of one tool call
|
|
131
|
-
* sit in different roots. The cure would be to splice RESOLVE_LOG_DIR_SH in here — and that buys a
|
|
132
|
-
* `git rev-parse` subprocess on EVERY Bash call, paid by the one layer whose whole guarantee is that
|
|
133
|
-
* it reads no config, spawns no binary and touches no network. Trading that guarantee for tidier log
|
|
134
|
-
* placement is the wrong way round: `L-1-cd/` is always at $CLAUDE_PROJECT_DIR, which is a rule a
|
|
135
|
-
* reader can simply know.
|
|
136
|
-
*
|
|
137
|
-
* Every write is swallowed and nothing ever reaches stdout — stdout is the PreToolUse decision channel
|
|
138
|
-
* and a stray byte there would corrupt allow/deny.
|
|
139
|
-
*/
|
|
140
|
-
const CD_AUDIT_SH = `# --- cd audit (best-effort; never blocks, never touches stdout) --------------------------------
|
|
141
|
-
SID="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
142
|
-
AID="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"agent_id"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
143
|
-
# Untrusted payload values are used as path segments, so anything outside [A-Za-z0-9._-] collapses to _
|
|
144
|
-
# and a leading dot is neutralised — ../../etc can never escape the logs directory.
|
|
145
|
-
clean() { printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' \\
|
|
146
|
-
| sed -e 's/\\.\\{2,\\}/_/g' -e 's/^\\.\\{1,\\}/_/' | cut -c1-64; }
|
|
147
|
-
wp_cd_log() { # $1 = verdict, $2 = destination (may be empty)
|
|
148
|
-
{
|
|
149
|
-
[ -n "$CLAUDE_PROJECT_DIR" ] || return 0
|
|
150
|
-
_d="$CLAUDE_PROJECT_DIR/${rules_config_1.WEBPIECES_TMP_DIR}/${rules_config_1.LOGS_STATE_DIR}/${log_streams_1.LMINUS1_CD_STREAM}"
|
|
151
|
-
mkdir -p "$_d" 2>/dev/null || return 0
|
|
152
|
-
# The LAYER is the directory; this is the WRITER, keyed exactly like LogStream.writerFile():
|
|
153
|
-
# <session>-<agent|coordinator>-guarantee-root.log. ALWAYS keyed; a missing session_id renders as
|
|
154
|
-
# 'unknown'. No bare-name branch anywhere.
|
|
155
|
-
_p="$(clean "\${SID:-unknown}")-$(clean "\${AID:-coordinator}")-guarantee-root"
|
|
156
|
-
_f="$_d/\${_p}.log"
|
|
157
|
-
_sz="$(wc -c < "$_f" 2>/dev/null | tr -d ' ')"
|
|
158
|
-
case "$_sz" in ''|*[!0-9]*) _sz=0 ;; esac
|
|
159
|
-
[ "$_sz" -gt 524288 ] && mv -f "$_f" "$_d/\${_p}.1.log" 2>/dev/null
|
|
160
|
-
# fault=- is a constant here: L-1 detects no L0 fault. It is present so ONE grep spans every
|
|
161
|
-
# hook-written stream rather than needing a different field list per layer.
|
|
162
|
-
printf '%s\\t%s\\tfault=-\\tdest=%s\\tcwd=%s\\t%s\\n' \\
|
|
163
|
-
"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$1" "$2" "$CWD" "$CMD" >> "$_f"
|
|
164
|
-
} 2>/dev/null || true
|
|
165
|
-
}
|
|
166
|
-
`;
|
|
167
|
-
// The deny boundary. Extracted for the same reason CD_AUDIT_SH and HEADER_SH are — to keep the
|
|
168
|
-
// renderer inside the 70-line method budget — and spliced back verbatim. Mirrors the shim's own emit:
|
|
169
|
-
// for Bash, permissionDecisionReason is NOT user-visible, so the red systemMessage carries the text.
|
|
170
|
-
const DENY_EMIT_SH = `wp_cd_log DENY "\${ABS:-$DEST}"
|
|
171
|
-
|
|
172
|
-
BS='\\' # one literal backslash, so the \\u001b escape never sits in this source
|
|
173
|
-
ESC="\${BS}u001b" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\u001b -> ESC
|
|
174
|
-
printf '{"systemMessage":"%s🛑 %s%s","hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "\${ESC}[31;1m" "$REASON" "\${ESC}[0m" "$REASON"
|
|
175
|
-
exit 0 # the decision is carried by permissionDecision deny, not the exit code
|
|
176
|
-
`;
|
|
177
|
-
// The file's own banner. Extracted to a module const for the same reason renderShim() extracts
|
|
178
|
-
// VERSION_DRIFT_GUARD_SH — to keep the renderer inside the 80-line method budget — and spliced back
|
|
179
|
-
// in verbatim, byte for byte.
|
|
180
|
-
const HEADER_SH = `#!/bin/sh
|
|
181
|
-
# webpieces L-1 hook — GUARANTEE ROOT. Generated by renderGuaranteeRoot(); do not hand-edit.
|
|
182
|
-
#
|
|
183
|
-
# Registered ABSOLUTE in .claude/settings.json, matcher "Bash":
|
|
184
|
-
# sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh"
|
|
185
|
-
#
|
|
186
|
-
# It exists because the GUARD hooks beside it are registered RELATIVE, so that each git tree is
|
|
187
|
-
# governed by its own @webpieces release. A relative hook that cannot resolve does not block — the
|
|
188
|
-
# harness logs it and lets the tool call proceed UNGUARDED. This file makes that unreachable by
|
|
189
|
-
# refusing any cd that would park the shell where the relative hooks cannot launch.
|
|
190
|
-
#
|
|
191
|
-
# Three tests, no config, no binary, no network:
|
|
192
|
-
# 1. destination holds .git -> ALLOW (tree root, worktree, or foreign clone)
|
|
193
|
-
# 2. destination is outside $CLAUDE_PROJECT_DIR -> ALLOW (the harness resets the cwd next call)
|
|
194
|
-
# 3. otherwise -> DENY (sticky AND unguarded)
|
|
195
|
-
#
|
|
196
|
-
# A denied cd never runs, so the shell never leaves the root and there is nothing to recover from.
|
|
197
|
-
`;
|
|
198
|
-
/**
|
|
199
|
-
* The POSIX-sh source. Byte-identical to `templates/guarantee-root.sh`, locked by a unit test, exactly
|
|
200
|
-
* as renderShim()/templates/ai-hook.sh are — so the file a consumer commits and the file this release
|
|
201
|
-
* expects can never silently diverge.
|
|
202
|
-
*/
|
|
203
|
-
// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's renderShim(), byte-locked to templates/guarantee-root.sh; module-scope for the same dependency-free reason
|
|
204
|
-
function renderGuaranteeRoot() {
|
|
205
|
-
return `${HEADER_SH}
|
|
206
|
-
PAYLOAD="$(cat)"
|
|
207
|
-
CWD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
208
|
-
TOOL="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\)".*/\\1/p')"
|
|
209
|
-
|
|
210
|
-
# The command PREFIX, not the whole command — note there is no closing " in this pattern.
|
|
211
|
-
# WHY: a JSON payload escapes an embedded double quote as \\", and \`[^"\\\\]*\` stops dead at that
|
|
212
|
-
# backslash, so the usual "capture between quotes" form yields the EMPTY STRING for any command
|
|
213
|
-
# containing a quote at all (measured: \`cd /a/b && echo "hi"\` -> ''). An empty CMD here would mean
|
|
214
|
-
# "no cd found" -> ALLOW, i.e. this guard would fail OPEN for every quoted command — the exact hazard
|
|
215
|
-
# it exists to close. Capturing only up to the first quote/backslash is enough, because everything
|
|
216
|
-
# L-1 needs (is the FIRST word a cd, and what is its target) lives in the prefix; a quote can only
|
|
217
|
-
# appear later, in the part we do not need.
|
|
218
|
-
CMD="$(printf '%s' "$PAYLOAD" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"\\\\]*\\).*/\\1/p')"
|
|
219
|
-
|
|
220
|
-
# Only Bash can move the shell. Anything else, and any payload we cannot read, is not ours.
|
|
221
|
-
[ "$TOOL" = "Bash" ] || exit 0
|
|
222
|
-
[ -n "$CMD" ] || exit 0
|
|
223
|
-
|
|
224
|
-
${CD_AUDIT_SH}
|
|
225
|
-
|
|
226
|
-
# Does the command OPEN with cd/pushd? Only a LEADING cd counts — the same rule effective-tree.ts
|
|
227
|
-
# enforces, because a later cd cannot retroactively move a command that has already run.
|
|
228
|
-
FIRST="$(printf '%s' "$CMD" | sed -n 's/^[[:space:]]*\\([^[:space:]]\\{1,\\}\\).*/\\1/p')"
|
|
229
|
-
case "$FIRST" in
|
|
230
|
-
cd|pushd) ;;
|
|
231
|
-
*) exit 0 ;; # no leading cd: nothing to audit, nothing to judge
|
|
232
|
-
esac
|
|
233
|
-
|
|
234
|
-
# The target: a single-quoted path first (that is how a path with spaces is spelled), else a bare word.
|
|
235
|
-
DEST="$(printf '%s' "$CMD" | sed -n "s/^[[:space:]]*[a-z]\\{2,5\\}[[:space:]]\\{1,\\}'\\([^']*\\)'.*/\\1/p")"
|
|
236
|
-
[ -n "$DEST" ] || DEST="$(printf '%s' "$CMD" | sed -n 's/^[[:space:]]*[a-z]\\{2,5\\}[[:space:]]\\{1,\\}\\([^[:space:];&|]\\{1,\\}\\).*/\\1/p')"
|
|
237
|
-
|
|
238
|
-
REASON=""
|
|
239
|
-
if [ -z "$DEST" ] || [ "$DEST" = "-" ]; then
|
|
240
|
-
REASON='${DENY_NO_TARGET}'
|
|
241
|
-
else
|
|
242
|
-
# A target the guard cannot expand is a target it cannot judge. sh has no regex here, so test the
|
|
243
|
-
# four unexpandable shapes directly.
|
|
244
|
-
case "$DEST" in
|
|
245
|
-
*'$'*|*'\`'*|'~'|'~/'*) REASON='${DENY_NOT_LITERAL}' ;;
|
|
246
|
-
esac
|
|
247
|
-
fi
|
|
248
|
-
|
|
249
|
-
if [ -z "$REASON" ]; then
|
|
250
|
-
# Resolve against the shell's real cwd. A destination that does not exist needs no verdict: the cd
|
|
251
|
-
# itself will fail and the shell stays exactly where it is.
|
|
252
|
-
ABS="$(CDPATH= cd -- "\${CWD:-.}" 2>/dev/null && CDPATH= cd -- "$DEST" 2>/dev/null && pwd)"
|
|
253
|
-
if [ -z "$ABS" ]; then wp_cd_log ALLOW-NO-SUCH-DIR "$DEST"; exit 0; fi
|
|
254
|
-
|
|
255
|
-
# TEST 1 — a git tree of any kind. A worktree's .git is a FILE, a clone's is a DIR; -e covers both.
|
|
256
|
-
if [ -e "$ABS/.git" ]; then wp_cd_log ALLOW-GIT-TREE "$ABS"; exit 0; fi
|
|
257
|
-
|
|
258
|
-
# TEST 2 — outside the governed project. The harness resets the cwd before the next call, so at most
|
|
259
|
-
# one command runs there, on paths we do not govern anyway.
|
|
260
|
-
case "$ABS/" in
|
|
261
|
-
"$CLAUDE_PROJECT_DIR"/*) ;;
|
|
262
|
-
*) wp_cd_log ALLOW-OUTSIDE "$ABS"; exit 0 ;;
|
|
263
|
-
esac
|
|
264
|
-
|
|
265
|
-
# TEST 3 — inside a governed tree with no shim beside it: sticky AND unguarded.
|
|
266
|
-
REASON="$(printf '${DENY_SUBDIR}' "$ABS" "$CLAUDE_PROJECT_DIR")"
|
|
267
|
-
fi
|
|
268
|
-
|
|
269
|
-
${DENY_EMIT_SH}`;
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* True when a committed guarantee-root.sh EXISTS but no longer equals renderGuaranteeRoot(). Missing
|
|
273
|
-
* file → false: a repo that has not adopted L-1 yet is not "stale", it is simply still on the two-hook
|
|
274
|
-
* registration, and `wp-install-ai-hooks` is what moves it forward.
|
|
275
|
-
*
|
|
276
|
-
* `root` is the tree whose committed copy this BINARY governs — resolved from the running module's own
|
|
277
|
-
* location by governingShimRoot(), never from cwd and never from $CLAUDE_PROJECT_DIR, for the same
|
|
278
|
-
* reason the shim's own check is anchored that way: it makes the two-tree straddle unconstructible.
|
|
279
|
-
*/
|
|
280
|
-
// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's committedShimStale(); module-scope for the same dependency-free reason
|
|
281
|
-
function committedGuaranteeRootStale(root) {
|
|
282
|
-
if (root === null)
|
|
283
|
-
return false;
|
|
284
|
-
const file = guaranteeRootPath(root);
|
|
285
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
286
|
-
try {
|
|
287
|
-
if (!fs.existsSync(file))
|
|
288
|
-
return false;
|
|
289
|
-
return fs.readFileSync(file, 'utf8') !== renderGuaranteeRoot();
|
|
290
|
-
}
|
|
291
|
-
catch (err) {
|
|
292
|
-
const error = (0, to_error_1.toError)(err);
|
|
293
|
-
void error; // best-effort: an unreadable tree counts as "not stale" so this never wedges a call
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
//# sourceMappingURL=guarantee-root.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"guarantee-root.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/guarantee-root.ts"],"names":[],"mappings":";;;AAoFA,8CAEC;AAOD,gDAMC;AAiHD,kDAkEC;AAYD,kEAYC;;AA9SD,+CAAyB;AACzB,mDAA6B;AAE7B,0DAA4E;AAC5E,qDAAwD;AAExD,+CAA2C;AAE3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AAEU,QAAA,qBAAqB,GAAG,qCAAqC,CAAC;AAE3E,6NAA6N;AAC7N,SAAgB,iBAAiB,CAAC,WAAmB;IACjD,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,mBAAmB,CAAC,CAAC;AAC/E,CAAC;AAED;;;GAGG;AACH,wJAAwJ;AACxJ,SAAgB,kBAAkB,CAAC,WAAmB;IAClD,MAAM,MAAM,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IAC9C,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,mBAAmB,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACjE,0FAA0F;IAC1F,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAChC,CAAC;AAED,sGAAsG;AACtG,6FAA6F;AAC7F,2DAA2D;AAC3D,MAAM,gBAAgB,GAClB,+FAA+F;MAC7F,+DAA+D;MAC/D,+DAA+D,CAAC;AAEtE,MAAM,cAAc,GAChB,8FAA8F;MAC5F,8FAA8F;MAC9F,oEAAoE,CAAC;AAE3E,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,WAAW,GACb,gGAAgG;MAC9F,8FAA8F;MAC9F,iGAAiG;MACjG,0FAA0F;MAC1F,gGAAgG;MAChG,wBAAwB,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,GAAG;;;;;;;;;;8BAUU,gCAAiB,IAAI,6BAAc,IAAI,+BAAiB;;;;;;;;;;;;;;;;CAgBrF,CAAC;AAGF,+FAA+F;AAC/F,sGAAsG;AACtG,qGAAqG;AACrG,MAAM,YAAY,GAAG;;;;;;CAMpB,CAAC;AAEF,+FAA+F;AAC/F,oGAAoG;AACpG,8BAA8B;AAC9B,MAAM,SAAS,GAAG;;;;;;;;;;;;;;;;;CAiBjB,CAAC;AAEF;;;;GAIG;AACH,kLAAkL;AAClL,SAAgB,mBAAmB;IAC/B,OAAO,GAAG,SAAS;;;;;;;;;;;;;;;;;;;EAmBrB,WAAW;;;;;;;;;;;;;;;;YAgBD,cAAc;;;;;sCAKY,gBAAgB;;;;;;;;;;;;;;;;;;;;;sBAqBhC,WAAW;;;EAG/B,YAAY,EAAE,CAAC;AACjB,CAAC;AAED;;;;;;;;GAQG;AACH,8IAA8I;AAC9I,SAAgB,2BAA2B,CAAC,IAAmB;IAC3D,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAChC,MAAM,IAAI,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACrC,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QACvC,OAAO,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,mBAAmB,EAAE,CAAC;IACnE,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC,CAAC,oFAAoF;QAChG,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { WEBPIECES_TMP_DIR, LOGS_STATE_DIR } from '@webpieces/rules-config';\nimport { LMINUS1_CD_STREAM } from '../core/log-streams';\n\nimport { toError } from '../core/to-error';\n\n/**\n * L-1 — the layer BELOW L0: guarantee the shell stays somewhere the RELATIVE guard hooks can launch.\n *\n * ─── Why a layer below L0 exists at all ────────────────────────────────────────────────────────────\n * From the Claude Code hooks reference: exit 2 is the blocking channel, exit 0 carries the JSON\n * decision, and ANY OTHER exit is a \"non-blocking error. Execution continues; the action proceeds\" —\n * including \"File missing or not executable: Error logged; tool proceeds.\"\n *\n * A HOOK THAT FAILS TO LAUNCH IS A SILENT ALLOW. Not a block, not an error the AI sees.\n *\n * Every layer L0-L4 assumes the hook process ran at all. This file is what makes that assumption true.\n *\n * ─── Why the guard hooks become RELATIVE ───────────────────────────────────────────────────────────\n * `.claude/settings.json` registers hooks as `sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\"`,\n * and `$CLAUDE_PROJECT_DIR` NEVER moves — proven from four separate worktrees' own logs, every line\n * reading `root=<worktree> projectDir=<primary>`. So every tree is governed by the PRIMARY's shim and\n * the PRIMARY's binary, forever: a worktree can never be judged by the release its own branch pins, and\n * measuring one tree while running another's binary is the non-convergent \"two-tree straddle\" recorded\n * in shim.ts (an agent gave up after four cures).\n *\n * The fix is to re-root the WHOLE hook, not to measure differently. The hooks reference says \"the hook\n * runs in the cwd value from the JSON input\", so a RELATIVE command resolves against the tool call's\n * own tree — each tree runs its own shim, its own binary, its own pin. One version, one tree.\n *\n * That is only safe if the relative path is guaranteed to resolve. Hence this file.\n *\n * ─── Why this is a SEPARATE checked-in file from ai-hook.sh ────────────────────────────────────────\n * 1. It is the ONE file that must stay $CLAUDE_PROJECT_DIR-anchored, so it is the one file that cannot\n * be upgraded per-tree. Its surface is therefore kept minimal: a path check. No drift scraper, no\n * allowlist, no config load, no binary — nothing that needs to change often.\n * 2. Its stability profile is the OPPOSITE of ai-hook.sh's. The shim changes most releases; a `cd`\n * validator should converge and stop. Sharing one file forces the stable half to inherit the\n * volatile half's churn — which is today's problem, one level up.\n * 3. A NEW FILE IS INVISIBLE TO OLD RELEASES; A NEW FLAG BREAKS THEM. Measured 2026-08-06: registering\n * `ai-hook.sh --force-root` made the installed shim read `--force-root` as a BIN NAME, fail to find\n * it, and emit fault U — denying `ls` and the very `cd` that would have fixed it:\n * 16:32:20 --force-root Bash tree=primary fault=U DENY-UNDECLARED ls\n * A separate file is simply not registered until a release that ships it, so old releases are safe.\n *\n * ─── The invariant, maintained inductively ─────────────────────────────────────────────────────────\n * A session always starts at a tree root (primary for the coordinator, the worktree for a subagent).\n * The only thing that moves the shell is a `cd`, and (measured 2026-08-02, effective-tree.ts) a `cd`\n * that stays INSIDE the workspace PERSISTS to later calls, while a `cd` that LEAVES it is RESET by the\n * harness before the next call.\n *\n * If every `cd` that would leave a tree root is refused, \"the shell is at a tree root\" is an\n * INDUCTIVE INVARIANT — so this hook never inspects cwd as a state, only the command.\n *\n * That is also why it is registered for Bash ALONE: no other tool can move the shell.\n *\n * ─── The predicate, in three tests ─────────────────────────────────────────────────────────────────\n * 1. destination holds `.git` → ALLOW. Complete by construction: the primary clone has a .git DIR,\n * every linked worktree (nested or sibling) has a .git FILE, and a nested foreign clone under\n * repositories/** has its own .git DIR. The first two are where the relative hooks launch; the\n * third is a tree we deliberately do not govern.\n * 2. destination is OUTSIDE $CLAUDE_PROJECT_DIR → ALLOW. The harness resets it before the next call,\n * so at most ONE call runs at a path we do not govern — and there is nothing there to guard.\n * 3. otherwise (inside a governed tree, no .git — `tools/`, `dataform/`, `packages/…`) → DENY. This\n * is the only region that is both STICKY and UNGUARDED.\n *\n * It reads NO config. `excludePaths` governs which FILES are enforced, not whether the hook may run,\n * and it cannot be parsed here anyway (this is sh, pre-config, pre-binary). The one measured divergence\n * is `tools/**`: exempt by excludePaths, denied here — correctly, because the real hooks genuinely\n * cannot launch there and we genuinely want them to. Cost: 4 calls in a 2,236-call sample.\n *\n * ─── Nothing to recover from ───────────────────────────────────────────────────────────────────────\n * A denied `cd` NEVER EXECUTES — PreToolUse denies the whole tool call before the shell moves — so the\n * shell is still at a root. There is no bad state and no cure command that needs allowlisting, which is\n * what keeps this hook from ever being able to wedge a session.\n *\n * Measured cost of the deny: 47 of 2,236 real Bash calls (2.10%), or 9.7% of all `cd` commands.\n */\n\nexport const GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';\n\n// webpieces-disable no-function-outside-class -- L-1 sibling of shim.ts's shimPath(); this module is deliberately dependency-free module-scope functions so it stays callable from a tree too broken to build a DI container\nexport function guaranteeRootPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'guarantee-root.sh');\n}\n\n/**\n * Write (or overwrite) the committed L-1 hook. Idempotent — the installer and `wp-upgrade-shim` both\n * call it, and re-running either simply re-arms the file. Twin of setup.ts's writeShim().\n */\n// webpieces-disable no-function-outside-class -- L-1 sibling of guaranteeRootPath(); this module is deliberately dependency-free module-scope functions\nexport function writeGuaranteeRoot(projectRoot: string): void {\n const target = guaranteeRootPath(projectRoot);\n fs.mkdirSync(path.dirname(target), { recursive: true });\n fs.writeFileSync(target, renderGuaranteeRoot(), { mode: 0o755 });\n // writeFileSync's mode is only applied when creating the file; force it on overwrite too.\n fs.chmodSync(target, 0o755);\n}\n\n// Deny REASON constraint, inherited from the shim: the text is interpolated into a `REASON=\"…\"` shell\n// assignment and then printf'd into a JSON string, so it may contain NO double quotes and NO\n// backslashes. Single quotes only — do not \"improve\" them.\nconst DENY_NOT_LITERAL =\n 'This cd target is not a literal path, so the guards cannot tell where the shell will end up. '\n + 'Use a literal absolute path: cd /abs/path && <your command>. '\n + 'A $VAR, ~, $(...) or backtick is never expanded by the guard.';\n\nconst DENY_NO_TARGET =\n 'A bare cd (or cd -) moves the shell somewhere the guards cannot predict - a bare cd goes to '\n + 'your home directory, where the webpieces hooks do not exist and every later tool call would '\n + 'run UNGUARDED. Name the directory: cd /abs/path && <your command>.';\n\n// `%s` is the destination, then the project root. Kept to one short paragraph on purpose: L0 already\n// ran a message diet and these denies regress straight back to a wall of text if each one argues.\nconst DENY_SUBDIR =\n 'The webpieces guard hooks are registered RELATIVE (.claude/webpieces/ai-hook.sh) so that each '\n + 'git tree is governed by its own release. %s has no .claude/webpieces/ai-hook.sh, so a shell '\n + 'parked there launches NO hooks at all and every later tool call runs UNGUARDED - and a cd that '\n + 'stays inside the project PERSISTS to your next call. Run it from the tree root instead: '\n + 'cd %s && <your command>. Tools that take their own directory (git -C, pnpm -C, pnpm --filter, '\n + 'nx) need no cd at all.';\n\n/**\n * The cd audit trail. A THIRD parallel writer joins guards+rules on every Bash call, so it gets its\n * own file under the same session/agent/hook key LogStream uses — one writer per directory, so an\n * append can never interleave with another's (macOS PIPE_BUF is 512 bytes and real log lines exceed it).\n *\n * Unlike the shim's RESOLVE_LOG_DIR_SH this needs NO worktree resolution: L-1 is $CLAUDE_PROJECT_DIR-\n * anchored by definition, so that tree's `.webpieces` is always the right home. That is the one upside\n * of being the hook that cannot follow the tree, and it is kept DELIBERATELY.\n *\n * The cost is stated rather than hidden: for a call inside a linked worktree, L-1's line lands under\n * $CLAUDE_PROJECT_DIR while L0's lands under `worktrees/<name>/`, so the two halves of one tool call\n * sit in different roots. The cure would be to splice RESOLVE_LOG_DIR_SH in here — and that buys a\n * `git rev-parse` subprocess on EVERY Bash call, paid by the one layer whose whole guarantee is that\n * it reads no config, spawns no binary and touches no network. Trading that guarantee for tidier log\n * placement is the wrong way round: `L-1-cd/` is always at $CLAUDE_PROJECT_DIR, which is a rule a\n * reader can simply know.\n *\n * Every write is swallowed and nothing ever reaches stdout — stdout is the PreToolUse decision channel\n * and a stray byte there would corrupt allow/deny.\n */\nconst CD_AUDIT_SH = `# --- cd audit (best-effort; never blocks, never touches stdout) --------------------------------\nSID=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"session_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nAID=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"agent_id\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Untrusted payload values are used as path segments, so anything outside [A-Za-z0-9._-] collapses to _\n# and a leading dot is neutralised — ../../etc can never escape the logs directory.\nclean() { printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' \\\\\n | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64; }\nwp_cd_log() { # $1 = verdict, $2 = destination (may be empty)\n {\n [ -n \"$CLAUDE_PROJECT_DIR\" ] || return 0\n _d=\"$CLAUDE_PROJECT_DIR/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}/${LMINUS1_CD_STREAM}\"\n mkdir -p \"$_d\" 2>/dev/null || return 0\n # The LAYER is the directory; this is the WRITER, keyed exactly like LogStream.writerFile():\n # <session>-<agent|coordinator>-guarantee-root.log. ALWAYS keyed; a missing session_id renders as\n # 'unknown'. No bare-name branch anywhere.\n _p=\"$(clean \"\\${SID:-unknown}\")-$(clean \"\\${AID:-coordinator}\")-guarantee-root\"\n _f=\"$_d/\\${_p}.log\"\n _sz=\"$(wc -c < \"$_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_sz\" in ''|*[!0-9]*) _sz=0 ;; esac\n [ \"$_sz\" -gt 524288 ] && mv -f \"$_f\" \"$_d/\\${_p}.1.log\" 2>/dev/null\n # fault=- is a constant here: L-1 detects no L0 fault. It is present so ONE grep spans every\n # hook-written stream rather than needing a different field list per layer.\n printf '%s\\\\t%s\\\\tfault=-\\\\tdest=%s\\\\tcwd=%s\\\\t%s\\\\n' \\\\\n \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$1\" \"$2\" \"$CWD\" \"$CMD\" >> \"$_f\"\n } 2>/dev/null || true\n}\n`;\n\n\n// The deny boundary. Extracted for the same reason CD_AUDIT_SH and HEADER_SH are — to keep the\n// renderer inside the 70-line method budget — and spliced back verbatim. Mirrors the shim's own emit:\n// for Bash, permissionDecisionReason is NOT user-visible, so the red systemMessage carries the text.\nconst DENY_EMIT_SH = `wp_cd_log DENY \"\\${ABS:-$DEST}\"\n\nBS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\nESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b -> ESC\nprintf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"$REASON\" \"\\${ESC}[0m\" \"$REASON\"\nexit 0 # the decision is carried by permissionDecision deny, not the exit code\n`;\n\n// The file's own banner. Extracted to a module const for the same reason renderShim() extracts\n// VERSION_DRIFT_GUARD_SH — to keep the renderer inside the 80-line method budget — and spliced back\n// in verbatim, byte for byte.\nconst HEADER_SH = `#!/bin/sh\n# webpieces L-1 hook — GUARANTEE ROOT. Generated by renderGuaranteeRoot(); do not hand-edit.\n#\n# Registered ABSOLUTE in .claude/settings.json, matcher \"Bash\":\n# sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh\"\n#\n# It exists because the GUARD hooks beside it are registered RELATIVE, so that each git tree is\n# governed by its own @webpieces release. A relative hook that cannot resolve does not block — the\n# harness logs it and lets the tool call proceed UNGUARDED. This file makes that unreachable by\n# refusing any cd that would park the shell where the relative hooks cannot launch.\n#\n# Three tests, no config, no binary, no network:\n# 1. destination holds .git -> ALLOW (tree root, worktree, or foreign clone)\n# 2. destination is outside $CLAUDE_PROJECT_DIR -> ALLOW (the harness resets the cwd next call)\n# 3. otherwise -> DENY (sticky AND unguarded)\n#\n# A denied cd never runs, so the shell never leaves the root and there is nothing to recover from.\n`;\n\n/**\n * The POSIX-sh source. Byte-identical to `templates/guarantee-root.sh`, locked by a unit test, exactly\n * as renderShim()/templates/ai-hook.sh are — so the file a consumer commits and the file this release\n * expects can never silently diverge.\n */\n// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's renderShim(), byte-locked to templates/guarantee-root.sh; module-scope for the same dependency-free reason\nexport function renderGuaranteeRoot(): string {\n return `${HEADER_SH}\nPAYLOAD=\"$(cat)\"\nCWD=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"cwd\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n\n# The command PREFIX, not the whole command — note there is no closing \" in this pattern.\n# WHY: a JSON payload escapes an embedded double quote as \\\\\", and \\`[^\"\\\\\\\\]*\\` stops dead at that\n# backslash, so the usual \"capture between quotes\" form yields the EMPTY STRING for any command\n# containing a quote at all (measured: \\`cd /a/b && echo \"hi\"\\` -> ''). An empty CMD here would mean\n# \"no cd found\" -> ALLOW, i.e. this guard would fail OPEN for every quoted command — the exact hazard\n# it exists to close. Capturing only up to the first quote/backslash is enough, because everything\n# L-1 needs (is the FIRST word a cd, and what is its target) lives in the prefix; a quote can only\n# appear later, in the part we do not need.\nCMD=\"$(printf '%s' \"$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\).*/\\\\1/p')\"\n\n# Only Bash can move the shell. Anything else, and any payload we cannot read, is not ours.\n[ \"$TOOL\" = \"Bash\" ] || exit 0\n[ -n \"$CMD\" ] || exit 0\n\n${CD_AUDIT_SH}\n\n# Does the command OPEN with cd/pushd? Only a LEADING cd counts — the same rule effective-tree.ts\n# enforces, because a later cd cannot retroactively move a command that has already run.\nFIRST=\"$(printf '%s' \"$CMD\" | sed -n 's/^[[:space:]]*\\\\([^[:space:]]\\\\{1,\\\\}\\\\).*/\\\\1/p')\"\ncase \"$FIRST\" in\n cd|pushd) ;;\n *) exit 0 ;; # no leading cd: nothing to audit, nothing to judge\nesac\n\n# The target: a single-quoted path first (that is how a path with spaces is spelled), else a bare word.\nDEST=\"$(printf '%s' \"$CMD\" | sed -n \"s/^[[:space:]]*[a-z]\\\\{2,5\\\\}[[:space:]]\\\\{1,\\\\}'\\\\([^']*\\\\)'.*/\\\\1/p\")\"\n[ -n \"$DEST\" ] || DEST=\"$(printf '%s' \"$CMD\" | sed -n 's/^[[:space:]]*[a-z]\\\\{2,5\\\\}[[:space:]]\\\\{1,\\\\}\\\\([^[:space:];&|]\\\\{1,\\\\}\\\\).*/\\\\1/p')\"\n\nREASON=\"\"\nif [ -z \"$DEST\" ] || [ \"$DEST\" = \"-\" ]; then\n REASON='${DENY_NO_TARGET}'\nelse\n # A target the guard cannot expand is a target it cannot judge. sh has no regex here, so test the\n # four unexpandable shapes directly.\n case \"$DEST\" in\n *'$'*|*'\\`'*|'~'|'~/'*) REASON='${DENY_NOT_LITERAL}' ;;\n esac\nfi\n\nif [ -z \"$REASON\" ]; then\n # Resolve against the shell's real cwd. A destination that does not exist needs no verdict: the cd\n # itself will fail and the shell stays exactly where it is.\n ABS=\"$(CDPATH= cd -- \"\\${CWD:-.}\" 2>/dev/null && CDPATH= cd -- \"$DEST\" 2>/dev/null && pwd)\"\n if [ -z \"$ABS\" ]; then wp_cd_log ALLOW-NO-SUCH-DIR \"$DEST\"; exit 0; fi\n\n # TEST 1 — a git tree of any kind. A worktree's .git is a FILE, a clone's is a DIR; -e covers both.\n if [ -e \"$ABS/.git\" ]; then wp_cd_log ALLOW-GIT-TREE \"$ABS\"; exit 0; fi\n\n # TEST 2 — outside the governed project. The harness resets the cwd before the next call, so at most\n # one command runs there, on paths we do not govern anyway.\n case \"$ABS/\" in\n \"$CLAUDE_PROJECT_DIR\"/*) ;;\n *) wp_cd_log ALLOW-OUTSIDE \"$ABS\"; exit 0 ;;\n esac\n\n # TEST 3 — inside a governed tree with no shim beside it: sticky AND unguarded.\n REASON=\"$(printf '${DENY_SUBDIR}' \"$ABS\" \"$CLAUDE_PROJECT_DIR\")\"\nfi\n\n${DENY_EMIT_SH}`;\n}\n\n/**\n * True when a committed guarantee-root.sh EXISTS but no longer equals renderGuaranteeRoot(). Missing\n * file → false: a repo that has not adopted L-1 yet is not \"stale\", it is simply still on the two-hook\n * registration, and `wp-install-ai-hooks` is what moves it forward.\n *\n * `root` is the tree whose committed copy this BINARY governs — resolved from the running module's own\n * location by governingShimRoot(), never from cwd and never from $CLAUDE_PROJECT_DIR, for the same\n * reason the shim's own check is anchored that way: it makes the two-tree straddle unconstructible.\n */\n// webpieces-disable no-function-outside-class -- L-1 twin of shim.ts's committedShimStale(); module-scope for the same dependency-free reason\nexport function committedGuaranteeRootStale(root: string | null): boolean {\n if (root === null) return false;\n const file = guaranteeRootPath(root);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(file)) return false;\n return fs.readFileSync(file, 'utf8') !== renderGuaranteeRoot();\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: an unreadable tree counts as \"not stale\" so this never wedges a call\n return false;\n }\n}\n"]}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { EffectiveTree } from './effective-tree';
|
|
2
|
-
/**
|
|
3
|
-
* WHO is making this tool call — the coordinator (the main agent loop) or a subagent?
|
|
4
|
-
*
|
|
5
|
-
* The PreToolUse payload carries `agent_id` / `agent_type` ONLY when the hook fires inside a SUBAGENT.
|
|
6
|
-
* Absent means the coordinator. That is the whole signal; there is no positive "I am the coordinator"
|
|
7
|
-
* field to read, so this class exists to make the absence explicit rather than an inline `?? ''`.
|
|
8
|
-
*
|
|
9
|
-
* Data-only (per CLAUDE.md, classes for data) — `coordinator` is derived once in the constructor, the
|
|
10
|
-
* same way EffectiveTree derives `redirected`.
|
|
11
|
-
*/
|
|
12
|
-
export declare class AgentIdentity {
|
|
13
|
-
/** Empty when the hook fired in the coordinator. */
|
|
14
|
-
readonly agentId: string;
|
|
15
|
-
/** Empty when the hook fired in the coordinator. */
|
|
16
|
-
readonly agentType: string;
|
|
17
|
-
readonly coordinator: boolean;
|
|
18
|
-
constructor(agentId: string, agentType: string);
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* The default for every caller that cannot tell — library consumers, the openclaw adapter, and every
|
|
22
|
-
* existing runBash() call site. It reads as NOT the coordinator on purpose: only the Claude Code
|
|
23
|
-
* adapter parses the payload that carries the answer, and a caller who does not know must never be
|
|
24
|
-
* GUESSED into a block. Fail open; the one caller that knows passes the real identity.
|
|
25
|
-
*/
|
|
26
|
-
export declare const UNKNOWN_AGENT: AgentIdentity;
|
|
27
|
-
/**
|
|
28
|
-
* L1: the COORDINATOR must not work inside a linked worktree — it must delegate that work to a
|
|
29
|
-
* subagent bound to the worktree.
|
|
30
|
-
*
|
|
31
|
-
* THE INCIDENT (reproduced from a transcript). The coordinator ran `git worktree add ../l2-matrix-doc`,
|
|
32
|
-
* `cd`'d in, and worked there for the rest of the session. Its governance stayed anchored to the
|
|
33
|
-
* PRIMARY clone — `$CLAUDE_PROJECT_DIR` is fixed at session start and does not follow a `cd` — while
|
|
34
|
-
* its filesystem was in the worktree. An L0 version-drift fault then fired against the PRIMARY
|
|
35
|
-
* (pin 0.4.545 vs node_modules 0.4.526) and prescribed `pnpm install`; that install ran in the
|
|
36
|
-
* WORKTREE, which was internally consistent at 0.4.526/0.4.526, so it succeeded, changed nothing in
|
|
37
|
-
* the measured tree, and the guard re-denied. Five identical installs later the agent had invented a
|
|
38
|
-
* false theory ("the harness is stripping my cd prefix") and handed the problem to the human.
|
|
39
|
-
*
|
|
40
|
-
* The cure is not a better message on that fault — it is upstream of it: a split between the tree the
|
|
41
|
-
* agent stands in and the tree that governs it must not be reachable at all. A subagent bound to the
|
|
42
|
-
* worktree has both in one place, so the project stays governed consistently.
|
|
43
|
-
*
|
|
44
|
-
* SCOPE, deliberately narrow:
|
|
45
|
-
* - `kind === 'worktree'` only. `'primary'` is home; `'foreign'` and `'outside'` are other
|
|
46
|
-
* jurisdictions and this guard leaves their classification exactly as it was.
|
|
47
|
-
* - subagents are never blocked — one pinned to a worktree is the CORRECT pattern.
|
|
48
|
-
* - reading is never blocked. The Read tool never reaches here, and a provably-inert inspection
|
|
49
|
-
* command is allowed through so the coordinator can still look around before it delegates.
|
|
50
|
-
*
|
|
51
|
-
* NOT in scope, and not a hole: the L0 cure allowlist runs BEFORE this in runBashInternal, so
|
|
52
|
-
* `cd <worktree> && pnpm install` — the literal command from the incident — still passes. That is the
|
|
53
|
-
* invariant L0 exists to hold (a cure must stay reachable), and it is fine: what this guard removes is
|
|
54
|
-
* the session shape in which running that install in the wrong tree was ever plausible.
|
|
55
|
-
*/
|
|
56
|
-
export declare class CoordinatorWorktreeGuard {
|
|
57
|
-
private readonly inspection;
|
|
58
|
-
/** The deny report, or null to allow. */
|
|
59
|
-
block(command: string, tree: EffectiveTree, agent: AgentIdentity): string | null;
|
|
60
|
-
private report;
|
|
61
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CoordinatorWorktreeGuard = exports.UNKNOWN_AGENT = exports.AgentIdentity = void 0;
|
|
4
|
-
const read_only_inspection_1 = require("./read-only-inspection");
|
|
5
|
-
/**
|
|
6
|
-
* WHO is making this tool call — the coordinator (the main agent loop) or a subagent?
|
|
7
|
-
*
|
|
8
|
-
* The PreToolUse payload carries `agent_id` / `agent_type` ONLY when the hook fires inside a SUBAGENT.
|
|
9
|
-
* Absent means the coordinator. That is the whole signal; there is no positive "I am the coordinator"
|
|
10
|
-
* field to read, so this class exists to make the absence explicit rather than an inline `?? ''`.
|
|
11
|
-
*
|
|
12
|
-
* Data-only (per CLAUDE.md, classes for data) — `coordinator` is derived once in the constructor, the
|
|
13
|
-
* same way EffectiveTree derives `redirected`.
|
|
14
|
-
*/
|
|
15
|
-
class AgentIdentity {
|
|
16
|
-
/** Empty when the hook fired in the coordinator. */
|
|
17
|
-
agentId;
|
|
18
|
-
/** Empty when the hook fired in the coordinator. */
|
|
19
|
-
agentType;
|
|
20
|
-
coordinator;
|
|
21
|
-
constructor(agentId, agentType) {
|
|
22
|
-
this.agentId = agentId;
|
|
23
|
-
this.agentType = agentType;
|
|
24
|
-
this.coordinator = agentId === '' && agentType === '';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
exports.AgentIdentity = AgentIdentity;
|
|
28
|
-
/**
|
|
29
|
-
* The default for every caller that cannot tell — library consumers, the openclaw adapter, and every
|
|
30
|
-
* existing runBash() call site. It reads as NOT the coordinator on purpose: only the Claude Code
|
|
31
|
-
* adapter parses the payload that carries the answer, and a caller who does not know must never be
|
|
32
|
-
* GUESSED into a block. Fail open; the one caller that knows passes the real identity.
|
|
33
|
-
*/
|
|
34
|
-
exports.UNKNOWN_AGENT = new AgentIdentity('unknown', 'unknown');
|
|
35
|
-
/**
|
|
36
|
-
* L1: the COORDINATOR must not work inside a linked worktree — it must delegate that work to a
|
|
37
|
-
* subagent bound to the worktree.
|
|
38
|
-
*
|
|
39
|
-
* THE INCIDENT (reproduced from a transcript). The coordinator ran `git worktree add ../l2-matrix-doc`,
|
|
40
|
-
* `cd`'d in, and worked there for the rest of the session. Its governance stayed anchored to the
|
|
41
|
-
* PRIMARY clone — `$CLAUDE_PROJECT_DIR` is fixed at session start and does not follow a `cd` — while
|
|
42
|
-
* its filesystem was in the worktree. An L0 version-drift fault then fired against the PRIMARY
|
|
43
|
-
* (pin 0.4.545 vs node_modules 0.4.526) and prescribed `pnpm install`; that install ran in the
|
|
44
|
-
* WORKTREE, which was internally consistent at 0.4.526/0.4.526, so it succeeded, changed nothing in
|
|
45
|
-
* the measured tree, and the guard re-denied. Five identical installs later the agent had invented a
|
|
46
|
-
* false theory ("the harness is stripping my cd prefix") and handed the problem to the human.
|
|
47
|
-
*
|
|
48
|
-
* The cure is not a better message on that fault — it is upstream of it: a split between the tree the
|
|
49
|
-
* agent stands in and the tree that governs it must not be reachable at all. A subagent bound to the
|
|
50
|
-
* worktree has both in one place, so the project stays governed consistently.
|
|
51
|
-
*
|
|
52
|
-
* SCOPE, deliberately narrow:
|
|
53
|
-
* - `kind === 'worktree'` only. `'primary'` is home; `'foreign'` and `'outside'` are other
|
|
54
|
-
* jurisdictions and this guard leaves their classification exactly as it was.
|
|
55
|
-
* - subagents are never blocked — one pinned to a worktree is the CORRECT pattern.
|
|
56
|
-
* - reading is never blocked. The Read tool never reaches here, and a provably-inert inspection
|
|
57
|
-
* command is allowed through so the coordinator can still look around before it delegates.
|
|
58
|
-
*
|
|
59
|
-
* NOT in scope, and not a hole: the L0 cure allowlist runs BEFORE this in runBashInternal, so
|
|
60
|
-
* `cd <worktree> && pnpm install` — the literal command from the incident — still passes. That is the
|
|
61
|
-
* invariant L0 exists to hold (a cure must stay reachable), and it is fine: what this guard removes is
|
|
62
|
-
* the session shape in which running that install in the wrong tree was ever plausible.
|
|
63
|
-
*/
|
|
64
|
-
class CoordinatorWorktreeGuard {
|
|
65
|
-
inspection = new read_only_inspection_1.ReadOnlyInspectionScan();
|
|
66
|
-
/** The deny report, or null to allow. */
|
|
67
|
-
block(command, tree, agent) {
|
|
68
|
-
if (!agent.coordinator)
|
|
69
|
-
return null;
|
|
70
|
-
if (tree.kind !== 'worktree')
|
|
71
|
-
return null;
|
|
72
|
-
if (this.inspection.isReadOnlyInspection(command))
|
|
73
|
-
return null;
|
|
74
|
-
return this.report(tree);
|
|
75
|
-
}
|
|
76
|
-
// Short on purpose — main landed a deliberate L0 message diet (384cdae) and these blocks regress
|
|
77
|
-
// straight back to a wall of text if each new one argues its case. State the block, name the
|
|
78
|
-
// worktree, prescribe the subagent, list the read-only escapes. Nothing else.
|
|
79
|
-
report(tree) {
|
|
80
|
-
return [
|
|
81
|
-
`❌ You are the COORDINATOR and this command works inside linked worktree ${tree.root}.`,
|
|
82
|
-
` Your governance is anchored to ${tree.governedRoot} and does NOT follow a \`cd\`, so working`,
|
|
83
|
-
` here splits your filesystem from your guards — the failure that burned five no-op installs.`,
|
|
84
|
-
'',
|
|
85
|
-
' Spawn a subagent bound to that worktree and work through it: the Agent tool with worktree',
|
|
86
|
-
` isolation, or have the subagent call EnterWorktree with path: ${tree.root}`,
|
|
87
|
-
'',
|
|
88
|
-
` You can still READ from here without moving: the Read tool, \`git -C ${tree.root} <cmd>\`,`,
|
|
89
|
-
' or `git show <branch>:<file>`.',
|
|
90
|
-
].join('\n');
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
exports.CoordinatorWorktreeGuard = CoordinatorWorktreeGuard;
|
|
94
|
-
//# sourceMappingURL=coordinator-worktree.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"coordinator-worktree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/coordinator-worktree.ts"],"names":[],"mappings":";;;AACA,iEAAgE;AAEhE;;;;;;;;;GASG;AACH,MAAa,aAAa;IACtB,oDAAoD;IAC3C,OAAO,CAAS;IACzB,oDAAoD;IAC3C,SAAS,CAAS;IAClB,WAAW,CAAU;IAE9B,YAAY,OAAe,EAAE,SAAiB;QAC1C,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,WAAW,GAAG,OAAO,KAAK,EAAE,IAAI,SAAS,KAAK,EAAE,CAAC;IAC1D,CAAC;CACJ;AAZD,sCAYC;AAED;;;;;GAKG;AACU,QAAA,aAAa,GAAG,IAAI,aAAa,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AAErE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAa,wBAAwB;IAChB,UAAU,GAAG,IAAI,6CAAsB,EAAE,CAAC;IAE3D,yCAAyC;IACzC,KAAK,CAAC,OAAe,EAAE,IAAmB,EAAE,KAAoB;QAC5D,IAAI,CAAC,KAAK,CAAC,WAAW;YAAE,OAAO,IAAI,CAAC;QACpC,IAAI,IAAI,CAAC,IAAI,KAAK,UAAU;YAAE,OAAO,IAAI,CAAC;QAC1C,IAAI,IAAI,CAAC,UAAU,CAAC,oBAAoB,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAC/D,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC;IAED,iGAAiG;IACjG,6FAA6F;IAC7F,8EAA8E;IACtE,MAAM,CAAC,IAAmB;QAC9B,OAAO;YACH,2EAA2E,IAAI,CAAC,IAAI,GAAG;YACvF,qCAAqC,IAAI,CAAC,YAAY,2CAA2C;YACjG,gGAAgG;YAChG,EAAE;YACF,8FAA8F;YAC9F,oEAAoE,IAAI,CAAC,IAAI,EAAE;YAC/E,EAAE;YACF,2EAA2E,IAAI,CAAC,IAAI,WAAW;YAC/F,mCAAmC;SACtC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;CACJ;AA3BD,4DA2BC","sourcesContent":["import { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\n\n/**\n * WHO is making this tool call — the coordinator (the main agent loop) or a subagent?\n *\n * The PreToolUse payload carries `agent_id` / `agent_type` ONLY when the hook fires inside a SUBAGENT.\n * Absent means the coordinator. That is the whole signal; there is no positive \"I am the coordinator\"\n * field to read, so this class exists to make the absence explicit rather than an inline `?? ''`.\n *\n * Data-only (per CLAUDE.md, classes for data) — `coordinator` is derived once in the constructor, the\n * same way EffectiveTree derives `redirected`.\n */\nexport class AgentIdentity {\n /** Empty when the hook fired in the coordinator. */\n readonly agentId: string;\n /** Empty when the hook fired in the coordinator. */\n readonly agentType: string;\n readonly coordinator: boolean;\n\n constructor(agentId: string, agentType: string) {\n this.agentId = agentId;\n this.agentType = agentType;\n this.coordinator = agentId === '' && agentType === '';\n }\n}\n\n/**\n * The default for every caller that cannot tell — library consumers, the openclaw adapter, and every\n * existing runBash() call site. It reads as NOT the coordinator on purpose: only the Claude Code\n * adapter parses the payload that carries the answer, and a caller who does not know must never be\n * GUESSED into a block. Fail open; the one caller that knows passes the real identity.\n */\nexport const UNKNOWN_AGENT = new AgentIdentity('unknown', 'unknown');\n\n/**\n * L1: the COORDINATOR must not work inside a linked worktree — it must delegate that work to a\n * subagent bound to the worktree.\n *\n * THE INCIDENT (reproduced from a transcript). The coordinator ran `git worktree add ../l2-matrix-doc`,\n * `cd`'d in, and worked there for the rest of the session. Its governance stayed anchored to the\n * PRIMARY clone — `$CLAUDE_PROJECT_DIR` is fixed at session start and does not follow a `cd` — while\n * its filesystem was in the worktree. An L0 version-drift fault then fired against the PRIMARY\n * (pin 0.4.545 vs node_modules 0.4.526) and prescribed `pnpm install`; that install ran in the\n * WORKTREE, which was internally consistent at 0.4.526/0.4.526, so it succeeded, changed nothing in\n * the measured tree, and the guard re-denied. Five identical installs later the agent had invented a\n * false theory (\"the harness is stripping my cd prefix\") and handed the problem to the human.\n *\n * The cure is not a better message on that fault — it is upstream of it: a split between the tree the\n * agent stands in and the tree that governs it must not be reachable at all. A subagent bound to the\n * worktree has both in one place, so the project stays governed consistently.\n *\n * SCOPE, deliberately narrow:\n * - `kind === 'worktree'` only. `'primary'` is home; `'foreign'` and `'outside'` are other\n * jurisdictions and this guard leaves their classification exactly as it was.\n * - subagents are never blocked — one pinned to a worktree is the CORRECT pattern.\n * - reading is never blocked. The Read tool never reaches here, and a provably-inert inspection\n * command is allowed through so the coordinator can still look around before it delegates.\n *\n * NOT in scope, and not a hole: the L0 cure allowlist runs BEFORE this in runBashInternal, so\n * `cd <worktree> && pnpm install` — the literal command from the incident — still passes. That is the\n * invariant L0 exists to hold (a cure must stay reachable), and it is fine: what this guard removes is\n * the session shape in which running that install in the wrong tree was ever plausible.\n */\nexport class CoordinatorWorktreeGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree, agent: AgentIdentity): string | null {\n if (!agent.coordinator) return null;\n if (tree.kind !== 'worktree') return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n return this.report(tree);\n }\n\n // Short on purpose — main landed a deliberate L0 message diet (384cdae) and these blocks regress\n // straight back to a wall of text if each new one argues its case. State the block, name the\n // worktree, prescribe the subagent, list the read-only escapes. Nothing else.\n private report(tree: EffectiveTree): string {\n return [\n `❌ You are the COORDINATOR and this command works inside linked worktree ${tree.root}.`,\n ` Your governance is anchored to ${tree.governedRoot} and does NOT follow a \\`cd\\`, so working`,\n ` here splits your filesystem from your guards — the failure that burned five no-op installs.`,\n '',\n ' Spawn a subagent bound to that worktree and work through it: the Agent tool with worktree',\n ` isolation, or have the subagent call EnterWorktree with path: ${tree.root}`,\n '',\n ` You can still READ from here without moving: the Read tool, \\`git -C ${tree.root} <cmd>\\`,`,\n ' or `git show <branch>:<file>`.',\n ].join('\\n');\n }\n}\n"]}
|