@codyswann/lisa 4.43.1 → 4.44.0
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-hooks/worktree-binding-guard.mjs +263 -1
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +103 -7
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +20 -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 +9 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +7 -6
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/worktree-binding-guard.mjs +263 -1
- 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 +1 -1
- package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +263 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +263 -1
- 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/src/base/hooks/worktree-binding-guard.mjs +263 -1
- package/scripts/check-generated-artifact-merge-coverage.mjs +301 -0
package/package.json
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"generate:two-channel-couplings": "bun scripts/generate-two-channel-couplings.ts",
|
|
58
58
|
"check:two-channel-couplings": "bun scripts/generate-two-channel-couplings.ts --check",
|
|
59
59
|
"check:deletion-basis": "node scripts/check-deletion-basis.mjs",
|
|
60
|
-
"check:artifacts": "failed=''; bun run check:upstream-evidence-manifest || failed=\"$failed upstream-evidence-manifest\"; bun run check:lisa-owned-hash-ledger || failed=\"$failed lisa-owned-hash-ledger\"; bun run check:nightly-guard-certificate || failed=\"$failed nightly-guard-certificate\"; bun run check:two-channel-couplings || failed=\"$failed two-channel-couplings\"; bun run check:deletion-basis || failed=\"$failed deletion-basis\"; if [ -n \"$failed\" ]; then echo \"check:artifacts FAILED, and these are the checks that failed:$failed\"; exit 1; fi; echo \"check:artifacts OK - all
|
|
60
|
+
"check:artifacts": "failed=''; bun run check:upstream-evidence-manifest || failed=\"$failed upstream-evidence-manifest\"; bun run check:lisa-owned-hash-ledger || failed=\"$failed lisa-owned-hash-ledger\"; bun run check:nightly-guard-certificate || failed=\"$failed nightly-guard-certificate\"; bun run check:two-channel-couplings || failed=\"$failed two-channel-couplings\"; bun run check:deletion-basis || failed=\"$failed deletion-basis\"; bun run check:merge-coverage || failed=\"$failed merge-coverage\"; if [ -n \"$failed\" ]; then echo \"check:artifacts FAILED, and these are the checks that failed:$failed\"; exit 1; fi; echo \"check:artifacts OK - all 6 generated-artifact checks passed.\"",
|
|
61
61
|
"refresh:upstream-public-commits": "node scripts/generate-upstream-evidence-manifest.mjs --refresh-public-commits",
|
|
62
62
|
"check:rules-pairing": "bash scripts/check-rules-pairing.sh",
|
|
63
63
|
"check:pipeline-status-reads": "node scripts/check-pipeline-status-reads.mjs",
|
|
@@ -90,7 +90,8 @@
|
|
|
90
90
|
"verify:health-agentic": "bun run build:dist && node scripts/verify-health-agentic-built.mjs",
|
|
91
91
|
"verify:health-consumer": "bun run build:dist && node scripts/verify-health-consumer-built.mjs",
|
|
92
92
|
"measure:tmpdir-growth": "node scripts/measure-tmpdir-growth.mjs",
|
|
93
|
-
"lint:staged": "lint-staged --config .lintstagedrc.json"
|
|
93
|
+
"lint:staged": "lint-staged --config .lintstagedrc.json",
|
|
94
|
+
"check:merge-coverage": "node scripts/check-generated-artifact-merge-coverage.mjs"
|
|
94
95
|
},
|
|
95
96
|
"engines": {
|
|
96
97
|
"npm": "please-use-bun",
|
|
@@ -174,7 +175,7 @@
|
|
|
174
175
|
"zod-validation-error": "^4.0.0"
|
|
175
176
|
},
|
|
176
177
|
"name": "@codyswann/lisa",
|
|
177
|
-
"version": "4.
|
|
178
|
+
"version": "4.44.0",
|
|
178
179
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
179
180
|
"main": "dist/index.js",
|
|
180
181
|
"exports": {
|
|
@@ -322,7 +323,7 @@
|
|
|
322
323
|
"test": "tests"
|
|
323
324
|
},
|
|
324
325
|
"types": "./dist/index.d.ts",
|
|
325
|
-
"lisaReleaseCommit": "
|
|
326
|
-
"gitHead": "
|
|
327
|
-
"lisaReleaseTag": "v4.
|
|
326
|
+
"lisaReleaseCommit": "b089a9b05713a6456bf17f8dff7fe35881a84efc",
|
|
327
|
+
"gitHead": "b089a9b05713a6456bf17f8dff7fe35881a84efc",
|
|
328
|
+
"lisaReleaseTag": "v4.44.0"
|
|
328
329
|
}
|
|
@@ -47,7 +47,13 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import { execFileSync } from "node:child_process";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
mkdirSync,
|
|
52
|
+
readFileSync,
|
|
53
|
+
realpathSync,
|
|
54
|
+
statSync,
|
|
55
|
+
writeFileSync,
|
|
56
|
+
} from "node:fs";
|
|
51
57
|
import { homedir } from "node:os";
|
|
52
58
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
53
59
|
|
|
@@ -148,6 +154,258 @@ function mainCheckout(cwd) {
|
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
/**
|
|
158
|
+
* How much of an executed script this guard will read, in bytes.
|
|
159
|
+
*
|
|
160
|
+
* A generated fixture can be megabytes, and a guard that reads all of it on
|
|
161
|
+
* every Bash call is a guard someone switches off.
|
|
162
|
+
*/
|
|
163
|
+
const SCRIPT_READ_LIMIT = 256 * 1024;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Programs that take their operands as DATA and never execute them.
|
|
167
|
+
*
|
|
168
|
+
* The same split `block-direct-issue-create.sh` makes, and for the same reason
|
|
169
|
+
* it had to: a guard that opens every file a command names, then judges the
|
|
170
|
+
* COMMAND by that FILE's contents, refuses ordinary inspection. Reading a
|
|
171
|
+
* script that visits another worktree is not visiting it.
|
|
172
|
+
*
|
|
173
|
+
* The list is an exemption, not an allowlist. Anything unrecognised is
|
|
174
|
+
* FOLLOWED, because an interpreter roster fails open on the runner nobody
|
|
175
|
+
* enumerated.
|
|
176
|
+
*/
|
|
177
|
+
const READ_ONLY_PROGRAMS = new Set([
|
|
178
|
+
"awk",
|
|
179
|
+
"basename",
|
|
180
|
+
"cat",
|
|
181
|
+
"cksum",
|
|
182
|
+
"cmp",
|
|
183
|
+
"comm",
|
|
184
|
+
"cut",
|
|
185
|
+
"diff",
|
|
186
|
+
"dirname",
|
|
187
|
+
"du",
|
|
188
|
+
"file",
|
|
189
|
+
"find",
|
|
190
|
+
"grep",
|
|
191
|
+
"egrep",
|
|
192
|
+
"fgrep",
|
|
193
|
+
"head",
|
|
194
|
+
"less",
|
|
195
|
+
"ls",
|
|
196
|
+
"md5",
|
|
197
|
+
"md5sum",
|
|
198
|
+
"more",
|
|
199
|
+
"nl",
|
|
200
|
+
"od",
|
|
201
|
+
"realpath",
|
|
202
|
+
"rg",
|
|
203
|
+
"sed",
|
|
204
|
+
"sha1sum",
|
|
205
|
+
"sha256sum",
|
|
206
|
+
"sort",
|
|
207
|
+
"stat",
|
|
208
|
+
"strings",
|
|
209
|
+
"tail",
|
|
210
|
+
"tee",
|
|
211
|
+
"tr",
|
|
212
|
+
"uniq",
|
|
213
|
+
"wc",
|
|
214
|
+
"xxd",
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
/** Interpreters whose first non-option operand is the file they run. */
|
|
218
|
+
const EXECUTING_INTERPRETERS = new Set([
|
|
219
|
+
"bash",
|
|
220
|
+
"dash",
|
|
221
|
+
"ksh",
|
|
222
|
+
"node",
|
|
223
|
+
"perl",
|
|
224
|
+
"python",
|
|
225
|
+
"python3",
|
|
226
|
+
"ruby",
|
|
227
|
+
"sh",
|
|
228
|
+
"zsh",
|
|
229
|
+
"bun",
|
|
230
|
+
"deno",
|
|
231
|
+
"tsx",
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
/** Shell builtins that run a file in the CURRENT shell. */
|
|
235
|
+
const SOURCE_BUILTINS = new Set([".", "source"]);
|
|
236
|
+
|
|
237
|
+
/** Where one command ends and the next begins. */
|
|
238
|
+
const COMMAND_SEPARATORS = /\|\||&&|[;|&\n]/u;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Split a command into tokens, honouring simple quoting.
|
|
242
|
+
*
|
|
243
|
+
* Deliberately simple, and the guard fails OPEN when it is not enough — see
|
|
244
|
+
* {@link scriptReachingForeignTree}. A tokeniser that guessed would be worse
|
|
245
|
+
* than one that says it does not know.
|
|
246
|
+
*/
|
|
247
|
+
function tokenise(segment) {
|
|
248
|
+
const tokens = segment.match(/"[^"]*"|'[^']*'|[^\s]+/gu) ?? [];
|
|
249
|
+
return tokens.map(token => token.replace(/^["']|["']$/gu, ""));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The path this command EXECUTES, or null.
|
|
254
|
+
*
|
|
255
|
+
* The command-position question. A path anywhere else is an argument, and an
|
|
256
|
+
* argument is data.
|
|
257
|
+
*/
|
|
258
|
+
function executedPath(segment) {
|
|
259
|
+
const tokens = tokenise(segment);
|
|
260
|
+
if (tokens.length === 0) return null;
|
|
261
|
+
const [head, ...rest] = tokens;
|
|
262
|
+
const program = head.split("/").pop() ?? "";
|
|
263
|
+
if (READ_ONLY_PROGRAMS.has(program)) return null;
|
|
264
|
+
if (SOURCE_BUILTINS.has(program)) return rest[0] ?? null;
|
|
265
|
+
if (!EXECUTING_INTERPRETERS.has(program)) {
|
|
266
|
+
// A command word that is itself a file runs by its shebang.
|
|
267
|
+
return head.includes("/") ? head : null;
|
|
268
|
+
}
|
|
269
|
+
// A known interpreter: the first operand that is not an option.
|
|
270
|
+
const operand = rest.find(token => !token.startsWith("-"));
|
|
271
|
+
return operand ?? null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A worktree root other than `bound` that this text reaches into, or null.
|
|
276
|
+
*
|
|
277
|
+
* ## Why this does not enumerate `cd`
|
|
278
|
+
*
|
|
279
|
+
* The redirect spellings — `cd`, `git -C`, `--work-tree`, `--git-dir`, a
|
|
280
|
+
* subshell, `env -C`, a variable holding the path — are an open set, and
|
|
281
|
+
* enumerating them is the failure this arm exists to end
|
|
282
|
+
* (CodySwannGT/lisa#3927 states it for the sibling guard). So the question is
|
|
283
|
+
* inverted the same way the filing guard inverted its own: not *which syntax
|
|
284
|
+
* moves*, which is unbounded, but *which tokens name a directory in another
|
|
285
|
+
* worktree*, which is bounded by the file.
|
|
286
|
+
*
|
|
287
|
+
* A path only has to RESOLVE. How it arrived does not matter, which is what
|
|
288
|
+
* makes an unenumerated spelling reach the same verdict as `cd`.
|
|
289
|
+
*
|
|
290
|
+
* ## Why comments are stripped first
|
|
291
|
+
*
|
|
292
|
+
* A script that merely MENTIONS a sibling worktree — a usage line, a comment,
|
|
293
|
+
* a log message — would otherwise be refused for describing the thing rather
|
|
294
|
+
* than doing it. Prose about a subject is the likeliest text to contain the
|
|
295
|
+
* subject's shapes, and the population that writes it is the people
|
|
296
|
+
* documenting the guard. A parity classifier elsewhere in this repository was
|
|
297
|
+
* fooled by exactly that and was caught only by a known-answer control.
|
|
298
|
+
*/
|
|
299
|
+
function foreignTreeIn(text, bound, cwd) {
|
|
300
|
+
const code = text
|
|
301
|
+
.split("\n")
|
|
302
|
+
.map(line => line.replace(/^\s*#.*$/u, ""))
|
|
303
|
+
.join("\n");
|
|
304
|
+
for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
|
|
305
|
+
const token = raw.replace(/^["']|["']$/gu, "");
|
|
306
|
+
if (!token.includes("/")) continue;
|
|
307
|
+
const candidate = isAbsolute(token) ? token : join(cwd, token);
|
|
308
|
+
let root;
|
|
309
|
+
try {
|
|
310
|
+
if (!statSync(candidate).isDirectory()) continue;
|
|
311
|
+
root = worktreeRoot(realpathSync(candidate));
|
|
312
|
+
} catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (root && root !== bound) return root;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The foreign worktree an executed script reaches, or null.
|
|
322
|
+
*
|
|
323
|
+
* ## Why this arm exists at all
|
|
324
|
+
*
|
|
325
|
+
* The check above compares `payload.cwd` against the bound root, and that is
|
|
326
|
+
* the right comparison — but **`payload.cwd` is sampled before the child
|
|
327
|
+
* process runs**. A script that changes directory internally moves AFTER the
|
|
328
|
+
* guard has measured. The guard is not wrong about the target; it is right
|
|
329
|
+
* about a target that stops being the target one instruction later.
|
|
330
|
+
*
|
|
331
|
+
* A measurement taken before execution cannot bind what execution does
|
|
332
|
+
* (CodySwannGT/lisa#3924). So the only thing left is to read what is about to
|
|
333
|
+
* run.
|
|
334
|
+
*
|
|
335
|
+
* ## What this arm does NOT cover, stated rather than implied
|
|
336
|
+
*
|
|
337
|
+
* An INLINE redirect — `cd B && git status`, `git -C B status` — is not
|
|
338
|
+
* refused here. Those are already refused by the runtime's own worktree
|
|
339
|
+
* isolation, and duplicating a control is how two controls drift into
|
|
340
|
+
* disagreeing. The scripted form is the one neither covers, and it is the only
|
|
341
|
+
* cell this arm fills.
|
|
342
|
+
*
|
|
343
|
+
* ## A relative script path is resolved against `payload.cwd`, and that is a
|
|
344
|
+
* KNOWN limitation shared with `parity-safety-net.sh`
|
|
345
|
+
*
|
|
346
|
+
* CodySwannGT/lisa#3933, filed against that guard, is the same defect: a
|
|
347
|
+
* relative token is resolved against the hook process's working directory, not
|
|
348
|
+
* against the directory the command will have once it runs its own leading
|
|
349
|
+
* `cd`. So `cd <other tree> && bash scripts/foo.sh` reads THIS tree's copy of
|
|
350
|
+
* `scripts/foo.sh` and reports on it with full confidence.
|
|
351
|
+
*
|
|
352
|
+
* It matters here for the same reason it matters there: on a machine running
|
|
353
|
+
* many worktrees of one repository, every tree holds its own copy of every
|
|
354
|
+
* script at the same relative path, and they differ by whatever each lane is
|
|
355
|
+
* doing. A confident scan of the wrong copy is wrong in both directions, and
|
|
356
|
+
* the silent one — the copy that will run reaches out, the copy that was read
|
|
357
|
+
* does not — is the direction this arm exists to prevent.
|
|
358
|
+
*
|
|
359
|
+
* NOT fixed here on purpose. #3933 is assigned and its options (honour a
|
|
360
|
+
* leading `cd`, or fail closed when the effective cwd is uncertain) belong in
|
|
361
|
+
* one place rather than two: a second implementation of the same resolution is
|
|
362
|
+
* how two guards drift into disagreeing about which file a command runs.
|
|
363
|
+
* When that lands, this call site should adopt whatever it produces.
|
|
364
|
+
*
|
|
365
|
+
* The exposure here is narrower than there. This arm is reached only after the
|
|
366
|
+
* session has been confirmed to be in its bound worktree, so the common case
|
|
367
|
+
* is a relative path resolving inside the tree the session is actually in.
|
|
368
|
+
*
|
|
369
|
+
* Fails OPEN and says so, like every other undecidable case in this file.
|
|
370
|
+
*/
|
|
371
|
+
function scriptReachingForeignTree(payload, bound) {
|
|
372
|
+
const command = payload.tool_input?.command;
|
|
373
|
+
if (typeof command !== "string" || !command) return null;
|
|
374
|
+
for (const segment of command.split(COMMAND_SEPARATORS)) {
|
|
375
|
+
const named = executedPath(segment);
|
|
376
|
+
if (!named) continue;
|
|
377
|
+
const path = isAbsolute(named) ? named : join(payload.cwd, named);
|
|
378
|
+
let text;
|
|
379
|
+
try {
|
|
380
|
+
if (statSync(path).size > SCRIPT_READ_LIMIT) {
|
|
381
|
+
say(`${path} is too large to inspect; NOT enforcing on it`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
text = readFileSync(path, "utf8");
|
|
385
|
+
} catch {
|
|
386
|
+
// Not a readable file: nothing was executed that this can read.
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const foreign = foreignTreeIn(text, bound, payload.cwd);
|
|
390
|
+
if (foreign) return { script: path, foreign };
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function reachesOut(script, foreign, bound) {
|
|
396
|
+
return refuse([
|
|
397
|
+
"worktree-binding-guard: this command runs a script that reaches into",
|
|
398
|
+
"another worktree.",
|
|
399
|
+
` script: ${script}`,
|
|
400
|
+
` reaches: ${foreign}`,
|
|
401
|
+
` bound to: ${bound}`,
|
|
402
|
+
"The directory change lives inside the file, so it happens after this",
|
|
403
|
+
"guard has measured where the session is. Run the work from the tree it",
|
|
404
|
+
"belongs to, or acknowledge the move:",
|
|
405
|
+
acceptanceLine(foreign),
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
|
|
151
409
|
function stateFile(sessionId) {
|
|
152
410
|
const home = process.env.LISA_STATE_HOME || join(homedir(), ".lisa");
|
|
153
411
|
return join(home, "worktree-binding", `${sessionId}.json`);
|
|
@@ -328,6 +586,10 @@ function evaluate(payload) {
|
|
|
328
586
|
return 0;
|
|
329
587
|
}
|
|
330
588
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|
|
589
|
+
// Last, and only once the session is provably where it should be: a script
|
|
590
|
+
// that moves somewhere else after this check has already run.
|
|
591
|
+
const reach = scriptReachingForeignTree(payload, observed);
|
|
592
|
+
if (reach) return reachesOut(reach.script, reach.foreign, observed);
|
|
331
593
|
return 0;
|
|
332
594
|
}
|
|
333
595
|
|
|
@@ -47,7 +47,13 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import { execFileSync } from "node:child_process";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
mkdirSync,
|
|
52
|
+
readFileSync,
|
|
53
|
+
realpathSync,
|
|
54
|
+
statSync,
|
|
55
|
+
writeFileSync,
|
|
56
|
+
} from "node:fs";
|
|
51
57
|
import { homedir } from "node:os";
|
|
52
58
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
53
59
|
|
|
@@ -148,6 +154,258 @@ function mainCheckout(cwd) {
|
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
/**
|
|
158
|
+
* How much of an executed script this guard will read, in bytes.
|
|
159
|
+
*
|
|
160
|
+
* A generated fixture can be megabytes, and a guard that reads all of it on
|
|
161
|
+
* every Bash call is a guard someone switches off.
|
|
162
|
+
*/
|
|
163
|
+
const SCRIPT_READ_LIMIT = 256 * 1024;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Programs that take their operands as DATA and never execute them.
|
|
167
|
+
*
|
|
168
|
+
* The same split `block-direct-issue-create.sh` makes, and for the same reason
|
|
169
|
+
* it had to: a guard that opens every file a command names, then judges the
|
|
170
|
+
* COMMAND by that FILE's contents, refuses ordinary inspection. Reading a
|
|
171
|
+
* script that visits another worktree is not visiting it.
|
|
172
|
+
*
|
|
173
|
+
* The list is an exemption, not an allowlist. Anything unrecognised is
|
|
174
|
+
* FOLLOWED, because an interpreter roster fails open on the runner nobody
|
|
175
|
+
* enumerated.
|
|
176
|
+
*/
|
|
177
|
+
const READ_ONLY_PROGRAMS = new Set([
|
|
178
|
+
"awk",
|
|
179
|
+
"basename",
|
|
180
|
+
"cat",
|
|
181
|
+
"cksum",
|
|
182
|
+
"cmp",
|
|
183
|
+
"comm",
|
|
184
|
+
"cut",
|
|
185
|
+
"diff",
|
|
186
|
+
"dirname",
|
|
187
|
+
"du",
|
|
188
|
+
"file",
|
|
189
|
+
"find",
|
|
190
|
+
"grep",
|
|
191
|
+
"egrep",
|
|
192
|
+
"fgrep",
|
|
193
|
+
"head",
|
|
194
|
+
"less",
|
|
195
|
+
"ls",
|
|
196
|
+
"md5",
|
|
197
|
+
"md5sum",
|
|
198
|
+
"more",
|
|
199
|
+
"nl",
|
|
200
|
+
"od",
|
|
201
|
+
"realpath",
|
|
202
|
+
"rg",
|
|
203
|
+
"sed",
|
|
204
|
+
"sha1sum",
|
|
205
|
+
"sha256sum",
|
|
206
|
+
"sort",
|
|
207
|
+
"stat",
|
|
208
|
+
"strings",
|
|
209
|
+
"tail",
|
|
210
|
+
"tee",
|
|
211
|
+
"tr",
|
|
212
|
+
"uniq",
|
|
213
|
+
"wc",
|
|
214
|
+
"xxd",
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
/** Interpreters whose first non-option operand is the file they run. */
|
|
218
|
+
const EXECUTING_INTERPRETERS = new Set([
|
|
219
|
+
"bash",
|
|
220
|
+
"dash",
|
|
221
|
+
"ksh",
|
|
222
|
+
"node",
|
|
223
|
+
"perl",
|
|
224
|
+
"python",
|
|
225
|
+
"python3",
|
|
226
|
+
"ruby",
|
|
227
|
+
"sh",
|
|
228
|
+
"zsh",
|
|
229
|
+
"bun",
|
|
230
|
+
"deno",
|
|
231
|
+
"tsx",
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
/** Shell builtins that run a file in the CURRENT shell. */
|
|
235
|
+
const SOURCE_BUILTINS = new Set([".", "source"]);
|
|
236
|
+
|
|
237
|
+
/** Where one command ends and the next begins. */
|
|
238
|
+
const COMMAND_SEPARATORS = /\|\||&&|[;|&\n]/u;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Split a command into tokens, honouring simple quoting.
|
|
242
|
+
*
|
|
243
|
+
* Deliberately simple, and the guard fails OPEN when it is not enough — see
|
|
244
|
+
* {@link scriptReachingForeignTree}. A tokeniser that guessed would be worse
|
|
245
|
+
* than one that says it does not know.
|
|
246
|
+
*/
|
|
247
|
+
function tokenise(segment) {
|
|
248
|
+
const tokens = segment.match(/"[^"]*"|'[^']*'|[^\s]+/gu) ?? [];
|
|
249
|
+
return tokens.map(token => token.replace(/^["']|["']$/gu, ""));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The path this command EXECUTES, or null.
|
|
254
|
+
*
|
|
255
|
+
* The command-position question. A path anywhere else is an argument, and an
|
|
256
|
+
* argument is data.
|
|
257
|
+
*/
|
|
258
|
+
function executedPath(segment) {
|
|
259
|
+
const tokens = tokenise(segment);
|
|
260
|
+
if (tokens.length === 0) return null;
|
|
261
|
+
const [head, ...rest] = tokens;
|
|
262
|
+
const program = head.split("/").pop() ?? "";
|
|
263
|
+
if (READ_ONLY_PROGRAMS.has(program)) return null;
|
|
264
|
+
if (SOURCE_BUILTINS.has(program)) return rest[0] ?? null;
|
|
265
|
+
if (!EXECUTING_INTERPRETERS.has(program)) {
|
|
266
|
+
// A command word that is itself a file runs by its shebang.
|
|
267
|
+
return head.includes("/") ? head : null;
|
|
268
|
+
}
|
|
269
|
+
// A known interpreter: the first operand that is not an option.
|
|
270
|
+
const operand = rest.find(token => !token.startsWith("-"));
|
|
271
|
+
return operand ?? null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A worktree root other than `bound` that this text reaches into, or null.
|
|
276
|
+
*
|
|
277
|
+
* ## Why this does not enumerate `cd`
|
|
278
|
+
*
|
|
279
|
+
* The redirect spellings — `cd`, `git -C`, `--work-tree`, `--git-dir`, a
|
|
280
|
+
* subshell, `env -C`, a variable holding the path — are an open set, and
|
|
281
|
+
* enumerating them is the failure this arm exists to end
|
|
282
|
+
* (CodySwannGT/lisa#3927 states it for the sibling guard). So the question is
|
|
283
|
+
* inverted the same way the filing guard inverted its own: not *which syntax
|
|
284
|
+
* moves*, which is unbounded, but *which tokens name a directory in another
|
|
285
|
+
* worktree*, which is bounded by the file.
|
|
286
|
+
*
|
|
287
|
+
* A path only has to RESOLVE. How it arrived does not matter, which is what
|
|
288
|
+
* makes an unenumerated spelling reach the same verdict as `cd`.
|
|
289
|
+
*
|
|
290
|
+
* ## Why comments are stripped first
|
|
291
|
+
*
|
|
292
|
+
* A script that merely MENTIONS a sibling worktree — a usage line, a comment,
|
|
293
|
+
* a log message — would otherwise be refused for describing the thing rather
|
|
294
|
+
* than doing it. Prose about a subject is the likeliest text to contain the
|
|
295
|
+
* subject's shapes, and the population that writes it is the people
|
|
296
|
+
* documenting the guard. A parity classifier elsewhere in this repository was
|
|
297
|
+
* fooled by exactly that and was caught only by a known-answer control.
|
|
298
|
+
*/
|
|
299
|
+
function foreignTreeIn(text, bound, cwd) {
|
|
300
|
+
const code = text
|
|
301
|
+
.split("\n")
|
|
302
|
+
.map(line => line.replace(/^\s*#.*$/u, ""))
|
|
303
|
+
.join("\n");
|
|
304
|
+
for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
|
|
305
|
+
const token = raw.replace(/^["']|["']$/gu, "");
|
|
306
|
+
if (!token.includes("/")) continue;
|
|
307
|
+
const candidate = isAbsolute(token) ? token : join(cwd, token);
|
|
308
|
+
let root;
|
|
309
|
+
try {
|
|
310
|
+
if (!statSync(candidate).isDirectory()) continue;
|
|
311
|
+
root = worktreeRoot(realpathSync(candidate));
|
|
312
|
+
} catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (root && root !== bound) return root;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The foreign worktree an executed script reaches, or null.
|
|
322
|
+
*
|
|
323
|
+
* ## Why this arm exists at all
|
|
324
|
+
*
|
|
325
|
+
* The check above compares `payload.cwd` against the bound root, and that is
|
|
326
|
+
* the right comparison — but **`payload.cwd` is sampled before the child
|
|
327
|
+
* process runs**. A script that changes directory internally moves AFTER the
|
|
328
|
+
* guard has measured. The guard is not wrong about the target; it is right
|
|
329
|
+
* about a target that stops being the target one instruction later.
|
|
330
|
+
*
|
|
331
|
+
* A measurement taken before execution cannot bind what execution does
|
|
332
|
+
* (CodySwannGT/lisa#3924). So the only thing left is to read what is about to
|
|
333
|
+
* run.
|
|
334
|
+
*
|
|
335
|
+
* ## What this arm does NOT cover, stated rather than implied
|
|
336
|
+
*
|
|
337
|
+
* An INLINE redirect — `cd B && git status`, `git -C B status` — is not
|
|
338
|
+
* refused here. Those are already refused by the runtime's own worktree
|
|
339
|
+
* isolation, and duplicating a control is how two controls drift into
|
|
340
|
+
* disagreeing. The scripted form is the one neither covers, and it is the only
|
|
341
|
+
* cell this arm fills.
|
|
342
|
+
*
|
|
343
|
+
* ## A relative script path is resolved against `payload.cwd`, and that is a
|
|
344
|
+
* KNOWN limitation shared with `parity-safety-net.sh`
|
|
345
|
+
*
|
|
346
|
+
* CodySwannGT/lisa#3933, filed against that guard, is the same defect: a
|
|
347
|
+
* relative token is resolved against the hook process's working directory, not
|
|
348
|
+
* against the directory the command will have once it runs its own leading
|
|
349
|
+
* `cd`. So `cd <other tree> && bash scripts/foo.sh` reads THIS tree's copy of
|
|
350
|
+
* `scripts/foo.sh` and reports on it with full confidence.
|
|
351
|
+
*
|
|
352
|
+
* It matters here for the same reason it matters there: on a machine running
|
|
353
|
+
* many worktrees of one repository, every tree holds its own copy of every
|
|
354
|
+
* script at the same relative path, and they differ by whatever each lane is
|
|
355
|
+
* doing. A confident scan of the wrong copy is wrong in both directions, and
|
|
356
|
+
* the silent one — the copy that will run reaches out, the copy that was read
|
|
357
|
+
* does not — is the direction this arm exists to prevent.
|
|
358
|
+
*
|
|
359
|
+
* NOT fixed here on purpose. #3933 is assigned and its options (honour a
|
|
360
|
+
* leading `cd`, or fail closed when the effective cwd is uncertain) belong in
|
|
361
|
+
* one place rather than two: a second implementation of the same resolution is
|
|
362
|
+
* how two guards drift into disagreeing about which file a command runs.
|
|
363
|
+
* When that lands, this call site should adopt whatever it produces.
|
|
364
|
+
*
|
|
365
|
+
* The exposure here is narrower than there. This arm is reached only after the
|
|
366
|
+
* session has been confirmed to be in its bound worktree, so the common case
|
|
367
|
+
* is a relative path resolving inside the tree the session is actually in.
|
|
368
|
+
*
|
|
369
|
+
* Fails OPEN and says so, like every other undecidable case in this file.
|
|
370
|
+
*/
|
|
371
|
+
function scriptReachingForeignTree(payload, bound) {
|
|
372
|
+
const command = payload.tool_input?.command;
|
|
373
|
+
if (typeof command !== "string" || !command) return null;
|
|
374
|
+
for (const segment of command.split(COMMAND_SEPARATORS)) {
|
|
375
|
+
const named = executedPath(segment);
|
|
376
|
+
if (!named) continue;
|
|
377
|
+
const path = isAbsolute(named) ? named : join(payload.cwd, named);
|
|
378
|
+
let text;
|
|
379
|
+
try {
|
|
380
|
+
if (statSync(path).size > SCRIPT_READ_LIMIT) {
|
|
381
|
+
say(`${path} is too large to inspect; NOT enforcing on it`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
text = readFileSync(path, "utf8");
|
|
385
|
+
} catch {
|
|
386
|
+
// Not a readable file: nothing was executed that this can read.
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const foreign = foreignTreeIn(text, bound, payload.cwd);
|
|
390
|
+
if (foreign) return { script: path, foreign };
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function reachesOut(script, foreign, bound) {
|
|
396
|
+
return refuse([
|
|
397
|
+
"worktree-binding-guard: this command runs a script that reaches into",
|
|
398
|
+
"another worktree.",
|
|
399
|
+
` script: ${script}`,
|
|
400
|
+
` reaches: ${foreign}`,
|
|
401
|
+
` bound to: ${bound}`,
|
|
402
|
+
"The directory change lives inside the file, so it happens after this",
|
|
403
|
+
"guard has measured where the session is. Run the work from the tree it",
|
|
404
|
+
"belongs to, or acknowledge the move:",
|
|
405
|
+
acceptanceLine(foreign),
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
|
|
151
409
|
function stateFile(sessionId) {
|
|
152
410
|
const home = process.env.LISA_STATE_HOME || join(homedir(), ".lisa");
|
|
153
411
|
return join(home, "worktree-binding", `${sessionId}.json`);
|
|
@@ -328,6 +586,10 @@ function evaluate(payload) {
|
|
|
328
586
|
return 0;
|
|
329
587
|
}
|
|
330
588
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|
|
589
|
+
// Last, and only once the session is provably where it should be: a script
|
|
590
|
+
// that moves somewhere else after this check has already run.
|
|
591
|
+
const reach = scriptReachingForeignTree(payload, observed);
|
|
592
|
+
if (reach) return reachesOut(reach.script, reach.foreign, observed);
|
|
331
593
|
return 0;
|
|
332
594
|
}
|
|
333
595
|
|