@coworker-jp/aidr 0.1.290 → 0.1.293
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 +5 -1
- package/src/agents/codex.mjs +5 -1
- package/src/agents/cursor.mjs +5 -1
- package/src/agents/gemini.mjs +5 -1
- package/src/agents/kiro.mjs +5 -1
- package/src/agents/standalone.mjs +5 -1
- package/src/agents/windsurf.mjs +5 -1
- package/src/binary-fetcher.mjs +215 -26
- package/src/cli.mjs +124 -8
- package/src/install-status.mjs +114 -0
- package/src/red-team.mjs +9 -3
- package/src/templates.mjs +192 -42
- package/src/verify.mjs +39 -0
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -13,7 +13,11 @@ const SCRIPT_FILES = [
|
|
|
13
13
|
"scan_webfetch.sh", "scan_websearch.sh",
|
|
14
14
|
"log_web_urls.sh",
|
|
15
15
|
];
|
|
16
|
-
|
|
16
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
17
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
18
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
19
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
20
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
17
21
|
|
|
18
22
|
export const meta = {
|
|
19
23
|
name: "claude",
|
package/src/agents/codex.mjs
CHANGED
|
@@ -27,7 +27,11 @@ const SCRIPT_FILES = [
|
|
|
27
27
|
"pre_shell.sh",
|
|
28
28
|
"post_shell.sh",
|
|
29
29
|
];
|
|
30
|
-
|
|
30
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
31
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
32
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
33
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
34
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
31
35
|
const ROOT_FILES = ["AGENTS.md"];
|
|
32
36
|
|
|
33
37
|
export const meta = {
|
package/src/agents/cursor.mjs
CHANGED
|
@@ -28,7 +28,11 @@ const SCRIPT_FILES = [
|
|
|
28
28
|
"post_read.sh",
|
|
29
29
|
"post_write.sh",
|
|
30
30
|
];
|
|
31
|
-
|
|
31
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
32
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
33
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
34
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
35
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
32
36
|
|
|
33
37
|
export const meta = {
|
|
34
38
|
name: "cursor",
|
package/src/agents/gemini.mjs
CHANGED
|
@@ -5,7 +5,11 @@ 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
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
9
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
10
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
11
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
12
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
9
13
|
|
|
10
14
|
export const meta = {
|
|
11
15
|
name: "gemini",
|
package/src/agents/kiro.mjs
CHANGED
|
@@ -18,7 +18,11 @@ const SCRIPT_FILES = [
|
|
|
18
18
|
"post_read.sh",
|
|
19
19
|
"post_write.sh",
|
|
20
20
|
];
|
|
21
|
-
|
|
21
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
22
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
23
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
24
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
25
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
22
26
|
|
|
23
27
|
export const meta = {
|
|
24
28
|
name: "kiro",
|
|
@@ -2,7 +2,11 @@ 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
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
6
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
7
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
8
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
9
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
6
10
|
|
|
7
11
|
export const meta = {
|
|
8
12
|
name: "standalone",
|
package/src/agents/windsurf.mjs
CHANGED
|
@@ -25,7 +25,11 @@ const SCRIPT_FILES = [
|
|
|
25
25
|
"post_read.sh",
|
|
26
26
|
"post_write.sh",
|
|
27
27
|
];
|
|
28
|
-
|
|
28
|
+
// `.ai-scanner-unscanned` は hook が「無検査でツールコールが通った」ことを
|
|
29
|
+
// 記録するファイル (#1774)。**消す対象に入れておくこと** —— 残ると bin
|
|
30
|
+
// ディレクトリが空にならず `rmdirIfEmpty` が効かないので、アンインストール後に
|
|
31
|
+
// ディレクトリと記録が居座る。7 エージェントすべて同じ表を持つ。
|
|
32
|
+
const BIN_FILES = ["ai-scanner", "opengrep", ".ai-scanner-unscanned"];
|
|
29
33
|
|
|
30
34
|
export const meta = {
|
|
31
35
|
name: "windsurf",
|
package/src/binary-fetcher.mjs
CHANGED
|
@@ -40,19 +40,142 @@ export function detectOpengrepPlatform() {
|
|
|
40
40
|
throw new Error(`Unsupported platform for Opengrep: ${os}/${arch}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* An HTTP status the download endpoint actually returned, carried on the error
|
|
45
|
+
* so callers can tell "this release does not exist" (404) apart from "the key
|
|
46
|
+
* was rejected" (403), "the service is unwell" (5xx) and "the network died"
|
|
47
|
+
* (no status at all). A plain `Error` with the status only in its message forces
|
|
48
|
+
* callers to regex-match prose, which is how a credential problem ends up
|
|
49
|
+
* being handled as a release problem.
|
|
50
|
+
*/
|
|
51
|
+
export class HttpStatusError extends Error {
|
|
52
|
+
constructor(url, status) {
|
|
53
|
+
super(`GET ${url} -> HTTP ${status}`);
|
|
54
|
+
this.name = "HttpStatusError";
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.url = url;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
async function fetchBuffer(url, accessKey) {
|
|
44
61
|
const headers = accessKey ? { "x-access-key": accessKey } : {};
|
|
45
62
|
const res = await fetch(url, { redirect: "follow", headers });
|
|
46
|
-
if (!res.ok) throw new
|
|
63
|
+
if (!res.ok) throw new HttpStatusError(url, res.status);
|
|
47
64
|
const ab = await res.arrayBuffer();
|
|
48
65
|
return Buffer.from(ab);
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
|
|
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 } = {}) {
|
|
52
120
|
const headers = accessKey ? { "x-access-key": accessKey } : {};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
);
|
|
56
179
|
}
|
|
57
180
|
|
|
58
181
|
async function clearQuarantine(destPath) {
|
|
@@ -65,28 +188,53 @@ async function clearQuarantine(destPath) {
|
|
|
65
188
|
}
|
|
66
189
|
|
|
67
190
|
/**
|
|
68
|
-
* Download an asset from `binUrl` into `destPath`, validating
|
|
69
|
-
* sibling `.sha256`
|
|
70
|
-
* 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.
|
|
71
196
|
*/
|
|
72
|
-
export async function fetchAsset(binUrl, destPath, { accessKey } = {}) {
|
|
197
|
+
export async function fetchAsset(binUrl, destPath, { accessKey, checksumOpts } = {}) {
|
|
73
198
|
const shaUrl = `${binUrl}.sha256`;
|
|
74
199
|
await fsp.mkdir(path.dirname(destPath), { recursive: true });
|
|
75
200
|
|
|
76
201
|
const buf = await fetchBuffer(binUrl, accessKey);
|
|
77
202
|
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
|
|
78
203
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
);
|
|
89
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}`);
|
|
236
|
+
}
|
|
237
|
+
const verified = true;
|
|
90
238
|
|
|
91
239
|
const tmp = `${destPath}.tmp-${process.pid}`;
|
|
92
240
|
await fsp.writeFile(tmp, buf);
|
|
@@ -100,16 +248,57 @@ export async function fetchAsset(binUrl, destPath, { accessKey } = {}) {
|
|
|
100
248
|
|
|
101
249
|
/**
|
|
102
250
|
* Download ai-scanner for the current platform into `destPath`.
|
|
103
|
-
*
|
|
251
|
+
*
|
|
252
|
+
* TWO KEYS, ONE FALLBACK (issue #1687)
|
|
253
|
+
* ------------------------------------
|
|
254
|
+
* The PROD npm package pins its own version, so it asks for `v{X.Y.Z}/{platform}`.
|
|
255
|
+
* That key exists only once the release's 4-platform upload has actually landed
|
|
256
|
+
* in S3 — and the version bump and the upload fire on the same push with no
|
|
257
|
+
* ordering between them, so for a window (7.8 hours on 2026-08-22's v0.1.289)
|
|
258
|
+
* the pinned key is a definite 404 while the *unpinned* key
|
|
259
|
+
* `{platform}` — the one the verify Lambda gates, which serves the last
|
|
260
|
+
* fully-published release (#176 / #387) — is serving a working binary the whole
|
|
261
|
+
* time. Without a fallback the client is the only party that does not know this.
|
|
262
|
+
*
|
|
263
|
+
* ONLY a definite 404 falls back:
|
|
264
|
+
* - 403 means the access key was rejected. The unpinned key would reject it
|
|
265
|
+
* too, and retrying would blur a credential problem into a release problem.
|
|
266
|
+
* - 5xx / network failure is NOT evidence of absence — same rule, and the same
|
|
267
|
+
* reasoning, as the release-readiness gate's fail-open
|
|
268
|
+
* (`aidr_common/release_publication.py`: "Only a *definite* not-found
|
|
269
|
+
* suppresses the version").
|
|
270
|
+
* The pin itself is NOT weakened: a healthy pinned fetch is used as-is and never
|
|
271
|
+
* consults the unpinned key. The pin exists so a machine installing during a
|
|
272
|
+
* release gets the release it asked for; the fallback only covers the case where
|
|
273
|
+
* that release provably has no bytes behind it.
|
|
274
|
+
*
|
|
275
|
+
* Returns { path, platform, sha256, verified, requestedVersion, servedUnpinned }.
|
|
276
|
+
* `servedUnpinned: true` means the caller must NOT claim the pinned version was
|
|
277
|
+
* installed — what landed is whatever the endpoint currently publishes.
|
|
104
278
|
*/
|
|
105
|
-
export async function fetchBinary(destPath, { env = "prod", accessKey, version } = {}) {
|
|
279
|
+
export async function fetchBinary(destPath, { env = "prod", accessKey, version, baseUrl } = {}) {
|
|
106
280
|
const platform = detectPlatform();
|
|
107
|
-
const base = downloadBase(env);
|
|
108
|
-
const
|
|
109
|
-
|
|
281
|
+
const base = baseUrl || downloadBase(env);
|
|
282
|
+
const unpinnedUrl = `${base}/${platform}`;
|
|
283
|
+
|
|
284
|
+
if (!version) {
|
|
285
|
+
const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
|
|
286
|
+
return { ...result, platform, requestedVersion: null, servedUnpinned: false };
|
|
287
|
+
}
|
|
110
288
|
|
|
111
|
-
|
|
112
|
-
|
|
289
|
+
try {
|
|
290
|
+
const result = await fetchAsset(`${base}/v${version}/${platform}`, destPath, { accessKey });
|
|
291
|
+
return { ...result, platform, requestedVersion: version, servedUnpinned: false };
|
|
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.
|
|
298
|
+
if (e?.status !== 404) throw e;
|
|
299
|
+
const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
|
|
300
|
+
return { ...result, platform, requestedVersion: version, servedUnpinned: true };
|
|
301
|
+
}
|
|
113
302
|
}
|
|
114
303
|
|
|
115
304
|
// Pinned Opengrep release used by aidr. Must be updated in lockstep with
|
package/src/cli.mjs
CHANGED
|
@@ -10,10 +10,11 @@ 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";
|
|
17
|
+
import { formatMissingBinaryStatus, formatPartialBinaryStatus } from "./install-status.mjs";
|
|
17
18
|
|
|
18
19
|
// Package identity — read at module load from the bundled package.json.
|
|
19
20
|
// The PROD publish workflow renames the package to `@coworker-jp/aidr`;
|
|
@@ -31,6 +32,11 @@ const IS_PROD_PACKAGE = PKG.name === "@coworker-jp/aidr";
|
|
|
31
32
|
const PINNED_VERSION = IS_PROD_PACKAGE ? PKG.version : undefined;
|
|
32
33
|
const DEFAULT_ENV = IS_PROD_PACKAGE ? "prod" : "dev";
|
|
33
34
|
|
|
35
|
+
// The command to quote back at the user when they have to re-run. Built from
|
|
36
|
+
// the package that is actually running, so the DEV package never tells anyone
|
|
37
|
+
// to install PROD.
|
|
38
|
+
const INSTALL_COMMAND_HINT = `npx ${PKG.name} install --agent <agents> --key <access key>`;
|
|
39
|
+
|
|
34
40
|
const ACCESS_KEY_RE = /^ak_[A-Za-z0-9_-]{43}$/;
|
|
35
41
|
|
|
36
42
|
function validateKey(key) {
|
|
@@ -164,6 +170,12 @@ async function cmdInstall(opts) {
|
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
172
|
|
|
173
|
+
// Outcome of the binary step, read again at the end of the install to write
|
|
174
|
+
// the closing status. `needed` distinguishes "no binary was asked for"
|
|
175
|
+
// (--skip-binary, stub-only agent set) from "a binary was asked for and did
|
|
176
|
+
// not arrive" — the two must never print the same closing line.
|
|
177
|
+
const binaryOutcome = { needed: false, scannerInstalled: false, error: null };
|
|
178
|
+
|
|
167
179
|
if (!opts.skipBinary && !opts.dryRun) {
|
|
168
180
|
const agentBinDirs = [];
|
|
169
181
|
for (const name of agents) {
|
|
@@ -175,6 +187,7 @@ async function cmdInstall(opts) {
|
|
|
175
187
|
if (agentBinDirs.length === 0) {
|
|
176
188
|
console.error("no agents with binary support selected; skipping binary fetch");
|
|
177
189
|
} else {
|
|
190
|
+
binaryOutcome.needed = true;
|
|
178
191
|
try {
|
|
179
192
|
const [primaryDir, ...restDirs] = agentBinDirs;
|
|
180
193
|
const fetchOpts = {
|
|
@@ -187,12 +200,30 @@ async function cmdInstall(opts) {
|
|
|
187
200
|
// dir so ai-scanner's sibling-lookup finds opengrep without relying on
|
|
188
201
|
// PATH. Users can point AI_SCANNER_OPENGREP elsewhere to substitute
|
|
189
202
|
// their own LGPL-2.1 build.
|
|
190
|
-
const scannerRes =
|
|
191
|
-
|
|
203
|
+
const scannerRes = assertVerified(
|
|
204
|
+
await fetchBinary(path.join(primaryDir, "ai-scanner"), fetchOpts),
|
|
205
|
+
"the ai-scanner binary",
|
|
206
|
+
);
|
|
207
|
+
binaryOutcome.scannerInstalled = true;
|
|
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)`);
|
|
213
|
+
if (scannerRes.servedUnpinned) {
|
|
214
|
+
// Say what was asked for, what arrived, and how to check — the pinned
|
|
215
|
+
// build genuinely has no bytes behind it, but protection IS in place,
|
|
216
|
+
// so this is a note, not an alarm.
|
|
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.`);
|
|
218
|
+
console.error(`note: the currently published build was installed instead, so scanning is active. Check which build you have with: ${scannerRes.path} version`);
|
|
219
|
+
}
|
|
192
220
|
// Opengrep is fetched unmodified from its public upstream GitHub
|
|
193
221
|
// release (LGPL-2.1); no access-key gating is needed here.
|
|
194
|
-
const opengrepRes =
|
|
195
|
-
|
|
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)`);
|
|
196
227
|
|
|
197
228
|
const copyOps = restDirs.flatMap(restDir =>
|
|
198
229
|
["ai-scanner", "opengrep"].map(async (file) => {
|
|
@@ -226,8 +257,9 @@ async function cmdInstall(opts) {
|
|
|
226
257
|
);
|
|
227
258
|
await Promise.all(copyOps);
|
|
228
259
|
} catch (e) {
|
|
260
|
+
binaryOutcome.error = e;
|
|
229
261
|
console.error(`binary fetch failed: ${e.message}`);
|
|
230
|
-
if (!opts.binaryOnly) console.error("continuing with hook install
|
|
262
|
+
if (!opts.binaryOnly) console.error("continuing with hook install; the closing status below says what this machine is protected by.");
|
|
231
263
|
else process.exit(1);
|
|
232
264
|
}
|
|
233
265
|
}
|
|
@@ -248,7 +280,14 @@ async function cmdInstall(opts) {
|
|
|
248
280
|
for (const bp of backups) {
|
|
249
281
|
console.log(`[${name}] existing file backed up to ${bp}`);
|
|
250
282
|
}
|
|
251
|
-
|
|
283
|
+
// Derived, never declared: an agent can re-fetch a missing binary at the
|
|
284
|
+
// start of its next session iff its install actually wrote a
|
|
285
|
+
// start_scanner.sh (templates.mjs — it pulls from the UNPINNED key). A
|
|
286
|
+
// boolean on `meta` would rot the first time an agent gains or loses that
|
|
287
|
+
// script; reading the written file list cannot.
|
|
288
|
+
const selfHeals = Boolean(res && Array.isArray(res.installed)
|
|
289
|
+
&& res.installed.some((p) => path.basename(p) === "start_scanner.sh"));
|
|
290
|
+
return { name, displayName: mod.meta.displayName || name, selfHeals, needsBinary: Boolean(mod.meta.agentDir) };
|
|
252
291
|
} catch (e) {
|
|
253
292
|
console.error(`[${name}] install failed: ${e.message}`);
|
|
254
293
|
process.exit(1);
|
|
@@ -306,6 +345,41 @@ async function cmdInstall(opts) {
|
|
|
306
345
|
console.log("Click Allow — the scanner uses this to collect endpoint info (login items).");
|
|
307
346
|
}
|
|
308
347
|
}
|
|
348
|
+
|
|
349
|
+
// ── Closing status (issue #1687) ──────────────────────────────────────
|
|
350
|
+
// LAST, on purpose. The per-agent lines above all say "installed", and a
|
|
351
|
+
// warning printed before them scrolls out of sight on a real terminal. If a
|
|
352
|
+
// binary was asked for and did not arrive, the run ends by saying so.
|
|
353
|
+
if (binaryOutcome.needed && binaryOutcome.error) {
|
|
354
|
+
const reason = binaryOutcome.error.message;
|
|
355
|
+
if (binaryOutcome.scannerInstalled) {
|
|
356
|
+
console.error(formatPartialBinaryStatus({ reason, installCommand: INSTALL_COMMAND_HINT }));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const needBinary = installResults.filter((r) => r.needsBinary);
|
|
360
|
+
const selfHealing = needBinary.filter((r) => r.selfHeals).map((r) => r.displayName);
|
|
361
|
+
const stranded = needBinary.filter((r) => !r.selfHeals).map((r) => r.displayName);
|
|
362
|
+
const binPaths = (await Promise.all(needBinary.map(async (r) => {
|
|
363
|
+
const mod = await loadAgent(r.name);
|
|
364
|
+
return mod.meta.agentDir ? path.join(home, mod.meta.agentDir, "bin", "ai-scanner") : null;
|
|
365
|
+
}))).filter(Boolean);
|
|
366
|
+
console.error(formatMissingBinaryStatus({
|
|
367
|
+
reason, selfHealing, stranded, binPaths, installCommand: INSTALL_COMMAND_HINT,
|
|
368
|
+
}));
|
|
369
|
+
// EXIT CODE. Default installs stay 0 when every agent that needs the
|
|
370
|
+
// binary can fetch it itself at the next session start: the hooks really
|
|
371
|
+
// are installed, they really do self-heal, and turning that red would
|
|
372
|
+
// break scripted installs for a condition that usually resolves on its
|
|
373
|
+
// own. But when at least one installed agent needs the binary and has NO
|
|
374
|
+
// self-heal path (gemini's hook is `[ ! -x "$BIN" ] && exit 0`; standalone
|
|
375
|
+
// has no hooks at all), nothing on this machine will ever fetch it again.
|
|
376
|
+
// Exiting 0 there is the CLI asserting an install that did not happen, so
|
|
377
|
+
// it exits 1. Verified against the consumers in this repo: both smoke
|
|
378
|
+
// harnesses (tests/smoke/aidr-matrix.sh — `--skip-binary`;
|
|
379
|
+
// tests/smoke/aidr-upgrade.sh — `--agent claude`, self-healing) are
|
|
380
|
+
// unaffected in either direction.
|
|
381
|
+
if (stranded.length > 0) process.exit(1);
|
|
382
|
+
}
|
|
309
383
|
}
|
|
310
384
|
|
|
311
385
|
async function cmdUninstall(opts) {
|
|
@@ -506,13 +580,55 @@ async function cmdDoctor() {
|
|
|
506
580
|
await fs.access(bin, fs.constants.F_OK);
|
|
507
581
|
present = true;
|
|
508
582
|
} catch {}
|
|
509
|
-
|
|
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");
|
|
510
605
|
}));
|
|
511
606
|
for (const line of lines) {
|
|
512
607
|
if (line) console.log(line);
|
|
513
608
|
}
|
|
514
609
|
}
|
|
515
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
|
+
const [first, last, count] = parts.map((n) => (/^\d+$/.test(n) ? Number(n) : null));
|
|
628
|
+
if (first === null || last === null || count === null || count === 0) return null;
|
|
629
|
+
return { first: new Date(first * 1000), last: new Date(last * 1000), count };
|
|
630
|
+
}
|
|
631
|
+
|
|
516
632
|
async function cmdListAgents() {
|
|
517
633
|
for (const name of AGENT_ORDER) {
|
|
518
634
|
const mod = await loadAgent(name);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Closing status for `aidr install` — the last thing the user reads.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS A SEPARATE, TESTABLE MODULE (issue #1687)
|
|
4
|
+
// ----------------------------------------------------
|
|
5
|
+
// Before this existed, an install that obtained no scanner binary printed one
|
|
6
|
+
// warning line and then `continuing with hook install`, and exited 0. Forty
|
|
7
|
+
// lines of per-agent output later, the run looked exactly like a successful
|
|
8
|
+
// one. The machine was configured and unprotected at the same time, and the
|
|
9
|
+
// only sentence about it had scrolled away.
|
|
10
|
+
//
|
|
11
|
+
// docs/product/UI_COPY.md, two 【非交渉】 rules, both of which apply here:
|
|
12
|
+
// 1. A failure sentence never ends at the fact. It carries the next action.
|
|
13
|
+
// Rewriting it into a success sentence is explicitly forbidden.
|
|
14
|
+
// 2. "Unknown" must not read as "safe", and must not be defended with a
|
|
15
|
+
// negation ("this does not mean ..."). The uncertainty is stated on the
|
|
16
|
+
// fact side instead.
|
|
17
|
+
// Both are load-bearing here: whether the hooks' own download succeeds at the
|
|
18
|
+
// next session start is genuinely unknown at install time, and saying nothing
|
|
19
|
+
// about it is what made this defect ship.
|
|
20
|
+
|
|
21
|
+
const RULE = "─".repeat(72);
|
|
22
|
+
|
|
23
|
+
function bulletList(names) {
|
|
24
|
+
return names.join(", ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The machine has no scanner binary. `selfHealing` are the installed agents
|
|
29
|
+
* whose hooks re-try the download at the start of their next session
|
|
30
|
+
* (start_scanner.sh, unpinned key); `stranded` are the installed agents that
|
|
31
|
+
* need the binary and have no such retry — for them nothing on this machine
|
|
32
|
+
* will ever fetch it again on its own.
|
|
33
|
+
*/
|
|
34
|
+
export function formatMissingBinaryStatus({
|
|
35
|
+
reason,
|
|
36
|
+
selfHealing = [],
|
|
37
|
+
stranded = [],
|
|
38
|
+
binPaths = [],
|
|
39
|
+
installCommand = "npx @coworker-jp/aidr install ...",
|
|
40
|
+
}) {
|
|
41
|
+
const lines = [
|
|
42
|
+
"",
|
|
43
|
+
RULE,
|
|
44
|
+
"This machine is NOT being scanned yet — the ai-scanner binary is not in place.",
|
|
45
|
+
"",
|
|
46
|
+
` what stopped it: ${reason}`,
|
|
47
|
+
"",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (selfHealing.length > 0) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`The hooks for ${bulletList(selfHealing)} are installed, and they try to`,
|
|
53
|
+
"download the binary once at the start of the next session.",
|
|
54
|
+
"Whether that download will succeed is unknown from here: nothing on this",
|
|
55
|
+
"machine has checked it yet, and until it lands, tool calls are unscanned.",
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (stranded.length > 0) {
|
|
60
|
+
lines.push(
|
|
61
|
+
`${bulletList(stranded)} will not re-try: the hooks for those agents exit`,
|
|
62
|
+
"without scanning while the binary is missing, and nothing on this machine",
|
|
63
|
+
"fetches it again.",
|
|
64
|
+
"Re-running this installer is the only thing that puts it in place.",
|
|
65
|
+
"",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push("Do this next:");
|
|
70
|
+
lines.push(` 1. Re-run the installer once the network and the access key are working:`);
|
|
71
|
+
lines.push(` ${installCommand}`);
|
|
72
|
+
if (binPaths.length > 0) {
|
|
73
|
+
lines.push(" 2. Confirm the binary is there afterwards:");
|
|
74
|
+
for (const p of binPaths) lines.push(` ls -l ${p}`);
|
|
75
|
+
lines.push(" 3. If it is still missing, contact your IT administrator and show them");
|
|
76
|
+
lines.push(" the 'binary fetch failed' line above.");
|
|
77
|
+
} else {
|
|
78
|
+
lines.push(" 2. If it still does not arrive, contact your IT administrator and show");
|
|
79
|
+
lines.push(" them the 'binary fetch failed' line above.");
|
|
80
|
+
}
|
|
81
|
+
lines.push(RULE);
|
|
82
|
+
lines.push("");
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The scanner binary IS in place, but a later step of the binary install threw
|
|
88
|
+
* (the bundled Opengrep engine, or the copy into the other agents' bin dirs).
|
|
89
|
+
* Protection exists, so this must not be dressed up as an outage — but the
|
|
90
|
+
* install is incomplete and saying "installed" alone would hide that.
|
|
91
|
+
*/
|
|
92
|
+
export function formatPartialBinaryStatus({
|
|
93
|
+
reason,
|
|
94
|
+
installCommand = "npx @coworker-jp/aidr install ...",
|
|
95
|
+
}) {
|
|
96
|
+
return [
|
|
97
|
+
"",
|
|
98
|
+
RULE,
|
|
99
|
+
"Scanning is active on this machine, but the binary step did not finish.",
|
|
100
|
+
"",
|
|
101
|
+
` what stopped it: ${reason}`,
|
|
102
|
+
"",
|
|
103
|
+
"Which agents received their own copy of the scanner, and whether the",
|
|
104
|
+
"code-analysis engine (opengrep) is present, is unknown from here. While",
|
|
105
|
+
"opengrep is absent the code-analysis rules stay switched off.",
|
|
106
|
+
"",
|
|
107
|
+
"Do this next:",
|
|
108
|
+
` 1. Re-run the installer to complete it: ${installCommand}`,
|
|
109
|
+
" 2. If it stops at the same place, contact your IT administrator and show",
|
|
110
|
+
" them the 'binary fetch failed' line above.",
|
|
111
|
+
RULE,
|
|
112
|
+
"",
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
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
|
|
@@ -46,9 +55,41 @@ should_check_update() {
|
|
|
46
55
|
[ $(($(date +%s) - last)) -ge "$PULL_INTERVAL" ]
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
# True for exactly 64 hex characters — the shape of a SHA-256 digest.
|
|
59
|
+
is_sha256() {
|
|
60
|
+
[ "\${#1}" -eq 64 ] || return 1
|
|
61
|
+
case "$1" in *[!0-9a-fA-F]*) return 1 ;; esac
|
|
62
|
+
return 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Lower-case SHA-256 of $1, using whichever tool this machine has. Returns
|
|
66
|
+
# non-zero when it has none — which is refused below, not waved through.
|
|
67
|
+
sha256_of() {
|
|
68
|
+
local out
|
|
69
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
70
|
+
out=$(sha256sum "$1" 2>/dev/null) || return 1
|
|
71
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
72
|
+
out=$(shasum -a 256 "$1" 2>/dev/null) || return 1
|
|
73
|
+
elif command -v openssl >/dev/null 2>&1; then
|
|
74
|
+
out=$(openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}') || return 1
|
|
75
|
+
else
|
|
76
|
+
return 1
|
|
77
|
+
fi
|
|
78
|
+
printf '%s' "\${out%% *}" | tr 'A-F' 'a-f'
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
# Download binary from $1 to $BIN, strip quarantine (macOS), chmod +x.
|
|
50
82
|
# Uses curl's native --max-time so no external \`timeout\` dependency.
|
|
51
83
|
# Access key is required by the /download endpoint.
|
|
84
|
+
#
|
|
85
|
+
# FAIL-CLOSED on the checksum (#1786). This path used to install whatever came
|
|
86
|
+
# back, unchecked. Every other place the product obtains this binary verifies it
|
|
87
|
+
# — the scanner's own self-update (#319), the sentinel daemon's self-provision,
|
|
88
|
+
# and \`aidr install\` (#1773) — and this one is the one that runs on a machine
|
|
89
|
+
# whose binary already went missing once. Refusing leaves the machine in the
|
|
90
|
+
# same state a failed download leaves it in (no binary, and the caller says so
|
|
91
|
+
# out loud), which is strictly better than running bytes nobody checked.
|
|
92
|
+
# \`$UNVERIFIED_REASON\` carries WHY, so the message can name it.
|
|
52
93
|
download_binary() {
|
|
53
94
|
local url="$1"
|
|
54
95
|
local tmp=$(mktemp "\${BIN_DIR}/ai-scanner.XXXXXX")
|
|
@@ -56,16 +97,67 @@ download_binary() {
|
|
|
56
97
|
if [ -n "\${AI_SCANNER_ACCESS_KEY:-}" ]; then
|
|
57
98
|
auth_args=(-H "x-access-key: \${AI_SCANNER_ACCESS_KEY}")
|
|
58
99
|
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
|
|
100
|
+
if ! curl -sfL --max-time 60 "\${auth_args[@]}" -o "$tmp" "$url" 2>/dev/null; then
|
|
101
|
+
rm -f "$tmp"
|
|
102
|
+
UNVERIFIED_REASON="the download from $url did not complete"
|
|
103
|
+
return 1
|
|
66
104
|
fi
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
|
|
106
|
+
local want got
|
|
107
|
+
want=$(curl -sfL --retry 2 --max-time 30 "\${auth_args[@]}" "\${url}.sha256" 2>/dev/null \
|
|
108
|
+
| tr -d '\\r' | awk 'NR==1{print $1}')
|
|
109
|
+
if ! is_sha256 "$want"; then
|
|
110
|
+
rm -f "$tmp"
|
|
111
|
+
UNVERIFIED_REASON="no published checksum was readable at \${url}.sha256, so the download could not be checked"
|
|
112
|
+
return 1
|
|
113
|
+
fi
|
|
114
|
+
if ! got=$(sha256_of "$tmp"); then
|
|
115
|
+
rm -f "$tmp"
|
|
116
|
+
UNVERIFIED_REASON="this machine has none of sha256sum, shasum or openssl, so the download could not be checked"
|
|
117
|
+
return 1
|
|
118
|
+
fi
|
|
119
|
+
if [ "$got" != "$(printf '%s' "$want" | tr 'A-F' 'a-f')" ]; then
|
|
120
|
+
rm -f "$tmp"
|
|
121
|
+
UNVERIFIED_REASON="the download did not match its published checksum (expected $want, got $got)"
|
|
122
|
+
return 1
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
chmod +x "$tmp"
|
|
126
|
+
# macOS: strip Gatekeeper quarantine so unsigned binary can execute
|
|
127
|
+
# (no-op on Linux where xattr isn't typically present)
|
|
128
|
+
xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true
|
|
129
|
+
mv -f "$tmp" "$BIN"
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# What has already run unscanned on this machine, if anything (#1774). The scan
|
|
134
|
+
# hooks append to this file every time they exit without a binary; this is the
|
|
135
|
+
# first moment anyone can say it out loud, and it is deleted the moment a binary
|
|
136
|
+
# is in place so a healed machine stops claiming to be blind.
|
|
137
|
+
UNSCANNED_STAMP="\${BIN_DIR}/.ai-scanner-unscanned"
|
|
138
|
+
|
|
139
|
+
report_unscanned_history() {
|
|
140
|
+
local line first count
|
|
141
|
+
[ -r "$UNSCANNED_STAMP" ] || return 0
|
|
142
|
+
read -r line < "$UNSCANNED_STAMP" 2>/dev/null || return 0
|
|
143
|
+
set -- $line
|
|
144
|
+
first="\${1:-}"; count="\${3:-}"
|
|
145
|
+
case "$count" in ''|*[!0-9]*) return 0 ;; esac
|
|
146
|
+
# \`date -d @<epoch>\` is GNU, \`date -r <epoch>\` is BSD/macOS, and the epoch
|
|
147
|
+
# itself is the fallback — printing nothing here would drop the fact.
|
|
148
|
+
local when
|
|
149
|
+
when=$(date -d "@$first" 2>/dev/null || date -r "$first" 2>/dev/null || echo "epoch $first")
|
|
150
|
+
echo "ai-scanner: $count tool call(s) have already run unscanned on this machine (first at $when)." >&2
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# There is no build for this platform, so this machine will not be scanned.
|
|
154
|
+
# Said with the next action attached, because a lone "Warning: Unsupported …"
|
|
155
|
+
# reads as cosmetic (docs/product/UI_COPY.md 【非交渉】) — and unlike a failed
|
|
156
|
+
# download, re-running the installer cannot fix it, so the action is different.
|
|
157
|
+
unsupported_platform() {
|
|
158
|
+
echo "ai-scanner: this session is NOT being scanned — there is no scanner build for this platform ($1)." >&2
|
|
159
|
+
report_unscanned_history
|
|
160
|
+
echo "ai-scanner: tool calls keep working, and they run unscanned on this machine. Contact your IT administrator with this message." >&2
|
|
69
161
|
}
|
|
70
162
|
|
|
71
163
|
OS=$(uname -s)
|
|
@@ -77,7 +169,7 @@ case "$OS" in
|
|
|
77
169
|
x86_64) PLATFORM_SLUG="linux-x86_64" ;;
|
|
78
170
|
aarch64|arm64) PLATFORM_SLUG="linux-aarch64" ;;
|
|
79
171
|
*)
|
|
80
|
-
|
|
172
|
+
unsupported_platform "Linux/$ARCH"
|
|
81
173
|
exit 0
|
|
82
174
|
;;
|
|
83
175
|
esac
|
|
@@ -91,13 +183,13 @@ case "$OS" in
|
|
|
91
183
|
# — only its contents became universal, never its name.
|
|
92
184
|
arm64|x86_64) PLATFORM_SLUG="darwin-aarch64" ;;
|
|
93
185
|
*)
|
|
94
|
-
|
|
186
|
+
unsupported_platform "macOS/$ARCH"
|
|
95
187
|
exit 0
|
|
96
188
|
;;
|
|
97
189
|
esac
|
|
98
190
|
;;
|
|
99
191
|
*)
|
|
100
|
-
|
|
192
|
+
unsupported_platform "$OS"
|
|
101
193
|
exit 0
|
|
102
194
|
;;
|
|
103
195
|
esac
|
|
@@ -105,13 +197,24 @@ esac
|
|
|
105
197
|
# Binary missing → fetch it
|
|
106
198
|
if [ ! -x "$BIN" ]; then
|
|
107
199
|
if download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}"; then
|
|
108
|
-
echo "ai-scanner binary installed (\${PLATFORM_SLUG})" >&2
|
|
200
|
+
echo "ai-scanner binary installed (\${PLATFORM_SLUG}, checksum verified)" >&2
|
|
201
|
+
rm -f "$UNSCANNED_STAMP"
|
|
109
202
|
else
|
|
110
|
-
|
|
203
|
+
# docs/product/UI_COPY.md 【非交渉】: the fact, stated so it cannot be
|
|
204
|
+
# read as harmless, and then the next action. The old single line
|
|
205
|
+
# ("Warning: Failed to download …") said neither what it meant for the
|
|
206
|
+
# session nor what to do, and it was the ONLY trace anywhere (#1774).
|
|
207
|
+
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
|
|
208
|
+
report_unscanned_history
|
|
209
|
+
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
210
|
fi
|
|
112
211
|
exit 0
|
|
113
212
|
fi
|
|
114
213
|
|
|
214
|
+
# A binary IS in place. Clear any record of having run unscanned, so the record
|
|
215
|
+
# only ever describes the machine's CURRENT state.
|
|
216
|
+
rm -f "$UNSCANNED_STAMP"
|
|
217
|
+
|
|
115
218
|
# Binary exists — throttled self-update check
|
|
116
219
|
if should_check_update; then
|
|
117
220
|
"$BIN" update 2>/dev/null || true
|
|
@@ -148,6 +251,42 @@ exit 0
|
|
|
148
251
|
// generous enough to absorb cold-start variance while still bounding a
|
|
149
252
|
// truly hung scanner. AI_SCANNER_HOOK_TIMEOUT (seconds) lets operators
|
|
150
253
|
// tune further per-environment.
|
|
254
|
+
// A tool call ran with NO scanner in place (issue #1774).
|
|
255
|
+
//
|
|
256
|
+
// The hook still exits 0 — fail-open is non-negotiable (CLAUDE.md "Fail-open
|
|
257
|
+
// Design"): the scanner's own absence must never block the user's work. What
|
|
258
|
+
// was missing is everything AFTER that: nothing re-fetched, nothing recorded,
|
|
259
|
+
// nothing visible. From the outside a machine whose binary vanished looked
|
|
260
|
+
// exactly like a healthy one, because "healthy" also produces no output.
|
|
261
|
+
//
|
|
262
|
+
// So the fact is written down next to where the binary belongs: first time
|
|
263
|
+
// seen, last time seen, and how many tool calls have run unscanned. That file
|
|
264
|
+
// is what `start_scanner.sh` reads to tell the user at the next session start,
|
|
265
|
+
// and what `aidr doctor` reads on demand. `start_scanner.sh` DELETES it once a
|
|
266
|
+
// binary is in place, so a machine that healed stops claiming to be blind.
|
|
267
|
+
//
|
|
268
|
+
// Never fails, never blocks: every write is best-effort and the function always
|
|
269
|
+
// returns 0. A bookkeeping error must not become a scanning outage.
|
|
270
|
+
//
|
|
271
|
+
// Two hooks firing at once can lose a count (last write wins). That is
|
|
272
|
+
// accepted: this is a signal that scanning stopped, not an accounting ledger.
|
|
273
|
+
const UNSCANNED_NOTE = `note_unscanned() {
|
|
274
|
+
local dir stamp now first count line
|
|
275
|
+
dir="$(dirname "$BIN")"
|
|
276
|
+
[ -d "$dir" ] || return 0
|
|
277
|
+
stamp="$dir/.ai-scanner-unscanned"
|
|
278
|
+
now="$(date +%s 2>/dev/null)" || return 0
|
|
279
|
+
first="$now"; count=0
|
|
280
|
+
if [ -r "$stamp" ]; then
|
|
281
|
+
read -r line < "$stamp" 2>/dev/null || line=""
|
|
282
|
+
set -- $line
|
|
283
|
+
case "\${1:-}" in ''|*[!0-9]*) ;; *) first="$1" ;; esac
|
|
284
|
+
case "\${3:-}" in ''|*[!0-9]*) ;; *) count="$3" ;; esac
|
|
285
|
+
fi
|
|
286
|
+
printf '%s %s %s\\n' "$first" "$now" "$((count + 1))" > "$stamp" 2>/dev/null || true
|
|
287
|
+
return 0
|
|
288
|
+
}`;
|
|
289
|
+
|
|
151
290
|
const SCAN_HELPERS = `run_scanner() {
|
|
152
291
|
if command -v timeout >/dev/null 2>&1; then
|
|
153
292
|
timeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT:-30}" "$BIN" "$@"
|
|
@@ -165,7 +304,8 @@ export const SCAN_BASH_SH = `#!/bin/bash
|
|
|
165
304
|
# PostToolUse hook for Bash: Scan command output.
|
|
166
305
|
# Thin Claude Code adapter — Rust handles cap, detection log, actor resolution.
|
|
167
306
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
168
|
-
|
|
307
|
+
${UNSCANNED_NOTE}
|
|
308
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
169
309
|
|
|
170
310
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
171
311
|
|
|
@@ -181,7 +321,8 @@ export const SCAN_BASH_INPUT_SH = `#!/bin/bash
|
|
|
181
321
|
# PreToolUse hook for Bash: Scan command before execution.
|
|
182
322
|
# Thin Claude Code adapter — Rust handles cap, detection log, actor resolution.
|
|
183
323
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
184
|
-
|
|
324
|
+
${UNSCANNED_NOTE}
|
|
325
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
185
326
|
|
|
186
327
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
187
328
|
|
|
@@ -198,7 +339,8 @@ export const SCAN_EDIT_SH = `#!/bin/bash
|
|
|
198
339
|
# Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
|
|
199
340
|
# Only the Edit-vs-MultiEdit content extraction is Claude Code specific.
|
|
200
341
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
201
|
-
|
|
342
|
+
${UNSCANNED_NOTE}
|
|
343
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
202
344
|
|
|
203
345
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
204
346
|
|
|
@@ -223,7 +365,8 @@ export const SCAN_READ_SH = `#!/bin/bash
|
|
|
223
365
|
# PostToolUse hook for Read: Scan file content Claude just read.
|
|
224
366
|
# Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
|
|
225
367
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
226
|
-
|
|
368
|
+
${UNSCANNED_NOTE}
|
|
369
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
227
370
|
|
|
228
371
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
229
372
|
|
|
@@ -242,7 +385,8 @@ export const SCAN_EDIT_OUTPUT_SH = `#!/bin/bash
|
|
|
242
385
|
# PostToolUse hook for Edit/MultiEdit/Write: Scan final file content.
|
|
243
386
|
# Thin Claude Code adapter — Rust handles cap, exclude dirs, detection log.
|
|
244
387
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
245
|
-
|
|
388
|
+
${UNSCANNED_NOTE}
|
|
389
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
246
390
|
|
|
247
391
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
248
392
|
|
|
@@ -262,7 +406,8 @@ export const SCAN_READ_INPUT_SH = `#!/bin/bash
|
|
|
262
406
|
# binary, including actor resolution, exclude-dir filtering, content cap, and
|
|
263
407
|
# detection-log writing.
|
|
264
408
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
265
|
-
|
|
409
|
+
${UNSCANNED_NOTE}
|
|
410
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
266
411
|
|
|
267
412
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
268
413
|
|
|
@@ -279,7 +424,8 @@ export const SCAN_WEBFETCH_SH = `#!/bin/bash
|
|
|
279
424
|
# PreToolUse hook for WebFetch: Scan URL before Claude fetches it.
|
|
280
425
|
# Thin Claude Code adapter — Rust handles phishing scoring + YARA + detection log.
|
|
281
426
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
282
|
-
|
|
427
|
+
${UNSCANNED_NOTE}
|
|
428
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
283
429
|
|
|
284
430
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
285
431
|
|
|
@@ -295,7 +441,8 @@ export const SCAN_WEBSEARCH_SH = `#!/bin/bash
|
|
|
295
441
|
# PostToolUse hook for WebSearch: Scan search results for phishing URLs.
|
|
296
442
|
# Thin Claude Code adapter — Rust handles cap, phishing scoring, detection log.
|
|
297
443
|
BIN="\${AI_SCANNER_BIN:-$HOME/.claude/bin/ai-scanner}"
|
|
298
|
-
|
|
444
|
+
${UNSCANNED_NOTE}
|
|
445
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
299
446
|
|
|
300
447
|
export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detections.log"
|
|
301
448
|
|
|
@@ -354,6 +501,8 @@ export CLAUDE_PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-.}"
|
|
|
354
501
|
# \`timeout\` (timed out) is mapped to 0 so a hung scanner never blocks the
|
|
355
502
|
# tool call. Bare macOS (no coreutils) falls through without a timeout —
|
|
356
503
|
# install coreutils (\`brew install coreutils\`) to get the safety net.
|
|
504
|
+
${UNSCANNED_NOTE}
|
|
505
|
+
|
|
357
506
|
run_scanner() {
|
|
358
507
|
if command -v timeout >/dev/null 2>&1; then
|
|
359
508
|
timeout --kill-after=2s "\${AI_SCANNER_HOOK_TIMEOUT}" "\${AI_SCANNER_BIN}" "$@"
|
|
@@ -388,7 +537,7 @@ EOF
|
|
|
388
537
|
export const CURSOR_PRE_SHELL_SH = `#!/bin/bash
|
|
389
538
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
390
539
|
source "$SCRIPT_DIR/env.sh"
|
|
391
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
540
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
392
541
|
export AI_SCANNER_DETECTION_LOG
|
|
393
542
|
jq -r '.tool_input.command // ""' | run_scanner scan bash-input
|
|
394
543
|
`;
|
|
@@ -396,7 +545,7 @@ jq -r '.tool_input.command // ""' | run_scanner scan bash-input
|
|
|
396
545
|
export const CURSOR_PRE_READ_SH = `#!/bin/bash
|
|
397
546
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
398
547
|
source "$SCRIPT_DIR/env.sh"
|
|
399
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
548
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
400
549
|
export AI_SCANNER_DETECTION_LOG
|
|
401
550
|
FILE_PATH=$(jq -r '.tool_input.file_path // ""')
|
|
402
551
|
[ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
|
|
@@ -406,7 +555,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
|
|
|
406
555
|
export const CURSOR_PRE_EDIT_SH = `#!/bin/bash
|
|
407
556
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
408
557
|
source "$SCRIPT_DIR/env.sh"
|
|
409
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
558
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
410
559
|
export AI_SCANNER_DETECTION_LOG
|
|
411
560
|
INPUT=$(cat)
|
|
412
561
|
FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
|
|
@@ -423,7 +572,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
|
|
|
423
572
|
export const CURSOR_POST_SHELL_SH = `#!/bin/bash
|
|
424
573
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
425
574
|
source "$SCRIPT_DIR/env.sh"
|
|
426
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
575
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
427
576
|
export AI_SCANNER_DETECTION_LOG
|
|
428
577
|
INPUT=$(cat)
|
|
429
578
|
COMMAND=$(jq -r '.tool_output // ""' <<<"$INPUT" | head -1 && jq -r '.tool_input.command // ""' <<<"$INPUT")
|
|
@@ -433,7 +582,7 @@ jq -r '.tool_output // ""' <<<"$INPUT" | run_scanner scan bash-output --command
|
|
|
433
582
|
export const CURSOR_POST_READ_SH = `#!/bin/bash
|
|
434
583
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
435
584
|
source "$SCRIPT_DIR/env.sh"
|
|
436
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
585
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
437
586
|
export AI_SCANNER_DETECTION_LOG
|
|
438
587
|
INPUT=$(cat)
|
|
439
588
|
FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
|
|
@@ -444,7 +593,7 @@ jq -r '.tool_output // ""' <<<"$INPUT" | run_scanner scan read-output --file "$F
|
|
|
444
593
|
export const CURSOR_POST_WRITE_SH = `#!/bin/bash
|
|
445
594
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
446
595
|
source "$SCRIPT_DIR/env.sh"
|
|
447
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
596
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
448
597
|
export AI_SCANNER_DETECTION_LOG
|
|
449
598
|
INPUT=$(cat)
|
|
450
599
|
FILE_PATH=$(jq -r '.tool_input.file_path // ""' <<<"$INPUT")
|
|
@@ -475,7 +624,7 @@ return 0 2>/dev/null || true
|
|
|
475
624
|
export const WINDSURF_PRE_COMMAND_SH = `#!/bin/bash
|
|
476
625
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
477
626
|
source "$SCRIPT_DIR/ensure_scanner.sh"
|
|
478
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
627
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
479
628
|
export AI_SCANNER_DETECTION_LOG
|
|
480
629
|
CONTENT=$(jq -r '.tool_info.command_line // ""')
|
|
481
630
|
[ -z "$CONTENT" ] || [ "$CONTENT" = "null" ] && exit 0
|
|
@@ -485,7 +634,7 @@ printf '%s' "$CONTENT" | run_scanner scan bash-input
|
|
|
485
634
|
export const WINDSURF_PRE_READ_SH = `#!/bin/bash
|
|
486
635
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
487
636
|
source "$SCRIPT_DIR/ensure_scanner.sh"
|
|
488
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
637
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
489
638
|
export AI_SCANNER_DETECTION_LOG
|
|
490
639
|
FILE_PATH=$(jq -r '.tool_info.file_path // ""')
|
|
491
640
|
[ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
|
|
@@ -495,7 +644,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
|
|
|
495
644
|
export const WINDSURF_PRE_WRITE_SH = `#!/bin/bash
|
|
496
645
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
497
646
|
source "$SCRIPT_DIR/ensure_scanner.sh"
|
|
498
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
647
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
499
648
|
export AI_SCANNER_DETECTION_LOG
|
|
500
649
|
INPUT=$(cat)
|
|
501
650
|
FILE_PATH=$(jq -r '.tool_info.file_path // ""' <<<"$INPUT")
|
|
@@ -507,7 +656,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
|
|
|
507
656
|
export const WINDSURF_POST_READ_SH = `#!/bin/bash
|
|
508
657
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
509
658
|
source "$SCRIPT_DIR/ensure_scanner.sh"
|
|
510
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
659
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
511
660
|
export AI_SCANNER_DETECTION_LOG
|
|
512
661
|
FILE_PATH=$(jq -r '.tool_info.file_path // ""')
|
|
513
662
|
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
|
|
@@ -517,7 +666,7 @@ run_scanner scan read-output --file "$FILE_PATH" < "$FILE_PATH"
|
|
|
517
666
|
export const WINDSURF_POST_WRITE_SH = `#!/bin/bash
|
|
518
667
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
519
668
|
source "$SCRIPT_DIR/ensure_scanner.sh"
|
|
520
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
669
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
521
670
|
export AI_SCANNER_DETECTION_LOG
|
|
522
671
|
FILE_PATH=$(jq -r '.tool_info.file_path // ""')
|
|
523
672
|
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
|
|
@@ -560,7 +709,7 @@ exec "$SCRIPT_DIR/start_scanner.sh"
|
|
|
560
709
|
export const KIRO_PRE_SHELL_SH = `#!/bin/bash
|
|
561
710
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
562
711
|
source "$SCRIPT_DIR/env.sh"
|
|
563
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
712
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
564
713
|
export AI_SCANNER_DETECTION_LOG
|
|
565
714
|
jq -r '.tool_input.command // ""' | run_scanner scan bash-input
|
|
566
715
|
`;
|
|
@@ -568,7 +717,7 @@ jq -r '.tool_input.command // ""' | run_scanner scan bash-input
|
|
|
568
717
|
export const KIRO_PRE_READ_SH = `#!/bin/bash
|
|
569
718
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
570
719
|
source "$SCRIPT_DIR/env.sh"
|
|
571
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
720
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
572
721
|
export AI_SCANNER_DETECTION_LOG
|
|
573
722
|
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.operations[0].path // ""')
|
|
574
723
|
[ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ] && exit 0
|
|
@@ -578,7 +727,7 @@ run_scanner scan read-input --file "$FILE_PATH" </dev/null
|
|
|
578
727
|
export const KIRO_PRE_WRITE_SH = `#!/bin/bash
|
|
579
728
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
580
729
|
source "$SCRIPT_DIR/env.sh"
|
|
581
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
730
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
582
731
|
export AI_SCANNER_DETECTION_LOG
|
|
583
732
|
INPUT=$(cat)
|
|
584
733
|
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // ""' <<<"$INPUT")
|
|
@@ -590,7 +739,7 @@ printf '%s' "$CONTENT" | run_scanner scan edit-input --file "$FILE_PATH"
|
|
|
590
739
|
export const KIRO_POST_SHELL_SH = `#!/bin/bash
|
|
591
740
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
592
741
|
source "$SCRIPT_DIR/env.sh"
|
|
593
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
742
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
594
743
|
export AI_SCANNER_DETECTION_LOG
|
|
595
744
|
INPUT=$(cat)
|
|
596
745
|
COMMAND=$(jq -r '.tool_input.command // ""' <<<"$INPUT")
|
|
@@ -601,7 +750,7 @@ printf '%s' "$RESPONSE" | run_scanner scan bash-output --command "$COMMAND"
|
|
|
601
750
|
export const KIRO_POST_READ_SH = `#!/bin/bash
|
|
602
751
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
603
752
|
source "$SCRIPT_DIR/env.sh"
|
|
604
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
753
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
605
754
|
export AI_SCANNER_DETECTION_LOG
|
|
606
755
|
INPUT=$(cat)
|
|
607
756
|
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.operations[0].path // ""' <<<"$INPUT")
|
|
@@ -613,7 +762,7 @@ printf '%s' "$RESPONSE" | run_scanner scan read-output --file "$FILE_PATH"
|
|
|
613
762
|
export const KIRO_POST_WRITE_SH = `#!/bin/bash
|
|
614
763
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
615
764
|
source "$SCRIPT_DIR/env.sh"
|
|
616
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
765
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
617
766
|
export AI_SCANNER_DETECTION_LOG
|
|
618
767
|
INPUT=$(cat)
|
|
619
768
|
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // ""' <<<"$INPUT")
|
|
@@ -635,7 +784,8 @@ export function getGeminiPreHookSh(binary) {
|
|
|
635
784
|
# the run_scanner timeout helper is inlined here. See agentEnvSh for the
|
|
636
785
|
# canonical version and the rationale for fail-open on exit 124.
|
|
637
786
|
BIN="${binary}"
|
|
638
|
-
|
|
787
|
+
${UNSCANNED_NOTE}
|
|
788
|
+
[ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
639
789
|
|
|
640
790
|
run_scanner() {
|
|
641
791
|
if command -v timeout >/dev/null 2>&1; then
|
|
@@ -705,7 +855,7 @@ export const CODEX_PRE_SHELL_SH = `#!/bin/bash
|
|
|
705
855
|
# Codex PreToolUse Bash hook: scan command and return block JSON to Codex.
|
|
706
856
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
|
|
707
857
|
source "$SCRIPT_DIR/env.sh"
|
|
708
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
858
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
709
859
|
export AI_SCANNER_DETECTION_LOG
|
|
710
860
|
INPUT=$(cat)
|
|
711
861
|
CMD=$(jq -r '.tool_input.command // ""' <<<"$INPUT" 2>/dev/null)
|
|
@@ -735,7 +885,7 @@ export const CODEX_POST_SHELL_SH = `#!/bin/bash
|
|
|
735
885
|
# Codex PostToolUse Bash hook: scan command output and return feedback JSON.
|
|
736
886
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]:-$0}")" && pwd)"
|
|
737
887
|
source "$SCRIPT_DIR/env.sh"
|
|
738
|
-
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && exit 0
|
|
888
|
+
BIN="$AI_SCANNER_BIN"; [ ! -x "$BIN" ] && { note_unscanned; exit 0; }
|
|
739
889
|
export AI_SCANNER_DETECTION_LOG
|
|
740
890
|
INPUT=$(cat)
|
|
741
891
|
CMD=$(jq -r '.tool_input.command // ""' <<<"$INPUT" 2>/dev/null)
|
package/src/verify.mjs
CHANGED
|
@@ -14,7 +14,46 @@ export function verifyEndpoint(env = "prod") {
|
|
|
14
14
|
return `${base}/verify`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// `AIDR_DOWNLOAD_BASE` redirects the licensed download endpoint, but ONLY to
|
|
18
|
+
// loopback. It is a TEST SEAM, not a configuration knob: the object it fetches
|
|
19
|
+
// IS the protection, so an env var able to point it at an arbitrary host would
|
|
20
|
+
// be a supply-chain hole in a security product. Restricted to loopback, anyone
|
|
21
|
+
// who can set it already owns the machine and gains nothing they did not have.
|
|
22
|
+
// A non-loopback value is refused *out loud* rather than silently ignored — a
|
|
23
|
+
// mis-set variable that quietly does nothing is the "silently lies" shape this
|
|
24
|
+
// repo keeps paying for.
|
|
25
|
+
const LOOPBACK_BASE_RE = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/;
|
|
26
|
+
let warnedAboutDownloadBase = false;
|
|
27
|
+
|
|
28
|
+
export function downloadBaseOverride(env = process.env) {
|
|
29
|
+
const raw = env.AIDR_DOWNLOAD_BASE;
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
if (LOOPBACK_BASE_RE.test(raw)) return raw.replace(/\/+$/, "");
|
|
32
|
+
if (!warnedAboutDownloadBase) {
|
|
33
|
+
warnedAboutDownloadBase = true;
|
|
34
|
+
console.warn(
|
|
35
|
+
`warning: AIDR_DOWNLOAD_BASE is set to a non-loopback URL and was refused; ` +
|
|
36
|
+
`the built-in download endpoint is being used instead. ` +
|
|
37
|
+
`Unset it, or point it at http://127.0.0.1:<port> if you are running tests.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
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
|
+
|
|
17
54
|
export function downloadBase(env = "prod") {
|
|
55
|
+
const override = downloadBaseOverride();
|
|
56
|
+
if (override) return `${override}/download`;
|
|
18
57
|
const base = ENDPOINTS[env] || ENDPOINTS.prod;
|
|
19
58
|
return `${base}/download`;
|
|
20
59
|
}
|