@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 +1 -1
- package/src/binary-fetcher.mjs +32 -0
- package/src/cli.mjs +66 -8
- package/src/sentinel.mjs +239 -0
- package/src/sudo-user.mjs +111 -0
- package/src/templates.mjs +4 -4
- package/src/verify.mjs +4 -0
package/package.json
CHANGED
package/src/binary-fetcher.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
package/src/sentinel.mjs
ADDED
|
@@ -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} && 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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|