@atbash/cli 0.5.12 → 0.5.13

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.
@@ -40,10 +40,16 @@ exports.registerConnectCommand = registerConnectCommand;
40
40
  exports.discover = discover;
41
41
  exports.enumerateSkills = enumerateSkills;
42
42
  exports.describeSkill = describeSkill;
43
+ exports.relaySafeServers = relaySafeServers;
44
+ exports.safeDescription = safeDescription;
43
45
  const fs = __importStar(require("fs"));
44
46
  const os = __importStar(require("os"));
45
47
  const path = __importStar(require("path"));
46
48
  const chalk_1 = __importDefault(require("chalk"));
49
+ const yaml_1 = require("yaml");
50
+ const TOML = __importStar(require("@iarna/toml"));
51
+ const jsonc = __importStar(require("jsonc-parser"));
52
+ const introspect_mcp_1 = require("./introspect-mcp");
47
53
  /**
48
54
  * `atbash connect <code>` — the local connector.
49
55
  *
@@ -59,7 +65,42 @@ const chalk_1 = __importDefault(require("chalk"));
59
65
  * The discovery mirrors the dashboard's src/lib/scan/discover.ts and produces
60
66
  * the same relay-safe payload the session endpoint expects.
61
67
  */
68
+ /**
69
+ * Official Atbash deployments that legitimately serve the onboarding flow.
70
+ *
71
+ * The pairing screen builds `--host` from the page you opened, so every origin
72
+ * that can host onboarding must appear here. If production were missing, the real
73
+ * atbash.ai would be flagged "unrecognized destination" — a false alarm on the
74
+ * genuine article, which is worse than silence: it teaches people and agents to
75
+ * wave through the one warning that actually catches a swapped host.
76
+ *
77
+ * Matched on EXACT hostname, never a suffix — "atbash.ai.evil.com" must not pass.
78
+ * Deliberately not a *.vercel.app wildcard: that would bless anyone's preview.
79
+ */
80
+ const KNOWN_HOSTS = new Set([
81
+ "atbash.ai",
82
+ "www.atbash.ai",
83
+ "chromia-verified-ai-dev-two.vercel.app",
84
+ "chromia-verified-ai-agent-scan.vercel.app",
85
+ ]);
86
+ /**
87
+ * Used when neither --host nor ATBASH_HOST is given. Still the dev deployment,
88
+ * because it is the one running the current connector API; switch this to
89
+ * https://atbash.ai once production serves it (KNOWN_HOSTS already allows it).
90
+ */
62
91
  const DEFAULT_HOST = "https://chromia-verified-ai-dev-two.vercel.app";
