@coworker-jp/aidr 0.1.318 → 0.1.323

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coworker-jp/aidr",
3
- "version": "0.1.318",
3
+ "version": "0.1.323",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,10 @@ const SCRIPT_FILES = [
21
21
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
22
22
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
23
23
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
24
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
24
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
25
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
26
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
27
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
25
28
 
26
29
  export const meta = {
27
30
  name: "claude",
@@ -35,7 +35,10 @@ const SCRIPT_FILES = [
35
35
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
36
36
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
37
37
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
38
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
38
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
39
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
40
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
41
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
39
42
  const ROOT_FILES = ["AGENTS.md"];
40
43
 
41
44
  export const meta = {
@@ -49,8 +52,10 @@ const CODEX_AGENTS_MD = `# AIDR Scanner
49
52
 
50
53
  AIDR security scanner is active for this Codex session. Set the access key
51
54
  via the \`AI_SCANNER_ACCESS_KEY\` environment variable. Hooks are registered
52
- in \`.codex/hooks.json\` and require \`[features] codex_hooks = true\` in
53
- \`.codex/config.toml\` (this setup writes both for you).
55
+ in \`.codex/hooks.json\` and require \`[features] hooks = true\` in
56
+ \`.codex/config.toml\` (this setup writes both for you). The older
57
+ \`[features] codex_hooks\` spelling is deprecated by Codex — if your config
58
+ still has it, setup renames it to \`hooks\` in place.
54
59
 
55
60
  Codex currently emits Bash tool events only. Read / Edit / WebFetch / WebSearch
56
61
  are not hookable — for those, use Claude Code.
@@ -180,7 +185,7 @@ async function mergeCodexConfigToml(target, { dryRun, stderr = process.stderr })
180
185
  if (stat) {
181
186
  stderr.write(
182
187
  `WARNING: ${target} already exists. (dry-run: would back up)\n` +
183
- `Merging [features] codex_hooks; your other settings are preserved.\n`
188
+ `Merging [features] hooks (the deprecated codex_hooks key is migrated if present); your other settings are preserved.\n`
184
189
  );
185
190
  }
186
191
  process.stdout.write(
@@ -196,7 +201,7 @@ async function mergeCodexConfigToml(target, { dryRun, stderr = process.stderr })
196
201
  await fsp.copyFile(target, backupPath);
197
202
  stderr.write(
198
203
  `WARNING: ${target} already exists. Backed up to ${backupPath}.\n` +
199
- `Merging [features] codex_hooks; your other settings are preserved.\n`
204
+ `Merging [features] hooks (the deprecated codex_hooks key is migrated if present); your other settings are preserved.\n`
200
205
  );
201
206
  }
202
207
 
@@ -36,7 +36,10 @@ const SCRIPT_FILES = [
36
36
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
37
37
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
38
38
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
39
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
39
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
40
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
41
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
42
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
40
43
 
41
44
  export const meta = {
42
45
  name: "cursor",
@@ -13,7 +13,10 @@ const HOOK_FILES = ["aidr-pre.sh"];
13
13
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
14
14
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
15
15
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
16
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
16
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
17
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
18
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
19
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
17
20
 
18
21
  export const meta = {
19
22
  name: "gemini",
@@ -26,11 +29,13 @@ export async function install(home, accessKey, opts = {}) {
26
29
  const { dryRun = false, scope = "project" } = opts;
27
30
  const base = meta.configDir(home);
28
31
  const hooks = path.join(base, "hooks");
29
- const binary = path.join(home, meta.agentDir, "bin", "ai-scanner");
32
+ // #2366: hook が呼ぶのは `ai-harness` (8 コンテキスト)。`ai-scanner` は
33
+ // 定期インベントリ送信で、`scan read-input` 等のサブコマンドを持たない。
34
+ const binary = path.join(home, meta.agentDir, "bin", "ai-harness");
30
35
  const settingsPath = path.join(base, "settings.json");
31
36
  const preHookPath = path.join(hooks, "aidr-pre.sh");
32
37
  const [settingsRes, hookRes] = await Promise.all([
33
- writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, binary), mergeGeminiSettings, { dryRun, backup: scope !== "project" }),
38
+ writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, hooks), mergeGeminiSettings, { dryRun, backup: scope !== "project" }),
34
39
  writeFileSafe(preHookPath, T.getGeminiPreHookSh(binary), { dryRun, force: true, mode: 0o755 }),
35
40
  ]);
36
41
  return {
@@ -26,7 +26,10 @@ const SCRIPT_FILES = [
26
26
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
27
27
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
28
28
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
29
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
29
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
30
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
31
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
32
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
30
33
 
31
34
  export const meta = {
32
35
  name: "kiro",
@@ -10,7 +10,10 @@ import { pathExists, rmdirIfEmpty } from "../fs-utils.mjs";
10
10
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
11
11
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
12
12
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
13
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
13
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
14
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
15
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
16
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
14
17
 
15
18
  export const meta = {
16
19
  name: "standalone",
@@ -33,7 +33,10 @@ const SCRIPT_FILES = [
33
33
  // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
34
34
  // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
35
35
  // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
36
- const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
36
+ // #2366: 配布物は 2 本。`ai-harness` が hook の 8 コンテキストを持ち、
37
+ // `ai-scanner` が定期インベントリ送信を持つ。**両方を消す対象に入れる** ——
38
+ // 片方だけ残すと bin ディレクトリが空にならず `rmdirIfEmpty` が効かない。
39
+ const BIN_FILES = ["ai-scanner", "ai-harness", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
37
40
 
38
41
  export const meta = {
39
42
  name: "windsurf",
@@ -282,8 +282,18 @@ export async function fetchAsset(binUrl, destPath, { accessKey, checksumOpts } =
282
282
  return { path: destPath, sha256, verified };
283
283
  }
284
284
 
285
+ //: The product served at the BARE platform key. Every other product is
286
+ //: addressed as `<product>/<platform>`; see `fetchBinary` for why this one is
287
+ //: not, and why that asymmetry is deliberate rather than an oversight.
288
+ export const DEFAULT_PRODUCT = "ai-scanner";
289
+
290
+ //: The hook binary (issue #2348 split, wired up in #2366). This is what every
291
+ //: hook adapter invokes — the eight tool-call scan contexts live here, NOT in
292
+ //: `ai-scanner`, which is the periodic endpoint-inventory collector.
293
+ export const HARNESS_PRODUCT = "ai-harness";
294
+
285
295
  /**
286
- * Download ai-scanner for the current platform into `destPath`.
296
+ * Download one scanner-family binary for the current platform into `destPath`.
287
297
  *
288
298
  * TWO KEYS, ONE FALLBACK (issue #1687)
289
299
  * ------------------------------------
@@ -312,19 +322,28 @@ export async function fetchAsset(binUrl, destPath, { accessKey, checksumOpts } =
312
322
  * `servedUnpinned: true` means the caller must NOT claim the pinned version was
313
323
  * installed — what landed is whatever the endpoint currently publishes.
314
324
  */
315
- export async function fetchBinary(destPath, { env = "prod", accessKey, version, baseUrl } = {}) {
325
+ export async function fetchBinary(destPath, { env = "prod", accessKey, version, baseUrl, product = DEFAULT_PRODUCT } = {}) {
316
326
  const platform = detectPlatform();
317
327
  const base = baseUrl || downloadBase(env);
318
- const unpinnedUrl = `${base}/${platform}`;
328
+ // ONE ROUTE, TWO PRODUCTS (issue #2366)
329
+ // ------------------------------------
330
+ // `ai-scanner` keeps the BARE platform key it has always had — that key is a
331
+ // wire contract with every endpoint already in the field, and the binary's own
332
+ // self-updater composes it. Everything else is addressed under its own name.
333
+ // Composing `ai-scanner/<platform>` "for symmetry" would ask a Lambda that
334
+ // predates the split for a path it rejects, and the endpoints that would hit
335
+ // it are exactly the ones with no other way in.
336
+ const seg = product === DEFAULT_PRODUCT ? "" : `${product}/`;
337
+ const unpinnedUrl = `${base}/${seg}${platform}`;
319
338
 
320
339
  if (!version) {
321
340
  const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
322
- return { ...result, platform, requestedVersion: null, servedUnpinned: false };
341
+ return { ...result, platform, product, requestedVersion: null, servedUnpinned: false };
323
342
  }
324
343
 
325
344
  try {
326
- const result = await fetchAsset(`${base}/v${version}/${platform}`, destPath, { accessKey });
327
- return { ...result, platform, requestedVersion: version, servedUnpinned: false };
345
+ const result = await fetchAsset(`${base}/v${version}/${seg}${platform}`, destPath, { accessKey });
346
+ return { ...result, platform, product, requestedVersion: version, servedUnpinned: false };
328
347
  } catch (e) {
329
348
  // A pinned release whose CHECKSUM is a definite 404 is incomplete in exactly
330
349
  // the same way as one whose binary is (#1773): half a release is not a
@@ -333,7 +352,7 @@ export async function fetchBinary(destPath, { env = "prod", accessKey, version,
333
352
  // unverified install, because `fetchAsset` refuses that on both keys.
334
353
  if (e?.status !== 404) throw e;
335
354
  const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
336
- return { ...result, platform, requestedVersion: version, servedUnpinned: true };
355
+ return { ...result, platform, product, requestedVersion: version, servedUnpinned: true };
337
356
  }
338
357
  }
339
358
 
package/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { Command } from "commander";
10
10
  const execFileP = promisify(execFile);
11
11
  import { AGENT_ORDER, AGENT_REGISTRY, loadAgent } from "./agents/index.mjs";
12
12
  import { verifyKey, maskKey } from "./verify.mjs";
13
- import { fetchBinary, fetchOpengrep, assertVerified } from "./binary-fetcher.mjs";
13
+ import { fetchBinary, fetchOpengrep, assertVerified, HARNESS_PRODUCT } from "./binary-fetcher.mjs";
14
14
  import { detectInstalled } from "./detect.mjs";
15
15
  import { evaluateInstallGate, formatLocalPcGuidance } from "./env-gate.mjs";
16
16
  import { resolveCallerHome, isRunningUnderSudo, chownToCaller } from "./sudo-user.mjs";
@@ -201,15 +201,33 @@ async function cmdInstall(opts) {
201
201
  version: PINNED_VERSION,
202
202
  };
203
203
 
204
- // ai-scanner + bundled Opengrep (LGPL-2.1). Both land in the same bin
205
- // dir so ai-scanner's sibling-lookup finds opengrep without relying on
206
- // PATH. Users can point AI_SCANNER_OPENGREP elsewhere to substitute
207
- // their own LGPL-2.1 build.
204
+ // THREE files, one bin dir (issue #2366).
205
+ //
206
+ // ai-harness the hook binary — the eight tool-call scan contexts.
207
+ // Every adapter this installer writes invokes THIS one,
208
+ // so it is fetched first: it is what "this machine is
209
+ // being scanned" means.
210
+ // ai-scanner the periodic endpoint-inventory collector, and the
211
+ // self-update vehicle start_scanner.sh drives.
212
+ // opengrep bundled Opengrep (LGPL-2.1), a sibling so the scanner's
213
+ // sibling-lookup finds it without relying on PATH. Users
214
+ // can point AI_SCANNER_OPENGREP elsewhere to substitute
215
+ // their own LGPL-2.1 build.
216
+ const harnessRes = assertVerified(
217
+ await fetchBinary(path.join(primaryDir, "ai-harness"),
218
+ { ...fetchOpts, product: HARNESS_PRODUCT }),
219
+ "the ai-harness binary",
220
+ );
221
+ // Set by the HOOK binary, not the periodic one: this flag is what
222
+ // decides between "nothing on this machine scans" and "scanning is in
223
+ // place, one piece is missing". Tying it to ai-scanner would report the
224
+ // reassuring half of that pair while every tool call ran unscanned.
225
+ binaryOutcome.scannerInstalled = true;
226
+ console.log(`binary: ${harnessRes.path} (${harnessRes.platform}, sha256 verified)`);
208
227
  const scannerRes = assertVerified(
209
228
  await fetchBinary(path.join(primaryDir, "ai-scanner"), fetchOpts),
210
229
  "the ai-scanner binary",
211
230
  );
212
- binaryOutcome.scannerInstalled = true;
213
231
  // Stated unconditionally, because it is now a condition of getting here
214
232
  // rather than a caption on a file that may or may not have been checked
215
233
  // (#1773): `fetchAsset` throws unless the bytes matched the published
@@ -231,7 +249,7 @@ async function cmdInstall(opts) {
231
249
  console.log(`binary: ${opengrepRes.path} (opengrep ${opengrepRes.platform}, sha256 verified)`);
232
250
 
233
251
  const copyOps = restDirs.flatMap(restDir =>
234
- ["ai-scanner", "opengrep"].map(async (file) => {
252
+ ["ai-harness", "ai-scanner", "opengrep"].map(async (file) => {
235
253
  const src = path.join(primaryDir, file);
236
254
  const dest = path.join(restDir, file);
237
255
  await fs.mkdir(restDir, { recursive: true });
@@ -366,7 +384,10 @@ async function cmdInstall(opts) {
366
384
  const stranded = needBinary.filter((r) => !r.selfHeals).map((r) => r.displayName);
367
385
  const binPaths = (await Promise.all(needBinary.map(async (r) => {
368
386
  const mod = await loadAgent(r.name);
369
- return mod.meta.agentDir ? path.join(home, mod.meta.agentDir, "bin", "ai-scanner") : null;
387
+ // The hook binary: this list is read out to someone checking whether the
388
+ // machine is scanned, and `ai-scanner` sitting there would answer "yes"
389
+ // to a question about a file that runs no hooks (#2366).
390
+ return mod.meta.agentDir ? path.join(home, mod.meta.agentDir, "bin", "ai-harness") : null;
370
391
  }))).filter(Boolean);
371
392
  console.error(formatMissingBinaryStatus({
372
393
  reason, selfHealing, stranded, binPaths, installCommand: INSTALL_COMMAND_HINT,
@@ -599,13 +620,24 @@ async function cmdDoctor() {
599
620
  const mod = await loadAgent(name);
600
621
  const agentDir = mod.meta.agentDir;
601
622
  if (!agentDir) return null;
602
- const bin = path.join(home, agentDir, "bin", "ai-scanner");
603
- let present = false;
604
- try {
605
- await fs.access(bin, fs.constants.F_OK);
606
- present = true;
607
- } catch {}
608
- const out = [`binary: ${bin} ${present ? "[present]" : "[missing]"}`];
623
+ // TWO binaries since the split (issue #2366), and only ONE of them decides
624
+ // whether tool calls are scanned. `ai-harness` carries the eight hook
625
+ // contexts; `ai-scanner` is the periodic collector. Reporting only the
626
+ // latter would print "[present]" on a machine whose every hook fail-opens.
627
+ const bin = path.join(home, agentDir, "bin", "ai-harness");
628
+ const periodicBin = path.join(home, agentDir, "bin", "ai-scanner");
629
+ const exists = async (p) => {
630
+ try { await fs.access(p, fs.constants.F_OK); return true; } catch { return false; }
631
+ };
632
+ const present = await exists(bin);
633
+ const periodicPresent = await exists(periodicBin);
634
+ const out = [
635
+ `tool-call scanner: ${bin} ${present ? "[present]" : "[missing]"}`,
636
+ `periodic collector: ${periodicBin} ${periodicPresent ? "[present]" : "[missing]"}`,
637
+ ];
638
+ if (!present) {
639
+ out.push(` Tool calls on this machine are NOT being scanned. Re-run the installer (${INSTALL_COMMAND_HINT}), or contact your IT administrator.`);
640
+ }
609
641
  // What the hooks recorded while there was no binary (#1774). Without this,
610
642
  // "[missing]" was the only trace, and it said nothing about how long the
611
643
  // machine had been unscanned or how much had gone through unscanned —
@@ -41,7 +41,10 @@ export function formatMissingBinaryStatus({
41
41
  const lines = [
42
42
  "",
43
43
  RULE,
44
- "This machine is NOT being scanned yet — the ai-scanner binary is not in place.",
44
+ // Names `ai-harness`, because that is the binary that scans a tool call
45
+ // (issue #2366). Saying "ai-scanner" here would send the reader to check a
46
+ // file that can be perfectly present on a machine where nothing is scanned.
47
+ "This machine is NOT being scanned yet — the ai-harness binary is not in place.",
45
48
  "",
46
49
  ` what stopped it: ${reason}`,
47
50
  "",
package/src/merge.mjs CHANGED
@@ -24,8 +24,17 @@
24
24
  export const CLAUDE_OUR_COMMAND_RE =
25
25
  /^(?:"\$(?:CLAUDE_PROJECT_DIR|HOME)"|\$(?:CLAUDE_PROJECT_DIR|HOME))\/\.claude\/coworker-ai\/(?:start_scanner|stop_scanner|scan_[a-z_]+|log_web_urls)\.sh(?:\s|$|"|')/;
26
26
 
27
+ // Matches BOTH the adapter we install today AND the broken pre-#2354 command.
28
+ // **The legacy alternative is load-bearing, not tidiness**: an endpoint that
29
+ // already ran the old installer has the broken command sitting in its
30
+ // `settings.json`. `mergeGeminiSettings` / `unmergeGeminiSettings` use this
31
+ // predicate to recognise our own entries — if it stopped matching the old form,
32
+ // an upgrade would **leave the broken hook in place and add the new one beside
33
+ // it**, so every tool call would still hit a command that exits 2, and
34
+ // uninstall would leave it behind forever. Drop the legacy branch only once no
35
+ // endpoint in the field can still carry it.
27
36
  export const GEMINI_OUR_COMMAND_RE =
28
- /ai-scanner["']?\s+scan\s+hook-gemini\b/;
37
+ /(?:\/\.gemini\/hooks\/aidr-(?:pre|post)\.sh|ai-scanner["']?\s+scan\s+hook-gemini\b)/;
29
38
 
30
39
  // Matches our Codex hook wrappers whether installed at the repo root
31
40
  // (.codex/coworker-ai/pre_shell.sh) or absolute user-scope path
@@ -229,7 +238,27 @@ export function buildWindsurfHooksJson(scope = "project", absHome = "") {
229
238
  };
230
239
  }
231
240
 
232
- export function buildGeminiSettings(accessKey, binary) {
241
+ // **2026-09-08 (issue #2354): this pointed at a subcommand that does not exist.**
242
+ // It emitted `<binary> scan hook-gemini --stage pre|post`; the scanner has no
243
+ // `hook-gemini` subcommand, so every invocation died with
244
+ // `error: unrecognized subcommand` and **exit code 2**. Install reported
245
+ // success, `settings.json` filled with hooks, and **not one tool call was ever
246
+ // scanned** — while the working adapter (`~/.gemini/hooks/aidr-pre.sh`) was
247
+ // written to disk on every install and referenced by nothing.
248
+ //
249
+ // Two things were wrong, not one. Exit 2 is also the *block* code, and this was
250
+ // the **only** agent calling the binary directly rather than through a shell
251
+ // wrapper — so it sat outside the fail-open invariant that every other agent
252
+ // gets from `run_scanner` (which maps a timeout's 124 to 0). Pointing at the
253
+ // adapter fixes both at once: the scan actually runs, **and** it runs inside
254
+ // the wrapper.
255
+ //
256
+ // `AfterTool` is gone rather than re-pointed: **there is no post adapter.**
257
+ // `agents/gemini.mjs` installs `aidr-pre.sh` only, and inventing post coverage
258
+ // here would be new agent coverage (#2350's scope), not a defect fix. Declaring
259
+ // only what exists is the point — the previous declaration is exactly how a
260
+ // hook that never ran looked installed.
261
+ export function buildGeminiSettings(accessKey, hooksDir) {
233
262
  // timeout is in milliseconds (Gemini convention). Default 60000 (1 min)
234
263
  // is too long for an inline hook — cap at 15000 so the editor kills a
235
264
  // hung scanner cleanly. The shell-side run_scanner cap fires first under
@@ -241,10 +270,7 @@ export function buildGeminiSettings(accessKey, binary) {
241
270
  },
242
271
  hooks: {
243
272
  BeforeTool: [
244
- { hooks: [{ type: "command", command: `"${binary}" scan hook-gemini --stage pre`, timeout: 15000 }] },
245
- ],
246
- AfterTool: [
247
- { hooks: [{ type: "command", command: `"${binary}" scan hook-gemini --stage post`, timeout: 15000 }] },
273
+ { hooks: [{ type: "command", command: `"${hooksDir}/aidr-pre.sh"`, timeout: 15000 }] },
248
274
  ],
249
275
  },
250
276
  };
package/src/templates.mjs CHANGED
@@ -11,11 +11,44 @@ export function getSettingsJson(accessKey, scope = "project") {
11
11
  }
12
12
 
13
13
  // start_scanner.sh — parameterized by the verify endpoint (prod / dev).
14
+ // ── このスクリプトが端末へ出す文言は ja / en を併記する (issue #2372 条件 3) ──
15
+ //
16
+ // **決定 (2026-09-09): (a) 併記する。** `docs/product/UI_COPY.md` の表の
17
+ // 「`aidr` CLI (端末のターミナルに出る文字列)」の行が既にこう決めており、
18
+ // **その行の理由がこの面にそのまま当たる**:
19
+ //
20
+ // * **読み手を実行時に特定できない。** 同じ端末で情シスも開発者も読む。
21
+ // * **出し分ける先が無い。** フックはロケールを持たない bash なので、
22
+ // 1 つの出力を全員が読む。**片方だけにすると、読めない側にとっては
23
+ // 何も書かれていないのと同じである。**
24
+ //
25
+ // この 2 つは「利用者が打ったコマンド」かどうかに依存しない。むしろこの面は
26
+ // CLI より弱い —— **利用者はこの出力を頼んでいない**ので、読めない言語で
27
+ // 出された行は、そのまま読み飛ばされる。
28
+ //
29
+ // **(b)「フックの stderr は en のみ」と明文化する案は採らなかった。** 採れば
30
+ // この面だけが表の 4 行のどれとも違う基準を持つことになり、**なぜここだけ
31
+ // 違うのかを説明できない** (英語だけだったのは判断の結果ではなく、
32
+ // 基準を持たないまま書かれ続けた結果である —— `UI_COPY.md` の
33
+ // `GAP-2355-CLI-COPY-JA-EN-UNEVEN` がその状態を名指ししている)。
34
+ //
35
+ // **★ 短くするために事実や次の行動を削らないこと** (`UI_COPY.md` 【非交渉】)。
36
+ // 1 文が長くなるなら**文を割る**。en 行はそのまま残してある —— 併記は
37
+ // 「英語を日本語で置き換える」ことではない。
38
+ //
39
+ // **ここで揃うのはこのスクリプトだけである** (`GAP-2372-AIDR-JA-EN-PARTIAL`)。
40
+ // `packages/aidr` の他の出力 (`red-team.mjs::redTeamInstallFailureText` など) は
41
+ // 英語のままで、揃えるのは別の作業である。**「aidr は併記済み」と読まないこと。**
14
42
  // `agentType` is currently unused inside the script (endpoint-info has moved
15
43
  // to schedule-only invocation via aidr --scheduled), but the parameter
16
44
  // is kept for signature stability with callers and the wider TS template.
17
45
  // eslint-disable-next-line no-unused-vars
18
46
  export function getStartScannerSh(downloadBase = "https://pytd2jd5p7.execute-api.ap-northeast-1.amazonaws.com/download", agentType = "claude-code") {
47
+ // `agentType` is the endpoint-info spelling (`claude-code`); `setup --agent`
48
+ // takes the installer/portal spelling (`claude`). They differ for exactly one
49
+ // agent, so the mapping is stated here rather than guessed at the call site —
50
+ // guessing would make the self-repair below configure the WRONG agent.
51
+ const setupAgentName = agentType === "claude-code" ? "claude" : agentType;
19
52
  // Which installer to quote back at the user, derived from the endpoint this
20
53
  // script was generated against. The DEV package must never tell anyone to
21
54
  // install PROD (same rule as cli.mjs's INSTALL_COMMAND_HINT) — and here the
@@ -32,6 +65,16 @@ export function getStartScannerSh(downloadBase = "https://pytd2jd5p7.execute-api
32
65
  # - Fail-open: never block Claude Code startup
33
66
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
34
67
  BIN_DIR="$(dirname "$BIN")"
68
+ # TWO binaries since the split (issue #2366). \`ai-harness\` runs the eight
69
+ # tool-call scan contexts — it is the one every hook adapter invokes, so it is
70
+ # the one whose absence means "this session is not being scanned". \`ai-scanner\`
71
+ # is the periodic inventory collector and the self-update vehicle. They are
72
+ # built from one crate and therefore must stay on ONE version; the skew check
73
+ # further down is what enforces that.
74
+ #
75
+ # Two env names, never one shared: pointing a single override at one product
76
+ # would silently leave the other pointing at a file that is not it.
77
+ HARNESS="\${AI_HARNESS_BIN:-$BIN_DIR/ai-harness}"
35
78
  # The self-update throttle belongs to the binary it throttles, so it is derived
36
79
  # from \$BIN — the one source this script already trusts for "where is my copy"
37
80
  # (\`.ai-scanner-unscanned\` is placed the same way). It used to be pinned to
@@ -132,7 +175,8 @@ sha256_of() {
132
175
  # \`$UNVERIFIED_REASON\` carries WHY, so the message can name it.
133
176
  download_binary() {
134
177
  local url="$1"
135
- local tmp=$(mktemp "\${BIN_DIR}/ai-scanner.XXXXXX")
178
+ local dest="$2"
179
+ local tmp=$(mktemp "\${BIN_DIR}/.download.XXXXXX")
136
180
  local auth_args=()
137
181
  if [ -n "\${AI_SCANNER_ACCESS_KEY:-}" ]; then
138
182
  auth_args=(-H "x-access-key: \${AI_SCANNER_ACCESS_KEY}")
@@ -166,10 +210,34 @@ download_binary() {
166
210
  # macOS: strip Gatekeeper quarantine so unsigned binary can execute
167
211
  # (no-op on Linux where xattr isn't typically present)
168
212
  xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true
169
- mv -f "$tmp" "$BIN"
213
+ mv -f "$tmp" "$dest"
170
214
  return 0
171
215
  }
172
216
 
217
+ # Fetch one product into place if it is not already there. The download route
218
+ # serves \`ai-scanner\` at the bare platform key (unchanged wire contract) and
219
+ # every other product under its own name.
220
+ #
221
+ # Says "installed" only when it actually installed something. Printing it on
222
+ # every session start for a file that was already there turns the line into
223
+ # noise, and the next reader stops distinguishing it from the one that matters.
224
+ # $1 = absolute destination, $2 = product name
225
+ ensure_binary() {
226
+ local dest="$1" product="$2" url
227
+ [ -x "$dest" ] && return 0
228
+ if [ "$product" = "ai-scanner" ]; then
229
+ url="\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}"
230
+ else
231
+ url="\${DOWNLOAD_BASE}/\${product}/\${PLATFORM_SLUG}"
232
+ fi
233
+ if download_binary "$url" "$dest"; then
234
+ echo "$product のバイナリを導入しました (\${PLATFORM_SLUG}、チェックサム検証済み)。" >&2
235
+ echo "$product binary installed (\${PLATFORM_SLUG}, checksum verified)" >&2
236
+ return 0
237
+ fi
238
+ return 1
239
+ }
240
+
173
241
  # What has already run unscanned on this machine, if anything (#1774). The scan
174
242
  # hooks append to this file every time they exit without a binary; this is the
175
243
  # first moment anyone can say it out loud, and it is deleted the moment a binary
@@ -187,6 +255,7 @@ report_unscanned_history() {
187
255
  # itself is the fallback — printing nothing here would drop the fact.
188
256
  local when
189
257
  when=$(date -d "@$first" 2>/dev/null || date -r "$first" 2>/dev/null || echo "epoch $first")
258
+ echo "ai-scanner: この端末では、すでに $count 件のツールコールが検査されないまま実行されています (最初は $when)。" >&2
190
259
  echo "ai-scanner: $count tool call(s) have already run unscanned on this machine (first at $when)." >&2
191
260
  }
192
261
 
@@ -195,8 +264,10 @@ report_unscanned_history() {
195
264
  # reads as cosmetic (docs/product/UI_COPY.md 【非交渉】) — and unlike a failed
196
265
  # download, re-running the installer cannot fix it, so the action is different.
197
266
  unsupported_platform() {
267
+ echo "ai-scanner: このセッションは検査されていません。このプラットフォーム ($1) 向けのスキャナーが存在しません。" >&2
198
268
  echo "ai-scanner: this session is NOT being scanned — there is no scanner build for this platform ($1)." >&2
199
269
  report_unscanned_history
270
+ echo "ai-scanner: ツールコールは動き続けますが、この端末では検査されないまま実行されます。このメッセージを添えて、社内の IT 管理者にご連絡ください。" >&2
200
271
  echo "ai-scanner: tool calls keep working, and they run unscanned on this machine. Contact your IT administrator with this message." >&2
201
272
  }
202
273
 
@@ -234,26 +305,46 @@ case "$OS" in
234
305
  ;;
235
306
  esac
236
307
 
237
- # Binary missing → fetch it
238
- if [ ! -x "$BIN" ]; then
239
- if download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}"; then
240
- echo "ai-scanner binary installed (\${PLATFORM_SLUG}, checksum verified)" >&2
241
- rm -f "$UNSCANNED_STAMP"
308
+ # The HOOK binary first: tool-call scanning depends on this one, so its absence
309
+ # is what the session-level message is about.
310
+ #
311
+ # docs/product/UI_COPY.md 【非交渉】: the fact, stated so it cannot be read as
312
+ # harmless, and then the next action. The old single line ("Warning: Failed to
313
+ # download …") said neither what it meant for the session nor what to do, and it
314
+ # was the ONLY trace anywhere (#1774).
315
+ SCANNING=0
316
+ if ensure_binary "$HARNESS" ai-harness; then
317
+ SCANNING=1
318
+ # A binary IS in place. Clear any record of having run unscanned, so the
319
+ # record only ever describes the machine's CURRENT state.
320
+ rm -f "$UNSCANNED_STAMP"
321
+ else
322
+ echo "ai-scanner: このセッションは検査されていません。ツールコール検査機 (ai-harness) がこの端末になく、今回も導入できませんでした: \${UNVERIFIED_REASON:-ダウンロードに失敗しました}。" >&2
323
+ echo "ai-scanner: this session is NOT being scanned — the tool-call scanner (ai-harness) is not installed on this machine and could not be installed now: \${UNVERIFIED_REASON:-the download failed}." >&2
324
+ report_unscanned_history
325
+ echo "ai-scanner: ツールコールは動き続けますが、導入されるまで検査されないまま実行されます。導入コマンド (${installCommand}) をもう一度実行してください。それでも直らなければ、このメッセージを添えて社内の IT 管理者にご連絡ください。" >&2
326
+ echo "ai-scanner: tool calls keep working, and they run unscanned until it is in place. Re-run the installer (${installCommand}), or contact your IT administrator with this message." >&2
327
+ fi
328
+
329
+ # The PERIODIC binary. Its absence does NOT stop tool-call scanning, so it gets
330
+ # its own, narrower sentence rather than borrowing the one above — saying "this
331
+ # session is NOT being scanned" when it IS would train the reader to ignore the
332
+ # line that means it.
333
+ if ! ensure_binary "$BIN" ai-scanner; then
334
+ # Only claims scanning is unaffected when it actually is — the harness may
335
+ # have failed too, and the line above is the one that says so.
336
+ if [ "$SCANNING" = 1 ]; then
337
+ echo "ai-scanner: ツールコールの検査は続いています。ただし定期の端末情報収集は導入できませんでした: \${UNVERIFIED_REASON:-ダウンロードに失敗しました}。この端末がポータルに現れなくなったら、導入コマンド (${installCommand}) をもう一度実行してください。" >&2
338
+ echo "ai-scanner: tool calls are still being scanned, but the periodic endpoint-inventory collector could not be installed: \${UNVERIFIED_REASON:-the download failed}. Re-run the installer (${installCommand}) if this endpoint stops appearing in the portal." >&2
242
339
  else
243
- # docs/product/UI_COPY.md 【非交渉】: the fact, stated so it cannot be
244
- # read as harmless, and then the next action. The old single line
245
- # ("Warning: Failed to download …") said neither what it meant for the
246
- # session nor what to do, and it was the ONLY trace anywhere (#1774).
247
- echo "ai-scanner: this session is NOT being scanned — the scanner is not installed on this machine and could not be installed now: \${UNVERIFIED_REASON:-the download failed}." >&2
248
- report_unscanned_history
249
- echo "ai-scanner: tool calls keep working, and they run unscanned until it is in place. Re-run the installer (${installCommand}), or contact your IT administrator with this message." >&2
340
+ echo "ai-scanner: 定期の端末情報収集も導入できませんでした: \${UNVERIFIED_REASON:-ダウンロードに失敗しました}。導入コマンド (${installCommand}) をもう一度実行してください。それでも直らなければ、このメッセージを添えて社内の IT 管理者にご連絡ください。" >&2
341
+ echo "ai-scanner: the periodic endpoint-inventory collector could not be installed either: \${UNVERIFIED_REASON:-the download failed}. Re-run the installer (${installCommand}), or contact your IT administrator with this message." >&2
250
342
  fi
251
- exit 0
252
343
  fi
253
344
 
254
- # A binary IS in place. Clear any record of having run unscanned, so the record
255
- # only ever describes the machine's CURRENT state.
256
- rm -f "$UNSCANNED_STAMP"
345
+ # Neither is in place: nothing below can run, and the message above already said
346
+ # so with its next action.
347
+ [ -x "$BIN" ] || exit 0
257
348
 
258
349
  # Binary exists — throttled self-update check.
259
350
  #
@@ -275,17 +366,130 @@ if should_check_update; then
275
366
  want_host=$(printf '%s' "$DOWNLOAD_BASE" | sed -E 's#^[a-z]+://([^/]+).*#\\1#')
276
367
  have_host=$(timeout "$VERSION_TIMEOUT" "$BIN" version 2>/dev/null | sed -n 's#^server: [a-z]*://\\([^/]*\\).*#\\1#p' | head -1)
277
368
  if [ -n "$have_host" ] && [ -n "$want_host" ] && [ "$have_host" != "$want_host" ]; then
369
+ echo "ai-scanner: 導入済みのバイナリは $have_host を見ていますが、この導入は $want_host から配信しています。アクセスキーは決して受け付けられず、検知ログは拒否されて捨てられます。一致するバイナリを取り直しています。" >&2
278
370
  echo "ai-scanner: the installed binary talks to $have_host, but this installation provisions from $want_host, so its access key can never be accepted (detection logs are refused and discarded). Re-downloading the matching binary." >&2
279
- download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}" || \\
371
+ download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}" "$BIN" || \\
372
+ echo "ai-scanner: 食い違ったバイナリを置き換えられませんでした: \${UNVERIFIED_REASON:-ダウンロードに失敗しました}。手元の検査は続きますが、検知ログは拒否され続けます。導入コマンドをもう一度実行してください。それでも直らなければ、このメッセージを添えて社内の IT 管理者にご連絡ください。" >&2
280
373
  echo "ai-scanner: could not replace the mismatched binary: \${UNVERIFIED_REASON:-the download failed}. Scanning continues locally, but detection logs will keep being refused. Re-run the installer, or contact your IT administrator with this message." >&2
281
374
  elif [ -z "$have_host" ]; then
375
+ echo "ai-scanner: 導入済みのバイナリがどのライセンスサーバーを見ているか読めなかったので、環境が一致するかは確認していません。検知ログが拒否されているようなら、導入コマンドをもう一度実行してください。" >&2
282
376
  echo "ai-scanner: could not read which license server the installed binary uses, so the environment match was NOT checked. If detection logs are being refused, re-run the installer." >&2
283
377
  fi
284
378
  timeout "$UPDATE_TIMEOUT" "$BIN" update 2>/dev/null || \\
379
+ echo "ai-scanner: スキャナーは今回、自分を更新できませんでした。すでに持っているルールで検査は続きます。これが繰り返されるなら、導入コマンドをもう一度実行してください。それでも直らなければ社内の IT 管理者にご連絡ください。" >&2
285
380
  echo "ai-scanner: the scanner could not update itself just now. It keeps scanning with the rules it already has; if this repeats, re-run the installer or contact your IT administrator." >&2
381
+
382
+ # Keep the hook binary on the SAME build as the periodic one (issue #2366).
383
+ # They come from one crate and share one version, so a skew means an update
384
+ # moved only ai-scanner and left the harness behind.
385
+ #
386
+ # \`"$HARNESS" update\` is deliberately NOT used (issue #2368): that
387
+ # self-update resolves the bare platform key, which serves ai-scanner —
388
+ # running it would overwrite the harness with the periodic binary and take
389
+ # every hook down with it. Refreshing through the same checksum-verified
390
+ # download path avoids that. **Do not "simplify" this into an update call.**
391
+ #
392
+ # \`GAP-UNOBSERVED-OVERWRITE\`: the overwrite itself has NOT been observed.
393
+ # Reproducing it needs \`/verify\` to advertise a newer version, and v0.1.319
394
+ # has zero keys in S3, so the precondition does not exist yet. What is
395
+ # written above is the mechanism read off \`cli/mod.rs::cmd_update\` — which is
396
+ # enough to route around, and NOT enough to call measured.
397
+ if [ -x "$HARNESS" ]; then
398
+ scanner_ver=$(timeout "$VERSION_TIMEOUT" "$BIN" version 2>/dev/null | awk 'NR==1{print $2}')
399
+ harness_ver=$(timeout "$VERSION_TIMEOUT" "$HARNESS" version 2>/dev/null | awk 'NR==1{print $2}')
400
+ if [ -n "$scanner_ver" ] && [ -n "$harness_ver" ] && [ "$scanner_ver" != "$harness_ver" ]; then
401
+ echo "ai-scanner: ツールコール検査機は $harness_ver、この端末は $scanner_ver です。同じビルドで揃うよう、いま取り直しています。" >&2
402
+ echo "ai-scanner: the tool-call scanner is on $harness_ver while this endpoint is on $scanner_ver. Refreshing it so both run one build." >&2
403
+ download_binary "\${DOWNLOAD_BASE}/ai-harness/\${PLATFORM_SLUG}" "$HARNESS" || \\
404
+ echo "ai-scanner: ツールコール検査機を取り直せませんでした: \${UNVERIFIED_REASON:-ダウンロードに失敗しました}。検査は $harness_ver のまま続きます。これが繰り返されるなら、導入コマンド (${installCommand}) をもう一度実行してください。" >&2
405
+ echo "ai-scanner: could not refresh the tool-call scanner: \${UNVERIFIED_REASON:-the download failed}. Scanning continues on $harness_ver; re-run the installer (${installCommand}) if this repeats." >&2
406
+ fi
407
+ fi
286
408
  date +%s > "$PULL_STAMP"
287
409
  fi
288
410
 
411
+ # ── 既に撒かれたラッパーを現行の形へ収束させる (issue #2372、案 1) ────────────
412
+ #
413
+ # **バイナリは自動で届くが、設定は届かない。** アダプタは導入時に 1 度だけ
414
+ # 書かれ、以後どの経路でも書き換わらない一方、自己更新はそれが名指すバイナリを
415
+ # 黙って差し替える。その非対称が #2372 の本体で、ここはその片側を閉じる。
416
+ #
417
+ # **なぜ SessionStart なのか (同意の範囲)**: これは**利用者が自分でエディタを
418
+ # 起動したときにだけ、その利用者として**走る。「そのエディタのフックを直す」と
419
+ # いう範囲から出ない。常駐が利用者の home を書く案 (#2372 案 2) は
420
+ # **同意の範囲の判断なので採らない** —— --agent が常駐を入れないという既存の
421
+ # 判断の、ちょうど裏返しである。
422
+ #
423
+ # **★ この経路が届かない端末が在る (正直に置く)。** 収束させるコードはこの
424
+ # スクリプトの中に在るので、**この版より前の start_scanner.sh を持つ端末には
425
+ # 届かない。** そこは agent_hooks の hook_wiring (endpoint-info) が
426
+ # **数として見えるようにする**部分が受け持つ —— だから #2372 の案 3 は
427
+ # 「あとで」ではなく必須である。GAP-2372-PRE-REPAIR-ADAPTERS-UNREACHED。
428
+ #
429
+ # 4 つの門。**どれも「直せない」を「直した」と読ませないため**にある:
430
+ # 1. hook のバイナリが在る (無ければ上の節が既に名指している)
431
+ # 2. その版が setup を持つ (旧い版は持たない。持たないことは失敗ではない)
432
+ # 3. アクセスキーが env に在る —— **無いまま書くと、鍵の無い settings.json を
433
+ # 置くことになり、いま効いている保護を壊す**
434
+ # 4. 兄弟のラッパーが実際に旧い形である (新しい形なら 1 バイトも書かない)
435
+ CONFIG_ROOT="$(cd "$(dirname "$0")/../.." 2>/dev/null && pwd)"
436
+ SELF_DIR="$(dirname "$0")"
437
+ if [ -x "$HARNESS" ] && [ -n "$CONFIG_ROOT" ] && [ -n "\${AI_SCANNER_ACCESS_KEY:-}" ]; then
438
+ # 旧い形の判定は endpoint-info の hook_wiring と**同じ集合・同じ述語**である
439
+ # (agent_hooks.rs の HookWiring::from_wrapper_dir / from_wrapper_text)。
440
+ # 2 つ目の判定を作らない ——作ると、ポータルが数えている集合と、
441
+ # ここが直す集合がずれる。
442
+ #
443
+ # **【2026-09-10 訂正 —— issue #2407】かつてこの行は「同じ述語である」と
444
+ # 宣言しながら、実体は別のファイルを読んでいた。** 数える側は印
445
+ # (\`hook_marker\`) 1 本を読んでおり、claude-code のそれは
446
+ # **\`start_scanner.sh\` そのもの** = 分割後のビルドでは必ず \`ai-harness\` を
447
+ # 名指すスクリプトである。一方ここが直すのは**その兄弟**なので、
448
+ # **「新しい start_scanner.sh + 旧い兄弟」= まさに収束に失敗した端末が
449
+ # \`ai-harness\` (移行済み) として数から消えていた** (DDB 読み戻しで実測)。
450
+ # **数える側をこの集合へ合わせて直した** —— 直す側に \`start_scanner.sh\` を
451
+ # 足すのは、数える側の食い違いを 1 ミリも動かさない。
452
+ #
453
+ # **下の 4 行 (glob / 除外の case / 2 本の grep) は実体から抜き出されて
454
+ # 機械照合される** —— \`agent_hooks::tests::w1_the_repair_loop_and_this_module_read_one_set\`
455
+ # が綴りだけでなく**向き** (\`|| continue\` か \`|| stale=1\` か) まで見る。
456
+ # 書き換えるときは、あちらの \`WIRING_EXCLUDED_WRAPPERS\` /
457
+ # \`SCANNER_TOKENS\` / \`HARNESS_TOKENS\` も同じ PR で直すこと。
458
+ # なぜ grep -E なのか (issue #2372。実測で 1 度落ちている)。
459
+ # ここは長く BRE の grep -q 'ai-scanner\\|AI_SCANNER_BIN' と書かれていたが、
460
+ # **この本文は JS のテンプレートリテラルなので、書いたバックスラッシュ 1 つは
461
+ # 出力に残らない** —— 端末に置かれていたのは 'ai-scanner|AI_SCANNER_BIN' で、
462
+ # BRE の | はただの文字なので **この行は決して一致せず、収束は 1 度も
463
+ # 走らなかった** (2026-09-09 に linux-testbed で実測: 旧い形のラッパーを
464
+ # 置いて start_scanner.sh を回しても 1 バイトも書き換わらない)。
465
+ # **症状は出ない** —— 収束しないことと「収束すべきものが無い」ことが
466
+ # 端末側から区別できない。-E にすれば JS の逃がしが要らず (パターンに
467
+ # バックスラッシュが 1 つも無い)、BSD grep (macOS) が BRE の \\| を
468
+ # 解さない問題も同時に消える。**BRE へ戻さないこと。**
469
+ stale=0
470
+ for w in "$SELF_DIR"/*.sh; do
471
+ [ -r "$w" ] || continue
472
+ case "$w" in *"/start_scanner.sh"|*"/stop_scanner.sh") continue ;; esac
473
+ grep -Eq 'ai-scanner|AI_SCANNER_BIN' "$w" || continue
474
+ grep -Eq 'ai-harness|AI_HARNESS_BIN' "$w" || stale=1
475
+ done
476
+ if [ "$stale" = 1 ] && timeout "$VERSION_TIMEOUT" "$HARNESS" setup --help >/dev/null 2>&1; then
477
+ # --home を渡すので根の解決は起きない。--scope は settings.json に焼く
478
+ # 根の綴り ("$CLAUDE_PROJECT_DIR" か "$HOME") を決めるだけなので、
479
+ # **いまの設置場所から導く** —— 推測すると、リポジトリに置いた設定が
480
+ # 端末個別の設定として書き直される。
481
+ if [ "$CONFIG_ROOT" = "$HOME" ]; then repair_scope=user; else repair_scope=project; fi
482
+ echo "ai-scanner: この端末のフックアダプタは分割前の形で、ツールコール検査機ではなく定期実行のバイナリを呼んでいます。いま更新しています。お手元での操作は要りません。" >&2
483
+ echo "ai-scanner: this endpoint's hook adapters are the pre-split shape, so they still call the periodic binary instead of the tool-call scanner. Updating them now; no action is needed from you." >&2
484
+ timeout "$UPDATE_TIMEOUT" "$HARNESS" setup \\
485
+ --agent ${setupAgentName} --scope "$repair_scope" \\
486
+ --access-key "$AI_SCANNER_ACCESS_KEY" \\
487
+ --download-base "$DOWNLOAD_BASE" --home "$CONFIG_ROOT" >/dev/null 2>&1 || \\
488
+ echo "ai-scanner: この端末のフックアダプタは分割前の形のままで、今回は更新できませんでした。ツールコールは、すでに導入済みのバイナリが検査し続けます。このメッセージが繰り返されるなら、導入コマンド (${installCommand}) をもう一度実行してください。それでも直らなければ、このメッセージを添えて社内の IT 管理者にご連絡ください。" >&2
489
+ echo "ai-scanner: the hook adapters on this endpoint are still the pre-split shape and could not be updated just now. Tool calls keep being scanned by the binary already installed; if this message repeats, re-run the installer (${installCommand}) or contact your IT administrator with this message." >&2
490
+ fi
491
+ fi
492
+
289
493
  exit 0
290
494
  `;
291
495
  }
@@ -368,7 +572,7 @@ const SCAN_HELPERS = `run_scanner() {
368
572
  export const SCAN_BASH_SH = `#!/bin/bash
369
573
  # PostToolUse hook for Bash: Scan command output.
370
574
  # Thin Claude Code adapter — Rust handles cap, detection log, actor resolution.
371
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
575
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
372
576
  ${UNSCANNED_NOTE}
373
577
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
374
578
 
@@ -385,7 +589,7 @@ jq -r '.tool_response // ""' <<<"$INPUT" \\
385
589
  export const SCAN_BASH_INPUT_SH = `#!/bin/bash
386
590
  # PreToolUse hook for Bash: Scan command before execution.
387
591
  # Thin Claude Code adapter — Rust handles cap, detection log, actor resolution.
388
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
592
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
389
593
  ${UNSCANNED_NOTE}
390
594
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
391
595
 
@@ -403,7 +607,7 @@ export const SCAN_EDIT_SH = `#!/bin/bash
403
607
  # PreToolUse hook for Edit/MultiEdit: Scan new content before write.
404
608
  # Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
405
609
  # Only the Edit-vs-MultiEdit content extraction is Claude Code specific.
406
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
610
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
407
611
  ${UNSCANNED_NOTE}
408
612
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
409
613
 
@@ -429,7 +633,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
429
633
  export const SCAN_READ_SH = `#!/bin/bash
430
634
  # PostToolUse hook for Read: Scan file content Claude just read.
431
635
  # Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
432
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
636
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
433
637
  ${UNSCANNED_NOTE}
434
638
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
435
639
 
@@ -449,7 +653,7 @@ jq -r '.tool_response.file.content // .tool_response // ""' <<<"$INPUT" \\
449
653
  export const SCAN_EDIT_OUTPUT_SH = `#!/bin/bash
450
654
  # PostToolUse hook for Edit/MultiEdit/Write: Scan final file content.
451
655
  # Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
452
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
656
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
453
657
  ${UNSCANNED_NOTE}
454
658
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
455
659
 
@@ -470,7 +674,7 @@ export const SCAN_READ_INPUT_SH = `#!/bin/bash
470
674
  # This is a thin Claude Code adapter — all scanning logic lives in the Rust
471
675
  # binary, including actor resolution, exclude-dir filtering, content cap, and
472
676
  # detection-log writing.
473
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
677
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
474
678
  ${UNSCANNED_NOTE}
475
679
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
476
680
 
@@ -488,7 +692,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
488
692
  export const SCAN_WEBFETCH_SH = `#!/bin/bash
489
693
  # PreToolUse hook for WebFetch: Scan URL before Claude fetches it.
490
694
  # Thin Claude Code adapter — Rust handles phishing scoring + YARA + detection log.
491
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
695
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
492
696
  ${UNSCANNED_NOTE}
493
697
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
494
698
 
@@ -505,7 +709,7 @@ run_scanner scan webfetch --url "$URL" </dev/null
505
709
  export const SCAN_WEBSEARCH_SH = `#!/bin/bash
506
710
  # PostToolUse hook for WebSearch: Scan search results for phishing URLs.
507
711
  # Thin Claude Code adapter — Rust handles cap, phishing scoring, detection log.
508
- BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
712
+ BIN="\${AI_HARNESS_BIN:-$HOME/.claude/bin/ai-harness}"
509
713
  ${UNSCANNED_NOTE}
510
714
  [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
511
715
 
@@ -551,6 +755,12 @@ export function agentEnvSh(accessKey, agentDir) {
551
755
  return `#!/bin/bash
552
756
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
553
757
  export AI_SCANNER_BIN="\${AI_SCANNER_BIN:-$HOME/${agentDir}/bin/ai-scanner}"
758
+ # The hook binary (issue #2366). Every adapter below scans through THIS one:
759
+ # the eight tool-call contexts live in \`ai-harness\`, not in \`ai-scanner\`
760
+ # (which is the periodic endpoint-inventory collector). Two names, never one
761
+ # shared override — pointing a single variable at one product would leave the
762
+ # other silently addressing a file that is not it.
763
+ export AI_HARNESS_BIN="\${AI_HARNESS_BIN:-$HOME/${agentDir}/bin/ai-harness}"
554
764
  export AI_SCANNER_ACCESS_KEY="${accessKey}"
555
765
  export AI_SCANNER_LEVEL="\${AI_SCANNER_LEVEL:-high}"
556
766
  export AI_SCANNER_EXCLUDE_DIRS="\${AI_SCANNER_EXCLUDE_DIRS:-}"
@@ -569,11 +779,11 @@ ${UNSCANNED_NOTE}
569
779
 
570
780
  run_scanner() {
571
781
  if command -v timeout >/dev/null 2>&1; then
572
- timeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT}" "\${AI_SCANNER_BIN}" "$@"
782
+ timeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT}" "\${AI_HARNESS_BIN}" "$@"
573
783
  elif command -v gtimeout >/dev/null 2>&1; then
574
- gtimeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT}" "\${AI_SCANNER_BIN}" "$@"
784
+ gtimeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT}" "\${AI_HARNESS_BIN}" "$@"
575
785
  else
