@codyswann/lisa 4.66.2 → 4.66.3
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 +128 -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 +6 -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 +128 -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 +128 -50
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +128 -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 +128 -50
- package/typescript/copy-contents/.husky/commit-msg +31 -11
|
@@ -511,6 +511,20 @@ 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).
|
|
521
|
+
* @param {string} value - One component of the binding key
|
|
522
|
+
* @returns {string} A component containing only `[A-Za-z0-9._]` and `%XX`
|
|
523
|
+
*/
|
|
524
|
+
function keyPart(value) {
|
|
525
|
+
return encodeURIComponent(value).replaceAll("-", "%2D");
|
|
526
|
+
}
|
|
527
|
+
|
|
514
528
|
/**
|
|
515
529
|
* The identity a binding belongs to.
|
|
516
530
|
*
|
|
@@ -524,22 +538,58 @@ function stateFile(bindingKey) {
|
|
|
524
538
|
* session-scoped key it always had, which is also the fallback for runtimes
|
|
525
539
|
* that never send the field.
|
|
526
540
|
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
541
|
+
* Both shapes escape every component with {@link keyPart}, so the session key
|
|
542
|
+
* `x` and the agent key `a--b` live in disjoint namespaces: an escaped
|
|
543
|
+
* component never contains `-`, a main-session key is one component, an
|
|
544
|
+
* agent key is two joined by the only `--` the string can hold. Session
|
|
545
|
+
* "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
|
|
546
|
+
* collide across the two shapes (CodySwannGT/lisa#4294).
|
|
530
547
|
*/
|
|
531
548
|
function bindingKey(payload) {
|
|
532
549
|
const agent = payload?.agent_id;
|
|
533
|
-
if (typeof agent !== "string" || !agent) return payload.session_id;
|
|
534
|
-
return `${payload.session_id}--${agent}
|
|
550
|
+
if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
|
|
551
|
+
return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
|
|
535
552
|
}
|
|
536
553
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
554
|
+
/**
|
|
555
|
+
* The keys earlier versions wrote for this payload, newest first.
|
|
556
|
+
*
|
|
557
|
+
* The v1 key was the raw `session_id` (single shared file per session). v2
|
|
558
|
+
* joined raw session and agent on `--`. Both are readable on upgrade so a
|
|
559
|
+
* mid-session deploy does not silently re-baseline an established binding —
|
|
560
|
+
* the ambiguity in a legacy `a--b` key is resolved toward the interpretation
|
|
561
|
+
* the lookup is making, and the next write lands on the current key while the
|
|
562
|
+
* legacy file is left to age out.
|
|
563
|
+
* @param {object} payload - The hook payload
|
|
564
|
+
* @returns {string[]} Zero or more legacy keys this payload may have written
|
|
565
|
+
*/
|
|
566
|
+
function legacyKeys(payload) {
|
|
567
|
+
const agent = payload?.agent_id;
|
|
568
|
+
const session = payload.session_id;
|
|
569
|
+
if (typeof agent === "string" && agent) {
|
|
570
|
+
// v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
|
|
571
|
+
// agent falling back to the shared session file is the #4277 bug again,
|
|
572
|
+
// so the list stops there.
|
|
573
|
+
const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
|
|
574
|
+
return [`${san(session)}--${san(agent)}`];
|
|
542
575
|
}
|
|
576
|
+
// v1 and v2 both keyed the main agent on the raw session id — but only when
|
|
577
|
+
// that id contains no `--`. One that does spells exactly the composite
|
|
578
|
+
// shape agent bindings now write, so reading it could hand this session a
|
|
579
|
+
// stranger's binding. Losing the v1 binding for such an id re-baselines
|
|
580
|
+
// once; the alternative is reading a file whose owner cannot be proven.
|
|
581
|
+
return session.includes("--") ? [] : [session];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function readState(bindingKeyValue, payload) {
|
|
585
|
+
for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
|
|
586
|
+
try {
|
|
587
|
+
return JSON.parse(readFileSync(stateFile(key), "utf8"));
|
|
588
|
+
} catch {
|
|
589
|
+
// Not written under this key — try the next older one.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
543
593
|
}
|
|
544
594
|
|
|
545
595
|
/**
|
|
@@ -552,17 +602,23 @@ function readState(sessionId) {
|
|
|
552
602
|
* {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
|
|
553
603
|
* would turn its "reported once" into "reported again after the next
|
|
554
604
|
* acknowledgement", which is the noise the notice is designed not to be.
|
|
555
|
-
* @param
|
|
605
|
+
* @param bindingKeyValue - The key whose state file this is
|
|
556
606
|
* @param state - Fields to write over the recorded state
|
|
607
|
+
* @param payload - The hook payload, for legacy-key reads while a binding is
|
|
608
|
+
* migrating to the current key format
|
|
557
609
|
* @returns Whether the write succeeded
|
|
558
610
|
*/
|
|
559
|
-
function writeState(
|
|
560
|
-
const file = stateFile(
|
|
611
|
+
function writeState(bindingKeyValue, state, payload) {
|
|
612
|
+
const file = stateFile(bindingKeyValue);
|
|
561
613
|
try {
|
|
562
614
|
mkdirSync(dirname(file), { recursive: true });
|
|
563
615
|
writeFileSync(
|
|
564
616
|
file,
|
|
565
|
-
`${JSON.stringify(
|
|
617
|
+
`${JSON.stringify(
|
|
618
|
+
{ ...readState(bindingKeyValue, payload), ...state },
|
|
619
|
+
null,
|
|
620
|
+
2
|
|
621
|
+
)}\n`
|
|
566
622
|
);
|
|
567
623
|
return true;
|
|
568
624
|
} catch (error) {
|
|
@@ -637,19 +693,24 @@ function claimedRoot(toolInput, cwd) {
|
|
|
637
693
|
*
|
|
638
694
|
* Returns nothing on purpose, like its sibling below: SessionStart has no call
|
|
639
695
|
* to refuse, so every path through it allows.
|
|
640
|
-
* @param
|
|
696
|
+
* @param bindingKeyValue - The key this baseline belongs to
|
|
641
697
|
* @param observed - The worktree root the session started in
|
|
698
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
642
699
|
*/
|
|
643
|
-
function recordBaseline(
|
|
700
|
+
function recordBaseline(bindingKeyValue, observed, payload) {
|
|
644
701
|
// Never overwrite. A resumed session, a reconnect, or anything else that
|
|
645
702
|
// fires the event twice must not be able to launder a displaced binding into
|
|
646
703
|
// a fresh baseline — which is the whole failure this exists to close.
|
|
647
|
-
if (readState(
|
|
648
|
-
writeState(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
704
|
+
if (readState(bindingKeyValue, payload)?.boundRoot) return;
|
|
705
|
+
writeState(
|
|
706
|
+
bindingKeyValue,
|
|
707
|
+
{
|
|
708
|
+
boundRoot: observed,
|
|
709
|
+
claimedRoot: null,
|
|
710
|
+
updatedAt: new Date().toISOString(),
|
|
711
|
+
},
|
|
712
|
+
payload
|
|
713
|
+
);
|
|
653
714
|
}
|
|
654
715
|
|
|
655
716
|
/**
|
|
@@ -663,12 +724,16 @@ function recordBaseline(sessionId, observed) {
|
|
|
663
724
|
function recordClaim(payload, observed) {
|
|
664
725
|
const claimed = claimedRoot(payload.tool_input, payload.cwd);
|
|
665
726
|
if (!claimed || claimed === observed) return;
|
|
666
|
-
const previous = readState(bindingKey(payload));
|
|
667
|
-
writeState(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
727
|
+
const previous = readState(bindingKey(payload), payload);
|
|
728
|
+
writeState(
|
|
729
|
+
bindingKey(payload),
|
|
730
|
+
{
|
|
731
|
+
boundRoot: previous?.boundRoot ?? observed,
|
|
732
|
+
claimedRoot: claimed,
|
|
733
|
+
updatedAt: new Date().toISOString(),
|
|
734
|
+
},
|
|
735
|
+
payload
|
|
736
|
+
);
|
|
672
737
|
}
|
|
673
738
|
|
|
674
739
|
function refuse(lines) {
|
|
@@ -704,11 +769,15 @@ function handleAcceptance(payload, observed) {
|
|
|
704
769
|
acceptanceLine(observed),
|
|
705
770
|
]);
|
|
706
771
|
}
|
|
707
|
-
writeState(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
772
|
+
writeState(
|
|
773
|
+
bindingKey(payload),
|
|
774
|
+
{
|
|
775
|
+
boundRoot: observed,
|
|
776
|
+
claimedRoot: null,
|
|
777
|
+
updatedAt: new Date().toISOString(),
|
|
778
|
+
},
|
|
779
|
+
payload
|
|
780
|
+
);
|
|
712
781
|
say(`bound to ${observed}`);
|
|
713
782
|
return 0;
|
|
714
783
|
}
|
|
@@ -880,15 +949,16 @@ function driftNotice(version) {
|
|
|
880
949
|
* Never refuses. The dependency is a reporting gap, not a live displacement,
|
|
881
950
|
* and blocking a session over a version number would be a wall where an
|
|
882
951
|
* observation belongs.
|
|
883
|
-
* @param
|
|
952
|
+
* @param bindingKeyValue - The key being started
|
|
884
953
|
* @param env - Environment to read the runtime version from
|
|
954
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
885
955
|
* @returns Nothing; every path through this allows
|
|
886
956
|
*/
|
|
887
|
-
function noticeRuntimeDrift(
|
|
957
|
+
function noticeRuntimeDrift(bindingKeyValue, env, payload) {
|
|
888
958
|
const version = runtimeVersion(env);
|
|
889
959
|
if (version === null || version === VERIFIED_RUNTIME_VERSION) return;
|
|
890
|
-
if (readState(
|
|
891
|
-
writeState(
|
|
960
|
+
if (readState(bindingKeyValue, payload)?.runtimeNoticed === version) return;
|
|
961
|
+
writeState(bindingKeyValue, { runtimeNoticed: version }, payload);
|
|
892
962
|
process.stdout.write(
|
|
893
963
|
`${JSON.stringify({
|
|
894
964
|
hookSpecificOutput: {
|
|
@@ -911,11 +981,11 @@ function evaluate(payload) {
|
|
|
911
981
|
if (!observed) return 0;
|
|
912
982
|
|
|
913
983
|
if (payload.hook_event_name === "SessionStart") {
|
|
914
|
-
recordBaseline(binding, observed);
|
|
984
|
+
recordBaseline(binding, observed, payload);
|
|
915
985
|
// After the baseline, never before: `recordBaseline` writes a fresh state
|
|
916
986
|
// for a session it has not seen, and the notice's record has to go on top
|
|
917
987
|
// of that write rather than under it.
|
|
918
|
-
noticeRuntimeDrift(binding, process.env);
|
|
988
|
+
noticeRuntimeDrift(binding, process.env, payload);
|
|
919
989
|
return 0;
|
|
920
990
|
}
|
|
921
991
|
if (payload.tool_name === "EnterWorktree") {
|
|
@@ -927,7 +997,7 @@ function evaluate(payload) {
|
|
|
927
997
|
const accepted = handleAcceptance(payload, observed);
|
|
928
998
|
if (accepted !== null) return accepted;
|
|
929
999
|
|
|
930
|
-
const state = readState(binding);
|
|
1000
|
+
const state = readState(binding, payload);
|
|
931
1001
|
if (!state?.boundRoot) {
|
|
932
1002
|
// No baseline means SessionStart did not run for this session — an older
|
|
933
1003
|
// install, a runtime that fires no such event, a harness that skips it.
|
|
@@ -936,22 +1006,30 @@ function evaluate(payload) {
|
|
|
936
1006
|
// refusing. Treating it as a refusal would turn a floor into a wall on
|
|
937
1007
|
// every surface where the event does not fire, and a wall gets switched
|
|
938
1008
|
// off, which costs the whole guard.
|
|
939
|
-
writeState(
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1009
|
+
writeState(
|
|
1010
|
+
binding,
|
|
1011
|
+
{
|
|
1012
|
+
boundRoot: observed,
|
|
1013
|
+
claimedRoot: null,
|
|
1014
|
+
updatedAt: new Date().toISOString(),
|
|
1015
|
+
},
|
|
1016
|
+
payload
|
|
1017
|
+
);
|
|
944
1018
|
return 0;
|
|
945
1019
|
}
|
|
946
1020
|
if (state.claimedRoot && state.claimedRoot !== observed) {
|
|
947
1021
|
return unconfirmedSwitch(state.claimedRoot, observed);
|
|
948
1022
|
}
|
|
949
1023
|
if (state.claimedRoot === observed) {
|
|
950
|
-
writeState(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1024
|
+
writeState(
|
|
1025
|
+
binding,
|
|
1026
|
+
{
|
|
1027
|
+
boundRoot: observed,
|
|
1028
|
+
claimedRoot: null,
|
|
1029
|
+
updatedAt: new Date().toISOString(),
|
|
1030
|
+
},
|
|
1031
|
+
payload
|
|
1032
|
+
);
|
|
955
1033
|
return 0;
|
|
956
1034
|
}
|
|
957
1035
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|
|
@@ -511,6 +511,20 @@ 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).
|
|
521
|
+
* @param {string} value - One component of the binding key
|
|
522
|
+
* @returns {string} A component containing only `[A-Za-z0-9._]` and `%XX`
|
|
523
|
+
*/
|
|
524
|
+
function keyPart(value) {
|
|
525
|
+
return encodeURIComponent(value).replaceAll("-", "%2D");
|
|
526
|
+
}
|
|
527
|
+
|
|
514
528
|
/**
|
|
515
529
|
* The identity a binding belongs to.
|
|
516
530
|
*
|
|
@@ -524,22 +538,58 @@ function stateFile(bindingKey) {
|
|
|
524
538
|
* session-scoped key it always had, which is also the fallback for runtimes
|
|
525
539
|
* that never send the field.
|
|
526
540
|
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
541
|
+
* Both shapes escape every component with {@link keyPart}, so the session key
|
|
542
|
+
* `x` and the agent key `a--b` live in disjoint namespaces: an escaped
|
|
543
|
+
* component never contains `-`, a main-session key is one component, an
|
|
544
|
+
* agent key is two joined by the only `--` the string can hold. Session
|
|
545
|
+
* "s%2Da--c" cannot collide with session "s-a" + agent "c", and neither can
|
|
546
|
+
* collide across the two shapes (CodySwannGT/lisa#4294).
|
|
530
547
|
*/
|
|
531
548
|
function bindingKey(payload) {
|
|
532
549
|
const agent = payload?.agent_id;
|
|
533
|
-
if (typeof agent !== "string" || !agent) return payload.session_id;
|
|
534
|
-
return `${payload.session_id}--${agent}
|
|
550
|
+
if (typeof agent !== "string" || !agent) return keyPart(payload.session_id);
|
|
551
|
+
return `${keyPart(payload.session_id)}--${keyPart(agent)}`;
|
|
535
552
|
}
|
|
536
553
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
554
|
+
/**
|
|
555
|
+
* The keys earlier versions wrote for this payload, newest first.
|
|
556
|
+
*
|
|
557
|
+
* The v1 key was the raw `session_id` (single shared file per session). v2
|
|
558
|
+
* joined raw session and agent on `--`. Both are readable on upgrade so a
|
|
559
|
+
* mid-session deploy does not silently re-baseline an established binding —
|
|
560
|
+
* the ambiguity in a legacy `a--b` key is resolved toward the interpretation
|
|
561
|
+
* the lookup is making, and the next write lands on the current key while the
|
|
562
|
+
* legacy file is left to age out.
|
|
563
|
+
* @param {object} payload - The hook payload
|
|
564
|
+
* @returns {string[]} Zero or more legacy keys this payload may have written
|
|
565
|
+
*/
|
|
566
|
+
function legacyKeys(payload) {
|
|
567
|
+
const agent = payload?.agent_id;
|
|
568
|
+
const session = payload.session_id;
|
|
569
|
+
if (typeof agent === "string" && agent) {
|
|
570
|
+
// v2 wrote the sanitised composite. v1 never wrote a per-agent file — an
|
|
571
|
+
// agent falling back to the shared session file is the #4277 bug again,
|
|
572
|
+
// so the list stops there.
|
|
573
|
+
const san = value => value.replaceAll(/[^A-Za-z0-9._-]/g, "_");
|
|
574
|
+
return [`${san(session)}--${san(agent)}`];
|
|
542
575
|
}
|
|
576
|
+
// v1 and v2 both keyed the main agent on the raw session id — but only when
|
|
577
|
+
// that id contains no `--`. One that does spells exactly the composite
|
|
578
|
+
// shape agent bindings now write, so reading it could hand this session a
|
|
579
|
+
// stranger's binding. Losing the v1 binding for such an id re-baselines
|
|
580
|
+
// once; the alternative is reading a file whose owner cannot be proven.
|
|
581
|
+
return session.includes("--") ? [] : [session];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function readState(bindingKeyValue, payload) {
|
|
585
|
+
for (const key of [bindingKeyValue, ...legacyKeys(payload)]) {
|
|
586
|
+
try {
|
|
587
|
+
return JSON.parse(readFileSync(stateFile(key), "utf8"));
|
|
588
|
+
} catch {
|
|
589
|
+
// Not written under this key — try the next older one.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
543
593
|
}
|
|
544
594
|
|
|
545
595
|
/**
|
|
@@ -552,17 +602,23 @@ function readState(sessionId) {
|
|
|
552
602
|
* {@link noticeRuntimeDrift} and read by nobody else, and a clobbering write
|
|
553
603
|
* would turn its "reported once" into "reported again after the next
|
|
554
604
|
* acknowledgement", which is the noise the notice is designed not to be.
|
|
555
|
-
* @param
|
|
605
|
+
* @param bindingKeyValue - The key whose state file this is
|
|
556
606
|
* @param state - Fields to write over the recorded state
|
|
607
|
+
* @param payload - The hook payload, for legacy-key reads while a binding is
|
|
608
|
+
* migrating to the current key format
|
|
557
609
|
* @returns Whether the write succeeded
|
|
558
610
|
*/
|
|
559
|
-
function writeState(
|
|
560
|
-
const file = stateFile(
|
|
611
|
+
function writeState(bindingKeyValue, state, payload) {
|
|
612
|
+
const file = stateFile(bindingKeyValue);
|
|
561
613
|
try {
|
|
562
614
|
mkdirSync(dirname(file), { recursive: true });
|
|
563
615
|
writeFileSync(
|
|
564
616
|
file,
|
|
565
|
-
`${JSON.stringify(
|
|
617
|
+
`${JSON.stringify(
|
|
618
|
+
{ ...readState(bindingKeyValue, payload), ...state },
|
|
619
|
+
null,
|
|
620
|
+
2
|
|
621
|
+
)}\n`
|
|
566
622
|
);
|
|
567
623
|
return true;
|
|
568
624
|
} catch (error) {
|
|
@@ -637,19 +693,24 @@ function claimedRoot(toolInput, cwd) {
|
|
|
637
693
|
*
|
|
638
694
|
* Returns nothing on purpose, like its sibling below: SessionStart has no call
|
|
639
695
|
* to refuse, so every path through it allows.
|
|
640
|
-
* @param
|
|
696
|
+
* @param bindingKeyValue - The key this baseline belongs to
|
|
641
697
|
* @param observed - The worktree root the session started in
|
|
698
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
642
699
|
*/
|
|
643
|
-
function recordBaseline(
|
|
700
|
+
function recordBaseline(bindingKeyValue, observed, payload) {
|
|
644
701
|
// Never overwrite. A resumed session, a reconnect, or anything else that
|
|
645
702
|
// fires the event twice must not be able to launder a displaced binding into
|
|
646
703
|
// a fresh baseline — which is the whole failure this exists to close.
|
|
647
|
-
if (readState(
|
|
648
|
-
writeState(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
704
|
+
if (readState(bindingKeyValue, payload)?.boundRoot) return;
|
|
705
|
+
writeState(
|
|
706
|
+
bindingKeyValue,
|
|
707
|
+
{
|
|
708
|
+
boundRoot: observed,
|
|
709
|
+
claimedRoot: null,
|
|
710
|
+
updatedAt: new Date().toISOString(),
|
|
711
|
+
},
|
|
712
|
+
payload
|
|
713
|
+
);
|
|
653
714
|
}
|
|
654
715
|
|
|
655
716
|
/**
|
|
@@ -663,12 +724,16 @@ function recordBaseline(sessionId, observed) {
|
|
|
663
724
|
function recordClaim(payload, observed) {
|
|
664
725
|
const claimed = claimedRoot(payload.tool_input, payload.cwd);
|
|
665
726
|
if (!claimed || claimed === observed) return;
|
|
666
|
-
const previous = readState(bindingKey(payload));
|
|
667
|
-
writeState(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
727
|
+
const previous = readState(bindingKey(payload), payload);
|
|
728
|
+
writeState(
|
|
729
|
+
bindingKey(payload),
|
|
730
|
+
{
|
|
731
|
+
boundRoot: previous?.boundRoot ?? observed,
|
|
732
|
+
claimedRoot: claimed,
|
|
733
|
+
updatedAt: new Date().toISOString(),
|
|
734
|
+
},
|
|
735
|
+
payload
|
|
736
|
+
);
|
|
672
737
|
}
|
|
673
738
|
|
|
674
739
|
function refuse(lines) {
|
|
@@ -704,11 +769,15 @@ function handleAcceptance(payload, observed) {
|
|
|
704
769
|
acceptanceLine(observed),
|
|
705
770
|
]);
|
|
706
771
|
}
|
|
707
|
-
writeState(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
772
|
+
writeState(
|
|
773
|
+
bindingKey(payload),
|
|
774
|
+
{
|
|
775
|
+
boundRoot: observed,
|
|
776
|
+
claimedRoot: null,
|
|
777
|
+
updatedAt: new Date().toISOString(),
|
|
778
|
+
},
|
|
779
|
+
payload
|
|
780
|
+
);
|
|
712
781
|
say(`bound to ${observed}`);
|
|
713
782
|
return 0;
|
|
714
783
|
}
|
|
@@ -880,15 +949,16 @@ function driftNotice(version) {
|
|
|
880
949
|
* Never refuses. The dependency is a reporting gap, not a live displacement,
|
|
881
950
|
* and blocking a session over a version number would be a wall where an
|
|
882
951
|
* observation belongs.
|
|
883
|
-
* @param
|
|
952
|
+
* @param bindingKeyValue - The key being started
|
|
884
953
|
* @param env - Environment to read the runtime version from
|
|
954
|
+
* @param payload - The hook payload, for legacy-key reads
|
|
885
955
|
* @returns Nothing; every path through this allows
|
|
886
956
|
*/
|
|
887
|
-
function noticeRuntimeDrift(
|
|
957
|
+
function noticeRuntimeDrift(bindingKeyValue, env, payload) {
|
|
888
958
|
const version = runtimeVersion(env);
|
|
889
959
|
if (version === null || version === VERIFIED_RUNTIME_VERSION) return;
|
|
890
|
-
if (readState(
|
|
891
|
-
writeState(
|
|
960
|
+
if (readState(bindingKeyValue, payload)?.runtimeNoticed === version) return;
|
|
961
|
+
writeState(bindingKeyValue, { runtimeNoticed: version }, payload);
|
|
892
962
|
process.stdout.write(
|
|
893
963
|
`${JSON.stringify({
|
|
894
964
|
hookSpecificOutput: {
|
|
@@ -911,11 +981,11 @@ function evaluate(payload) {
|
|
|
911
981
|
if (!observed) return 0;
|
|
912
982
|
|
|
913
983
|
if (payload.hook_event_name === "SessionStart") {
|
|
914
|
-
recordBaseline(binding, observed);
|
|
984
|
+
recordBaseline(binding, observed, payload);
|
|
915
985
|
// After the baseline, never before: `recordBaseline` writes a fresh state
|
|
916
986
|
// for a session it has not seen, and the notice's record has to go on top
|
|
917
987
|
// of that write rather than under it.
|
|
918
|
-
noticeRuntimeDrift(binding, process.env);
|
|
988
|
+
noticeRuntimeDrift(binding, process.env, payload);
|
|
919
989
|
return 0;
|
|
920
990
|
}
|
|
921
991
|
if (payload.tool_name === "EnterWorktree") {
|
|
@@ -927,7 +997,7 @@ function evaluate(payload) {
|
|
|
927
997
|
const accepted = handleAcceptance(payload, observed);
|
|
928
998
|
if (accepted !== null) return accepted;
|
|
929
999
|
|
|
930
|
-
const state = readState(binding);
|
|
1000
|
+
const state = readState(binding, payload);
|
|
931
1001
|
if (!state?.boundRoot) {
|
|
932
1002
|
// No baseline means SessionStart did not run for this session — an older
|
|
933
1003
|
// install, a runtime that fires no such event, a harness that skips it.
|
|
@@ -936,22 +1006,30 @@ function evaluate(payload) {
|
|
|
936
1006
|
// refusing. Treating it as a refusal would turn a floor into a wall on
|
|
937
1007
|
// every surface where the event does not fire, and a wall gets switched
|
|
938
1008
|
// off, which costs the whole guard.
|
|
939
|
-
writeState(
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1009
|
+
writeState(
|
|
1010
|
+
binding,
|
|
1011
|
+
{
|
|
1012
|
+
boundRoot: observed,
|
|
1013
|
+
claimedRoot: null,
|
|
1014
|
+
updatedAt: new Date().toISOString(),
|
|
1015
|
+
},
|
|
1016
|
+
payload
|
|
1017
|
+
);
|
|
944
1018
|
return 0;
|
|
945
1019
|
}
|
|
946
1020
|
if (state.claimedRoot && state.claimedRoot !== observed) {
|
|
947
1021
|
return unconfirmedSwitch(state.claimedRoot, observed);
|
|
948
1022
|
}
|
|
949
1023
|
if (state.claimedRoot === observed) {
|
|
950
|
-
writeState(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1024
|
+
writeState(
|
|
1025
|
+
binding,
|
|
1026
|
+
{
|
|
1027
|
+
boundRoot: observed,
|
|
1028
|
+
claimedRoot: null,
|
|
1029
|
+
updatedAt: new Date().toISOString(),
|
|
1030
|
+
},
|
|
1031
|
+
payload
|
|
1032
|
+
);
|
|
955
1033
|
return 0;
|
|
956
1034
|
}
|
|
957
1035
|
if (state.boundRoot !== observed) return displaced(state.boundRoot, observed);
|