@coworker-jp/aidr 0.0.2 → 0.0.4

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.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -155,3 +155,35 @@ export async function fetchOpengrep(destPath) {
155
155
 
156
156
  return { path: destPath, platform, sha256, verified: true };
157
157
  }
158
+
159
+ /**
160
+ * Download coworker-sentinel for the current platform into `destPath`.
161
+ * Uses the presigned S3 URL from the verify response when available,
162
+ * otherwise falls back to the licensed download endpoint.
163
+ * Returns { path, platform, sha256, verified }.
164
+ */
165
+ export async function fetchSentinel(destPath, { accessKey, presignedUrl, env = "prod" } = {}) {
166
+ const platform = detectPlatform();
167
+
168
+ let binUrl;
169
+ if (presignedUrl) {
170
+ // Use presigned URL from verify response (no auth header needed)
171
+ binUrl = presignedUrl;
172
+ accessKey = undefined;
173
+ } else {
174
+ const base = downloadBase(env);
175
+ binUrl = `${base}/sentinel/${platform}`;
176
+ }
177
+
178
+ await fsp.mkdir(path.dirname(destPath), { recursive: true });
179
+ const buf = await fetchBuffer(binUrl, accessKey);
180
+ const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
181
+
182
+ const tmp = `${destPath}.tmp-${process.pid}`;
183
+ await fsp.writeFile(tmp, buf);
184
+ await fsp.chmod(tmp, 0o755);
185
+ await fsp.rename(tmp, destPath);
186
+ await clearQuarantine(destPath);
187
+
188
+ return { path: destPath, platform, sha256, verified: false };
189
+ }
package/src/cli.mjs CHANGED
@@ -13,6 +13,7 @@ import { verifyKey, maskKey } from "./verify.mjs";
13
13
  import { fetchBinary, fetchOpengrep } from "./binary-fetcher.mjs";
14
14
  import { detectInstalled } from "./detect.mjs";
15
15
  import { installBrowserExtension } from "./browser-extension.mjs";
16
+ import { resolveCallerHome, isRunningUnderSudo, chownToCaller } from "./sudo-user.mjs";
16
17
 
17
18
  // Package identity — read at module load from the bundled package.json.
18
19
  // The PROD publish workflow renames the package to `@coworker-jp/aidr`;
@@ -83,21 +84,47 @@ async function cmdInstall(opts) {
83
84
  });
84
85
  }
85
86
 
86
- const home = process.env.HOME || os.homedir();
87
+ const home = resolveCallerHome();
87
88
  const scope = opts.scope || "user";
88
89
  const base = scope === "user" ? home : process.cwd();
89
90
  validateKey(opts.key);
90
91
 
92
+ let verifyInfo = null;
91
93
  if (opts.verify !== false) {
92
94
  try {
93
- const info = await verifyKey(opts.key, opts.env || DEFAULT_ENV);
94
- console.log(`verified: plan=${info.plan} expires_at=${info.expires_at || "n/a"}`);
95
+ verifyInfo = await verifyKey(opts.key, opts.env || DEFAULT_ENV);
95
96
  } catch (e) {
96
97
  console.error(`verify failed: ${e.message}`);
97
98
  process.exit(1);
98
99
  }
99
100
  }
100
101
 
102
+ // --with-sentinel requires pro or trial plan
103
+ if (opts.withSentinel && verifyInfo && !verifyInfo.edr_enabled) {
104
+ console.error("Error: --with-sentinel requires a Pro or Trial plan.");
105
+ console.error(`Current plan: ${verifyInfo.plan}`);
106
+ process.exit(1);
107
+ }
108
+
109
+ // --with-sentinel requires root (same as --scheduled)
110
+ if (opts.withSentinel && !opts.dryRun
111
+ && process.getuid && process.getuid() !== 0
112
+ && process.env.AI_SCANNER_FAKE_ROOT !== "1") {
113
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
114
+ if (interactive) {
115
+ console.error("--with-sentinel requires root. Re-running via sudo (you may be prompted for your password)...");
116
+ const result = spawnSync(
117
+ "sudo",
118
+ ["-E", process.execPath, ...process.argv.slice(1)],
119
+ { stdio: "inherit" }
120
+ );
121
+ process.exit(result.status ?? 1);
122
+ }
123
+ console.error("Error: --with-sentinel requires root privileges.");
124
+ console.error(` sudo -E npx @coworker-jp/aidr install --with-sentinel --key <ak_xxx>`);
125
+ process.exit(2);
126
+ }
127
+
101
128
  let agents;
