@codyswann/lisa 4.84.4 → 4.84.6

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.
Files changed (103) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +20 -6
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/package.json +4 -4
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa/hooks/block-host-name-leak.mjs +118 -40
  12. package/plugins/lisa/hooks/block-host-name-leak.sh +5 -0
  13. package/plugins/lisa/hooks/host-name-command-context.mjs +397 -0
  14. package/plugins/lisa-agy/hooks/block-host-name-leak.mjs +118 -40
  15. package/plugins/lisa-agy/hooks/block-host-name-leak.sh +5 -0
  16. package/plugins/lisa-agy/hooks/host-name-command-context.mjs +397 -0
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/hooks/block-host-name-leak.mjs +118 -40
  27. package/plugins/lisa-copilot/hooks/block-host-name-leak.sh +5 -0
  28. package/plugins/lisa-copilot/hooks/host-name-command-context.mjs +397 -0
  29. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cursor/hooks/block-host-name-leak.mjs +118 -40
  31. package/plugins/lisa-cursor/hooks/block-host-name-leak.sh +5 -0
  32. package/plugins/lisa-cursor/hooks/host-name-command-context.mjs +397 -0
  33. package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
  35. package/plugins/lisa-devin/hooks/block-host-name-leak.mjs +118 -40
  36. package/plugins/lisa-devin/hooks/block-host-name-leak.sh +5 -0
  37. package/plugins/lisa-devin/hooks/host-name-command-context.mjs +397 -0
  38. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-agy/plugin.json +1 -1
  41. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  69. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails-agy/plugin.json +1 -1
  76. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  83. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  90. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
  94. package/plugins/src/base/hooks/block-host-name-leak.mjs +118 -40
  95. package/plugins/src/base/hooks/block-host-name-leak.sh +5 -0
  96. package/plugins/src/base/hooks/host-name-command-context.mjs +397 -0
  97. package/scripts/generate-agy-plugin-artifacts.mjs +7 -3
  98. package/scripts/lib/host-name-review-cases.mjs +354 -0
  99. package/scripts/lib/host-name-target-cases.mjs +298 -0
  100. package/scripts/two-channel-couplings.json +29 -6
  101. package/scripts/verify-host-name-target-scope.mjs +347 -0
  102. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +139 -48
  103. package/typescript/create-only/.github/workflows/review-evidence.yml +2 -2
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "ratified": {
3
3
  "quality.yml::scripts/check-learnings-budget.ts": "Lisa-source-only branch, and the else arm is the delivery. The step tests for BOTH scripts/check-learnings-budget.ts and src/core/learnings-budget-check.ts, which co-exist only in this repository; every consumer takes the else arm and runs the prover as `bunx @codyswann/lisa@<their own declared version>`, which is the PACKAGE channel and not a caller-tree read at all. A trailing `grep -qE \"learnings budget (passed|saturated)|no learnings file\"` then refuses a run that printed no verdict, so the arm cannot pass vacuously. Ratified because the caller-tree read is unreachable outside this repository, not because the gap is tolerated.",
4
- "quality.yml::scripts/generate-nightly-e2e-guard-certificate.mjs": "Lisa-source-only branch. Both reusable jobs require `github.repository == 'CodySwannGT/lisa'` before the step can start, and the run body repeats that exact repository guard before checking this artifact path. Ordinary and private host callers therefore skip the step without reading or fetching from their own tree. The scoped branch fetches only the immutable refs needed to behavior-test Lisa's own retained certificate inputs. Ratified because the caller-tree read is unreachable outside this repository, not because an undelivered host dependency is tolerated."
4
+ "quality.yml::scripts/generate-nightly-e2e-guard-certificate.mjs": "Lisa-source-only branch. Both reusable jobs require `github.repository == 'CodySwannGT/lisa'` before the step can start, and the run body repeats that exact repository guard before checking this artifact path. Ordinary and private host callers therefore skip the step without reading or fetching from their own tree. The scoped branch fetches only the immutable refs needed to behavior-test Lisa's own retained certificate inputs. Ratified because the caller-tree read is unreachable outside this repository, not because an undelivered host dependency is tolerated.",
5
+ "quality.yml::scripts/verify-host-name-target-scope.mjs": "Lisa-source-only regression proof. The step requires `github.repository == 'CodySwannGT/lisa' && steps.gate_run.outcome == 'success'` before executing this driver. Every host caller skips the step without reading this path. Ratified because the caller-tree read is unreachable outside this repository; no host dependency is introduced."
5
6
  },
