@codyswann/lisa 4.72.5 → 4.72.7
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 +176 -53
- 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 +18 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/nestjs/package-lisa/package.lisa.json +1 -1
- package/package.json +6 -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 +176 -53
- 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 +176 -53
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +176 -53
- 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 +176 -53
- package/typescript/package-lisa/package.lisa.json +2 -2
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.967.0",
|
|
65
65
|
"@aws-sdk/client-cognito-identity-provider": "^3.969.0",
|
|
66
66
|
"@aws-sdk/rds-signer": "^3.968.0",
|
|
67
|
-
"@graphql-tools/utils": "^
|
|
67
|
+
"@graphql-tools/utils": "^12.0.1",
|
|
68
68
|
"@nestjs/apollo": "^13.2.3",
|
|
69
69
|
"@nestjs/common": "^11.1.11",
|
|
70
70
|
"@nestjs/config": "^4.0.2",
|
package/package.json
CHANGED
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"eslint-plugin-react-hooks": "^7.0.0",
|
|
154
154
|
"flatted": "^3.4.2",
|
|
155
155
|
"form-data": ">=4.0.6",
|
|
156
|
-
"handlebars": ">=4.7.
|
|
156
|
+
"handlebars": ">=4.7.10",
|
|
157
157
|
"lodash": ">=4.18.1",
|
|
158
158
|
"multer": ">=2.3.0",
|
|
159
159
|
"nanoid": "^3.3.18",
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
"eslint-plugin-react-hooks": "^7.0.0",
|
|
178
178
|
"flatted": "^3.4.2",
|
|
179
179
|
"form-data": ">=4.0.6",
|
|
180
|
-
"handlebars": ">=4.7.
|
|
180
|
+
"handlebars": ">=4.7.10",
|
|
181
181
|
"lodash": ">=4.18.1",
|
|
182
182
|
"multer": ">=2.3.0",
|
|
183
183
|
"nanoid": "^3.3.18",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"qs": "^6.16.0"
|
|
190
190
|
},
|
|
191
191
|
"name": "@codyswann/lisa",
|
|
192
|
-
"version": "4.72.
|
|
192
|
+
"version": "4.72.7",
|
|
193
193
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
194
194
|
"main": "dist/index.js",
|
|
195
195
|
"exports": {
|
|
@@ -345,7 +345,7 @@
|
|
|
345
345
|
"test": "tests"
|
|
346
346
|
},
|
|
347
347
|
"types": "./dist/index.d.ts",
|
|
348
|
-
"lisaReleaseCommit": "
|
|
349
|
-
"gitHead": "
|
|
350
|
-
"lisaReleaseTag": "v4.72.
|
|
348
|
+
"lisaReleaseCommit": "0ff901294afde67c20b1db2ef1056add09209e1e",
|
|
349
|
+
"gitHead": "0ff901294afde67c20b1db2ef1056add09209e1e",
|
|
350
|
+
"lisaReleaseTag": "v4.72.7"
|
|
351
351
|
}
|
|
@@ -42,14 +42,16 @@
|
|
|
42
42
|
* or accept a linked target this session was explicitly offered. A selected
|
|
43
43
|
* target does not prove a move: the next guarded call must actually be there.
|
|
44
44
|
*
|
|
45
|
-
* FAILING OPEN, LOUDLY.
|
|
46
|
-
* cwd, not a git repository, an unreadable state file —
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* FAILING OPEN, LOUDLY. Missing input or unavailable I/O — no session id, no
|
|
46
|
+
* cwd, not a git repository, an unreadable state file — retains the measured
|
|
47
|
+
* fail-open behavior. A readable state file with missing, mismatched or damaged
|
|
48
|
+
* ownership is a different fact: it cannot authorize a baseline or be silently
|
|
49
|
+
* overwritten. Legacy ambiguity requires explicit intent; a conflicting reserved
|
|
50
|
+
* slot requires a new session identity.
|
|
50
51
|
*/
|
|
51
52
|
|
|
52
53
|
import { execFileSync } from "node:child_process";
|
|
54
|
+
import { createHash } from "node:crypto";
|
|
53
55
|
import {
|
|
54
56
|
mkdirSync,
|
|
55
57
|
readFileSync,
|
|
@@ -72,6 +74,10 @@ const GUARDED_TOOLS = new Set(["Bash", "Write", "Edit", "MultiEdit"]);
|
|
|
72
74
|
*/
|
|
73
75
|
const ACCEPT_PREFIX = "lisa-worktree-binding: accept";
|
|
74
76
|
const ACK_AND_SEPARATOR = Symbol("acknowledgement AND separator");
|
|
77
|
+
const BINDING_VERSION = 4;
|
|
78
|
+
const CURRENT_BINDING = Symbol("current binding state");
|
|
79
|
+
const UNAVAILABLE_BINDING = Symbol("unavailable binding read");
|
|
80
|
+
const BINDING_WRITE_CONFLICT = Symbol("binding write conflict");
|
|
75
81
|
|
|
76
82
|
/**
|
|
77
83
|
* Resolve a path the way git reports one.
|
|
@@ -586,62 +592,114 @@ function keyPart(value) {
|
|
|
586
592
|
* its writes were refused, or an acknowledgement rebound the file out from
|
|
587
593
|
* under the sibling (CodySwannGT/lisa#4277). The payload's `agent_id` is what
|
|
588
594
|
* distinguishes a subagent from the main agent, so a caller carrying one gets
|
|
589
|
-
* its own state file. The main agent carries no `agent_id` and
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
595
|
+
* its own state file. The main agent carries no `agent_id` and uses a null
|
|
596
|
+
* agent component, also the fallback for runtimes that never send the field.
|
|
597
|
+
*
|
|
598
|
+
* v4 hashes the JSON tuple into a lowercase, bounded filename in its own
|
|
599
|
+
* directory. The digest fixes filename size and casing; the persisted exact
|
|
600
|
+
* tuple remains the authority. A conflicting owner in a reserved slot is
|
|
601
|
+
* refused, never overwritten. Earlier flat filenames were ambiguous across
|
|
602
|
+
* formats (CodySwannGT/lisa#4311).
|
|
603
|
+
* @param {object} payload - Native hook identity
|
|
604
|
+
* @returns {[string, string|null]} Session and normalized optional agent
|
|
599
605
|
*/
|
|
600
|
-
function
|
|
606
|
+
function bindingIdentity(payload) {
|
|
601
607
|
const agent = payload?.agent_id;
|
|
602
|
-
|
|
603
|
-
|
|
608
|
+
return [
|
|
609
|
+
payload.session_id,
|
|
610
|
+
typeof agent === "string" && agent ? agent : null,
|
|
611
|
+
];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Resolve the reserved slot without exposing filename-unsafe identity bytes.
|
|
616
|
+
* @param {object} payload - Native hook identity
|
|
617
|
+
* @returns {string} Versioned, bounded relative state-file stem
|
|
618
|
+
*/
|
|
619
|
+
function bindingKey(payload) {
|
|
620
|
+
const digest = createHash("sha256")
|
|
621
|
+
.update(JSON.stringify(bindingIdentity(payload)))
|
|
622
|
+
.digest("hex");
|
|
623
|
+
return join(`v${BINDING_VERSION}`, digest);
|
|
604
624
|
}
|
|
605
625
|
|
|
606
626
|
/**
|
|
607
627
|
* The keys earlier versions wrote for this payload, newest first.
|
|
608
628
|
*
|
|
609
629
|
* The v1 key was the raw `session_id` (single shared file per session). v2
|
|
610
|
-
* joined
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
630
|
+
* joined sanitized session and agent on `--`; v3 escaped both components.
|
|
631
|
+
* All are inspected on upgrade, but only a matching v4 owner record grants
|
|
632
|
+
* authority. An unstamped file cannot distinguish its possible owners, so the
|
|
633
|
+
* guard requires explicit intent rather than guessing or silently re-baselining.
|
|
634
|
+
* Legacy files stay untouched and only safe filename stems are inspected.
|
|
615
635
|
* @param {object} payload - The hook payload
|
|
616
636
|
* @returns {string[]} Zero or more legacy keys this payload may have written
|
|
617
637
|
*/
|
|
618
638
|
function legacyKeys(payload) {
|
|
619
639
|
const agent = payload?.agent_id;
|
|
620
640
|
const session = payload.session_id;
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
641
|
+
const perAgent = typeof agent === "string" && agent;
|
|
642
|
+
const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
|
|
643
|
+
const older = perAgent ? `${san(session)}--${san(agent)}` : session;
|
|
644
|
+
let escaped = null;
|
|
645
|
+
try {
|
|
646
|
+
escaped = perAgent
|
|
647
|
+
? `${keyPart(session)}--${keyPart(agent)}`
|
|
648
|
+
: keyPart(session);
|
|
649
|
+
} catch {
|
|
650
|
+
// A lone surrogate could not have produced an escaped v3 filename.
|
|
627
651
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
652
|
+
return [...new Set([escaped, older])].filter(
|
|
653
|
+
key => typeof key === "string" && key && !/[\\/]/u.test(key)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Require the producer's version, exact tuple and an absolute binding root.
|
|
659
|
+
* A matching identity with a damaged root cannot authorize re-baselining.
|
|
660
|
+
* @param {object|null} state - Parsed local state
|
|
661
|
+
* @param {object} payload - Caller claiming the slot
|
|
662
|
+
* @returns {boolean} Whether the record identifies this caller
|
|
663
|
+
*/
|
|
664
|
+
function ownsState(state, payload) {
|
|
665
|
+
const expected = bindingIdentity(payload);
|
|
666
|
+
return (
|
|
667
|
+
typeof state?.boundRoot === "string" &&
|
|
668
|
+
isAbsolute(state.boundRoot) &&
|
|
669
|
+
!state.boundRoot.includes("\0") &&
|
|
670
|
+
state?.bindingVersion === BINDING_VERSION &&
|
|
671
|
+
Array.isArray(state.bindingIdentity) &&
|
|
672
|
+
state.bindingIdentity.length === expected.length &&
|
|
673
|
+
state.bindingIdentity.every((part, index) => part === expected[index])
|
|
674
|
+
);
|
|
634
675
|
}
|
|
635
676
|
|
|
636
677
|
function readState(bindingKeyValue, payload) {
|
|
637
|
-
|
|
678
|
+
try {
|
|
679
|
+
const state = JSON.parse(readFileSync(stateFile(bindingKeyValue), "utf8"));
|
|
680
|
+
return ownsState(state, payload)
|
|
681
|
+
? { ...state, [CURRENT_BINDING]: true }
|
|
682
|
+
: { unverifiedOwner: true, canonicalConflict: true };
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error instanceof SyntaxError)
|
|
685
|
+
return { unverifiedOwner: true, canonicalConflict: true };
|
|
686
|
+
// Unavailable I/O proves neither absence nor conflicting ownership. Keep
|
|
687
|
+
// it separate so an exclusive write cannot mistake EACCES for a race.
|
|
688
|
+
if (error.code !== "ENOENT")
|
|
689
|
+
return { [UNAVAILABLE_BINDING]: error.message };
|
|
690
|
+
}
|
|
691
|
+
let ambiguous = false;
|
|
692
|
+
for (const key of legacyKeys(payload)) {
|
|
638
693
|
try {
|
|
639
|
-
|
|
640
|
-
|
|
694
|
+
const state = JSON.parse(readFileSync(stateFile(key), "utf8"));
|
|
695
|
+
if (ownsState(state, payload)) return state;
|
|
696
|
+
ambiguous = true;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (error instanceof SyntaxError) ambiguous = true;
|
|
641
699
|
// Not written under this key — try the next older one.
|
|
642
700
|
}
|
|
643
701
|
}
|
|
644
|
-
return null;
|
|
702
|
+
return ambiguous ? { unverifiedOwner: true } : null;
|
|
645
703
|
}
|
|
646
704
|
|
|
647
705
|
/**
|
|
@@ -657,23 +715,45 @@ function readState(bindingKeyValue, payload) {
|
|
|
657
715
|
* @param state - Fields to write over the recorded state
|
|
658
716
|
* @param payload - The hook payload, for legacy-key reads while a binding is
|
|
659
717
|
* migrating to the current key format
|
|
660
|
-
* @
|
|
718
|
+
* @param options - Validated acknowledgement or create-only baseline intent
|
|
719
|
+
* @returns True on success, false for unavailable I/O, or a conflict sentinel
|
|
661
720
|
*/
|
|
662
|
-
function writeState(bindingKeyValue, state, payload) {
|
|
721
|
+
function writeState(bindingKeyValue, state, payload, options = {}) {
|
|
663
722
|
const file = stateFile(bindingKeyValue);
|
|
664
723
|
const previous = readState(bindingKeyValue, payload);
|
|
724
|
+
if (previous?.[UNAVAILABLE_BINDING]) {
|
|
725
|
+
say(
|
|
726
|
+
`could not read the binding (${previous[UNAVAILABLE_BINDING]}); NOT enforcing`
|
|
727
|
+
);
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
if (
|
|
731
|
+
(options.createOnly && previous) ||
|
|
732
|
+
previous?.canonicalConflict ||
|
|
733
|
+
(previous?.unverifiedOwner && !options.acceptUnowned)
|
|
734
|
+
)
|
|
735
|
+
return BINDING_WRITE_CONFLICT;
|
|
736
|
+
const proven = previous?.unverifiedOwner ? null : previous;
|
|
737
|
+
// A runtime notice alone cannot establish a binding after a failed baseline.
|
|
738
|
+
// Every record we produce must satisfy the same authority check as a read.
|
|
739
|
+
const next = {
|
|
740
|
+
...proven,
|
|
741
|
+
...state,
|
|
742
|
+
bindingVersion: BINDING_VERSION,
|
|
743
|
+
bindingIdentity: bindingIdentity(payload),
|
|
744
|
+
};
|
|
745
|
+
if (!ownsState(next, payload)) return false;
|
|
665
746
|
const originalRoot =
|
|
666
|
-
|
|
747
|
+
proven?.originalRoot ?? proven?.boundRoot ?? state.boundRoot;
|
|
667
748
|
const originalCommonDir =
|
|
668
|
-
|
|
749
|
+
proven?.originalCommonDir ?? repositoryIdentity(originalRoot);
|
|
669
750
|
try {
|
|
670
751
|
mkdirSync(dirname(file), { recursive: true });
|
|
671
752
|
writeFileSync(
|
|
672
753
|
file,
|
|
673
754
|
`${JSON.stringify(
|
|
674
755
|
{
|
|
675
|
-
...
|
|
676
|
-
...state,
|
|
756
|
+
...next,
|
|
677
757
|
// Preserve the initial root after intentional rebinds. A legacy
|
|
678
758
|
// state's established root is its only proven recovery anchor.
|
|
679
759
|
originalRoot,
|
|
@@ -681,10 +761,12 @@ function writeState(bindingKeyValue, state, payload) {
|
|
|
681
761
|
},
|
|
682
762
|
null,
|
|
683
763
|
2
|
|
684
|
-
)}\n
|
|
764
|
+
)}\n`,
|
|
765
|
+
{ flag: previous?.[CURRENT_BINDING] ? "w" : "wx" }
|
|
685
766
|
);
|
|
686
767
|
return true;
|
|
687
768
|
} catch (error) {
|
|
769
|
+
if (error.code === "EEXIST") return BINDING_WRITE_CONFLICT;
|
|
688
770
|
say(`could not record the binding (${error.message}); NOT enforcing`);
|
|
689
771
|
return false;
|
|
690
772
|
}
|
|
@@ -764,7 +846,8 @@ function recordBaseline(bindingKeyValue, observed, payload) {
|
|
|
764
846
|
// Never overwrite. A resumed session, a reconnect, or anything else that
|
|
765
847
|
// fires the event twice must not be able to launder a displaced binding into
|
|
766
848
|
// a fresh baseline — which is the whole failure this exists to close.
|
|
767
|
-
|
|
849
|
+
const previous = readState(bindingKeyValue, payload);
|
|
850
|
+
if (previous?.boundRoot || previous?.unverifiedOwner) return;
|
|
768
851
|
writeState(
|
|
769
852
|
bindingKeyValue,
|
|
770
853
|
{
|
|
@@ -772,7 +855,8 @@ function recordBaseline(bindingKeyValue, observed, payload) {
|
|
|
772
855
|
claimedRoot: null,
|
|
773
856
|
updatedAt: new Date().toISOString(),
|
|
774
857
|
},
|
|
775
|
-
payload
|
|
858
|
+
payload,
|
|
859
|
+
{ createOnly: true }
|
|
776
860
|
);
|
|
777
861
|
}
|
|
778
862
|
|
|
@@ -788,6 +872,7 @@ function recordClaim(payload, observed) {
|
|
|
788
872
|
const claimed = claimedRoot(payload.tool_input, payload.cwd);
|
|
789
873
|
if (!claimed || claimed === observed) return;
|
|
790
874
|
const previous = readState(bindingKey(payload), payload);
|
|
875
|
+
if (previous?.unverifiedOwner) return;
|
|
791
876
|
writeState(
|
|
792
877
|
bindingKey(payload),
|
|
793
878
|
{
|
|
@@ -795,7 +880,8 @@ function recordClaim(payload, observed) {
|
|
|
795
880
|
claimedRoot: claimed,
|
|
796
881
|
updatedAt: new Date().toISOString(),
|
|
797
882
|
},
|
|
798
|
-
payload
|
|
883
|
+
payload,
|
|
884
|
+
{ createOnly: !previous }
|
|
799
885
|
);
|
|
800
886
|
}
|
|
801
887
|
|
|
@@ -804,11 +890,41 @@ function refuse(lines) {
|
|
|
804
890
|
return 2;
|
|
805
891
|
}
|
|
806
892
|
|
|
893
|
+
/** Refuse an action whose binding changed while its creation was in flight. */
|
|
894
|
+
function bindingWriteConflict() {
|
|
895
|
+
return refuse([
|
|
896
|
+
"worktree-binding-guard: another hook recorded this session's binding during this call.",
|
|
897
|
+
"The existing file has been preserved. Retry the call to check its established binding.",
|
|
898
|
+
]);
|
|
899
|
+
}
|
|
900
|
+
|
|
807
901
|
function acceptanceLine(observed) {
|
|
808
902
|
const literal = `${ACCEPT_PREFIX} ${observed}`.replaceAll("'", "'\\''");
|
|
809
903
|
return ` echo '${literal}'`;
|
|
810
904
|
}
|
|
811
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Explain a real ownership ambiguity without treating a stranger's root as fact.
|
|
908
|
+
* @param {object} state - Unverified-state sentinel, containing no imported fields
|
|
909
|
+
* @param {string|null} observed - Measured checkout
|
|
910
|
+
* @returns {number} Blocking native-hook status
|
|
911
|
+
*/
|
|
912
|
+
function unownedBinding(state, observed) {
|
|
913
|
+
return refuse([
|
|
914
|
+
"worktree-binding-guard: cannot prove this binding belongs to this session.",
|
|
915
|
+
"The existing file has no matching owner record. It has not been imported or changed.",
|
|
916
|
+
...(state.canonicalConflict
|
|
917
|
+
? [
|
|
918
|
+
"This session's reserved state file identifies another owner or an unknown format.",
|
|
919
|
+
"Start a new session with its own identity; acknowledging cannot overwrite that file.",
|
|
920
|
+
]
|
|
921
|
+
: [
|
|
922
|
+
"Choose the worktree you intend to use by acknowledging its measured path:",
|
|
923
|
+
acceptanceLine(observed),
|
|
924
|
+
]),
|
|
925
|
+
]);
|
|
926
|
+
}
|
|
927
|
+
|
|
812
928
|
/** Read literal shell words and AND separators without evaluating expansions. */
|
|
813
929
|
function acknowledgementWords(text) {
|
|
814
930
|
const words = [];
|
|
@@ -926,6 +1042,7 @@ function handleAcceptance(payload, observed) {
|
|
|
926
1042
|
if (!request) return null;
|
|
927
1043
|
const { path, validMove } = request;
|
|
928
1044
|
const previous = readState(bindingKey(payload), payload);
|
|
1045
|
+
if (previous?.canonicalConflict) return unownedBinding(previous, observed);
|
|
929
1046
|
const original = previous?.originalRoot ?? previous?.boundRoot;
|
|
930
1047
|
const recovering = isOriginalRoot(previous, path);
|
|
931
1048
|
const offered =
|
|
@@ -952,8 +1069,10 @@ function handleAcceptance(payload, observed) {
|
|
|
952
1069
|
offeredRoot: null,
|
|
953
1070
|
updatedAt: new Date().toISOString(),
|
|
954
1071
|
},
|
|
955
|
-
payload
|
|
1072
|
+
payload,
|
|
1073
|
+
{ acceptUnowned: true }
|
|
956
1074
|
);
|
|
1075
|
+
if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
|
|
957
1076
|
if (written)
|
|
958
1077
|
say(
|
|
959
1078
|
path === observed
|
|
@@ -1188,6 +1307,7 @@ function evaluate(payload) {
|
|
|
1188
1307
|
if (!GUARDED_TOOLS.has(payload.tool_name)) return 0;
|
|
1189
1308
|
|
|
1190
1309
|
const state = readState(binding, payload);
|
|
1310
|
+
if (state?.unverifiedOwner) return unownedBinding(state, observed);
|
|
1191
1311
|
if (!state?.boundRoot) {
|
|
1192
1312
|
// No baseline means SessionStart did not run for this session — an older
|
|
1193
1313
|
// install, a runtime that fires no such event, a harness that skips it.
|
|
@@ -1196,15 +1316,17 @@ function evaluate(payload) {
|
|
|
1196
1316
|
// refusing. Treating it as a refusal would turn a floor into a wall on
|
|
1197
1317
|
// every surface where the event does not fire, and a wall gets switched
|
|
1198
1318
|
// off, which costs the whole guard.
|
|
1199
|
-
writeState(
|
|
1319
|
+
const written = writeState(
|
|
1200
1320
|
binding,
|
|
1201
1321
|
{
|
|
1202
1322
|
boundRoot: observed,
|
|
1203
1323
|
claimedRoot: null,
|
|
1204
1324
|
updatedAt: new Date().toISOString(),
|
|
1205
1325
|
},
|
|
1206
|
-
payload
|
|
1326
|
+
payload,
|
|
1327
|
+
{ createOnly: true }
|
|
1207
1328
|
);
|
|
1329
|
+
if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
|
|
1208
1330
|
return 0;
|
|
1209
1331
|
}
|
|
1210
1332
|
if (
|
|
@@ -1216,7 +1338,7 @@ function evaluate(payload) {
|
|
|
1216
1338
|
return unconfirmedSwitch(state.claimedRoot, observed);
|
|
1217
1339
|
}
|
|
1218
1340
|
if (state.claimedRoot === observed) {
|
|
1219
|
-
writeState(
|
|
1341
|
+
const written = writeState(
|
|
1220
1342
|
binding,
|
|
1221
1343
|
{
|
|
1222
1344
|
boundRoot: observed,
|
|
@@ -1226,6 +1348,7 @@ function evaluate(payload) {
|
|
|
1226
1348
|
},
|
|
1227
1349
|
payload
|
|
1228
1350
|
);
|
|
1351
|
+
if (written === BINDING_WRITE_CONFLICT) return bindingWriteConflict();
|
|
1229
1352
|
return 0;
|
|
1230
1353
|
}
|
|
1231
1354
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|