102
129
  if (opts.all) {
103
130
  agents = detectInstalled(home);
@@ -245,8 +272,6 @@ async function cmdInstall(opts) {
245
272
  })
246
273
  );
247
274
  await Promise.all(copyOps);
248
-
249
- console.log("Includes Opengrep (LGPL-2.1).");
250
275
  } catch (e) {
251
276
  console.error(`binary fetch failed: ${e.message}`);
252
277
  if (!opts.binaryOnly) console.error("continuing with hook install (use --skip-binary to suppress)");
@@ -297,6 +322,25 @@ async function cmdInstall(opts) {
297
322
  }
298
323
  }
299
324
 
325
+ if (opts.withSentinel) {
326
+ const { installSentinel } = await import("./sentinel.mjs");
327
+ try {
328
+ const platform = (await import("./binary-fetcher.mjs")).detectPlatform();
329
+ const presignedUrl = verifyInfo?.sentinel_download_urls?.[platform] || undefined;
330
+ await installSentinel({
331
+ accessKey: opts.key,
332
+ dryRun: opts.dryRun,
333
+ skipBinary: opts.skipBinary,
334
+ presignedUrl,
335
+ env: opts.env || DEFAULT_ENV,
336
+ noActivate: process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1",
337
+ });
338
+ } catch (e) {
339
+ console.error(`sentinel install failed: ${e.message}`);
340
+ process.exit(1);
341
+ }
342
+ }
343
+
300
344
  if (opts.browserExtension && !opts.dryRun) {
301
345
  const firstAgentName = agents[0];
302
346
  const firstAgent = await loadAgent(firstAgentName);
@@ -308,6 +352,19 @@ async function cmdInstall(opts) {
308
352
  }
309
353
  }
310
354
 