92
+ // Discovery is intentionally bounded. A local config is untrusted input even
93
+ // though it lives on the operator's machine: a giant/malformed file must not turn
94
+ // a one-shot connector into a memory or latency sink.
95
+ const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
96
+ const MAX_SKILL_BYTES = 256 * 1024;
97
+ const MAX_RELAY_SERVERS = 200;
98
+ const SECRET_DESCRIPTION = [
99
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
100
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/i,
101
+ /\b(?:sk|pk|ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{12,}\b/,
102
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/,
103
+ ];
63
104
  // ── filesystem helpers (all best-effort; never throw) ────────────────────────
64
105
  const exists = (...segs) => {
65
106
  try {
@@ -70,16 +111,23 @@ const exists = (...segs) => {
70
111
  }
71
112
  };
72
113
  const readJson = (...segs) => {
73
- try {
74
- return JSON.parse(fs.readFileSync(path.join(...segs), "utf8"));
75
- }
76
- catch {
114
+ // JSONC-tolerant: many editor MCP configs (VS Code, Cursor, Windsurf) allow
115
+ // comments and trailing commas, which JSON.parse rejects — silently reporting
116
+ // "nothing found" on a perfectly valid config. jsonc-parser is best-effort and
117
+ // never throws; it returns the parsed value alongside a (here-ignored) error list.
118
+ const text = readText(MAX_CONFIG_BYTES, ...segs);
119
+ if (text === undefined)
77
120
  return null;
78
- }
121
+ const errors = [];
122
+ const val = jsonc.parse(text, errors, { allowTrailingComma: true, disallowComments: false });
123
+ return val && typeof val === "object" && !Array.isArray(val) ? val : null;
79
124
  };
80
- const readText = (...segs) => {
125
+ const readText = (maxBytes, ...segs) => {
81
126
  try {
82
- return fs.readFileSync(path.join(...segs), "utf8");
127
+ const file = path.join(...segs);
128
+ if (fs.statSync(file).size > maxBytes)
129
+ return undefined;
130
+ return fs.readFileSync(file, "utf8");
83
131
  }
84
132
  catch {
85
133
  return undefined;
@@ -95,37 +143,32 @@ const listDirs = (...segs) => {
95
143
  return [];
96
144
  }
97
145
  };
98
- /** One-line skill description from SKILL.md frontmatter `description:` or first heading. */
146
+ /** One-line skill description from SKILL.md frontmatter `description:` or first heading.
147
+ * Frontmatter is parsed with a real YAML parser (handles quotes, block scalars,
148
+ * folded/multiline values and nested maps that the previous regex mis-read). */
99
149
  function describeSkill(text, fallback) {
100
150
  if (!text)
101
151
  return fallback;
102
152
  const frontmatter = (text.match(/^---\r?\n([\s\S]*?)\r?\n---/) || [])[1];
103
- const field = frontmatter ? frontmatter.match(/^[ \t]*description:[ \t]*(.*)$/im) : null;
104
- if (frontmatter && field) {
105
- const inline = field[1].trim();
106
- if (/^[>|][+-]?\d*$/.test(inline)) {
107
- const lines = [];
108
- for (const line of frontmatter.slice((field.index ?? 0) + field[0].length).split(/\r?\n/)) {
109
- if (!line.trim()) {
110
- if (lines.length)
111
- break;
112
- continue;
113
- }
114
- if (!/^[ \t]/.test(line))
115
- break; // dedent ends the block scalar
116
- lines.push(line.trim());
117
- }
118
- const folded = lines.join(" ").trim();
119
- if (folded)
120
- return folded;
121
- }
122
- else if (inline) {
123
- return inline.replace(/^["']|["']$/g, "").trim();
153
+ if (frontmatter) {
154
+ try {
155
+ const data = (0, yaml_1.parse)(frontmatter);
156
+ const desc = data && typeof data.description === "string" ? data.description : "";
157
+ const oneLine = desc.replace(/\s+/g, " ").trim();
158
+ if (oneLine)
159
+ return safeDescription(oneLine) ?? fallback;
124
160
  }
161
+ catch { /* malformed frontmatter — fall through to first heading */ }
125
162
  }
126
163
  const heading = text.split(/\r?\n/).map((l) => l.trim())
127
164
  .find((l) => l && !l.startsWith("---") && !/^(name|description):/i.test(l));
128
- return heading ? heading.replace(/^#+\s*/, "").trim() : fallback;
165
+ return heading ? (safeDescription(heading.replace(/^#+\s*/, "").trim()) ?? fallback) : fallback;
166
+ }
167
+ /** Descriptions are useful capability evidence, but never worth relaying a
168
+ * credential or binary/control content from an untrusted local skill file. */
169
+ function safeDescription(raw) {
170
+ const value = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 240);
171
+ return value && !SECRET_DESCRIPTION.some((pattern) => pattern.test(value)) ? value : undefined;
129
172
  }
130
173
  /** Walk a skills root (bounded) → declared tools. Mirrors src/lib/scan/adapters/local-skills.ts. */
131
174
  function enumerateSkills(root, maxDepth = 3, cap = 400) {
@@ -140,9 +183,10 @@ function enumerateSkills(root, maxDepth = 3, cap = 400) {
140
183
  catch {
141
184
  return;
142
185
  }
143
- if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) {
186
+ const skillFile = entries.find((e) => e.isFile() && e.name.toLowerCase() === "skill.md");
187
+ if (skillFile) {
144
188
  const name = path.basename(dir);
145
- tools.push({ name, description: describeSkill(readText(dir, "SKILL.md"), `${category} skill`) });
189
+ tools.push({ name, description: describeSkill(readText(MAX_SKILL_BYTES, dir, skillFile.name), `${category} skill`) });
146
190
  return; // a skill folder is a leaf
147
191
  }
148
192
  for (const e of entries)
@@ -169,18 +213,105 @@ const MCP_CONFIGS = [
169
213
  */
170
214
  function parseCodexMcpServers(toml) {
171
215
  const servers = {};
172
- const re = /\[mcp_servers\.(?:"([^"\]]+)"|([A-Za-z0-9_-]+))\]([\s\S]*?)(?=\n\s*\[|$)/g;
173
- let m;
174
- while ((m = re.exec(toml))) {
175
- const body = m[3];
176
- if (/^\s*enabled\s*=\s*false\b/m.test(body))
216
+ let parsed;
217
+ try {
218
+ parsed = TOML.parse(toml);
219
+ }
220
+ catch {
221
+ return servers;
222
+ }
223
+ const mcp = parsed.mcp_servers && typeof parsed.mcp_servers === "object"
224
+ ? parsed.mcp_servers : undefined;
225
+ if (!mcp)
226
+ return servers;
227
+ for (const [name, spec] of Object.entries(mcp)) {
228
+ if (!spec || typeof spec !== "object")
229
+ continue;
230
+ const s = spec;
231
+ if (s.enabled === false)
177
232
  continue; // declared but switched off
178
- const url = (body.match(/^\s*url\s*=\s*"([^"]*)"/m) || [])[1];
179
- const command = (body.match(/^\s*command\s*=\s*"([^"]*)"/m) || [])[1];
180
- servers[m[1] || m[2]] = url ? { url } : command ? { command } : {};
233
+ const url = typeof s.url === "string" ? s.url : undefined;
234
+ const command = typeof s.command === "string" ? s.command : undefined;
235
+ servers[name] = url ? { url } : command ? { command } : {};
181
236
  }
182
237
  return servers;
183
238
  }
239
+ /**
240
+ * Strip a relayed mcpServers map down to the STRUCTURAL fields the hosted scanner
241
+ * needs to identify/reach a server (name, url, command, type, transport). Every
242
+ * other field — env, headers, token, apiKey, args, … — can carry live credentials
243
+ * or local paths and MUST NOT leave the machine. The connector relays the tool
244
+ * SURFACE, never secrets. (Auth'd servers simply come back "needs credentials";
245
+ * the hosted scanner degrades gracefully rather than us exfiltrating a token.)
246
+ */
247
+ /** Strip credentials/query/fragment from an endpoint URL — keep only scheme, host
248
+ * and path. `user:pass@` and `?token=…` are live secrets; the host+path is the
249
+ * structural signal the hosted scanner needs. Returns undefined if unparseable. */
250
+ function safeServerUrl(raw) {
251
+ if (typeof raw !== "string" || !raw.trim())
252
+ return undefined;
253
+ try {
254
+ const u = new URL(raw);
255
+ u.username = "";
256
+ u.password = "";
257
+ u.search = "";
258
+ u.hash = "";
259
+ return u.toString();
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ }
265
+ /** Reduce a launch command to its executable BASENAME — the capability signal
266
+ * ("node", "npx", "uvx", "python") — without the absolute path that would leak
267
+ * the local username and directory layout (e.g. /Users/alice/private/agent/run).
268
+ * Takes the first whitespace token ("npx -y pkg" → "npx"), then basenames it, and
269
+ * rejects multiline / absurdly long values outright. */
270
+ function safeServerCommand(raw) {
271
+ if (typeof raw !== "string")
272
+ return undefined;
273
+ const trimmed = raw.trim();
274
+ if (!trimmed || /[\r\n]/.test(trimmed) || trimmed.length > 200)
275
+ return undefined;
276
+ const first = trimmed.split(/\s+/)[0];
277
+ const base = path.basename(first);
278
+ return base || undefined;
279
+ }
280
+ /**
281
+ * THE sanitization boundary. Reduce an mcpServers map to the SANITIZED structural
282
+ * signal the hosted scanner needs — server name, host+path of the endpoint, and
283
+ * executable basename — and nothing else. Every value that could carry a live
284
+ * secret or PII (env, headers, token, apiKey, args, url credentials, absolute
285
+ * command paths) is dropped or reduced. The connector relays the tool SURFACE,
286
+ * never secrets. Raw specs from `readAllMcpServers()` MUST pass through here before
287
+ * entering any relayed `Agent`.
288
+ *
289
+ * Caps the map at MAX_RELAY_SERVERS. Callers are expected to compare the returned
290
+ * size against the input size and label the result partial — see `discover()`.
291
+ */
292
+ function relaySafeServers(servers) {
293
+ const out = {};
294
+ for (const [name, spec] of Object.entries(servers).slice(0, MAX_RELAY_SERVERS)) {
295
+ if (!spec || typeof spec !== "object") {
296
+ out[name] = {};
297
+ continue;
298
+ }
299
+ const s = spec;
300
+ const safe = {};
301
+ const url = safeServerUrl(s.url);
302
+ if (url)
303
+ safe.url = url;
304
+ const command = safeServerCommand(s.command);
305
+ if (command)
306
+ safe.command = command;
307
+ if (typeof s.type === "string")
308
+ safe.type = s.type.slice(0, 40);
309
+ if (typeof s.transport === "string")
310
+ safe.transport = s.transport.slice(0, 40);
311
+ out[name] = safe;
312
+ }
313
+ return out;
314
+ }
184
315
  /** Enumerate the agents/MCP tools configured on this machine. Read-only. */
185
316
  function discover(home) {
186
317
  const agents = [];
@@ -198,17 +329,41 @@ function discover(home) {
198
329
  tools.push({ name: `plugin:${name}`, description: "Hermes plugin" });
199
330
  agents.push({ type: "declared", label: "Hermes", detail: `${tools.length} skill${tools.length === 1 ? "" : "s"} / plugins`, declaredTools: tools });
200
331
  }
201
- // MCP-config files config (mcpServers) blobs.
332
+ // MCP config sources collected once as RAW specs, then sanitized for relay.
333
+ for (const { label, servers } of readAllMcpServers(home)) {
334
+ const count = Object.keys(servers).length;
335
+ const safe = relaySafeServers(servers);
336
+ // Say so when the relay cap truncated the map. A payload listing 200 of 250
337
+ // servers under a bare "250 MCP servers" label reads as complete coverage.
338
+ const relayed = Object.keys(safe).length;
339
+ const detail = relayed < count
340
+ ? `${relayed} of ${count} MCP servers relayed (capped at ${MAX_RELAY_SERVERS} — list is partial)`
341
+ : `${count} MCP server${count === 1 ? "" : "s"}`;
342
+ agents.push({ type: "config", label, detail, config: { mcpServers: safe } });
343
+ }
344
+ return agents;
345
+ }
346
+ /**
347
+ * ⚠️ SECURITY INVARIANT — this returns RAW, UNSANITIZED server specs.
348
+ * The values here contain live secrets: url credentials, `env`, `headers`,
349
+ * `token`, `apiKey`, and absolute command paths. They exist ONLY so `--introspect`
350
+ * can reach a server locally. They MUST NEVER be put into the relay payload
351
+ * directly. Every path that turns these into an `Agent` MUST route them through
352
+ * `relaySafeServers()` first (the single sanitization boundary). If you add a new
353
+ * consumer, sanitize before it leaves this file. Callers today: `discover()`
354
+ * (sanitizes) and the introspection pass (local only, never relayed).
355
+ */
356
+ function readAllMcpServers(home) {
357
+ const out = [];
202
358
  const seen = new Set();
203
359
  for (const { label, segs } of MCP_CONFIGS) {
204
360
  if (seen.has(label))
205
361
  continue;
206
362
  const cfg = readJson(home, ...segs);
207
363
  const servers = cfg && typeof cfg === "object" ? (cfg.mcpServers ?? cfg.servers) : undefined;
208
- const count = servers && typeof servers === "object" ? Object.keys(servers).length : 0;
209
- if (count > 0 && servers) {
364
+ if (servers && typeof servers === "object" && Object.keys(servers).length > 0) {
210
365
  seen.add(label);
211
- agents.push({ type: "config", label, detail: `${count} MCP server${count === 1 ? "" : "s"}`, config: { mcpServers: servers } });
366
+ out.push({ label, servers });
212
367
  }
213
368
  }
214
369
  // Claude Code (~/.claude.json): servers live globally AND per-project — merge both.
@@ -227,19 +382,17 @@ function discover(home) {
227
382
  if (projects && typeof projects === "object")
228
383
  for (const p of Object.values(projects))
229
384
  collect(p);
230
- const count = Object.keys(merged).length;
231
- if (count > 0)
232
- agents.push({ type: "config", label: "Claude Code", detail: `${count} MCP server${count === 1 ? "" : "s"}`, config: { mcpServers: merged } });
385
+ if (Object.keys(merged).length > 0)
386
+ out.push({ label: "Claude Code", servers: merged });
233
387
  }
234
- // OpenAI Codex CLI (~/.codex/config.toml) — TOML, parsed separately.
235
- const codexToml = readText(home, ".codex", "config.toml");
388
+ // OpenAI Codex CLI (~/.codex/config.toml) — TOML.
389
+ const codexToml = readText(MAX_CONFIG_BYTES, home, ".codex", "config.toml");
236
390
  if (codexToml) {
237
391
  const servers = parseCodexMcpServers(codexToml);
238
- const count = Object.keys(servers).length;
239
- if (count > 0)
240
- agents.push({ type: "config", label: "Codex", detail: `${count} MCP server${count === 1 ? "" : "s"}`, config: { mcpServers: servers } });
392
+ if (Object.keys(servers).length > 0)
393
+ out.push({ label: "Codex", servers });
241
394
  }
242
- return agents;
395
+ return out;
243
396
  }
244
397
  function registerConnectCommand(program) {
245
398
  program
@@ -248,22 +401,195 @@ function registerConnectCommand(program) {
248
401
  .argument("<code>", "The pairing code from the onboarding \"Scan another machine\" screen")
249
402
  .option("--host <url>", "Atbash deployment hosting your session")
250
403
  .option("--home <dir>", "Home directory to scan (for testing)")
404
+ .option("--dry-run", "Do the read-only scan and print exactly what WOULD be sent (destination + tool names), then exit WITHOUT sending")
405
+ .option("--json", "Emit machine-readable JSON instead of formatted text (for agents / automation)")
406
+ .option("--introspect", "Also START configured MCP servers locally and list their advertised tools/resources (introspection-confirmed surface, not runtime behavior), then stop them. Never calls a tool")
407
+ .option("--allow-unrecognized-host", "Permit relaying to a --host that is not an official Atbash deployment (otherwise the command refuses to send)")
251
408
  .action(async (rawCode, opts) => {
252
409
  const home = opts.home || os.homedir();
253
410
  const host = (opts.host || process.env.ATBASH_HOST || DEFAULT_HOST).replace(/\/+$/, "");
254
411
  const code = rawCode.trim().toUpperCase().replace(/[^A-Z0-9-]/g, "");
255
- console.log("\n" + chalk_1.default.bold(" Atbash connector"));
412
+ const machine = os.hostname();
413
+ const dryRun = !!opts.dryRun;
414
+ const asJson = !!opts.json;
415
+ const introspect = !!opts.introspect;
416
+ const allowUnrecognizedHost = !!opts.allowUnrecognizedHost;
417
+ const endpoint = `${host}/api/agent-scan/session/${encodeURIComponent(code)}`;
418
+ // Parsed up front so the destination verdict is available to every output
419
+ // path, including the JSON one an agent reads before deciding to proceed.
420
+ let hostUrl = null;
421
+ try {
422
+ hostUrl = new URL(host);
423
+ }
424
+ catch {
425
+ hostUrl = null;
426
+ }
427
+ const hostname = hostUrl ? hostUrl.hostname.toLowerCase() : "";
428
+ const isLoopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|::1)$/.test(hostname);
429
+ // "Recognized" = an official Atbash deployment (or your own machine for dev).
430
+ const recognizedHost = KNOWN_HOSTS.has(hostname) || isLoopback;
431
+ // Machine-readable summary for the --json path AND for any error, so an agent
432
+ // gets a stable, parseable description of exactly what this command is/was doing.
433
+ const emitJson = (extra) => console.log(JSON.stringify({
434
+ tool: "@atbash/cli connect",
435
+ transmits: (introspect
436
+ ? "agent/tool/skill names, MCP endpoint URLs, launch command basenames, and MCP-introspected tool names/descriptions"
437
+ : "agent/tool/skill names, MCP endpoint URLs and launch command basenames")
438
+ + " — never tokens, auth headers, env values, tool inputs/outputs, or file contents",
439
+ // Introspection STARTS local MCP servers, so the run is not purely read-only.
440
+ readOnly: !introspect,
441
+ startsLocalServers: introspect,
442
+ requests: dryRun ? 0 : 1,
443
+ machine, host, endpoint, code,
444
+ // An agent should treat `recognizedHost: false` as a reason to stop and
445
+ // ask, not a detail to skim past.
446
+ recognizedHost, knownHosts: [...KNOWN_HOSTS],
447
+ ...extra,
448
+ }, null, 2));
256
449
  if (!code) {
257
- console.error(chalk_1.default.red(" Missing pairing code.") + " Open the \"Scan another machine\" screen for one.\n");
450
+ if (asJson)
451
+ emitJson({ ok: false, error: "missing pairing code" });
452
+ else
453
+ console.error("\n" + chalk_1.default.bold(" Atbash connector") + "\n" + chalk_1.default.red(" Missing pairing code.") + " Open the \"Scan another machine\" screen for one.\n");
454
+ process.exit(1);
455
+ }
456
+ // ── destination safety ───────────────────────────────────────────────────
457
+ // The only place this flow could be turned against you is a swapped --host
458
+ // pointing the relay at someone else's server. So: refuse a channel that
459
+ // isn't encrypted, and make an unrecognized destination impossible to miss.
460
+ const httpsOk = !!hostUrl && (hostUrl.protocol === "https:" || (hostUrl.protocol === "http:" && isLoopback));
461
+ if (!httpsOk) {
462
+ const msg = `refusing to send over a non-HTTPS host (${host}) — the relay must travel encrypted`;
463
+ if (asJson)
464
+ emitJson({ ok: false, error: msg });
465
+ else
466
+ console.error("\n" + chalk_1.default.red(` ✗ ${msg}.`) + chalk_1.default.dim(" Use an https:// host (localhost may use http).\n"));
467
+ process.exit(1);
468
+ }
469
+ // An unrecognized destination is the one real attack (a swapped --host). A
470
+ // warning is not enough for the automation path, which skips prompts — so
471
+ // refuse to SEND to an unknown host unless the operator explicitly opts in.
472
+ // (--dry-run never sends, so it may proceed to preview.)
473
+ if (!recognizedHost && !allowUnrecognizedHost && !dryRun) {
474
+ const msg = `refusing to relay to an unrecognized host (${hostname || host}) — not an official Atbash deployment. Re-run with --allow-unrecognized-host if you trust it.`;
475
+ if (asJson)
476
+ emitJson({ ok: false, error: msg });
477
+ else
478
+ console.error("\n" + chalk_1.default.red(` ✗ ${msg}`) + "\n");
258
479
  process.exit(1);
259
480
  }
260
- console.log(chalk_1.default.dim(` Machine : ${os.hostname()}`));
261
- console.log(chalk_1.default.dim(` Host : ${host}`));
262
- console.log(chalk_1.default.dim(` Code : ${code}\n`));
263
481
  const agents = discover(home);
482
+ // ── optional: MCP introspection (starts servers locally) ──────────────────
483
+ // Turns name-only MCP entries into an introspection-confirmed ADVERTISED
484
+ // surface. This does not verify implementation behavior. Additive: each
485
+ // reachable server contributes a declared-tools agent the scanner classifies
486
+ // exactly like any other. Only sanitized names/descriptions are relayed.
487
+ if (introspect) {
488
+ const sources = readAllMcpServers(home);
489
+ if (sources.length > 0) {
490
+ if (!asJson)
491
+ console.log(chalk_1.default.dim(` Introspecting ${sources.reduce((n, s) => n + Object.keys(s.servers).length, 0)} MCP server(s) locally (starting & stopping each)…`));
492
+ const results = await (0, introspect_mcp_1.introspectServers)(sources, {
493
+ onProgress: asJson ? undefined : (label, server, res) => console.log(chalk_1.default.dim(` ${res.ok ? chalk_1.default.green("✓") : chalk_1.default.yellow("•")} ${label} · ${server}${res.ok ? ` → ${res.tools.length} tool(s)` : ` (${res.error ?? "unreachable"})`}`)),
494
+ });
495
+ for (const r of results) {
496
+ // The introspection pass is capped. Record the shortfall as an
497
+ // explicitly INCOMPLETE surface rather than letting those servers
498
+ // vanish from a report that otherwise reads as exhaustive.
499
+ if (r.omitted) {
500
+ const note = `${r.omitted} server(s) in this source were not introspected — the run hit its per-scan server cap`;
501
+ if (!asJson)
502
+ console.log(chalk_1.default.yellow(` ! ${r.label} · ${note}`));
503
+ agents.push({
504
+ type: "declared",
505
+ label: `${r.label} · (not introspected)`,
506
+ detail: `${r.omitted} MCP server(s) skipped · introspection incomplete`,
507
+ declaredTools: [],
508
+ surfaceEvidence: {
509
+ source: "mcp-tools-list",
510
+ status: "incomplete",
511
+ observedAt: new Date().toISOString(),
512
+ error: note,
513
+ },
514
+ });
515
+ }
516
+ for (const s of r.servers) {
517
+ if (!s.ok) {
518
+ agents.push({
519
+ type: "declared",
520
+ label: `${r.label} · ${s.server}`,
521
+ detail: `MCP ${s.transport} · introspection incomplete`,
522
+ declaredTools: [],
523
+ surfaceEvidence: {
524
+ source: "mcp-tools-list",
525
+ status: "incomplete",
526
+ transport: s.transport,
527
+ server: s.server,
528
+ observedAt: new Date().toISOString(),
529
+ error: (s.error ?? "unreachable").slice(0, 300),
530
+ },
531
+ });
532
+ continue;
533
+ }
534
+ const extras = [s.resourceCount ? `${s.resourceCount} resource${s.resourceCount === 1 ? "" : "s"}` : "", s.promptCount ? `${s.promptCount} prompt${s.promptCount === 1 ? "" : "s"}` : ""].filter(Boolean);
535
+ agents.push({
536
+ type: "declared",
537
+ label: `${r.label} · ${s.server}`,
538
+ // "introspection-confirmed" — the server DECLARED these tools when we
539
+ // listed them; it is not proof of what the implementation will do.
540
+ detail: `${s.tools.length} tool${s.tools.length === 1 ? "" : "s"} (MCP ${s.transport} · introspection-confirmed)${extras.length ? " · " + extras.join(", ") : ""}`,
541
+ declaredTools: s.tools,
542
+ surfaceEvidence: {
543
+ source: "mcp-tools-list",
544
+ status: "complete",
545
+ transport: s.transport,
546
+ server: s.server,
547
+ observedAt: new Date().toISOString(),
548
+ },
549
+ });
550
+ }
551
+ }
552
+ }
553
+ }
554
+ const payload = { agents, machine };
555
+ // ── machine-readable path (agents / automation) ──────────────────────────
556
+ if (asJson) {
557
+ if (dryRun) {
558
+ emitJson({ ok: true, dryRun: true, sent: false, agentCount: agents.length, payload });
559
+ return;
560
+ }
561
+ try {
562
+ const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
563
+ const data = (await res.json().catch(() => ({})));
564
+ emitJson({ ok: res.ok, sent: res.ok, status: res.status, received: data.received, agentCount: agents.length, error: res.ok ? undefined : (data.error || res.statusText) });
565
+ process.exit(res.ok ? 0 : 1);
566
+ }
567
+ catch (err) {
568
+ emitJson({ ok: false, sent: false, error: err instanceof Error ? err.message : String(err) });
569
+ process.exit(1);
570
+ }
571
+ }
572
+ // ── human-readable path ──────────────────────────────────────────────────
573
+ console.log("\n" + chalk_1.default.bold(" Atbash connector") + (dryRun ? chalk_1.default.dim(" (dry run — nothing will be sent)") : ""));
574
+ // Capability disclosure: state plainly what runs, so a person OR an agent
575
+ // reviewing this command can confirm nothing hidden is happening.
576
+ console.log(chalk_1.default.dim(" Reads (read-only): known agent/MCP config files under your home dir — OpenClaw, Hermes,"));
577
+ console.log(chalk_1.default.dim(" Claude Desktop/Code, Cursor, Windsurf, VS Code, Gemini, Continue, Codex."));
578
+ console.log(chalk_1.default.dim(" Relays names, MCP URLs and launch-command basenames only — never tokens, headers, env values or file contents."));
579
+ if (introspect)
580
+ console.log(chalk_1.default.yellow(" Introspection ON:") + chalk_1.default.dim(" also STARTS your MCP servers locally to list their tools, then stops them. Relays tool names/descriptions only — never inputs, outputs, args or env."));
581
+ console.log(chalk_1.default.dim(` Sends: ${dryRun ? "nothing (dry run)" : "one POST to the destination below, then exits"}.`));
582
+ // Machine identity is stated loudly on purpose: if this ran somewhere other
583
+ // than the computer you mean to govern (an AI-assistant sandbox, a VM, a
584
+ // remote box), the results are from THAT machine — not yours.
585
+ console.log(` ${chalk_1.default.bold("Machine")} : ${chalk_1.default.bold.cyan(machine)} ${chalk_1.default.yellow("← results below come from THIS machine")}`);
586
+ console.log(` ${chalk_1.default.bold("Sends to")} : ${chalk_1.default.cyan(endpoint)}${recognizedHost ? chalk_1.default.dim(isLoopback ? " (local development host)" : " (recognized Atbash host)") : chalk_1.default.yellow(" ← UNRECOGNIZED host, not an official Atbash deployment — only proceed if you trust it")}`);
587
+ console.log(chalk_1.default.dim(` Code : ${code}\n`));
264
588
  if (agents.length === 0) {
265
589
  console.log(" No local agents found (looked for OpenClaw, Hermes, and MCP configs).");
266
- console.log(" Relaying an empty result so the browser stops waiting.\n");
590
+ if (!dryRun)
591
+ console.log(" Relaying an empty result so the browser stops waiting.");
592
+ console.log("");
267
593
  }
268
594
  else {
269
595
  console.log(" Found:");
@@ -271,11 +597,18 @@ function registerConnectCommand(program) {
271
597
  console.log(` ${chalk_1.default.green("•")} ${a.label} ${chalk_1.default.dim("— " + a.detail)}`);
272
598
  console.log("");
273
599
  }
600
+ console.log(chalk_1.default.yellow(` ⚠ These results are from ${chalk_1.default.bold(machine)}.`) + chalk_1.default.dim(" If that isn't the computer you want to govern, run this command there instead.\n"));
601
+ if (dryRun) {
602
+ console.log(chalk_1.default.bold(" Dry run — the exact JSON that WOULD be sent:"));
603
+ console.log(chalk_1.default.dim(JSON.stringify(payload, null, 2).split("\n").map((l) => " " + l).join("\n")));
604
+ console.log("\n" + chalk_1.default.green(" ✓ Nothing was sent.") + chalk_1.default.dim(" Re-run without --dry-run to relay.\n"));
605
+ return;
606
+ }
274
607
  try {
275
- const res = await fetch(`${host}/api/agent-scan/session/${encodeURIComponent(code)}`, {
608
+ const res = await fetch(endpoint, {
276
609
  method: "POST",
277
610
  headers: { "Content-Type": "application/json" },
278
- body: JSON.stringify({ agents }),
611
+ body: JSON.stringify(payload),
279
612
  });
280
613
  const data = (await res.json().catch(() => ({})));
281
614
  if (!res.ok) {
@@ -283,7 +616,7 @@ function registerConnectCommand(program) {
283
616
  console.error(chalk_1.default.dim(" The code may be wrong or expired — re-open the screen for a fresh one.\n"));
284
617
  process.exit(1);
285
618
  }
286
- console.log(chalk_1.default.green(` ✓ Relayed ${data.received ?? agents.length} agent(s).`) + " Switch back to the browser — it should update now.\n");
619
+ console.log(chalk_1.default.green(` ✓ Relayed ${data.received ?? agents.length} agent(s) from ${chalk_1.default.bold(machine)}.`) + " Switch back to the browser — it should show this machine now.\n");
287
620
  }
288
621
  catch (err) {
289
622
  console.error(chalk_1.default.red(` Could not reach ${host}: ${err instanceof Error ? err.message : String(err)}\n`));