576
- "\${AI_SCANNER_BIN}" "$@"
786
+ "\${AI_HARNESS_BIN}" "$@"
577
787
  fi
578
788
  local rc=$?
579
789
  [ $rc -eq 124 ] && return 0
@@ -593,7 +803,7 @@ source "$SCRIPT_DIR/env.sh"
593
803
  cat > /dev/null
594
804
  bash "$SCRIPT_DIR/start_scanner.sh" 2>/dev/null || true
595
805
  cat <<EOF
596
- {"env":{"AI_SCANNER_BIN":"\${AI_SCANNER_BIN}","AI_SCANNER_ACCESS_KEY":"${accessKey}","AI_SCANNER_LEVEL":"high"}}
806
+ {"env":{"AI_SCANNER_BIN":"\${AI_SCANNER_BIN}","AI_HARNESS_BIN":"\${AI_HARNESS_BIN}","AI_SCANNER_ACCESS_KEY":"${accessKey}","AI_SCANNER_LEVEL":"high"}}
597
807
  EOF
598
808
  `;
599
809
  }
@@ -601,7 +811,7 @@ EOF
601
811
  export const CURSOR_PRE_SHELL_SH = `#!/bin/bash
602
812
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
603
813
  source "$SCRIPT_DIR/env.sh"