355
+ // Auto-sudo re-exec wrote per-agent files as root into the original user's
356
+ // home / cwd. Hand them back so the user can manage them without sudo on
357
+ // subsequent runs. System-wide writes under /opt + /etc are intentionally
358
+ // left root-owned by skipping anything outside `base`.
359
+ if (isRunningUnderSudo() && !opts.dryRun) {
360
+ for (const r of installResults) {
361
+ const mod = await loadAgent(r.name);
362
+ const agentDir = mod.meta.agentDir;
363
+ if (!agentDir) continue;
364
+ await chownToCaller(path.join(base, agentDir));
365
+ }
366
+ }
367
+
311
368
  if (restartAgents.length > 0 && !opts.dryRun) {
312
369
  const list = restartAgents.join(", ");
313
370
  console.log("");
@@ -320,7 +377,7 @@ async function cmdInstall(opts) {
320
377
  }
321
378
 
322
379
  async function cmdUninstall(opts) {
323
- const home = process.env.HOME || os.homedir();
380
+ const home = resolveCallerHome();
324
381
  const scope = opts.scope || "user";
325
382
  const base = scope === "user" ? home : process.cwd();
326
383
 
@@ -466,7 +523,7 @@ async function cmdUninstall(opts) {
466
523
  }
467
524
 
468
525
  async function cmdDoctor() {
469
- const home = process.env.HOME || os.homedir();
526
+ const home = resolveCallerHome();
470
527
  console.log(`home: ${home}`);
471
528
  console.log(`node: ${process.version}`);
472
529
  console.log(`platform: ${process.platform}/${process.arch}`);
@@ -503,7 +560,7 @@ export async function run(argv) {
503
560
  program
504
561
  .name("aidr")
505
562
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
506
- .version("0.0.2");
563
+ .version("0.0.4");
507
564
 
508
565
  program
509
566
  .command("install")
@@ -522,6 +579,7 @@ export async function run(argv) {
522
579
  .option("--uninstall", "remove all ai-scanner hooks/scripts/binaries for the selected agents (see 'uninstall' command)", false)
523
580
  .option("--scheduled", "enable scheduled endpoint-info collection (creates a cron/systemd/launchd entry; without this flag endpoint-info is never collected)", false)
524
581
  .option("--interval <hours>", "scheduled scan interval in hours (min 1, default 4; requires --scheduled)", "4")
582
+ .option("--with-sentinel", "install coworker-sentinel host-level EDR daemon (Pro/Trial plan; requires sudo)", false)
525
583
  .option("--browser-extension", "install browser extension native messaging host for Shadow IT network monitoring (default: disabled)", false)
526
584
  .action(cmdInstall);
527
585
 
@@ -0,0 +1,239 @@
1
+ /**
2
+ * coworker-sentinel install/uninstall helpers.
3
+ *
4
+ * Requires root (sudo -E) because the daemon runs as a LaunchDaemon (macOS)
5
+ * or systemd service (Linux) to monitor OS-level events system-wide.
6
+ *
7
+ * Install paths (shared with scheduled.mjs):
8
+ * Binary: /opt/coworker/aidr/bin/coworker-sentinel
9
+ * macOS: /Library/LaunchDaemons/jp.coworker.sentinel.plist
10
+ * Linux: /etc/systemd/system/coworker-sentinel.service
11
+ * Env: /etc/aidr/aidr.env (shared with scheduler)
12
+ */
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { execFile, spawnSync } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { fetchSentinel, detectPlatform } from "./binary-fetcher.mjs";
18
+ import { SYSTEM_BIN_DIR, SYSTEM_ENV_FILE } from "./scheduled.mjs";
19
+
20
+ const execFileP = promisify(execFile);
21
+
22
+ const ROOT_PREFIX = process.env.AIDR_SCHEDULED_ROOT || "";
23
+
24
+ export const SENTINEL_BIN = path.join(SYSTEM_BIN_DIR, "coworker-sentinel");
25
+ export const SENTINEL_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.sentinel.plist";
26
+ export const SENTINEL_SERVICE = ROOT_PREFIX + "/etc/systemd/system/coworker-sentinel.service";
27
+ export const SENTINEL_LOG_DIR = ROOT_PREFIX + "/var/log/coworker-sentinel";
28
+
29
+ function isRoot() {
30
+ if (process.env.AI_SCANNER_FAKE_ROOT === "1") return true;
31
+ return Boolean(process.getuid && process.getuid() === 0);
32
+ }
33
+
34
+ async function exists(p) {
35
+ try { await fs.access(p); return true; } catch { return false; }
36
+ }
37
+
38
+ export async function isSentinelInstalled() {
39
+ return (
40
+ (await exists(SENTINEL_PLIST)) ||
41
+ (await exists(SENTINEL_SERVICE))
42
+ );
43
+ }
44
+
45
+ function buildLaunchdPlist(envFile) {
46
+ return `<?xml version="1.0" encoding="UTF-8"?>
47
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
48
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
49
+ <plist version="1.0">
50
+ <dict>
51
+ <key>Label</key>
52
+ <string>jp.coworker.sentinel</string>
53
+ <key>ProgramArguments</key>
54
+ <array>
55
+ <string>/bin/sh</string>
56
+ <string>-c</string>
57
+ <string>. ${envFile} &amp;&amp; exec ${SENTINEL_BIN}</string>
58
+ </array>
59
+ <key>RunAtLoad</key>
60
+ <true/>
61
+ <key>KeepAlive</key>
62
+ <true/>
63
+ <key>StandardOutPath</key>
64
+ <string>${SENTINEL_LOG_DIR}/sentinel.log</string>
65
+ <key>StandardErrorPath</key>
66
+ <string>${SENTINEL_LOG_DIR}/sentinel.log</string>
67
+ <key>ThrottleInterval</key>
68
+ <integer>10</integer>
69
+ </dict>
70
+ </plist>
71
+ `;
72
+ }
73
+
74
+ function buildSystemdService(envFile) {
75
+ return `[Unit]
76
+ Description=coworker-sentinel Host EDR Daemon
77
+ After=network.target
78
+ StartLimitIntervalSec=60
79
+ StartLimitBurst=5
80
+
81
+ [Service]
82
+ Type=simple
83
+ EnvironmentFile=${envFile}
84
+ ExecStart=${SENTINEL_BIN}
85
+ Restart=on-failure
86
+ RestartSec=5
87
+ StandardOutput=journal
88
+ StandardError=journal
89
+ SyslogIdentifier=coworker-sentinel
90
+
91
+ [Install]
92
+ WantedBy=multi-user.target
93
+ `;
94
+ }
95
+
96
+ /**
97
+ * Install the sentinel daemon.
98
+ *
99
+ * opts:
100
+ * accessKey — ak_xxx (written to env file if not already present)
101
+ * dryRun — log actions without executing
102
+ * noActivate — skip launchctl/systemctl activation
103
+ * presignedUrl — presigned S3 URL from verify response (optional)
104
+ * env — "dev" | "prod"
105
+ */
106
+ export async function installSentinel(opts = {}) {
107
+ const { accessKey, dryRun = false, skipBinary = false, noActivate = false, presignedUrl, env = "prod" } = opts;
108
+
109
+ if (!isRoot()) {
110
+ throw new Error(
111
+ "installSentinel requires root privileges.\n" +
112
+ "Re-run with: sudo -E npx @coworker-jp/aidr install --key <key> --with-sentinel"
113
+ );
114
+ }
115
+
116
+ const platform = detectPlatform();
117
+ console.error(`[sentinel] Installing coworker-sentinel for ${platform}…`);
118
+
119
+ // 1. Download binary
120
+ if (!dryRun && !skipBinary) {
121
+ await fs.mkdir(SYSTEM_BIN_DIR, { recursive: true });
122
+ console.error(`[sentinel] Downloading binary → ${SENTINEL_BIN}`);
123
+ await fetchSentinel(SENTINEL_BIN, { accessKey, presignedUrl, env });
124
+ } else if (skipBinary) {
125
+ console.error(`[sentinel] Skipping binary download (--skip-binary)`);
126
+ } else {
127
+ console.error(`[sentinel] DRY-RUN: would download → ${SENTINEL_BIN}`);
128
+ }
129
+
130
+ // 2. Create log directory
131
+ if (!dryRun) {
132
+ await fs.mkdir(SENTINEL_LOG_DIR, { recursive: true, mode: 0o755 });
133
+ }
134
+
135
+ // 3. Write env file (append AI_SCANNER_ACCESS_KEY if missing)
136
+ if (accessKey && !dryRun) {
137
+ let content = "";
138
+ try { content = await fs.readFile(SYSTEM_ENV_FILE, "utf8"); } catch { /* first run */ }
139
+ if (!content.includes("AI_SCANNER_ACCESS_KEY")) {
140
+ await fs.mkdir(path.dirname(SYSTEM_ENV_FILE), { recursive: true });
141
+ await fs.appendFile(SYSTEM_ENV_FILE, `\nAI_SCANNER_ACCESS_KEY=${accessKey}\n`);
142
+ await fs.chmod(SYSTEM_ENV_FILE, 0o600);
143
+ }
144
+ }
145
+
146
+ // 4. Write service/plist
147
+ if (process.platform === "darwin") {
148
+ await _installMacos(dryRun, noActivate);
149
+ } else {
150
+ await _installLinux(dryRun, noActivate);
151
+ }
152
+
153
+ console.error("[sentinel] Installation complete.");
154
+ }
155
+
156
+ async function _installMacos(dryRun, noActivate) {
157
+ const plistContent = buildLaunchdPlist(SYSTEM_ENV_FILE);
158
+ if (!dryRun) {
159
+ await fs.mkdir(path.dirname(SENTINEL_PLIST), { recursive: true });
160
+ await fs.writeFile(SENTINEL_PLIST, plistContent, { mode: 0o644 });
161
+ console.error(`[sentinel] Written: ${SENTINEL_PLIST}`);
162
+
163
+ if (!noActivate && process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE !== "1") {
164
+ // Unload existing (ignore errors) then load
165
+ spawnSync("launchctl", ["unload", SENTINEL_PLIST], { stdio: "ignore" });
166
+ const load = spawnSync("launchctl", ["load", "-w", SENTINEL_PLIST]);
167
+ if (load.status !== 0) {
168
+ console.warn("[sentinel] launchctl load failed — daemon may need manual start");
169
+ } else {
170
+ console.error("[sentinel] Daemon loaded via launchctl");
171
+ }
172
+ }
173
+ } else {
174
+ console.error(`[sentinel] DRY-RUN: would write ${SENTINEL_PLIST}`);
175
+ }
176
+ }
177
+
178
+ async function _installLinux(dryRun, noActivate) {
179
+ const serviceContent = buildSystemdService(SYSTEM_ENV_FILE);
180
+ const serviceDir = path.dirname(SENTINEL_SERVICE);
181
+ if (!dryRun) {
182
+ await fs.mkdir(serviceDir, { recursive: true });
183
+ await fs.writeFile(SENTINEL_SERVICE, serviceContent, { mode: 0o644 });
184
+ console.error(`[sentinel] Written: ${SENTINEL_SERVICE}`);
185
+
186
+ if (!noActivate && process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE !== "1") {
187
+ spawnSync("systemctl", ["daemon-reload"], { stdio: "ignore" });
188
+ spawnSync("systemctl", ["enable", "--now", "coworker-sentinel"], { stdio: "ignore" });
189
+ console.error("[sentinel] Enabled and started via systemctl");
190
+ }
191
+ } else {
192
+ console.error(`[sentinel] DRY-RUN: would write ${SENTINEL_SERVICE}`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Uninstall the sentinel daemon.
198
+ */
199
+ export async function uninstallSentinel({ dryRun = false } = {}) {
200
+ if (!isRoot()) {
201
+ throw new Error("uninstallSentinel requires root privileges.");
202
+ }
203
+
204
+ console.error("[sentinel] Uninstalling coworker-sentinel…");
205
+
206
+ if (process.platform === "darwin") {
207
+ if (await exists(SENTINEL_PLIST)) {
208
+ if (!dryRun) {
209
+ spawnSync("launchctl", ["unload", SENTINEL_PLIST], { stdio: "ignore" });
210
+ await fs.unlink(SENTINEL_PLIST);
211
+ console.error(`[sentinel] Removed: ${SENTINEL_PLIST}`);
212
+ } else {
213
+ console.error(`[sentinel] DRY-RUN: would remove ${SENTINEL_PLIST}`);
214
+ }
215
+ }
216
+ } else {
217
+ if (await exists(SENTINEL_SERVICE)) {
218
+ if (!dryRun) {
219
+ spawnSync("systemctl", ["disable", "--now", "coworker-sentinel"], { stdio: "ignore" });
220
+ await fs.unlink(SENTINEL_SERVICE);
221
+ spawnSync("systemctl", ["daemon-reload"], { stdio: "ignore" });
222
+ console.error(`[sentinel] Removed: ${SENTINEL_SERVICE}`);
223
+ } else {
224
+ console.error(`[sentinel] DRY-RUN: would remove ${SENTINEL_SERVICE}`);
225
+ }
226
+ }
227
+ }
228
+
229
+ if (await exists(SENTINEL_BIN)) {
230
+ if (!dryRun) {
231
+ await fs.unlink(SENTINEL_BIN);
232
+ console.error(`[sentinel] Removed: ${SENTINEL_BIN}`);
233
+ } else {
234
+ console.error(`[sentinel] DRY-RUN: would remove ${SENTINEL_BIN}`);
235
+ }
236
+ }
237
+
238
+ console.error("[sentinel] Uninstall complete.");
239
+ }
@@ -0,0 +1,111 @@
1
+ // SUDO_USER-aware HOME resolution and ownership helpers.
2
+ //
3
+ // `aidr install --scheduled` auto-escalates via `sudo -E` (cli.mjs).
4
+ // After sudo, the new node process runs as root with HOME reset to /root by
5
+ // the default `set_home` policy. Without correction, every path derived from
6
+ // HOME (e.g. ~/.claude/settings.json under `--scope user`) would land in
7
+ // /root instead of the original user's home, AND every file we write would
8
+ // be root-owned even though the user expects to manage them.
9
+ //
10
+ // We restore the caller's identity via the SUDO_USER / SUDO_UID / SUDO_GID
11
+ // vars sudo always sets, and chown the user-scope writes back to that user
12
+ // when the install completes.
13
+
14
+ import fs from "node:fs";
15
+ import fsp from "node:fs/promises";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ /**
21
+ * Look up `username`'s home directory.
22
+ * 1. /etc/passwd (Linux always; macOS for system users only)
23
+ * 2. macOS dscl (regular macOS users live in Open Directory)
24
+ * Returns null if not found.
25
+ *
26
+ * Test override: AIDR_PASSWD_FILE points at an alternate passwd-format file.
27
+ */
28
+ export function lookupUserHome(username) {
29
+ if (!username) return null;
30
+ const passwdPath = process.env.AIDR_PASSWD_FILE || "/etc/passwd";
31
+ try {
32
+ const passwd = fs.readFileSync(passwdPath, "utf8");
33
+ for (const line of passwd.split("\n")) {
34
+ const f = line.split(":");
35
+ if (f[0] === username && f[5]) return f[5];
36
+ }
37
+ } catch { /* fall through */ }
38
+
39
+ if (process.platform === "darwin" && !process.env.AIDR_PASSWD_FILE) {
40
+ try {
41
+ const out = execFileSync(
42
+ "dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"],
43
+ { encoding: "utf8", timeout: 2000 }
44
+ );
45
+ const m = out.match(/NFSHomeDirectory:\s*(.+)/);
46
+ if (m) return m[1].trim();
47
+ } catch { /* fall through */ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Resolve the calling user's home.
54
+ * - If running as root with SUDO_USER set, use SUDO_USER's home (auto-sudo case).
55
+ * - Otherwise fall back to $HOME / os.homedir().
56
+ *
57
+ * Test override: AIDR_FORCE_HOME bypasses everything.
58
+ */
59
+ export function resolveCallerHome() {
60
+ if (process.env.AIDR_FORCE_HOME) return process.env.AIDR_FORCE_HOME;
61
+ if (isRunningUnderSudo()) {
62
+ const sudoHome = lookupUserHome(process.env.SUDO_USER);
63
+ if (sudoHome) return sudoHome;
64
+ }
65
+ return process.env.HOME || os.homedir();
66
+ }
67
+
68
+ /**
69
+ * True when we're running as root *and* SUDO_USER is set — i.e. the auto-sudo
70
+ * re-exec path. Plain `su` or login-as-root sets neither, and is treated as
71
+ * intentional root usage (no chown back).
72
+ */
73
+ export function isRunningUnderSudo() {
74
+ if (process.platform === "win32") return false;
75
+ if (!process.getuid || process.getuid() !== 0) return false;
76
+ return Boolean(process.env.SUDO_USER);
77
+ }
78
+
79
+ /**
80
+ * SUDO_UID / SUDO_GID parsed as integers, or null when not under sudo.
81
+ */
82
+ export function callerIds() {
83
+ if (!isRunningUnderSudo()) return null;
84
+ const uid = parseInt(process.env.SUDO_UID || "", 10);
85
+ const gid = parseInt(process.env.SUDO_GID || "", 10);
86
+ if (Number.isInteger(uid) && Number.isInteger(gid)) return { uid, gid };
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Recursively chown a path tree to the caller (SUDO_USER) when running under
92
+ * auto-sudo. No-op when not under sudo or on Windows. Errors per-entry are
93
+ * swallowed — worst case the user fixes ownership manually with a single
94
+ * `chown -R`. Symlinks are chowned via lchown (don't follow).
95
+ */
96
+ export async function chownToCaller(targetPath) {
97
+ const ids = callerIds();
98
+ if (!ids) return;
99
+ await chownTree(targetPath, ids.uid, ids.gid);
100
+ }
101
+
102
+ async function chownTree(p, uid, gid) {
103
+ let st;
104
+ try { st = await fsp.lstat(p); } catch { return; }
105
+ try { await fsp.lchown(p, uid, gid); } catch { /* ignore */ }
106
+ if (st.isDirectory()) {
107
+ let entries;
108
+ try { entries = await fsp.readdir(p); } catch { return; }
109
+ await Promise.all(entries.map((e) => chownTree(path.join(p, e), uid, gid)));
110
+ }
111
+ }
package/src/templates.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // SOURCE: docker/user-frontend/src/lib/scanner-templates.ts
2
- // ESM port of the scanner hook templates. Keep in sync with the TS source.
3
- // Templates are byte-identical to the canonical .{agent}/ files in ai-scanner
4
- // repo (Claude / Cursor / Windsurf / Kiro) — only the access key value differs.
1
+ // Canonical source for the scanner hook templates installed by aidr.
2
+ // Each template emits a script byte-identical to the corresponding file under
3
+ // .{agent}/ at the root of this repo (Claude / Cursor / Windsurf / Kiro);
4
+ // only the access key and the verify endpoint differ.
5
5
 
6
6
  import { buildClaudeSettings, buildGeminiSettings } from "./merge.mjs";
7
7
 
package/src/verify.mjs CHANGED
@@ -42,6 +42,10 @@ export async function verifyKey(accessKey, env = "prod") {
42
42
  status: body.status,
43
43
  plan: body.plan || "standard",
44
44
  expires_at: body.expires_at || null,
45
+ edr_enabled: body.edr_enabled || false,
46
+ latest_sentinel_version: body.latest_sentinel_version || null,
47
+ sentinel_download_urls: body.sentinel_download_urls || {},
48
+ sentinel_sha256: body.sentinel_sha256 || {},
45
49
  };
46
50
  }
47
51