@coworker-jp/aidr 0.1.10 → 0.1.18

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.
@@ -0,0 +1,208 @@
1
+ // Install-environment gate (issue #243).
2
+ //
3
+ // ai-scanner's hook integration targets environments where coworker-sentinel
4
+ // cannot run: cloud coding-agent sandboxes (cloud Claude Code), CI jobs,
5
+ // devcontainers, and other ephemeral machines. The protection layer for a
6
+ // person's own PC is the coworker-sentinel daemon (OS-native installer).
7
+ //
8
+ // Engineers routinely run `npx @coworker-jp/aidr install` on their laptop
9
+ // without reading the docs, then assume the endpoint is protected — leaving a
10
+ // DLP/EDR gap because sentinel was never installed. This module is the
11
+ // poka-yoke: when the environment looks like a personal PC (interactive
12
+ // terminal, zero cloud/CI/remote indicators), `aidr install` stops and points
13
+ // at the sentinel installer instead. `--local` (or AIDR_ALLOW_LOCAL_INSTALL=1)
14
+ // explicitly overrides.
15
+ //
16
+ // Fail-safe direction: any remote indicator, or a non-interactive stdin
17
+ // (= automation / an agent running the command), passes silently — those are
18
+ // exactly the environments ai-scanner is for, and we must never break CI or
19
+ // cloud sandboxes with a prompt they cannot answer.
20
+
21
+ import fs from "node:fs";
22
+ import { downloadBase } from "./verify.mjs";
23
+
24
+ // Environment variables that mark a CI system, a cloud dev environment, or a
25
+ // remote shell. Presence of ANY (with a non-falsy value) means "not a personal
26
+ // PC install" and the gate stays open.
27
+ //
28
+ // Measured reference point (cloud Claude Code / agent-run shells): when an AI
29
+ // agent executes the install command, stdin is not a TTY — so even an agent on
30
+ // an unusual host without these vars still passes via the TTY rule. A human
31
+ // typing in a terminal on their own laptop has a TTY and none of these vars.
32
+ const REMOTE_ENV_FLAGS = [
33
+ // CI systems (most set CI=true; the rest are vendor-specific)
34
+ "CI",
35
+ "GITHUB_ACTIONS",
36
+ "GITLAB_CI",
37
+ "CIRCLECI",
38
+ "TRAVIS",
39
+ "BUILDKITE",
40
+ "JENKINS_URL",
41
+ "TEAMCITY_VERSION",
42
+ "TF_BUILD",
43
+ "CODEBUILD_BUILD_ID",
44
+ // Cloud dev environments / hosted sandboxes
45
+ "CODESPACES",
46
+ "GITPOD_WORKSPACE_ID",
47
+ "CLOUD_SHELL",
48
+ "REPL_ID",
49
+ // Remote shell: someone SSH'd into this machine, so it is a server (or a
50
+ // remotely-managed box), not the operator's own desktop session.
51
+ "SSH_CONNECTION",
52
+ "SSH_CLIENT",
53
+ "SSH_TTY",
54
+ // Kubernetes pod
55
+ "KUBERNETES_SERVICE_HOST",
56
+ // systemd-nspawn / podman export `container=`
57
+ "container",
58
+ ];
59
+
60
+ function envFlagSet(env, name) {
61
+ const v = env[name];
62
+ if (v === undefined || v === "") return false;
63
+ // A handful of tools export CI=false / CI=0 to mean "not CI".
64
+ if (/^(false|0)$/i.test(String(v).trim())) return false;
65
+ return true;
66
+ }
67
+
68
+ function safeExists(p) {
69
+ try {
70
+ fs.statSync(p);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ function safeRead(p) {
78
+ try {
79
+ return fs.readFileSync(p, "utf8");
80
+ } catch {
81
+ return "";
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Collect indicators that this process is running in a cloud / CI / container
87
+ * / remote environment. Returns human-readable reason strings (empty array =
88
+ * no remote indicator found).
89
+ */
90
+ export function collectRemoteIndicators({
91
+ env = process.env,
92
+ platform = process.platform,
93
+ exists = safeExists,
94
+ read = safeRead,
95
+ } = {}) {
96
+ const reasons = [];
97
+ for (const name of REMOTE_ENV_FLAGS) {
98
+ if (envFlagSet(env, name)) reasons.push(`env ${name} is set`);
99
+ }
100
+ if (platform === "linux") {
101
+ // Container runtimes leave marker files / cgroup names behind.
102
+ if (exists("/.dockerenv")) reasons.push("/.dockerenv exists (container)");
103
+ if (exists("/run/.containerenv")) reasons.push("/run/.containerenv exists (container)");
104
+ const cgroup = read("/proc/1/cgroup");
105
+ if (/docker|containerd|kubepods|libpod|lxc|buildkit|sandbox/i.test(cgroup)) {
106
+ reasons.push("/proc/1/cgroup names a container runtime");
107
+ }
108
+ }
109
+ return reasons;
110
+ }
111
+
112
+ /**
113
+ * WSL is NOT a remote indicator: a WSL distro lives on the user's own Windows
114
+ * PC, which should be protected by the sentinel Windows installer. Detecting
115
+ * it only changes which installer we recommend.
116
+ */
117
+ export function isWsl({ platform = process.platform, read = safeRead } = {}) {
118
+ if (platform !== "linux") return false;
119
+ return /microsoft/i.test(read("/proc/version"));
120
+ }
121
+
122
+ /** Which sentinel installer platform to recommend for this machine. */
123
+ export function sentinelPlatformFor({
124
+ platform = process.platform,
125
+ read = safeRead,
126
+ } = {}) {
127
+ if (platform === "win32") return "windows";
128
+ if (platform === "darwin") return "macos";
129
+ if (isWsl({ platform, read })) return "windows"; // WSL = the user's Windows PC
130
+ return "linux";
131
+ }
132
+
133
+ /**
134
+ * Decide whether `aidr install` should stop because this looks like a
135
+ * personal PC. Pure decision function — injection points exist for tests.
136
+ *
137
+ * Returns { block: boolean, reasons: string[], sentinelPlatform: string }.
138
+ * - block=false, reasons=[...] → remote indicators found (silent pass)
139
+ * - block=false, reasons=[] → non-interactive (silent pass)
140
+ * - block=true → interactive TTY + zero remote indicators
141
+ */
142
+ export function evaluateInstallGate({
143
+ env = process.env,
144
+ platform = process.platform,
145
+ // AIDR_TEST_ASSUME_TTY=1 lets non-PTY test harnesses exercise the blocking
146
+ // branch. It only ever forces the fail-safe (more blocking) direction.
147
+ tty = process.stdin.isTTY === true || env?.AIDR_TEST_ASSUME_TTY === "1",
148
+ exists = safeExists,
149
+ read = safeRead,
150
+ } = {}) {
151
+ const sentinelPlatform = sentinelPlatformFor({ platform, read });
152
+ if (envFlagSet(env, "AIDR_ALLOW_LOCAL_INSTALL")) {
153
+ return { block: false, reasons: ["AIDR_ALLOW_LOCAL_INSTALL is set"], sentinelPlatform };
154
+ }
155
+ const reasons = collectRemoteIndicators({ env, platform, exists, read });
156
+ if (reasons.length > 0) return { block: false, reasons, sentinelPlatform };
157
+ if (!tty) return { block: false, reasons: [], sentinelPlatform };
158
+ return { block: true, reasons: [], sentinelPlatform };
159
+ }
160
+
161
+ const PORTAL_DOWNLOAD_PAGES = {
162
+ prod: "https://aidr-frontend-prod.vercel.app/ja/download",
163
+ dev: "https://aidr-frontend-dev.vercel.app/ja/download",
164
+ };
165
+
166
+ const SENTINEL_INSTALLER_LABELS = {
167
+ windows: "coworker-sentinel-setup.exe (Windows)",
168
+ macos: "CoworkerSentinel.dmg (macOS)",
169
+ linux: "coworker-sentinel.deb (Linux, x86_64; arm64 は /linux-arm64)",
170
+ };
171
+
172
+ /**
173
+ * The message printed when the gate blocks. Plain-language (情シス /
174
+ * non-specialist audience), bilingual, and actionable in one read:
175
+ * what happened, what to run instead, and how to override.
176
+ */
177
+ export function formatLocalPcGuidance({ sentinelPlatform, env: targetEnv = "prod" } = {}) {
178
+ const setupUrl = `${downloadBase(targetEnv)}/setup/${sentinelPlatform}`;
179
+ const portalUrl = PORTAL_DOWNLOAD_PAGES[targetEnv] || PORTAL_DOWNLOAD_PAGES.prod;
180
+ const installerLabel = SENTINEL_INSTALLER_LABELS[sentinelPlatform] || sentinelPlatform;
181
+ return [
182
+ "────────────────────────────────────────────────────────────",
183
+ "このマシンは手元の PC (ローカル環境) のようです。インストールを中断しました。",
184
+ "",
185
+ "ai-scanner の hook は、クラウドで動く AI コーディングエージェント",
186
+ "(クラウド版 Claude Code・CI・devcontainer 等) 向けの補助スキャナーです。",
187
+ "この PC を守るのは coworker-sentinel (常駐の端末保護) です。",
188
+ "",
189
+ "This looks like your local PC, so the install was stopped. ai-scanner",
190
+ "hooks are for cloud / CI coding-agent environments; this machine should",
191
+ "run coworker-sentinel instead.",
192
+ "",
193
+ " 1) この PC の保護: coworker-sentinel をインストール",
194
+ ` ${installerLabel}`,
195
+ ` ダウンロード: ${setupUrl}`,
196
+ ` ポータルの Download ページ (Step 1): ${portalUrl}`,
197
+ "",
198
+ " 2) ai-scanner はリポジトリに入れて使います (クラウド側で自動適用):",
199
+ " リポジトリ直下で --scope project を付けて実行し、生成された設定",
200
+ " (.claude/ 等) をコミットしてください。",
201
+ " npx @coworker-jp/aidr install --agent claude --scope project --key ak_xxx",
202
+ "",
203
+ "意図的にこの PC へインストールする場合は --local を付けて再実行してください。",
204
+ "(To install on this PC anyway, re-run with --local.)",
205
+ "判定理由: 対話ターミナルで実行され、クラウド/CI/リモート環境の指標が見つかりませんでした。",
206
+ "────────────────────────────────────────────────────────────",
207
+ ].join("\n");
208
+ }
@@ -0,0 +1,169 @@
1
+ // Red Team ネットワーク脆弱性スキャナを MCP サーバーとして登録する。
2
+ //
3
+ // 従来は Claude Desktop 用の `.mcpb` バンドル (zip) をユーザーが手で展開し、
4
+ // macOS の quarantine 属性を剥がし、バンドル内 manifest.json からアクセスキーを
5
+ // 抜き出して `claude mcp add` に渡す、という 4 段階の手作業だった。`.mcpb` は
6
+ // Claude Desktop の拡張形式であって Claude Code は解釈できないため、Claude Code
7
+ // 側では「Desktop 用の箱を配って中身を自分で取り出させる」形式ミスマッチが
8
+ // 手順の複雑さを生んでいた。
9
+ //
10
+ // ここでは生バイナリを直接取得する。sha256 検証・実行権限付与・quarantine 除去は
11
+ // binary-fetcher の fetchAsset が既に行うため、手作業だった 4 段階が消える。
12
+ // `.mcpb` は Claude Desktop 用の成果物として引き続き配布される。
13
+
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+
19
+ import { fetchAsset, detectPlatform } from "./binary-fetcher.mjs";
20
+ import { downloadBase } from "./verify.mjs";
21
+
22
+ const execFileP = promisify(execFile);
23
+
24
+ /** MCP サーバー名。`claude mcp remove network-scanner` で消せる。 */
25
+ export const MCP_SERVER_NAME = "network-scanner";
26
+
27
+ /**
28
+ * Red Team の生バイナリを取得する。
29
+ *
30
+ * verify Lambda の `GET /download/red-team/<platform>` はアクセスキー認証に
31
+ * 加えて Red Team エンタイトルメント (pro/trial かつ契約シート >= 10) を要求し、
32
+ * 不適格なら 403 を返す。fetchAsset は非 2xx で throw するため、ここでは
33
+ * 特別扱いせず呼び出し側にメッセージを見せる。
34
+ */
35
+ export async function fetchRedTeamBinary(destPath, { env = "prod", accessKey } = {}) {
36
+ const platform = detectPlatform();
37
+ const binUrl = `${downloadBase(env)}/red-team/${platform}`;
38
+ const result = await fetchAsset(binUrl, destPath, { accessKey });
39
+ return { ...result, platform };
40
+ }
41
+
42
+ /**
43
+ * aidr の scope を `claude mcp add` の scope にマップする。
44
+ *
45
+ * aidr の `project` を claude の `project` に素直に写さないのは意図的。claude の
46
+ * `project` scope は `.mcp.json` に書き出す = リポジトリにコミットされる想定の
47
+ * ファイルであり、そこに MCP の env として渡すアクセスキーが載ると秘密情報が
48
+ * git 履歴に入る。`local` は同じくプロジェクト単位だが `~/.claude.json` 側に
49
+ * 保存されるため、コミット対象にならない。
50
+ */
51
+ function claudeScopeFor(scope) {
52
+ return scope === "user" ? "user" : "local";
53
+ }
54
+
55
+ /** `claude` CLI が PATH にあるか。 */
56
+ async function claudeCliAvailable() {
57
+ try {
58
+ await execFileP("claude", ["--version"]);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 手作業に落とす場合のコマンドを組み立てる(`claude` が PATH に無いとき、
67
+ * および --dry-run のときに表示する)。
68
+ */
69
+ export function buildMcpAddArgs(binPath, accessKey, scope) {
70
+ return [
71
+ "mcp",
72
+ "add",
73
+ MCP_SERVER_NAME,
74
+ "-s",
75
+ claudeScopeFor(scope),
76
+ "-e",
77
+ `AI_SCANNER_ACCESS_KEY=${accessKey}`,
78
+ "--",
79
+ binPath,
80
+ ];
81
+ }
82
+
83
+ /**
84
+ * Red Team バイナリを取得し、Claude Code に MCP サーバーとして登録する。
85
+ *
86
+ * 冪等: `claude mcp add` は同名サーバーが既にあると失敗するため、先に
87
+ * `claude mcp remove` を試す(存在しなければ無視)。
88
+ */
89
+ export async function installRedTeam({
90
+ accessKey,
91
+ env = "prod",
92
+ scope = "user",
93
+ home,
94
+ dryRun = false,
95
+ stdout = console.log,
96
+ stderr = console.error,
97
+ }) {
98
+ // 既存の install と同じ配置規約: <base>/.claude/bin/ に置く。
99
+ const binDir = path.join(home, ".claude", "bin");
100
+ const binName = process.platform === "win32" ? "network-scanner.exe" : "network-scanner";
101
+ const binPath = path.join(binDir, binName);
102
+
103
+ if (dryRun) {
104
+ stdout(`[dry-run] would download red-team binary -> ${binPath}`);
105
+ stdout(`[dry-run] would run: claude ${buildMcpAddArgs(binPath, "<key>", scope).join(" ")}`);
106
+ return { installed: false, dryRun: true, path: binPath };
107
+ }
108
+
109
+ await fs.mkdir(binDir, { recursive: true });
110
+ const res = await fetchRedTeamBinary(binPath, { env, accessKey });
111
+ stdout(
112
+ `binary: ${res.path} (${res.platform}${res.verified ? ", sha256 verified" : ""})`,
113
+ );
114
+
115
+ const args = buildMcpAddArgs(binPath, accessKey, scope);
116
+
117
+ if (!(await claudeCliAvailable())) {
118
+ stderr("`claude` CLI not found on PATH — the binary is installed but not registered.");
119
+ stderr("Run this once Claude Code is available:");
120
+ stderr(` claude ${args.join(" ")}`);
121
+ return { installed: false, registered: false, path: binPath };
122
+ }
123
+
124
+ // 再実行を許すため、既存エントリがあれば先に外す。未登録なら失敗するので無視。
125
+ try {
126
+ await execFileP("claude", ["mcp", "remove", MCP_SERVER_NAME, "-s", claudeScopeFor(scope)]);
127
+ } catch {
128
+ // not registered yet — expected on first install
129
+ }
130
+
131
+ await execFileP("claude", args);
132
+ stdout(`mcp: registered '${MCP_SERVER_NAME}' (scope=${claudeScopeFor(scope)})`);
133
+
134
+ return { installed: true, registered: true, path: binPath };
135
+ }
136
+
137
+ /** Claude Code から MCP 登録を外し、バイナリを消す。 */
138
+ export async function uninstallRedTeam({
139
+ scope = "user",
140
+ home,
141
+ dryRun = false,
142
+ stdout = console.log,
143
+ }) {
144
+ const binPath = path.join(
145
+ home,
146
+ ".claude",
147
+ "bin",
148
+ process.platform === "win32" ? "network-scanner.exe" : "network-scanner",
149
+ );
150
+
151
+ if (dryRun) {
152
+ stdout(`[dry-run] would run: claude mcp remove ${MCP_SERVER_NAME} -s ${claudeScopeFor(scope)}`);
153
+ stdout(`[dry-run] would remove ${binPath}`);
154
+ return { removed: false, dryRun: true };
155
+ }
156
+
157
+ if (await claudeCliAvailable()) {
158
+ try {
159
+ await execFileP("claude", ["mcp", "remove", MCP_SERVER_NAME, "-s", claudeScopeFor(scope)]);
160
+ stdout(`mcp: removed '${MCP_SERVER_NAME}'`);
161
+ } catch {
162
+ // already absent
163
+ }
164
+ }
165
+
166
+ await fs.rm(binPath, { force: true });
167
+ stdout(`removed ${binPath}`);
168
+ return { removed: true };
169
+ }