604
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
814
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
605
815
  export AI_SCANNER_DETECTION_LOG
606
816
  jq -r '.tool_input.command // ""' | run_scanner scan bash-input
607
817
  `;
@@ -609,7 +819,7 @@ jq -r '.tool_input.command // ""' | run_scanner scan bash-input
609
819
  export const CURSOR_PRE_READ_SH = `#!/bin/bash
610
820
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
611
821
  source "$SCRIPT_DIR/env.sh"
612
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
822
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
613
823
  export AI_SCANNER_DETECTION_LOG
614
824
  FILE_PATH=$(jq -r '.tool_input.file_path // ""')
615
825
  [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
@@ -619,7 +829,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
619
829
  export const CURSOR_PRE_EDIT_SH = `#!/bin/bash
620
830
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
621
831
  source "$SCRIPT_DIR/env.sh"
622
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
832
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
623
833
  export AI_SCANNER_DETECTION_LOG
624
834
  INPUT=$(cat)
625
835
  FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
@@ -636,7 +846,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
636
846
  export const CURSOR_POST_SHELL_SH = `#!/bin/bash
637
847
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
638
848
  source "$SCRIPT_DIR/env.sh"
639
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
849
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
640
850
  export AI_SCANNER_DETECTION_LOG
641
851
  INPUT=$(cat)
642
852
  COMMAND=$(jq -r '.tool_output // ""' <<<"$INPUT" | head -1 && jq -r '.tool_input.command // ""' <<<"$INPUT")
@@ -646,7 +856,7 @@ jq -r '.tool_output // ""' <<<"$INPUT" | run_scanner scan bash-output --command
646
856
  export const CURSOR_POST_READ_SH = `#!/bin/bash
647
857
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
648
858
  source "$SCRIPT_DIR/env.sh"
649
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
859
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
650
860
  export AI_SCANNER_DETECTION_LOG
651
861
  INPUT=$(cat)
652
862
  FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
@@ -657,7 +867,7 @@ jq -r '.tool_output // ""' <<<"$INPUT" | run_scanner scan read-output --file "$F
657
867
  export const CURSOR_POST_WRITE_SH = `#!/bin/bash
658
868
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
659
869
  source "$SCRIPT_DIR/env.sh"
660
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
870
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
661
871
  export AI_SCANNER_DETECTION_LOG
662
872
  INPUT=$(cat)
663
873
  FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
@@ -672,7 +882,7 @@ run_scanner scan edit-output --file "$FILE_PATH" < "$FILE_PATH"
672
882
  export const WINDSURF_ENSURE_SCANNER_SH = `#!/bin/bash
673
883
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
674
884
  source "$SCRIPT_DIR/env.sh"
675
- BIN="$AI_SCANNER_BIN"
885
+ BIN="$AI_HARNESS_BIN"
676
886
  LOCK_FILE="/tmp/.ai-scanner-ensure.lock"
677
887
  if [ -x "$BIN" ] && timeout 2 "$BIN" health >/dev/null 2>&1; then
678
888
  return 0 2>/dev/null || true
@@ -688,7 +898,7 @@ return 0 2>/dev/null || true
688
898
  export const WINDSURF_PRE_COMMAND_SH = `#!/bin/bash
689
899
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
690
900
  source "$SCRIPT_DIR/ensure_scanner.sh"
691
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
901
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
692
902
  export AI_SCANNER_DETECTION_LOG
693
903
  CONTENT=$(jq -r '.tool_info.command_line // ""')
694
904
  [ -z "$CONTENT" ] || [ "$CONTENT" = "null" ] && exit 0
@@ -698,7 +908,7 @@ printf '%s' "$CONTENT" | run_scanner scan bash-input
698
908
  export const WINDSURF_PRE_READ_SH = `#!/bin/bash
699
909
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
700
910
  source "$SCRIPT_DIR/ensure_scanner.sh"
701
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
911
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
702
912
  export AI_SCANNER_DETECTION_LOG
703
913
  FILE_PATH=$(jq -r '.tool_info.file_path // ""')
704
914
  [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
@@ -708,7 +918,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
708
918
  export const WINDSURF_PRE_WRITE_SH = `#!/bin/bash
709
919
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
710
920
  source "$SCRIPT_DIR/ensure_scanner.sh"
711
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
921
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
712
922
  export AI_SCANNER_DETECTION_LOG
713
923
  INPUT=$(cat)
714
924
  FILE_PATH=$(jq -r '.tool_info.file_path // ""' <<<"$INPUT")
@@ -720,7 +930,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
720
930
  export const WINDSURF_POST_READ_SH = `#!/bin/bash
721
931
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
722
932
  source "$SCRIPT_DIR/ensure_scanner.sh"
723
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
933
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
724
934
  export AI_SCANNER_DETECTION_LOG
725
935
  FILE_PATH=$(jq -r '.tool_info.file_path // ""')
726
936
  [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
@@ -730,7 +940,7 @@ run_scanner scan read-output --file "$FILE_PATH" < "$FILE_PATH"
730
940
  export const WINDSURF_POST_WRITE_SH = `#!/bin/bash
731
941
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
732
942
  source "$SCRIPT_DIR/ensure_scanner.sh"
733
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
943
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
734
944
  export AI_SCANNER_DETECTION_LOG
735
945
  FILE_PATH=$(jq -r '.tool_info.file_path // ""')
736
946
  [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
@@ -773,7 +983,7 @@ exec "$SCRIPT_DIR/start_scanner.sh"
773
983
  export const KIRO_PRE_SHELL_SH = `#!/bin/bash
774
984
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
775
985
  source "$SCRIPT_DIR/env.sh"
776
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
986
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
777
987
  export AI_SCANNER_DETECTION_LOG
778
988
  jq -r '.tool_input.command // ""' | run_scanner scan bash-input
779
989
  `;
@@ -781,7 +991,7 @@ jq -r '.tool_input.command // ""' | run_scanner scan bash-input
781
991
  export const KIRO_PRE_READ_SH = `#!/bin/bash
782
992
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
783
993
  source "$SCRIPT_DIR/env.sh"
784
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
994
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
785
995
  export AI_SCANNER_DETECTION_LOG
786
996
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.operations[0].path // ""')
787
997
  [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
@@ -791,7 +1001,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
791
1001
  export const KIRO_PRE_WRITE_SH = `#!/bin/bash
792
1002
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
793
1003
  source "$SCRIPT_DIR/env.sh"
794
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
1004
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
795
1005
  export AI_SCANNER_DETECTION_LOG
796
1006
  INPUT=$(cat)
797
1007
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // ""' <<<"$INPUT")
@@ -803,7 +1013,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
803
1013
  export const KIRO_POST_SHELL_SH = `#!/bin/bash
804
1014
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
805
1015
  source "$SCRIPT_DIR/env.sh"
806
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
1016
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
807
1017
  export AI_SCANNER_DETECTION_LOG
808
1018
  INPUT=$(cat)
809
1019
  COMMAND=$(jq -r '.tool_input.command // ""' <<<"$INPUT")
@@ -814,7 +1024,7 @@ printf '%s' "$RESPONSE" | run_scanner scan bash-output --command "$COMMAND"
814
1024
  export const KIRO_POST_READ_SH = `#!/bin/bash
815
1025
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
816
1026
  source "$SCRIPT_DIR/env.sh"
817
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
1027
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
818
1028
  export AI_SCANNER_DETECTION_LOG
819
1029
  INPUT=$(cat)
820
1030
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.operations[0].path // ""' <<<"$INPUT")
@@ -826,7 +1036,7 @@ printf '%s' "$RESPONSE" | run_scanner scan read-output --file "$FILE_PATH"
826
1036
  export const KIRO_POST_WRITE_SH = `#!/bin/bash
827
1037
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
828
1038
  source "$SCRIPT_DIR/env.sh"
829
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
1039
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
830
1040
  export AI_SCANNER_DETECTION_LOG
831
1041
  INPUT=$(cat)
832
1042
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // ""' <<<"$INPUT")
@@ -902,7 +1112,7 @@ export const CODEX_SESSION_INIT_SH = `#!/bin/bash
902
1112
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
903
1113
  source "$SCRIPT_DIR/env.sh"
904
1114
  cat > /dev/null # drain stdin so Codex doesn't block on a write
905
- BIN="$AI_SCANNER_BIN"
1115
+ BIN="$AI_HARNESS_BIN"
906
1116
  LOCK_FILE="/tmp/.ai-scanner-codex-init.lock"
907
1117
  if [ -x "$BIN" ] && timeout 2 "$BIN" health >/dev/null 2>&1; then
908
1118
  exit 0
@@ -919,7 +1129,7 @@ export const CODEX_PRE_SHELL_SH = `#!/bin/bash
919
1129
  # Codex PreToolUse Bash hook: scan command and return block JSON to Codex.
920
1130
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
921
1131
  source "$SCRIPT_DIR/env.sh"
922
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
1132
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
923
1133
  export AI_SCANNER_DETECTION_LOG
924
1134
  INPUT=$(cat)
925
1135
  CMD=$(jq -r '.tool_input.command // ""' <<<"$INPUT" 2>/dev/null)
@@ -949,7 +1159,7 @@ export const CODEX_POST_SHELL_SH = `#!/bin/bash
949
1159
  # Codex PostToolUse Bash hook: scan command output and return feedback JSON.
950
1160
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
951
1161
  source "$SCRIPT_DIR/env.sh"
952
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
1162
+ BIN="$AI_HARNESS_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
953
1163
  export AI_SCANNER_DETECTION_LOG
954
1164
  INPUT=$(cat)
955
1165
  CMD=$(jq -r '.tool_input.command // ""' <<<"$INPUT" 2>/dev/null)
@@ -1,14 +1,54 @@
1
1
  // Line-based TOML merger for Codex's ~/.codex/config.toml feature flag.
2
- // Codex requires `[features] codex_hooks = true` to activate hook dispatch.
2
+ //
3
+ // Codex requires `[features] hooks = true` to activate hook dispatch. The older
4
+ // spelling `[features] codex_hooks = true` is a **deprecated alias**: Codex still
5
+ // honours it, but prints
6
+ //
7
+ // ⚠ `[features].codex_hooks` is deprecated. Use `[features].hooks` instead.
8
+ //
9
+ // on every start (issue #2367). So we write the canonical key and *migrate* any
10
+ // legacy key we find, instead of leaving both around.
11
+ //
3
12
  // Users may already have their own config (model settings, other features), so
4
13
  // we must merge rather than overwrite.
5
14
  //
6
- // Scope: *only* handles a single key inside a single section. No full TOML
7
- // parser, no external deps. Preserves comments, blank lines, ordering, and
15
+ // Scope: *only* handles a single logical flag inside a single section. No full
16
+ // TOML parser, no external deps. Preserves comments, blank lines, ordering, and
8
17
  // other sections exactly.
18
+ //
19
+ // ── Migration decision table (merge) ─────────────────────────────────
20
+ // Only the *first* `[features]` section is considered (duplicate table headers
21
+ // are invalid TOML anyway — see the known limitation at the bottom of this file).
22
+ //
23
+ // old key | new key | result
24
+ // --------+---------+--------------------------------------------------------
25
+ // absent | absent | insert `hooks = true` right under the section header
26
+ // absent | true | no-op (byte-identical output)
27
+ // absent | false | value flipped to true, line position/comment preserved
28
+ // true | absent | line RENAMED in place -> `hooks = true`
29
+ // (indentation, position and trailing comment preserved)
30
+ // false | absent | same rename, and the value is set to true
31
+ // any | any | the canonical `hooks` line wins and is set to true;
32
+ // the deprecated `codex_hooks` line is REMOVED entirely.
33
+ // This is the only case where a trailing comment can be
34
+ // lost, because it is attached to the line we delete.
35
+ // Conflicting values need no tie-break: setup means
36
+ // "hooks on", so the result is always `hooks = true`.
37
+ //
38
+ // Standalone comment lines are never deleted, even when they sit directly above
39
+ // a removed legacy key — deleting prose a user wrote is worse than leaving it.
40
+ //
41
+ // ── unmerge ──────────────────────────────────────────────────────────
42
+ // Strips BOTH spellings, so a config written by an older aidr (legacy key) is
43
+ // fully cleaned up by a newer aidr's uninstall. Everything else in `[features]`
44
+ // (the user's own flags and comments) survives; the section header is dropped
45
+ // only when nothing but blanks/comments would remain.
9
46
 
10
47
  const SECTION = "features";
11
- const KEY = "codex_hooks";
48
+ // Canonical key. Codex >= the version that introduced `[features].hooks`.
49
+ export const FEATURE_KEY = "hooks";
50
+ // Deprecated alias still accepted by Codex, but it prints a warning (#2367).
51
+ export const LEGACY_FEATURE_KEY = "codex_hooks";
12
52
  const VALUE = "true";
13
53
 
14
54
  // Match a section header line: optional leading whitespace, `[name]`, optional trailing comment.
@@ -20,10 +60,22 @@ function sectionHeader(name) {
20
60
  // Match any section header (used to find section boundaries).
21
61
  const ANY_SECTION_RE = /^\s*\[\s*[^\]]+\s*\](?:\s*#.*)?$/;
22
62
 
23
- // Match `key = value` with optional trailing comment; capture the value token.
63
+ // Match `key = value` with optional trailing comment.
64
+ // Groups: 1 = indent, 2 = key, 3 = ` = `, 4 = value token, 5 = trailing comment.
65
+ // The `^\s*` anchor means the `hooks` matcher can never hit a `codex_hooks`
66
+ // line (and vice versa) — the key must start the line after whitespace only.
24
67
  function keyAssignment(key) {
25
68
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
- return new RegExp(`^(\\s*${escaped}\\s*=\\s*)([^#\\n]*?)(\\s*(?:#.*)?)$`);
69
+ return new RegExp(`^(\\s*)(${escaped})(\\s*=\\s*)([^#\\n]*?)(\\s*(?:#.*)?)$`);
70
+ }
71
+
72
+ const CANONICAL_RE = keyAssignment(FEATURE_KEY);
73
+ const LEGACY_RE = keyAssignment(LEGACY_FEATURE_KEY);
74
+
75
+ // Rewrite a matched assignment line as `<indent>hooks<eq>true<trailing>`.
76
+ function rewriteAsEnabled(line, re) {
77
+ const m = re.exec(line);
78
+ return `${m[1]}${FEATURE_KEY}${m[3]}${VALUE}${m[5]}`;
27
79
  }
28
80
 
29
81
  // Detect obvious malformed TOML so we fail closed instead of silently corrupting.
@@ -61,37 +113,64 @@ function findSectionRange(lines, section) {
61
113
  return { start, end };
62
114
  }
63
115
 
64
- // Merge `[features] codex_hooks = true` into the given TOML text, preserving
65
- // everything else. Idempotent: re-running on an already-merged file is a no-op.
116
+ // Locate the first canonical / first legacy assignment inside a section range.
117
+ function findKeys(lines, range) {
118
+ let canonical = -1;
119
+ let legacy = -1;
120
+ for (let i = range.start + 1; i < range.end; i++) {
121
+ if (canonical === -1 && CANONICAL_RE.test(lines[i])) canonical = i;
122
+ if (legacy === -1 && LEGACY_RE.test(lines[i])) legacy = i;
123
+ }
124
+ return { canonical, legacy };
125
+ }
126
+
127
+ // Merge `[features] hooks = true` into the given TOML text, migrating the
128
+ // deprecated `codex_hooks` spelling and preserving everything else.
129
+ // Idempotent: re-running on an already-merged file returns the input verbatim.
66
130
  export function mergeCodexHookFeature(text) {
67
131
  const input = text ?? "";
68
132
  if (input !== "") validate(input);
69
133
 
70
134
  // Empty file / whitespace-only: write a fresh minimal TOML.
71
135
  if (input.trim() === "") {
72
- return `[${SECTION}]\n${KEY} = ${VALUE}\n`;
136
+ return `[${SECTION}]\n${FEATURE_KEY} = ${VALUE}\n`;
73
137
  }
74
138
 
75
139
  const lines = input.split("\n");
76
140
  // Preserve final newline state — JSON.stringify-style: if original ended with
77
141
  // "\n", .split gives a trailing "" element we'll keep and rejoin with.
78
142
  const range = findSectionRange(lines, SECTION);
79
- const assignRe = keyAssignment(KEY);
80
143
 
81
144
  if (range) {
82
- // Section exists — look for the key inside it.
83
- for (let i = range.start + 1; i < range.end; i++) {
84
- const m = assignRe.exec(lines[i]);
85
- if (m) {
86
- const currentValue = m[2].trim();
87
- if (currentValue === VALUE) return input; // already correct, no-op
88
- lines[i] = `${m[1]}${VALUE}${m[3]}`; // overwrite `false` / other
89
- return lines.join("\n");
90
- }
145
+ const { canonical, legacy } = findKeys(lines, range);
146
+
147
+ if (canonical !== -1 && legacy !== -1) {
148
+ // Both spellings present. The canonical line wins (position + comment
149
+ // kept); the deprecated alias line goes away, which is what silences the
150
+ // warning. Conflicting values resolve to `true` because that is what this
151
+ // setup means.
152
+ lines[canonical] = rewriteAsEnabled(lines[canonical], CANONICAL_RE);
153
+ lines.splice(legacy, 1);
154
+ return lines.join("\n");
91
155
  }
156
+
157
+ if (legacy !== -1) {
158
+ // Legacy only — rename in place so ordering, indentation and any trailing
159
+ // comment survive. `codex_hooks = false` becomes `hooks = true`.
160
+ lines[legacy] = rewriteAsEnabled(lines[legacy], LEGACY_RE);
161
+ return lines.join("\n");
162
+ }
163
+
164
+ if (canonical !== -1) {
165
+ const m = CANONICAL_RE.exec(lines[canonical]);
166
+ if (m[4].trim() === VALUE) return input; // already correct, no-op
167
+ lines[canonical] = rewriteAsEnabled(lines[canonical], CANONICAL_RE);
168
+ return lines.join("\n");
169
+ }
170
+
92
171
  // Key absent — insert right after the section header (before any subkeys),
93
172
  // so the new line doesn't get buried under a long section.
94
- lines.splice(range.start + 1, 0, `${KEY} = ${VALUE}`);
173
+ lines.splice(range.start + 1, 0, `${FEATURE_KEY} = ${VALUE}`);
95
174
  return lines.join("\n");
96
175
  }
97
176
 
@@ -100,12 +179,14 @@ export function mergeCodexHookFeature(text) {
100
179
  const needsLeadingBlank = input.length > 0 && !input.endsWith("\n\n");
101
180
  const prefix = input.endsWith("\n") ? "" : "\n";
102
181
  const spacer = needsLeadingBlank ? "\n" : "";
103
- return `${input}${prefix}${spacer}[${SECTION}]\n${KEY} = ${VALUE}\n`;
182
+ return `${input}${prefix}${spacer}[${SECTION}]\n${FEATURE_KEY} = ${VALUE}\n`;
104
183
  }
105
184
 
106
- // Reverse of mergeCodexHookFeature: strip our `codex_hooks = true` line.
107
- // - Removes the key line.
108
- // - Removes an empty `[features]` section if our line was the only content.
185
+ // Reverse of mergeCodexHookFeature: strip our feature flag line(s).
186
+ // - Removes `hooks` AND the deprecated `codex_hooks` (a config written by an
187
+ // older aidr carries the legacy spelling; leaving it behind would keep the
188
+ // deprecation warning alive after uninstall).
189
+ // - Removes an empty `[features]` section if our line(s) were the only content.
109
190
  // Returns { text, empty } — empty=true means the TOML is now effectively blank
110
191
  // so the caller can delete the file entirely.
111
192
  export function unmergeCodexHookFeature(text) {
@@ -120,17 +201,17 @@ export function unmergeCodexHookFeature(text) {
120
201
  return { text: input, empty: input.trim() === "" };
121
202
  }
122
203
 
123
- const assignRe = keyAssignment(KEY);
124
- let removedIdx = -1;
204
+ const removed = [];
125
205
  for (let i = range.start + 1; i < range.end; i++) {
126
- if (assignRe.test(lines[i])) { removedIdx = i; break; }
206
+ if (CANONICAL_RE.test(lines[i]) || LEGACY_RE.test(lines[i])) removed.push(i);
127
207
  }
128
208
 
129
- if (removedIdx === -1) {
209
+ if (removed.length === 0) {
130
210
  return { text: input, empty: input.trim() === "" };
131
211
  }
132
212
 
133
- lines.splice(removedIdx, 1);
213
+ // Delete back-to-front so earlier indices stay valid.
214
+ for (let k = removed.length - 1; k >= 0; k--) lines.splice(removed[k], 1);
134
215
 
135
216
  // Re-evaluate the section bounds after removal.
136
217
  const newRange = findSectionRange(lines, SECTION);
@@ -165,3 +246,10 @@ export function unmergeCodexHookFeature(text) {
165
246
  const result = lines.join("\n");
166
247
  return { text: result, empty: result.trim() === "" };
167
248
  }
249
+
250
+ // ── Known limitation (declared, not fixed) ───────────────────────────
251
+ // Only the FIRST `[features]` table is inspected. A file with two `[features]`
252
+ // headers is already invalid TOML (duplicate table), so Codex would reject it
253
+ // before our flag mattered; handling it here would mean writing a real parser.
254
+ // If such a file ever appears, a legacy key living in the second block would
255
+ // survive the migration and the deprecation warning would persist.