@codyswann/lisa 4.45.4 → 4.45.5
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 +115 -23
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +4 -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 +8 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +5 -4
- 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 +115 -23
- 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 +115 -23
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +115 -23
- 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 +115 -23
- package/scripts/check-cwd-resolution-corpus.mjs +163 -0
package/package.json
CHANGED
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"check:pipeline-status-reads": "node scripts/check-pipeline-status-reads.mjs",
|
|
64
64
|
"check:whole-output-guards": "node scripts/check-whole-output-guards.mjs",
|
|
65
65
|
"check:empty-subject-guards": "node scripts/check-empty-subject-guards.mjs",
|
|
66
|
+
"check:cwd-resolution-corpus": "node scripts/check-cwd-resolution-corpus.mjs",
|
|
66
67
|
"check:probe-absence-direction": "node scripts/check-probe-absence-direction.mjs",
|
|
67
68
|
"check:shell-guard-refusals": "bun run build && $npm_execpath run lisa-test-run -- --adapter direct -- node scripts/check-shell-guard-refusal-coverage.mjs",
|
|
68
69
|
"check:workflow-inputs": "node scripts/detect-stale-workflow-inputs.mjs --project . --project typescript/create-only --project rails/create-only --json",
|
|
@@ -175,7 +176,7 @@
|
|
|
175
176
|
"zod-validation-error": "^4.0.0"
|
|
176
177
|
},
|
|
177
178
|
"name": "@codyswann/lisa",
|
|
178
|
-
"version": "4.45.
|
|
179
|
+
"version": "4.45.5",
|
|
179
180
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
180
181
|
"main": "dist/index.js",
|
|
181
182
|
"exports": {
|
|
@@ -323,7 +324,7 @@
|
|
|
323
324
|
"test": "tests"
|
|
324
325
|
},
|
|
325
326
|
"types": "./dist/index.d.ts",
|
|
326
|
-
"lisaReleaseCommit": "
|
|
327
|
-
"gitHead": "
|
|
328
|
-
"lisaReleaseTag": "v4.45.
|
|
327
|
+
"lisaReleaseCommit": "2dfa25a8a2115203dd400e5e1f69aefef8014933",
|
|
328
|
+
"gitHead": "2dfa25a8a2115203dd400e5e1f69aefef8014933",
|
|
329
|
+
"lisaReleaseTag": "v4.45.5"
|
|
329
330
|
}
|
|
@@ -304,6 +304,11 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
304
304
|
for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
|
|
305
305
|
const token = raw.replace(/^["']|["']$/gu, "");
|
|
306
306
|
if (!token.includes("/")) continue;
|
|
307
|
+
// A null base means an unliteral `cd` left the directory this script runs
|
|
308
|
+
// from unknown. An ABSOLUTE token is unambiguous either way; a relative one
|
|
309
|
+
// is exactly the thing that cannot be placed, so it is skipped rather than
|
|
310
|
+
// resolved against a directory picked for having been handy.
|
|
311
|
+
if (!isAbsolute(token) && cwd === null) continue;
|
|
307
312
|
const candidate = isAbsolute(token) ? token : join(cwd, token);
|
|
308
313
|
let root;
|
|
309
314
|
try {
|
|
@@ -317,6 +322,63 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
317
322
|
return null;
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
/**
|
|
326
|
+
* A `cd` target this guard cannot turn into a literal directory.
|
|
327
|
+
*
|
|
328
|
+
* Mirrors `parity-safety-net.sh`'s `follow_cd` test exactly, and the mirror is
|
|
329
|
+
* the point: two spellings of one rule are how the guards drift.
|
|
330
|
+
*/
|
|
331
|
+
const UNLITERAL_CD_TARGET = /[$`*?[{]/u;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The literal target of a leading `cd`, `""` for a bare `cd`, or null when the
|
|
335
|
+
* segment does not change directory.
|
|
336
|
+
*
|
|
337
|
+
* A bare `cd` is `cd "$HOME"`. Leaving the previous directory in place for it
|
|
338
|
+
* would resolve later tokens from a directory the shell has already left.
|
|
339
|
+
* @param segment - One statement from the command
|
|
340
|
+
* @returns The target text, or null when this segment is not a `cd`
|
|
341
|
+
*/
|
|
342
|
+
function cdTarget(segment) {
|
|
343
|
+
const tokens = tokenise(segment);
|
|
344
|
+
if (tokens.length === 0 || tokens[0] !== "cd") return null;
|
|
345
|
+
return tokens[1] ?? "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Apply one `cd` to the directory currently in force.
|
|
350
|
+
*
|
|
351
|
+
* Contract points 1-4 of `cwd-resolution-corpus.json`: a literal target
|
|
352
|
+
* REPLACES the directory rather than joining a list of candidates — the
|
|
353
|
+
* ordering was #3933's entire defect; a relative target composes against the
|
|
354
|
+
* directory in force; a target that is not literal text yields unknown; and
|
|
355
|
+
* unknown is sticky only until the next resolvable `cd`, because this returns
|
|
356
|
+
* a fresh answer for each one.
|
|
357
|
+
* @param current - Directory in force, or null when it is already unknown
|
|
358
|
+
* @param target - Literal `cd` target, `""` for a bare `cd`
|
|
359
|
+
* @returns The new directory, or null when it cannot be known
|
|
360
|
+
*/
|
|
361
|
+
function applyCd(current, target) {
|
|
362
|
+
const home = homedir();
|
|
363
|
+
let value = target === "" ? home : target;
|
|
364
|
+
if (value === "~") value = home;
|
|
365
|
+
else if (value.startsWith("~/")) value = join(home, value.slice(2));
|
|
366
|
+
if (
|
|
367
|
+
value === "" ||
|
|
368
|
+
value.startsWith("-") ||
|
|
369
|
+
UNLITERAL_CD_TARGET.test(value)
|
|
370
|
+
) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
if (!isAbsolute(value) && current === null) return null;
|
|
374
|
+
const next = isAbsolute(value) ? resolve(value) : resolve(current, value);
|
|
375
|
+
try {
|
|
376
|
+
return statSync(next).isDirectory() ? next : null;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
320
382
|
/**
|
|
321
383
|
* The foreign worktree an executed script reaches, or null.
|
|
322
384
|
*
|
|
@@ -340,41 +402,71 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
340
402
|
* disagreeing. The scripted form is the one neither covers, and it is the only
|
|
341
403
|
* cell this arm fills.
|
|
342
404
|
*
|
|
343
|
-
* ## A relative script path is resolved against
|
|
344
|
-
*
|
|
405
|
+
* ## A relative script path is resolved against the cwd the command will have
|
|
406
|
+
*
|
|
407
|
+
* A leading `cd` establishes the directory this guard resolves relative tokens
|
|
408
|
+
* from, because that is the directory the shell will be in once the command
|
|
409
|
+
* runs. Resolving against `payload.cwd` instead was CodySwannGT/lisa#3933's
|
|
410
|
+
* defect, inherited here knowingly and fixed in CodySwannGT/lisa#3952: on a
|
|
411
|
+
* machine running many worktrees of one repository, every tree holds its own
|
|
412
|
+
* copy of every script at the same relative path, and they differ by whatever
|
|
413
|
+
* each lane is doing.
|
|
345
414
|
*
|
|
346
|
-
*
|
|
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.
|
|
415
|
+
* ## One contract, two implementations, and why that is the honest answer
|
|
351
416
|
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* the
|
|
357
|
-
*
|
|
417
|
+
* `parity-safety-net.sh` answers this same question — "which file will this
|
|
418
|
+
* command actually execute?" — in POSIX shell, where `follow_cd` mutates
|
|
419
|
+
* file-scope state that `follow_locate` reads. This guard is Node. **There is
|
|
420
|
+
* no call that reaches from here into a shell function's file-scope state**, so
|
|
421
|
+
* the duplication is not a factoring somebody skipped; it is one idea that
|
|
422
|
+
* cannot cross a language boundary as a function call.
|
|
358
423
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* When that lands, this call site should adopt whatever it produces.
|
|
424
|
+
* What binds the two is a written contract and a corpus of rows that BOTH
|
|
425
|
+
* guards are driven over in CI, so a divergence fails a test on the commit that
|
|
426
|
+
* causes it rather than surfacing later as a wrong verdict. Contract and corpus
|
|
427
|
+
* live in `tests/unit/hooks/support/cwd-resolution-corpus.json`.
|
|
364
428
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
429
|
+
* This is explicitly NOT unification, and CodySwannGT/lisa#3952 records it as
|
|
430
|
+
* such: two implementations remain. **A behaviour with no row in the corpus is
|
|
431
|
+
* free to diverge — add a row when you add a branch.**
|
|
432
|
+
*
|
|
433
|
+
* ## The two guards agree on the RESOLUTION, not on the verdict
|
|
434
|
+
*
|
|
435
|
+
* When the effective directory cannot be known, both guards decline to resolve
|
|
436
|
+
* the token. What they then do differs, and that difference is deliberate:
|
|
437
|
+
* `parity-safety-net.sh` refuses, because a destructive command it cannot see
|
|
438
|
+
* is the thing it exists to stop. This guard fails open, which is the doctrine
|
|
439
|
+
* every other undecidable case in this file follows. The corpus asserts the
|
|
440
|
+
* resolved path, not the exit code, precisely so that one contract can bind two
|
|
441
|
+
* guards whose fail directions are correctly different.
|
|
368
442
|
*
|
|
369
443
|
* Fails OPEN and says so, like every other undecidable case in this file.
|
|
370
444
|
*/
|
|
371
445
|
function scriptReachingForeignTree(payload, bound) {
|
|
372
446
|
const command = payload.tool_input?.command;
|
|
373
447
|
if (typeof command !== "string" || !command) return null;
|
|
448
|
+
let cwd = payload.cwd;
|
|
374
449
|
for (const segment of command.split(COMMAND_SEPARATORS)) {
|
|
450
|
+
const target = cdTarget(segment);
|
|
451
|
+
if (target !== null) {
|
|
452
|
+
cwd = applyCd(cwd, target);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
375
455
|
const named = executedPath(segment);
|
|
376
456
|
if (!named) continue;
|
|
377
|
-
|
|
457
|
+
if (!isAbsolute(named) && cwd === null) {
|
|
458
|
+
// Contract point 3: the effective directory is unknown, so WHICH copy of
|
|
459
|
+
// a same-named script would run is unknown too. Declining to resolve is
|
|
460
|
+
// the half shared with parity-safety-net.sh; failing open rather than
|
|
461
|
+
// refusing is this guard's own doctrine, and the corpus binds the two
|
|
462
|
+
// guards on the resolution precisely so it does not have to bind them
|
|
463
|
+
// on a verdict one of them would be wrong to reach.
|
|
464
|
+
say(
|
|
465
|
+
`cannot tell which directory ${named} would run from; NOT enforcing on it`
|
|
466
|
+
);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const path = isAbsolute(named) ? resolve(named) : resolve(cwd, named);
|
|
378
470
|
let text;
|
|
379
471
|
try {
|
|
380
472
|
if (statSync(path).size > SCRIPT_READ_LIMIT) {
|
|
@@ -386,7 +478,7 @@ function scriptReachingForeignTree(payload, bound) {
|
|
|
386
478
|
// Not a readable file: nothing was executed that this can read.
|
|
387
479
|
continue;
|
|
388
480
|
}
|
|
389
|
-
const foreign = foreignTreeIn(text, bound,
|
|
481
|
+
const foreign = foreignTreeIn(text, bound, cwd);
|
|
390
482
|
if (foreign) return { script: path, foreign };
|
|
391
483
|
}
|
|
392
484
|
return null;
|
|
@@ -304,6 +304,11 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
304
304
|
for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
|
|
305
305
|
const token = raw.replace(/^["']|["']$/gu, "");
|
|
306
306
|
if (!token.includes("/")) continue;
|
|
307
|
+
// A null base means an unliteral `cd` left the directory this script runs
|
|
308
|
+
// from unknown. An ABSOLUTE token is unambiguous either way; a relative one
|
|
309
|
+
// is exactly the thing that cannot be placed, so it is skipped rather than
|
|
310
|
+
// resolved against a directory picked for having been handy.
|
|
311
|
+
if (!isAbsolute(token) && cwd === null) continue;
|
|
307
312
|
const candidate = isAbsolute(token) ? token : join(cwd, token);
|
|
308
313
|
let root;
|
|
309
314
|
try {
|
|
@@ -317,6 +322,63 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
317
322
|
return null;
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
/**
|
|
326
|
+
* A `cd` target this guard cannot turn into a literal directory.
|
|
327
|
+
*
|
|
328
|
+
* Mirrors `parity-safety-net.sh`'s `follow_cd` test exactly, and the mirror is
|
|
329
|
+
* the point: two spellings of one rule are how the guards drift.
|
|
330
|
+
*/
|
|
331
|
+
const UNLITERAL_CD_TARGET = /[$`*?[{]/u;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The literal target of a leading `cd`, `""` for a bare `cd`, or null when the
|
|
335
|
+
* segment does not change directory.
|
|
336
|
+
*
|
|
337
|
+
* A bare `cd` is `cd "$HOME"`. Leaving the previous directory in place for it
|
|
338
|
+
* would resolve later tokens from a directory the shell has already left.
|
|
339
|
+
* @param segment - One statement from the command
|
|
340
|
+
* @returns The target text, or null when this segment is not a `cd`
|
|
341
|
+
*/
|
|
342
|
+
function cdTarget(segment) {
|
|
343
|
+
const tokens = tokenise(segment);
|
|
344
|
+
if (tokens.length === 0 || tokens[0] !== "cd") return null;
|
|
345
|
+
return tokens[1] ?? "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Apply one `cd` to the directory currently in force.
|
|
350
|
+
*
|
|
351
|
+
* Contract points 1-4 of `cwd-resolution-corpus.json`: a literal target
|
|
352
|
+
* REPLACES the directory rather than joining a list of candidates — the
|
|
353
|
+
* ordering was #3933's entire defect; a relative target composes against the
|
|
354
|
+
* directory in force; a target that is not literal text yields unknown; and
|
|
355
|
+
* unknown is sticky only until the next resolvable `cd`, because this returns
|
|
356
|
+
* a fresh answer for each one.
|
|
357
|
+
* @param current - Directory in force, or null when it is already unknown
|
|
358
|
+
* @param target - Literal `cd` target, `""` for a bare `cd`
|
|
359
|
+
* @returns The new directory, or null when it cannot be known
|
|
360
|
+
*/
|
|
361
|
+
function applyCd(current, target) {
|
|
362
|
+
const home = homedir();
|
|
363
|
+
let value = target === "" ? home : target;
|
|
364
|
+
if (value === "~") value = home;
|
|
365
|
+
else if (value.startsWith("~/")) value = join(home, value.slice(2));
|
|
366
|
+
if (
|
|
367
|
+
value === "" ||
|
|
368
|
+
value.startsWith("-") ||
|
|
369
|
+
UNLITERAL_CD_TARGET.test(value)
|
|
370
|
+
) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
if (!isAbsolute(value) && current === null) return null;
|
|
374
|
+
const next = isAbsolute(value) ? resolve(value) : resolve(current, value);
|
|
375
|
+
try {
|
|
376
|
+
return statSync(next).isDirectory() ? next : null;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
320
382
|
/**
|
|
321
383
|
* The foreign worktree an executed script reaches, or null.
|
|
322
384
|
*
|
|
@@ -340,41 +402,71 @@ function foreignTreeIn(text, bound, cwd) {
|
|
|
340
402
|
* disagreeing. The scripted form is the one neither covers, and it is the only
|
|
341
403
|
* cell this arm fills.
|
|
342
404
|
*
|
|
343
|
-
* ## A relative script path is resolved against
|
|
344
|
-
*
|
|
405
|
+
* ## A relative script path is resolved against the cwd the command will have
|
|
406
|
+
*
|
|
407
|
+
* A leading `cd` establishes the directory this guard resolves relative tokens
|
|
408
|
+
* from, because that is the directory the shell will be in once the command
|
|
409
|
+
* runs. Resolving against `payload.cwd` instead was CodySwannGT/lisa#3933's
|
|
410
|
+
* defect, inherited here knowingly and fixed in CodySwannGT/lisa#3952: on a
|
|
411
|
+
* machine running many worktrees of one repository, every tree holds its own
|
|
412
|
+
* copy of every script at the same relative path, and they differ by whatever
|
|
413
|
+
* each lane is doing.
|
|
345
414
|
*
|
|
346
|
-
*
|
|
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.
|
|
415
|
+
* ## One contract, two implementations, and why that is the honest answer
|
|
351
416
|
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* the
|
|
357
|
-
*
|
|
417
|
+
* `parity-safety-net.sh` answers this same question — "which file will this
|
|
418
|
+
* command actually execute?" — in POSIX shell, where `follow_cd` mutates
|
|
419
|
+
* file-scope state that `follow_locate` reads. This guard is Node. **There is
|
|
420
|
+
* no call that reaches from here into a shell function's file-scope state**, so
|
|
421
|
+
* the duplication is not a factoring somebody skipped; it is one idea that
|
|
422
|
+
* cannot cross a language boundary as a function call.
|
|
358
423
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* When that lands, this call site should adopt whatever it produces.
|
|
424
|
+
* What binds the two is a written contract and a corpus of rows that BOTH
|
|
425
|
+
* guards are driven over in CI, so a divergence fails a test on the commit that
|
|
426
|
+
* causes it rather than surfacing later as a wrong verdict. Contract and corpus
|
|
427
|
+
* live in `tests/unit/hooks/support/cwd-resolution-corpus.json`.
|
|
364
428
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
429
|
+
* This is explicitly NOT unification, and CodySwannGT/lisa#3952 records it as
|
|
430
|
+
* such: two implementations remain. **A behaviour with no row in the corpus is
|
|
431
|
+
* free to diverge — add a row when you add a branch.**
|
|
432
|
+
*
|
|
433
|
+
* ## The two guards agree on the RESOLUTION, not on the verdict
|
|
434
|
+
*
|
|
435
|
+
* When the effective directory cannot be known, both guards decline to resolve
|
|
436
|
+
* the token. What they then do differs, and that difference is deliberate:
|
|
437
|
+
* `parity-safety-net.sh` refuses, because a destructive command it cannot see
|
|
438
|
+
* is the thing it exists to stop. This guard fails open, which is the doctrine
|
|
439
|
+
* every other undecidable case in this file follows. The corpus asserts the
|
|
440
|
+
* resolved path, not the exit code, precisely so that one contract can bind two
|
|
441
|
+
* guards whose fail directions are correctly different.
|
|
368
442
|
*
|
|
369
443
|
* Fails OPEN and says so, like every other undecidable case in this file.
|
|
370
444
|
*/
|
|
371
445
|
function scriptReachingForeignTree(payload, bound) {
|
|
372
446
|
const command = payload.tool_input?.command;
|
|
373
447
|
if (typeof command !== "string" || !command) return null;
|
|
448
|
+
let cwd = payload.cwd;
|
|
374
449
|
for (const segment of command.split(COMMAND_SEPARATORS)) {
|
|
450
|
+
const target = cdTarget(segment);
|
|
451
|
+
if (target !== null) {
|
|
452
|
+
cwd = applyCd(cwd, target);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
375
455
|
const named = executedPath(segment);
|
|
376
456
|
if (!named) continue;
|
|
377
|
-
|
|
457
|
+
if (!isAbsolute(named) && cwd === null) {
|
|
458
|
+
// Contract point 3: the effective directory is unknown, so WHICH copy of
|
|
459
|
+
// a same-named script would run is unknown too. Declining to resolve is
|
|
460
|
+
// the half shared with parity-safety-net.sh; failing open rather than
|
|
461
|
+
// refusing is this guard's own doctrine, and the corpus binds the two
|
|
462
|
+
// guards on the resolution precisely so it does not have to bind them
|
|
463
|
+
// on a verdict one of them would be wrong to reach.
|
|
464
|
+
say(
|
|
465
|
+
`cannot tell which directory ${named} would run from; NOT enforcing on it`
|
|
466
|
+
);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const path = isAbsolute(named) ? resolve(named) : resolve(cwd, named);
|
|
378
470
|
let text;
|
|
379
471
|
try {
|
|
380
472
|
if (statSync(path).size > SCRIPT_READ_LIMIT) {
|
|
@@ -386,7 +478,7 @@ function scriptReachingForeignTree(payload, bound) {
|
|
|
386
478
|
// Not a readable file: nothing was executed that this can read.
|
|
387
479
|
continue;
|
|
388
480
|
}
|
|
389
|
-
const foreign = foreignTreeIn(text, bound,
|
|
481
|
+
const foreign = foreignTreeIn(text, bound, cwd);
|
|
390
482
|
if (foreign) return { script: path, foreign };
|
|
391
483
|
}
|
|
392
484
|
return null;
|