@codyswann/lisa 4.66.2 → 4.66.4
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 +135 -50
- package/cdk/create-only/.github/workflows/deploy.yml +10 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -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 +7 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +19 -4
- package/expo/create-only/.github/workflows/deploy.yml +10 -0
- package/nestjs/create-only/.github/workflows/deploy.yml +10 -0
- package/package.json +4 -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 +135 -50
- 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 +135 -50
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +135 -50
- 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 +135 -50
- package/typescript/copy-contents/.husky/commit-msg +48 -11
|
@@ -511,6 +511,27 @@ function stateFile(bindingKey) {
|
|
|
511
511
|
return join(home, "worktree-binding", `${bindingKey}.json`);
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Percent-escape every byte that is not filename-safe, so the output cannot
|
|
516
|
+
* contain `-` at all and two escaped parts can be joined on `--` without the
|
|
517
|
+
* joiner ever being smuggled in from either side.
|
|
518
|
+
*
|
|
519
|
+
* `:` and friends are folded too — the key lands in a filename and `:` is
|
|
520
|
+
* illegal on Windows/NTFS (CodySwannGT/lisa#4277). `encodeURIComponent`
|
|
521
|
+
* leaves `! ' ( ) *` alone, and `*` is equally illegal on Windows, so they
|
|
522
|
+
* are escaped by hand.
|
|
523
|
+
* @param {string} value - One component of the binding key
|
|
524
|
+
* @returns {string} A component containing only `[A-Za-z0-9._~]` and `%XX`
|
|
525
|
+
*/
|
|
526
|
+
function keyPart(value) {
|
|
527
|
+
return encodeURIComponent(value)
|
|
528
|
+
.replaceAll(
|
|
529
|
+
/[!'()*]/g,
|
|
530
|
+
c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
|
|
531
|
+
)
|
|
532
|
+
.replaceAll("-", "%2D");
|
|
533
|
+
}
|
|
534
|
+
|
|
514
535
|
/**
|
|
515
536
|
* The identity a binding belongs to.
|
|
516
537
|
*
|
|
@@ -524,22 +545,58 @@ function stateFile(bindingKey) {
|
|
|
524
545
|
* session-scoped key it always had, which is also the fallback for runtimes
|
|
525
546
|
* that never send the field.
|
|
526
547
|
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
548
|
+
* Both shapes escape every component with {@link keyPart}, so the session key
|
|
549
|
+
* `x` and the agent key `a--b` live in disjoint namespaces: an escaped
|
|
550
|
+
* component never contains `-`, a main-session key is one component, an
|
|
551
|
+
* agent key is two joined by the only `--` the string can hold. Session
|
|
552
|
+
* "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
|
|
553
|
+
* collide across the two shapes (CodySwannGT/lisa#4294).
|
|
530
554
|
*/
|
|
531
555
|
function bindingKey(payload) {
|
|
532
556
|
const agent = payload?.agent_id;
|
|
533
|
-
if (typeof agent !== "string" || !agent) return payload.session_id;
|
|
534
|
-
return `${payload.session_id}--${agent}
|
|
557
|
+
if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
|
|
558
|
+
return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
|
|
535
559
|
}
|
|
536
560
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
561
|
+
/**
|
|
562
|
+
* The keys earlier versions wrote for this payload, newest first.
|
|
563
|
+
*
|
|
564
|
+
* The v1 key was the raw `session_id` (single shared file per session). v2
|
|
565
|
+
* joined raw session and agent on `--`. Both are readable on upgrade so a
|
|
566
|
+
* mid-session deploy does not silently re-baseline an established binding —
|
|
567
|
+
* the ambiguity in a legacy `a--b` key is resolved toward the interpretation
|
|
568
|
+
* the lookup is making, and the next write lands on the current key while the
|
|
569
|
+
* legacy file is left to age out.
|
|
570
|
+
* @param {object} payload - The hook payload
|
|
571
|
+
* @returns {string[]} Zero or more legacy keys this payload may have written
|
|
572
|
+
*/
|
|
573
|
+
function legacyKeys(payload) {
|
|
574
|
+
const agent = payload?.agent_id;
|
|
575
|
+
const session = payload.session_id;
|
|
576
|
+
if (typeof agent === "string" && agent) {
|
|
577
|
+
// v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
|
|
578
|
+
// agent falling back to the shared session file is the #4277 bug again,
|
|
579
|
+
// so the list stops there.
|
|
580
|
+
const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
|
|
581
|
+
return [`${san(session)}--${san(agent)}`];
|
|
542
582
|
}
|
|
583
|
+
// v1 and v2 both keyed the main agent on the raw session id — but only when
|
|
584
|
+
// that id contains no `--`. One that does spells exactly the composite
|
|
585
|
+
// shape agent bindings now write, so reading it could hand this session a
|
|
586
|
+
// stranger's binding. Losing the v1 binding for such an id re-baselines
|
|
587
|
+
// once; the alternative is reading a file whose owner cannot be proven.
|
|
588
|
+
return session.includes("--") ? [] : [session];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function readState(bindingKeyValue, payload) {
|
|
592
|
+
for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
|
|
593
|
+
try {
|
|
594
|
+
return JSON.parse(readFileSync(stateFile(key), "utf8"));
|
|
595
|
+
} catch {
|
|
596
|
+
// Not written under this key — try the next older one.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
543
600
|
}
|
|
544
601
|
|
|
545
602
|
/**
|
|
@@ -552,17 +609,23 @@ function readState(sessionId) {
|
|
|
552
609
|
* {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
|
|
553
610
|
* would turn its "reported once" into "reported again after the next
|
|
554
611
|
* acknowledgement", which is the noise the notice is designed not to be.
|
|
555
|
-
* @param
|
|
612
|
+
* @param bindingKeyValue - The key whose state file this is
|
|
556
613
|
* @param state - Fields to write over the recorded state
|
|
614
|
+
* @param payload - The hook payload, for legacy-key reads while a binding is
|
|
615
|
+
* migrating to the current key format
|
|
557
616
|
* @returns Whether the write succeeded
|
|
558
617
|
*/
|
|
559
|
-
function writeState(
|
|
560
|
-
const file = stateFile(
|
|
618
|
+
function writeState(bindingKeyValue, state, payload) {
|
|
619
|
+
const file = stateFile(bindingKeyValue);
|
|
561
620
|
try {
|
|
562
621
|
mkdirSync(dirname(file), { recursive: true });
|
|
563
622
|
writeFileSync(
|
|
564
623
|
file,
|
|
565
|
-
`${JSON.stringify(
|
|
624
|
+
`${JSON.stringify(
|
|
625
|
+
{ ...readState(bindingKeyValue, payload), ...state },
|
|
626
|
+
null,
|
|
627
|
+
2
|
|
628
|
+
)}\n`
|
|
566
629
|
);
|
|
567
630
|
return true;
|
|
568
631
|
} catch (error) {
|
|
@@ -637,19 +700,24 @@ function claimedRoot(toolInput, cwd) {
|
|
|
637
700
|
*
|
|
638
701
|
* Returns nothing on purpose, like its sibling below: SessionStart has no call
|
|
639
702
|
* to refuse, so every path through it allows.
|
|
640
|
-
* @param
|
|
703
|
+
* @param bindingKeyValue - The key this baseline belongs to
|
|
641
704
|
* @param observed - The worktree root the session started in
|
|
705
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
642
706
|
*/
|
|
643
|
-
function recordBaseline(
|
|
707
|
+
function recordBaseline(bindingKeyValue, observed, payload) {
|
|
644
708
|
// Never overwrite. A resumed session, a reconnect, or anything else that
|
|
645
709
|
// fires the event twice must not be able to launder a displaced binding into
|
|
646
710
|
// a fresh baseline — which is the whole failure this exists to close.
|
|
647
|
-
if (readState(
|
|
648
|
-
writeState(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
711
|
+
if (readState(bindingKeyValue, payload)?.boundRoot) return;
|
|
712
|
+
writeState(
|
|
713
|
+
bindingKeyValue,
|
|
714
|
+
{
|
|
715
|
+
boundRoot: observed,
|
|
716
|
+
claimedRoot: null,
|
|
717
|
+
updatedAt: new Date().toISOString(),
|
|
718
|
+
},
|
|
719
|
+
payload
|
|
720
|
+
);
|
|
653
721
|
}
|
|
654
722
|
|
|
655
723
|
/**
|
|
@@ -663,12 +731,16 @@ function recordBaseline(sessionId, observed) {
|
|
|
663
731
|
function recordClaim(payload, observed) {
|
|
664
732
|
const claimed = claimedRoot(payload.tool_input, payload.cwd);
|
|
665
733
|
if (!claimed || claimed === observed) return;
|
|
666
|
-
const previous = readState(bindingKey(payload));
|
|
667
|
-
writeState(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
734
|
+
const previous = readState(bindingKey(payload), payload);
|
|
735
|
+
writeState(
|
|
736
|
+
bindingKey(payload),
|
|
737
|
+
{
|
|
738
|
+
boundRoot: previous?.boundRoot ?? observed,
|
|
739
|
+
claimedRoot: claimed,
|
|
740
|
+
updatedAt: new Date().toISOString(),
|
|
741
|
+
},
|
|
742
|
+
payload
|
|
743
|
+
);
|
|
672
744
|
}
|
|
673
745
|
|
|
674
746
|
function refuse(lines) {
|
|
@@ -704,11 +776,15 @@ function handleAcceptance(payload, observed) {
|
|
|
704
776
|
acceptanceLine(observed),
|
|
705
777
|
]);
|
|
706
778
|
}
|
|
707
|
-
writeState(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
779
|
+
writeState(
|
|
780
|
+
bindingKey(payload),
|
|
781
|
+
{
|
|
782
|
+
boundRoot: observed,
|
|
783
|
+
claimedRoot: null,
|
|
784
|
+
updatedAt: new Date().toISOString(),
|
|
785
|
+
},
|
|
786
|
+
payload
|
|
787
|
+
);
|
|
712
788
|
say(`bound to ${observed}`);
|
|
713
789
|
return 0;
|
|
714
790
|
}
|
|
@@ -880,15 +956,16 @@ function driftNotice(version) {
|
|
|
880
956
|
* Never refuses. The dependency is a reporting gap, not a live displacement,
|
|
881
957
|
* and blocking a session over a version number would be a wall where an
|
|
882
958
|
* observation belongs.
|
|
883
|
-
* @param
|
|
959
|
+
* @param bindingKeyValue - The key being started
|
|
884
960
|
* @param env - Environment to read the runtime version from
|
|
961
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
885
962
|
* @returns Nothing; every path through this allows
|
|
886
963
|
*/
|
|
887
|
-
function noticeRuntimeDrift(
|
|
964
|
+
function noticeRuntimeDrift(bindingKeyValue, env, payload) {
|
|
888
965
|
const version = runtimeVersion(env);
|
|
889
966
|
if (version === null || version === VERIFIED_RUNTIME_VERSION) return;
|
|
890
|
-
if (readState(
|
|
891
|
-
writeState(
|
|
967
|
+
if (readState(bindingKeyValue, payload)?.runtimeNoticed === version) return;
|
|
968
|
+
writeState(bindingKeyValue, { runtimeNoticed: version }, payload);
|
|
892
969
|
process.stdout.write(
|
|
893
970
|
`${JSON.stringify({
|
|
894
971
|
hookSpecificOutput: {
|
|
@@ -911,11 +988,11 @@ function evaluate(payload) {
|
|
|
911
988
|
if (!observed) return 0;
|
|
912
989
|
|
|
913
990
|
if (payload.hook_event_name === "SessionStart") {
|
|
914
|
-
recordBaseline(binding, observed);
|
|
991
|
+
recordBaseline(binding, observed, payload);
|
|
915
992
|
// After the baseline, never before: `recordBaseline` writes a fresh state
|
|
916
993
|
// for a session it has not seen, and the notice's record has to go on top
|
|
917
994
|
// of that write rather than under it.
|
|
918
|
-
noticeRuntimeDrift(binding, process.env);
|
|
995
|
+
noticeRuntimeDrift(binding, process.env, payload);
|
|
919
996
|
return 0;
|
|
920
997
|
}
|
|
921
998
|
if (payload.tool_name === "EnterWorktree") {
|
|
@@ -927,7 +1004,7 @@ function evaluate(payload) {
|
|
|
927
1004
|
const accepted = handleAcceptance(payload, observed);
|
|
928
1005
|
if (accepted !== null) return accepted;
|
|
929
1006
|
|
|
930
|
-
const state = readState(binding);
|
|
1007
|
+
const state = readState(binding, payload);
|
|
931
1008
|
if (!state?.boundRoot) {
|
|
932
1009
|
// No baseline means SessionStart did not run for this session — an older
|
|
933
1010
|
// install, a runtime that fires no such event, a harness that skips it.
|
|
@@ -936,22 +1013,30 @@ function evaluate(payload) {
|
|
|
936
1013
|
// refusing. Treating it as a refusal would turn a floor into a wall on
|
|
937
1014
|
// every surface where the event does not fire, and a wall gets switched
|
|
938
1015
|
// off, which costs the whole guard.
|
|
939
|
-
writeState(
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1016
|
+
writeState(
|
|
1017
|
+
binding,
|
|
1018
|
+
{
|
|
1019
|
+
boundRoot: observed,
|
|
1020
|
+
claimedRoot: null,
|
|
1021
|
+
updatedAt: new Date().toISOString(),
|
|
1022
|
+
},
|
|
1023
|
+
payload
|
|
1024
|
+
);
|
|
944
1025
|
return 0;
|
|
945
1026
|
}
|
|
946
1027
|
if (state.claimedRoot && state.claimedRoot !== observed) {
|
|
947
1028
|
return unconfirmedSwitch(state.claimedRoot, observed);
|
|
948
1029
|
}
|
|
949
1030
|
if (state.claimedRoot === observed) {
|
|
950
|
-
writeState(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1031
|
+
writeState(
|
|
1032
|
+
binding,
|
|
1033
|
+
{
|
|
1034
|
+
boundRoot: observed,
|
|
1035
|
+
claimedRoot: null,
|
|
1036
|
+
updatedAt: new Date().toISOString(),
|
|
1037
|
+
},
|
|
1038
|
+
payload
|
|
1039
|
+
);
|
|
955
1040
|
return 0;
|
|
956
1041
|
}
|
|
957
1042
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|
|
@@ -511,6 +511,27 @@ function stateFile(bindingKey) {
|
|
|
511
511
|
return join(home, "worktree-binding", `${bindingKey}.json`);
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Percent-escape every byte that is not filename-safe, so the output cannot
|
|
516
|
+
* contain `-` at all and two escaped parts can be joined on `--` without the
|
|
517
|
+
* joiner ever being smuggled in from either side.
|
|
518
|
+
*
|
|
519
|
+
* `:` and friends are folded too — the key lands in a filename and `:` is
|
|
520
|
+
* illegal on Windows/NTFS (CodySwannGT/lisa#4277). `encodeURIComponent`
|
|
521
|
+
* leaves `! ' ( ) *` alone, and `*` is equally illegal on Windows, so they
|
|
522
|
+
* are escaped by hand.
|
|
523
|
+
* @param {string} value - One component of the binding key
|
|
524
|
+
* @returns {string} A component containing only `[A-Za-z0-9._~]` and `%XX`
|
|
525
|
+
*/
|
|
526
|
+
function keyPart(value) {
|
|
527
|
+
return encodeURIComponent(value)
|
|
528
|
+
.replaceAll(
|
|
529
|
+
/[!'()*]/g,
|
|
530
|
+
c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
|
|
531
|
+
)
|
|
532
|
+
.replaceAll("-", "%2D");
|
|
533
|
+
}
|
|
534
|
+
|
|
514
535
|
/**
|
|
515
536
|
* The identity a binding belongs to.
|
|
516
537
|
*
|
|
@@ -524,22 +545,58 @@ function stateFile(bindingKey) {
|
|
|
524
545
|
* session-scoped key it always had, which is also the fallback for runtimes
|
|
525
546
|
* that never send the field.
|
|
526
547
|
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
548
|
+
* Both shapes escape every component with {@link keyPart}, so the session key
|
|
549
|
+
* `x` and the agent key `a--b` live in disjoint namespaces: an escaped
|
|
550
|
+
* component never contains `-`, a main-session key is one component, an
|
|
551
|
+
* agent key is two joined by the only `--` the string can hold. Session
|
|
552
|
+
* "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
|
|
553
|
+
* collide across the two shapes (CodySwannGT/lisa#4294).
|
|
530
554
|
*/
|
|
531
555
|
function bindingKey(payload) {
|
|
532
556
|
const agent = payload?.agent_id;
|
|
533
|
-
if (typeof agent !== "string" || !agent) return payload.session_id;
|
|
534
|
-
return `${payload.session_id}--${agent}
|
|
557
|
+
if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
|
|
558
|
+
return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
|
|
535
559
|
}
|
|
536
560
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
561
|
+
/**
|
|
562
|
+
* The keys earlier versions wrote for this payload, newest first.
|
|
563
|
+
*
|
|
564
|
+
* The v1 key was the raw `session_id` (single shared file per session). v2
|
|
565
|
+
* joined raw session and agent on `--`. Both are readable on upgrade so a
|
|
566
|
+
* mid-session deploy does not silently re-baseline an established binding —
|
|
567
|
+
* the ambiguity in a legacy `a--b` key is resolved toward the interpretation
|
|
568
|
+
* the lookup is making, and the next write lands on the current key while the
|
|
569
|
+
* legacy file is left to age out.
|
|
570
|
+
* @param {object} payload - The hook payload
|
|
571
|
+
* @returns {string[]} Zero or more legacy keys this payload may have written
|
|
572
|
+
*/
|
|
573
|
+
function legacyKeys(payload) {
|
|
574
|
+
const agent = payload?.agent_id;
|
|
575
|
+
const session = payload.session_id;
|
|
576
|
+
if (typeof agent === "string" && agent) {
|
|
577
|
+
// v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
|
|
578
|
+
// agent falling back to the shared session file is the #4277 bug again,
|
|
579
|
+
// so the list stops there.
|
|
580
|
+
const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
|
|
581
|
+
return [`${san(session)}--${san(agent)}`];
|
|
542
582
|
}
|
|
583
|
+
// v1 and v2 both keyed the main agent on the raw session id — but only when
|
|
584
|
+
// that id contains no `--`. One that does spells exactly the composite
|
|
585
|
+
// shape agent bindings now write, so reading it could hand this session a
|
|
586
|
+
// stranger's binding. Losing the v1 binding for such an id re-baselines
|
|
587
|
+
// once; the alternative is reading a file whose owner cannot be proven.
|
|
588
|
+
return session.includes("--") ? [] : [session];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function readState(bindingKeyValue, payload) {
|
|
592
|
+
for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
|
|
593
|
+
try {
|
|
594
|
+
return JSON.parse(readFileSync(stateFile(key), "utf8"));
|
|
595
|
+
} catch {
|
|
596
|
+
// Not written under this key — try the next older one.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
543
600
|
}
|
|
544
601
|
|
|
545
602
|
/**
|
|
@@ -552,17 +609,23 @@ function readState(sessionId) {
|
|
|
552
609
|
* {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
|
|
553
610
|
* would turn its "reported once" into "reported again after the next
|
|
554
611
|
* acknowledgement", which is the noise the notice is designed not to be.
|
|
555
|
-
* @param
|
|
612
|
+
* @param bindingKeyValue - The key whose state file this is
|
|
556
613
|
* @param state - Fields to write over the recorded state
|
|
614
|
+
* @param payload - The hook payload, for legacy-key reads while a binding is
|
|
615
|
+
* migrating to the current key format
|
|
557
616
|
* @returns Whether the write succeeded
|
|
558
617
|
*/
|
|
559
|
-
function writeState(
|
|
560
|
-
const file = stateFile(
|
|
618
|
+
function writeState(bindingKeyValue, state, payload) {
|
|
619
|
+
const file = stateFile(bindingKeyValue);
|
|
561
620
|
try {
|
|
562
621
|
mkdirSync(dirname(file), { recursive: true });
|
|
563
622
|
writeFileSync(
|
|
564
623
|
file,
|
|
565
|
-
`${JSON.stringify(
|
|
624
|
+
`${JSON.stringify(
|
|
625
|
+
{ ...readState(bindingKeyValue, payload), ...state },
|
|
626
|
+
null,
|
|
627
|
+
2
|
|
628
|
+
)}\n`
|
|
566
629
|
);
|
|
567
630
|
return true;
|
|
568
631
|
} catch (error) {
|
|
@@ -637,19 +700,24 @@ function claimedRoot(toolInput, cwd) {
|
|
|
637
700
|
*
|
|
638
701
|
* Returns nothing on purpose, like its sibling below: SessionStart has no call
|
|
639
702
|
* to refuse, so every path through it allows.
|
|
640
|
-
* @param
|
|
703
|
+
* @param bindingKeyValue - The key this baseline belongs to
|
|
641
704
|
* @param observed - The worktree root the session started in
|
|
705
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
642
706
|
*/
|
|
643
|
-
function recordBaseline(
|
|
707
|
+
function recordBaseline(bindingKeyValue, observed, payload) {
|
|
644
708
|
// Never overwrite. A resumed session, a reconnect, or anything else that
|
|
645
709
|
// fires the event twice must not be able to launder a displaced binding into
|
|
646
710
|
// a fresh baseline — which is the whole failure this exists to close.
|
|
647
|
-
if (readState(
|
|
648
|
-
writeState(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
711
|
+
if (readState(bindingKeyValue, payload)?.boundRoot) return;
|
|
712
|
+
writeState(
|
|
713
|
+
bindingKeyValue,
|
|
714
|
+
{
|
|
715
|
+
boundRoot: observed,
|
|
716
|
+
claimedRoot: null,
|
|
717
|
+
updatedAt: new Date().toISOString(),
|
|
718
|
+
},
|
|
719
|
+
payload
|
|
720
|
+
);
|
|
653
721
|
}
|
|
654
722
|
|
|
655
723
|
/**
|
|
@@ -663,12 +731,16 @@ function recordBaseline(sessionId, observed) {
|
|
|
663
731
|
function recordClaim(payload, observed) {
|
|
664
732
|
const claimed = claimedRoot(payload.tool_input, payload.cwd);
|
|
665
733
|
if (!claimed || claimed === observed) return;
|
|
666
|
-
const previous = readState(bindingKey(payload));
|
|
667
|
-
writeState(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
734
|
+
const previous = readState(bindingKey(payload), payload);
|
|
735
|
+
writeState(
|
|
736
|
+
bindingKey(payload),
|
|
737
|
+
{
|
|
738
|
+
boundRoot: previous?.boundRoot ?? observed,
|
|
739
|
+
claimedRoot: claimed,
|
|
740
|
+
updatedAt: new Date().toISOString(),
|
|
741
|
+
},
|
|
742
|
+
payload
|
|
743
|
+
);
|
|
672
744
|
}
|
|
673
745
|
|
|
674
746
|
function refuse(lines) {
|
|
@@ -704,11 +776,15 @@ function handleAcceptance(payload, observed) {
|
|
|
704
776
|
acceptanceLine(observed),
|
|
705
777
|
]);
|
|
706
778
|
}
|
|
707
|
-
writeState(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
779
|
+
writeState(
|
|
780
|
+
bindingKey(payload),
|
|
781
|
+
{
|
|
782
|
+
boundRoot: observed,
|
|
783
|
+
claimedRoot: null,
|
|
784
|
+
updatedAt: new Date().toISOString(),
|
|
785
|
+
},
|
|
786
|
+
payload
|
|
787
|
+
);
|
|
712
788
|
say(`bound to ${observed}`);
|
|
713
789
|
return 0;
|
|
714
790
|
}
|
|
@@ -880,15 +956,16 @@ function driftNotice(version) {
|
|
|
880
956
|
* Never refuses. The dependency is a reporting gap, not a live displacement,
|
|
881
957
|
* and blocking a session over a version number would be a wall where an
|
|
882
958
|
* observation belongs.
|
|
883
|
-
* @param
|
|
959
|
+
* @param bindingKeyValue - The key being started
|
|
884
960
|
* @param env - Environment to read the runtime version from
|
|
961
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
885
962
|
* @returns Nothing; every path through this allows
|
|
886
963
|
*/
|
|
887
|
-
function noticeRuntimeDrift(
|
|
964
|
+
function noticeRuntimeDrift(bindingKeyValue, env, payload) {
|
|
888
965
|
const version = runtimeVersion(env);
|
|
889
966
|
if (version === null || version === VERIFIED_RUNTIME_VERSION) return;
|
|
890
|
-
if (readState(
|
|
891
|
-
writeState(
|
|
967
|
+
if (readState(bindingKeyValue, payload)?.runtimeNoticed === version) return;
|
|
968
|
+
writeState(bindingKeyValue, { runtimeNoticed: version }, payload);
|
|
892
969
|
process.stdout.write(
|
|
893
970
|
`${JSON.stringify({
|
|
894
971
|
hookSpecificOutput: {
|
|
@@ -911,11 +988,11 @@ function evaluate(payload) {
|
|
|
911
988
|
if (!observed) return 0;
|
|
912
989
|
|
|
913
990
|
if (payload.hook_event_name === "SessionStart") {
|
|
914
|
-
recordBaseline(binding, observed);
|
|
991
|
+
recordBaseline(binding, observed, payload);
|
|
915
992
|
// After the baseline, never before: `recordBaseline` writes a fresh state
|
|
916
993
|
// for a session it has not seen, and the notice's record has to go on top
|
|
917
994
|
// of that write rather than under it.
|
|
918
|
-
noticeRuntimeDrift(binding, process.env);
|
|
995
|
+
noticeRuntimeDrift(binding, process.env, payload);
|
|
919
996
|
return 0;
|
|
920
997
|
}
|
|
921
998
|
if (payload.tool_name === "EnterWorktree") {
|
|
@@ -927,7 +1004,7 @@ function evaluate(payload) {
|
|
|
927
1004
|
const accepted = handleAcceptance(payload, observed);
|
|
928
1005
|
if (accepted !== null) return accepted;
|
|
929
1006
|
|
|
930
|
-
const state = readState(binding);
|
|
1007
|
+
const state = readState(binding, payload);
|
|
931
1008
|
if (!state?.boundRoot) {
|
|
932
1009
|
// No baseline means SessionStart did not run for this session — an older
|
|
933
1010
|
// install, a runtime that fires no such event, a harness that skips it.
|
|
@@ -936,22 +1013,30 @@ function evaluate(payload) {
|
|
|
936
1013
|
// refusing. Treating it as a refusal would turn a floor into a wall on
|
|
937
1014
|
// every surface where the event does not fire, and a wall gets switched
|
|
938
1015
|
// off, which costs the whole guard.
|
|
939
|
-
writeState(
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1016
|
+
writeState(
|
|
1017
|
+
binding,
|
|
1018
|
+
{
|
|
1019
|
+
boundRoot: observed,
|
|
1020
|
+
claimedRoot: null,
|
|
1021
|
+
updatedAt: new Date().toISOString(),
|
|
1022
|
+
},
|
|
1023
|
+
payload
|
|
1024
|
+
);
|
|
944
1025
|
return 0;
|
|
945
1026
|
}
|
|
946
1027
|
if (state.claimedRoot && state.claimedRoot !== observed) {
|
|
947
1028
|
return unconfirmedSwitch(state.claimedRoot, observed);
|
|
948
1029
|
}
|
|
949
1030
|
if (state.claimedRoot === observed) {
|
|
950
|
-
writeState(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1031
|
+
writeState(
|
|
1032
|
+
binding,
|
|
1033
|
+
{
|
|
1034
|
+
boundRoot: observed,
|
|
1035
|
+
claimedRoot: null,
|
|
1036
|
+
updatedAt: new Date().toISOString(),
|
|
1037
|
+
},
|
|
1038
|
+
payload
|
|
1039
|
+
);
|
|
955
1040
|
return 0;
|
|
956
1041
|
}
|
|
957
1042
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|