6
7
  "staleness": {
7
8
  "npm-updater.yml::scripts/lisa-npm-updater.mjs": {
@@ -99,30 +100,34 @@
99
100
  },
100
101
  "quality.yml::scripts/lisa-work-item.mjs": {
101
102
  "decision": "handshake"
103
+ },
104
+ "quality.yml::scripts/verify-host-name-target-scope.mjs": {
105
+ "decision": "not-needed",
106
+ "reason": "Unreachable outside this repository under the exact step-level repository guard ratified above. The driver and its cases run from the same Lisa checkout as the hooks under test. No consuming caller reads its installed or caller-tree copy through this workflow step, so no independently versioned consumer copy participates in this coupling."
102
107
  }
103
108
  },
104
109
  "inspected": {
105
110
  "workflows": 26,
106
- "steps": 785,
107
- "couplings": 36,
111
+ "steps": 787,
112
+ "couplings": 37,
108
113
  "inventory": 186
109
114
  },
110
115
  "counts": {
111
116
  "package-backed": 11,
112
117
  "apply-lagged": 23,
113
118
  "never-delivered": 0,
114
- "undelivered": 2
119
+ "undelivered": 3
115
120
  },
116
121
  "detectionCounts": {
117
122
  "version-handshake": 4,
118
123
  "capability-probe": 0,
119
124
  "existence-only": 5,
120
125
  "no-probe": 14,
121
- "unchecked": 2
126
+ "unchecked": 3
122
127
  },
123
128
  "decisionCounts": {
124
129
  "handshake": 4,
125
- "not-needed": 11,
130
+ "not-needed": 12,
126
131
  "warranted": 10
127
132
  },
128
133
  "couplings": [
@@ -675,6 +680,24 @@
675
680
  "all/copy-overwrite"
676
681
  ],
677
682
  "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/lisa-work-item.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (all/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read sits behind an existence test, which is ALL this scan examined of the step's control flow: it did not read the test's other branch, any fallback, any explicit `exit`, or any verification of the step's output. Whether an absent path skips quietly, falls back, or fails loudly is therefore NOT MEASURED — read the step before concluding it proves nothing. The shape worth ruling out is the silent one, where a skip posts no context and an absent required context is not a red one. The step's text does carry `else-branch`, `exit-zero`, `exit-nonzero`, `error-annotation`, `notice-annotation` — located in the step, deliberately NOT related to this read, and enough to make \"it skips silently\" a claim to check rather than assume. STALENESS (a separate dimension from the delivery verdict above): `version-handshake`. The delivered artifact answers a contract version and the workflow asks for one, so a copy too old to speak the same contract is NAMED rather than silently run. Tokens found: `artifact-declares-version`, `artifact-answers-version`, `step-requests-version`, `workflow-requests-version`. `workflow-requests-version` is workflow-scoped — it does not say THIS step asks."
