feat(cli): 1.5.0 — CLI-first architecture, tool-less MCP, policy engine
CLI becomes the API; MCP becomes a tool-less push-pipe. Bundle -42% (250 KB → 146 KB) after stripping ~1700 lines of dead tool handlers. - Tool-less MCP: tools/list returns []. Inbound peer messages still arrive as experimental.claude/channel notifications mid-turn. - Resource-noun-verb CLI: peer list, message send, memory recall, etc. Legacy flat verbs (peers, send, remember) remain as aliases. - Bundled claudemesh skill auto-installed by `claudemesh install` — sole CLI-discoverability surface for Claude. - Unix-socket bridge: CLI invocations dial the push-pipe's warm WS (~220 ms warm vs ~600 ms cold). - --mesh <slug> flag: connect a session to multiple meshes. - Policy engine: every broker-touching verb runs through a YAML gate at ~/.claudemesh/policy.yaml (auto-created). Destructive verbs prompt; non-TTY auto-denies. Audit log at ~/.claudemesh/audit.log. - --approval-mode plan|read-only|write|yolo + --policy <path>. Spec: .artifacts/specs/2026-05-02-architecture-north-star.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
114
apps/cli/src/services/bridge/client.ts
Normal file
114
apps/cli/src/services/bridge/client.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Bridge client — CLI invocations dial the per-mesh Unix socket the
|
||||
* MCP push-pipe holds open, so they reuse its warm WS instead of opening
|
||||
* a fresh one (~5ms vs ~300-700ms).
|
||||
*
|
||||
* Usage from a command:
|
||||
*
|
||||
* const result = await tryBridge(meshSlug, "send", { to, message });
|
||||
* if (result === null) { ...fall through to cold withMesh()... }
|
||||
* else { ...warm path succeeded... }
|
||||
*
|
||||
* `tryBridge` returns null on:
|
||||
* - socket file absent (no push-pipe running)
|
||||
* - socket connect fails (push-pipe crashed without cleanup)
|
||||
* - bridge timeout
|
||||
* That null is the caller's signal to fall back to a cold WS connection
|
||||
* via `withMesh`. So the bridge is purely an optimization — every verb
|
||||
* still works without it.
|
||||
*/
|
||||
|
||||
import { createConnection } from "node:net";
|
||||
import { existsSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
socketPath,
|
||||
frame,
|
||||
LineParser,
|
||||
type BridgeRequest,
|
||||
type BridgeResponse,
|
||||
type BridgeVerb,
|
||||
} from "./protocol.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Send one request and await the matching response. Returns:
|
||||
* - { ok: true, result } on success
|
||||
* - { ok: false, error } on bridge-reachable-but-broker-error
|
||||
* - null on bridge-unreachable (caller should fall back to cold WS)
|
||||
*/
|
||||
export async function tryBridge(
|
||||
meshSlug: string,
|
||||
verb: BridgeVerb,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<{ ok: true; result: unknown } | { ok: false; error: string } | null> {
|
||||
const path = socketPath(meshSlug);
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const id = randomUUID();
|
||||
const req: BridgeRequest = { id, verb, args };
|
||||
const parser = new LineParser();
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
value: { ok: true; result: unknown } | { ok: false; error: string } | null,
|
||||
): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.destroy(); } catch {}
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const socket = createConnection({ path });
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(null); // timeout = bridge unreachable, fall back to cold path
|
||||
}, timeoutMs);
|
||||
|
||||
socket.on("connect", () => {
|
||||
try {
|
||||
socket.write(frame(req));
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
const lines = parser.feed(chunk);
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let res: BridgeResponse;
|
||||
try {
|
||||
res = JSON.parse(line) as BridgeResponse;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (res.id !== id) continue; // not our response — keep reading
|
||||
if (res.ok) finish({ ok: true, result: res.result });
|
||||
else finish({ ok: false, error: res.error });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
// ENOENT (file disappeared between existsSync and connect),
|
||||
// ECONNREFUSED (stale socket), EPERM (permission), etc. — all mean
|
||||
// bridge unreachable.
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ECONNREFUSED" || code === "ENOENT" || code === "EPERM") {
|
||||
finish(null);
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
// If we close without a response, treat as unreachable.
|
||||
finish(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
93
apps/cli/src/services/bridge/protocol.ts
Normal file
93
apps/cli/src/services/bridge/protocol.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Bridge protocol — wire format between the MCP push-pipe (server) and
|
||||
* CLI invocations (client) over a per-mesh Unix domain socket.
|
||||
*
|
||||
* Why: every CLI op should reuse the warm WS the push-pipe already holds
|
||||
* (~5ms) instead of opening its own (~300-700ms cold start). The bridge is
|
||||
* the load-bearing piece of the CLI-first architecture — see
|
||||
* .artifacts/specs/2026-05-02-architecture-north-star.md commitment #3.
|
||||
*
|
||||
* Wire format: line-delimited JSON. One JSON object per "\n"-terminated line.
|
||||
* Each request carries an `id` string; the response echoes it.
|
||||
*
|
||||
* Socket path: ~/.claudemesh/sockets/<mesh-slug>.sock (mode 0600).
|
||||
*
|
||||
* Connection model: persistent. A CLI invocation opens, sends one or more
|
||||
* requests, reads matching responses, then closes. Multiplexing via `id`
|
||||
* means concurrent CLI calls don't have to serialize on the same socket
|
||||
* (though current callers all do one round-trip and exit).
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
/** Socket path for a given mesh. Caller is responsible for ensuring the
|
||||
* parent directory exists (`~/.claudemesh/sockets/`). */
|
||||
export function socketPath(meshSlug: string): string {
|
||||
return join(homedir(), ".claudemesh", "sockets", `${meshSlug}.sock`);
|
||||
}
|
||||
|
||||
/** Directory holding all per-mesh sockets. Created with mode 0700 on push-pipe boot. */
|
||||
export function socketDir(): string {
|
||||
return join(homedir(), ".claudemesh", "sockets");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbs the bridge accepts. Keep this list narrow in 1.2.0 — three writes
|
||||
* (send, summary, status), the read-shaped peers, plus ping for health.
|
||||
* Expand in 1.3.0 once the bridge is proven.
|
||||
*/
|
||||
export type BridgeVerb =
|
||||
| "ping"
|
||||
| "peers"
|
||||
| "send"
|
||||
| "summary"
|
||||
| "status_set"
|
||||
| "visible";
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
verb: BridgeVerb;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BridgeResponseOk {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export interface BridgeResponseErr {
|
||||
id: string;
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
|
||||
|
||||
/** Serialise a request/response to a single line ("\n"-terminated). */
|
||||
export function frame(obj: BridgeRequest | BridgeResponse): string {
|
||||
return JSON.stringify(obj) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful line-buffered parser. Pass each chunk from the socket via
|
||||
* `feed`; collect completed lines from the returned array.
|
||||
*/
|
||||
export class LineParser {
|
||||
private buf = "";
|
||||
|
||||
feed(chunk: Buffer | string): string[] {
|
||||
this.buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
const lines: string[] = [];
|
||||
let nl = this.buf.indexOf("\n");
|
||||
while (nl !== -1) {
|
||||
lines.push(this.buf.slice(0, nl));
|
||||
this.buf = this.buf.slice(nl + 1);
|
||||
nl = this.buf.indexOf("\n");
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
229
apps/cli/src/services/bridge/server.ts
Normal file
229
apps/cli/src/services/bridge/server.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Bridge server — the MCP push-pipe runs one of these per connected mesh.
|
||||
*
|
||||
* Listens on a Unix domain socket at `~/.claudemesh/sockets/<mesh-slug>.sock`,
|
||||
* accepts line-delimited JSON requests from CLI invocations, dispatches each
|
||||
* request to the corresponding `BrokerClient` method, and writes the response
|
||||
* back on the same line.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `startBridgeServer(client)` is called from the MCP push-pipe boot path
|
||||
* once the WS is connected (or even before — verbs that need an open WS
|
||||
* will return an error).
|
||||
* - On startup it `unlinks` any stale socket file (left by a crashed
|
||||
* prior process), then `listen`s.
|
||||
* - On shutdown (`stop()`) it closes the listener and unlinks the socket.
|
||||
*
|
||||
* Concurrency: each accepted connection gets its own line-buffered parser.
|
||||
* Multiple in-flight requests are correlated by `id`; the server doesn't
|
||||
* need to serialize because the underlying `BrokerClient` calls are
|
||||
* `async` and non-blocking.
|
||||
*
|
||||
* Error model: malformed lines are dropped silently (don't tear down the
|
||||
* socket). Unknown verbs return `{ok: false, error: "unknown verb"}`.
|
||||
* Broker errors are wrapped into the `error` string.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type Socket } from "node:net";
|
||||
import { mkdirSync, unlinkSync, existsSync, chmodSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { BrokerClient } from "~/services/broker/facade.js";
|
||||
import {
|
||||
socketPath,
|
||||
socketDir,
|
||||
frame,
|
||||
LineParser,
|
||||
type BridgeRequest,
|
||||
type BridgeResponse,
|
||||
type BridgeVerb,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface BridgeServer {
|
||||
stop(): void;
|
||||
path: string;
|
||||
}
|
||||
|
||||
type PeerStatus = "idle" | "working" | "dnd";
|
||||
|
||||
/**
|
||||
* Resolve a `to` string to a broker-friendly target spec. Mirrors what
|
||||
* `commands/send.ts` does today — display name → pubkey, hex stays hex,
|
||||
* `@group` and `*` pass through.
|
||||
*/
|
||||
async function resolveTarget(
|
||||
client: BrokerClient,
|
||||
to: string,
|
||||
): Promise<{ ok: true; spec: string } | { ok: false; error: string }> {
|
||||
if (to.startsWith("@") || to === "*" || /^[0-9a-f]{64}$/i.test(to)) {
|
||||
return { ok: true, spec: to };
|
||||
}
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
|
||||
if (!match) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `peer "${to}" not found. online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, spec: match.pubkey };
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
client: BrokerClient,
|
||||
req: BridgeRequest,
|
||||
): Promise<BridgeResponse> {
|
||||
const args = req.args ?? {};
|
||||
try {
|
||||
switch (req.verb as BridgeVerb) {
|
||||
case "ping": {
|
||||
const peers = await client.listPeers();
|
||||
return {
|
||||
id: req.id,
|
||||
ok: true,
|
||||
result: {
|
||||
mesh: client.meshSlug,
|
||||
ws_status: client.status,
|
||||
peers_online: peers.length,
|
||||
push_buffer: client.pushHistory.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "peers": {
|
||||
const peers = await client.listPeers();
|
||||
return { id: req.id, ok: true, result: peers };
|
||||
}
|
||||
case "send": {
|
||||
const to = String(args.to ?? "");
|
||||
const message = String(args.message ?? "");
|
||||
const priority = (args.priority as "now" | "next" | "low" | undefined) ?? "next";
|
||||
if (!to || !message) {
|
||||
return { id: req.id, ok: false, error: "send: `to` and `message` required" };
|
||||
}
|
||||
const resolved = await resolveTarget(client, to);
|
||||
if (!resolved.ok) return { id: req.id, ok: false, error: resolved.error };
|
||||
const result = await client.send(resolved.spec, message, priority);
|
||||
if (!result.ok) {
|
||||
return { id: req.id, ok: false, error: result.error ?? "send failed" };
|
||||
}
|
||||
return {
|
||||
id: req.id,
|
||||
ok: true,
|
||||
result: { messageId: result.messageId, target: resolved.spec },
|
||||
};
|
||||
}
|
||||
case "summary": {
|
||||
const text = String(args.summary ?? "");
|
||||
if (!text) return { id: req.id, ok: false, error: "summary: `summary` required" };
|
||||
await client.setSummary(text);
|
||||
return { id: req.id, ok: true, result: { summary: text } };
|
||||
}
|
||||
case "status_set": {
|
||||
const state = String(args.status ?? "") as PeerStatus;
|
||||
if (!["idle", "working", "dnd"].includes(state)) {
|
||||
return { id: req.id, ok: false, error: "status_set: must be idle | working | dnd" };
|
||||
}
|
||||
await client.setStatus(state);
|
||||
return { id: req.id, ok: true, result: { status: state } };
|
||||
}
|
||||
case "visible": {
|
||||
const visible = Boolean(args.visible);
|
||||
await client.setVisible(visible);
|
||||
return { id: req.id, ok: true, result: { visible } };
|
||||
}
|
||||
default:
|
||||
return { id: req.id, ok: false, error: `unknown verb: ${req.verb}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleConnection(socket: Socket, client: BrokerClient): void {
|
||||
const parser = new LineParser();
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
const lines = parser.feed(chunk);
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let req: BridgeRequest;
|
||||
try {
|
||||
req = JSON.parse(line) as BridgeRequest;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!req || typeof req !== "object" || !req.id || !req.verb) continue;
|
||||
|
||||
// Fire-and-await without blocking the read loop.
|
||||
void dispatch(client, req).then((res) => {
|
||||
try {
|
||||
socket.write(frame(res));
|
||||
} catch {
|
||||
/* socket might have closed mid-flight; ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
// Don't crash the push-pipe on per-connection errors.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the per-mesh bridge server. Returns a handle the caller stores so
|
||||
* it can `stop()` on shutdown.
|
||||
*
|
||||
* Idempotent: if a socket file already exists, attempts to connect to it.
|
||||
* If that connection succeeds, another live process owns it — return null.
|
||||
* If it fails (ECONNREFUSED), the file is stale; unlink it and proceed.
|
||||
*/
|
||||
export function startBridgeServer(client: BrokerClient): BridgeServer | null {
|
||||
const path = socketPath(client.meshSlug);
|
||||
const dir = socketDir();
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Last-writer-wins: unconditionally remove any existing socket file and
|
||||
// bind fresh. A live process previously holding it keeps its already-
|
||||
// accepted connections (sockets aren't path-based after connect), but
|
||||
// new CLI dials hit the new server. In practice this only matters when
|
||||
// two `claudemesh launch` invocations target the same mesh — rare, and
|
||||
// either instance serving CLI requests is fine because both speak to
|
||||
// the same broker.
|
||||
if (existsSync(path)) {
|
||||
try { unlinkSync(path); } catch {}
|
||||
}
|
||||
|
||||
const server: Server = createServer((socket) => handleConnection(socket, client));
|
||||
|
||||
try {
|
||||
server.listen(path);
|
||||
} catch (err) {
|
||||
process.stderr.write(`[claudemesh] bridge: failed to bind ${path}: ${String(err)}\n`);
|
||||
return null;
|
||||
}
|
||||
|
||||
server.on("error", (err) => {
|
||||
process.stderr.write(`[claudemesh] bridge: ${String(err)}\n`);
|
||||
});
|
||||
|
||||
// Tighten permissions so other users on the host can't dial in.
|
||||
try { chmodSync(path, 0o600); } catch {}
|
||||
|
||||
let stopped = false;
|
||||
return {
|
||||
path,
|
||||
stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try { server.close(); } catch {}
|
||||
try { unlinkSync(path); } catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
324
apps/cli/src/services/policy/index.ts
Normal file
324
apps/cli/src/services/policy/index.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Policy engine — gates every CLI verb's broker call behind allow/prompt/deny
|
||||
* rules evaluated against a YAML config. Modeled on Gemini CLI's `--policy /
|
||||
* --admin-policy` and Codex's `--sandbox` modes.
|
||||
*
|
||||
* Why: when claudemesh is invoked from Claude's Bash tool, the user's
|
||||
* `allowedTools = ["Bash"]` setting gives Claude carte blanche over the
|
||||
* CLI. The policy engine adds a second gate INSIDE claudemesh that the
|
||||
* shell-permission layer can't bypass — `claudemesh file delete` can be
|
||||
* `decision: deny` regardless of whether Bash is allowed.
|
||||
*
|
||||
* Spec: .artifacts/specs/2026-05-02-architecture-north-star.md commitment #7.
|
||||
*
|
||||
* Decision tree:
|
||||
* 1. Parse `--approval-mode` flag → coarse mode (plan|read-only|write|yolo).
|
||||
* 2. Read user policy from --policy <path> | $CLAUDEMESH_POLICY |
|
||||
* ~/.claudemesh/policy.yaml (auto-created with defaults).
|
||||
* 3. Read admin policy (if any) from --admin-policy | /etc/claudemesh/admin-policy.yaml.
|
||||
* Admin rules win on conflict.
|
||||
* 4. For an invocation `(resource, verb, mesh)`:
|
||||
* a. Coarse mode: read-only/plan deny all writes outright.
|
||||
* b. Match the most-specific rule (admin > user > built-in default).
|
||||
* c. Apply decision: allow | prompt | deny.
|
||||
* d. On `prompt`, ask interactively unless `--yes` or yolo mode.
|
||||
*
|
||||
* Audit log: simple newline-JSON append-only at ~/.claudemesh/audit.log.
|
||||
* Hash-chained tamper-evidence is parked for 2.x.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
export type ApprovalMode = "plan" | "read-only" | "write" | "yolo";
|
||||
|
||||
export type Decision = "allow" | "prompt" | "deny";
|
||||
|
||||
/** A single rule. Earlier rules are matched first; the first match wins. */
|
||||
export interface PolicyRule {
|
||||
/** Resource name, e.g. "send", "file", "sql". `*` matches any. */
|
||||
resource: string;
|
||||
/** Verb name, e.g. "delete", "execute", "list". `*` matches any. */
|
||||
verb: string;
|
||||
/** Optional mesh slug filter. Omit for all meshes. */
|
||||
mesh?: string;
|
||||
/** Optional peer filter (display name, @group, or *). Currently advisory. */
|
||||
peers?: string[];
|
||||
/** What to do on match. */
|
||||
decision: Decision;
|
||||
/** Free-text reason surfaced when decision is `prompt` or `deny`. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
default: Decision;
|
||||
rules: PolicyRule[];
|
||||
}
|
||||
|
||||
/** Built-in fallback if no user/admin policy exists. Sensible defaults:
|
||||
* destructive writes prompt; everything else is allowed. The user's first
|
||||
* run writes this file so they can edit it. */
|
||||
export const DEFAULT_POLICY: Policy = {
|
||||
default: "allow",
|
||||
rules: [
|
||||
// Destructive writes — prompt the human.
|
||||
{ resource: "peer", verb: "kick", decision: "prompt", reason: "ends a peer's session" },
|
||||
{ resource: "peer", verb: "ban", decision: "prompt", reason: "permanently revokes membership" },
|
||||
{ resource: "peer", verb: "disconnect", decision: "prompt", reason: "disconnects a peer" },
|
||||
{ resource: "file", verb: "delete", decision: "prompt", reason: "deletes a shared file" },
|
||||
{ resource: "vector", verb: "delete", decision: "prompt", reason: "removes vector entries" },
|
||||
{ resource: "vault", verb: "delete", decision: "prompt", reason: "deletes encrypted secret" },
|
||||
{ resource: "memory", verb: "forget", decision: "prompt", reason: "removes shared memory" },
|
||||
{ resource: "skill", verb: "remove", decision: "prompt", reason: "removes published skill" },
|
||||
{ resource: "webhook", verb: "delete", decision: "prompt", reason: "removes webhook integration" },
|
||||
{ resource: "watch", verb: "remove", decision: "prompt", reason: "removes URL watcher" },
|
||||
{ resource: "sql", verb: "execute", decision: "prompt", reason: "raw SQL write to mesh DB" },
|
||||
{ resource: "graph", verb: "execute", decision: "prompt", reason: "graph mutation" },
|
||||
{ resource: "mesh", verb: "delete", decision: "prompt", reason: "deletes the mesh for everyone" },
|
||||
],
|
||||
};
|
||||
|
||||
const USER_POLICY_PATH = join(homedir(), ".claudemesh", "policy.yaml");
|
||||
const AUDIT_LOG_PATH = join(homedir(), ".claudemesh", "audit.log");
|
||||
|
||||
/**
|
||||
* Minimal YAML parser for our policy format. Accepts the shape:
|
||||
* default: allow|prompt|deny
|
||||
* rules:
|
||||
* - resource: peer
|
||||
* verb: kick
|
||||
* mesh: flexicar # optional
|
||||
* peers: ["@admin"] # optional
|
||||
* decision: prompt
|
||||
* reason: "..." # optional
|
||||
*
|
||||
* We avoid pulling in a real YAML dep (zero-dep CLI). For complex configs
|
||||
* users can pre-process to JSON; we accept that too via .json extension.
|
||||
*/
|
||||
export function parsePolicyYaml(text: string): Policy {
|
||||
// If the file is JSON, parse directly.
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
return JSON.parse(trimmed) as Policy;
|
||||
}
|
||||
|
||||
const policy: Policy = { default: "allow", rules: [] };
|
||||
const lines = text.split("\n");
|
||||
let cur: Partial<PolicyRule> | null = null;
|
||||
const flush = (): void => {
|
||||
if (cur && cur.resource && cur.verb && cur.decision) {
|
||||
policy.rules.push(cur as PolicyRule);
|
||||
}
|
||||
cur = null;
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/#.*$/, "").trimEnd();
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const top = line.match(/^(default):\s*(\S+)/);
|
||||
if (top) {
|
||||
policy.default = top[2] as Decision;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^rules\s*:/.test(line)) continue;
|
||||
|
||||
// New rule entry: starts with " -" or "- "
|
||||
if (/^\s*-\s/.test(line)) {
|
||||
flush();
|
||||
cur = {};
|
||||
const m = line.match(/-\s*(\w+)\s*:\s*(.*)$/);
|
||||
if (m) (cur as Record<string, unknown>)[m[1]!] = parseValue(m[2]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation key/value within a rule: " key: value"
|
||||
const kv = line.match(/^\s+(\w+)\s*:\s*(.*)$/);
|
||||
if (kv && cur) {
|
||||
(cur as Record<string, unknown>)[kv[1]!] = parseValue(kv[2]!);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
function parseValue(raw: string): string | string[] | boolean | number {
|
||||
const v = raw.trim();
|
||||
if (!v) return "";
|
||||
// Inline array: ["a", "b"]
|
||||
if (v.startsWith("[") && v.endsWith("]")) {
|
||||
return v
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
// Quoted string
|
||||
const q = v.match(/^["'](.*)["']$/);
|
||||
if (q) return q[1]!;
|
||||
// Bools / numbers
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Serialise a Policy as YAML. */
|
||||
export function serializePolicyYaml(p: Policy): string {
|
||||
let out = `# claudemesh policy file\n`;
|
||||
out += `# Edit to change which CLI ops require confirmation or are forbidden.\n`;
|
||||
out += `# Decisions: allow | prompt | deny\n`;
|
||||
out += `# See: ~/.claude/skills/claudemesh/SKILL.md or claudemesh policy --help\n\n`;
|
||||
out += `default: ${p.default}\n\n`;
|
||||
out += `rules:\n`;
|
||||
for (const r of p.rules) {
|
||||
out += ` - resource: ${r.resource}\n`;
|
||||
out += ` verb: ${r.verb}\n`;
|
||||
if (r.mesh) out += ` mesh: ${r.mesh}\n`;
|
||||
if (r.peers) out += ` peers: [${r.peers.map((p) => `"${p}"`).join(", ")}]\n`;
|
||||
out += ` decision: ${r.decision}\n`;
|
||||
if (r.reason) out += ` reason: "${r.reason}"\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Load the user's policy, creating the default on first run. */
|
||||
export function loadPolicy(opts?: { policyPath?: string; envOverride?: string }): Policy {
|
||||
const path =
|
||||
opts?.policyPath ??
|
||||
opts?.envOverride ??
|
||||
process.env.CLAUDEMESH_POLICY ??
|
||||
USER_POLICY_PATH;
|
||||
|
||||
if (!existsSync(path)) {
|
||||
// First run — write defaults so the user can discover/edit them.
|
||||
if (path === USER_POLICY_PATH) {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, serializePolicyYaml(DEFAULT_POLICY), "utf-8");
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
return DEFAULT_POLICY;
|
||||
}
|
||||
|
||||
try {
|
||||
return parsePolicyYaml(readFileSync(path, "utf-8"));
|
||||
} catch (e) {
|
||||
process.stderr.write(
|
||||
`[claudemesh] policy: failed to parse ${path}: ${e instanceof Error ? e.message : String(e)}\n`,
|
||||
);
|
||||
return DEFAULT_POLICY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Match wildcards: `*` in the rule matches anything. */
|
||||
function matches(rule: string, value: string): boolean {
|
||||
if (rule === "*") return true;
|
||||
return rule === value;
|
||||
}
|
||||
|
||||
export interface CheckContext {
|
||||
resource: string;
|
||||
verb: string;
|
||||
mesh?: string;
|
||||
/** Coarse mode from --approval-mode (or default 'write'). */
|
||||
mode: ApprovalMode;
|
||||
/** True if the verb is destructive (kick/ban/delete/forget/execute/etc). */
|
||||
isWrite: boolean;
|
||||
/** If true, prompt-decisions are auto-approved (e.g. -y / yolo). */
|
||||
yes: boolean;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
decision: Decision;
|
||||
reason?: string;
|
||||
matchedRule?: PolicyRule;
|
||||
}
|
||||
|
||||
/** Evaluate a policy against a check context. Pure — no I/O. */
|
||||
export function evaluate(policy: Policy, ctx: CheckContext): CheckResult {
|
||||
// Coarse approval-mode short-circuits.
|
||||
if (ctx.mode === "yolo") return { decision: "allow", reason: "yolo mode" };
|
||||
if ((ctx.mode === "plan" || ctx.mode === "read-only") && ctx.isWrite) {
|
||||
return { decision: "deny", reason: `${ctx.mode} mode forbids writes` };
|
||||
}
|
||||
|
||||
for (const r of policy.rules) {
|
||||
if (!matches(r.resource, ctx.resource)) continue;
|
||||
if (!matches(r.verb, ctx.verb)) continue;
|
||||
if (r.mesh && ctx.mesh && r.mesh !== ctx.mesh) continue;
|
||||
return { decision: r.decision, reason: r.reason, matchedRule: r };
|
||||
}
|
||||
return { decision: policy.default };
|
||||
}
|
||||
|
||||
/** Append a one-line JSON record to ~/.claudemesh/audit.log. */
|
||||
export function audit(record: Record<string, unknown>): void {
|
||||
try {
|
||||
mkdirSync(dirname(AUDIT_LOG_PATH), { recursive: true });
|
||||
appendFileSync(
|
||||
AUDIT_LOG_PATH,
|
||||
JSON.stringify({ ts: new Date().toISOString(), ...record }) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt for `prompt` decisions. Returns true if the user
|
||||
* approves. In a non-TTY context (cron, scripts) returns false to be safe —
|
||||
* the user must opt in via `--approval-mode yolo` or a `decision: allow`
|
||||
* rule.
|
||||
*/
|
||||
export async function confirmPrompt(message: string): Promise<boolean> {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
return false;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(`${message} [y/N] `, (answer) => {
|
||||
rl.close();
|
||||
const a = answer.trim().toLowerCase();
|
||||
resolve(a === "y" || a === "yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-stop check: load policy, evaluate, audit, prompt if needed. Returns
|
||||
* `true` if the operation may proceed, `false` if blocked.
|
||||
*
|
||||
* Callers pass in `ctx` with the current invocation. They should `return`
|
||||
* (or `process.exit`) when this returns false.
|
||||
*/
|
||||
export async function gate(ctx: CheckContext, opts?: { policyPath?: string }): Promise<boolean> {
|
||||
const policy = loadPolicy(opts);
|
||||
const result = evaluate(policy, ctx);
|
||||
|
||||
audit({ ...ctx, decision: result.decision, reason: result.reason });
|
||||
|
||||
if (result.decision === "allow") return true;
|
||||
if (result.decision === "deny") {
|
||||
process.stderr.write(
|
||||
`\n ✘ blocked by policy: ${ctx.resource} ${ctx.verb}` +
|
||||
(result.reason ? ` — ${result.reason}` : "") +
|
||||
`\n edit ${USER_POLICY_PATH} to change.\n`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// prompt
|
||||
if (ctx.yes) return true;
|
||||
const reason = result.reason ? ` — ${result.reason}` : "";
|
||||
const confirmed = await confirmPrompt(
|
||||
`\n ⚠ ${ctx.resource} ${ctx.verb}${reason}. Continue?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
process.stderr.write(` cancelled.\n`);
|
||||
audit({ ...ctx, decision: "cancelled-at-prompt" });
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
Reference in New Issue
Block a user