@coworker-jp/aidr 0.1.298 → 0.1.301

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coworker-jp/aidr",
3
- "version": "0.1.298",
3
+ "version": "0.1.301",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -80,6 +80,11 @@ async function cmdInstall(opts) {
80
80
  // the relevant options (agent, all, scope, yes, dry-run) to the uninstall
81
81
  // flow. --key isn't needed but commander requires it via requiredOption,
82
82
  // so we accept and ignore it.
83
+ //
84
+ // `--purge` is deliberately NOT reachable through this alias (issue #1958):
85
+ // it is the one option that deletes the quarantined package archives, and a
86
+ // convenience alias is the wrong place to be able to destroy evidence from.
87
+ // `aidr uninstall --purge` is the only spelling.
83
88
  if (opts.uninstall) {
84
89
  return cmdUninstall({
85
90
  agent: opts.agent,
@@ -468,6 +473,18 @@ async function cmdUninstall(opts) {
468
473
  }
469
474
  }
470
475
 
476
+ // ai-scanner's own state (issue #1958). Resolved BEFORE phase 3: on an
477
+ // endpoint that only ever ran `aidr install`, the per-agent copies are the
478
+ // only ai-scanner binaries on the machine, and phase 3 deletes them.
479
+ const { scannerBinaryCandidates, findScannerBinary, cleanScannerState, describeScannerState } =
480
+ await import("./scanner-state.mjs");
481
+ const scannerBinary = await findScannerBinary(
482
+ scannerBinaryCandidates(home, plans.map(({ mod }) => mod?.meta?.agentDir))
483
+ );
484
+ for (const line of describeScannerState({ outcome: scannerBinary ? "dry-run" : "no-binary", binary: scannerBinary }, { purge: opts.purge })) {
485
+ console.error(line);
486
+ }
487
+
471
488
  if (opts.dryRun) return;
472
489
 
473
490
  // Phase 2: confirm
@@ -484,7 +501,15 @@ async function cmdUninstall(opts) {
484
501
  }
485
502
  }
486
503
 
487
- // Phase 3: execute
504
+ // Phase 3a: the scanner's own state, while its binaries still exist.
505
+ for (const line of describeScannerState(
506
+ cleanScannerState({ binary: scannerBinary, purge: opts.purge }),
507
+ { purge: opts.purge }
508
+ )) {
509
+ console.log(line);
510
+ }
511
+
512
+ // Phase 3b: execute
488
513
  await Promise.all(plans.map(async ({ name, mod }) => {
489
514
  try {
490
515
  const res = await mod.uninstall(base, { dryRun: false });
@@ -834,6 +859,7 @@ export async function run(argv) {
834
859
  .option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
835
860
  .option("-y, --yes", "proceed without confirmation prompt", false)
836
861
  .option("--dry-run", "print what would be removed without touching disk", false)
862
+ .option("--purge", "also delete the quarantined package archives (they are evidence, so an ordinary uninstall keeps them)", false)
837
863
  .action(cmdUninstall);
838
864
 
839
865
  const redTeam = program
package/src/merge.mjs CHANGED
@@ -150,6 +150,73 @@ export function buildCursorHooksJson(scope = "project", absHome = "") {
150
150
  }
151
151
 
152
152
  // Windsurf hooks.json — flat entries, one command per event (no matcher).
153
+ //
154
+ // NO `timeout` HERE, AND THAT IS DELIBERATE (#1896). Windsurf's hook entry
155
+ // schema has no such field: the documented entry keys are `command`,
156
+ // `powershell`, `show_output` and `working_directory`, and the page says
157
+ // nothing about any time limit at all (docs.windsurf.com/windsurf/cascade/hooks
158
+ // → docs.devin.ai/desktop/cascade/hooks, read 2026-08-25). The other four
159
+ // agents each declare one because their schemas accept it; Windsurf cannot,
160
+ // so this is a DECLARED inequality rather than an oversight — which is the
161
+ // whole reason this comment exists. Emitting an unknown key would not buy a
162
+ // cap and could be rejected by a future schema validation.
163
+ //
164
+ // What bounds a Windsurf hook instead: the in-script `run_scanner` cap
165
+ // (`AI_SCANNER_HOOK_TIMEOUT`, default 30s) plus `ensure_scanner.sh`'s
166
+ // `flock -w 120`. Those are OUR caps, so they hold on every editor — but
167
+ // there is no second, editor-side net here the way there is elsewhere.
168
+ // `tests/merge-hooks-json.test.mjs` pins both directions of that statement.
169
+ //
170
+ // NO `powershell` HERE EITHER, AND THAT IS A DECLARED INEQUALITY (#1907) —
171
+ // it is NOT a claim that Windows is covered.
172
+ //
173
+ // What the vendor actually documents (docs.windsurf.com/windsurf/cascade/hooks
174
+ // → docs.devin.ai/desktop/cascade/hooks, read 2026-08-25, quoted verbatim):
175
+ //
176
+ // command "The shell command to execute on **macOS/Linux** (run via
177
+ // `bash -c`). At least one of `command` or `powershell` must
178
+ // be specified."
179
+ // powershell "Optional. The command to execute on **Windows** (run via
180
+ // `powershell -Command`). If omitted on Windows, `command` is
181
+ // used as a fallback."
182
+ //
183
+ // and its cross-platform table, for the row we are actually in:
184
+ // | Windows | `command` ✓ | `powershell` ✗ | Falls back to `command` via `powershell -Command` |
185
+ //
186
+ // So on Windows the entry is NOT skipped. #1907 was filed on the assumption
187
+ // that it would be; the primary source says otherwise, and this comment is
188
+ // the corrected record. The real question is narrower and still open: what
189
+ // gets handed to `powershell -Command` is THE PATH OF A POSIX SHELL SCRIPT
190
+ // (`.windsurf/coworker-ai/pre_command.sh`), and every hook adapter this
191
+ // product ships — all five agents, 42 files — is a `.sh`. There is no
192
+ // Windows-native adapter anywhere in this tree to point a `powershell` key at.
193
+ //
194
+ // Why the outcome is invisible either way: the same page's exit-code table
195
+ // says `0` proceeds, `2` blocks, and "Any other | Error | Action proceeds
196
+ // normally". A fallback that fires and cannot run therefore produces the same
197
+ // user-visible result as a scan that ran and found nothing. Fail-open is
198
+ // deliberate, and it is exactly what hides this.
199
+ //
200
+ // UNMEASURED: whether that fallback reaches ai-scanner on a real Windows
201
+ // machine. This development host has no Windows machine with Windsurf on it.
202
+ //
203
+ // Two wrong moves, spelled out so nobody makes them from this comment alone:
204
+ // * emit `powershell: "bash <path>.sh"` — plausible, and unverified. If Git
205
+ // Bash is not on PATH it exits non-zero, which the table above turns into
206
+ // "proceeds normally" — the same silent gap, now with a tree that CLAIMS
207
+ // Windows is handled. A bare `bash` on Windows can also resolve to WSL's
208
+ // `C:\Windows\System32\bash.exe`, which sees a different filesystem than
209
+ // the path we wrote.
210
+ // * delete this comment because "the docs say it falls back" — the fallback
211
+ // is documented; the fallback WORKING is not.
212
+ //
213
+ // HOW THIS INEQUALITY RETIRES (so it cannot sit here forever): install
214
+ // Windsurf on a Windows machine, install these hooks, and drive a pre_* hook
215
+ // with an input the scanner MUST block. Judge on the block (exit 2), not on a
216
+ // log line or a telemetry field — fail-open makes "ran and allowed" and
217
+ // "never ran" byte-identical. Then either delete this block (it reaches the
218
+ // scanner) or add the `powershell` value the measurement showed to be right.
219
+ // `tests/merge-hooks-json.test.mjs` pins both directions until that day.
153
220
  export function buildWindsurfHooksJson(scope = "project", absHome = "") {
154
221
  const p = scope === "user" ? `${absHome}/.windsurf/coworker-ai` : ".windsurf/coworker-ai";
155
222
  return {
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ai-scanner's own on-disk state, at uninstall time (issue #1958).
3
+ *
4
+ * `aidr uninstall` used to remove hooks, adapter scripts and the per-agent
5
+ * binaries, and nothing else. The scanner's state directory
6
+ * (`~/.coworker/aidr/{ai-scanner,quarantine}`) survived every uninstall, which
7
+ * is wrong in both directions:
8
+ *
9
+ * - **Reinstalling did not reset anything.** The 7-day-rule verdict cache
10
+ * came back with the endpoint, so "uninstall it and install it again" —
11
+ * the support step a customer is told to take — did not do what it says.
12
+ * - **The quarantined package archives were left with no owner.** They are
13
+ * specimens of the packages this endpoint blocked, i.e. evidence, sitting
14
+ * in a directory of a product that is no longer installed and that nothing
15
+ * tells the user about.
16
+ *
17
+ * ## What decides what goes
18
+ *
19
+ * Nothing here. The rule — caches go, archives stay unless a purge was asked
20
+ * for — lives in `ScannerPaths::uninstall_state` (docker/scanner/src/paths.rs)
21
+ * and is reached by running the binary's own `uninstall-state` subcommand. The
22
+ * debian `postrm`, the Windows uninstaller and the macOS teardown call the same
23
+ * subcommand for the same reason: a policy transcribed into four languages
24
+ * drifts in three of them, and the direction it drifts in is "somebody deleted
25
+ * the evidence".
26
+ *
27
+ * ## Why the binary must be run BEFORE the agent directories are removed
28
+ *
29
+ * On an endpoint that only ever ran `aidr install`, the per-agent copies are
30
+ * the only ai-scanner binaries on the machine. Remove them first and there is
31
+ * nothing left to ask.
32
+ */
33
+ import path from "path";
34
+ import fs from "fs/promises";
35
+ import { spawnSync } from "child_process";
36
+
37
+ /** Executable name, per platform. */
38
+ export const SCANNER_EXE =
39
+ process.platform === "win32" ? "ai-scanner.exe" : "ai-scanner";
40
+
41
+ /**
42
+ * Locations an OS-native installer or the sentinel daemon provisions, in
43
+ * preference order. Mirrors `scheduler::default_scanner_paths()` on the Rust
44
+ * side; kept as its own function so a test can assert both platforms without
45
+ * being run on both.
46
+ */
47
+ export function systemScannerPaths(platform = process.platform, env = process.env) {
48
+ if (platform === "win32") {
49
+ const pf = env.ProgramFiles || "C:\\Program Files";
50
+ return [path.join(pf, "coworker", "aidr", "bin", "ai-scanner.exe")];
51
+ }
52
+ return ["/opt/coworker/aidr/bin/ai-scanner", "/usr/local/bin/ai-scanner"];
53
+ }
54
+
55
+ /**
56
+ * Every place this uninstall could find a scanner binary: the agents being
57
+ * uninstalled first (they are the copies `aidr install` put there, and the
58
+ * only ones on an agent-only endpoint), then the OS-native locations.
59
+ */
60
+ export function scannerBinaryCandidates(home, agentDirs, platform = process.platform, env = process.env) {
61
+ const exe = platform === "win32" ? "ai-scanner.exe" : "ai-scanner";
62
+ const perAgent = (agentDirs || [])
63
+ .filter(Boolean)
64
+ .map((d) => path.join(home, d, "bin", exe));
65
+ return [...perAgent, ...systemScannerPaths(platform, env)];
66
+ }
67
+
68
+ /** First candidate that exists, or `null`. */
69
+ export async function findScannerBinary(candidates, { access } = {}) {
70
+ const probe = access || ((p) => fs.access(p, fs.constants.X_OK));
71
+ for (const candidate of candidates) {
72
+ try {
73
+ await probe(candidate);
74
+ return candidate;
75
+ } catch {
76
+ // Try the next one. A candidate that is missing, or present but not
77
+ // executable, is equally unusable here.
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Ask the scanner to remove its own state.
85
+ *
86
+ * Returns `{ outcome, binary, status, stdout, stderr }` where `outcome` is one
87
+ * of `"cleaned"`, `"unresolved"` (the binary ran and reported something is
88
+ * still on the machine), `"failed"` (it could not be run) or `"no-binary"`.
89
+ *
90
+ * **`"no-binary"` is not success.** It is the case where we do not know what
91
+ * is on this machine, and the caller has to say so rather than print the same
92
+ * reassuring line it prints for a clean sweep.
93
+ */
94
+ export function cleanScannerState({ binary, purge = false, dryRun = false, run = spawnSync }) {
95
+ if (!binary) return { outcome: "no-binary", binary: null };
96
+ if (dryRun) return { outcome: "dry-run", binary };
97
+ const args = purge ? ["uninstall-state", "--purge"] : ["uninstall-state"];
98
+ const res = run(binary, args, { encoding: "utf8" });
99
+ if (res.error) {
100
+ return { outcome: "failed", binary, error: res.error.message };
101
+ }
102
+ return {
103
+ outcome: res.status === 0 ? "cleaned" : "unresolved",
104
+ binary,
105
+ status: res.status,
106
+ stdout: res.stdout || "",
107
+ stderr: res.stderr || "",
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The lines a user sees. Split out from `cleanScannerState` so the wording can
113
+ * be asserted without spawning anything.
114
+ *
115
+ * Every branch ends on what to do next. A line that stops at "could not" reads,
116
+ * to someone who has just uninstalled a security product, as "so there is
117
+ * nothing to worry about" — and in the `"no-binary"` branch we specifically do
118
+ * not know that.
119
+ */
120
+ export function describeScannerState(result, { purge = false } = {}) {
121
+ switch (result.outcome) {
122
+ case "dry-run":
123
+ return [
124
+ ` [scanner] ${purge ? "remove" : "clear"} this endpoint's scanner state via ${result.binary} uninstall-state${purge ? " --purge" : ""}`,
125
+ purge
126
+ ? " (cached verdicts AND the quarantined package archives)"
127
+ : " (cached verdicts; quarantined package archives are kept and their location is printed)",
128
+ ];
129
+ case "cleaned":
130
+ return (result.stdout || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`);
131
+ case "unresolved":
132
+ return [
133
+ ...(result.stdout || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`),
134
+ ...(result.stderr || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`),
135
+ "[scanner] Some of this endpoint's scanner state is still on the machine (listed above).",
136
+ "[scanner] Next: delete those paths yourself, or ask your IT administrator to.",
137
+ ];
138
+ case "failed":
139
+ return [
140
+ `[scanner] ${result.binary} could not be run (${result.error}), so this endpoint's scanner state was left where it is.`,
141
+ "[scanner] It is under ~/.coworker/aidr/ unless AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR name another location.",
142
+ "[scanner] Next: delete that directory yourself, or ask your IT administrator to.",
143
+ ];
144
+ case "no-binary":
145
+ default:
146
+ return [
147
+ "[scanner] No ai-scanner binary was found on this endpoint, so its state was left where it is.",
148
+ "[scanner] It is under ~/.coworker/aidr/ unless AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR name another location, and it holds the quarantined package archives this endpoint kept as evidence.",
149
+ "[scanner] Next: delete that directory yourself once you no longer need it, or ask your IT administrator to.",
150
+ ];
151
+ }
152
+ }
package/src/templates.mjs CHANGED
@@ -55,6 +55,14 @@ mkdir -p "$BIN_DIR"
55
55
 
56
56
  # macOS has no base \`timeout\` (it's in GNU coreutils). Shim it so hooks work
57
57
  # on fresh Macs without brew install coreutils.
58
+ #
59
+ # NOTE (issue #1879): the last branch — a bare macOS with neither \`timeout\` nor
60
+ # \`gtimeout\` — DROPS the duration and execs directly. On such a machine every
61
+ # \`timeout N "$BIN" ...\` below is unbounded, and the only remaining safety net
62
+ # is the editor's own hook timeout (Claude Code / Codex: 60s, written by
63
+ # merge.mjs; Cursor / Windsurf / Kiro: unverified). Installing coreutils is the
64
+ # documented remedy. The shim is deliberately kept — a hook that refused to run
65
+ # without coreutils would be worse than one that runs unbounded.
58
66
  if ! command -v timeout >/dev/null 2>&1; then
59
67
  if command -v gtimeout >/dev/null 2>&1; then
60
68
  timeout() { gtimeout "$@"; }
@@ -63,6 +71,23 @@ if ! command -v timeout >/dev/null 2>&1; then
63
71
  fi
64
72
  fi
65
73
 
74
+ # Bounds for the two ai-scanner invocations this script makes. Both sit
75
+ # STRICTLY ABOVE the binary's own internal network bound for that subcommand,
76
+ # so the wrapper only ever fires on a genuinely hung binary and never pre-empts
77
+ # a slow-but-working one. (A bound that fires on healthy-but-slow would print
78
+ # the "we did NOT check" message spuriously — a false positive, which is how
79
+ # guards stop being read.)
80
+ #
81
+ # version: load_plan -> /verify, reqwest timeout 10s
82
+ # (docker/scanner/src/main.rs). 20s leaves 10s for process start.
83
+ # update: /verify (10s) THEN the binary download (30s), sequential
84
+ # (docker/scanner/src/main.rs cmd_update) = 40s internal worst case.
85
+ # 60s leaves 20s for hashing and the rename. Killing an update
86
+ # mid-flight is safe: it writes to a temp file in the same directory
87
+ # and only renames over \$BIN once the advertised hash matches.
88
+ VERSION_TIMEOUT=20
89
+ UPDATE_TIMEOUT=60
90
+
66
91
  # Generic throttle: true (0) if stamp missing or older than PULL_INTERVAL
67
92
  should_check_update() {
68
93
  [ ! -f "$PULL_STAMP" ] && return 0
@@ -248,7 +273,7 @@ rm -f "$UNSCANNED_STAMP"
248
273
  # operator can act on; "|| true" turned it into silence.
249
274
  if should_check_update; then
250
275
  want_host=$(printf '%s' "$DOWNLOAD_BASE" | sed -E 's#^[a-z]+://([^/]+).*#\\1#')
251
- have_host=$("$BIN" version 2>/dev/null | sed -n 's#^server: [a-z]*://\\([^/]*\\).*#\\1#p' | head -1)
276
+ have_host=$(timeout "$VERSION_TIMEOUT" "$BIN" version 2>/dev/null | sed -n 's#^server: [a-z]*://\\([^/]*\\).*#\\1#p' | head -1)
252
277
  if [ -n "$have_host" ] && [ -n "$want_host" ] && [ "$have_host" != "$want_host" ]; then
253
278
  echo "ai-scanner: the installed binary talks to $have_host, but this installation provisions from $want_host, so its access key can never be accepted (detection logs are refused and discarded). Re-downloading the matching binary." >&2
254
279
  download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}" || \\
@@ -256,7 +281,7 @@ if should_check_update; then
256
281
  elif [ -z "$have_host" ]; then
257
282
  echo "ai-scanner: could not read which license server the installed binary uses, so the environment match was NOT checked. If detection logs are being refused, re-run the installer." >&2
258
283
  fi
259
- "$BIN" update 2>/dev/null || \\
284
+ timeout "$UPDATE_TIMEOUT" "$BIN" update 2>/dev/null || \\
260
285
  echo "ai-scanner: the scanner could not update itself just now. It keeps scanning with the rules it already has; if this repeats, re-run the installer or contact your IT administrator." >&2
261
286
  date +%s > "$PULL_STAMP"
262
287
  fi