683
+ },
684
+ {
685
+ "key": "quality.yml::scripts/verify-host-name-target-scope.mjs",
686
+ "workflow": "quality.yml",
687
+ "path": "scripts/verify-host-name-target-scope.mjs",
688
+ "channel": "undelivered",
689
+ "verdict": "undelivered",
690
+ "remedy": "author-the-artifact",
691
+ "guarded": false,
692
+ "handling": [],
693
+ "detection": "unchecked",
694
+ "stalenessSignals": [
695
+ "workflow-requests-version"
696
+ ],
697
+ "decision": "not-needed",
698
+ "reason": "Unreachable outside this repository under the exact step-level repository guard ratified above. The driver and its cases run from the same Lisa checkout as the hooks under test. No consuming caller reads its installed or caller-tree copy through this workflow step, so no independently versioned consumer copy participates in this coupling.",
699
+ "lanes": [],
700
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/verify-host-name-target-scope.mjs` from the CALLER's tree, and Lisa ships no such path in any delivery lane — no apply and no bump ever produces it. Either the consumer authors it, or whatever the step does where the path is missing runs instead — and that branch is the part this scan does not read. No existence test guards the read, which is ALL this scan examined of the step's control flow: it did not read anything that could absorb the failure. An absent path most likely fails the job loudly — the better of the two failures, because it is visible — but that is inferred from the missing guard, NOT MEASURED. The step's text carries none of the tokens this scan can see (`else-branch`, `exit-zero`, `exit-nonzero`, `error-annotation`, `notice-annotation`, `failure-suppression`), which is a statement about those names and not about the step's behaviour. STALENESS (a separate dimension from the delivery verdict above): `unchecked`. Lisa delivers nothing at this path, so there is no artifact to ask whether it could answer a version. The staleness half is UNKNOWN here — not current, and not probe-less. Tokens found: `workflow-requests-version`. `workflow-requests-version` is workflow-scoped — it does not say THIS step asks."
678
701
  }
679
702
  ]
680
703
  }
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+ /** Actual-wrapper target-scope proof; commands under inspection are never executed. */
3
+ import assert from "node:assert/strict";
4
+ import { createHash } from "node:crypto";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ rmSync,
12
+ symlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { boundedExecFileSync, boundedSpawnSync } from "./lib/bounded-spawn.mjs";
18
+ import { hostNameTargetCases } from "./lib/host-name-target-cases.mjs";
19
+ import { hostNameReviewCases } from "./lib/host-name-review-cases.mjs";
20
+
21
+ const root = process.cwd();
22
+ const DETECTOR_RELATIVE = "dist/core/downstream-references.js";
23
+ const fixtureName = "notarealhostproject";
24
+ const lisa = "CodySwannGT/lisa";
25
+ const host = `acme/${fixtureName}`;
26
+ const scratch = mkdtempSync(path.join(tmpdir(), "lisa-host-scope-"));
27
+ const historical = process.argv
28
+ .find(arg => arg.startsWith("--historical-ref="))
29
+ ?.split("=")[1];
30
+ const hookFlag = process.argv
31
+ .find(arg => arg.startsWith("--hooks="))
32
+ ?.slice("--hooks=".length);
33
+ const packageFlag = process.argv
34
+ .find(arg => arg.startsWith("--package-root="))
35
+ ?.slice("--package-root=".length);
36
+ const payloadRoot = path.resolve(packageFlag ?? root);
37
+ const baseEnv = Object.fromEntries(
38
+ Object.entries(process.env).filter(
39
+ ([key]) => !/^(?:GIT_|GH_|GITHUB_)/u.test(key)
40
+ )
41
+ );
42
+ const fixtureEnv = {
43
+ ...baseEnv,
44
+ GH_CONFIG_DIR: path.join(scratch, "gh-config"),
45
+ GH_TOKEN: "local-fixture-no-network-token",
46
+ GH_PROMPT_DISABLED: "1",
47
+ CDPATH: "",
48
+ GIT_CONFIG_GLOBAL: "/dev/null",
49
+ GIT_CONFIG_SYSTEM: "/dev/null",
50
+ LISA_DOWNSTREAM_NAMES: fixtureName,
51
+ };
52
+ mkdirSync(fixtureEnv.GH_CONFIG_DIR);
53
+
54
+ /** @param {string} file @returns {string} Exact byte digest. */
55
+ const digest = file =>
56
+ createHash("sha256").update(readFileSync(file)).digest("hex");
57
+ /** @param {string} cwd @param {string[]} args @returns {string} Local Git output. */
58
+ const git = (cwd, args) =>
59
+ boundedExecFileSync("git", ["-C", cwd, ...args], {
60
+ env: fixtureEnv,
61
+ encoding: "utf8",
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ }).trim();
64
+ /** @param {string} name @param {string|undefined} origin @returns {string} Isolated checkout. */
65
+ function checkout(name, origin) {
66
+ const cwd = path.join(scratch, name);
67
+ mkdirSync(path.join(cwd, "node_modules/@codyswann/lisa"), {
68
+ recursive: true,
69
+ });
70
+ symlinkSync(
71
+ path.join(payloadRoot, "dist"),
72
+ path.join(cwd, "node_modules/@codyswann/lisa/dist")
73
+ );
74
+ assert.equal(
75
+ realpathSync(path.join(cwd, "node_modules/@codyswann/lisa/dist")),
76
+ realpathSync(path.join(payloadRoot, "dist")),
77
+ "fixture must load the selected payload detector, never a checkout substitute"
78
+ );
79
+ git(cwd, ["init", "-q"]);
80
+ if (origin !== undefined)
81
+ git(cwd, ["remote", "add", "origin", `git@github.com:${origin}.git`]);
82
+ return cwd;
83
+ }
84
+
85
+ const records = [];
86
+ try {
87
+ assert.ok(
88
+ existsSync(path.join(payloadRoot, DETECTOR_RELATIVE)),
89
+ "actual compiled detector must exist; build/access is not replaced by a fixture"
90
+ );
91
+ assert.ok(
92
+ !(packageFlag && historical),
93
+ "Published payload replay cannot mix historical hooks with package detector bytes"
94
+ );
95
+ let hooks = path.resolve(
96
+ payloadRoot,
97
+ hookFlag ?? (packageFlag ? "plugins/lisa/hooks" : "plugins/src/base/hooks")
98
+ );
99
+ if (historical !== undefined) {
100
+ hooks = path.join(scratch, "historical-hooks");
101
+ mkdirSync(hooks);
102
+ for (const file of [
103
+ "block-host-name-leak.mjs",
104
+ "block-host-name-leak.sh",
105
+ ]) {
106
+ writeFileSync(
107
+ path.join(hooks, file),
108
+ boundedExecFileSync(
109
+ "git",
110
+ ["-C", root, "show", `${historical}:plugins/src/base/hooks/${file}`],
111
+ { env: fixtureEnv, stdio: ["ignore", "pipe", "pipe"] }
112
+ )
113
+ );
114
+ }
115
+ }
116
+ const wrapper = path.join(hooks, "block-host-name-leak.sh");
117
+ const classifier = path.join(hooks, "block-host-name-leak.mjs");
118
+ const identity = {
119
+ proverCheckoutHead: git(root, ["rev-parse", "HEAD"]),
120
+ proverSha256: digest(new URL(import.meta.url)),
121
+ payloadRoot,
122
+ payloadPackageVersion: JSON.parse(
123
+ readFileSync(path.join(payloadRoot, "package.json"), "utf8")
124
+ ).version,
125
+ payloadPackageJsonSha256: digest(path.join(payloadRoot, "package.json")),
126
+ detectorRealpath: realpathSync(path.join(payloadRoot, DETECTOR_RELATIVE)),
127
+ historicalRef: historical ?? null,
128
+ wrapperSha256: digest(wrapper),
129
+ classifierSha256: digest(classifier),
130
+ contextHelperSha256: existsSync(
131
+ path.join(hooks, "host-name-command-context.mjs")
132
+ )
133
+ ? digest(path.join(hooks, "host-name-command-context.mjs"))
134
+ : null,
135
+ detectorSha256: digest(path.join(payloadRoot, DETECTOR_RELATIVE)),
136
+ observation:
137
+ "Actual Bash wrapper and MJS CLI with real compiled detector; synthetic name via documented environment extension. Inspected tracker writes are never executed; no GitHub API request is made.",
138
+ };
139
+ console.log(JSON.stringify({ identity }));
140
+ const hostCwd = checkout("host", host);
141
+ const lisaCwd = checkout("protected", lisa);
142
+ const unknownCwd = checkout("unknown", undefined);
143
+ const body = path.join(scratch, "body.md");
144
+ writeFileSync(body, `Work-Item: ${host}#1\n`);
145
+ const relative = path.join(lisaCwd, "subdirectory");
146
+ mkdirSync(relative);
147
+ writeFileSync(path.join(lisaCwd, "body.md"), "No identity here.\n");
148
+ writeFileSync(
149
+ path.join(relative, "body.md"),
150
+ `Evidence from ${fixtureName}.\n`
151
+ );
152
+ const selectedLisa = checkout("selected-protected", host);
153
+ git(selectedLisa, [
154
+ "remote",
155
+ "add",
156
+ "upstream",
157
+ `git@github.com:${lisa}.git`,
158
+ ]);
159
+ git(selectedLisa, ["config", "remote.upstream.gh-resolved", "base"]);
160
+ const selectedHost = checkout("selected-host", lisa);
161
+ git(selectedHost, [
162
+ "remote",
163
+ "add",
164
+ "upstream",
165
+ `git@github.com:${host}.git`,
166
+ ]);
167
+ git(selectedHost, ["config", "remote.upstream.gh-resolved", "base"]);
168
+ for (const [cwd, expected] of [
169
+ [selectedLisa, lisa],
170
+ [selectedHost, host],
171
+ ]) {
172
+ const selected = boundedExecFileSync(
173
+ "gh",
174
+ ["repo", "set-default", "--view"],
175
+ {
176
+ cwd,
177
+ env: fixtureEnv,
178
+ encoding: "utf8",
179
+ stdio: ["ignore", "pipe", "pipe"],
180
+ }
181
+ ).trim();
182
+ assert.equal(
183
+ selected.toLowerCase(),
184
+ expected.toLowerCase(),
185
+ "real gh local default-repository read must confirm the fixture"
186
+ );
187
+ console.log(
188
+ JSON.stringify({ localDefaultControl: path.basename(cwd), selected })
189
+ );
190
+ }
191
+ const quotedHost = checkout("host; quoted-directory", host);
192
+ const ambiguous = checkout("ambiguous", host);
193
+ git(ambiguous, ["remote", "add", "upstream", `git@github.com:${lisa}.git`]);
194
+ const cdStart = checkout("cd-start", host);
195
+ const cdHost = checkout("cd-start/target", host);
196
+ const cdProtected = checkout("cd-search/target", lisa);
197
+ const cdSearch = path.dirname(cdProtected);
198
+ writeFileSync(path.join(cdHost, "body.md"), "No identity here.\n");
199
+ writeFileSync(path.join(cdProtected, "body.md"), fixtureName);
200
+ const actualCd = boundedExecFileSync(
201
+ "/bin/bash",
202
+ ["-c", 'cd target && printf "%s" "$PWD"'],
203
+ { cwd: cdStart, env: { ...fixtureEnv, CDPATH: cdSearch }, encoding: "utf8" }
204
+ );
205
+ assert.ok(
206
+ actualCd.endsWith(cdProtected),
207
+ "real Bash must confirm CDPATH fixture routing"
208
+ );
209
+ console.log(
210
+ JSON.stringify({ localCdpathControl: { actualCd, expected: cdProtected } })
211
+ );
212
+ const actualArgv = boundedExecFileSync(
213
+ "/bin/bash",
214
+ [
215
+ "-c",
216
+ 'gh() { printf "%s\\n" "$@"; }; gh issue comment 1 --repo "$HOST_REPOSITORY" $ROUTING --body "$KNOWN_NAME"',
217
+ ],
218
+ {
219
+ cwd: hostCwd,
220
+ env: {
221
+ ...fixtureEnv,
222
+ HOST_REPOSITORY: host,
223
+ ROUTING: `--repo ${lisa}`,
224
+ KNOWN_NAME: fixtureName,
225
+ },
226
+ encoding: "utf8",
227
+ }
228
+ )
229
+ .trim()
230
+ .split("\n");
231
+ assert.deepEqual(actualArgv, [
232
+ "issue",
233
+ "comment",
234
+ "1",
235
+ "--repo",
236
+ host,
237
+ "--repo",
238
+ lisa,
239
+ "--body",
240
+ fixtureName,
241
+ ]);
242
+ console.log(
243
+ JSON.stringify({
244
+ localArgvControl: { actualArgv, inspectedWriteExecuted: false },
245
+ })
246
+ );
247
+ const continuation = "\\\n";
248
+ for (const [name, command, expected] of [
249
+ [
250
+ "continuation removes no real argument",
251
+ `gh issue ${continuation} create --body ${continuation} "$KNOWN_NAME"`,
252
+ ["issue", "create", "--body", fixtureName],
253
+ ],
254
+ [
255
+ "quoted empty word survives continuation",
256
+ `gh issue create --body ""${continuation} "$KNOWN_NAME"`,
257
+ ["issue", "create", "--body", "", fixtureName],
258
+ ],
259
+ ]) {
260
+ const argv = boundedExecFileSync(
261
+ "/bin/bash",
262
+ ["-c", `gh() { printf "%s\\n" "$@"; }; ${command}`],
263
+ {
264
+ cwd: hostCwd,
265
+ env: { ...fixtureEnv, KNOWN_NAME: fixtureName },
266
+ encoding: "utf8",
267
+ }
268
+ )
269
+ .trim()
270
+ .split("\n");
271
+ assert.deepEqual(argv, expected, name);
272
+ console.log(
273
+ JSON.stringify({
274
+ localContinuationArgvControl: {
275
+ name,
276
+ argv,
277
+ inspectedWriteExecuted: false,
278
+ },
279
+ })
280
+ );
281
+ }
282
+ const context = {
283
+ hostCwd,
284
+ lisaCwd,
285
+ unknownCwd,
286
+ body,
287
+ selectedLisa,
288
+ selectedHost,
289
+ relative,
290
+ quotedHost,
291
+ ambiguous,
292
+ fixtureName,
293
+ lisa,
294
+ host,
295
+ cdStart,
296
+ cdSearch,
297
+ cdHost,
298
+ };
299
+ const cases = [
300
+ ...hostNameTargetCases(context),
301
+ ...hostNameReviewCases(context),
302
+ ];
303
+ for (const [
304
+ name,
305
+ cwd,
306
+ command,
307
+ expected,
308
+ overrides = {},
309
+ refusal = "BLOCKED: this tracker write names a host project.",
310
+ ] of cases) {
311
+ const run = boundedSpawnSync("/bin/bash", [wrapper], {
312
+ cwd,
313
+ input: JSON.stringify({ tool_name: "Bash", tool_input: { command } }),
314
+ env: {
315
+ ...fixtureEnv,
316
+ ...overrides,
317
+ CLAUDE_PROJECT_DIR: overrides.CLAUDE_PROJECT_DIR ?? cwd,
318
+ },
319
+ encoding: "utf8",
320
+ stdio: ["pipe", "pipe", "pipe"],
321
+ });
322
+ const output = `${run.stdout ?? ""}${run.stderr ?? ""}`;
323
+ assert.ok(
324
+ !output.includes("NOT active"),
325
+ `${name}: a missing detector/interpreter is not a passing control`
326
+ );
327
+ assert.ok(
328
+ run.status === 0 || run.status === 2,
329
+ `${name}: expected an actual hook verdict, got ${run.status}`
330
+ );
331
+ const passed =
332
+ run.status === expected && (expected !== 2 || output.includes(refusal));
333
+ records.push({ name, expected, actual: run.status, passed, output });
334
+ console.log(JSON.stringify(records.at(-1)));
335
+ }
336
+ const failed = records.filter(record => !record.passed);
337
+ console.log(
338
+ JSON.stringify({
339
+ total: records.length,
340
+ passed: records.length - failed.length,
341
+ failed: failed.length,
342
+ })
343
+ );
344
+ if (failed.length > 0) process.exitCode = 1;
345
+ } finally {
346
+ rmSync(scratch, { recursive: true, force: true });
347
+ }