@codyswann/lisa 4.41.1 → 4.42.1
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/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +3 -3
- package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +327 -0
- package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.sh +57 -0
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +49 -3
- package/all/merge/.claude/settings.json +11 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +22 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +21 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +18 -1
- package/plugins/lisa/.codex-plugin/hooks.json +17 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/worktree-binding-guard.mjs +324 -0
- package/plugins/lisa/hooks/worktree-binding-guard.sh +54 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +18 -1
- package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +324 -0
- package/plugins/lisa-copilot/hooks/worktree-binding-guard.sh +54 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +12 -0
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +324 -0
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.sh +54 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/materialized-artifacts.json +2 -0
- package/plugins/src/base/.claude-plugin/plugin.json +17 -0
- package/plugins/src/base/hooks/worktree-binding-guard.mjs +324 -0
- package/plugins/src/base/hooks/worktree-binding-guard.sh +54 -0
- package/scripts/build-plugins.sh +2 -2
- package/scripts/lisa-enforcement-fallback.sh +3 -3
- package/scripts/two-channel-couplings.json +1 -1
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"all/copy-overwrite/scripts/lisa-hooks/parity-safety-net-heredoc.py",
|
|
12
12
|
"all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh",
|
|
13
13
|
"all/copy-overwrite/scripts/lisa-hooks/sonar-secrets.sh",
|
|
14
|
+
"all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs",
|
|
15
|
+
"all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.sh",
|
|
14
16
|
"expo/copy-overwrite/scripts/lib/bounded-spawn.mjs",
|
|
15
17
|
"expo/copy-overwrite/scripts/lib/invoked-as-script.mjs",
|
|
16
18
|
"rails/copy-overwrite/scripts/check-threshold-ratchet.mjs",
|
|
@@ -107,6 +107,15 @@
|
|
|
107
107
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/withdrawn-rulings.sh --hook"
|
|
108
108
|
}
|
|
109
109
|
]
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"matcher": "EnterWorktree",
|
|
113
|
+
"hooks": [
|
|
114
|
+
{
|
|
115
|
+
"type": "command",
|
|
116
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/worktree-binding-guard.sh"
|
|
117
|
+
}
|
|
118
|
+
]
|
|
110
119
|
}
|
|
111
120
|
],
|
|
112
121
|
"PreToolUse": [
|
|
@@ -149,6 +158,10 @@
|
|
|
149
158
|
{
|
|
150
159
|
"type": "command",
|
|
151
160
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-managed-file-edits.sh"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"type": "command",
|
|
164
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/worktree-binding-guard.sh"
|
|
152
165
|
}
|
|
153
166
|
]
|
|
154
167
|
},
|
|
@@ -162,6 +175,10 @@
|
|
|
162
175
|
{
|
|
163
176
|
"type": "command",
|
|
164
177
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-managed-file-edits.sh"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"type": "command",
|
|
181
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/worktree-binding-guard.sh"
|
|
165
182
|
}
|
|
166
183
|
]
|
|
167
184
|
},
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Reconcile the worktree a session was TOLD it is in against the one it is
|
|
4
|
+
* ACTUALLY operating in, and refuse to act while the two disagree.
|
|
5
|
+
*
|
|
6
|
+
* WHAT WENT WRONG. `EnterWorktree` reported "the session is now working in the
|
|
7
|
+
* worktree" for a tree that Bash never moved to and that Edit then refused by
|
|
8
|
+
* name (CodySwannGT/lisa#3864). Three subsystems, three answers, and the one
|
|
9
|
+
* that answered affirmatively was the wrong one. A refusal teaches you
|
|
10
|
+
* something; a success message that is not true is a confirmation — it removes
|
|
11
|
+
* the reason to check and sends the agent forward confidently in the wrong
|
|
12
|
+
* place. Acting on that confirmation, one session ran an install that landed in
|
|
13
|
+
* a different agent's active worktree. The next such command is a `git commit`
|
|
14
|
+
* or a file write, and that tree held eighteen modified tracked files.
|
|
15
|
+
*
|
|
16
|
+
* The displacement is also mutual: whoever calls `EnterWorktree` last wins for
|
|
17
|
+
* every session whose own binding never took, so each agent repairing itself
|
|
18
|
+
* displaces the others. That is why "just re-enter your worktree" is not a
|
|
19
|
+
* remedy — it is the mechanism.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS FIXES AND WHAT IT CANNOT. Lisa does not own `EnterWorktree`, so it
|
|
22
|
+
* cannot make that call itself return failure. What it can do is make the
|
|
23
|
+
* false confirmation cost nothing: the FIRST action taken on the strength of it
|
|
24
|
+
* is refused, and the refusal names the worktree the session is really bound
|
|
25
|
+
* to. The gap is stated rather than hidden — the acceptance criterion asking
|
|
26
|
+
* `EnterWorktree` to fail is met one tool call later, by the guard, not by the
|
|
27
|
+
* tool.
|
|
28
|
+
*
|
|
29
|
+
* WHY BLOCKING AND NOT A WARNING. The failure mode is that nothing feels wrong.
|
|
30
|
+
* There is no symptom to notice, so an advisory line scrolls past exactly when
|
|
31
|
+
* it matters. A refusal is the only form of this check the agent cannot read
|
|
32
|
+
* past.
|
|
33
|
+
*
|
|
34
|
+
* WHY AN EXPLICIT ACKNOWLEDGEMENT AND NOT "BLOCK ONCE". A guard that blocks the
|
|
35
|
+
* first attempt and lets the retry through is defeated by a blind retry, which
|
|
36
|
+
* is the single most likely next action. So the block stands until the session
|
|
37
|
+
* states, in the acknowledgement, the absolute path it now intends to work in.
|
|
38
|
+
* A path that does not match what the session is actually in is refused too —
|
|
39
|
+
* that is the rejection control, and it is what stops a stale acknowledgement
|
|
40
|
+
* copied from another agent's transcript from silently rebinding this one.
|
|
41
|
+
*
|
|
42
|
+
* FAILING OPEN, LOUDLY. Anything this cannot determine — no session id, no
|
|
43
|
+
* cwd, not a git repository, an unreadable state file — exits 0 with a line on
|
|
44
|
+
* stderr. A guard that cannot read its input cannot tell a displacement from a
|
|
45
|
+
* directory listing, and one that wedges every tool call is switched off within
|
|
46
|
+
* the hour. Silence is the only outcome that is never acceptable.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { execFileSync } from "node:child_process";
|
|
50
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { homedir } from "node:os";
|
|
52
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
53
|
+
|
|
54
|
+
/** Tools whose effect on the wrong worktree is not undoable by re-reading. */
|
|
55
|
+
const GUARDED_TOOLS = new Set(["Bash", "Write", "Edit", "MultiEdit"]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The literal an agent echoes to rebind this session deliberately.
|
|
59
|
+
*
|
|
60
|
+
* A shell line rather than a flag on this script, because the only surface the
|
|
61
|
+
* agent has for saying so is a Bash command, and it must be one this guard sees
|
|
62
|
+
* on its own PreToolUse pass — which is exactly where a command string arrives.
|
|
63
|
+
*/
|
|
64
|
+
const ACCEPT_PREFIX = "lisa-worktree-binding: accept";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a path the way git reports one.
|
|
68
|
+
*
|
|
69
|
+
* `git rev-parse --show-toplevel` answers with symlinks resolved, and on macOS
|
|
70
|
+
* every temporary directory is a symlink — `/var/…` is `/private/var/…`. A
|
|
71
|
+
* comparison between git's answer and a path handed in by a tool would then
|
|
72
|
+
* differ by that prefix alone and read as a displacement, which is a false
|
|
73
|
+
* accusation in exactly the state the guard is supposed to be trusted in. A
|
|
74
|
+
* path that does not exist yet cannot be resolved and is returned as given.
|
|
75
|
+
* @param value - Absolute path to normalize
|
|
76
|
+
* @returns The path with symlinks resolved where possible
|
|
77
|
+
*/
|
|
78
|
+
function realpath(value) {
|
|
79
|
+
try {
|
|
80
|
+
return realpathSync(value);
|
|
81
|
+
} catch {
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function say(message) {
|
|
87
|
+
process.stderr.write(`worktree-binding-guard: ${message}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run git, returning trimmed stdout, or null when it exits non-zero. */
|
|
91
|
+
function git(args, cwd) {
|
|
92
|
+
try {
|
|
93
|
+
return execFileSync("git", args, {
|
|
94
|
+
cwd,
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
97
|
+
}).trim();
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The worktree root containing `cwd`, or null when `cwd` is not in a repo. */
|
|
104
|
+
function worktreeRoot(cwd) {
|
|
105
|
+
const top = git(["rev-parse", "--show-toplevel"], cwd);
|
|
106
|
+
return top ? resolve(top) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The MAIN checkout's root, which is where `.claude/worktrees/` lives.
|
|
111
|
+
*
|
|
112
|
+
* `--git-common-dir` is shared by every linked worktree, so its parent is the
|
|
113
|
+
* main checkout no matter which worktree asks.
|
|
114
|
+
*/
|
|
115
|
+
function mainCheckout(cwd) {
|
|
116
|
+
const common = git(
|
|
117
|
+
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
118
|
+
cwd
|
|
119
|
+
);
|
|
120
|
+
return common ? dirname(resolve(common)) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stateFile(sessionId) {
|
|
124
|
+
const home = process.env.LISA_STATE_HOME || join(homedir(), ".lisa");
|
|
125
|
+
return join(home, "worktree-binding", `${sessionId}.json`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readState(sessionId) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeState(sessionId, state) {
|
|
137
|
+
const file = stateFile(sessionId);
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
140
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
say(`could not record the binding (${error.message}); NOT enforcing`);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The worktree `EnterWorktree` claims the session moved to.
|
|
150
|
+
*
|
|
151
|
+
* `path` is given verbatim; `name` resolves under the main checkout's
|
|
152
|
+
* `.claude/worktrees/`, which is where the tool creates one. A call carrying
|
|
153
|
+
* neither is a generated name this guard cannot predict, so it returns null and
|
|
154
|
+
* the claim is simply not recorded — an unrecorded claim degrades to the plain
|
|
155
|
+
* displacement check rather than to a false accusation.
|
|
156
|
+
*/
|
|
157
|
+
function claimedRoot(toolInput, cwd) {
|
|
158
|
+
const path = toolInput?.path;
|
|
159
|
+
if (typeof path === "string" && path.trim()) {
|
|
160
|
+
return realpath(resolve(isAbsolute(path) ? path : join(cwd, path)));
|
|
161
|
+
}
|
|
162
|
+
const name = toolInput?.name;
|
|
163
|
+
if (typeof name === "string" && name.trim()) {
|
|
164
|
+
const main = mainCheckout(cwd);
|
|
165
|
+
return main
|
|
166
|
+
? realpath(join(main, ".claude", "worktrees", name.trim()))
|
|
167
|
+
: null;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Record what `EnterWorktree` just claimed, for the next tool call to check.
|
|
174
|
+
*
|
|
175
|
+
* Returns nothing on purpose. This runs on PostToolUse, where the tool has
|
|
176
|
+
* already happened and there is no call left to refuse, so every path through
|
|
177
|
+
* it is an allow — and a function whose only answer is "allow" should not be
|
|
178
|
+
* spelled as one that computes an exit code.
|
|
179
|
+
*/
|
|
180
|
+
function recordClaim(payload, observed) {
|
|
181
|
+
const claimed = claimedRoot(payload.tool_input, payload.cwd);
|
|
182
|
+
if (!claimed || claimed === observed) return;
|
|
183
|
+
const previous = readState(payload.session_id);
|
|
184
|
+
writeState(payload.session_id, {
|
|
185
|
+
boundRoot: previous?.boundRoot ?? observed,
|
|
186
|
+
claimedRoot: claimed,
|
|
187
|
+
updatedAt: new Date().toISOString(),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function refuse(lines) {
|
|
192
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function acceptanceLine(observed) {
|
|
197
|
+
return ` echo '${ACCEPT_PREFIX} ${observed}'`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Handle an acknowledgement line, or return null when the command is not one.
|
|
202
|
+
*
|
|
203
|
+
* The path is compared against what the session is measurably in, not against
|
|
204
|
+
* what it says it wants. An acknowledgement naming somewhere else is the case
|
|
205
|
+
* this is for: it means the operator is reasoning about a tree they are not in.
|
|
206
|
+
*/
|
|
207
|
+
function handleAcceptance(payload, observed) {
|
|
208
|
+
const command = payload.tool_input?.command;
|
|
209
|
+
if (typeof command !== "string" || !command.includes(ACCEPT_PREFIX))
|
|
210
|
+
return null;
|
|
211
|
+
const stated = command.slice(
|
|
212
|
+
command.indexOf(ACCEPT_PREFIX) + ACCEPT_PREFIX.length
|
|
213
|
+
);
|
|
214
|
+
const match = /([^\s'"]+)/.exec(stated);
|
|
215
|
+
const path = match ? realpath(resolve(match[1])) : null;
|
|
216
|
+
if (path !== observed) {
|
|
217
|
+
return refuse([
|
|
218
|
+
`worktree-binding-guard: this acknowledgement names ${path ?? "no path"},`,
|
|
219
|
+
`but this session is operating in ${observed}. Not rebinding.`,
|
|
220
|
+
`If ${observed} is where you mean to work, acknowledge that path:`,
|
|
221
|
+
acceptanceLine(observed),
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
writeState(payload.session_id, {
|
|
225
|
+
boundRoot: observed,
|
|
226
|
+
claimedRoot: null,
|
|
227
|
+
updatedAt: new Date().toISOString(),
|
|
228
|
+
});
|
|
229
|
+
say(`bound to ${observed}`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Refusal text for a switch that reported success without taking effect. */
|
|
234
|
+
function unconfirmedSwitch(claimed, observed) {
|
|
235
|
+
return refuse([
|
|
236
|
+
`worktree-binding-guard: EnterWorktree reported success for`,
|
|
237
|
+
` ${claimed}`,
|
|
238
|
+
`but this session is still operating in`,
|
|
239
|
+
` ${observed}`,
|
|
240
|
+
"The switch did not take effect. Anything run now lands in the second",
|
|
241
|
+
"path, not the first — including installs, commits, and file writes, and",
|
|
242
|
+
"that tree may hold another agent's uncommitted work.",
|
|
243
|
+
"",
|
|
244
|
+
"Use absolute paths under the tree you actually want, or acknowledge that",
|
|
245
|
+
"you intend to keep working where you are:",
|
|
246
|
+
acceptanceLine(observed),
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Refusal text for a binding that moved without this session asking. */
|
|
251
|
+
function displaced(bound, observed) {
|
|
252
|
+
return refuse([
|
|
253
|
+
"worktree-binding-guard: this session's worktree changed underneath it.",
|
|
254
|
+
` bound to: ${bound}`,
|
|
255
|
+
` now in: ${observed}`,
|
|
256
|
+
"Concurrent sessions share one worktree binding, so another agent's",
|
|
257
|
+
"EnterWorktree can move this one. Commands run now land in the second",
|
|
258
|
+
"path. If you moved deliberately, say so:",
|
|
259
|
+
acceptanceLine(observed),
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function evaluate(payload) {
|
|
264
|
+
const sessionId = payload.session_id;
|
|
265
|
+
const cwd = payload.cwd;
|
|
266
|
+
if (typeof sessionId !== "string" || !sessionId || typeof cwd !== "string") {
|
|
267
|
+
say("payload carries no session id or cwd; binding is NOT enforced");
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
const observed = worktreeRoot(cwd);
|
|
271
|
+
if (!observed) return 0;
|
|
272
|
+
|
|
273
|
+
if (payload.tool_name === "EnterWorktree") {
|
|
274
|
+
recordClaim(payload, observed);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
if (!GUARDED_TOOLS.has(payload.tool_name)) return 0;
|
|
278
|
+
|
|
279
|
+
const accepted = handleAcceptance(payload, observed);
|
|
280
|
+
if (accepted !== null) return accepted;
|
|
281
|
+
|
|
282
|
+
const state = readState(sessionId);
|
|
283
|
+
if (!state?.boundRoot) {
|
|
284
|
+
writeState(sessionId, {
|
|
285
|
+
boundRoot: observed,
|
|
286
|
+
claimedRoot: null,
|
|
287
|
+
updatedAt: new Date().toISOString(),
|
|
288
|
+
});
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
if (state.claimedRoot && state.claimedRoot !== observed) {
|
|
292
|
+
return unconfirmedSwitch(state.claimedRoot, observed);
|
|
293
|
+
}
|
|
294
|
+
if (state.claimedRoot === observed) {
|
|
295
|
+
writeState(sessionId, {
|
|
296
|
+
boundRoot: observed,
|
|
297
|
+
claimedRoot: null,
|
|
298
|
+
updatedAt: new Date().toISOString(),
|
|
299
|
+
});
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function main() {
|
|
307
|
+
let raw = "";
|
|
308
|
+
try {
|
|
309
|
+
raw = readFileSync(0, "utf8");
|
|
310
|
+
} catch {
|
|
311
|
+
say("could not read the hook payload; binding is NOT enforced");
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
let payload;
|
|
315
|
+
try {
|
|
316
|
+
payload = JSON.parse(raw);
|
|
317
|
+
} catch {
|
|
318
|
+
say("hook payload was not JSON; binding is NOT enforced");
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
return evaluate(payload);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
process.exit(main());
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse guard for Bash/Write/Edit/MultiEdit, and PostToolUse recorder for
|
|
3
|
+
# EnterWorktree: refuse to act while the worktree the session was told it is in
|
|
4
|
+
# disagrees with the one it is measurably in (CodySwannGT/lisa#3864).
|
|
5
|
+
#
|
|
6
|
+
# The reconciliation lives in worktree-binding-guard.mjs beside this file. It
|
|
7
|
+
# needs per-session state, path resolution and git plumbing, and a bash
|
|
8
|
+
# reimplementation of any of that would be a second copy of the thing the guard
|
|
9
|
+
# exists to keep honest.
|
|
10
|
+
#
|
|
11
|
+
# This wrapper does three things and nothing else: probe its interpreter, pass
|
|
12
|
+
# the hook envelope through untouched, and return the classifier's exit code so
|
|
13
|
+
# a refusal is a refusal.
|
|
14
|
+
#
|
|
15
|
+
# FAILING OPEN, LOUDLY
|
|
16
|
+
#
|
|
17
|
+
# Claude Code treats exit 2 as a refusal and every OTHER non-zero exit as a
|
|
18
|
+
# non-blocking hook error, so a missing interpreter under `set -e` would exit
|
|
19
|
+
# 127 and permit exactly what this exists to stop. Degrading to "allow" is
|
|
20
|
+
# correct — a hook that cannot read its input cannot tell a displacement from a
|
|
21
|
+
# directory listing — but it must SAY so, because a guard that is silently
|
|
22
|
+
# absent reads exactly like a guard that is passing. Same reasoning as
|
|
23
|
+
# block-host-name-leak.sh.
|
|
24
|
+
#
|
|
25
|
+
# AGENT PARITY, AND WHY IT STOPS WHERE IT DOES
|
|
26
|
+
#
|
|
27
|
+
# `EnterWorktree` is a Claude Code tool, and the process-global session binding
|
|
28
|
+
# it rewrites is a Claude Code structure. Codex, Antigravity and OpenCode manage
|
|
29
|
+
# their own worktrees and have neither, so there is nothing on those harnesses
|
|
30
|
+
# for the reconciler to reconcile — no `.agy.sh` adapter is shipped for this
|
|
31
|
+
# guard, and its absence is a statement rather than an omission. The Codex
|
|
32
|
+
# variant carries the PreToolUse half because the displacement check is
|
|
33
|
+
# harness-agnostic: it only asks whether the directory a session is measurably
|
|
34
|
+
# in has changed since it last acted.
|
|
35
|
+
set -uo pipefail
|
|
36
|
+
|
|
37
|
+
input="$(cat)"
|
|
38
|
+
|
|
39
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
40
|
+
printf 'worktree-binding-guard: node not found; worktree-binding enforcement is NOT active\n' >&2
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
45
|
+
classifier="$hook_dir/worktree-binding-guard.mjs"
|
|
46
|
+
if [ ! -r "$classifier" ]; then
|
|
47
|
+
printf 'worktree-binding-guard: classifier missing at %s; worktree-binding enforcement is NOT active\n' \
|
|
48
|
+
"$classifier" >&2
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
status=0
|
|
53
|
+
printf '%s' "$input" | node "$classifier" || status=$?
|
|
54
|
+
exit "$status"
|
package/scripts/build-plugins.sh
CHANGED
|
@@ -166,7 +166,7 @@ if [ -d "$SRC_DIR/base/hooks" ]; then
|
|
|
166
166
|
fi
|
|
167
167
|
for guard in block-no-verify parity-safety-net block-shell-json-parsing \
|
|
168
168
|
block-instruction-file-edits block-direct-issue-create \
|
|
169
|
-
block-managed-file-edits block-blind-automerge; do
|
|
169
|
+
block-managed-file-edits block-blind-automerge worktree-binding-guard; do
|
|
170
170
|
if [ -f "$SRC_DIR/base/hooks/$guard.sh" ]; then
|
|
171
171
|
materialize "$SRC_DIR/base/hooks/$guard.sh" "$HOST_GUARD_DIR/$guard.sh"
|
|
172
172
|
chmod +x "$HOST_GUARD_DIR/$guard.sh"
|
|
@@ -192,7 +192,7 @@ done
|
|
|
192
192
|
# sibling references back out of the shipped scripts, so a companion added to a
|
|
193
193
|
# guard and not added here fails as a missing dependency rather than waiting to
|
|
194
194
|
# be discovered as a permanent block downstream.
|
|
195
|
-
for companion in parity-safety-net-heredoc.py; do
|
|
195
|
+
for companion in parity-safety-net-heredoc.py worktree-binding-guard.mjs; do
|
|
196
196
|
if [ -f "$SRC_DIR/base/hooks/$companion" ]; then
|
|
197
197
|
materialize "$SRC_DIR/base/hooks/$companion" "$HOST_GUARD_DIR/$companion"
|
|
198
198
|
fi
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Every PreToolUse guard — block-no-verify, parity-safety-net,
|
|
7
7
|
# block-shell-json-parsing, block-instruction-file-edits,
|
|
8
|
-
# block-direct-issue-create, block-blind-automerge —
|
|
9
|
-
# Lisa plugin. A cloud session
|
|
8
|
+
# block-direct-issue-create, block-blind-automerge, worktree-binding-guard —
|
|
9
|
+
# is declared in the Lisa plugin. A cloud session
|
|
10
10
|
# installs plugins at session start from the marketplace the repository
|
|
11
11
|
# declares, and when that does not happen the container runs with
|
|
12
12
|
# `installed_plugins.json` empty and no enforcement whatsoever.
|
|
@@ -313,7 +313,7 @@ plugin_tree_used=0
|
|
|
313
313
|
|
|
314
314
|
for guard in block-no-verify parity-safety-net block-shell-json-parsing \
|
|
315
315
|
block-instruction-file-edits block-direct-issue-create \
|
|
316
|
-
block-managed-file-edits block-blind-automerge; do
|
|
316
|
+
block-managed-file-edits block-blind-automerge worktree-binding-guard; do
|
|
317
317
|
if [ -f "$host_tree/$guard.sh" ]; then
|
|
318
318
|
guard_names+=("$guard")
|
|
319
319
|
guard_scripts+=("$host_tree/$guard.sh")
|