@atbash/cli 0.5.15 → 0.6.1-dev.2
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 +389 -2
- package/dist/bin/atbash.js +39 -4
- package/dist/bin/atbash.js.map +1 -1
- package/dist/commands/config-cmd.js +15 -15
- package/dist/commands/config-cmd.js.map +1 -1
- package/dist/commands/connect.d.ts +115 -1
- package/dist/commands/connect.js +292 -73
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/github-scan.d.ts +104 -0
- package/dist/commands/github-scan.js +1054 -0
- package/dist/commands/github-scan.js.map +1 -0
- package/dist/commands/held.js +7 -2
- package/dist/commands/held.js.map +1 -1
- package/dist/commands/history.js +4 -1
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/judge-options.d.ts +21 -0
- package/dist/commands/judge-options.js +24 -0
- package/dist/commands/judge-options.js.map +1 -0
- package/dist/commands/judge.js +5 -2
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/mcp-cmd.d.ts +13 -0
- package/dist/commands/mcp-cmd.js +216 -0
- package/dist/commands/mcp-cmd.js.map +1 -0
- package/dist/commands/policy.js +4 -1
- package/dist/commands/policy.js.map +1 -1
- package/dist/commands/scan.d.ts +45 -0
- package/dist/commands/scan.js +301 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/setup.d.ts +533 -0
- package/dist/commands/setup.js +2653 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/stats.js +3 -1
- package/dist/commands/stats.js.map +1 -1
- package/dist/commands/status.js +4 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/tier.js +4 -1
- package/dist/commands/tier.js.map +1 -1
- package/dist/commands/tools.js +11 -4
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/lib/grade-llm.d.ts +38 -0
- package/dist/lib/grade-llm.js +64 -0
- package/dist/lib/grade-llm.js.map +1 -0
- package/dist/lib/llm.d.ts +34 -0
- package/dist/lib/llm.js +101 -0
- package/dist/lib/llm.js.map +1 -0
- package/dist/lib/policy.d.ts +40 -0
- package/dist/lib/policy.js +94 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/risk.d.ts +112 -0
- package/dist/lib/risk.js +288 -0
- package/dist/lib/risk.js.map +1 -0
- package/dist/lib/threats-llm.d.ts +39 -0
- package/dist/lib/threats-llm.js +72 -0
- package/dist/lib/threats-llm.js.map +1 -0
- package/dist/shared/atbash-targets.d.ts +49 -0
- package/dist/shared/atbash-targets.js +63 -0
- package/dist/shared/atbash-targets.js.map +1 -0
- package/dist/shared/openclaw-runtime.d.ts +221 -0
- package/dist/shared/openclaw-runtime.js +476 -0
- package/dist/shared/openclaw-runtime.js.map +1 -0
- package/dist/shared/win-exec.d.ts +63 -0
- package/dist/shared/win-exec.js +147 -0
- package/dist/shared/win-exec.js.map +1 -0
- package/package.json +4 -3
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `atbash connect <code>` — the local connector.
|
|
4
|
+
*
|
|
5
|
+
* A hosted Atbash onboarding page can't read your machine's filesystem, and the
|
|
6
|
+
* Atbash server only sees its own disk. This command runs on YOUR machine,
|
|
7
|
+
* enumerates the agents/MCP tools configured locally, and relays them to your
|
|
8
|
+
* onboarding session via a one-time pairing code shown in the browser.
|
|
9
|
+
*
|
|
10
|
+
* Read-only: it reads a fixed set of known agent config paths and reports the
|
|
11
|
+
* declared tool surface (server/skill NAMES) — never the secret values inside.
|
|
12
|
+
* It makes ONE outbound POST (the relay) and exits.
|
|
13
|
+
*
|
|
14
|
+
* The discovery mirrors the dashboard's src/lib/scan/discover.ts and produces
|
|
15
|
+
* the same relay-safe payload the session endpoint expects.
|
|
16
|
+
*/
|
|
2
17
|
interface DeclaredTool {
|
|
3
18
|
name: string;
|
|
4
19
|
description?: string;
|
|
@@ -19,6 +34,7 @@ type Agent = {
|
|
|
19
34
|
detail: string;
|
|
20
35
|
declaredTools: DeclaredTool[];
|
|
21
36
|
surfaceEvidence?: SurfaceEvidence;
|
|
37
|
+
governance?: Governance;
|
|
22
38
|
} | {
|
|
23
39
|
type: "config";
|
|
24
40
|
label: string;
|
|
@@ -27,6 +43,7 @@ type Agent = {
|
|
|
27
43
|
mcpServers: Record<string, unknown>;
|
|
28
44
|
};
|
|
29
45
|
};
|
|
46
|
+
declare const MAX_RELAY_SERVERS = 200;
|
|
30
47
|
/** One-line skill description from SKILL.md frontmatter `description:` or first heading.
|
|
31
48
|
* Frontmatter is parsed with a real YAML parser (handles quotes, block scalars,
|
|
32
49
|
* folded/multiline values and nested maps that the previous regex mis-read). */
|
|
@@ -49,7 +66,104 @@ declare function enumerateSkills(root: string, maxDepth?: number, cap?: number):
|
|
|
49
66
|
* size against the input size and label the result partial — see `discover()`.
|
|
50
67
|
*/
|
|
51
68
|
declare function relaySafeServers(servers: Record<string, unknown>): Record<string, unknown>;
|
|
69
|
+
/**
|
|
70
|
+
* Atbash's own wiring on this machine — is the boundary actually in place?
|
|
71
|
+
*
|
|
72
|
+
* The relay reported an agent's TOOL SURFACE and nothing about whether Atbash
|
|
73
|
+
* governs it, so the dashboard's "re-scan to confirm it reports as enforcing"
|
|
74
|
+
* had nothing to read. Worse, the two failures that matter most are invisible in
|
|
75
|
+
* a file listing: a plugin installed but never enabled, and a plugin enabled but
|
|
76
|
+
* pointed at no key. Both leave a machine that looks wired and enforces nothing.
|
|
77
|
+
*
|
|
78
|
+
* FILE READS ONLY. No processes are spawned, so this stays inside the read-only
|
|
79
|
+
* promise the onboarding page makes about this command. Everything below is a
|
|
80
|
+
* documented location:
|
|
81
|
+
*
|
|
82
|
+
* - OpenClaw: the plugin entry in `~/.openclaw/openclaw.json`. Both the current
|
|
83
|
+
* `openclaw` key and the legacy `atbash-plugin` key count, since the dashboard
|
|
84
|
+
* recognizes either.
|
|
85
|
+
* - Hermes: plugins are OPT-IN via a `plugins.enabled` allow-list in
|
|
86
|
+
* `~/.hermes/config.yaml` (hermes_cli/plugins.py:_get_enabled_plugins — a
|
|
87
|
+
* missing key means nothing loads), and the plugin reads its key path from
|
|
88
|
+
* `~/.hermes/.env`.
|
|
89
|
+
*
|
|
90
|
+
* `enforcing` is only ever true when BOTH the hook is switched on and a key path
|
|
91
|
+
* is configured. Reporting enforcement on the strength of an installed package
|
|
92
|
+
* would recreate the exact false green this is meant to catch.
|
|
93
|
+
*/
|
|
94
|
+
interface Governance {
|
|
95
|
+
installed: boolean;
|
|
96
|
+
enforcing: boolean;
|
|
97
|
+
entry?: string;
|
|
98
|
+
keyPathConfigured: boolean;
|
|
99
|
+
agentPubkey?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Every agent this machine has been wired to, newest-known last.
|
|
102
|
+
*
|
|
103
|
+
* A machine governs exactly ONE agent at a time: the plugin config names one
|
|
104
|
+
* key path and that file holds one identity, so each `atbash setup` run
|
|
105
|
+
* displaces the previous agent and archives its key under
|
|
106
|
+
* `~/.config/atbash/keys/<pubkey>.key`. On a real box that had quietly rotated
|
|
107
|
+
* five times, nothing anywhere said so — the browser could not tell "already
|
|
108
|
+
* protected" from "protected, but for a different agent than the one you are
|
|
109
|
+
* onboarding".
|
|
110
|
+
*
|
|
111
|
+
* Only PUBLIC keys leave the machine, and only from filenames — the archived
|
|
112
|
+
* files are never opened. The current agent's key is included so the caller can
|
|
113
|
+
* mark which of the list is live without a second lookup.
|
|
114
|
+
*/
|
|
115
|
+
knownAgents?: string[];
|
|
116
|
+
}
|
|
52
117
|
/** Enumerate the agents/MCP tools configured on this machine. Read-only. */
|
|
53
118
|
declare function discover(home: string): Agent[];
|
|
119
|
+
/**
|
|
120
|
+
* ⚠️ SECURITY INVARIANT — this returns RAW, UNSANITIZED server specs.
|
|
121
|
+
* The values here contain live secrets: url credentials, `env`, `headers`,
|
|
122
|
+
* `token`, `apiKey`, and absolute command paths. They exist ONLY so `--introspect`
|
|
123
|
+
* can reach a server locally. They MUST NEVER be put into the relay payload
|
|
124
|
+
* directly. Every path that turns these into an `Agent` MUST route them through
|
|
125
|
+
* `relaySafeServers()` first (the single sanitization boundary). If you add a new
|
|
126
|
+
* consumer, sanitize before it leaves this file. Callers today: `discover()`
|
|
127
|
+
* (sanitizes), `connect --introspect` and `atbash scan --introspect` (both local
|
|
128
|
+
* only, never relayed — scan has no relay path at all).
|
|
129
|
+
*/
|
|
130
|
+
declare function readAllMcpServers(home: string): {
|
|
131
|
+
label: string;
|
|
132
|
+
servers: Record<string, unknown>;
|
|
133
|
+
}[];
|
|
134
|
+
/**
|
|
135
|
+
* Vet the SHAPE of a relay destination — a separate, earlier question than
|
|
136
|
+
* whether the host is one of ours.
|
|
137
|
+
*
|
|
138
|
+
* `--host` is copied off a web page and everything `discover()` finds is POSTed
|
|
139
|
+
* to it, so the string itself is attacker-influenced. Four refusals:
|
|
140
|
+
*
|
|
141
|
+
* - **Unparseable.** Anything unrecognized is refused rather than tried.
|
|
142
|
+
* - **Credentials** (`user:pass@`). They have no use here, and they are how a
|
|
143
|
+
* lookalike URL hides its real host from someone skimming a pasted command.
|
|
144
|
+
* - **A query string or fragment.** The endpoint path is APPENDED to this
|
|
145
|
+
* value, so a `?` or `#` swallows it and sends the payload somewhere nobody
|
|
146
|
+
* reviewed: `https://h/?x=` + `/api/agent-scan/...` requests `/` instead.
|
|
147
|
+
* - **Plain HTTP**, except to loopback so a local dev server still works.
|
|
148
|
+
*
|
|
149
|
+
* The KNOWN_HOSTS allowlist is the SEPARATE gate applied after this one, and it
|
|
150
|
+
* is what makes a swapped host non-exfiltrating; this is the shape check that
|
|
151
|
+
* keeps a well-formed-looking URL from quietly redirecting the request. Both
|
|
152
|
+
* must pass before anything is sent.
|
|
153
|
+
*/
|
|
154
|
+
export interface HostVerdict {
|
|
155
|
+
/** Normalized `scheme://host[/path]`; the raw trimmed input if unparseable. */
|
|
156
|
+
host: string;
|
|
157
|
+
hostname: string;
|
|
158
|
+
isLoopback: boolean;
|
|
159
|
+
/** An official Atbash deployment, or this machine for local development. */
|
|
160
|
+
recognized: boolean;
|
|
161
|
+
/** Non-null means refuse to send. User-facing text. */
|
|
162
|
+
problem: string | null;
|
|
163
|
+
/** Optional remediation line shown after `problem`. */
|
|
164
|
+
hint?: string;
|
|
165
|
+
}
|
|
166
|
+
export declare function checkRelayHost(raw: string): HostVerdict;
|
|
54
167
|
export declare function registerConnectCommand(program: Command): void;
|
|
55
|
-
export { discover, enumerateSkills, describeSkill, relaySafeServers, safeDescription };
|
|
168
|
+
export { discover, enumerateSkills, describeSkill, relaySafeServers, safeDescription, readAllMcpServers, MAX_RELAY_SERVERS };
|
|
169
|
+
export type { Agent, DeclaredTool };
|
package/dist/commands/connect.js
CHANGED
|
@@ -36,12 +36,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.MAX_RELAY_SERVERS = void 0;
|
|
40
|
+
exports.checkRelayHost = checkRelayHost;
|
|
39
41
|
exports.registerConnectCommand = registerConnectCommand;
|
|
40
42
|
exports.discover = discover;
|
|
41
43
|
exports.enumerateSkills = enumerateSkills;
|
|
42
44
|
exports.describeSkill = describeSkill;
|
|
43
45
|
exports.relaySafeServers = relaySafeServers;
|
|
44
46
|
exports.safeDescription = safeDescription;
|
|
47
|
+
exports.readAllMcpServers = readAllMcpServers;
|
|
45
48
|
const fs = __importStar(require("fs"));
|
|
46
49
|
const os = __importStar(require("os"));
|
|
47
50
|
const path = __importStar(require("path"));
|
|
@@ -50,51 +53,14 @@ const yaml_1 = require("yaml");
|
|
|
50
53
|
const TOML = __importStar(require("@iarna/toml"));
|
|
51
54
|
const jsonc = __importStar(require("jsonc-parser"));
|
|
52
55
|
const introspect_mcp_1 = require("./introspect-mcp");
|
|
53
|
-
|
|
54
|
-
* `atbash connect <code>` — the local connector.
|
|
55
|
-
*
|
|
56
|
-
* A hosted Atbash onboarding page can't read your machine's filesystem, and the
|
|
57
|
-
* Atbash server only sees its own disk. This command runs on YOUR machine,
|
|
58
|
-
* enumerates the agents/MCP tools configured locally, and relays them to your
|
|
59
|
-
* onboarding session via a one-time pairing code shown in the browser.
|
|
60
|
-
*
|
|
61
|
-
* Read-only: it reads a fixed set of known agent config paths and reports the
|
|
62
|
-
* declared tool surface (server/skill NAMES) — never the secret values inside.
|
|
63
|
-
* It makes ONE outbound POST (the relay) and exits.
|
|
64
|
-
*
|
|
65
|
-
* The discovery mirrors the dashboard's src/lib/scan/discover.ts and produces
|
|
66
|
-
* the same relay-safe payload the session endpoint expects.
|
|
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
|
-
*/
|
|
91
|
-
const DEFAULT_HOST = "https://chromia-verified-ai-dev-two.vercel.app";
|
|
56
|
+
const atbash_targets_1 = require("../shared/atbash-targets");
|
|
92
57
|
// Discovery is intentionally bounded. A local config is untrusted input even
|
|
93
58
|
// though it lives on the operator's machine: a giant/malformed file must not turn
|
|
94
59
|
// a one-shot connector into a memory or latency sink.
|
|
95
60
|
const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
|
|
96
61
|
const MAX_SKILL_BYTES = 256 * 1024;
|
|
97
62
|
const MAX_RELAY_SERVERS = 200;
|
|
63
|
+
exports.MAX_RELAY_SERVERS = MAX_RELAY_SERVERS;
|
|
98
64
|
const SECRET_DESCRIPTION = [
|
|
99
65
|
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
100
66
|
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/i,
|
|
@@ -196,15 +162,8 @@ function enumerateSkills(root, maxDepth = 3, cap = 400) {
|
|
|
196
162
|
walk(root, 0, path.basename(root));
|
|
197
163
|
return tools;
|
|
198
164
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
{ label: "Claude Desktop", segs: [".config", "Claude", "claude_desktop_config.json"] },
|
|
202
|
-
{ label: "Cursor", segs: [".cursor", "mcp.json"] },
|
|
203
|
-
{ label: "Windsurf", segs: [".codeium", "windsurf", "mcp_config.json"] },
|
|
204
|
-
{ label: "VS Code (MCP)", segs: [".vscode", "mcp.json"] },
|
|
205
|
-
{ label: "Gemini CLI", segs: [".gemini", "settings.json"] },
|
|
206
|
-
{ label: "Continue", segs: [".continue", "config.json"] },
|
|
207
|
-
];
|
|
165
|
+
// MCP_CONFIGS lives in ../shared/atbash-targets — shared with `atbash setup`, so a
|
|
166
|
+
// client this scan reports is a client the configurator can also find.
|
|
208
167
|
/**
|
|
209
168
|
* OpenAI Codex CLI stores config as TOML with [mcp_servers.<name>] tables. The
|
|
210
169
|
* header must be ONE key (bare or quoted); a dotted tail is a SUB-TABLE of that
|
|
@@ -312,22 +271,241 @@ function relaySafeServers(servers) {
|
|
|
312
271
|
}
|
|
313
272
|
return out;
|
|
314
273
|
}
|
|
274
|
+
/** Public keys of agents previously wired here, from the archive filenames. */
|
|
275
|
+
function archivedAgentPubkeys(home) {
|
|
276
|
+
let names;
|
|
277
|
+
try {
|
|
278
|
+
names = fs.readdirSync(path.join(home, ".config", "atbash", "keys"));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
return names
|
|
284
|
+
.map((n) => n.match(/^((?:02|03)[0-9a-fA-F]{64})\.key$/)?.[1])
|
|
285
|
+
.filter((k) => !!k);
|
|
286
|
+
}
|
|
287
|
+
/** The public half of the agent key, if one is in the canonical place. */
|
|
288
|
+
function agentPubkeyOnDisk(home) {
|
|
289
|
+
const text = readText(MAX_CONFIG_BYTES, home, ".config", "atbash", "guard-client-key");
|
|
290
|
+
const match = text?.match(/^[ \t]*"?pub_?[kK]ey"?[ \t]*[:=][ \t]*"?((?:02|03)[0-9a-fA-F]{64})"?/m);
|
|
291
|
+
return match?.[1];
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Every directory an OpenClaw release has used as its plugin store, relative to
|
|
295
|
+
* `~/.openclaw`.
|
|
296
|
+
*
|
|
297
|
+
* ⚠️ THIS FILE READ ONLY `extensions/`, WHICH IS THE BUG THE SCAN REPORTED AS
|
|
298
|
+
* "no tools". `extensions/` is the 2026.2.x store; 2026.6.x installs plugins as
|
|
299
|
+
* npm dependencies of `~/.openclaw` — into `~/.openclaw/node_modules` — and
|
|
300
|
+
* leaves `extensions/` EMPTY. So a fully wired 2026.6.6 machine relayed zero
|
|
301
|
+
* plugins and no governance, and the dashboard rendered that as every certified
|
|
302
|
+
* capability removed with the agent's risk score falling to zero.
|
|
303
|
+
*
|
|
304
|
+
* Keep in sync with OPENCLAW_PLUGIN_ROOTS in the dashboard
|
|
305
|
+
* (src/lib/scan/adapters/local-skills.ts) and in public/c.mjs. Three copies of
|
|
306
|
+
* this list exist because three different programs read the same machine; all
|
|
307
|
+
* three had the same single-layout assumption.
|
|
308
|
+
*/
|
|
309
|
+
const OPENCLAW_PLUGIN_ROOTS = [["extensions"], ["node_modules"], ["npm", "node_modules"]];
|
|
310
|
+
/**
|
|
311
|
+
* The 2026.7.x layout, a WILDCARD so it cannot be a fixed root above.
|
|
312
|
+
*
|
|
313
|
+
* Found by running a real `openclaw plugins install`, not by reading a listing:
|
|
314
|
+
* it creates a per-plugin npm PROJECT at `~/.openclaw/npm/projects/<mangled>/`
|
|
315
|
+
* whose directory name is NOT the plugin id
|
|
316
|
+
* ("atbash-atbash-openclaw-eada1196a5"). The identity is in that project's
|
|
317
|
+
* package.json dependencies and its node_modules. On those builds the install
|
|
318
|
+
* ledger also leaves disk entirely for `~/.openclaw/state/openclaw.sqlite`, so a
|
|
319
|
+
* file scan is a best-effort inventory and never proof of absence — which is why
|
|
320
|
+
* `atbash setup` asks `openclaw plugins list --json` for the governance verdict.
|
|
321
|
+
*/
|
|
322
|
+
const OPENCLAW_NPM_PROJECTS = ["npm", "projects"];
|
|
323
|
+
/**
|
|
324
|
+
* Plugins installed as npm PROJECTS on 2026.7.x.
|
|
325
|
+
*
|
|
326
|
+
* ⚠️ DO NOT enumerate `npm/projects/*./node_modules` wholesale — it is a full npm
|
|
327
|
+
* install tree, so listing it reported every TRANSITIVE DEPENDENCY as a plugin
|
|
328
|
+
* the agent holds (one real plugin became 14 capabilities, `@opentelemetry/core`
|
|
329
|
+
* among them). Junk in the capability surface inflates the count, dilutes real
|
|
330
|
+
* findings, and a re-scan reports it as "+14 added" drift nobody can explain.
|
|
331
|
+
*
|
|
332
|
+
* OpenClaw derives the project directory name from the package name, so
|
|
333
|
+
* `@atbash/atbash-openclaw` becomes `atbash-atbash-openclaw-eada1196a5`. The
|
|
334
|
+
* plugin is the declared dependency whose mangled name PREFIXES the project
|
|
335
|
+
* directory — which excludes `@opentelemetry/api`, added to every such project.
|
|
336
|
+
*/
|
|
337
|
+
function openclawNpmProjectPlugins(home) {
|
|
338
|
+
const out = [];
|
|
339
|
+
const mangle = (name) => name.replace(/^@/, "").replace(/[/]/g, "-");
|
|
340
|
+
for (const project of listDirs(home, ".openclaw", ...OPENCLAW_NPM_PROJECTS)) {
|
|
341
|
+
const pkg = readJson(home, ".openclaw", ...OPENCLAW_NPM_PROJECTS, project, "package.json");
|
|
342
|
+
const deps = pkg && typeof pkg.dependencies === "object" && pkg.dependencies ? Object.keys(pkg.dependencies) : [];
|
|
343
|
+
for (const dep of deps) {
|
|
344
|
+
if (!project.startsWith(mangle(dep)))
|
|
345
|
+
continue;
|
|
346
|
+
out.push({ name: dep, dir: path.join(home, ".openclaw", ...OPENCLAW_NPM_PROJECTS, project, "node_modules", dep) });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
/** Installed plugin names across every known layout, scoped packages expanded. */
|
|
352
|
+
function openclawPluginNames(home) {
|
|
353
|
+
const names = new Set();
|
|
354
|
+
for (const { name } of openclawNpmProjectPlugins(home))
|
|
355
|
+
names.add(name);
|
|
356
|
+
for (const root of OPENCLAW_PLUGIN_ROOTS) {
|
|
357
|
+
for (const name of listDirs(home, ".openclaw", ...root)) {
|
|
358
|
+
// `@atbash` is a scope DIRECTORY, not a plugin — the package is inside it.
|
|
359
|
+
if (name.startsWith("@")) {
|
|
360
|
+
for (const scoped of listDirs(home, ".openclaw", ...root, name))
|
|
361
|
+
names.add(`${name}/${scoped}`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
names.add(name);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return names;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Plugin ids OpenClaw has RECORDED as installed, wherever this release keeps
|
|
371
|
+
* that record.
|
|
372
|
+
*
|
|
373
|
+
* 2026.2.x keeps it inside openclaw.json as `plugins.installs`; newer builds
|
|
374
|
+
* moved it to `plugins/installs.json`, and that migration is destructive — a
|
|
375
|
+
* machine in the field had its original consumed and left as
|
|
376
|
+
* `installs.json.migrated`, which is then the only surviving record of what was
|
|
377
|
+
* installed before the upgrade.
|
|
378
|
+
*/
|
|
379
|
+
function openclawLedgerNames(home) {
|
|
380
|
+
const names = new Set();
|
|
381
|
+
const cfg = readJson(home, ".openclaw", "openclaw.json");
|
|
382
|
+
const plugins = cfg && typeof cfg.plugins === "object" ? cfg.plugins : undefined;
|
|
383
|
+
const inline = plugins && typeof plugins.installs === "object" ? plugins.installs : undefined;
|
|
384
|
+
if (inline)
|
|
385
|
+
Object.keys(inline).forEach((k) => names.add(k));
|
|
386
|
+
for (const file of ["installs.json", "installs.json.migrated"]) {
|
|
387
|
+
const led = readJson(home, ".openclaw", "plugins", file);
|
|
388
|
+
if (!led || typeof led !== "object")
|
|
389
|
+
continue;
|
|
390
|
+
const inner = led.plugins;
|
|
391
|
+
const map = inner && typeof inner === "object" ? inner : led;
|
|
392
|
+
Object.keys(map).forEach((k) => names.add(k));
|
|
393
|
+
}
|
|
394
|
+
// ~/.openclaw/package.json on the npm-channel layout, plus one per npm project
|
|
395
|
+
// on 2026.7.x — the authoritative name when the project dir name is mangled.
|
|
396
|
+
const prefixes = [[], ...listDirs(home, ".openclaw", ...OPENCLAW_NPM_PROJECTS).map((p) => [...OPENCLAW_NPM_PROJECTS, p])];
|
|
397
|
+
for (const prefix of prefixes) {
|
|
398
|
+
const pkg = readJson(home, ".openclaw", ...prefix, "package.json");
|
|
399
|
+
const deps = pkg && typeof pkg.dependencies === "object" ? pkg.dependencies : undefined;
|
|
400
|
+
if (deps)
|
|
401
|
+
Object.keys(deps).forEach((k) => names.add(k));
|
|
402
|
+
}
|
|
403
|
+
return names;
|
|
404
|
+
}
|
|
405
|
+
function openclawGovernance(home) {
|
|
406
|
+
const cfg = readJson(home, ".openclaw", "openclaw.json");
|
|
407
|
+
const plugins = cfg && typeof cfg.plugins === "object" ? cfg.plugins : undefined;
|
|
408
|
+
const entries = plugins && typeof plugins.entries === "object" ? plugins.entries : undefined;
|
|
409
|
+
// All THREE ids a machine can legitimately carry, newest first.
|
|
410
|
+
//
|
|
411
|
+
// `atbash-openclaw` is what the current `@atbash/atbash-openclaw` registers as.
|
|
412
|
+
// `atbash-plugin` is the earlier `@atbash/atbash-plugin`. `openclaw` is the
|
|
413
|
+
// RETIRED `@atbash-plugin/openclaw` id — kept here for DETECTION only, because
|
|
414
|
+
// a machine can still have one and the honest answer is "this is what is
|
|
415
|
+
// there", not silence. Setup never writes it: the docs are explicit that a
|
|
416
|
+
// leftover `openclaw` entry configures nothing.
|
|
417
|
+
const ENTRY_IDS = ["atbash-openclaw", "atbash-plugin", "openclaw"];
|
|
418
|
+
// Prefer the entry that is actually SWITCHED ON, not merely the first id in the
|
|
419
|
+
// list. `atbash setup` deliberately leaves a machine carrying two entries — the
|
|
420
|
+
// legacy one stood down to `{enabled:false}` and the real config on
|
|
421
|
+
// `atbash-openclaw` — so a plain `find` can pick the disabled one and report a
|
|
422
|
+
// freshly wired machine as ungoverned.
|
|
423
|
+
const presentIds = ENTRY_IDS.filter((k) => entries && k in entries);
|
|
424
|
+
const switchedOnId = (k) => {
|
|
425
|
+
const e = entries?.[k];
|
|
426
|
+
if (!e || typeof e !== "object")
|
|
427
|
+
return true;
|
|
428
|
+
const c = typeof e.config === "object" && e.config ? e.config : undefined;
|
|
429
|
+
return e.enabled !== false && c?.enabled !== false;
|
|
430
|
+
};
|
|
431
|
+
const entryName = presentIds.find(switchedOnId) ?? presentIds[0];
|
|
432
|
+
const extension = ENTRY_IDS.find((k) => openclawPluginNames(home).has(k));
|
|
433
|
+
if (!entryName && !extension)
|
|
434
|
+
return undefined;
|
|
435
|
+
const entry = entryName && entries ? entries[entryName] : undefined;
|
|
436
|
+
const entryConfig = entry && typeof entry.config === "object" ? entry.config : undefined;
|
|
437
|
+
const keyPathConfigured = typeof entryConfig?.chromiaSecretPath === "string" && !!entryConfig.chromiaSecretPath.trim();
|
|
438
|
+
// Absent `enabled` means on — the plugin's own default. Only an explicit false is off.
|
|
439
|
+
const switchedOn = entry?.enabled !== false && entryConfig?.enabled !== false;
|
|
440
|
+
const current = agentPubkeyOnDisk(home);
|
|
441
|
+
const knownAgents = [...new Set([...archivedAgentPubkeys(home), ...(current ? [current] : [])])];
|
|
442
|
+
// An entry under the retired id is present but inert — the plugin reads a
|
|
443
|
+
// different key, so it can never be enforcing however complete it looks.
|
|
444
|
+
const inertId = entryName === "openclaw";
|
|
445
|
+
return {
|
|
446
|
+
// The install LEDGER counts as evidence too: a plugin can be recorded as
|
|
447
|
+
// installed on a machine whose plugin directory we do not recognise, and
|
|
448
|
+
// "not in a directory I know" is not the same as "not installed".
|
|
449
|
+
installed: !!(entryName || extension || ENTRY_IDS.some((k) => openclawLedgerNames(home).has(k))),
|
|
450
|
+
enforcing: !!entryName && !inertId && switchedOn && keyPathConfigured,
|
|
451
|
+
entry: entryName ?? extension,
|
|
452
|
+
keyPathConfigured,
|
|
453
|
+
agentPubkey: current,
|
|
454
|
+
...(knownAgents.length ? { knownAgents } : {}),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function hermesGovernance(home) {
|
|
458
|
+
if (!exists(home, ".hermes", "hermes-agent"))
|
|
459
|
+
return undefined;
|
|
460
|
+
// The package lands in the venv that runs Hermes, not in a plugins directory.
|
|
461
|
+
const venvBase = path.join(home, ".hermes", "hermes-agent", "venv", "lib");
|
|
462
|
+
const installed = listDirs(venvBase).some((py) => exists(venvBase, py, "site-packages", "atbash_hermes_plugin"));
|
|
463
|
+
// Opt-in allow-list: a missing `plugins.enabled` key means NOTHING loads.
|
|
464
|
+
let enabled = false;
|
|
465
|
+
const configYaml = readText(MAX_CONFIG_BYTES, home, ".hermes", "config.yaml");
|
|
466
|
+
if (configYaml) {
|
|
467
|
+
try {
|
|
468
|
+
const parsed = (0, yaml_1.parse)(configYaml);
|
|
469
|
+
const list = parsed?.plugins?.enabled;
|
|
470
|
+
enabled = Array.isArray(list) && list.some((n) => String(n).trim() === HERMES_PLUGIN_NAME);
|
|
471
|
+
}
|
|
472
|
+
catch { /* malformed config — treat as not enabled rather than guess */ }
|
|
473
|
+
}
|
|
474
|
+
const env = readText(MAX_CONFIG_BYTES, home, ".hermes", ".env") ?? "";
|
|
475
|
+
const keyPathConfigured = /^\s*(?:export\s+)?ATBASH_(?:KEY_PATH|AGENT_PRIVKEY)\s*=\s*\S/m.test(env);
|
|
476
|
+
const current = agentPubkeyOnDisk(home);
|
|
477
|
+
const knownAgents = [...new Set([...archivedAgentPubkeys(home), ...(current ? [current] : [])])];
|
|
478
|
+
return {
|
|
479
|
+
installed,
|
|
480
|
+
enforcing: installed && enabled && keyPathConfigured,
|
|
481
|
+
entry: HERMES_PLUGIN_NAME,
|
|
482
|
+
keyPathConfigured,
|
|
483
|
+
agentPubkey: current,
|
|
484
|
+
...(knownAgents.length ? { knownAgents } : {}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const HERMES_PLUGIN_NAME = "atbash-hermes-plugin";
|
|
315
488
|
/** Enumerate the agents/MCP tools configured on this machine. Read-only. */
|
|
316
489
|
function discover(home) {
|
|
317
490
|
const agents = [];
|
|
318
491
|
// OpenClaw → declared tools (skills live under ~/.hermes/skills; plus extensions).
|
|
319
492
|
if (exists(home, ".openclaw", "openclaw.json")) {
|
|
320
493
|
const tools = enumerateSkills(path.join(home, ".hermes", "skills"));
|
|
321
|
-
|
|
494
|
+
// Across EVERY layout, not just `extensions/` — see OPENCLAW_PLUGIN_ROOTS.
|
|
495
|
+
// Reading one store reported ZERO plugins on a machine whose plugins were all
|
|
496
|
+
// installed and loading.
|
|
497
|
+
for (const name of openclawPluginNames(home))
|
|
322
498
|
tools.push({ name: `plugin:${name}`, description: "OpenClaw plugin / extension" });
|
|
323
|
-
|
|
499
|
+
const governance = openclawGovernance(home);
|
|
500
|
+
agents.push({ type: "declared", label: "OpenClaw", detail: `${tools.length} skill${tools.length === 1 ? "" : "s"} / plugins`, declaredTools: tools, ...(governance ? { governance } : {}) });
|
|
324
501
|
}
|
|
325
502
|
// Hermes → declared tools (skills + agent plugins).
|
|
326
503
|
if (exists(home, ".hermes", "hermes-agent")) {
|
|
327
504
|
const tools = enumerateSkills(path.join(home, ".hermes", "skills"));
|
|
328
505
|
for (const name of listDirs(home, ".hermes", "hermes-agent", "plugins"))
|
|
329
506
|
tools.push({ name: `plugin:${name}`, description: "Hermes plugin" });
|
|
330
|
-
|
|
507
|
+
const governance = hermesGovernance(home);
|
|
508
|
+
agents.push({ type: "declared", label: "Hermes", detail: `${tools.length} skill${tools.length === 1 ? "" : "s"} / plugins`, declaredTools: tools, ...(governance ? { governance } : {}) });
|
|
331
509
|
}
|
|
332
510
|
// MCP config sources — collected once as RAW specs, then sanitized for relay.
|
|
333
511
|
for (const { label, servers } of readAllMcpServers(home)) {
|
|
@@ -351,12 +529,13 @@ function discover(home) {
|
|
|
351
529
|
* directly. Every path that turns these into an `Agent` MUST route them through
|
|
352
530
|
* `relaySafeServers()` first (the single sanitization boundary). If you add a new
|
|
353
531
|
* consumer, sanitize before it leaves this file. Callers today: `discover()`
|
|
354
|
-
* (sanitizes) and
|
|
532
|
+
* (sanitizes), `connect --introspect` and `atbash scan --introspect` (both local
|
|
533
|
+
* only, never relayed — scan has no relay path at all).
|
|
355
534
|
*/
|
|
356
535
|
function readAllMcpServers(home) {
|
|
357
536
|
const out = [];
|
|
358
537
|
const seen = new Set();
|
|
359
|
-
for (const { label, segs } of MCP_CONFIGS) {
|
|
538
|
+
for (const { label, segs } of atbash_targets_1.MCP_CONFIGS) {
|
|
360
539
|
if (seen.has(label))
|
|
361
540
|
continue;
|
|
362
541
|
const cfg = readJson(home, ...segs);
|
|
@@ -394,6 +573,35 @@ function readAllMcpServers(home) {
|
|
|
394
573
|
}
|
|
395
574
|
return out;
|
|
396
575
|
}
|
|
576
|
+
const LOOPBACK_RE = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|::1)$/;
|
|
577
|
+
function checkRelayHost(raw) {
|
|
578
|
+
const trimmed = String(raw ?? "").trim().replace(/\/+$/, "");
|
|
579
|
+
let url = null;
|
|
580
|
+
try {
|
|
581
|
+
url = new URL(trimmed);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
url = null;
|
|
585
|
+
}
|
|
586
|
+
if (!url) {
|
|
587
|
+
return { host: trimmed, hostname: "", isLoopback: false, recognized: false,
|
|
588
|
+
problem: `refusing to relay: --host is not a valid URL (${trimmed || "empty"})` };
|
|
589
|
+
}
|
|
590
|
+
const hostname = url.hostname.toLowerCase();
|
|
591
|
+
const isLoopback = LOOPBACK_RE.test(hostname);
|
|
592
|
+
const recognized = atbash_targets_1.KNOWN_HOSTS.has(hostname) || isLoopback;
|
|
593
|
+
// Normalized to scheme + host + path. Whatever the checks below reject is also
|
|
594
|
+
// dropped here, so nothing unvetted can survive into `endpoint`.
|
|
595
|
+
const host = `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
|
|
596
|
+
const v = (problem, hint) => ({ host, hostname, isLoopback, recognized, problem, hint });
|
|
597
|
+
if (url.username || url.password)
|
|
598
|
+
return v(`refusing to relay to a --host carrying credentials (${hostname})`, "Drop the user:pass@ prefix — the relay never authenticates this way.");
|
|
599
|
+
if (url.search || url.hash)
|
|
600
|
+
return v(`refusing to relay to a --host carrying a query string or fragment (${trimmed})`, "The session path is appended to --host, so a ? or # would redirect the request.");
|
|
601
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback))
|
|
602
|
+
return v(`refusing to send over a non-HTTPS host (${host}) — the relay must travel encrypted`, "Use an https:// host (localhost may use http).");
|
|
603
|
+
return v(null);
|
|
604
|
+
}
|
|
397
605
|
function registerConnectCommand(program) {
|
|
398
606
|
program
|
|
399
607
|
.command("connect")
|
|
@@ -407,7 +615,8 @@ function registerConnectCommand(program) {
|
|
|
407
615
|
.option("--allow-unrecognized-host", "Permit relaying to a --host that is not an official Atbash deployment (otherwise the command refuses to send)")
|
|
408
616
|
.action(async (rawCode, opts) => {
|
|
409
617
|
const home = opts.home || os.homedir();
|
|
410
|
-
const
|
|
618
|
+
const hostCheck = checkRelayHost(opts.host || process.env.ATBASH_HOST || atbash_targets_1.DEFAULT_HOST);
|
|
619
|
+
const host = hostCheck.host;
|
|
411
620
|
const code = rawCode.trim().toUpperCase().replace(/[^A-Z0-9-]/g, "");
|
|
412
621
|
const machine = os.hostname();
|
|
413
622
|
const dryRun = !!opts.dryRun;
|
|
@@ -415,19 +624,11 @@ function registerConnectCommand(program) {
|
|
|
415
624
|
const introspect = !!opts.introspect;
|
|
416
625
|
const allowUnrecognizedHost = !!opts.allowUnrecognizedHost;
|
|
417
626
|
const endpoint = `${host}/api/agent-scan/session/${encodeURIComponent(code)}`;
|
|
418
|
-
//
|
|
627
|
+
// Vetted up front so the destination verdict is available to every output
|
|
419
628
|
// path, including the JSON one an agent reads before deciding to proceed.
|
|
420
|
-
|
|
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);
|
|
629
|
+
const { hostname, isLoopback } = hostCheck;
|
|
429
630
|
// "Recognized" = an official Atbash deployment (or your own machine for dev).
|
|
430
|
-
const recognizedHost =
|
|
631
|
+
const recognizedHost = hostCheck.recognized;
|
|
431
632
|
// Machine-readable summary for the --json path AND for any error, so an agent
|
|
432
633
|
// gets a stable, parseable description of exactly what this command is/was doing.
|
|
433
634
|
const emitJson = (extra) => console.log(JSON.stringify({
|
|
@@ -443,7 +644,7 @@ function registerConnectCommand(program) {
|
|
|
443
644
|
machine, host, endpoint, code,
|
|
444
645
|
// An agent should treat `recognizedHost: false` as a reason to stop and
|
|
445
646
|
// ask, not a detail to skim past.
|
|
446
|
-
recognizedHost, knownHosts: [...KNOWN_HOSTS],
|
|
647
|
+
recognizedHost, knownHosts: [...atbash_targets_1.KNOWN_HOSTS],
|
|
447
648
|
...extra,
|
|
448
649
|
}, null, 2));
|
|
449
650
|
if (!code) {
|
|
@@ -457,13 +658,11 @@ function registerConnectCommand(program) {
|
|
|
457
658
|
// The only place this flow could be turned against you is a swapped --host
|
|
458
659
|
// pointing the relay at someone else's server. So: refuse a channel that
|
|
459
660
|
// isn't encrypted, and make an unrecognized destination impossible to miss.
|
|
460
|
-
|
|
461
|
-
if (!httpsOk) {
|
|
462
|
-
const msg = `refusing to send over a non-HTTPS host (${host}) — the relay must travel encrypted`;
|
|
661
|
+
if (hostCheck.problem) {
|
|
463
662
|
if (asJson)
|
|
464
|
-
emitJson({ ok: false, error:
|
|
663
|
+
emitJson({ ok: false, error: hostCheck.problem });
|
|
465
664
|
else
|
|
466
|
-
console.error("\n" + chalk_1.default.red(` ✗ ${
|
|
665
|
+
console.error("\n" + chalk_1.default.red(` ✗ ${hostCheck.problem}.`) + (hostCheck.hint ? chalk_1.default.dim(` ${hostCheck.hint}`) : "") + "\n");
|
|
467
666
|
process.exit(1);
|
|
468
667
|
}
|
|
469
668
|
// An unrecognized destination is the one real attack (a swapped --host). A
|
|
@@ -593,10 +792,30 @@ function registerConnectCommand(program) {
|
|
|
593
792
|
}
|
|
594
793
|
else {
|
|
595
794
|
console.log(" Found:");
|
|
596
|
-
for (const a of agents)
|
|
597
|
-
|
|
795
|
+
for (const a of agents) {
|
|
796
|
+
// Show whether Atbash is actually wired into this runtime, not just
|
|
797
|
+
// what it can do. The relay has carried this since 0.5.15-dev.5, but it
|
|
798
|
+
// was invisible here AND in the browser, so "did the scan detect
|
|
799
|
+
// governance?" had no answer anywhere — which defeats the point of
|
|
800
|
+
// detecting it. Silent for MCP-client configs and framework exports,
|
|
801
|
+
// where the question does not apply.
|
|
802
|
+
const g = "governance" in a ? a.governance : undefined;
|
|
803
|
+
const badge = !g
|
|
804
|
+
? ""
|
|
805
|
+
: g.enforcing
|
|
806
|
+
? " " + chalk_1.default.green("✓ Atbash enforcing")
|
|
807
|
+
: g.installed
|
|
808
|
+
? " " + chalk_1.default.yellow("⚠ Atbash installed, NOT enforcing")
|
|
809
|
+
: " " + chalk_1.default.dim("· Atbash not installed");
|
|
810
|
+
console.log(` ${chalk_1.default.green("•")} ${a.label} ${chalk_1.default.dim("— " + a.detail)}${badge}`);
|
|
811
|
+
}
|
|
598
812
|
console.log("");
|
|
599
813
|
}
|
|
814
|
+
const halfWired = agents.filter((a) => "governance" in a && a.governance && !a.governance.enforcing && a.governance.installed);
|
|
815
|
+
if (halfWired.length) {
|
|
816
|
+
console.log(chalk_1.default.yellow(` ⚠ ${halfWired.map((a) => a.label).join(", ")}: Atbash is installed but not enforcing.`) +
|
|
817
|
+
chalk_1.default.dim("\n Actions through that runtime are NOT judged. Run `atbash setup` there, or check the\n plugin is enabled and pointed at a key.\n"));
|
|
818
|
+
}
|
|
600
819
|
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
820
|
if (dryRun) {
|
|
602
821
|
console.log(chalk_1.default.bold(" Dry run — the exact JSON that WOULD be sent:"));
|