@coworker-jp/aidr 0.1.144 → 0.1.271
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/claude-desktop.mjs +258 -0
- package/src/cli.mjs +76 -12
- package/src/red-team.mjs +223 -30
package/package.json
CHANGED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Register an MCP server with **Claude Desktop** by editing its configuration
|
|
2
|
+
// file, for the servers that have no other route into it.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists at all: `claude mcp add` only knows Claude Code, and the
|
|
5
|
+
// `.mcpb` bundle format only carries servers we build and ship ourselves. The
|
|
6
|
+
// browser-control companion is neither — it is the upstream Apache-2.0 package
|
|
7
|
+
// run through `npx`, which we deliberately do not redistribute (`red-team.mjs`,
|
|
8
|
+
// `kind: "npx"`). That left the Claude Desktop user with one option: open
|
|
9
|
+
// `claude_desktop_config.json` and hand-edit JSON.
|
|
10
|
+
//
|
|
11
|
+
// Hand-editing is the wrong ask for the audience. A mistake there is not
|
|
12
|
+
// "the new server didn't start" — a malformed configuration file takes down
|
|
13
|
+
// EVERY MCP server in Claude Desktop, including the scanner `.mcpb` the user
|
|
14
|
+
// installed one step earlier. The failure is also silent from the user's point
|
|
15
|
+
// of view: Claude Desktop simply comes back with no tools.
|
|
16
|
+
//
|
|
17
|
+
// So the work moves here, where it can be done correctly once: parse, merge,
|
|
18
|
+
// back up, write atomically — all of which `fs-utils.mjs` already implements
|
|
19
|
+
// for the agent settings files.
|
|
20
|
+
//
|
|
21
|
+
// The other thing this buys, and the reason a `.mcpb` could not replace it:
|
|
22
|
+
// **the absolute path of `npx` is resolved on the machine being configured.**
|
|
23
|
+
// A bundle is static, so it would have to hard-code either `npx` (which relies
|
|
24
|
+
// on whatever PATH Claude Desktop hands its child processes — Claude Desktop
|
|
25
|
+
// ships no Node runtime of its own) or one absolute path (which differs per
|
|
26
|
+
// install: Homebrew, nvm, Volta, system). Running on the endpoint removes that
|
|
27
|
+
// guess entirely.
|
|
28
|
+
|
|
29
|
+
import fsp from "node:fs/promises";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
|
|
34
|
+
import { writeJsonMerge } from "./fs-utils.mjs";
|
|
35
|
+
|
|
36
|
+
const execFileP = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where Claude Desktop keeps its configuration.
|
|
40
|
+
*
|
|
41
|
+
* Returns null where Claude Desktop does not exist, so callers can say so
|
|
42
|
+
* instead of writing a file nothing will ever read. There is no official Linux
|
|
43
|
+
* build; inventing a path for it would produce a silent no-op that looks like
|
|
44
|
+
* a successful install.
|
|
45
|
+
*/
|
|
46
|
+
export function desktopConfigPath(home, { platform = process.platform, env = process.env } = {}) {
|
|
47
|
+
const p = pathFor(platform);
|
|
48
|
+
if (platform === "darwin") {
|
|
49
|
+
return p.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
50
|
+
}
|
|
51
|
+
if (platform === "win32") {
|
|
52
|
+
const appData = env.APPDATA || p.join(home, "AppData", "Roaming");
|
|
53
|
+
return p.join(appData, "Claude", "claude_desktop_config.json");
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Path semantics for the platform being configured, not for the host.
|
|
60
|
+
*
|
|
61
|
+
* `platform` is injectable so the Windows layout can be tested from CI (which
|
|
62
|
+
* runs Linux). Leaving `path` host-native would make that injection a half
|
|
63
|
+
* measure: the binary NAME would follow the argument while joining and
|
|
64
|
+
* `dirname` still used POSIX rules, so `dirname("C:\\…\\node.exe")` returns "."
|
|
65
|
+
* and the resolved command silently becomes a relative path. On a real Windows
|
|
66
|
+
* machine `process.platform` selects win32 here, so behaviour is unchanged.
|
|
67
|
+
*/
|
|
68
|
+
function pathFor(platform) {
|
|
69
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Absolute path to `npx` on this machine.
|
|
74
|
+
*
|
|
75
|
+
* Order: the sibling of the Node binary currently running (we were started by
|
|
76
|
+
* `npx` itself, so that directory is where npm's shims live on every standard
|
|
77
|
+
* install), then the platform's own lookup.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately has NO fallback to the bare string `"npx"`. Writing that would
|
|
80
|
+
* hand the user back the exact failure this function exists to remove, and it
|
|
81
|
+
* would fail later — at Claude Desktop startup, with the server simply absent
|
|
82
|
+
* — rather than here, where we can still say what went wrong.
|
|
83
|
+
*/
|
|
84
|
+
export async function resolveNpxPath({
|
|
85
|
+
platform = process.platform,
|
|
86
|
+
execPath = process.execPath,
|
|
87
|
+
lookup = defaultLookup,
|
|
88
|
+
exists = defaultExists,
|
|
89
|
+
} = {}) {
|
|
90
|
+
const p = pathFor(platform);
|
|
91
|
+
const binName = platform === "win32" ? "npx.cmd" : "npx";
|
|
92
|
+
const sibling = p.join(p.dirname(execPath), binName);
|
|
93
|
+
if (await exists(sibling)) return sibling;
|
|
94
|
+
|
|
95
|
+
const found = await lookup(binName, platform);
|
|
96
|
+
if (found && (await exists(found))) return found;
|
|
97
|
+
|
|
98
|
+
throw new Error(
|
|
99
|
+
"could not locate npx on this machine. " +
|
|
100
|
+
"Claude Desktop needs an absolute path because it does not inherit your shell's PATH. " +
|
|
101
|
+
"Install Node.js (which ships npx), then re-run this command.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function defaultLookup(binName, platform) {
|
|
106
|
+
const cmd = platform === "win32" ? "where" : "which";
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await execFileP(cmd, [binName]);
|
|
109
|
+
// `where` can return several lines; take the first.
|
|
110
|
+
return (stdout || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function defaultExists(p) {
|
|
117
|
+
try {
|
|
118
|
+
await fsp.access(p);
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Merge our server entry into an existing parsed configuration.
|
|
127
|
+
*
|
|
128
|
+
* Pure so it can be tested without touching a filesystem, and because every
|
|
129
|
+
* interesting case here is a shape the user's file might already be in:
|
|
130
|
+
*
|
|
131
|
+
* - `mcpServers` absent → create it
|
|
132
|
+
* - `mcpServers` an object → add / replace one member, keep the rest
|
|
133
|
+
* - `mcpServers` NOT an object → replace it
|
|
134
|
+
*
|
|
135
|
+
* That last case is not hypothetical: a real installation was observed with
|
|
136
|
+
* `"mcpServers": []` (an empty ARRAY). Spreading an array into an object would
|
|
137
|
+
* produce `{"0": …}`-shaped garbage, and leaving it alone would make the
|
|
138
|
+
* install silently do nothing. Every unrelated top-level key is preserved
|
|
139
|
+
* untouched — real files carry app state next to `mcpServers`.
|
|
140
|
+
*/
|
|
141
|
+
export function mergeDesktopServers(existing, ours) {
|
|
142
|
+
const base = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
|
|
143
|
+
const servers =
|
|
144
|
+
base.mcpServers && typeof base.mcpServers === "object" && !Array.isArray(base.mcpServers)
|
|
145
|
+
? base.mcpServers
|
|
146
|
+
: {};
|
|
147
|
+
return { ...base, mcpServers: { ...servers, ...ours } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when `name` already has an entry we did not write. */
|
|
151
|
+
export function hasServer(existing, name) {
|
|
152
|
+
const servers = existing && typeof existing === "object" ? existing.mcpServers : null;
|
|
153
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) return false;
|
|
154
|
+
return Object.prototype.hasOwnProperty.call(servers, name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Install an `npx`-kind MCP server into Claude Desktop.
|
|
159
|
+
*
|
|
160
|
+
* `meta` is a `RED_TEAM_PRODUCTS` entry (`kind: "npx"`). Binary products are
|
|
161
|
+
* rejected by the caller: those ship as `.mcpb` bundles, which is the route
|
|
162
|
+
* Claude Desktop already has for them.
|
|
163
|
+
*/
|
|
164
|
+
export async function installDesktopCompanion({
|
|
165
|
+
meta,
|
|
166
|
+
home,
|
|
167
|
+
dryRun = false,
|
|
168
|
+
force = false,
|
|
169
|
+
platform = process.platform,
|
|
170
|
+
env = process.env,
|
|
171
|
+
stdout = console.log,
|
|
172
|
+
stderr = console.error,
|
|
173
|
+
npxPath = null,
|
|
174
|
+
readConfig = defaultReadConfig,
|
|
175
|
+
} = {}) {
|
|
176
|
+
const target = desktopConfigPath(home, { platform, env });
|
|
177
|
+
if (!target) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Claude Desktop is not available on this platform (${platform}). ` +
|
|
180
|
+
"Use --agent claude to install into Claude Code instead.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Read before writing so we can refuse to take over a name the user already
|
|
185
|
+
// configured. Same rule as the Claude Code path: the upstream server names
|
|
186
|
+
// are generic, and a customer may well have registered `playwright`
|
|
187
|
+
// themselves with a different version or different flags.
|
|
188
|
+
const existing = await readConfig(target);
|
|
189
|
+
if (!force && hasServer(existing, meta.mcpServerName)) {
|
|
190
|
+
stderr(`'${meta.mcpServerName}' is already configured in ${target} — leaving it as is.`);
|
|
191
|
+
stderr(`Re-run with --force to replace it with: npx -y ${meta.npmSpec} ${meta.serverArgs.join(" ")}`);
|
|
192
|
+
return { installed: false, skipped: true, path: target };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const command = npxPath || (await resolveNpxPath({ platform }));
|
|
196
|
+
const ours = {
|
|
197
|
+
[meta.mcpServerName]: {
|
|
198
|
+
command,
|
|
199
|
+
args: ["-y", meta.npmSpec, ...meta.serverArgs],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const res = await writeJsonMerge(target, ours, mergeDesktopServers, { dryRun });
|
|
204
|
+
if (res.written) {
|
|
205
|
+
stdout(`claude desktop: registered '${meta.mcpServerName}' in ${target}`);
|
|
206
|
+
stdout(` command: ${command}`);
|
|
207
|
+
} else if (res.unchanged) {
|
|
208
|
+
stdout(`claude desktop: '${meta.mcpServerName}' already up to date in ${target}`);
|
|
209
|
+
}
|
|
210
|
+
return { installed: Boolean(res.written), path: target, backupPath: res.backupPath, command };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Remove our server entry from the Claude Desktop configuration. */
|
|
214
|
+
export async function uninstallDesktopCompanion({
|
|
215
|
+
meta,
|
|
216
|
+
home,
|
|
217
|
+
dryRun = false,
|
|
218
|
+
platform = process.platform,
|
|
219
|
+
env = process.env,
|
|
220
|
+
stdout = console.log,
|
|
221
|
+
readConfig = defaultReadConfig,
|
|
222
|
+
} = {}) {
|
|
223
|
+
const target = desktopConfigPath(home, { platform, env });
|
|
224
|
+
if (!target) return { removed: false };
|
|
225
|
+
|
|
226
|
+
const existing = await readConfig(target);
|
|
227
|
+
if (!hasServer(existing, meta.mcpServerName)) {
|
|
228
|
+
// Say so. Returning quietly here made "there was nothing to remove" and
|
|
229
|
+
// "the removal worked" look identical from the terminal — both were exit 0
|
|
230
|
+
// with no output, and the user has no way to tell which one happened.
|
|
231
|
+
stdout(`claude desktop: '${meta.mcpServerName}' is not registered in ${target} — nothing to remove.`);
|
|
232
|
+
return { removed: false, path: target };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Rebuild without our entry rather than deleting the file: everything else in
|
|
236
|
+
// it belongs to the user.
|
|
237
|
+
const kept = { ...existing.mcpServers };
|
|
238
|
+
delete kept[meta.mcpServerName];
|
|
239
|
+
const res = await writeJsonMerge(
|
|
240
|
+
target,
|
|
241
|
+
{},
|
|
242
|
+
(base) => ({ ...(base || {}), mcpServers: kept }),
|
|
243
|
+
{ dryRun },
|
|
244
|
+
);
|
|
245
|
+
if (res.written) stdout(`claude desktop: removed '${meta.mcpServerName}' from ${target}`);
|
|
246
|
+
return { removed: Boolean(res.written), path: target };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function defaultReadConfig(target) {
|
|
250
|
+
try {
|
|
251
|
+
const text = await fsp.readFile(target, "utf8");
|
|
252
|
+
return JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
253
|
+
} catch {
|
|
254
|
+
// Missing is the common first-install case. Malformed is caught (and
|
|
255
|
+
// refused) by writeJsonMerge, which is the only thing that writes.
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -549,12 +549,52 @@ aidr now installs agent hooks only:
|
|
|
549
549
|
// install/uninstall の --agent 複数指定・--all の枠組みには乗せず、独立した
|
|
550
550
|
// サブコマンドにしている。実装は red-team.mjs に分離 (scheduled/sentinel と
|
|
551
551
|
// 同じ遅延 import パターン)。
|
|
552
|
+
// `claude` は Claude Code (`claude mcp add`)、`claude-desktop` は Claude Desktop
|
|
553
|
+
// (設定ファイルへのマージ)。後者を足したのは、Claude Desktop に入る唯一の手段が
|
|
554
|
+
// 「`claude_desktop_config.json` を手で編集する」だったため — 失敗すると設定
|
|
555
|
+
// ファイル全体が読めなくなり、その端末の MCP サーバーが全滅する (直前に入れた
|
|
556
|
+
// スキャナの `.mcpb` ごと)。実装は claude-desktop.mjs。
|
|
557
|
+
const RED_TEAM_AGENTS = new Set(["claude", "claude-desktop"]);
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Which directory the install resolves its target against.
|
|
561
|
+
*
|
|
562
|
+
* Exported as a pure function so it can be tested on any platform. The same
|
|
563
|
+
* property asserted through the CLI would only ever run on macOS or Windows —
|
|
564
|
+
* `installDesktopCompanion` rejects Linux before it gets here — and CI runs on
|
|
565
|
+
* Linux, so a CLI-level test of this would be a test that never executes
|
|
566
|
+
* (CLAUDE.md: テストは「あること」ではなく「Linux CI で走ること」).
|
|
567
|
+
*
|
|
568
|
+
* Claude Desktop has exactly one configuration file, in the user's profile;
|
|
569
|
+
* there is no project-scoped variant. Honouring `--scope project` for it would
|
|
570
|
+
* write a file Claude Desktop never reads and report success either way.
|
|
571
|
+
*/
|
|
572
|
+
export function redTeamInstallBase({ agent, scope, home, cwd }) {
|
|
573
|
+
if (agent === "claude-desktop") return home;
|
|
574
|
+
return scope === "user" ? home : cwd;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Reject an `--agent` this CLI does not know, for whichever subcommand asked.
|
|
579
|
+
*
|
|
580
|
+
* Shared by install and uninstall on purpose. When only install validated,
|
|
581
|
+
* `red-team uninstall --agent <typo>` did not fail — it fell through to the
|
|
582
|
+
* default branch and removed the **Claude Code** registration, reporting
|
|
583
|
+
* success. A typo silently doing the wrong removal is worse than the same typo
|
|
584
|
+
* on install, because there is nothing left on screen to say what happened.
|
|
585
|
+
*/
|
|
586
|
+
function requireSupportedAgent(subcommand, agent) {
|
|
587
|
+
if (RED_TEAM_AGENTS.has(agent)) return;
|
|
588
|
+
console.error(
|
|
589
|
+
`red-team ${subcommand}: unsupported agent '${agent}' ` +
|
|
590
|
+
`(expected ${[...RED_TEAM_AGENTS].join(" | ")})`,
|
|
591
|
+
);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
552
595
|
async function cmdRedTeamInstall(opts) {
|
|
553
596
|
const agent = opts.agent || "claude";
|
|
554
|
-
|
|
555
|
-
console.error(`red-team install: unsupported agent '${agent}' (only 'claude' is supported)`);
|
|
556
|
-
process.exit(1);
|
|
557
|
-
}
|
|
597
|
+
requireSupportedAgent("install", agent);
|
|
558
598
|
|
|
559
599
|
const home = resolveCallerHome();
|
|
560
600
|
const scope = opts.scope || "user";
|
|
@@ -576,12 +616,22 @@ async function cmdRedTeamInstall(opts) {
|
|
|
576
616
|
accessKey: opts.key,
|
|
577
617
|
env,
|
|
578
618
|
scope,
|
|
579
|
-
home:
|
|
619
|
+
home: redTeamInstallBase({ agent, scope, home, cwd: process.cwd() }),
|
|
580
620
|
dryRun: opts.dryRun,
|
|
621
|
+
force: opts.force,
|
|
622
|
+
product: opts.product || "network-scanner",
|
|
623
|
+
agent,
|
|
581
624
|
});
|
|
582
625
|
if (res.installed) {
|
|
583
626
|
console.log("");
|
|
584
|
-
|
|
627
|
+
// Claude Desktop keeps running with the configuration it read at launch,
|
|
628
|
+
// and closing the window does not end the process — so "restart" has to
|
|
629
|
+
// say *quit*, or the user sees no new tools and concludes it failed.
|
|
630
|
+
console.log(
|
|
631
|
+
agent === "claude-desktop"
|
|
632
|
+
? "Next: quit Claude Desktop completely (Cmd+Q on macOS / quit from the notification area on Windows) and start it again."
|
|
633
|
+
: "Next: restart Claude Code so the MCP server is picked up.",
|
|
634
|
+
);
|
|
585
635
|
}
|
|
586
636
|
} catch (e) {
|
|
587
637
|
// 403 red_team_requires_10_seats は「Pro/Trial かつ契約シート 10 以上」の
|
|
@@ -593,14 +643,24 @@ async function cmdRedTeamInstall(opts) {
|
|
|
593
643
|
}
|
|
594
644
|
|
|
595
645
|
async function cmdRedTeamUninstall(opts) {
|
|
646
|
+
const agent = opts.agent || "claude";
|
|
647
|
+
requireSupportedAgent("uninstall", agent);
|
|
648
|
+
|
|
596
649
|
const home = resolveCallerHome();
|
|
597
650
|
const scope = opts.scope || "user";
|
|
598
651
|
const { uninstallRedTeam } = await import("./red-team.mjs");
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
652
|
+
try {
|
|
653
|
+
await uninstallRedTeam({
|
|
654
|
+
scope,
|
|
655
|
+
home: redTeamInstallBase({ agent, scope, home, cwd: process.cwd() }),
|
|
656
|
+
dryRun: opts.dryRun,
|
|
657
|
+
product: opts.product || "network-scanner",
|
|
658
|
+
agent,
|
|
659
|
+
});
|
|
660
|
+
} catch (e) {
|
|
661
|
+
console.error(`red-team uninstall failed: ${e.message}`);
|
|
662
|
+
process.exit(1);
|
|
663
|
+
}
|
|
604
664
|
}
|
|
605
665
|
|
|
606
666
|
function rejectRemovedFlags(argv) {
|
|
@@ -658,17 +718,21 @@ export async function run(argv) {
|
|
|
658
718
|
redTeam
|
|
659
719
|
.command("install")
|
|
660
720
|
.description("Download the scanner and register it as an MCP server")
|
|
661
|
-
.option("--agent <name>", "target
|
|
721
|
+
.option("--agent <name>", "target: claude (Claude Code) | claude-desktop (Claude Desktop)", "claude")
|
|
662
722
|
.requiredOption("--key <ak_xxx>", "access key (ak_<43-char>)")
|
|
663
723
|
.option("--env <env>", "target environment: dev|prod (default: auto-detected from package name)")
|
|
724
|
+
.option("--product <name>", "red team product: network-scanner|web-scanner|playwright", "network-scanner")
|
|
664
725
|
.option("--scope <project|user>", "installation scope: user ($HOME) or project (cwd)", "user")
|
|
665
726
|
.option("--no-verify", "skip /verify call")
|
|
727
|
+
.option("--force", "replace an existing MCP server registration of the same name", false)
|
|
666
728
|
.option("--dry-run", "print planned actions, do not touch disk", false)
|
|
667
729
|
.action(cmdRedTeamInstall);
|
|
668
730
|
|
|
669
731
|
redTeam
|
|
670
732
|
.command("uninstall")
|
|
671
733
|
.description("Remove the MCP server registration and the downloaded scanner")
|
|
734
|
+
.option("--agent <name>", "target: claude (Claude Code) | claude-desktop (Claude Desktop)", "claude")
|
|
735
|
+
.option("--product <name>", "red team product: network-scanner|web-scanner|playwright", "network-scanner")
|
|
672
736
|
.option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
|
|
673
737
|
.option("--dry-run", "print what would be removed without touching disk", false)
|
|
674
738
|
.action(cmdRedTeamUninstall);
|
package/src/red-team.mjs
CHANGED
|
@@ -21,22 +21,84 @@ import { downloadBase } from "./verify.mjs";
|
|
|
21
21
|
|
|
22
22
|
const execFileP = promisify(execFile);
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Playwright MCP の固定バージョン。顧客端末で `npx` が解決する = 実質そこが
|
|
25
|
+
// 依存ツリーの決定者なので、`@latest` ではなくピン留めする (公開直後の版を顧客
|
|
26
|
+
// 端末に降ろさない = 7 日ルールの趣旨。docs/scanner/SUPPLY_CHAIN.md)。
|
|
27
|
+
// 更新時は公開から 7 日以上経った stable を選ぶこと。
|
|
28
|
+
export const PLAYWRIGHT_MCP_VERSION = "0.0.78";
|
|
29
|
+
|
|
30
|
+
// RedTeam プロダクト。`kind` が配布方式を決める:
|
|
31
|
+
// binary — 我々がビルドして S3 で配る Rust バイナリ (ゲート付き)。
|
|
32
|
+
// npx — 上流の公開 npm パッケージをそのまま登録する (再配布しない)。
|
|
33
|
+
// network スキャナと web スキャナが別々なのは MCPB が 1 バンドル=1 サーバーで
|
|
34
|
+
// 同居できないため。`product` 省略時は network-scanner なので既存呼び出しは不変。
|
|
35
|
+
export const RED_TEAM_PRODUCTS = {
|
|
36
|
+
"network-scanner": {
|
|
37
|
+
kind: "binary",
|
|
38
|
+
mcpServerName: "network-scanner",
|
|
39
|
+
binName: "network-scanner",
|
|
40
|
+
},
|
|
41
|
+
"web-scanner": {
|
|
42
|
+
kind: "binary",
|
|
43
|
+
mcpServerName: "web-scanner",
|
|
44
|
+
binName: "web-scanner",
|
|
45
|
+
},
|
|
46
|
+
// web-scanner の相棒。エージェントがブラウザを操作できるようになるので、
|
|
47
|
+
// ログインが要る画面や SPA を診断できる (web-scanner 自身は HTTP と
|
|
48
|
+
// ヘッドレス DOM までで、ログイン操作の手段を持たない)。
|
|
49
|
+
// Apache-2.0 の公開パッケージなので再配布せず npx で直接叩く =
|
|
50
|
+
// 実行時のアクセスキーゲートは無い (掛けても意味がないため)。
|
|
51
|
+
// `--browser chrome` は端末にインストール済みの Chrome を使う指定
|
|
52
|
+
// (chromium を DL しない / 企業 CA・プロキシ設定をそのまま使える)。
|
|
53
|
+
playwright: {
|
|
54
|
+
kind: "npx",
|
|
55
|
+
mcpServerName: "playwright",
|
|
56
|
+
npmSpec: `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`,
|
|
57
|
+
serverArgs: ["--browser", "chrome", "--ignore-https-errors"],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
export const DEFAULT_RED_TEAM_PRODUCT = "network-scanner";
|
|
61
|
+
|
|
62
|
+
/** 後方互換: network-scanner の MCP サーバー名。 */
|
|
63
|
+
export const MCP_SERVER_NAME = RED_TEAM_PRODUCTS[DEFAULT_RED_TEAM_PRODUCT].mcpServerName;
|
|
64
|
+
|
|
65
|
+
/** product 名 → メタ。未知なら throw (呼び出し側でメッセージ表示)。 */
|
|
66
|
+
function productMeta(product) {
|
|
67
|
+
const meta = RED_TEAM_PRODUCTS[product];
|
|
68
|
+
if (!meta) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`unknown red-team product '${product}' (expected ${Object.keys(RED_TEAM_PRODUCTS).join(" | ")})`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return meta;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** OS に応じた実ファイル名 (Windows は .exe)。 */
|
|
77
|
+
function binFileName(meta) {
|
|
78
|
+
return process.platform === "win32" ? `${meta.binName}.exe` : meta.binName;
|
|
79
|
+
}
|
|
26
80
|
|
|
27
81
|
/**
|
|
28
82
|
* Red Team の生バイナリを取得する。
|
|
29
83
|
*
|
|
30
|
-
* verify Lambda の `GET /download/red-team
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
84
|
+
* verify Lambda の `GET /download/red-team/[<product>/]<platform>` はアクセス
|
|
85
|
+
* キー認証に加えて Red Team エンタイトルメント (pro/trial かつ契約シート >= 10)
|
|
86
|
+
* を要求し、不適格なら 403 を返す。fetchAsset は非 2xx で throw するため、
|
|
87
|
+
* ここでは特別扱いせず呼び出し側にメッセージを見せる。
|
|
88
|
+
*
|
|
89
|
+
* network-scanner は従来どおり 1 セグメント (`/red-team/<platform>`) を使う
|
|
90
|
+
* (配信済みルートとバイト等価)。他 product は `/red-team/<product>/<platform>`。
|
|
34
91
|
*/
|
|
35
|
-
export async function fetchRedTeamBinary(
|
|
92
|
+
export async function fetchRedTeamBinary(
|
|
93
|
+
destPath,
|
|
94
|
+
{ env = "prod", accessKey, product = DEFAULT_RED_TEAM_PRODUCT } = {},
|
|
95
|
+
) {
|
|
96
|
+
productMeta(product); // validate early
|
|
36
97
|
const platform = detectPlatform();
|
|
37
|
-
const
|
|
98
|
+
const rel = product === DEFAULT_RED_TEAM_PRODUCT ? platform : `${product}/${platform}`;
|
|
99
|
+
const binUrl = `${downloadBase(env)}/red-team/${rel}`;
|
|
38
100
|
const result = await fetchAsset(binUrl, destPath, { accessKey });
|
|
39
|
-
return { ...result, platform };
|
|
101
|
+
return { ...result, platform, product };
|
|
40
102
|
}
|
|
41
103
|
|
|
42
104
|
/**
|
|
@@ -52,6 +114,21 @@ function claudeScopeFor(scope) {
|
|
|
52
114
|
return scope === "user" ? "user" : "local";
|
|
53
115
|
}
|
|
54
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 同名の MCP サーバーが既に登録されているか (スコープ問わず)。
|
|
119
|
+
*
|
|
120
|
+
* `claude mcp get <name>` は見つからなければ非ゼロで終わる。登録済みなら
|
|
121
|
+
* 表示テキストをそのまま返して、呼び出し側が「何が登録済みか」を人間に見せる。
|
|
122
|
+
*/
|
|
123
|
+
async function mcpServerExists(name) {
|
|
124
|
+
try {
|
|
125
|
+
const { stdout } = await execFileP("claude", ["mcp", "get", name]);
|
|
126
|
+
return { exists: true, detail: (stdout || "").trim() };
|
|
127
|
+
} catch {
|
|
128
|
+
return { exists: false, detail: "" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
55
132
|
/** `claude` CLI が PATH にあるか。 */
|
|
56
133
|
async function claudeCliAvailable() {
|
|
57
134
|
try {
|
|
@@ -66,11 +143,11 @@ async function claudeCliAvailable() {
|
|
|
66
143
|
* 手作業に落とす場合のコマンドを組み立てる(`claude` が PATH に無いとき、
|
|
67
144
|
* および --dry-run のときに表示する)。
|
|
68
145
|
*/
|
|
69
|
-
export function buildMcpAddArgs(binPath, accessKey, scope) {
|
|
146
|
+
export function buildMcpAddArgs(binPath, accessKey, scope, mcpServerName = MCP_SERVER_NAME) {
|
|
70
147
|
return [
|
|
71
148
|
"mcp",
|
|
72
149
|
"add",
|
|
73
|
-
|
|
150
|
+
mcpServerName,
|
|
74
151
|
"-s",
|
|
75
152
|
claudeScopeFor(scope),
|
|
76
153
|
"-e",
|
|
@@ -80,6 +157,27 @@ export function buildMcpAddArgs(binPath, accessKey, scope) {
|
|
|
80
157
|
];
|
|
81
158
|
}
|
|
82
159
|
|
|
160
|
+
/**
|
|
161
|
+
* `kind: "npx"` の product を登録する引数を組み立てる。
|
|
162
|
+
*
|
|
163
|
+
* 我々のバイナリではなく上流の公開パッケージを起動するだけなので、アクセス
|
|
164
|
+
* キーは渡さない (受け取らないし、実行時ゲートも無い)。
|
|
165
|
+
*/
|
|
166
|
+
export function buildNpxMcpAddArgs(meta, scope) {
|
|
167
|
+
return [
|
|
168
|
+
"mcp",
|
|
169
|
+
"add",
|
|
170
|
+
meta.mcpServerName,
|
|
171
|
+
"-s",
|
|
172
|
+
claudeScopeFor(scope),
|
|
173
|
+
"--",
|
|
174
|
+
"npx",
|
|
175
|
+
"-y",
|
|
176
|
+
meta.npmSpec,
|
|
177
|
+
...meta.serverArgs,
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
|
|
83
181
|
/**
|
|
84
182
|
* Red Team バイナリを取得し、Claude Code に MCP サーバーとして登録する。
|
|
85
183
|
*
|
|
@@ -92,29 +190,103 @@ export async function installRedTeam({
|
|
|
92
190
|
scope = "user",
|
|
93
191
|
home,
|
|
94
192
|
dryRun = false,
|
|
193
|
+
force = false,
|
|
194
|
+
product = DEFAULT_RED_TEAM_PRODUCT,
|
|
195
|
+
// どちらの Claude に入れるか。`claude` = Claude Code (`claude mcp add`)、
|
|
196
|
+
// `claude-desktop` = Claude Desktop (設定ファイルへのマージ)。後者は
|
|
197
|
+
// `kind: "npx"` の product 専用 — バイナリ product は `.mcpb` という
|
|
198
|
+
// 専用の経路を既に持っており、そちらはアクセスキーの刻印も伴うため。
|
|
199
|
+
agent = "claude",
|
|
95
200
|
stdout = console.log,
|
|
96
201
|
stderr = console.error,
|
|
202
|
+
serverExists = mcpServerExists,
|
|
203
|
+
// `claude` の実在判定も注入できるようにする。ここが注入できないと、テストは
|
|
204
|
+
// 「実行マシンに claude が入っているか」に暗黙依存する — 実際 #754 のガード
|
|
205
|
+
// テストは開発機では緑・CI (claude 不在) では赤になり、その差が PR では
|
|
206
|
+
// 見えなかった (aidr スイートは main push でしか走っていなかった)。
|
|
207
|
+
claudeAvailable = claudeCliAvailable,
|
|
97
208
|
}) {
|
|
209
|
+
const meta = productMeta(product);
|
|
210
|
+
|
|
211
|
+
// Claude Desktop はコマンドを受け付けないので、設定ファイルへ直接マージする。
|
|
212
|
+
// バイナリ product をここへ流さないのは意図的 — `.mcpb` がその経路であり、
|
|
213
|
+
// バンドルにはアクセスキーが刻印される。設定ファイルに同じことをすると
|
|
214
|
+
// 平文の鍵をユーザーの config に置くことになる。
|
|
215
|
+
if (agent === "claude-desktop") {
|
|
216
|
+
if (meta.kind !== "npx") {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`--agent claude-desktop does not install '${product}'. ` +
|
|
219
|
+
"Download its .mcpb bundle from the portal and double-click it instead.",
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const { installDesktopCompanion } = await import("./claude-desktop.mjs");
|
|
223
|
+
const res = await installDesktopCompanion({ meta, home, dryRun, force, stdout, stderr });
|
|
224
|
+
return { installed: res.installed, registered: res.installed, path: res.path };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// kind: "npx" — 我々は何も配らない。上流の公開パッケージを `claude mcp add`
|
|
228
|
+
// で登録するだけなので、ダウンロードも sha256 検証もアクセスキーも無い。
|
|
229
|
+
if (meta.kind === "npx") {
|
|
230
|
+
const npxArgs = buildNpxMcpAddArgs(meta, scope);
|
|
231
|
+
if (dryRun) {
|
|
232
|
+
stdout(`[dry-run] would register ${product} via npx (no download)`);
|
|
233
|
+
stdout(`[dry-run] would run: claude ${npxArgs.join(" ")}`);
|
|
234
|
+
return { installed: false, dryRun: true, registered: false };
|
|
235
|
+
}
|
|
236
|
+
if (!(await claudeAvailable())) {
|
|
237
|
+
stderr("`claude` CLI not found on PATH — nothing was registered.");
|
|
238
|
+
stderr("Run this once Claude Code is available:");
|
|
239
|
+
stderr(` claude ${npxArgs.join(" ")}`);
|
|
240
|
+
return { installed: false, registered: false };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 上流パッケージのサーバー名 (`playwright` 等) は汎用的で、顧客が自分で
|
|
244
|
+
// 登録済みのことがある。install は同スコープで remove → add するため、
|
|
245
|
+
// 黙って進めると顧客自身の設定 (別バージョン・別フラグ・別プロファイル) を
|
|
246
|
+
// 上書きしてしまう。我々が所有していない名前は勝手に奪わない。
|
|
247
|
+
// 上書きしたいときだけ --force。
|
|
248
|
+
if (!force) {
|
|
249
|
+
const { exists, detail } = await serverExists(meta.mcpServerName);
|
|
250
|
+
if (exists) {
|
|
251
|
+
stderr(`'${meta.mcpServerName}' is already registered — leaving it as is.`);
|
|
252
|
+
if (detail) stderr(detail);
|
|
253
|
+
stderr(`Re-run with --force to replace it with: npx -y ${meta.npmSpec} ${meta.serverArgs.join(" ")}`);
|
|
254
|
+
return { installed: false, registered: false, skipped: true };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// 再実行を許すため、既存エントリがあれば先に外す。未登録なら失敗するので無視。
|
|
258
|
+
try {
|
|
259
|
+
await execFileP("claude", ["mcp", "remove", meta.mcpServerName, "-s", claudeScopeFor(scope)]);
|
|
260
|
+
} catch {
|
|
261
|
+
// not registered yet — expected on first install
|
|
262
|
+
}
|
|
263
|
+
await execFileP("claude", npxArgs);
|
|
264
|
+
stdout(`mcp: registered '${meta.mcpServerName}' (scope=${claudeScopeFor(scope)}, via npx)`);
|
|
265
|
+
return { installed: true, registered: true };
|
|
266
|
+
}
|
|
267
|
+
|
|
98
268
|
// 既存の install と同じ配置規約: <base>/.claude/bin/ に置く。
|
|
99
269
|
const binDir = path.join(home, ".claude", "bin");
|
|
100
|
-
const binName =
|
|
270
|
+
const binName = binFileName(meta);
|
|
101
271
|
const binPath = path.join(binDir, binName);
|
|
102
272
|
|
|
103
273
|
if (dryRun) {
|
|
104
|
-
stdout(`[dry-run] would download red-team binary -> ${binPath}`);
|
|
105
|
-
stdout(
|
|
274
|
+
stdout(`[dry-run] would download red-team binary (${product}) -> ${binPath}`);
|
|
275
|
+
stdout(
|
|
276
|
+
`[dry-run] would run: claude ${buildMcpAddArgs(binPath, "<key>", scope, meta.mcpServerName).join(" ")}`,
|
|
277
|
+
);
|
|
106
278
|
return { installed: false, dryRun: true, path: binPath };
|
|
107
279
|
}
|
|
108
280
|
|
|
109
281
|
await fs.mkdir(binDir, { recursive: true });
|
|
110
|
-
const res = await fetchRedTeamBinary(binPath, { env, accessKey });
|
|
282
|
+
const res = await fetchRedTeamBinary(binPath, { env, accessKey, product });
|
|
111
283
|
stdout(
|
|
112
284
|
`binary: ${res.path} (${res.platform}${res.verified ? ", sha256 verified" : ""})`,
|
|
113
285
|
);
|
|
114
286
|
|
|
115
|
-
const args = buildMcpAddArgs(binPath, accessKey, scope);
|
|
287
|
+
const args = buildMcpAddArgs(binPath, accessKey, scope, meta.mcpServerName);
|
|
116
288
|
|
|
117
|
-
if (!(await
|
|
289
|
+
if (!(await claudeAvailable())) {
|
|
118
290
|
stderr("`claude` CLI not found on PATH — the binary is installed but not registered.");
|
|
119
291
|
stderr("Run this once Claude Code is available:");
|
|
120
292
|
stderr(` claude ${args.join(" ")}`);
|
|
@@ -123,13 +295,13 @@ export async function installRedTeam({
|
|
|
123
295
|
|
|
124
296
|
// 再実行を許すため、既存エントリがあれば先に外す。未登録なら失敗するので無視。
|
|
125
297
|
try {
|
|
126
|
-
await execFileP("claude", ["mcp", "remove",
|
|
298
|
+
await execFileP("claude", ["mcp", "remove", meta.mcpServerName, "-s", claudeScopeFor(scope)]);
|
|
127
299
|
} catch {
|
|
128
300
|
// not registered yet — expected on first install
|
|
129
301
|
}
|
|
130
302
|
|
|
131
303
|
await execFileP("claude", args);
|
|
132
|
-
stdout(`mcp: registered '${
|
|
304
|
+
stdout(`mcp: registered '${meta.mcpServerName}' (scope=${claudeScopeFor(scope)})`);
|
|
133
305
|
|
|
134
306
|
return { installed: true, registered: true, path: binPath };
|
|
135
307
|
}
|
|
@@ -139,30 +311,51 @@ export async function uninstallRedTeam({
|
|
|
139
311
|
scope = "user",
|
|
140
312
|
home,
|
|
141
313
|
dryRun = false,
|
|
314
|
+
product = DEFAULT_RED_TEAM_PRODUCT,
|
|
315
|
+
agent = "claude",
|
|
142
316
|
stdout = console.log,
|
|
317
|
+
claudeAvailable = claudeCliAvailable,
|
|
143
318
|
}) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
319
|
+
const meta = productMeta(product);
|
|
320
|
+
|
|
321
|
+
if (agent === "claude-desktop") {
|
|
322
|
+
// install と同じ範囲でしか外せない。ここに guard が無いと、`--product` を
|
|
323
|
+
// 省いた既定 (`network-scanner` = `.mcpb` product) がこの分岐へ流れ、
|
|
324
|
+
// `claude_desktop_config.json` に一度も書かれていないものを探して
|
|
325
|
+
// `{removed:false}` を**何も出力せず** exit 0 で返していた。利用者からは
|
|
326
|
+
// 「消えたのか、そもそも入っていなかったのか」が区別できない。
|
|
327
|
+
if (meta.kind !== "npx") {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`--agent claude-desktop does not manage '${product}'. ` +
|
|
330
|
+
"It is a .mcpb bundle: remove it from Claude Desktop's extension list instead.",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const { uninstallDesktopCompanion } = await import("./claude-desktop.mjs");
|
|
334
|
+
return uninstallDesktopCompanion({ meta, home, dryRun, stdout });
|
|
335
|
+
}
|
|
336
|
+
// kind: "npx" は我々がディスクに置いたものが無いので、消すのは MCP 登録だけ。
|
|
337
|
+
const binPath =
|
|
338
|
+
meta.kind === "npx" ? null : path.join(home, ".claude", "bin", binFileName(meta));
|
|
150
339
|
|
|
151
340
|
if (dryRun) {
|
|
152
|
-
stdout(`[dry-run] would run: claude mcp remove ${
|
|
153
|
-
stdout(`[dry-run] would remove ${binPath}`);
|
|
341
|
+
stdout(`[dry-run] would run: claude mcp remove ${meta.mcpServerName} -s ${claudeScopeFor(scope)}`);
|
|
342
|
+
if (binPath) stdout(`[dry-run] would remove ${binPath}`);
|
|
154
343
|
return { removed: false, dryRun: true };
|
|
155
344
|
}
|
|
156
345
|
|
|
157
|
-
if (await
|
|
346
|
+
if (await claudeAvailable()) {
|
|
158
347
|
try {
|
|
159
|
-
await execFileP("claude", ["mcp", "remove",
|
|
160
|
-
stdout(`mcp: removed '${
|
|
348
|
+
await execFileP("claude", ["mcp", "remove", meta.mcpServerName, "-s", claudeScopeFor(scope)]);
|
|
349
|
+
stdout(`mcp: removed '${meta.mcpServerName}'`);
|
|
161
350
|
} catch {
|
|
162
351
|
// already absent
|
|
163
352
|
}
|
|
164
353
|
}
|
|
165
354
|
|
|
355
|
+
if (!binPath) {
|
|
356
|
+
return { removed: true };
|
|
357
|
+
}
|
|
358
|
+
|
|
166
359
|
await fs.rm(binPath, { force: true });
|
|
167
360
|
stdout(`removed ${binPath}`);
|
|
168
361
|
return { removed: true };
|