@coworker-jp/aidr 0.1.292 → 0.1.294

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.292",
3
+ "version": "0.1.294",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,15 @@ const SCRIPT_FILES = [
13
13
  "scan_webfetch.sh", "scan_websearch.sh",
14
14
  "log_web_urls.sh",
15
15
  ];
16
- const BIN_FILES = ["ai-scanner", "opengrep"];
16
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
17
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
18
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
19
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
20
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
21
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
22
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
23
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
24
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
17
25
 
18
26
  export const meta = {
19
27
  name: "claude",
@@ -27,7 +27,15 @@ const SCRIPT_FILES = [
27
27
  "pre_shell.sh",
28
28
  "post_shell.sh",
29
29
  ];
30
- const BIN_FILES = ["ai-scanner", "opengrep"];
30
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
31
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
32
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
33
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
34
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
35
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
36
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
37
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
38
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
31
39
  const ROOT_FILES = ["AGENTS.md"];
32
40
 
33
41
  export const meta = {
@@ -28,7 +28,15 @@ const SCRIPT_FILES = [
28
28
  "post_read.sh",
29
29
  "post_write.sh",
30
30
  ];
31
- const BIN_FILES = ["ai-scanner", "opengrep"];
31
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
32
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
33
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
34
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
35
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
36
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
37
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
38
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
39
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
32
40
 
33
41
  export const meta = {
34
42
  name: "cursor",
@@ -5,7 +5,15 @@ import * as T from "../templates.mjs";
5
5
  import { buildGeminiSettings, mergeGeminiSettings, unmergeGeminiSettings } from "../merge.mjs";
6
6
 
7
7
  const HOOK_FILES = ["aidr-pre.sh"];
8
- const BIN_FILES = ["ai-scanner", "opengrep"];
8
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
9
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
10
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
11
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
12
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
13
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
14
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
15
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
16
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
9
17
 
10
18
  export const meta = {
11
19
  name: "gemini",
@@ -18,7 +18,15 @@ const SCRIPT_FILES = [
18
18
  "post_read.sh",
19
19
  "post_write.sh",
20
20
  ];
21
- const BIN_FILES = ["ai-scanner", "opengrep"];
21
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
22
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
23
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
24
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
25
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
26
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
27
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
28
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
29
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
22
30
 
23
31
  export const meta = {
24
32
  name: "kiro",
@@ -2,7 +2,15 @@ import path from "node:path";
2
2
  import fsp from "node:fs/promises";
3
3
  import { pathExists, rmdirIfEmpty } from "../fs-utils.mjs";
4
4
 
5
- const BIN_FILES = ["ai-scanner", "opengrep"];
5
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
6
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
7
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
8
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
9
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
10
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
11
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
12
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
13
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
6
14
 
7
15
  export const meta = {
8
16
  name: "standalone",
@@ -25,7 +25,15 @@ const SCRIPT_FILES = [
25
25
  "post_read.sh",
26
26
  "post_write.sh",
27
27
  ];
28
- const BIN_FILES = ["ai-scanner", "opengrep"];
28
+ // bin ディレクトリ直下に生まれる、バイナリ以外の生成物。
29
+ // `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
30
+ // 記録するファイル (#1774)、`.ai-scanner-last-pull` は自己更新の間引きスタンプ
31
+ // (#1795。エージェントごとに 1 個で、以前は `~/.claude/` に 1 個だけだった)。
32
+ // **どちらも消す対象に入れておくこと** —— 残ると bin ディレクトリが空にならず
33
+ // `rmdirIfEmpty` が効かないので、アンインストール後にディレクトリと記録が居座る。
34
+ // 7 エージェントすべて同じ表を持ち、`tests/bin-dir-artifacts.test.mjs` が
35
+ // 「表 ⇔ テンプレートが実際に書く名前」を両方向で照合する。
36
+ const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned", ".ai-scanner-last-pull"];
29
37
 
30
38
  export const meta = {
31
39
  name: "windsurf",
@@ -65,11 +65,117 @@ async function fetchBuffer(url, accessKey) {
65
65
  return Buffer.from(ab);
66
66
  }
67
67
 
68
- async function fetchText(url, accessKey) {
68
+ /**
69
+ * Refused to install because the download could not be checked against a
70
+ * publisher checksum. `status` is set ONLY when the endpoint gave a definite
71
+ * "not there" for the sidecar, so `fetchBinary` can treat an incompletely
72
+ * published pinned release exactly like a missing pinned payload (fall back to
73
+ * the published key, which brings its own checksum) instead of turning a half
74
+ * release into an unverified install.
75
+ */
76
+ export class IntegrityUnverifiableError extends Error {
77
+ constructor(message, { status } = {}) {
78
+ super(message);
79
+ this.name = "IntegrityUnverifiableError";
80
+ if (status !== undefined) this.status = status;
81
+ }
82
+ }
83
+
84
+ /** A publisher checksum is 64 lowercase-or-uppercase hex characters. */
85
+ const SHA256_HEX_RE = /^[0-9a-fA-F]{64}$/;
86
+
87
+ /** Statuses that mean "this object is not there", as opposed to "ask again". */
88
+ const DEFINITELY_ABSENT = new Set([404, 410]);
89
+
90
+ /**
91
+ * Statuses that will not change on a retry but are NOT evidence of absence: the
92
+ * caller was refused. Kept apart from `DEFINITELY_ABSENT` so a rejected access
93
+ * key never makes `fetchBinary` fall back to another key (same reason as the
94
+ * payload's own 403 rule below) and never reads as "the release is incomplete".
95
+ */
96
+ const REFUSED = new Set([401, 403]);
97
+
98
+ /** How many times a transient sidecar failure is re-asked before giving up. */
99
+ export const CHECKSUM_ATTEMPTS = 3;
100
+ const CHECKSUM_RETRY_MS = 400;
101
+
102
+ /**
103
+ * Fetch the sibling `.sha256` sidecar and classify the outcome. The three
104
+ * outcomes are kept apart on purpose — the previous implementation collapsed
105
+ * every one of them into `null` (`if (!res.ok) return null` plus a
106
+ * `.catch(() => null)`), which is how "the object is gone" became
107
+ * indistinguishable from "the network blinked", and both became "install it
108
+ * anyway":
109
+ *
110
+ * { kind: "hash", hex } 200 carrying a usable digest
111
+ * { kind: "absent", status } the endpoint answered that it is not there
112
+ * { kind: "unusable", detail } reachable but no digest came back, or the
113
+ * service/network could not answer at all
114
+ *
115
+ * A 200 whose body is empty / whitespace / not 64 hex characters is `unusable`,
116
+ * NOT `absent`: an empty sidecar object and an error page both used to pass the
117
+ * old `if (expected)` test as falsy-or-garbage and skip verification.
118
+ */
119
+ async function fetchChecksum(url, accessKey, { attempts = CHECKSUM_ATTEMPTS, sleep = defaultSleep } = {}) {
69
120
  const headers = accessKey ? { "x-access-key": accessKey } : {};
70
- const res = await fetch(url, { redirect: "follow", headers });
71
- if (!res.ok) return null;
72
- return (await res.text()).trim();
121
+ let last = null;
122
+ for (let attempt = 1; attempt <= attempts; attempt++) {
123
+ let res;
124
+ try {
125
+ res = await fetch(url, { redirect: "follow", headers });
126
+ } catch (e) {
127
+ last = { kind: "unusable", detail: `${url} could not be reached (${e.message})` };
128
+ if (attempt < attempts) { await sleep(CHECKSUM_RETRY_MS * attempt); continue; }
129
+ return last;
130
+ }
131
+ if (DEFINITELY_ABSENT.has(res.status)) {
132
+ // Not a transient condition: asking again cannot make it appear.
133
+ return { kind: "absent", status: res.status, detail: `${url} -> HTTP ${res.status}` };
134
+ }
135
+ if (REFUSED.has(res.status)) {
136
+ return {
137
+ kind: "unusable",
138
+ detail: `${url} -> HTTP ${res.status} (the access key was refused for the checksum)`,
139
+ };
140
+ }
141
+ if (!res.ok) {
142
+ last = { kind: "unusable", detail: `${url} -> HTTP ${res.status}` };
143
+ if (attempt < attempts) { await sleep(CHECKSUM_RETRY_MS * attempt); continue; }
144
+ return last;
145
+ }
146
+ const body = (await res.text().catch(() => "")).trim();
147
+ const token = body.split(/\s+/)[0] || "";
148
+ if (SHA256_HEX_RE.test(token)) return { kind: "hash", hex: token.toLowerCase() };
149
+ // Reachable, answered 200, and still no digest. Re-asking a served object
150
+ // will not change its bytes, so this is decided on the first look.
151
+ return {
152
+ kind: "unusable",
153
+ detail: body
154
+ ? `${url} did not return a SHA256 digest (got ${JSON.stringify(token.slice(0, 32))})`
155
+ : `${url} returned an empty checksum`,
156
+ };
157
+ }
158
+ return last;
159
+ }
160
+
161
+ function defaultSleep(ms) {
162
+ return new Promise((resolve) => setTimeout(resolve, ms));
163
+ }
164
+
165
+ /**
166
+ * Throw unless this asset was checked against a publisher checksum. Callers use
167
+ * this so `verified` is a CONDITION, not a caption: it used to be read only to
168
+ * decide whether to append ", sha256 verified" to a log line, which meant a code
169
+ * path returning `verified: false` would install the binary and merely print
170
+ * one word less. Every consumer of `fetchAsset` / `fetchBinary` must call this
171
+ * before treating the file as installed.
172
+ */
173
+ export function assertVerified(result, label = "the downloaded file") {
174
+ if (result?.verified === true) return result;
175
+ throw new IntegrityUnverifiableError(
176
+ `${label} was not checked against a publisher SHA256 checksum, so it was not installed. ` +
177
+ `Re-run the installer; if it keeps happening, contact your IT administrator.`,
178
+ );
73
179
  }
74
180
 
75
181
  async function clearQuarantine(destPath) {
@@ -82,28 +188,53 @@ async function clearQuarantine(destPath) {
82
188
  }
83
189
 
84
190
  /**
85
- * Download an asset from `binUrl` into `destPath`, validating SHA256 when a
86
- * sibling `.sha256` file is available. Sets executable mode and strips macOS
87
- * quarantine. Returns { path, sha256, verified }.
191
+ * Download an asset from `binUrl` into `destPath`, validating it against the
192
+ * sibling `.sha256` published beside it. Sets executable mode and strips macOS
193
+ * quarantine. Returns { path, sha256, verified } with `verified` always true —
194
+ * the alternative is a throw, never a returned `verified: false` (#1773).
195
+ * `checksumOpts` is a test seam ({ attempts, sleep }) for the retry loop.
88
196
  */
89
- export async function fetchAsset(binUrl, destPath, { accessKey } = {}) {
197
+ export async function fetchAsset(binUrl, destPath, { accessKey, checksumOpts } = {}) {
90
198
  const shaUrl = `${binUrl}.sha256`;
91
199
  await fsp.mkdir(path.dirname(destPath), { recursive: true });
92
200
 
93
201
  const buf = await fetchBuffer(binUrl, accessKey);
94
202
  const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
95
203
 
96
- let verified = false;
97
- const expected = await fetchText(shaUrl, accessKey).catch(() => null);
98
- if (expected) {
99
- const want = expected.split(/\s+/)[0].toLowerCase();
100
- if (want !== sha256) {
101
- throw new Error(`SHA256 mismatch for ${binUrl}: got ${sha256}, expected ${want}`);
102
- }
103
- verified = true;
104
- } else {
105
- console.warn(`warning: ${shaUrl} unavailable; skipping SHA256 verification`);
204
+ // FAIL-CLOSED (#1773). No checksum ⇒ nothing is written to `destPath`.
205
+ //
206
+ // This is the same rule the scanner's own self-updater has followed since
207
+ // #319 ("a missing sha256 means DO NOT install", docker/scanner/src/main.rs
208
+ // `check_download_hash`) and the same rule `fetchOpengrep` below follows
209
+ // against its pinned hash. Only the FIRST install — this function — used to
210
+ // be exempt, which is exactly backwards: at first install there is no
211
+ // previously-verified binary in place to fall back on.
212
+ //
213
+ // Suppressing one small object must not be able to downgrade the check on a
214
+ // security product's executable, and it is far easier to delete a hash than
215
+ // to forge one. So the outcomes are: verified install, or no install plus a
216
+ // loud, actionable status — never a quiet unverified install.
217
+ const checksum = await fetchChecksum(shaUrl, accessKey, checksumOpts);
218
+ if (checksum.kind === "absent") {
219
+ throw new IntegrityUnverifiableError(
220
+ `no published SHA256 checksum for ${binUrl} (${checksum.detail}), so the download was ` +
221
+ `discarded without being installed. Re-run the installer in a few minutes; if it keeps ` +
222
+ `happening, contact your IT administrator — the release is incomplete.`,
223
+ { status: checksum.status },
224
+ );
225
+ }
226
+ if (checksum.kind !== "hash") {
227
+ throw new IntegrityUnverifiableError(
228
+ `could not read the SHA256 checksum for ${binUrl} after ${CHECKSUM_ATTEMPTS} attempts ` +
229
+ `(${checksum.detail}), so the download was discarded without being installed. ` +
230
+ `Check the network connection and re-run the installer; if it keeps happening, ` +
231
+ `contact your IT administrator.`,
232
+ );
233
+ }
234
+ if (checksum.hex !== sha256) {
235
+ throw new Error(`SHA256 mismatch for ${binUrl}: got ${sha256}, expected ${checksum.hex}`);
106
236
  }
237
+ const verified = true;
107
238
 
108
239
  const tmp = `${destPath}.tmp-${process.pid}`;
109
240
  await fsp.writeFile(tmp, buf);
@@ -159,6 +290,11 @@ export async function fetchBinary(destPath, { env = "prod", accessKey, version,
159
290
  const result = await fetchAsset(`${base}/v${version}/${platform}`, destPath, { accessKey });
160
291
  return { ...result, platform, requestedVersion: version, servedUnpinned: false };
161
292
  } catch (e) {
293
+ // A pinned release whose CHECKSUM is a definite 404 is incomplete in exactly
294
+ // the same way as one whose binary is (#1773): half a release is not a
295
+ // release. It falls back here, and the published key it falls back to
296
+ // carries its own checksum — the fallback can never widen into an
297
+ // unverified install, because `fetchAsset` refuses that on both keys.
162
298
  if (e?.status !== 404) throw e;
163
299
  const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
164
300
  return { ...result, platform, requestedVersion: version, servedUnpinned: true };
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 } from "./binary-fetcher.mjs";
13
+ import { fetchBinary, fetchOpengrep, assertVerified } 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";
@@ -200,20 +200,30 @@ async function cmdInstall(opts) {
200
200
  // dir so ai-scanner's sibling-lookup finds opengrep without relying on
201
201
  // PATH. Users can point AI_SCANNER_OPENGREP elsewhere to substitute
202
202
  // their own LGPL-2.1 build.
203
- const scannerRes = await fetchBinary(path.join(primaryDir, "ai-scanner"), fetchOpts);
203
+ const scannerRes = assertVerified(
204
+ await fetchBinary(path.join(primaryDir, "ai-scanner"), fetchOpts),
205
+ "the ai-scanner binary",
206
+ );
204
207
  binaryOutcome.scannerInstalled = true;
205
- console.log(`binary: ${scannerRes.path} (${scannerRes.platform}${scannerRes.verified ? ", sha256 verified" : ""})`);
208
+ // Stated unconditionally, because it is now a condition of getting here
209
+ // rather than a caption on a file that may or may not have been checked
210
+ // (#1773): `fetchAsset` throws unless the bytes matched the published
211
+ // SHA256, and `assertVerified` is the second lock on that.
212
+ console.log(`binary: ${scannerRes.path} (${scannerRes.platform}, sha256 verified)`);
206
213
  if (scannerRes.servedUnpinned) {
207
214
  // Say what was asked for, what arrived, and how to check — the pinned
208
215
  // build genuinely has no bytes behind it, but protection IS in place,
209
216
  // so this is a note, not an alarm.
210
- console.error(`note: release v${scannerRes.requestedVersion} of the scanner is not published for ${scannerRes.platform} yet, so it could not be the one installed.`);
217
+ console.error(`note: release v${scannerRes.requestedVersion} of the scanner is not fully published for ${scannerRes.platform} yet (its binary or its checksum is not there), so it could not be the one installed.`);
211
218
  console.error(`note: the currently published build was installed instead, so scanning is active. Check which build you have with: ${scannerRes.path} version`);
212
219
  }
213
220
  // Opengrep is fetched unmodified from its public upstream GitHub
214
221
  // release (LGPL-2.1); no access-key gating is needed here.
215
- const opengrepRes = await fetchOpengrep(path.join(primaryDir, "opengrep"));
216
- console.log(`binary: ${opengrepRes.path} (opengrep ${opengrepRes.platform}${opengrepRes.verified ? ", sha256 verified" : ""})`);
222
+ const opengrepRes = assertVerified(
223
+ await fetchOpengrep(path.join(primaryDir, "opengrep")),
224
+ "the opengrep engine",
225
+ );
226
+ console.log(`binary: ${opengrepRes.path} (opengrep ${opengrepRes.platform}, sha256 verified)`);
217
227
 
218
228
  const copyOps = restDirs.flatMap(restDir =>
219
229
  ["ai-scanner", "opengrep"].map(async (file) => {
@@ -570,13 +580,64 @@ async function cmdDoctor() {
570
580
  await fs.access(bin, fs.constants.F_OK);
571
581
  present = true;
572
582
  } catch {}
573
- return `binary: ${bin} ${present ? "[present]" : "[missing]"}`;
583
+ const out = [`binary: ${bin} ${present ? "[present]" : "[missing]"}`];
584
+ // What the hooks recorded while there was no binary (#1774). Without this,
585
+ // "[missing]" was the only trace, and it said nothing about how long the
586
+ // machine had been unscanned or how much had gone through unscanned —
587
+ // start_scanner.sh removes the record as soon as a binary is in place, so
588
+ // its presence describes the CURRENT state, not history.
589
+ const unscanned = await readUnscannedRecord(path.dirname(bin));
590
+ if (unscanned) {
591
+ out.push(
592
+ ` ${unscanned.count} tool call(s) ran with no scanner in place, ` +
593
+ `from ${unscanned.first.toISOString()} to ${unscanned.last.toISOString()}.`,
594
+ );
595
+ out.push(present
596
+ ? " A binary is in place now, and this record has not been cleared yet: " +
597
+ "it is cleared at the next session start. Whether anything was missed in that " +
598
+ "window is not knowable from here — nothing scanned those calls."
599
+ // INSTALL_COMMAND_HINT, not a literal: the DEV package must never tell
600
+ // anyone to install PROD.
601
+ : ` Those calls were not scanned. Re-run the installer (${INSTALL_COMMAND_HINT}), ` +
602
+ "or contact your IT administrator.");
603
+ }
604
+ return out.join("\n");
574
605
  }));
575
606
  for (const line of lines) {
576
607
  if (line) console.log(line);
577
608
  }
578
609
  }
579
610
 
611
+ /**
612
+ * Read the `.ai-scanner-unscanned` record the scan hooks write next to the
613
+ * binary when they exit without one (templates.mjs `note_unscanned`). Three
614
+ * integers on one line: first seen, last seen, count. A malformed or absent
615
+ * file yields null — this is a diagnostic read, so it must never throw and must
616
+ * never invent a number (a count of 0 would read as "nothing was missed").
617
+ */
618
+ export async function readUnscannedRecord(binDir) {
619
+ let raw;
620
+ try {
621
+ raw = await fs.readFile(path.join(binDir, ".ai-scanner-unscanned"), "utf8");
622
+ } catch {
623
+ return null;
624
+ }
625
+ const parts = raw.split(/\r?\n/)[0].trim().split(/\s+/);
626
+ if (parts.length < 3) return null;
627
+ // 10 進の非負整数だけを受け、**`Number` が正確に持てる範囲を超えたら壊れている
628
+ // ものとして扱う**。超えた値をそのまま返すと `9007199254740993` が
629
+ // `9007199254740992` として表示され、しかも誰も気付けない。Rust 側
630
+ // (`docker/scanner/src/agent_hooks.rs::parse_unscanned_record`) が同じ上限を
631
+ // 持ち、`tests/fixtures/unscanned-record-cases.json` が両者の一致を固定する。
632
+ const [first, last, count] = parts.map((n) => {
633
+ if (!/^\d+$/.test(n)) return null;
634
+ const v = Number(n);
635
+ return Number.isSafeInteger(v) ? v : null;
636
+ });
637
+ if (first === null || last === null || count === null || count === 0) return null;
638
+ return { first: new Date(first * 1000), last: new Date(last * 1000), count };
639
+ }
640
+
580
641
  async function cmdListAgents() {
581
642
  for (const name of AGENT_ORDER) {
582
643
  const mod = await loadAgent(name);
package/src/red-team.mjs CHANGED
@@ -16,7 +16,7 @@ import path from "node:path";
16
16
  import { execFile } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
18
 
19
- import { fetchAsset, detectPlatform } from "./binary-fetcher.mjs";
19
+ import { fetchAsset, detectPlatform, assertVerified } from "./binary-fetcher.mjs";
20
20
  import { downloadBase } from "./verify.mjs";
21
21
 
22
22
  const execFileP = promisify(execFile);
@@ -97,7 +97,13 @@ export async function fetchRedTeamBinary(
97
97
  const platform = detectPlatform();
98
98
  const rel = product === DEFAULT_RED_TEAM_PRODUCT ? platform : `${product}/${platform}`;
99
99
  const binUrl = `${downloadBase(env)}/red-team/${rel}`;
100
- const result = await fetchAsset(binUrl, destPath, { accessKey });
100
+ // 検証できなかったものは `fetchAsset` throw するので、ここに来た時点で
101
+ // 公開 SHA256 と一致している。`assertVerified` はその 2 つ目の錠で、
102
+ // 「verified を表示にしか使っていない」状態 (#1773) に戻ることを防ぐ。
103
+ const result = assertVerified(
104
+ await fetchAsset(binUrl, destPath, { accessKey }),
105
+ `the ${product} binary`,
106
+ );
101
107
  return { ...result, platform, product };
102
108
  }
103
109
 
@@ -281,7 +287,7 @@ export async function installRedTeam({
281
287
  await fs.mkdir(binDir, { recursive: true });
282
288
  const res = await fetchRedTeamBinary(binPath, { env, accessKey, product });
283
289
  stdout(
284
- `binary: ${res.path} (${res.platform}${res.verified ? ", sha256 verified" : ""})`,
290
+ `binary: ${res.path} (${res.platform}, sha256 verified)`,
285
291
  );
286
292
 
287
293
  const args = buildMcpAddArgs(binPath, accessKey, scope, meta.mcpServerName);
package/src/templates.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // only the access key and the verify endpoint differ.
5
5
 
6
6
  import { buildClaudeSettings, buildGeminiSettings } from "./merge.mjs";
7
+ import { isDevDownloadBase } from "./verify.mjs";
7
8
 
8
9
  export function getSettingsJson(accessKey, scope = "project") {
9
10
  return JSON.stringify(buildClaudeSettings(accessKey, scope), null, 2) + "\n";
@@ -15,6 +16,14 @@ export function getSettingsJson(accessKey, scope = "project") {
15
16
  // is kept for signature stability with callers and the wider TS template.
16
17
  // eslint-disable-next-line no-unused-vars
17
18
  export function getStartScannerSh(downloadBase = "https://pytd2jd5p7.execute-api.ap-northeast-1.amazonaws.com/download", agentType = "claude-code") {
19
+ // Which installer to quote back at the user, derived from the endpoint this
20
+ // script was generated against. The DEV package must never tell anyone to
21
+ // install PROD (same rule as cli.mjs's INSTALL_COMMAND_HINT) — and here the
22
+ // package name is not in scope, so the endpoint is the only evidence
23
+ // available. Anything that is not the DEV endpoint is treated as PROD.
24
+ const installCommand = isDevDownloadBase(downloadBase)
25
+ ? "npx @coworker-jp/aidr-dev install"
26
+ : "npx @coworker-jp/aidr install";
18
27
  return `#!/bin/bash
19
28
  # SessionStart hook: Setup ai-scanner for the current platform
20
29
  # - Linux (x86_64 / aarch64): download native binary from S3
@@ -23,11 +32,26 @@ export function getStartScannerSh(downloadBase = "https://pytd2jd5p7.execute-api
23
32
  # - Fail-open: never block Claude Code startup
24
33
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
25
34
  BIN_DIR="$(dirname "$BIN")"
26
- PULL_STAMP="$HOME/.claude/.ai-scanner-last-pull"
35
+ # The self-update throttle belongs to the binary it throttles, so it is derived
36
+ # from \$BIN — the one source this script already trusts for "where is my copy"
37
+ # (\`.ai-scanner-unscanned\` is placed the same way). It used to be pinned to
38
+ # \`\$HOME/.claude/\`, which made ONE stamp for ALL agents: every agent installs
39
+ # its own copy under \`\$HOME/.<agent>/bin/\`, so a Claude Code session starting
40
+ # suppressed the update check of the Cursor copy for PULL_INTERVAL seconds and
41
+ # vice versa (#1795). docs/scanner/SCANNER.md already documented it as per-agent.
42
+ PULL_STAMP="$BIN_DIR/.ai-scanner-last-pull"
27
43
  PULL_INTERVAL=300
28
44
  DOWNLOAD_BASE="${downloadBase}"
29
45
 
30
- mkdir -p "$BIN_DIR" "$HOME/.claude/quarantine" "$HOME/.claude/ai-scanner"
46
+ # Only the directory this script itself writes into (the download lands here).
47
+ # The scanner's own state directories are created by the binary at startup
48
+ # (\`main()\` -> \`paths::process_paths().create_dirs()\`, docker/scanner/src/paths.rs),
49
+ # which is the single source of truth for that layout and honours
50
+ # AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR. Creating them here as well
51
+ # was a second, hardcoded copy of that layout that ignored both overrides, and
52
+ # on a machine where the binary never arrives it left empty \`~/.claude/\`
53
+ # directories behind for agents that are not Claude Code (#1795).
54
+ mkdir -p "$BIN_DIR"
31
55
 
32
56
  # macOS has no base \`timeout\` (it's in GNU coreutils). Shim it so hooks work
33
57
  # on fresh Macs without brew install coreutils.
@@ -46,9 +70,41 @@ should_check_update() {
46
70
  [ $(($(date +%s) - last)) -ge "$PULL_INTERVAL" ]
47
71
  }
48
72
 
73
+ # True for exactly 64 hex characters — the shape of a SHA-256 digest.
74
+ is_sha256() {
75
+ [ "\${#1}" -eq 64 ] || return 1
76
+ case "$1" in *[!0-9a-fA-F]*) return 1 ;; esac
77
+ return 0
78
+ }
79
+
80
+ # Lower-case SHA-256 of $1, using whichever tool this machine has. Returns
81
+ # non-zero when it has none — which is refused below, not waved through.
82
+ sha256_of() {
83
+ local out
84
+ if command -v sha256sum >/dev/null 2>&1; then
85
+ out=$(sha256sum "$1" 2>/dev/null) || return 1
86
+ elif command -v shasum >/dev/null 2>&1; then
87
+ out=$(shasum -a 256 "$1" 2>/dev/null) || return 1
88
+ elif command -v openssl >/dev/null 2>&1; then
89
+ out=$(openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}') || return 1
90
+ else
91
+ return 1
92
+ fi
93
+ printf '%s' "\${out%% *}" | tr 'A-F' 'a-f'
94
+ }
95
+
49
96
  # Download binary from $1 to $BIN, strip quarantine (macOS), chmod +x.
50
97
  # Uses curl's native --max-time so no external \`timeout\` dependency.
51
98
  # Access key is required by the /download endpoint.
99
+ #
100
+ # FAIL-CLOSED on the checksum (#1786). This path used to install whatever came
101
+ # back, unchecked. Every other place the product obtains this binary verifies it
102
+ # — the scanner's own self-update (#319), the sentinel daemon's self-provision,
103
+ # and \`aidr install\` (#1773) — and this one is the one that runs on a machine
104
+ # whose binary already went missing once. Refusing leaves the machine in the
105
+ # same state a failed download leaves it in (no binary, and the caller says so
106
+ # out loud), which is strictly better than running bytes nobody checked.
107
+ # \`$UNVERIFIED_REASON\` carries WHY, so the message can name it.
52
108
  download_binary() {
53
109
  local url="$1"
54
110
  local tmp=$(mktemp "\${BIN_DIR}/ai-scanner.XXXXXX")
@@ -56,16 +112,67 @@ download_binary() {
56
112
  if [ -n "\${AI_SCANNER_ACCESS_KEY:-}" ]; then
57
113
  auth_args=(-H "x-access-key: \${AI_SCANNER_ACCESS_KEY}")
58
114
  fi
59
- if curl -sfL --max-time 60 "\${auth_args[@]}" -o "$tmp" "$url" 2>/dev/null; then
60
- chmod +x "$tmp"
61
- # macOS: strip Gatekeeper quarantine so unsigned binary can execute
62
- # (no-op on Linux where xattr isn't typically present)
63
- xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true
64
- mv -f "$tmp" "$BIN"
65
- return 0
115
+ if ! curl -sfL --max-time 60 "\${auth_args[@]}" -o "$tmp" "$url" 2>/dev/null; then
116
+ rm -f "$tmp"
117
+ UNVERIFIED_REASON="the download from $url did not complete"
118
+ return 1
66
119
  fi
67
- rm -f "$tmp"
68
- return 1
120
+
121
+ local want got
122
+ want=$(curl -sfL --retry 2 --max-time 30 "\${auth_args[@]}" "\${url}.sha256" 2>/dev/null \
123
+ | tr -d '\\r' | awk 'NR==1{print $1}')
124
+ if ! is_sha256 "$want"; then
125
+ rm -f "$tmp"
126
+ UNVERIFIED_REASON="no published checksum was readable at \${url}.sha256, so the download could not be checked"
127
+ return 1
128
+ fi
129
+ if ! got=$(sha256_of "$tmp"); then
130
+ rm -f "$tmp"
131
+ UNVERIFIED_REASON="this machine has none of sha256sum, shasum or openssl, so the download could not be checked"
132
+ return 1
133
+ fi
134
+ if [ "$got" != "$(printf '%s' "$want" | tr 'A-F' 'a-f')" ]; then
135
+ rm -f "$tmp"
136
+ UNVERIFIED_REASON="the download did not match its published checksum (expected $want, got $got)"
137
+ return 1
138
+ fi
139
+
140
+ chmod +x "$tmp"
141
+ # macOS: strip Gatekeeper quarantine so unsigned binary can execute
142
+ # (no-op on Linux where xattr isn't typically present)
143
+ xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true
144
+ mv -f "$tmp" "$BIN"
145
+ return 0
146
+ }
147
+
148
+ # What has already run unscanned on this machine, if anything (#1774). The scan
149
+ # hooks append to this file every time they exit without a binary; this is the
150
+ # first moment anyone can say it out loud, and it is deleted the moment a binary
151
+ # is in place so a healed machine stops claiming to be blind.
152
+ UNSCANNED_STAMP="\${BIN_DIR}/.ai-scanner-unscanned"
153
+
154
+ report_unscanned_history() {
155
+ local line first count
156
+ [ -r "$UNSCANNED_STAMP" ] || return 0
157
+ read -r line < "$UNSCANNED_STAMP" 2>/dev/null || return 0
158
+ set -- $line
159
+ first="\${1:-}"; count="\${3:-}"
160
+ case "$count" in ''|*[!0-9]*) return 0 ;; esac
161
+ # \`date -d @<epoch>\` is GNU, \`date -r <epoch>\` is BSD/macOS, and the epoch
162
+ # itself is the fallback — printing nothing here would drop the fact.
163
+ local when
164
+ when=$(date -d "@$first" 2>/dev/null || date -r "$first" 2>/dev/null || echo "epoch $first")
165
+ echo "ai-scanner: $count tool call(s) have already run unscanned on this machine (first at $when)." >&2
166
+ }
167
+
168
+ # There is no build for this platform, so this machine will not be scanned.
169
+ # Said with the next action attached, because a lone "Warning: Unsupported …"
170
+ # reads as cosmetic (docs/product/UI_COPY.md 【非交渉】) — and unlike a failed
171
+ # download, re-running the installer cannot fix it, so the action is different.
172
+ unsupported_platform() {
173
+ echo "ai-scanner: this session is NOT being scanned — there is no scanner build for this platform ($1)." >&2
174
+ report_unscanned_history
175
+ echo "ai-scanner: tool calls keep working, and they run unscanned on this machine. Contact your IT administrator with this message." >&2
69
176
  }
70
177
 
71
178
  OS=$(uname -s)
@@ -77,7 +184,7 @@ case "$OS" in
77
184
  x86_64) PLATFORM_SLUG="linux-x86_64" ;;
78
185
  aarch64|arm64) PLATFORM_SLUG="linux-aarch64" ;;
79
186
  *)
80
- echo "Warning: Unsupported Linux architecture: $ARCH (expected x86_64 or aarch64)" >&2
187
+ unsupported_platform "Linux/$ARCH"
81
188
  exit 0
82
189
  ;;
83
190
  esac
@@ -91,13 +198,13 @@ case "$OS" in
91
198
  # — only its contents became universal, never its name.
92
199
  arm64|x86_64) PLATFORM_SLUG="darwin-aarch64" ;;
93
200
  *)
94
- echo "Warning: Unsupported macOS architecture: $ARCH" >&2
201
+ unsupported_platform "macOS/$ARCH"
95
202
  exit 0
96
203
  ;;
97
204
  esac
98
205
  ;;
99
206
  *)
100
- echo "Warning: Unsupported OS for ai-scanner: $OS" >&2
207
+ unsupported_platform "$OS"
101
208
  exit 0
102
209
  ;;
103
210
  esac
@@ -105,13 +212,24 @@ esac
105
212
  # Binary missing → fetch it
106
213
  if [ ! -x "$BIN" ]; then
107
214
  if download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}"; then
108
- echo "ai-scanner binary installed (\${PLATFORM_SLUG})" >&2
215
+ echo "ai-scanner binary installed (\${PLATFORM_SLUG}, checksum verified)" >&2
216
+ rm -f "$UNSCANNED_STAMP"
109
217
  else
110
- echo "Warning: Failed to download ai-scanner binary from \${DOWNLOAD_BASE}/\${PLATFORM_SLUG}" >&2
218
+ # docs/product/UI_COPY.md 【非交渉】: the fact, stated so it cannot be
219
+ # read as harmless, and then the next action. The old single line
220
+ # ("Warning: Failed to download …") said neither what it meant for the
221
+ # session nor what to do, and it was the ONLY trace anywhere (#1774).
222
+ 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
223
+ report_unscanned_history
224
+ 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
111
225
  fi
112
226
  exit 0
113
227
  fi
114
228
 
229
+ # A binary IS in place. Clear any record of having run unscanned, so the record
230
+ # only ever describes the machine's CURRENT state.
231
+ rm -f "$UNSCANNED_STAMP"
232
+
115
233
  # Binary exists — throttled self-update check
116
234
  if should_check_update; then
117
235
  "$BIN" update 2>/dev/null || true
@@ -148,6 +266,42 @@ exit 0
148
266
  // generous enough to absorb cold-start variance while still bounding a
149
267
  // truly hung scanner. AI_SCANNER_HOOK_TIMEOUT (seconds) lets operators
150
268
  // tune further per-environment.
269
+ // A tool call ran with NO scanner in place (issue #1774).
270
+ //
271
+ // The hook still exits 0 — fail-open is non-negotiable (CLAUDE.md "Fail-open
272
+ // Design"): the scanner's own absence must never block the user's work. What
273
+ // was missing is everything AFTER that: nothing re-fetched, nothing recorded,
274
+ // nothing visible. From the outside a machine whose binary vanished looked
275
+ // exactly like a healthy one, because "healthy" also produces no output.
276
+ //
277
+ // So the fact is written down next to where the binary belongs: first time
278
+ // seen, last time seen, and how many tool calls have run unscanned. That file
279
+ // is what `start_scanner.sh` reads to tell the user at the next session start,
280
+ // and what `aidr doctor` reads on demand. `start_scanner.sh` DELETES it once a
281
+ // binary is in place, so a machine that healed stops claiming to be blind.
282
+ //
283
+ // Never fails, never blocks: every write is best-effort and the function always
284
+ // returns 0. A bookkeeping error must not become a scanning outage.
285
+ //
286
+ // Two hooks firing at once can lose a count (last write wins). That is
287
+ // accepted: this is a signal that scanning stopped, not an accounting ledger.
288
+ const UNSCANNED_NOTE = `note_unscanned() {
289
+ local dir stamp now first count line
290
+ dir="$(dirname "$BIN")"
291
+ [ -d "$dir" ] || return 0
292
+ stamp="$dir/.ai-scanner-unscanned"
293
+ now="$(date +%s 2>/dev/null)" || return 0
294
+ first="$now"; count=0
295
+ if [ -r "$stamp" ]; then
296
+ read -r line < "$stamp" 2>/dev/null || line=""
297
+ set -- $line
298
+ case "\${1:-}" in ''|*[!0-9]*) ;; *) first="$1" ;; esac
299
+ case "\${3:-}" in ''|*[!0-9]*) ;; *) count="$3" ;; esac
300
+ fi
301
+ printf '%s %s %s\\n' "$first" "$now" "$((count + 1))" > "$stamp" 2>/dev/null || true
302
+ return 0
303
+ }`;
304
+
151
305
  const SCAN_HELPERS = `run_scanner() {
152
306
  if command -v timeout >/dev/null 2>&1; then
153
307
  timeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT:-30}" "$BIN" "$@"
@@ -165,7 +319,8 @@ export const SCAN_BASH_SH = `#!/bin/bash
165
319
  # PostToolUse hook for Bash: Scan command output.
166
320
  # Thin Claude Code adapter — Rust handles cap, detection log, actor resolution.
167
321
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
168
- [ ! -x "$BIN" ] && exit 0
322
+ ${UNSCANNED_NOTE}
323
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
169
324
 
170
325
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
171
326
 
@@ -181,7 +336,8 @@ export const SCAN_BASH_INPUT_SH = `#!/bin/bash
181
336
  # PreToolUse hook for Bash: Scan command before execution.
182
337
  # Thin Claude Code adapter — Rust handles cap, detection log, actor resolution.
183
338
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
184
- [ ! -x "$BIN" ] && exit 0
339
+ ${UNSCANNED_NOTE}
340
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
185
341
 
186
342
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
187
343
 
@@ -198,7 +354,8 @@ export const SCAN_EDIT_SH = `#!/bin/bash
198
354
  # Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
199
355
  # Only the Edit-vs-MultiEdit content extraction is Claude Code specific.
200
356
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
201
- [ ! -x "$BIN" ] && exit 0
357
+ ${UNSCANNED_NOTE}
358
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
202
359
 
203
360
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
204
361
 
@@ -223,7 +380,8 @@ export const SCAN_READ_SH = `#!/bin/bash
223
380
  # PostToolUse hook for Read: Scan file content Claude just read.
224
381
  # Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
225
382
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
226
- [ ! -x "$BIN" ] && exit 0
383
+ ${UNSCANNED_NOTE}
384
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
227
385
 
228
386
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
229
387
 
@@ -242,7 +400,8 @@ export const SCAN_EDIT_OUTPUT_SH = `#!/bin/bash
242
400
  # PostToolUse hook for Edit/MultiEdit/Write: Scan final file content.
243
401
  # Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
244
402
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
245
- [ ! -x "$BIN" ] && exit 0
403
+ ${UNSCANNED_NOTE}
404
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
246
405
 
247
406
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
248
407
 
@@ -262,7 +421,8 @@ export const SCAN_READ_INPUT_SH = `#!/bin/bash
262
421
  # binary, including actor resolution, exclude-dir filtering, content cap, and
263
422
  # detection-log writing.
264
423
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
265
- [ ! -x "$BIN" ] && exit 0
424
+ ${UNSCANNED_NOTE}
425
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
266
426
 
267
427
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
268
428
 
@@ -279,7 +439,8 @@ export const SCAN_WEBFETCH_SH = `#!/bin/bash
279
439
  # PreToolUse hook for WebFetch: Scan URL before Claude fetches it.
280
440
  # Thin Claude Code adapter — Rust handles phishing scoring + YARA + detection log.
281
441
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
282
- [ ! -x "$BIN" ] && exit 0
442
+ ${UNSCANNED_NOTE}
443
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
283
444
 
284
445
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
285
446
 
@@ -295,7 +456,8 @@ export const SCAN_WEBSEARCH_SH = `#!/bin/bash
295
456
  # PostToolUse hook for WebSearch: Scan search results for phishing URLs.
296
457
  # Thin Claude Code adapter — Rust handles cap, phishing scoring, detection log.
297
458
  BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
298
- [ ! -x "$BIN" ] && exit 0
459
+ ${UNSCANNED_NOTE}
460
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
299
461
 
300
462
  export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
301
463
 
@@ -354,6 +516,8 @@ export CLAUDE_PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-.}"
354
516
  # \`timeout\` (timed out) is mapped to 0 so a hung scanner never blocks the
355
517
  # tool call. Bare macOS (no coreutils) falls through without a timeout —
356
518
  # install coreutils (\`brew install coreutils\`) to get the safety net.
519
+ ${UNSCANNED_NOTE}
520
+
357
521
  run_scanner() {
358
522
  if command -v timeout >/dev/null 2>&1; then
359
523
  timeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT}" "\${AI_SCANNER_BIN}" "$@"
@@ -388,7 +552,7 @@ EOF
388
552
  export const CURSOR_PRE_SHELL_SH = `#!/bin/bash
389
553
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
390
554
  source "$SCRIPT_DIR/env.sh"
391
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
555
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
392
556
  export AI_SCANNER_DETECTION_LOG
393
557
  jq -r '.tool_input.command // ""' | run_scanner scan bash-input
394
558
  `;
@@ -396,7 +560,7 @@ jq -r '.tool_input.command // ""' | run_scanner scan bash-input
396
560
  export const CURSOR_PRE_READ_SH = `#!/bin/bash
397
561
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
398
562
  source "$SCRIPT_DIR/env.sh"
399
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
563
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
400
564
  export AI_SCANNER_DETECTION_LOG
401
565
  FILE_PATH=$(jq -r '.tool_input.file_path // ""')
402
566
  [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
@@ -406,7 +570,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
406
570
  export const CURSOR_PRE_EDIT_SH = `#!/bin/bash
407
571
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
408
572
  source "$SCRIPT_DIR/env.sh"
409
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
573
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
410
574
  export AI_SCANNER_DETECTION_LOG
411
575
  INPUT=$(cat)
412
576
  FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
@@ -423,7 +587,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
423
587
  export const CURSOR_POST_SHELL_SH = `#!/bin/bash
424
588
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
425
589
  source "$SCRIPT_DIR/env.sh"
426
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
590
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
427
591
  export AI_SCANNER_DETECTION_LOG
428
592
  INPUT=$(cat)
429
593
  COMMAND=$(jq -r '.tool_output // ""' <<<"$INPUT" | head -1 && jq -r '.tool_input.command // ""' <<<"$INPUT")
@@ -433,7 +597,7 @@ jq -r '.tool_output // ""' <<<"$INPUT" | run_scanner scan bash-output --command
433
597
  export const CURSOR_POST_READ_SH = `#!/bin/bash
434
598
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
435
599
  source "$SCRIPT_DIR/env.sh"
436
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
600
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
437
601
  export AI_SCANNER_DETECTION_LOG
438
602
  INPUT=$(cat)
439
603
  FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
@@ -444,7 +608,7 @@ jq -r '.tool_output // ""' <<<"$INPUT" | run_scanner scan read-output --file "$F
444
608
  export const CURSOR_POST_WRITE_SH = `#!/bin/bash
445
609
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
446
610
  source "$SCRIPT_DIR/env.sh"
447
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
611
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
448
612
  export AI_SCANNER_DETECTION_LOG
449
613
  INPUT=$(cat)
450
614
  FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
@@ -475,7 +639,7 @@ return 0 2>/dev/null || true
475
639
  export const WINDSURF_PRE_COMMAND_SH = `#!/bin/bash
476
640
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
477
641
  source "$SCRIPT_DIR/ensure_scanner.sh"
478
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
642
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
479
643
  export AI_SCANNER_DETECTION_LOG
480
644
  CONTENT=$(jq -r '.tool_info.command_line // ""')
481
645
  [ -z "$CONTENT" ] || [ "$CONTENT" = "null" ] && exit 0
@@ -485,7 +649,7 @@ printf '%s' "$CONTENT" | run_scanner scan bash-input
485
649
  export const WINDSURF_PRE_READ_SH = `#!/bin/bash
486
650
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
487
651
  source "$SCRIPT_DIR/ensure_scanner.sh"
488
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
652
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
489
653
  export AI_SCANNER_DETECTION_LOG
490
654
  FILE_PATH=$(jq -r '.tool_info.file_path // ""')
491
655
  [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
@@ -495,7 +659,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
495
659
  export const WINDSURF_PRE_WRITE_SH = `#!/bin/bash
496
660
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
497
661
  source "$SCRIPT_DIR/ensure_scanner.sh"
498
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
662
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
499
663
  export AI_SCANNER_DETECTION_LOG
500
664
  INPUT=$(cat)
501
665
  FILE_PATH=$(jq -r '.tool_info.file_path // ""' <<<"$INPUT")
@@ -507,7 +671,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
507
671
  export const WINDSURF_POST_READ_SH = `#!/bin/bash
508
672
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
509
673
  source "$SCRIPT_DIR/ensure_scanner.sh"
510
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
674
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
511
675
  export AI_SCANNER_DETECTION_LOG
512
676
  FILE_PATH=$(jq -r '.tool_info.file_path // ""')
513
677
  [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
@@ -517,7 +681,7 @@ run_scanner scan read-output --file "$FILE_PATH" < "$FILE_PATH"
517
681
  export const WINDSURF_POST_WRITE_SH = `#!/bin/bash
518
682
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
519
683
  source "$SCRIPT_DIR/ensure_scanner.sh"
520
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
684
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
521
685
  export AI_SCANNER_DETECTION_LOG
522
686
  FILE_PATH=$(jq -r '.tool_info.file_path // ""')
523
687
  [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
@@ -560,7 +724,7 @@ exec "$SCRIPT_DIR/start_scanner.sh"
560
724
  export const KIRO_PRE_SHELL_SH = `#!/bin/bash
561
725
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
562
726
  source "$SCRIPT_DIR/env.sh"
563
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
727
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
564
728
  export AI_SCANNER_DETECTION_LOG
565
729
  jq -r '.tool_input.command // ""' | run_scanner scan bash-input
566
730
  `;
@@ -568,7 +732,7 @@ jq -r '.tool_input.command // ""' | run_scanner scan bash-input
568
732
  export const KIRO_PRE_READ_SH = `#!/bin/bash
569
733
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
570
734
  source "$SCRIPT_DIR/env.sh"
571
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
735
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
572
736
  export AI_SCANNER_DETECTION_LOG
573
737
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.operations[0].path // ""')
574
738
  [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
@@ -578,7 +742,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
578
742
  export const KIRO_PRE_WRITE_SH = `#!/bin/bash
579
743
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
580
744
  source "$SCRIPT_DIR/env.sh"
581
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
745
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
582
746
  export AI_SCANNER_DETECTION_LOG
583
747
  INPUT=$(cat)
584
748
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // ""' <<<"$INPUT")
@@ -590,7 +754,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
590
754
  export const KIRO_POST_SHELL_SH = `#!/bin/bash
591
755
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
592
756
  source "$SCRIPT_DIR/env.sh"
593
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
757
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
594
758
  export AI_SCANNER_DETECTION_LOG
595
759
  INPUT=$(cat)
596
760
  COMMAND=$(jq -r '.tool_input.command // ""' <<<"$INPUT")
@@ -601,7 +765,7 @@ printf '%s' "$RESPONSE" | run_scanner scan bash-output --command "$COMMAND"
601
765
  export const KIRO_POST_READ_SH = `#!/bin/bash
602
766
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
603
767
  source "$SCRIPT_DIR/env.sh"
604
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
768
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
605
769
  export AI_SCANNER_DETECTION_LOG
606
770
  INPUT=$(cat)
607
771
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.operations[0].path // ""' <<<"$INPUT")
@@ -613,7 +777,7 @@ printf '%s' "$RESPONSE" | run_scanner scan read-output --file "$FILE_PATH"
613
777
  export const KIRO_POST_WRITE_SH = `#!/bin/bash
614
778
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
615
779
  source "$SCRIPT_DIR/env.sh"
616
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
780
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
617
781
  export AI_SCANNER_DETECTION_LOG
618
782
  INPUT=$(cat)
619
783
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // ""' <<<"$INPUT")
@@ -635,7 +799,8 @@ export function getGeminiPreHookSh(binary) {
635
799
  # the run_scanner timeout helper is inlined here. See agentEnvSh for the
636
800
  # canonical version and the rationale for fail-open on exit 124.
637
801
  BIN="${binary}"
638
- [ ! -x "$BIN" ] && exit 0
802
+ ${UNSCANNED_NOTE}
803
+ [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
639
804
 
640
805
  run_scanner() {
641
806
  if command -v timeout >/dev/null 2>&1; then
@@ -705,7 +870,7 @@ export const CODEX_PRE_SHELL_SH = `#!/bin/bash
705
870
  # Codex PreToolUse Bash hook: scan command and return block JSON to Codex.
706
871
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
707
872
  source "$SCRIPT_DIR/env.sh"
708
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
873
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
709
874
  export AI_SCANNER_DETECTION_LOG
710
875
  INPUT=$(cat)
711
876
  CMD=$(jq -r '.tool_input.command // ""' <<<"$INPUT" 2>/dev/null)
@@ -735,7 +900,7 @@ export const CODEX_POST_SHELL_SH = `#!/bin/bash
735
900
  # Codex PostToolUse Bash hook: scan command output and return feedback JSON.
736
901
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
737
902
  source "$SCRIPT_DIR/env.sh"
738
- BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
903
+ BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
739
904
  export AI_SCANNER_DETECTION_LOG
740
905
  INPUT=$(cat)
741
906
  CMD=$(jq -r '.tool_input.command // ""' <<<"$INPUT" 2>/dev/null)
package/src/verify.mjs CHANGED
@@ -40,6 +40,17 @@ export function downloadBaseOverride(env = process.env) {
40
40
  return null;
41
41
  }
42
42
 
43
+ /**
44
+ * Is this download base the DEV endpoint? Used by the hook templates to quote
45
+ * the right installer back at the user (the DEV package must never tell anyone
46
+ * to install PROD). Derived from `ENDPOINTS` rather than matched against a
47
+ * literal, so changing the DEV endpoint cannot leave a second, stale copy of
48
+ * that decision behind.
49
+ */
50
+ export function isDevDownloadBase(base = "") {
51
+ return typeof base === "string" && base.startsWith(ENDPOINTS.dev);
52
+ }
53
+
43
54
  export function downloadBase(env = "prod") {
44
55
  const override = downloadBaseOverride();
45
56
  if (override) return `${override}/download`;