@atbash/cli 0.5.12 → 0.5.14
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/README.md +50 -0
- package/dist/bin/atbash.js +0 -0
- package/dist/commands/connect.d.ts +31 -2
- package/dist/commands/connect.js +395 -62
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/introspect-mcp.d.ts +74 -0
- package/dist/commands/introspect-mcp.js +215 -0
- package/dist/commands/introspect-mcp.js.map +1 -0
- package/dist/commands/minimal-mcp-client.d.ts +33 -0
- package/dist/commands/minimal-mcp-client.js +391 -0
- package/dist/commands/minimal-mcp-client.js.map +1 -0
- package/package.json +20 -4
package/dist/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 =
|
|
179
|
-
const command =
|
|
180
|
-
servers[
|
|
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
|
|
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
|
-
|
|
209
|
-
if (count > 0 && servers) {
|
|
364
|
+
if (servers && typeof servers === "object" && Object.keys(servers).length > 0) {
|
|
210
365
|
seen.add(label);
|
|
211
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
608
|
+
const res = await fetch(endpoint, {
|
|
276
609
|
method: "POST",
|
|
277
610
|
headers: { "Content-Type": "application/json" },
|
|
278
|
-
body: JSON.stringify(
|
|
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
|
|
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`));
|