@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 +1 -1
- package/src/agents/claude.mjs +9 -1
- package/src/agents/codex.mjs +9 -1
- package/src/agents/cursor.mjs +9 -1
- package/src/agents/gemini.mjs +9 -1
- package/src/agents/kiro.mjs +9 -1
- package/src/agents/standalone.mjs +9 -1
- package/src/agents/windsurf.mjs +9 -1
- package/src/binary-fetcher.mjs +154 -18
- package/src/cli.mjs +68 -7
- package/src/red-team.mjs +9 -3
- package/src/templates.mjs +209 -44
- package/src/verify.mjs +11 -0
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -13,7 +13,15 @@ const SCRIPT_FILES = [
|
|
|
13
13
|
"scan_webfetch.sh", "scan_websearch.sh",
|
|
14
14
|
"log_web_urls.sh",
|
|
15
15
|
];
|
|
16
|
-
|
|
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",
|
package/src/agents/codex.mjs
CHANGED
|
@@ -27,7 +27,15 @@ const SCRIPT_FILES = [
|
|
|
27
27
|
"pre_shell.sh",
|
|
28
28
|
"post_shell.sh",
|
|
29
29
|
];
|
|
30
|
-
|
|
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 = {
|
package/src/agents/cursor.mjs
CHANGED
|
@@ -28,7 +28,15 @@ const SCRIPT_FILES = [
|
|
|
28
28
|
"post_read.sh",
|
|
29
29
|
"post_write.sh",
|
|
30
30
|
];
|
|
31
|
-
|
|
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",
|
package/src/agents/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/src/agents/kiro.mjs
CHANGED
|
@@ -18,7 +18,15 @@ const SCRIPT_FILES = [
|
|
|
18
18
|
"post_read.sh",
|
|
19
19
|
"post_write.sh",
|
|
20
20
|
];
|
|
21
|
-
|
|
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
|
-
|
|
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",
|
package/src/agents/windsurf.mjs
CHANGED
|
@@ -25,7 +25,15 @@ const SCRIPT_FILES = [
|
|
|
25
25
|
"post_read.sh",
|
|
26
26
|
"post_write.sh",
|
|
27
27
|
];
|
|
28
|
-
|
|
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",
|
package/src/binary-fetcher.mjs
CHANGED
|
@@ -65,11 +65,117 @@ async function fetchBuffer(url, accessKey) {
|
|
|
65
65
|
return Buffer.from(ab);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
86
|
-
* sibling `.sha256`
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
unsupported_platform "macOS/$ARCH"
|
|
95
202
|
exit 0
|
|
96
203
|
;;
|
|
97
204
|
esac
|
|
98
205
|
;;
|
|
99
206
|
*)
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`;
|