@coworker-jp/aidr 0.1.299 → 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.299",
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
@@ -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
+ }