From c6d871f8879685ea51ac0b3f8912a79de2c2b10a Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Wed, 6 May 2026 23:42:24 -0700 Subject: [PATCH 1/7] Add Claude Desktop integration with MCP-proxy enforcement --- cli/src/commands/install.ts | 20 +- cli/src/commands/mcp-proxy.ts | 339 +++++++++++++++++ cli/src/commands/mcp-server.ts | 198 ++++++++++ cli/src/detect/agentlock-state.ts | 39 ++ cli/src/detect/claude-desktop.ts | 71 ++++ cli/src/detect/index.ts | 2 + cli/src/detect/types.ts | 1 + cli/src/index.ts | 39 ++ cli/src/util/install-fs.ts | 20 +- cli/tests/detect.test.ts | 54 +++ cli/tests/mcp-proxy.test.ts | 249 ++++++++++++ cli/tests/mcp-server.test.ts | 174 +++++++++ .../internal/api/hooks_claude_desktop.go | 245 ++++++++++++ .../internal/api/hooks_claude_desktop_test.go | 253 +++++++++++++ control-plane/internal/api/install.go | 31 +- .../internal/api/install_claude_desktop.go | 355 ++++++++++++++++++ .../api/install_claude_desktop_test.go | 237 ++++++++++++ control-plane/internal/api/router.go | 4 + 18 files changed, 2327 insertions(+), 4 deletions(-) create mode 100644 cli/src/commands/mcp-proxy.ts create mode 100644 cli/src/commands/mcp-server.ts create mode 100644 cli/src/detect/claude-desktop.ts create mode 100644 cli/tests/mcp-proxy.test.ts create mode 100644 cli/tests/mcp-server.test.ts create mode 100644 control-plane/internal/api/hooks_claude_desktop.go create mode 100644 control-plane/internal/api/hooks_claude_desktop_test.go create mode 100644 control-plane/internal/api/install_claude_desktop.go create mode 100644 control-plane/internal/api/install_claude_desktop_test.go diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index ea67c3c..7294d31 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -38,6 +38,7 @@ import { executeUninstallOps, readExistingFiles, } from "../util/install-fs.ts"; +import { claudeDesktopConfigPath } from "../detect/claude-desktop.ts"; import { binDir, home, isWin } from "../util/paths.ts"; import { mintAttestedSession, type AttestedTier } from "../util/session-mint.ts"; @@ -159,15 +160,22 @@ export async function runInstall(argv: string[] = []): Promise { // explicitly avoids the host-vs-container path mismatch. When --config- // dir is set, mirror it for every harness so the legacy flag's "single // dir wins" behavior is preserved on both sides. + // Claude Desktop's config sits under platform-specific Application + // Support / APPDATA dirs, not under a "~/.claude" sibling. Resolve via + // the detector helper so dev mode (AGENTLOCK_DEV_HOME) and real-host + // mode share one source of truth for the path. + const claudeDesktopDir = resolve(join(claudeDesktopConfigPath(), "..")); const hostConfigDirs: Record = flags.configDirOverride ? { "claude-code": flags.configDirOverride, + "claude-desktop": flags.configDirOverride, codex: flags.configDirOverride, cursor: flags.configDirOverride, gemini: flags.configDirOverride, } : { "claude-code": resolve(join(home(), ".claude")), + "claude-desktop": claudeDesktopDir, codex: resolve(join(home(), ".codex")), cursor: resolve(join(home(), ".cursor")), gemini: resolve(join(home(), ".gemini")), @@ -177,7 +185,11 @@ export async function runInstall(argv: string[] = []): Promise { const devMode = !!process.env.AGENTLOCK_DEV_HOME; const results = await detectAll(); const isMvpEnabled = (id: HarnessId): boolean => - id === "claude-code" || id === "codex" || id === "cursor" || id === "gemini"; + id === "claude-code" || + id === "claude-desktop" || + id === "codex" || + id === "cursor" || + id === "gemini"; const options = results.map((r) => { const enabled = devMode || isMvpEnabled(r.id); let sub: string; @@ -337,6 +349,8 @@ export async function runInstall(argv: string[] = []): Promise { if (!dir) continue; if (id === "claude-code") { uninstallPaths.push(resolve(join(dir, "settings.json"))); + } else if (id === "claude-desktop") { + uninstallPaths.push(resolve(join(dir, "claude_desktop_config.json"))); } else if (id === "codex" || id === "cursor") { uninstallPaths.push(resolve(join(dir, "hooks.json"))); } else if (id === "gemini") { @@ -388,6 +402,9 @@ export async function runInstall(argv: string[] = []): Promise { const claudeSettings = resolve( join(hostConfigDirs["claude-code"], "settings.json"), ); + const claudeDesktopConfig = resolve( + join(hostConfigDirs["claude-desktop"], "claude_desktop_config.json"), + ); const codexHooks = resolve(join(hostConfigDirs["codex"], "hooks.json")); const codexConfig = resolve(join(hostConfigDirs["codex"], "config.toml")); const cursorHooks = resolve(join(hostConfigDirs["cursor"], "hooks.json")); @@ -396,6 +413,7 @@ export async function runInstall(argv: string[] = []): Promise { ); const existingFiles = await readExistingFiles([ claudeSettings, + claudeDesktopConfig, codexHooks, codexConfig, cursorHooks, diff --git a/cli/src/commands/mcp-proxy.ts b/cli/src/commands/mcp-proxy.ts new file mode 100644 index 0000000..871043d --- /dev/null +++ b/cli/src/commands/mcp-proxy.ts @@ -0,0 +1,339 @@ +// `agentlock mcp-proxy --name -- ` +// +// Stdio proxy that sits between Claude Desktop and a real MCP server. +// Spawned by Claude Desktop after `agentlock install` rewrites the +// user's claude_desktop_config.json so every entry points at this proxy +// instead of at the original command. Original command + args + env are +// preserved in the same config under `_agentlock_original` so uninstall +// can restore them. +// +// Wire shape: +// * stdin ← Claude Desktop's JSON-RPC frames (newline-delimited) +// * stdout → frames going back to Claude Desktop +// * child stdin/stdout ↔ the real MCP server we spawned +// +// We pass everything through verbatim EXCEPT JSON-RPC requests with +// method "tools/call" — those we pause, POST to the daemon's +// /v1/hooks/claude-desktop/pre-tool-use, and either: +// * allow → forward to child; on the child's response, fire-and- +// forget POST /post-tool-use for ledger completeness +// * deny → synthesize an MCP tool error reply ({isError:true, ...}) +// using the same JSON-RPC id, send it directly back to Claude, and +// never wake the child +// +// Daemon-down posture: fail-open (matches the Claude Code shim). A +// daemon outage MUST NOT brick a user's Desktop app — the dashboard's +// "daemon offline" banner is the user-visible signal, not a flood of +// blocked tool calls. Per-server fail-closed is a future feature. + +import { spawn } from "node:child_process"; + +interface JsonRpcMessage { + jsonrpc: "2.0"; + id?: number | string | null; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +interface DaemonHookResponse { + continue?: boolean; + stopReason?: string; + hookSpecificOutput?: { + permissionDecision?: "allow" | "deny" | "ask"; + permissionDecisionReason?: string; + }; +} + +interface ProxyArgs { + serverName: string; + childCmd: string; + childArgs: string[]; +} + +function defaultDaemonUrl(): string { + return ( + process.env.AGENTLOCK_DAEMON_URL ?? + process.env.AGENTLOCK_CONTROL_PLANE_URL ?? + "http://127.0.0.1:7878" + ); +} + +// sessionId is stable per-server-name so Claude Desktop's auto-launched +// proxies attach to a deterministic daemon-side session rather than +// minting a new one on every start. The daemon's auto-create path tags +// these signer="none" — they show up on the dashboard with the same +// red unattested banner Claude Code's auto-sessions get. +function sessionIdFor(serverName: string): string { + return `claude-desktop-${serverName}`.slice(0, 128); +} + +// parseProxyArgs accepts either: +// --name -- +// --name= -- +// Anything after the first standalone "--" is the child command. +export function parseProxyArgs(argv: string[]): ProxyArgs { + let serverName = ""; + let i = 0; + while (i < argv.length) { + const a = argv[i]!; + if (a === "--") { + i++; + break; + } + if (a === "--name") { + serverName = argv[i + 1] ?? ""; + i += 2; + continue; + } + if (a.startsWith("--name=")) { + serverName = a.slice("--name=".length); + i++; + continue; + } + throw new Error(`unrecognized arg: ${a}`); + } + const rest = argv.slice(i); + if (!serverName) throw new Error("missing --name "); + if (rest.length === 0) throw new Error("missing -- ..."); + return { serverName, childCmd: rest[0]!, childArgs: rest.slice(1) }; +} + +// mcpToolName converts an MCP tools/call into the Claude Code-style +// `mcp____` namespacing so policy rules written for one +// surface match the other. +function mcpToolName(serverName: string, toolName: string): string { + return `mcp__${serverName}__${toolName}`; +} + +// callPreToolUse asks the daemon whether a tools/call should run. +// Returns the decision; a transport / 5xx / parse error returns "allow" +// so a daemon outage fails-open (see file header). +async function callPreToolUse( + daemonUrl: string, + serverName: string, + sessionId: string, + msg: JsonRpcMessage, +): Promise<{ decision: "allow" | "deny" | "ask"; reason: string }> { + const params = (msg.params ?? {}) as { name?: string; arguments?: unknown }; + const toolName = mcpToolName(serverName, params.name ?? ""); + const body = { + session_id: sessionId, + hook_event_name: "PreToolUse", + tool_name: toolName, + tool_input: (params.arguments ?? {}) as Record, + tool_use_id: String(msg.id ?? "0"), + cwd: process.cwd(), + }; + try { + const res = await fetch( + daemonUrl.replace(/\/+$/, "") + "/v1/hooks/claude-desktop/pre-tool-use", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) return { decision: "allow", reason: "" }; + const parsed = (await res.json()) as DaemonHookResponse; + const decision = parsed.hookSpecificOutput?.permissionDecision ?? "allow"; + const reason = + parsed.hookSpecificOutput?.permissionDecisionReason ?? + parsed.stopReason ?? + ""; + return { decision, reason }; + } catch { + // Fail-open: a daemon outage must not brick the Desktop app. + return { decision: "allow", reason: "" }; + } +} + +// recordPostToolUse fires off completion telemetry. Best-effort: we +// don't await this on the hot path, and we never propagate errors back +// to the child or the host — the tool call already ran. +function recordPostToolUse( + daemonUrl: string, + serverName: string, + sessionId: string, + toolName: string, + toolUseId: string, + toolResponse: unknown, +): void { + const body = { + session_id: sessionId, + hook_event_name: "PostToolUse", + tool_name: mcpToolName(serverName, toolName), + tool_input: {}, + tool_use_id: toolUseId, + tool_response: toolResponse, + }; + fetch( + daemonUrl.replace(/\/+$/, "") + "/v1/hooks/claude-desktop/post-tool-use", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ).catch(() => { + // Silent: ledger gap is acceptable, blocking a tool response on it + // would defeat the fail-open posture above. + }); +} + +// denyResponseFor synthesizes an MCP tool-error reply matching the +// caller's id. Claude Desktop renders this as a tool failure with the +// supplied text — the model sees it as a normal "tool returned an +// error" outcome and can react in conversation. +function denyResponseFor(id: number | string | null, reason: string): JsonRpcMessage { + return { + jsonrpc: "2.0", + id, + result: { + isError: true, + content: [ + { + type: "text", + text: `blocked by OpenAgentLock policy: ${reason || "denied"}`, + }, + ], + }, + }; +} + +// LineBuffer accumulates byte chunks and emits complete newline- +// delimited lines. MCP stdio frames are guaranteed not to contain +// embedded newlines (per spec) so a simple split is safe. +class LineBuffer { + private buf = ""; + push(chunk: Buffer | string): string[] { + this.buf += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const out: string[] = []; + let nl: number; + while ((nl = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + if (line.length > 0) out.push(line); + } + return out; + } +} + +export async function runMcpProxy(argv: string[]): Promise { + let parsed: ProxyArgs; + try { + parsed = parseProxyArgs(argv); + } catch (e) { + process.stderr.write( + `agentlock mcp-proxy: ${(e as Error).message}\n` + + `usage: agentlock mcp-proxy --name -- [args...]\n`, + ); + process.exit(2); + } + + const daemonUrl = defaultDaemonUrl(); + const sessionId = sessionIdFor(parsed.serverName); + + const child = spawn(parsed.childCmd, parsed.childArgs, { + stdio: ["pipe", "pipe", "inherit"], + }); + + child.on("error", (err) => { + // The configured child command can't even be spawned (ENOENT, permission + // denied, etc.). Surface to stderr and exit so Claude Desktop logs the + // reason rather than seeing silent stdout closure. + process.stderr.write( + `agentlock mcp-proxy: failed to spawn ${parsed.childCmd}: ${err.message}\n`, + ); + process.exit(2); + }); + + child.on("exit", (code, signal) => { + // Mirror the child's termination to our parent so Claude Desktop's + // server-died detection works the same as it would without the proxy. + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 0); + }); + + // Track the in-flight tool/call ids so we can fire post-tool-use when + // the matching response comes back from the child. Map keyed by + // JSON-RPC id (stringified to handle mixed numeric/string ids). + const pendingToolCalls = new Map(); + + // Claude Desktop → us → child + const stdinBuf = new LineBuffer(); + process.stdin.on("data", async (chunk: Buffer) => { + for (const line of stdinBuf.push(chunk)) { + let msg: JsonRpcMessage; + try { + msg = JSON.parse(line) as JsonRpcMessage; + } catch { + // Pass malformed lines through — the child will reject them and + // we don't want to silently drop messages we don't understand. + child.stdin.write(line + "\n"); + continue; + } + if (msg.method === "tools/call" && msg.id !== undefined && msg.id !== null) { + const params = (msg.params ?? {}) as { + name?: string; + arguments?: unknown; + }; + const { decision, reason } = await callPreToolUse( + daemonUrl, + parsed.serverName, + sessionId, + msg, + ); + if (decision === "deny") { + // Short-circuit: respond directly to Claude, don't wake child. + process.stdout.write(JSON.stringify(denyResponseFor(msg.id, reason)) + "\n"); + continue; + } + // Allow / ask → forward and remember the id so the response + // path can fire post-tool-use. + pendingToolCalls.set(String(msg.id), { + name: params.name ?? "", + toolUseId: String(msg.id), + }); + child.stdin.write(line + "\n"); + continue; + } + child.stdin.write(line + "\n"); + } + }); + process.stdin.on("end", () => { + child.stdin.end(); + }); + + // Child → us → Claude Desktop + const childStdoutBuf = new LineBuffer(); + child.stdout.on("data", (chunk: Buffer) => { + for (const line of childStdoutBuf.push(chunk)) { + // Forward verbatim before doing any post-processing — Claude + // Desktop's UX is sensitive to latency and we don't want a + // ledger-write to add noticeable lag. + process.stdout.write(line + "\n"); + + let msg: JsonRpcMessage; + try { + msg = JSON.parse(line) as JsonRpcMessage; + } catch { + continue; + } + if (msg.id === undefined || msg.id === null) continue; + const pending = pendingToolCalls.get(String(msg.id)); + if (!pending) continue; + pendingToolCalls.delete(String(msg.id)); + // Fire-and-forget post-tool-use telemetry. result OR error is + // fine — summarizeToolResponse on the daemon side handles both. + recordPostToolUse( + daemonUrl, + parsed.serverName, + sessionId, + pending.name, + pending.toolUseId, + msg.result ?? msg.error, + ); + } + }); +} diff --git a/cli/src/commands/mcp-server.ts b/cli/src/commands/mcp-server.ts new file mode 100644 index 0000000..c4f1e94 --- /dev/null +++ b/cli/src/commands/mcp-server.ts @@ -0,0 +1,198 @@ +// `agentlock mcp-server` — minimal MCP stdio server for Claude Desktop. +// +// Claude Desktop has no PreToolUse / PostToolUse hook surface upstream +// (anthropics/claude-code#45514, closed without ship). The only way to +// register agentlock is as an MCP server entry in +// claude_desktop_config.json. That gives us no enforcement — Claude +// will not ask us before running its own tools — but it gives an +// observability surface: when Claude is steered into invoking one of +// our tools, we see it and can forward to the daemon. +// +// Scope is intentionally narrow. We expose two read-only tools backed +// by the daemon's existing /v1/health and /v1/ledger/tail endpoints. We +// do NOT expose anything that mutates state or claims to gate other +// tools — that would mislead users about what this surface can do. +// +// MCP transport: JSON-RPC 2.0 over stdin/stdout, newline-delimited. +// We implement only the methods Claude Desktop sends in practice: +// initialize, notifications/initialized, tools/list, tools/call, +// shutdown. Anything else returns -32601 Method not found. + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: number | string | null; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number | string | null; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +const PROTOCOL_VERSION = "2024-11-05"; +const SERVER_INFO = { name: "agentlock", version: "0.1.0" }; + +function defaultDaemonUrl(): string { + return ( + process.env.AGENTLOCK_DAEMON_URL ?? + process.env.AGENTLOCK_CONTROL_PLANE_URL ?? + "http://127.0.0.1:7878" + ); +} + +const TOOLS = [ + { + name: "agentlock_status", + description: + "Probe the OpenAgentLock daemon. Returns reachable=true/false plus the daemon's reported status. Read-only.", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "agentlock_recent_decisions", + description: + "Return the last N entries from the OpenAgentLock ledger (verdicts, signers, tool names). Read-only. Defaults to 20, max 100.", + inputSchema: { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }, + }, + required: [], + }, + }, +] as const; + +async function callStatus(): Promise<{ + content: Array<{ type: "text"; text: string }>; +}> { + const url = defaultDaemonUrl().replace(/\/+$/, "") + "/v1/health"; + try { + const res = await fetch(url, { method: "GET" }); + if (!res.ok) { + return mcpText(`daemon ${url} returned ${res.status}`); + } + const body = (await res.json()) as { status?: string }; + return mcpText( + `daemon reachable at ${defaultDaemonUrl()} (status=${body.status ?? "unknown"})`, + ); + } catch (e) { + return mcpText(`daemon unreachable at ${defaultDaemonUrl()}: ${(e as Error).message}`); + } +} + +async function callRecentDecisions( + args: Record | undefined, +): Promise<{ content: Array<{ type: "text"; text: string }> }> { + const rawLimit = args?.limit; + let limit = 20; + if (typeof rawLimit === "number" && Number.isFinite(rawLimit)) { + limit = Math.max(1, Math.min(100, Math.floor(rawLimit))); + } + const url = + defaultDaemonUrl().replace(/\/+$/, "") + `/v1/ledger/tail?limit=${limit}`; + try { + const res = await fetch(url, { method: "GET" }); + if (!res.ok) { + return mcpText(`daemon ${url} returned ${res.status}`); + } + const body = await res.text(); + return mcpText(body); + } catch (e) { + return mcpText(`daemon unreachable: ${(e as Error).message}`); + } +} + +function mcpText(text: string): { content: Array<{ type: "text"; text: string }> } { + return { content: [{ type: "text", text }] }; +} + +function send(res: JsonRpcResponse): void { + process.stdout.write(JSON.stringify(res) + "\n"); +} + +function reply(id: JsonRpcResponse["id"], result: unknown): void { + send({ jsonrpc: "2.0", id, result }); +} + +function fail(id: JsonRpcResponse["id"], code: number, message: string): void { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +async function dispatch(req: JsonRpcRequest): Promise { + const id = req.id ?? null; + switch (req.method) { + case "initialize": + reply(id, { + protocolVersion: PROTOCOL_VERSION, + // Tools only — we don't expose resources or prompts. Declaring + // the empty objects would advertise capabilities we don't serve. + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }); + return; + case "notifications/initialized": + case "initialized": + // Notifications carry no id and expect no reply. + return; + case "tools/list": + reply(id, { tools: TOOLS }); + return; + case "tools/call": { + const params = (req.params ?? {}) as { + name?: string; + arguments?: Record; + }; + if (params.name === "agentlock_status") { + reply(id, await callStatus()); + return; + } + if (params.name === "agentlock_recent_decisions") { + reply(id, await callRecentDecisions(params.arguments)); + return; + } + fail(id, -32602, `unknown tool: ${params.name ?? ""}`); + return; + } + case "shutdown": + reply(id, null); + return; + case "exit": + process.exit(0); + default: + // Notifications (no id) silently drop; requests get -32601. + if (req.id !== undefined && req.id !== null) { + fail(id, -32601, `method not found: ${req.method}`); + } + } +} + +export async function runMcpServer(): Promise { + // MCP stdio transport is newline-delimited JSON-RPC. Buffer until we + // see a newline so payloads spanning multiple chunks parse cleanly. + let buf = ""; + for await (const chunk of process.stdin) { + buf += (chunk as Buffer).toString("utf8"); + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let req: JsonRpcRequest; + try { + req = JSON.parse(line) as JsonRpcRequest; + } catch { + // Parse errors with no id can't be replied to. Drop and continue. + continue; + } + try { + await dispatch(req); + } catch (e) { + if (req.id !== undefined && req.id !== null) { + fail(req.id, -32603, (e as Error).message); + } + } + } + } +} diff --git a/cli/src/detect/agentlock-state.ts b/cli/src/detect/agentlock-state.ts index 279f443..4a98b41 100644 --- a/cli/src/detect/agentlock-state.ts +++ b/cli/src/detect/agentlock-state.ts @@ -73,6 +73,45 @@ export function claudeAgentlockState(settingsPath: string): AgentlockState { return NOT_INSTALLED; } +// claudeDesktopAgentlockState reads a Claude Desktop config file +// (claude_desktop_config.json) and reports whether agentlock is wired in +// as an MCP server. Claude Desktop has no PreToolUse hook surface; the +// only install we can do is registering an MCP server entry under +// mcpServers. We mark our entry with `_agentlock: true` (an unknown key +// MCP ignores) so uninstall can find it without name-collision risk. +export function claudeDesktopAgentlockState(configPath: string): AgentlockState { + if (!existsSync(configPath)) return NOT_INSTALLED; + let raw: string; + try { + raw = readFileSync(configPath, "utf8"); + } catch { + return NOT_INSTALLED; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return NOT_INSTALLED; + } + const servers = (parsed as { mcpServers?: Record }).mcpServers; + if (!servers || typeof servers !== "object") return NOT_INSTALLED; + + for (const name of Object.keys(servers)) { + const entry = servers[name]; + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + if (e._agentlock !== true) continue; + // Daemon URL lives in `env.AGENTLOCK_DAEMON_URL` — same convention as + // the Claude Code shim. No nested HTTP hook on this surface. + const env = (e.env as Record | undefined) ?? undefined; + const url = env && typeof env.AGENTLOCK_DAEMON_URL === "string" + ? env.AGENTLOCK_DAEMON_URL + : undefined; + return { installed: true, daemonURL: url ? originOf(url) : undefined }; + } + return NOT_INSTALLED; +} + // devStubAgentlockState reads `.agentlock-dev.json` from a non-claude // harness's dev sandbox dir. The daemon's apply pipeline writes that // marker for harnesses without a real installer yet. Presence + the diff --git a/cli/src/detect/claude-desktop.ts b/cli/src/detect/claude-desktop.ts new file mode 100644 index 0000000..1097146 --- /dev/null +++ b/cli/src/detect/claude-desktop.ts @@ -0,0 +1,71 @@ +// Detector for Anthropic's standalone Claude Desktop app — distinct +// from Claude Code (the CLI/IDE harness). Different config dir, no +// PreToolUse / PostToolUse hook surface upstream. +// +// As of 2026-05, Claude Desktop's only documented extensibility surface +// is `mcpServers` entries in claude_desktop_config.json. Anthropic +// closed the hook-parity feature request (#45514) without shipping. We +// therefore install agentlock as an MCP server entry — that's the only +// honest write we can do here. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { appSupport } from "../util/paths.ts"; +import { claudeDesktopAgentlockState } from "./agentlock-state.ts"; +import type { Detector, Detection, DetectedScope } from "./types.ts"; + +// claudeDesktopConfigPath returns the platform-correct config path. +// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json +// Windows: %APPDATA%\Claude\claude_desktop_config.json +// Linux: not officially supported — fall through to xdgConfigHome via +// appSupport(), which gives a reasonable detection no-op (file won't +// exist on a Linux box) without crashing the registry. +export function claudeDesktopConfigPath(): string { + return join(appSupport(), "Claude", "claude_desktop_config.json"); +} + +export const claudeDesktop: Detector = { + id: "claude-desktop", + displayName: "Claude Desktop", + + async detect(): Promise { + const configPath = claudeDesktopConfigPath(); + const dir = join(configPath, ".."); + + const evidence: string[] = []; + const dirExists = existsSync(dir); + const configExists = existsSync(configPath); + if (dirExists) evidence.push(`found ${dir}`); + if (configExists) evidence.push(`found ${configPath}`); + + const scopes: DetectedScope[] = [ + { kind: "global", path: configPath, exists: configExists }, + ]; + + const al = claudeDesktopAgentlockState(configPath); + + return { + id: this.id, + displayName: this.displayName, + installed: dirExists, + evidence, + scopes, + // mcp-stdio only: Claude Desktop has no PreToolUse / PostToolUse + // surface (anthropics/claude-code#45514, closed-as-duplicate, not + // shipped). We cannot wire the same lifecycle-hooks path as Claude + // Code; declaring it here would mislead the install picker. + surfaces: ["mcp-stdio"], + notes: dirExists + ? [ + "Install wraps every MCP server entry with `agentlock mcp-proxy` so each tools/call is gated by daemon policy. Originals preserved under _agentlock_original for clean uninstall.", + "Coverage is the MCP slice only: not gated are Computer Use, integrated terminal, native connectors (Slack/GCal), Cowork's non-MCP paths, and Anthropic cloud features. For full local enforcement, use Claude Code.", + ] + : [ + "Claude Desktop not detected. Selecting it will create the config dir on install.", + "When Claude Desktop is in use, install wraps each MCP server — coverage is MCP-slice only (not Computer Use, terminal, connectors, or cloud features).", + ], + agentlockInstalled: al.installed, + agentlockDaemonURL: al.daemonURL, + }; + }, +}; diff --git a/cli/src/detect/index.ts b/cli/src/detect/index.ts index 185527f..4d64a8c 100644 --- a/cli/src/detect/index.ts +++ b/cli/src/detect/index.ts @@ -1,6 +1,7 @@ // Detector registry. Add new harnesses here. import { claudeCode } from "./claude-code.ts"; +import { claudeDesktop } from "./claude-desktop.ts"; import { cline } from "./cline.ts"; import { codex } from "./codex.ts"; import { continueDev } from "./continue-dev.ts"; @@ -15,6 +16,7 @@ import type { Detection, Detector } from "./types.ts"; // path is left out — we don't ship dead picker rows. export const ALL_DETECTORS: Detector[] = [ claudeCode, + claudeDesktop, codex, opencode, cursor, diff --git a/cli/src/detect/types.ts b/cli/src/detect/types.ts index c0a3762..c2806b1 100644 --- a/cli/src/detect/types.ts +++ b/cli/src/detect/types.ts @@ -3,6 +3,7 @@ export type HarnessId = | "claude-code" + | "claude-desktop" | "codex" | "opencode" | "cursor" diff --git a/cli/src/index.ts b/cli/src/index.ts index d475345..ed39822 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -25,6 +25,8 @@ import { runHookCodex } from "./commands/hook-codex.ts"; import { runHookCursor } from "./commands/hook-cursor.ts"; import { runHookGemini } from "./commands/hook-gemini.ts"; import { runLedgerRoot, runLedgerVerify } from "./commands/ledger.ts"; +import { runMcpProxy } from "./commands/mcp-proxy.ts"; +import { runMcpServer } from "./commands/mcp-server.ts"; import { runSignerEnroll } from "./commands/signer-enroll.ts"; import { runRulesAdd, @@ -549,4 +551,41 @@ const hookClaudeCode = hook }); void hookClaudeCode; +// `agentlock mcp-server` — MCP stdio server spawned by Claude Desktop. +// Claude Desktop has no PreToolUse/PostToolUse surface, so this is the +// only honest install: register agentlock as an MCP server, expose +// read-only observability tools (status, recent ledger entries) backed +// by the daemon. No enforcement on Desktop's built-in tools. +program + .command("mcp-server") + .description( + "Run the OpenAgentLock MCP stdio server. Spawned by Claude Desktop after `agentlock install` registers it under mcpServers. Exposes read-only status + ledger query tools.", + ) + .action(async () => { + await runMcpServer(); + }); + +// `agentlock mcp-proxy --name -- [args...]` +// +// Stdio bridge spawned by Claude Desktop in place of each user-installed +// MCP server (`agentlock install` rewrites their claude_desktop_config.json +// to point here, preserving the original command under _agentlock_original). +// Pumps bytes both directions verbatim except for tools/call requests, +// which it forwards to /v1/hooks/claude-desktop/pre-tool-use for policy +// evaluation. allowUnknownOption + helpOption(false) keeps commander from +// gobbling the child's flags before we see the `--` separator. +program + .command("mcp-proxy") + .description( + "Stdio proxy for Claude Desktop's MCP servers. Intercepts tools/call, applies policy via the daemon, forwards or denies. Spawned by Claude Desktop after `agentlock install` wraps each user MCP server.", + ) + .allowUnknownOption() + .helpOption(false) + .action(async () => { + // Slice off "node|bun, scriptPath, mcp-proxy" — the rest are our args. + const ix = process.argv.indexOf("mcp-proxy"); + const rest = ix >= 0 ? process.argv.slice(ix + 1) : []; + await runMcpProxy(rest); + }); + await program.parseAsync(process.argv); diff --git a/cli/src/util/install-fs.ts b/cli/src/util/install-fs.ts index 5c33b83..8556150 100644 --- a/cli/src/util/install-fs.ts +++ b/cli/src/util/install-fs.ts @@ -6,7 +6,7 @@ import { promises as fs } from "node:fs"; import { dirname, resolve, sep } from "node:path"; -import { homedir } from "node:os"; +import { homedir, platform } from "node:os"; import type { InstallFileOp, InstallUninstallOp } from "./api.ts"; @@ -30,12 +30,28 @@ export function checkSafeTarget( const allowed = [".claude", ".codex", ".cursor", ".gemini"].map((d) => resolve(home, d), ); + // Claude Desktop's config lives outside the dotfile convention. Add + // its real path so prod installs that target it pass the safety check + // without forcing --config-dir bypass on every run. + if (platform() === "darwin") { + allowed.push(resolve(home, "Library", "Application Support", "Claude")); + } else if (platform() === "win32") { + const appdata = process.env.APPDATA; + if (appdata) allowed.push(resolve(appdata, "Claude")); + allowed.push(resolve(home, "AppData", "Roaming", "Claude")); + } else { + // Linux: no official Claude Desktop release. We still allow the + // XDG conventional path so users running a community port don't + // hit this gate; the daemon won't actually plan a write there + // unless the detector found the dir. + allowed.push(resolve(home, ".config", "Claude")); + } const target = resolve(absPath); for (const root of allowed) { if (target === root || target.startsWith(root + sep)) return; } throw new Error( - `unsafe target: ${absPath} does not resolve under ~/.claude, ~/.codex, ~/.cursor, or ~/.gemini`, + `unsafe target: ${absPath} does not resolve under ~/.claude, ~/.codex, ~/.cursor, ~/.gemini, or the Claude Desktop config dir`, ); } diff --git a/cli/tests/detect.test.ts b/cli/tests/detect.test.ts index 4ffdcab..9c6cbd7 100644 --- a/cli/tests/detect.test.ts +++ b/cli/tests/detect.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { claudeCode } from "../src/detect/claude-code.ts"; +import { claudeDesktop, claudeDesktopConfigPath } from "../src/detect/claude-desktop.ts"; import { codex } from "../src/detect/codex.ts"; import { continueDev } from "../src/detect/continue-dev.ts"; import { cursor } from "../src/detect/cursor.ts"; @@ -47,6 +48,7 @@ function touch(p: string, body = ""): void { describe("contract — every detector returns a well-formed Detection", () => { const detectors = [ claudeCode, + claudeDesktop, codex, opencode, cursor, @@ -81,6 +83,11 @@ describe("absent — installed=false on a clean home", () => { expect(r.evidence).toEqual([]); }); + test("claude-desktop", async () => { + const r = await claudeDesktop.detect(); + expect(r.installed).toBe(false); + }); + test("codex", async () => { const r = await codex.detect(); expect(r.installed).toBe(false); @@ -164,6 +171,53 @@ describe("contract details", () => { expect(r.surfaces).toContain("mcp-stdio"); expect(r.surfaces).toContain("extension-only"); }); + + // Claude Desktop's enforcement covers MCP tool calls (via the proxy), + // not Anthropic's server-side cloud features. The detector must not + // advertise lifecycle-hooks (we don't get a native PreToolUse) and + // must surface the cloud-features-out-of-scope caveat in notes so + // picker rows don't oversell coverage. + test("claude-desktop surfaces MCP enforcement and out-of-scope caveat", async () => { + const r = await claudeDesktop.detect(); + expect(r.surfaces).toContain("mcp-stdio"); + expect(r.surfaces).not.toContain("lifecycle-hooks"); + expect( + r.notes.some( + (n) => + n.includes("mcp-proxy") || + n.includes("out of scope") || + n.includes("cloud"), + ), + ).toBe(true); + }); + + test("claude-desktop detects when config dir exists", async () => { + const dir = join(claudeDesktopConfigPath(), ".."); + mkdirSync(dir, { recursive: true }); + const r = await claudeDesktop.detect(); + expect(r.installed).toBe(true); + }); + + test("claude-desktop reports agentlockInstalled when our MCP entry is present", async () => { + const cfg = claudeDesktopConfigPath(); + mkdirSync(join(cfg, ".."), { recursive: true }); + writeFileSync( + cfg, + JSON.stringify({ + mcpServers: { + agentlock: { + _agentlock: true, + command: "agentlock", + args: ["mcp-server"], + env: { AGENTLOCK_DAEMON_URL: "http://127.0.0.1:7878" }, + }, + }, + }), + ); + const r = await claudeDesktop.detect(); + expect(r.agentlockInstalled).toBe(true); + expect(r.agentlockDaemonURL).toBe("http://127.0.0.1:7878"); + }); }); describe("registry", () => { diff --git a/cli/tests/mcp-proxy.test.ts b/cli/tests/mcp-proxy.test.ts new file mode 100644 index 0000000..bdd7ed1 --- /dev/null +++ b/cli/tests/mcp-proxy.test.ts @@ -0,0 +1,249 @@ +// `agentlock mcp-proxy` end-to-end contract. +// +// Spawn the CLI with a tiny fake MCP child + a mock daemon, feed JSON- +// RPC frames, assert the proxy honors policy and passes through. +// +// Run: cd cli && bun test tests/mcp-proxy.test.ts + +import { afterEach, describe, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { join } from "node:path"; + +import { parseProxyArgs } from "../src/commands/mcp-proxy.ts"; + +const CLI_ENTRY = join(import.meta.dir, "..", "src", "index.ts"); + +// Minimal fake MCP server: reads JSON-RPC requests on stdin, echoes +// each one back as a canned response. Used as the proxy's child. +const FAKE_CHILD = ` +let buf = ''; +process.stdin.on('data', (c) => { + buf += c.toString('utf8'); + let nl; + while ((nl = buf.indexOf('\\n')) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,result:{protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fake',version:'0.0.1'}}}) + '\\n'); + } else if (msg.method === 'tools/list') { + process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,result:{tools:[{name:'echo',description:'',inputSchema:{type:'object'}}]}}) + '\\n'); + } else if (msg.method === 'tools/call') { + process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:msg.id,result:{content:[{type:'text',text:'CHILD_RAN:'+JSON.stringify(msg.params)}]}}) + '\\n'); + } + } +}); +`; + +interface ProxyHandle { + child: ChildProcess; + stdoutLines: string[]; + daemonHits: Array<{ path: string; body: any }>; + daemonStop: () => void; +} + +function startProxy(opts: { + serverName: string; + daemonBehavior: "allow" | "deny" | "down"; + denyReason?: string; +}): ProxyHandle { + const daemonHits: Array<{ path: string; body: any }> = []; + let daemonUrl = "http://127.0.0.1:1"; // unreachable port for "down" + let stop: () => void = () => {}; + if (opts.daemonBehavior !== "down") { + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const body = await req.json().catch(() => ({})); + daemonHits.push({ path: url.pathname, body }); + if (url.pathname === "/v1/hooks/claude-desktop/pre-tool-use") { + if (opts.daemonBehavior === "deny") { + return Response.json({ + continue: false, + stopReason: opts.denyReason ?? "denied by test policy", + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: opts.denyReason ?? "denied by test policy", + }, + }); + } + return Response.json({ + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }); + } + if (url.pathname === "/v1/hooks/claude-desktop/post-tool-use") { + return Response.json({ continue: true }); + } + return new Response("not found", { status: 404 }); + }, + }); + daemonUrl = `http://127.0.0.1:${server.port}`; + stop = () => server.stop(); + } + + const env = { ...(process.env as Record), AGENTLOCK_DAEMON_URL: daemonUrl }; + const child = spawn( + "bun", + [ + CLI_ENTRY, + "mcp-proxy", + "--name", + opts.serverName, + "--", + "bun", + "-e", + FAKE_CHILD, + ], + { stdio: ["pipe", "pipe", "pipe"], env }, + ); + + const stdoutLines: string[] = []; + let stdoutBuf = ""; + child.stdout!.on("data", (c: Buffer) => { + stdoutBuf += c.toString("utf8"); + let nl: number; + while ((nl = stdoutBuf.indexOf("\n")) >= 0) { + const line = stdoutBuf.slice(0, nl); + stdoutBuf = stdoutBuf.slice(nl + 1); + if (line.length > 0) stdoutLines.push(line); + } + }); + child.stderr!.on("data", () => { + // Suppress; the child or proxy may log. + }); + + return { child, stdoutLines, daemonHits, daemonStop: stop }; +} + +async function send(child: ChildProcess, msg: object): Promise { + child.stdin!.write(JSON.stringify(msg) + "\n"); +} + +async function waitForLines(handle: ProxyHandle, count: number, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (handle.stdoutLines.length < count && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 20)); + } +} + +let active: ProxyHandle | undefined; +afterEach(() => { + if (active) { + active.child.stdin!.end(); + active.child.kill(); + active.daemonStop(); + active = undefined; + } +}); + +describe("mcp-proxy argument parsing", () => { + test("requires --name and -- separator", () => { + expect(() => parseProxyArgs([])).toThrow(); + expect(() => parseProxyArgs(["--name", "foo"])).toThrow(); // no `--` + expect(() => parseProxyArgs(["--name", "foo", "--"])).toThrow(); // empty child + }); + + test("captures everything after -- as the child command", () => { + const got = parseProxyArgs([ + "--name", + "filesystem", + "--", + "npx", + "-y", + "@mcp/server", + "/tmp", + ]); + expect(got.serverName).toBe("filesystem"); + expect(got.childCmd).toBe("npx"); + expect(got.childArgs).toEqual(["-y", "@mcp/server", "/tmp"]); + }); + + test("--name=foo equivalent form", () => { + const got = parseProxyArgs(["--name=fs", "--", "echo", "hi"]); + expect(got.serverName).toBe("fs"); + expect(got.childCmd).toBe("echo"); + }); +}); + +describe("mcp-proxy passthrough", () => { + test("initialize and tools/list pass through verbatim from child", async () => { + active = startProxy({ serverName: "fs", daemonBehavior: "allow" }); + await send(active.child, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + await send(active.child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + await waitForLines(active, 2); + const init = JSON.parse(active.stdoutLines[0]!); + const list = JSON.parse(active.stdoutLines[1]!); + expect(init.result.serverInfo.name).toBe("fake"); + expect(list.result.tools[0].name).toBe("echo"); + // Daemon must not be hit for non-tools/call methods. + expect(active.daemonHits.find((h) => h.path.includes("pre-tool-use"))).toBeUndefined(); + }); +}); + +describe("mcp-proxy interception", () => { + test("tools/call: daemon allow → child runs, response forwarded", async () => { + active = startProxy({ serverName: "fs", daemonBehavior: "allow" }); + await send(active.child, { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "echo", arguments: { msg: "hi" } }, + }); + await waitForLines(active, 1); + const reply = JSON.parse(active.stdoutLines[0]!); + expect(reply.id).toBe(5); + // The fake child stamps its result with CHILD_RAN: — proves we + // really forwarded to the child rather than synthesizing a response. + expect(reply.result.content[0].text).toContain("CHILD_RAN:"); + + const preHit = active.daemonHits.find((h) => h.path.includes("pre-tool-use")); + expect(preHit).toBeDefined(); + expect(preHit!.body.tool_name).toBe("mcp__fs__echo"); + }); + + test("tools/call: daemon deny → synthesized error, child never runs", async () => { + active = startProxy({ + serverName: "fs", + daemonBehavior: "deny", + denyReason: "policy: read of /etc forbidden", + }); + await send(active.child, { + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { name: "read_file", arguments: { path: "/etc/passwd" } }, + }); + await waitForLines(active, 1); + const reply = JSON.parse(active.stdoutLines[0]!); + expect(reply.id).toBe(7); + expect(reply.result.isError).toBe(true); + expect(reply.result.content[0].text).toContain("blocked by OpenAgentLock"); + expect(reply.result.content[0].text).toContain("read of /etc forbidden"); + // The fake child's CHILD_RAN: marker must NOT appear — proving the + // child was bypassed entirely on deny. + expect(reply.result.content[0].text).not.toContain("CHILD_RAN:"); + }); + + test("tools/call: daemon down → fail-open, child runs", async () => { + active = startProxy({ serverName: "fs", daemonBehavior: "down" }); + await send(active.child, { + jsonrpc: "2.0", + id: 9, + method: "tools/call", + params: { name: "echo", arguments: {} }, + }); + await waitForLines(active, 1); + const reply = JSON.parse(active.stdoutLines[0]!); + expect(reply.id).toBe(9); + // Fail-open: child ran, response forwarded. + expect(reply.result.content[0].text).toContain("CHILD_RAN:"); + }); +}); diff --git a/cli/tests/mcp-server.test.ts b/cli/tests/mcp-server.test.ts new file mode 100644 index 0000000..3603cfa --- /dev/null +++ b/cli/tests/mcp-server.test.ts @@ -0,0 +1,174 @@ +// `agentlock mcp-server` JSON-RPC contract tests. +// +// Spawn the CLI as a subprocess (matching how Claude Desktop launches +// it), feed newline-delimited JSON-RPC, and check the responses. +// +// Run: cd cli && bun test tests/mcp-server.test.ts + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +const CLI_ENTRY = join(import.meta.dir, "..", "src", "index.ts"); + +interface Reply { + jsonrpc: "2.0"; + id: number | string | null; + result?: any; + error?: { code: number; message: string }; +} + +// runMcp spawns `bun src/index.ts mcp-server`, sends each request as a +// newline-delimited JSON line, and returns the parsed replies in order. +// Closes stdin after the last request so the process exits. +async function runMcp( + requests: Array>, + daemonUrl?: string, +): Promise { + const env: Record = { ...(process.env as Record) }; + if (daemonUrl) env.AGENTLOCK_DAEMON_URL = daemonUrl; + + const child = spawn("bun", [CLI_ENTRY, "mcp-server"], { + stdio: ["pipe", "pipe", "pipe"], + env, + }); + + const stdoutChunks: Buffer[] = []; + child.stdout.on("data", (c: Buffer) => stdoutChunks.push(c)); + + for (const req of requests) { + child.stdin.write(JSON.stringify(req) + "\n"); + } + child.stdin.end(); + + await new Promise((resolve) => child.once("close", () => resolve())); + + const out = Buffer.concat(stdoutChunks).toString("utf8"); + const replies: Reply[] = []; + for (const line of out.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + replies.push(JSON.parse(trimmed)); + } + return replies; +} + +let daemonServer: ReturnType | undefined; +let daemonUrl: string; + +beforeEach(() => { + // Mock daemon for tools/call to hit. Each test starts a fresh server + // so route handlers don't leak between cases. + daemonServer = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/v1/health") { + return Response.json({ status: "ok" }); + } + if (url.pathname === "/v1/ledger/tail") { + return Response.json({ + entries: [ + { seq: 1, verdict: "allow", tool_name: "Bash" }, + { seq: 2, verdict: "deny", tool_name: "Write" }, + ], + }); + } + return new Response("not found", { status: 404 }); + }, + }); + daemonUrl = `http://127.0.0.1:${daemonServer.port}`; +}); + +afterEach(() => { + daemonServer?.stop(); +}); + +describe("mcp-server", () => { + test("initialize returns protocolVersion + tool capability", async () => { + const [reply] = await runMcp([ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + ]); + expect(reply).toBeDefined(); + expect(reply!.id).toBe(1); + expect(reply!.result?.serverInfo?.name).toBe("agentlock"); + expect(reply!.result?.capabilities?.tools).toBeDefined(); + }); + + test("tools/list returns the read-only observability tools", async () => { + const replies = await runMcp([ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + ]); + const list = replies.find((r) => r.id === 2); + expect(list).toBeDefined(); + const names = (list!.result.tools as Array<{ name: string }>).map((t) => t.name); + expect(names).toContain("agentlock_status"); + expect(names).toContain("agentlock_recent_decisions"); + }); + + test("tools/call agentlock_status hits the daemon /v1/health", async () => { + const replies = await runMcp( + [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "agentlock_status", arguments: {} }, + }, + ], + daemonUrl, + ); + const call = replies.find((r) => r.id === 2); + expect(call).toBeDefined(); + const text = (call!.result.content as Array<{ text: string }>)[0]!.text; + expect(text).toContain("reachable"); + expect(text).toContain("status=ok"); + }); + + test("tools/call agentlock_status fails-soft when daemon is down", async () => { + // Point at a port nothing is listening on. + const replies = await runMcp( + [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "agentlock_status", arguments: {} }, + }, + ], + "http://127.0.0.1:1", // port 1 is reserved; refused + ); + const call = replies.find((r) => r.id === 2); + expect(call).toBeDefined(); + // Must not return a JSON-RPC error — Claude reads tool errors as + // model-visible failures. Surface the daemon-down state as text. + expect(call!.error).toBeUndefined(); + const text = (call!.result.content as Array<{ text: string }>)[0]!.text; + expect(text).toContain("daemon unreachable"); + }); + + test("unknown tool returns -32602 invalid params", async () => { + const replies = await runMcp([ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "agentlock_unknown" }, + }, + ]); + const call = replies.find((r) => r.id === 2); + expect(call?.error?.code).toBe(-32602); + }); + + test("unknown method returns -32601 method not found", async () => { + const replies = await runMcp([ + { jsonrpc: "2.0", id: 1, method: "completely/made/up", params: {} }, + ]); + const reply = replies[0]; + expect(reply?.error?.code).toBe(-32601); + }); +}); diff --git a/control-plane/internal/api/hooks_claude_desktop.go b/control-plane/internal/api/hooks_claude_desktop.go new file mode 100644 index 0000000..71a0b44 --- /dev/null +++ b/control-plane/internal/api/hooks_claude_desktop.go @@ -0,0 +1,245 @@ +// Claude Desktop hook endpoints. Claude Desktop has no PreToolUse +// callback upstream (see anthropics/claude-code#45514), so these +// endpoints are NOT called by Claude Desktop directly. They're called +// by `agentlock mcp-proxy`, which sits between Claude Desktop and each +// user-installed MCP server, intercepts every JSON-RPC tools/call, and +// turns it into one of these requests. +// +// Wire shape mirrors hooks_claude.go on purpose — same input contract, +// same claudeHookOutput response, same ledger discipline — so existing +// policy gates and dashboards work uniformly across Claude Code and +// Claude Desktop. The only thing that changes is the `source` tag in +// the ledger ("claude-desktop") and the toolUseID prefix. + +package api + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "log" + "net/http" + "time" + + "github.com/openagentlock/openagentlock/control-plane/internal/storage" +) + +// ensureClaudeDesktopSession is the desktop-tagged variant of +// ensureClaudeSession. Auto-creates an unattested session on first hit, +// stamping Harness="claude-desktop" so dashboards distinguish desktop +// proxy traffic from CLI traffic. Otherwise identical behavior. +func ensureClaudeDesktopSession(r *http.Request, d Deps, id string) (storage.Session, error) { + sess, err := d.Store.GetSession(r.Context(), id) + if err == nil { + return refreshUnattestedPolicyHash(d, sess), nil + } + if !errors.Is(err, storage.ErrSessionNotFound) { + return storage.Session{}, err + } + now := time.Now().UTC() + live := livePolicyFor(d) + policyHash := "" + if live != nil { + policyHash = live.Hash + } + newSess := storage.Session{ + ID: id, + StartedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + PolicyHash: policyHash, + SessionPubKey: "none", + Signer: "none", + SignerPubKey: "none", + Harness: "claude-desktop", + } + if err := d.Store.CreateSession(r.Context(), newSess); err != nil { + if errors.Is(err, storage.ErrSessionExists) { + existing, getErr := d.Store.GetSession(r.Context(), id) + if getErr != nil { + return storage.Session{}, getErr + } + return refreshUnattestedPolicyHash(d, existing), nil + } + return storage.Session{}, err + } + return newSess, nil +} + +// refreshUnattestedPolicyHash re-pins an unattested session's policy +// hash to whatever the live policy is right now. Without this, a +// long-lived auto-created session (the proxy's stable +// "claude-desktop-" id, the CLI's installed Claude Code +// session) keeps evaluating against whatever policy snapshot was +// pinned at first-hit time — adding a deny gate later would not fire +// for that session until daemon restart. Mutation is in-memory only; +// the stored row keeps its original hash for audit. Attested sessions +// (signer != "none") are NOT touched — their signature committed to +// a specific policy hash and that pin is the entire point. +func refreshUnattestedPolicyHash(d Deps, sess storage.Session) storage.Session { + if sess.Signer != "none" { + return sess + } + live := livePolicyFor(d) + if live == nil || live.Hash == sess.PolicyHash { + return sess + } + sess.PolicyHash = live.Hash + return sess +} + +// claudeDesktopPreToolUseHandler runs the same gate-check + ledger flow +// as claudePreToolUseHandler, with source="claude-desktop". The proxy +// reads the response and either forwards the JSON-RPC tools/call to the +// real MCP server (allow) or synthesizes an MCP error reply (deny). +func claudeDesktopPreToolUseHandler(d Deps) http.HandlerFunc { + if d.Store == nil { + return todo("hooks.claude-desktop.pre-tool-use") + } + return func(w http.ResponseWriter, r *http.Request) { + var in claudePreToolInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + if in.SessionID == "" || in.ToolName == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "session_id, tool_name required") + return + } + in.ToolInput = normalizeMCPHTTPURLInput(in.ToolName, in.ToolInput) + + sess, err := ensureClaudeDesktopSession(r, d, in.SessionID) + if err != nil { + writeError(w, http.StatusInternalServerError, "session_error", err.Error()) + return + } + + evalPolicy, result := evaluatePolicyForSession(d, sess, in.Cwd, in.ToolName, in.ToolInput) + if evalPolicy == nil { + writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded") + return + } + + var origVerdict, mode string + result, mode, origVerdict = applyDaemonModeOverride(result) + monitorMatch := result.MonitorMatch + + toolUseID := in.ToolUseID + if toolUseID == "" { + toolUseID = "claude-desktop.pre-tool-use" + } + + payloadBytes, err := json.Marshal(map[string]any{ + "session_id": in.SessionID, + "source": "claude-desktop", + "tool": in.ToolName, + "input": in.ToolInput, + "verdict": origVerdict, + "rule_id": result.RuleID, + "daemon_mode": mode, + "monitor_match": monitorMatch, + }) + if err != nil { + log.Printf("claude-desktop pre-tool-use: marshal: %v", err) + writeError(w, http.StatusInternalServerError, "marshal_error", err.Error()) + return + } + payloadHash := sha256.Sum256(payloadBytes) + if _, err := d.Store.AppendLedger(r.Context(), storage.AppendInput{ + TS: time.Now().UTC(), + Source: "claude-desktop", + Tool: in.ToolName, + ToolUseID: toolUseID, + Signer: sess.Signer, + RuleID: result.RuleID, + Verdict: origVerdict, + MonitorMatch: monitorMatch, + MatcherInput: ledgerMatcherInput(in.ToolInput), + PolicyTrace: storagePolicyTrace(result.Trace), + PayloadHash: payloadHash[:], + }); err != nil { + writeError(w, http.StatusInternalServerError, "ledger_error", err.Error()) + return + } + + reason := denyReasonWithNudge(result) + out := claudeHookOutput{ + Continue: result.Verdict == "allow", + HookSpecificOutput: claudeHookSpecifics{ + HookEventName: "PreToolUse", + PermissionDecision: result.Verdict, + PermissionDecisionReason: reason, + }, + } + if result.Verdict == "deny" { + out.StopReason = reason + } + writeJSON(w, http.StatusOK, out) + } +} + +// claudeDesktopPostToolUseHandler records completion outcomes from the +// proxy. Mirrors claudePostToolUseHandler with source="claude-desktop". +func claudeDesktopPostToolUseHandler(d Deps) http.HandlerFunc { + if d.Store == nil { + return todo("hooks.claude-desktop.post-tool-use") + } + return func(w http.ResponseWriter, r *http.Request) { + var in claudePostToolInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + if in.SessionID == "" || in.ToolName == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "session_id, tool_name required") + return + } + + sess, err := ensureClaudeDesktopSession(r, d, in.SessionID) + if err != nil { + log.Printf("claude-desktop post-tool-use: session: %v", err) + writeError(w, http.StatusInternalServerError, "session_error", "session create failed") + return + } + + respSize, success := summarizeToolResponse(in.ToolResponse) + + toolUseID := in.ToolUseID + if toolUseID == "" { + toolUseID = "claude-desktop.post-tool-use" + } + verdict := "complete" + if !success { + verdict = "failure" + } + + payloadBytes, err := json.Marshal(map[string]any{ + "session_id": in.SessionID, + "source": "claude-desktop", + "tool": in.ToolName, + "tool_use_id": toolUseID, + "response_size": respSize, + "success": success, + }) + if err != nil { + log.Printf("claude-desktop post-tool-use: marshal: %v", err) + writeError(w, http.StatusInternalServerError, "marshal_error", "payload hash failed") + return + } + payloadHash := sha256.Sum256(payloadBytes) + if _, err := d.Store.AppendLedger(r.Context(), storage.AppendInput{ + TS: time.Now().UTC(), + Source: "claude-desktop", + ToolUseID: toolUseID, + Tool: in.ToolName, + Signer: sess.Signer, + Verdict: verdict, + PayloadHash: payloadHash[:], + }); err != nil { + log.Printf("claude-desktop post-tool-use: ledger: %v", err) + writeError(w, http.StatusInternalServerError, "ledger_error", "ledger append failed") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"continue": true}) + } +} diff --git a/control-plane/internal/api/hooks_claude_desktop_test.go b/control-plane/internal/api/hooks_claude_desktop_test.go new file mode 100644 index 0000000..ead8313 --- /dev/null +++ b/control-plane/internal/api/hooks_claude_desktop_test.go @@ -0,0 +1,253 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/openagentlock/openagentlock/control-plane/internal/storage" +) + +func postClaudeDesktopPre(t *testing.T, fx gateFixture, body string) (*http.Response, map[string]any) { + t.Helper() + res, err := http.Post(fx.srv.URL+"/v1/hooks/claude-desktop/pre-tool-use", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST: %v", err) + } + var out map[string]any + if res.Header.Get("Content-Type") == "application/json" { + _ = json.NewDecoder(res.Body).Decode(&out) + } + _ = res.Body.Close() + return res, out +} + +// The proxy sends MCP-style tool names as `mcp____`. Most +// policy gates won't match them; the daemon responds with "allow" by +// default. This test verifies the wire shape only — actual policy +// matching is exercised by the existing claude-code tests. +func TestClaudeDesktopPreToolUse_AllowsByDefault(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + body := `{ + "session_id": "claude-desktop-filesystem", + "hook_event_name": "PreToolUse", + "tool_name": "mcp__filesystem__read_file", + "tool_use_id": "5", + "tool_input": {"path": "/tmp/safe"} + }` + res, out := postClaudeDesktopPre(t, fx, body) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%v", res.StatusCode, out) + } + if out["continue"] != true { + t.Fatalf("continue = %v", out["continue"]) + } + spec, _ := out["hookSpecificOutput"].(map[string]any) + if spec["permissionDecision"] != "allow" { + t.Fatalf("decision = %v", spec) + } +} + +// Bash-tool destructive policy applies to ANY tool input matching its +// matcher — so a tools/call carrying a destructive bash command (even +// under an MCP tool name) gets denied. This validates the policy +// pathway shares the same gate-check as Claude Code's hooks. +func TestClaudeDesktopPreToolUse_DeniesDestructiveBashViaProxy(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + body := `{ + "session_id": "claude-desktop-shell", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_use_id": "7", + "tool_input": {"command": "rm -rf /tmp/x"} + }` + res, out := postClaudeDesktopPre(t, fx, body) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + if out["continue"] != false { + t.Fatalf("expected continue=false on deny: %v", out) + } + spec, _ := out["hookSpecificOutput"].(map[string]any) + if spec["permissionDecision"] != "deny" { + t.Fatalf("decision = %v", spec) + } + sr, ok := out["stopReason"].(string) + if !ok || sr == "" { + t.Fatalf("expected non-empty stopReason, got %v", out["stopReason"]) + } +} + +// Auto-creates the daemon-side session on first hit, tagged +// Harness="claude-desktop" so dashboards distinguish proxy traffic. +func TestClaudeDesktopPreToolUse_AutoCreatesUnattestedSession(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + body := `{ + "session_id": "brand-new-desktop-session", + "hook_event_name": "PreToolUse", + "tool_name": "mcp__filesystem__read_file", + "tool_use_id": "9", + "tool_input": {"path": "/tmp/x"} + }` + _, _ = postClaudeDesktopPre(t, fx, body) + sess, err := fx.store.GetSession(context.Background(), "brand-new-desktop-session") + if err != nil { + t.Fatalf("auto-session not created: %v", err) + } + if sess.Signer != "none" { + t.Fatalf("auto-session should be unattested signer=none, got %q", sess.Signer) + } + if sess.Harness != "claude-desktop" { + t.Fatalf("auto-session harness should be 'claude-desktop', got %q", sess.Harness) + } +} + +// TestRefreshUnattestedPolicyHash_RePinsUnattestedToLive is the +// positive case for the proxy's stable-session-id papercut: a long- +// lived auto-created session whose pinned hash is stale must get +// re-pinned to live so policy edits made AFTER the session was created +// still apply on the next call. +func TestRefreshUnattestedPolicyHash_RePinsUnattestedToLive(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + stale := storage.Session{ + ID: "stale-id", + Signer: "none", + PolicyHash: "sha256:stale-hash-from-prior-policy-version", + } + got := refreshUnattestedPolicyHash(Deps{Store: fx.store}, stale) + if got.PolicyHash == stale.PolicyHash { + t.Fatalf("unattested session not refreshed (still pinned to %q)", got.PolicyHash) + } + // The live policy hash lives in livePolicyRegistry; we just assert it + // changed off the stale value. Existing tests cover the registry + // itself, so we don't reach in to compare hashes directly here. +} + +// TestRefreshUnattestedPolicyHash_LeavesAttestedSessionsAlone is the +// safety case: the fix must NOT mutate attested sessions. Their pinned +// hash is committed to by the signer's signature; re-pinning would +// silently invalidate the attestation. +func TestRefreshUnattestedPolicyHash_LeavesAttestedSessionsAlone(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + attested := storage.Session{ + ID: "attested-id", + Signer: "totp", + PolicyHash: "sha256:legacy-hash-attested-by-signer", + } + got := refreshUnattestedPolicyHash(Deps{Store: fx.store}, attested) + if got.PolicyHash != attested.PolicyHash { + t.Fatalf("attested session hash mutated: got %q want %q", got.PolicyHash, attested.PolicyHash) + } +} + +// TestRefreshUnattestedPolicyHash_AlreadyCurrentIsNoop: when an +// unattested session is already pinned to the live policy, refresh +// must be a no-op (don't churn the struct, don't allocate). Prevents a +// future regression where someone "fixes" the conditional and ends up +// always rewriting. +func TestRefreshUnattestedPolicyHash_AlreadyCurrentIsNoop(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + live := livePolicyFor(Deps{Store: fx.store}) + if live == nil { + t.Skip("no live policy in registry; can't run this test") + } + current := storage.Session{ + ID: "current-id", + Signer: "none", + PolicyHash: live.Hash, + } + got := refreshUnattestedPolicyHash(Deps{Store: fx.store}, current) + if got.PolicyHash != live.Hash { + t.Fatalf("already-current session got rewritten: %q -> %q", live.Hash, got.PolicyHash) + } +} + +// TestClaudeDesktopPreToolUse_PolicyEditTakesEffectMidSession is the +// end-to-end version: confirms the regression doesn't reappear by +// driving a real HTTP roundtrip. Session is created with permissive +// policy, policy is mutated to deny via the live API, next call from +// the same session id is denied. Without the re-pin, this hangs at +// allow because the session's pinned hash still resolves to the +// permissive snapshot. +func TestClaudeDesktopPreToolUse_PolicyEditTakesEffectMidSession(t *testing.T) { + fx := newGateFixture(t, monitorPolicyYAML) + + allowBody := `{ + "session_id": "stable-proxy-session", + "hook_event_name": "PreToolUse", + "tool_name": "mcp__filesystem__read_text_file", + "tool_use_id": "1", + "tool_input": {"path": "/tmp/anything"} + }` + // First hit creates the session pinned to monitorPolicyYAML's hash. + res, out := postClaudeDesktopPre(t, fx, allowBody) + if res.StatusCode != http.StatusOK || out["continue"] != true { + t.Fatalf("baseline allow failed: status=%d out=%v", res.StatusCode, out) + } + if _, err := fx.store.GetSession(context.Background(), "stable-proxy-session"); err != nil { + t.Fatalf("session not created: %v", err) + } + + // Add a deny gate for the path used above. This call mutates the + // live policy and Swap()s a new hash into the registry. + gateYAML := `id: dyn.block-tmp +match: + tool: mcp__filesystem__read_text_file + any_path_regex: + - "/tmp/" +evaluate: + - kind: always + action: deny +nudge: blocked by mid-session policy edit` + gateBody, _ := json.Marshal(map[string]any{"yaml": gateYAML, "replace": true}) + addRes, err := http.Post(fx.srv.URL+"/v1/policy/gates/yaml", "application/json", strings.NewReader(string(gateBody))) + if err != nil { + t.Fatalf("add gate: %v", err) + } + addRes.Body.Close() + + // Force-enforce so the gate's per-gate monitor mode escalates to deny. + t.Setenv("AGENTLOCK_MODE", "firewall") + + // Re-issue the same call from the same session. With the fix: + // re-pinned to live → deny. Without it: still pinned to the old + // hash → no gate match → allow. + res, out = postClaudeDesktopPre(t, fx, allowBody) + if res.StatusCode != http.StatusOK { + t.Fatalf("second call status = %d", res.StatusCode) + } + if out["continue"] != false { + t.Fatalf("policy edit did not take effect mid-session: out=%v", out) + } + spec, _ := out["hookSpecificOutput"].(map[string]any) + if spec["permissionDecision"] != "deny" { + t.Fatalf("expected deny, got %v", spec) + } +} + +func TestClaudeDesktopPostToolUse_RecordsCompletion(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + body := `{ + "session_id": "claude-desktop-fs", + "hook_event_name": "PostToolUse", + "tool_name": "mcp__filesystem__read_file", + "tool_use_id": "5", + "tool_input": {}, + "tool_response": {"content": [{"type":"text","text":"hello"}]} + }` + res, err := http.Post(fx.srv.URL+"/v1/hooks/claude-desktop/post-tool-use", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + var out map[string]any + _ = json.NewDecoder(res.Body).Decode(&out) + if out["continue"] != true { + t.Fatalf("continue = %v", out["continue"]) + } +} diff --git a/control-plane/internal/api/install.go b/control-plane/internal/api/install.go index 514ac5f..96d569c 100644 --- a/control-plane/internal/api/install.go +++ b/control-plane/internal/api/install.go @@ -132,6 +132,10 @@ func buildPlanOps(req installPlanRequest) ([]fileOp, []string, []string) { switch h { case "claude-code": ops = append(ops, claudeCodePlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.StatusLineScript, req.HarnessConfigDirs, req.ExistingFiles)) + case "claude-desktop": + op, ws := claudeDesktopPlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles) + ops = append(ops, op) + warnings = append(warnings, ws...) case "codex": codexOps, ws := codexPlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles) ops = append(ops, codexOps...) @@ -477,6 +481,8 @@ func installApplyHandler(d Deps) http.HandlerFunc { func harnessForPath(path string) string { dir := filepath.Base(filepath.Dir(path)) switch { + case strings.HasSuffix(path, "claude_desktop_config.json"): + return "claude-desktop" case strings.HasSuffix(path, "settings.json"): // Gemini also writes settings.json (in ~/.gemini); disambiguate // by parent directory. Anything else is Claude Code. @@ -582,7 +588,7 @@ func isAgentlockEntry(v any) bool { // executes "/Users/ronaldli/Library/Application" as a script — that's // the "line 1: on: command not found" failure mode that produced red // "PreToolUse:hook error" banners in earlier installs. Single quotes -// are the simplest robust escape: macOS state dirs can't contain '\''. +// are the simplest robust escape: macOS state dirs can't contain '\”. // For the (extremely unlikely) edge case where they do, we fall back // to the close-quote / escaped-quote / open-quote idiom. func shellQuote(s string) string { @@ -675,6 +681,8 @@ func installUninstallHandler(d Deps) http.HandlerFunc { newBytes, removed, stripErr = stripCursorHooks(existing) case "gemini": newBytes, removed, stripErr = stripGeminiSettings(existing) + case "claude-desktop": + newBytes, removed, stripErr = stripClaudeDesktopConfig(existing) default: // Default to Claude's settings.json shape. Older manifests // without a Harness field land here, which is the right @@ -881,6 +889,27 @@ func installUninstallHarnessesHandler(d Deps) http.HandlerFunc { } } ops = append(ops, op) + case "claude-desktop": + p, err := claudeDesktopConfigPath(req.ConfigDirOverride, req.HarnessConfigDirs) + if err != nil { + failures++ + ops = append(ops, uninstallOp{Op: "strip", Path: "", Error: err.Error()}) + continue + } + existing := []byte(req.ExistingFiles[p]) + newBytes, removed, stripErr := stripClaudeDesktopConfig(existing) + op := uninstallOp{Op: "strip", Path: p} + if stripErr != nil { + failures++ + op.Error = stripErr.Error() + log.Printf("install.uninstall_harnesses: strip claude-desktop %s: %v", p, stripErr) + } else { + op.EntriesRemoved = removed + if removed > 0 { + op.Content = string(newBytes) + } + } + ops = append(ops, op) default: if devHome == "" || !knownHarnessID(h) { // No real installer + not in dev mode → nothing to do. diff --git a/control-plane/internal/api/install_claude_desktop.go b/control-plane/internal/api/install_claude_desktop.go new file mode 100644 index 0000000..db51f18 --- /dev/null +++ b/control-plane/internal/api/install_claude_desktop.go @@ -0,0 +1,355 @@ +package api + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// install_claude_desktop wires Anthropic's standalone Claude Desktop app +// (distinct from Claude Code). Claude Desktop has no PreToolUse / +// PostToolUse hook surface upstream — see anthropics/claude-code#45514, +// closed-as-duplicate without a ship. The only documented extension +// surface is `mcpServers` entries in claude_desktop_config.json. +// +// What we install: +// +// 1. An "agentlock" MCP server entry — observability tools (status, +// recent ledger entries) backed by the daemon. Read-only. +// 2. EVERY existing entry under mcpServers is rewritten to spawn +// `agentlock mcp-proxy --name -- `. +// The proxy sits between Claude Desktop and the real MCP server, +// pumps bytes both ways verbatim, and on each JSON-RPC tools/call +// requests a verdict from /v1/hooks/claude-desktop/pre-tool-use. +// Allow → forward to child. Deny → synthesize MCP error reply. +// +// The original command/args/env for each wrapped entry are preserved +// under `_agentlock_original` on the same entry. stripClaudeDesktopConfig +// reverses the wrap by reading those originals back. Re-running install +// is idempotent: we always re-read originals first, then re-wrap, so +// drift from a user-edited args list is corrected on every run. +// +// What this does NOT cover: Anthropic's server-side features (web +// search, code interpreter) run in their cloud and aren't gateable by +// any local solution — Claude Code can't reach them either. Documented +// in docs/status.md. + +// claudeDesktopServerName is the key under mcpServers we own (the +// observability-only MCP server entry). User-installed entries get +// wrapped in-place under their original names so server-name-based +// references in user prompts continue to work. +const claudeDesktopServerName = "agentlock" + +// agentlockOriginalKey holds the verbatim command/args/env of a +// user-installed MCP server before we wrapped it. Strip on uninstall +// reads this back to restore the original entry. +const agentlockOriginalKey = "_agentlock_original" + +// claudeDesktopConfigPath returns the platform-correct config path. The +// CLI normally pre-resolves this and passes it via +// harness_config_dirs["claude-desktop"]; the daemon-side fallback only +// kicks in for older CLIs that don't send the override. +// +// macOS: $HOME/Library/Application Support/Claude/claude_desktop_config.json +// Windows: %APPDATA%/Claude/claude_desktop_config.json +// Linux: no official Claude Desktop release; we still resolve a path +// +// (under XDG_CONFIG_HOME/Claude) so dev sandboxes work, but +// users won't have the app installed there. +func claudeDesktopConfigPath(configDirOverride string, overrides map[string]string) (string, error) { + dir := configDirOverride + if dir == "" { + if d := overrides["claude-desktop"]; d != "" { + dir = d + } else if devHome := os.Getenv("AGENTLOCK_DEV_HOME"); devHome != "" { + dir = claudeDesktopDevDir(devHome) + } else if d, err := claudeDesktopHostDir(); err == nil { + dir = d + } else { + return "", fmt.Errorf("cannot resolve Claude Desktop config dir; set config_dir_override, AGENTLOCK_DEV_HOME, or HOME/APPDATA") + } + } + return filepath.Join(dir, "claude_desktop_config.json"), nil +} + +// claudeDesktopDevDir mirrors the CLI's appSupport()-based resolution +// inside the AGENTLOCK_DEV_HOME sandbox. Tests that set DEV_HOME to a +// tmpdir get a path with the same shape the production install would +// hit, so the merge / strip codepaths exercise the same parent dirs. +func claudeDesktopDevDir(devHome string) string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(devHome, "Library", "Application Support", "Claude") + case "windows": + return filepath.Join(devHome, "AppData", "Roaming", "Claude") + default: + return filepath.Join(devHome, ".config", "Claude") + } +} + +// claudeDesktopHostDir is the real-host fallback when no override and no +// dev sandbox is set. Honors $APPDATA on Windows; falls back to the +// per-OS conventional dir relative to $HOME otherwise. +func claudeDesktopHostDir() (string, error) { + if runtime.GOOS == "windows" { + if appdata := os.Getenv("APPDATA"); appdata != "" { + return filepath.Join(appdata, "Claude"), nil + } + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + if env := os.Getenv("HOME"); env != "" { + home = env + } else { + return "", fmt.Errorf("no home directory") + } + } + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Claude"), nil + case "windows": + return filepath.Join(home, "AppData", "Roaming", "Claude"), nil + default: + return filepath.Join(home, ".config", "Claude"), nil + } +} + +// claudeDesktopServerEntry returns the mcpServers["agentlock"] entry we +// merge into the user's config. The `_agentlock: true` field is an +// unknown key to MCP / Claude Desktop and is ignored at launch; we keep +// it so uninstall identifies our entry without relying on the env var +// (which a user could legitimately set on their own server). +func claudeDesktopServerEntry(daemonURL, agentlockBinary string) map[string]any { + bin := claudeCodeBinary(agentlockBinary) + daemonURL = strings.TrimRight(daemonURL, "/") + return map[string]any{ + "_agentlock": true, + "command": bin, + "args": []any{"mcp-server"}, + "env": map[string]any{ + "AGENTLOCK_DAEMON_URL": daemonURL, + }, + } +} + +// wrapMcpServerEntry rewrites a single mcpServers entry to spawn +// `agentlock mcp-proxy --name -- ` while +// preserving the original under _agentlock_original for restore. If +// the entry is already wrapped (carries our marker), we re-read its +// preserved original and re-wrap it — handles drift where the user +// edited the wrapped command directly. +func wrapMcpServerEntry(name string, original map[string]any, daemonURL, agentlockBinary string) map[string]any { + bin := claudeCodeBinary(agentlockBinary) + daemonURL = strings.TrimRight(daemonURL, "/") + + // If this is already our wrapper, recover the real original from + // the stashed copy so a re-install doesn't double-wrap. + if isAgentlockEntry(original) { + if stashed, ok := original[agentlockOriginalKey].(map[string]any); ok { + original = stashed + } + } + + origCmd, _ := original["command"].(string) + origArgsRaw, _ := original["args"].([]any) + origEnv, _ := original["env"].(map[string]any) + + // Build the proxy's args: --name -- . + // Use []any rather than []string so json.Marshal emits a JSON array. + args := []any{"mcp-proxy", "--name", name, "--", origCmd} + args = append(args, origArgsRaw...) + + // Inherit the original's env so credentials, API keys, etc. still + // reach the child. Add our own AGENTLOCK_DAEMON_URL if absent so the + // proxy can find the daemon without relying on PATH-time defaults. + env := map[string]any{} + for k, v := range origEnv { + env[k] = v + } + if _, ok := env["AGENTLOCK_DAEMON_URL"]; !ok { + env["AGENTLOCK_DAEMON_URL"] = daemonURL + } + + // Preserve the original verbatim so strip can put it back exactly. + originalCopy := map[string]any{ + "command": origCmd, + "args": origArgsRaw, + } + if len(origEnv) > 0 { + originalCopy["env"] = origEnv + } + + return map[string]any{ + "_agentlock": true, + agentlockOriginalKey: originalCopy, + "command": bin, + "args": args, + "env": env, + } +} + +// mergeClaudeDesktopConfig parses the existing claude_desktop_config.json +// bytes (may be empty), wraps every user-installed MCP server with our +// proxy, refreshes our standalone observability entry, and returns the +// new bytes. Idempotent on every run — the wrapper helper unwinds any +// existing wrap to find the original before re-wrapping, so drift from +// a user-edited args list is corrected each time. +func mergeClaudeDesktopConfig(existing []byte, daemonURL, agentlockBinary string) ([]byte, error) { + cfg := map[string]any{} + if len(existing) > 0 { + if err := json.Unmarshal(existing, &cfg); err != nil { + return nil, fmt.Errorf("parse existing config: %w", err) + } + } + + servers, _ := cfg["mcpServers"].(map[string]any) + if servers == nil { + servers = map[string]any{} + } + + // Refresh our observability entry. Always replace; the user can't + // have a legitimate non-agentlock server named "agentlock" because + // MCP server names are user-chosen and we own this one by convention. + servers[claudeDesktopServerName] = claudeDesktopServerEntry(daemonURL, agentlockBinary) + + // Wrap every other entry. Skip our own observability entry (already + // handled above) — we don't proxy the proxy. + for name, v := range servers { + if name == claudeDesktopServerName { + continue + } + entry, ok := v.(map[string]any) + if !ok { + continue + } + servers[name] = wrapMcpServerEntry(name, entry, daemonURL, agentlockBinary) + } + + cfg["mcpServers"] = servers + return json.MarshalIndent(cfg, "", " ") +} + +// claudeDesktopPlan returns the merged config the CLI should write. When +// existingFiles[configPath] is unset we emit a fresh config carrying +// only the agentlock entry; otherwise we merge against the supplied +// bytes so user-set mcpServers + any other top-level keys survive. +func claudeDesktopPlan(daemonURL, configDirOverride, agentlockBinary string, overrides map[string]string, existingFiles map[string]string) (fileOp, []string) { + warnings := []string{ + // Honest scope statement. We gate the MCP slice (every tools/call + // to a user-installed MCP server or .mcpb Desktop Extension), but + // Claude Desktop has additional local capabilities that don't go + // through MCP and are NOT gated by this install: + // - Computer Use (direct mouse/keyboard control) + // - Cowork's non-MCP agentic paths (where applicable) + // - Integrated terminal command execution + // - Native connectors (Slack, Google Calendar, etc.) + // - Server-side features (web search, code interpreter) + // Documented in docs/status.md so dashboard / report can't + // overstate coverage. + "claude-desktop: agentlock gates MCP tool calls only — every user-installed MCP server and .mcpb Desktop Extension is wrapped. NOT gated: Computer Use, integrated terminal, native connectors (Slack/GCal), Cowork's non-MCP paths, and Anthropic cloud features (web search, code interpreter). For full local enforcement, use Claude Code instead.", + } + + configPath, err := claudeDesktopConfigPath(configDirOverride, overrides) + if err != nil { + configPath = "" + } + abs := configPath + if a, err := filepath.Abs(configPath); err == nil { + abs = a + } + + var existing []byte + backupPath := "" + if c, ok := existingFiles[abs]; ok && c != "" { + existing = []byte(c) + backupPath = fmt.Sprintf("%s.agentlock-backup-%d", abs, time.Now().UnixNano()) + } + + merged, mergeErr := mergeClaudeDesktopConfig(existing, daemonURL, agentlockBinary) + if mergeErr != nil { + // Existing config was unparseable. Fall back to an agentlock-only + // payload so the install still produces something usable; the + // CLI will surface the parse error when it diffs against existing. + fresh := map[string]any{ + "mcpServers": map[string]any{ + claudeDesktopServerName: claudeDesktopServerEntry(daemonURL, agentlockBinary), + }, + } + merged, _ = json.MarshalIndent(fresh, "", " ") + } + return fileOp{ + Op: "write", + Path: abs, + Content: string(merged), + Reason: fmt.Sprintf("register agentlock as MCP server in Claude Desktop → %s (no PreToolUse upstream)", strings.TrimRight(daemonURL, "/")), + BackupPath: backupPath, + }, warnings +} + +// stripClaudeDesktopConfig reverses the wrap. For each entry tagged +// _agentlock:true: +// +// - if it has a stashed _agentlock_original, restore that original +// in place (the user's MCP server returns to its pre-install state) +// - if it has no original (our standalone "agentlock" observability +// entry), drop it entirely +// +// Pure: no disk I/O. Mirrors stripClaudeSettings's contract so the +// uninstall switch can dispatch uniformly. +func stripClaudeDesktopConfig(existing []byte) ([]byte, int, error) { + if len(existing) == 0 { + return nil, 0, nil + } + cfg := map[string]any{} + if err := json.Unmarshal(existing, &cfg); err != nil { + return nil, 0, fmt.Errorf("parse desktop config: %w", err) + } + servers, _ := cfg["mcpServers"].(map[string]any) + if servers == nil { + return nil, 0, nil + } + + removed := 0 + for name, v := range servers { + entry, ok := v.(map[string]any) + if !ok { + continue + } + if !isAgentlockEntry(entry) { + continue + } + if original, ok := entry[agentlockOriginalKey].(map[string]any); ok { + // Restore the user's original entry verbatim. Drop the + // _agentlock_original wrapper so the restored entry doesn't + // carry stray markers. + restored := map[string]any{ + "command": original["command"], + "args": original["args"], + } + if e, ok := original["env"].(map[string]any); ok && len(e) > 0 { + restored["env"] = e + } + servers[name] = restored + removed++ + continue + } + // No stashed original = our standalone observability entry. Drop it. + delete(servers, name) + removed++ + } + if len(servers) == 0 { + delete(cfg, "mcpServers") + } else { + cfg["mcpServers"] = servers + } + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, 0, fmt.Errorf("marshal: %w", err) + } + return out, removed, nil +} diff --git a/control-plane/internal/api/install_claude_desktop_test.go b/control-plane/internal/api/install_claude_desktop_test.go new file mode 100644 index 0000000..ca2f429 --- /dev/null +++ b/control-plane/internal/api/install_claude_desktop_test.go @@ -0,0 +1,237 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" +) + +// TestClaudeDesktopPlan_RegistersAgentlockAndWrapsUserServers covers the +// happy path: plan emits a write op whose content has BOTH our standalone +// "agentlock" observability entry AND every user mcpServers entry rewritten +// to spawn `agentlock mcp-proxy ... -- `. +func TestClaudeDesktopPlan_RegistersAgentlockAndWrapsUserServers(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + + // Seed an existing config carrying a user-installed MCP server. + existing := `{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"FOO": "bar"} + } + } + }` + planBody := fmt.Sprintf(`{ + "session_id": %q, + "harnesses": ["claude-desktop"], + "daemon_url": "http://127.0.0.1:7878", + "config_dir_override": "/tmp/fake-claude-desktop", + "existing_files": {"/tmp/fake-claude-desktop/claude_desktop_config.json": %q} + }`, fx.sessionID, existing) + res, err := http.Post(fx.srv.URL+"/v1/install/plan", "application/json", strings.NewReader(planBody)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(res.Body) + t.Fatalf("status = %d body=%s", res.StatusCode, buf.String()) + } + + var plan map[string]any + _ = json.NewDecoder(res.Body).Decode(&plan) + ops, _ := plan["operations"].([]any) + if len(ops) != 1 { + t.Fatalf("expected 1 op, got %d: %+v", len(ops), plan) + } + op, _ := ops[0].(map[string]any) + + content, _ := op["content"].(string) + var got map[string]any + if err := json.Unmarshal([]byte(content), &got); err != nil { + t.Fatalf("content not valid JSON: %v\n%s", err, content) + } + servers, _ := got["mcpServers"].(map[string]any) + + // Standalone observability entry must still be present. + agentlockEntry, _ := servers["agentlock"].(map[string]any) + if agentlockEntry == nil { + t.Fatalf("missing standalone agentlock entry: %s", content) + } + + // User's filesystem entry must be WRAPPED (command -> agentlock, + // args[0] -> "mcp-proxy", original preserved). + fs, _ := servers["filesystem"].(map[string]any) + if fs == nil { + t.Fatalf("user filesystem server lost: %s", content) + } + if fs["command"] != "agentlock" { + t.Fatalf("filesystem.command not wrapped: %v", fs["command"]) + } + args, _ := fs["args"].([]any) + if len(args) < 5 || args[0] != "mcp-proxy" || args[1] != "--name" || args[2] != "filesystem" || args[3] != "--" || args[4] != "npx" { + t.Fatalf("filesystem.args not wrapped correctly: %+v", args) + } + original, _ := fs["_agentlock_original"].(map[string]any) + if original == nil { + t.Fatalf("filesystem._agentlock_original missing: %+v", fs) + } + if original["command"] != "npx" { + t.Fatalf("preserved command wrong: %v", original["command"]) + } +} + +// TestMergeClaudeDesktopConfig_IsIdempotentAfterWrap: re-running install +// must not double-wrap. The second merge sees an already-wrapped entry, +// recovers the stashed original from _agentlock_original, and re-wraps +// from scratch — yielding identical output. +func TestMergeClaudeDesktopConfig_IsIdempotentAfterWrap(t *testing.T) { + existing := `{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + }` + first, err := mergeClaudeDesktopConfig([]byte(existing), "http://127.0.0.1:7878", "agentlock") + if err != nil { + t.Fatalf("first merge: %v", err) + } + second, err := mergeClaudeDesktopConfig(first, "http://127.0.0.1:7878", "agentlock") + if err != nil { + t.Fatalf("second merge: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatalf("non-idempotent re-wrap:\nfirst: %s\nsecond: %s", first, second) + } + + // Spot-check that the wrap structure was preserved across both passes: + // args still start with mcp-proxy, original still pinned. + var got map[string]any + _ = json.Unmarshal(second, &got) + servers, _ := got["mcpServers"].(map[string]any) + fs, _ := servers["filesystem"].(map[string]any) + args, _ := fs["args"].([]any) + if len(args) == 0 || args[0] != "mcp-proxy" { + t.Fatalf("re-wrap lost mcp-proxy prefix: %+v", args) + } + original, _ := fs["_agentlock_original"].(map[string]any) + if original == nil || original["command"] != "npx" { + t.Fatalf("re-wrap lost original: %+v", fs) + } +} + +// TestStripClaudeDesktopConfig_RestoresWrappedEntries: uninstall must +// restore each user-wrapped entry from its stashed _agentlock_original +// AND drop our standalone observability entry. Net effect: config +// returns to its pre-install state byte-for-byte (modulo whitespace). +func TestStripClaudeDesktopConfig_RestoresWrappedEntries(t *testing.T) { + pre := `{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"FOO": "bar"} + } + } + }` + wrapped, err := mergeClaudeDesktopConfig([]byte(pre), "http://127.0.0.1:7878", "agentlock") + if err != nil { + t.Fatalf("merge: %v", err) + } + + out, removed, err := stripClaudeDesktopConfig(wrapped) + if err != nil { + t.Fatalf("strip: %v", err) + } + if removed != 2 { + t.Fatalf("expected 2 removals (1 wrap restore + 1 standalone drop), got %d", removed) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("strip output not JSON: %v\n%s", err, out) + } + servers, _ := got["mcpServers"].(map[string]any) + if _, ok := servers["agentlock"]; ok { + t.Fatalf("standalone agentlock entry not removed: %s", out) + } + fs, _ := servers["filesystem"].(map[string]any) + if fs == nil { + t.Fatalf("filesystem entry lost during strip: %s", out) + } + if fs["command"] != "npx" { + t.Fatalf("filesystem.command not restored: %v", fs["command"]) + } + if _, ok := fs["_agentlock_original"]; ok { + t.Fatalf("_agentlock_original leaked into restored entry: %+v", fs) + } + if _, ok := fs["_agentlock"]; ok { + t.Fatalf("_agentlock marker leaked into restored entry: %+v", fs) + } + env, _ := fs["env"].(map[string]any) + if env["FOO"] != "bar" { + t.Fatalf("user env lost on restore: %+v", env) + } +} + +// TestStripClaudeDesktopConfig_LeavesUnmarkedAgentlockUntouched: if a +// user happens to name their own server "agentlock" without our marker, +// strip must not touch it. +func TestStripClaudeDesktopConfig_LeavesUnmarkedAgentlockUntouched(t *testing.T) { + cfg := `{"mcpServers":{"agentlock":{"command":"my-tool","args":[]}}}` + out, removed, err := stripClaudeDesktopConfig([]byte(cfg)) + if err != nil { + t.Fatalf("strip: %v", err) + } + if removed != 0 { + t.Fatalf("expected 0 removals on unmarked entry, got %d (out=%s)", removed, out) + } +} + +// TestClaudeDesktopPlan_WarningsCarryEnforcementCaveat: the plan still +// emits a warning explaining what we DO and DON'T cover so dashboards +// and reports can't promise enforcement we can't deliver. +func TestClaudeDesktopPlan_WarningsCarryEnforcementCaveat(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + planBody := fmt.Sprintf(`{ + "session_id": %q, + "harnesses": ["claude-desktop"], + "daemon_url": "http://127.0.0.1:7878", + "config_dir_override": "/tmp/fake-claude-desktop" + }`, fx.sessionID) + res, err := http.Post(fx.srv.URL+"/v1/install/plan", "application/json", strings.NewReader(planBody)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + var plan map[string]any + _ = json.NewDecoder(res.Body).Decode(&plan) + warnings, _ := plan["warnings"].([]any) + if len(warnings) == 0 { + t.Fatalf("expected at least one warning, got %+v", plan) + } +} + +// TestHarnessForPath_ClaudeDesktop ensures the manifest uninstall path +// dispatches strip ops back to stripClaudeDesktopConfig. +func TestHarnessForPath_ClaudeDesktop(t *testing.T) { + cases := []struct{ path, want string }{ + {"/Users/x/Library/Application Support/Claude/claude_desktop_config.json", "claude-desktop"}, + {"/home/x/AppData/Roaming/Claude/claude_desktop_config.json", "claude-desktop"}, + {"/Users/x/.claude/settings.json", "claude-code"}, + } + for _, c := range cases { + got := harnessForPath(c.path) + if got != c.want { + t.Errorf("harnessForPath(%q) = %q, want %q", c.path, got, c.want) + } + } +} diff --git a/control-plane/internal/api/router.go b/control-plane/internal/api/router.go index cd48066..3781521 100644 --- a/control-plane/internal/api/router.go +++ b/control-plane/internal/api/router.go @@ -87,6 +87,10 @@ func NewRouter(deps ...Deps) http.Handler { {"POST", "/v1/hooks/gemini/pre-tool-use", geminiPreToolUseHandler(d)}, {"POST", "/v1/hooks/gemini/post-tool-use", geminiPostToolUseHandler(d)}, {"POST", "/v1/hooks/gemini/stop", geminiStopHandler(d)}, + // Claude Desktop has no upstream PreToolUse hook; the agentlock + // mcp-proxy subprocess hits these on every JSON-RPC tools/call. + {"POST", "/v1/hooks/claude-desktop/pre-tool-use", claudeDesktopPreToolUseHandler(d)}, + {"POST", "/v1/hooks/claude-desktop/post-tool-use", claudeDesktopPostToolUseHandler(d)}, // MCP TOFU pinning. {"GET", "/v1/mcp/pins", mcpPinsListHandler(d)}, From e73e3b120fa6d7006acad77c636001998dbd6a90 Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Wed, 6 May 2026 23:42:24 -0700 Subject: [PATCH 2/7] Apply policy edits to long-lived unattested sessions --- control-plane/internal/api/hooks_claude.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/control-plane/internal/api/hooks_claude.go b/control-plane/internal/api/hooks_claude.go index 49308e4..0227fe0 100644 --- a/control-plane/internal/api/hooks_claude.go +++ b/control-plane/internal/api/hooks_claude.go @@ -330,10 +330,16 @@ func claudeStopHandler(d Deps) http.HandlerFunc { // ensureClaudeSession returns the daemon's session for the given ID, // auto-creating an unattested "none" signer session if we have never seen // it before. Auto-created sessions surface as red banners in the dashboard. +// +// For unattested sessions we re-pin to the live policy on every call — +// see refreshUnattestedPolicyHash for why. Long-lived auto-created +// sessions otherwise keep evaluating against whatever policy was live +// at first-hit time, so policy edits made after the session existed +// silently no-op for that session. func ensureClaudeSession(r *http.Request, d Deps, id string) (storage.Session, error) { sess, err := d.Store.GetSession(r.Context(), id) if err == nil { - return sess, nil + return refreshUnattestedPolicyHash(d, sess), nil } if !errors.Is(err, storage.ErrSessionNotFound) { return storage.Session{}, err @@ -356,7 +362,11 @@ func ensureClaudeSession(r *http.Request, d Deps, id string) (storage.Session, e } if err := d.Store.CreateSession(r.Context(), newSess); err != nil { if errors.Is(err, storage.ErrSessionExists) { - return d.Store.GetSession(r.Context(), id) + existing, getErr := d.Store.GetSession(r.Context(), id) + if getErr != nil { + return storage.Session{}, getErr + } + return refreshUnattestedPolicyHash(d, existing), nil } return storage.Session{}, err } From df374dfc76eea3456beb08313cd3a0f7dc80fa1f Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Wed, 6 May 2026 23:42:24 -0700 Subject: [PATCH 3/7] Document Claude Desktop coverage scope --- docs/status.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/status.md b/docs/status.md index 2ee6afa..20c12d7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -8,8 +8,9 @@ Live status of every component shipped to the public repo. Shipped | | `agentlock install` (Claude Code, Codex CLI, Cursor, Gemini CLI) | Shipped | +| `agentlock install` (Claude Desktop) | Shipped — **MCP-slice enforcement** via `agentlock mcp-proxy`. Wraps every user-installed MCP server and `.mcpb` Desktop Extension; each `tools/call` goes through daemon policy. **Not gated:** Computer Use (direct mouse/keyboard), integrated terminal, native connectors (Slack/GCal), and server-side features (web search, code interpreter). **Cowork coverage uncertain:** any MCP-mediated tool call Cowork makes IS gated; whether Cowork has separate non-MCP code paths is unverified — verify in your environment by running a Cowork task and checking the agentlock ledger. For full local enforcement of an agent harness, use Claude Code. Tracks [anthropics/claude-code#45514](https://github.com/anthropics/claude-code/issues/45514) for native PreToolUse parity. | | `agentlock install` (OpenCode, Cline, Continue, VS Code Copilot) | Not yet implemented — detected but disabled in selector | -| `agentlock install` (Claude Desktop, Codex Desktop, Openclaw, Nemoclaw, Hermesagent, Pi) | Not yet implemented — roadmap; awaiting per-app hook/config investigation | +| `agentlock install` (Codex Desktop, Openclaw, Nemoclaw, Hermesagent, Pi) | Not yet implemented — roadmap; awaiting per-app hook/config investigation | | `agentlock install --tier {unattested,software,totp}` | Shipped | | `agentlock status` | Shipped | | `agentlock signer enroll --tier totp` | Shipped | @@ -17,6 +18,8 @@ Live status of every component shipped to the public repo. Not yet implemented | | `agentlock session create / rotate / end` (software, totp) | Shipped | | `agentlock hook claude-code / codex / cursor / gemini ` shims | Shipped | +| `agentlock mcp-server` (Claude Desktop MCP stdio server, read-only) | Shipped — exposes status + ledger query tools | +| `agentlock mcp-proxy` (Claude Desktop tools/call gate) | Shipped — sits between Desktop and each user MCP server, fail-open on daemon-down | | `agentlock ledger root / verify` | Shipped | | `agentlock fake-hook` (eval / scenario harness) | Shipped | | `agentlock dashboard` (open local web dashboard) | Shipped | @@ -38,6 +41,7 @@ Live status of every component shipped to the public repo. Shipped | | `/v1/hooks/cursor/*` | Shipped | | `/v1/hooks/gemini/*` | Shipped | +| `/v1/hooks/claude-desktop/*` | Shipped — called by `agentlock mcp-proxy`, not by Claude Desktop directly | | `/v1/auth` (password) | Shipped | | `/v1/auth` (OIDC) | Not yet implemented — stub returns mode hint | | `/v1/auth` (LDAP) | Not yet implemented — stub returns mode hint | From 79618bd23c1358f64e38ad878bc25244dd7818c4 Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Wed, 6 May 2026 23:44:45 -0700 Subject: [PATCH 4/7] Document terminal dashboard alongside web dashboard --- README.md | 2 +- docs/guide/dashboard.md | 30 ++++++++++++++++++++++++------ docs/guide/getting-started.md | 2 +- docs/index.md | 6 +++--- mkdocs.yml | 2 +- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c5a97c9..d7cba9f 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ agentlock install --tier totp --code 123456 --passphrase 'your-passphrase-here' For a quick eval without a signer (dev only): start the daemon with `-e AGENTLOCK_ALLOW_UNATTESTED=1`, then `agentlock install` (defaults to unattested). -Open the local web dashboard at . +Open the local web dashboard at , or run `agentlock dashboard` for a terminal TUI with the same live ledger tail, sessions, loaded gates, and a one-key monitor⇄enforce flip. Full walkthrough at . diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index b23e94e..78a20e8 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -1,8 +1,13 @@ -# Local web dashboard +# Dashboard -The control plane serves a small SPA at `127.0.0.1:7879`. It is shaped like a firewall admin UI — log table on the left, rule tree on the right, live activity at the bottom. +OpenAgentLock ships two surfaces over the same daemon endpoints: -## What it does +- **Web dashboard** — a small SPA the control plane serves at `127.0.0.1:7879`. Shaped like a firewall admin UI: log table on the left, rule tree on the right, live activity at the bottom. +- **Terminal dashboard** — `agentlock dashboard`, an OpenTUI viewer over the daemon's JSON + SSE endpoints. Read-mostly today; edit flows still live on the web dashboard. + +Both read from the same ledger and policy state — pick whichever fits the moment. + +## What the web dashboard does - **Log table** — every tool call across every harness, with per-row source / session / verdict / signer - **Rule tree** — visual editor for the YAML policy with diff preview before save @@ -11,11 +16,24 @@ The control plane serves a small SPA at `127.0.0.1:7879`. It is shaped like a fi - **Mode toggle** — flip the daemon between `monitor` and `enforce` (separate from the policy file's own `mode`) - **MCP pin queue** — accept or reject newly seen MCP servers -## Why a separate UI +## What the terminal dashboard does + +```bash +agentlock dashboard +# --daemon override control-plane base URL (env: AGENTLOCK_CONTROL_PLANE_URL) +# --token bearer token when AGENTLOCK_AUTH=password (env: AGENTLOCK_TOKEN) +``` + +- **Live ledger tail** — events stream in over SSE +- **Sessions** — open sessions with their signer tier and policy hash +- **Loaded gates** — the gates the daemon currently evaluates +- **Mode flip** — one keypress to toggle the daemon between `monitor` and `enforce` + +Rule edits and the MCP pin queue still live on the web dashboard. -The TUI on your terminal is for setup. Once installed, you should not see it during your agent loop. Approval prompts in the hot path are user-hostile; we keep them out of the agent's flow on purpose. +## Why two surfaces -The web dashboard is where you spend time *between* agent sessions: reviewing what the agent did, tightening rules, and resolving MCP pin requests. +Approval prompts in the hot path are user-hostile, so neither surface sits in the agent's flow. Both are where you spend time *between* agent sessions: reviewing what the agent did, tightening rules, and resolving MCP pin requests. The web dashboard is the full admin UI; the terminal dashboard is for when you'd rather not leave your shell. ## Access diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 1c582c0..956306f 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -118,7 +118,7 @@ Then pick a signer tier and run `install`. Two recommended paths: Pick the harnesses to harden, review the diff, confirm. The installer writes harness-specific configuration (e.g. `~/.claude/settings.json` hook entries, `~/.codex/hooks.json`, plus `codex_hooks = true` in `~/.codex/config.toml` — auto-set on first install, with a backup of the original) and registers a clean rollback path you can invoke later with `agentlock uninstall`. -Open the dashboard at to watch live activity. +Open the dashboard at to watch live activity. If you'd rather stay in the terminal, `agentlock dashboard` opens a TUI with the same ledger tail, sessions, gates, and monitor⇄enforce flip. ## What happens next diff --git a/docs/index.md b/docs/index.md index e708c8c..79e1c81 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,10 +54,10 @@ Rust crate. SHA-256 leaf hashing, Merkle root, inclusion proofs, verification. T
-#### Local web dashboard -`127.0.0.1:7879` +#### Dashboard +`127.0.0.1:7879` · `agentlock dashboard` -Read logs, author rules, watch live activity. Firewall-admin shape. +Read logs, author rules, watch live activity. Web SPA at `:7879`, or a terminal TUI via `agentlock dashboard`.
diff --git a/mkdocs.yml b/mkdocs.yml index 23b1c51..daa503f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,7 +109,7 @@ nav: - Policies and rules: guide/policies.md - Signers: guide/signers.md - The ledger: guide/ledger.md - - Local web dashboard: guide/dashboard.md + - Dashboard: guide/dashboard.md - Isolation: guide/isolation.md - MCP: guide/mcp.md - Threat model: guide/threat-model.md From bffdde199db36e992361bfbe5279939788224de5 Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Thu, 7 May 2026 18:35:36 -0700 Subject: [PATCH 5/7] Gate MCP tool calls from Claude Desktop Extensions --- control-plane/internal/api/install.go | 108 +++- .../internal/api/install_claude_desktop.go | 540 ++++++++++++++++-- .../api/install_claude_desktop_test.go | 538 ++++++++++++++++- 3 files changed, 1133 insertions(+), 53 deletions(-) diff --git a/control-plane/internal/api/install.go b/control-plane/internal/api/install.go index 96d569c..1a4e762 100644 --- a/control-plane/internal/api/install.go +++ b/control-plane/internal/api/install.go @@ -133,8 +133,8 @@ func buildPlanOps(req installPlanRequest) ([]fileOp, []string, []string) { case "claude-code": ops = append(ops, claudeCodePlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.StatusLineScript, req.HarnessConfigDirs, req.ExistingFiles)) case "claude-desktop": - op, ws := claudeDesktopPlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles) - ops = append(ops, op) + desktopOps, ws := claudeDesktopPlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles) + ops = append(ops, desktopOps...) warnings = append(warnings, ws...) case "codex": codexOps, ws := codexPlan(req.DaemonURL, req.ConfigDirOverride, req.AgentlockBinary, req.HarnessConfigDirs, req.ExistingFiles) @@ -481,7 +481,13 @@ func installApplyHandler(d Deps) http.HandlerFunc { func harnessForPath(path string) string { dir := filepath.Base(filepath.Dir(path)) switch { - case strings.HasSuffix(path, "claude_desktop_config.json"): + case strings.HasSuffix(path, "claude_desktop_config.json"), + strings.HasSuffix(path, "extensions-installations.json"): + return "claude-desktop" + case strings.HasSuffix(path, "manifest.json") && + strings.Contains(path, "/Claude Extensions/"): + // Bundle manifest of a Desktop Extension. Path shape: + // /Claude Extensions//manifest.json. return "claude-desktop" case strings.HasSuffix(path, "settings.json"): // Gemini also writes settings.json (in ~/.gemini); disambiguate @@ -682,7 +688,18 @@ func installUninstallHandler(d Deps) http.HandlerFunc { case "gemini": newBytes, removed, stripErr = stripGeminiSettings(existing) case "claude-desktop": - newBytes, removed, stripErr = stripClaudeDesktopConfig(existing) + // Three file shapes land here: + // - claude_desktop_config.json (manual mcpServers path) + // - extensions-installations.json (Desktop Extensions registry) + // - //manifest.json (bundle manifests, the actual launch source) + if strings.HasSuffix(e.SettingsPath, "extensions-installations.json") { + newBytes, removed, stripErr = stripExtensionRegistry(existing) + } else if strings.HasSuffix(e.SettingsPath, "manifest.json") && + strings.Contains(e.SettingsPath, "/Claude Extensions/") { + newBytes, removed, stripErr = stripBundleManifest(existing) + } else { + newBytes, removed, stripErr = stripClaudeDesktopConfig(existing) + } default: // Default to Claude's settings.json shape. Older manifests // without a Harness field land here, which is the right @@ -890,26 +907,81 @@ func installUninstallHarnessesHandler(d Deps) http.HandlerFunc { } ops = append(ops, op) case "claude-desktop": - p, err := claudeDesktopConfigPath(req.ConfigDirOverride, req.HarnessConfigDirs) + // Strip both files Claude Desktop install touches: the + // mcpServers config (claude_desktop_config.json) and + // the Desktop Extensions registry + // (extensions-installations.json). Each is independent + // — one can be missing without affecting the other. + cfgP, err := claudeDesktopConfigPath(req.ConfigDirOverride, req.HarnessConfigDirs) if err != nil { failures++ ops = append(ops, uninstallOp{Op: "strip", Path: "", Error: err.Error()}) - continue - } - existing := []byte(req.ExistingFiles[p]) - newBytes, removed, stripErr := stripClaudeDesktopConfig(existing) - op := uninstallOp{Op: "strip", Path: p} - if stripErr != nil { - failures++ - op.Error = stripErr.Error() - log.Printf("install.uninstall_harnesses: strip claude-desktop %s: %v", p, stripErr) } else { - op.EntriesRemoved = removed - if removed > 0 { - op.Content = string(newBytes) + cfgExisting := []byte(req.ExistingFiles[cfgP]) + newBytes, removed, stripErr := stripClaudeDesktopConfig(cfgExisting) + op := uninstallOp{Op: "strip", Path: cfgP} + if stripErr != nil { + failures++ + op.Error = stripErr.Error() + log.Printf("install.uninstall_harnesses: strip claude-desktop %s: %v", cfgP, stripErr) + } else { + op.EntriesRemoved = removed + if removed > 0 { + op.Content = string(newBytes) + } + } + ops = append(ops, op) + } + if regP, err := extensionsRegistryPath(req.ConfigDirOverride, req.HarnessConfigDirs); err == nil { + regExisting := []byte(req.ExistingFiles[regP]) + if len(regExisting) > 0 { + newBytes, removed, stripErr := stripExtensionRegistry(regExisting) + op := uninstallOp{Op: "strip", Path: regP} + if stripErr != nil { + failures++ + op.Error = stripErr.Error() + log.Printf("install.uninstall_harnesses: strip claude-desktop extensions %s: %v", regP, stripErr) + } else { + op.EntriesRemoved = removed + if removed > 0 { + op.Content = string(newBytes) + } + } + ops = append(ops, op) + } + } + // Strip every per-extension bundle manifest the CLI sent + // us. The on-disk manifest is THE launch source for + // Desktop Extensions (probed empirically) so this is + // what actually un-gates the user. + bundlesDir, _ := claudeDesktopExtensionsDir(req.ConfigDirOverride, req.HarnessConfigDirs) + if bundlesDir != "" { + absBundles := bundlesDir + if a, err := filepath.Abs(bundlesDir); err == nil { + absBundles = a + } + for path, body := range req.ExistingFiles { + if !strings.HasSuffix(path, "/manifest.json") { + continue + } + if filepath.Dir(filepath.Dir(path)) != absBundles { + continue + } + newBytes, removed, stripErr := stripBundleManifest([]byte(body)) + op := uninstallOp{Op: "strip", Path: path} + if stripErr != nil { + failures++ + op.Error = stripErr.Error() + log.Printf("install.uninstall_harnesses: strip claude-desktop bundle %s: %v", path, stripErr) + } else { + op.EntriesRemoved = removed + if removed > 0 { + op.Content = string(newBytes) + } + } + ops = append(ops, op) } } - ops = append(ops, op) default: if devHome == "" || !knownHarnessID(h) { // No real installer + not in dev mode → nothing to do. diff --git a/control-plane/internal/api/install_claude_desktop.go b/control-plane/internal/api/install_claude_desktop.go index db51f18..f3db902 100644 --- a/control-plane/internal/api/install_claude_desktop.go +++ b/control-plane/internal/api/install_claude_desktop.go @@ -233,47 +233,62 @@ func mergeClaudeDesktopConfig(existing []byte, daemonURL, agentlockBinary string return json.MarshalIndent(cfg, "", " ") } -// claudeDesktopPlan returns the merged config the CLI should write. When -// existingFiles[configPath] is unset we emit a fresh config carrying -// only the agentlock entry; otherwise we merge against the supplied -// bytes so user-set mcpServers + any other top-level keys survive. -func claudeDesktopPlan(daemonURL, configDirOverride, agentlockBinary string, overrides map[string]string, existingFiles map[string]string) (fileOp, []string) { +// claudeDesktopPlan returns the merged file ops the CLI should execute. +// +// Two surfaces: +// +// 1. claude_desktop_config.json — the manual mcpServers JSON path. +// Each entry is rewritten through `agentlock mcp-proxy --name +// -- `; original preserved under +// `_agentlock_original` for clean restore. +// +// 2. Per-extension bundle manifests under +// `/Claude Extensions//manifest.json` — the +// actual launch source for Desktop Extensions installed via +// Settings → Extensions UI. Claude Desktop's manifest schema +// validator is strict (`additionalProperties: false` everywhere), +// so wrap markers can't live in the legacy `mcp_config._agentlock` +// slot. Instead we use the schema-blessed root-level `_meta` +// object (added in MCPB v0.3) — `_meta.agentlock.{wrapped, +// original_command, original_args, original_env, +// original_manifest_version}`. Manifest versions 0.1 / 0.2 (or +// missing) are bumped to 0.3 on wrap; the original is stashed for +// restore. Rewriting `mcp_config.command` to a non-`node`/non- +// `python` binary defeats Claude Desktop's UtilityProcess +// short-circuit and forces basic-execution spawn — that's how the +// proxy ends up in the byte path. +// +// The Desktop Extensions registry (`extensions-installations.json`) +// is intentionally NOT wrapped — empirical probing (May 2026) showed +// it's an audit record only, not the launch source. Wrapping it +// would be dead text and risks fighting Anthropic's auto-update +// hash check. mergeExtensionRegistry / stripExtensionRegistry remain +// in this file as defensive helpers for cleanup of any stale wrap +// state, but the install path never emits a registry write op. +func claudeDesktopPlan(daemonURL, configDirOverride, agentlockBinary string, overrides map[string]string, existingFiles map[string]string) ([]fileOp, []string) { warnings := []string{ - // Honest scope statement. We gate the MCP slice (every tools/call - // to a user-installed MCP server or .mcpb Desktop Extension), but - // Claude Desktop has additional local capabilities that don't go - // through MCP and are NOT gated by this install: - // - Computer Use (direct mouse/keyboard control) - // - Cowork's non-MCP agentic paths (where applicable) - // - Integrated terminal command execution - // - Native connectors (Slack, Google Calendar, etc.) - // - Server-side features (web search, code interpreter) - // Documented in docs/status.md so dashboard / report can't - // overstate coverage. - "claude-desktop: agentlock gates MCP tool calls only — every user-installed MCP server and .mcpb Desktop Extension is wrapped. NOT gated: Computer Use, integrated terminal, native connectors (Slack/GCal), Cowork's non-MCP paths, and Anthropic cloud features (web search, code interpreter). For full local enforcement, use Claude Code instead.", + "claude-desktop: agentlock gates MCP tool calls for both manual mcpServers entries (claude_desktop_config.json) and Desktop Extensions installed via Settings → Extensions UI. Each per-extension bundle manifest is rewritten in place; auto-updates from Anthropic may overwrite the wrap on extension version bumps — re-run `agentlock install` after extension updates. Other surfaces remain out of scope: Computer Use, integrated terminal, native connectors (Slack/GCal), Cowork's non-MCP paths, server-side cloud features. For full local enforcement of an agent harness, use Claude Code.", } + ops := make([]fileOp, 0, 4) + + // claude_desktop_config.json — manual mcpServers path. configPath, err := claudeDesktopConfigPath(configDirOverride, overrides) if err != nil { configPath = "" } - abs := configPath + cfgAbs := configPath if a, err := filepath.Abs(configPath); err == nil { - abs = a + cfgAbs = a } - - var existing []byte - backupPath := "" - if c, ok := existingFiles[abs]; ok && c != "" { - existing = []byte(c) - backupPath = fmt.Sprintf("%s.agentlock-backup-%d", abs, time.Now().UnixNano()) + var cfgExisting []byte + cfgBackup := "" + if c, ok := existingFiles[cfgAbs]; ok && c != "" { + cfgExisting = []byte(c) + cfgBackup = fmt.Sprintf("%s.agentlock-backup-%d", cfgAbs, time.Now().UnixNano()) } - - merged, mergeErr := mergeClaudeDesktopConfig(existing, daemonURL, agentlockBinary) + merged, mergeErr := mergeClaudeDesktopConfig(cfgExisting, daemonURL, agentlockBinary) if mergeErr != nil { - // Existing config was unparseable. Fall back to an agentlock-only - // payload so the install still produces something usable; the - // CLI will surface the parse error when it diffs against existing. fresh := map[string]any{ "mcpServers": map[string]any{ claudeDesktopServerName: claudeDesktopServerEntry(daemonURL, agentlockBinary), @@ -281,13 +296,470 @@ func claudeDesktopPlan(daemonURL, configDirOverride, agentlockBinary string, ove } merged, _ = json.MarshalIndent(fresh, "", " ") } - return fileOp{ + ops = append(ops, fileOp{ Op: "write", - Path: abs, + Path: cfgAbs, Content: string(merged), Reason: fmt.Sprintf("register agentlock as MCP server in Claude Desktop → %s (no PreToolUse upstream)", strings.TrimRight(daemonURL, "/")), - BackupPath: backupPath, - }, warnings + BackupPath: cfgBackup, + }) + + // Desktop Extension bundle manifests — _meta.agentlock wrap. + bundlesDir, _ := claudeDesktopExtensionsDir(configDirOverride, overrides) + if bundlesDir != "" { + registryAbsPath, _ := extensionsRegistryPath(configDirOverride, overrides) + settings := collectExtensionSettings(existingFiles, registryAbsPath) + ops = append(ops, bundleManifestOps(bundlesDir, daemonURL, agentlockBinary, settings, existingFiles)...) + } + + return ops, warnings +} + +// claudeDesktopExtensionsDir returns the absolute path of +// "/Claude Extensions". Sibling of the registry — same +// override / dev-home / host-fallback resolution rules. +func claudeDesktopExtensionsDir(configDirOverride string, overrides map[string]string) (string, error) { + cfg, err := claudeDesktopConfigPath(configDirOverride, overrides) + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(cfg), "Claude Extensions"), nil +} + +// bundleManifestOps walks existingFiles for any path of the form +// "//manifest.json" and emits a wrap op for each +// matching extension that isn't disabled. Disabled extensions are +// either skipped (already-unwrapped) or unwrapped (if they were +// wrapped on a prior install). The CLI is responsible for sending +// each bundle manifest's contents in existing_files; manifests not +// supplied are silently skipped. +func bundleManifestOps(bundlesDir, daemonURL, agentlockBinary string, settings map[string]extensionSettings, existingFiles map[string]string) []fileOp { + abs := bundlesDir + if a, err := filepath.Abs(bundlesDir); err == nil { + abs = a + } + var ops []fileOp + for path, body := range existingFiles { + if !strings.HasSuffix(path, "/manifest.json") { + continue + } + // path layout: //manifest.json + extDir := filepath.Dir(path) + if filepath.Dir(extDir) != abs { + continue + } + extID := filepath.Base(extDir) + + merged, ok := mergeBundleManifest([]byte(body), extID, daemonURL, agentlockBinary, settings) + if !ok { + continue + } + ops = append(ops, fileOp{ + Op: "write", + Path: path, + Content: string(merged), + Reason: fmt.Sprintf("wrap Desktop Extension bundle %q → %s", extID, strings.TrimRight(daemonURL, "/")), + BackupPath: fmt.Sprintf("%s.agentlock-backup-%d", path, time.Now().UnixNano()), + }) + } + return ops +} + +// mergeBundleManifest parses one extension's on-disk manifest.json, +// applies wrapManifest (or restoreManifest when the extension is now +// disabled), and returns the new bytes. Returns ok=false when the +// manifest is unparseable or there's nothing to do (e.g. disabled +// extension that was never wrapped — no write op needed). +func mergeBundleManifest(existing []byte, extID, daemonURL, agentlockBinary string, settings map[string]extensionSettings) ([]byte, bool) { + manifest := map[string]any{} + if err := json.Unmarshal(existing, &manifest); err != nil { + return nil, false + } + + // Disabled extensions: unwind any prior wrap, no-op otherwise. + // Leaving an unmodified disabled extension untouched avoids a + // pointless write op every install run. + if s, ok := settings[extID]; ok && !s.IsEnabled { + if !restoreManifest(manifest) { + return nil, false + } + out, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, false + } + return out, true + } + + if !wrapManifest(manifest, extID, daemonURL, agentlockBinary) { + return nil, false + } + out, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, false + } + return out, true +} + +// stripBundleManifest reverses the wrap on a single bundle manifest. +// Returns the new bytes plus 1 if a wrap was undone, 0 otherwise. +// Pure: no disk I/O. +func stripBundleManifest(existing []byte) ([]byte, int, error) { + if len(existing) == 0 { + return nil, 0, nil + } + manifest := map[string]any{} + if err := json.Unmarshal(existing, &manifest); err != nil { + return nil, 0, fmt.Errorf("parse bundle manifest: %w", err) + } + if !restoreManifest(manifest) { + return nil, 0, nil + } + out, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, 0, fmt.Errorf("marshal: %w", err) + } + return out, 1, nil +} + +// extensionsRegistryPath sits next to claude_desktop_config.json in the +// same Claude support dir. Anthropic stores the install record for every +// Desktop Extension here; the per-extension settings (isEnabled, +// userConfig) live one dir over in "Claude Extensions Settings/". +func extensionsRegistryPath(configDirOverride string, overrides map[string]string) (string, error) { + cfg, err := claudeDesktopConfigPath(configDirOverride, overrides) + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(cfg), "extensions-installations.json"), nil +} + +// extensionSettings is the parsed shape of one +// "Claude Extensions Settings/.json" file. We only need isEnabled +// today; userConfig is preserved on disk by Claude Desktop and not our +// concern. +type extensionSettings struct { + IsEnabled bool `json:"isEnabled"` +} + +// collectExtensionSettings walks existingFiles for any path that looks +// like a Claude Extensions Settings/.json sibling of the registry +// path, parses each, and returns a map keyed by extension id. Missing +// or unparseable entries default to enabled — same behavior Claude +// Desktop seems to take when the settings file is absent on first run +// (we err on the side of wrapping; a disabled extension that we wrap +// is harmless because it's never spawned anyway). +func collectExtensionSettings(existingFiles map[string]string, registryAbsPath string) map[string]extensionSettings { + out := map[string]extensionSettings{} + settingsDir := filepath.Join(filepath.Dir(registryAbsPath), "Claude Extensions Settings") + for path, body := range existingFiles { + if filepath.Dir(path) != settingsDir { + continue + } + base := filepath.Base(path) + if !strings.HasSuffix(base, ".json") { + continue + } + extID := strings.TrimSuffix(base, ".json") + var parsed extensionSettings + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + // Default to enabled on parse error — matches the "wrap + // unless explicitly disabled" posture above. + parsed.IsEnabled = true + } + out[extID] = parsed + } + return out +} + +// wrapManifest rewrites one Desktop Extension manifest in place to +// route MCP traffic through `agentlock mcp-proxy --name -- +// `. The schema-blessed `_meta.agentlock` slot +// (MCPB v0.3+) carries the originals so a strip can restore them. +// +// Two non-obvious moves: +// +// - manifest_version is bumped from "0.1" / "0.2" / missing → "0.3" +// when needed, so the validator accepts our `_meta` block. The +// original is stashed under _meta.agentlock.original_manifest_version +// (or _meta.agentlock.original_manifest_version_absent when the +// field was missing) for byte-clean restore. +// +// - mcp_config.command is rewritten to the agentlock binary path +// (NOT "node" / "python"), which forces Claude Desktop's runtime +// into the basic-execution branch. Otherwise, for type=node / +// type=python extensions, Claude Desktop short-circuits via +// UtilityProcess (Electron's built-in node) and would bypass our +// wrap entirely. Empirically verified May 2026. +// +// Template variables in args (`${__dirname}`, +// `${user_config.}`) pass through verbatim — Claude Desktop +// expands them at launch, the proxy spawns whatever it's handed. +// +// Returns true if the wrap was applied; false on shape mismatch +// (no `server.mcp_config`). Idempotent: re-wrapping reads the prior +// _meta.agentlock to recover real originals first. +func wrapManifest(manifest map[string]any, extID, daemonURL, agentlockBinary string) bool { + server, ok := manifest["server"].(map[string]any) + if !ok { + return false + } + mcp, ok := server["mcp_config"].(map[string]any) + if !ok { + return false + } + + bin := claudeCodeBinary(agentlockBinary) + daemonURL = strings.TrimRight(daemonURL, "/") + + // Recover prior originals (if previously wrapped) so a re-wrap + // reflects the user's true source state, not our wrapper. + meta, _ := manifest["_meta"].(map[string]any) + prior, _ := meta["agentlock"].(map[string]any) + + var ( + origCmd string + origArgs []any + origEnv map[string]any + origManifestVersion string + origManifestVersionMiss bool + ) + if prior != nil { + origCmd, _ = prior["original_command"].(string) + origArgs, _ = prior["original_args"].([]any) + origEnv, _ = prior["original_env"].(map[string]any) + origManifestVersion, _ = prior["original_manifest_version"].(string) + if a, ok := prior["original_manifest_version_absent"].(bool); ok && a { + origManifestVersionMiss = true + } + } else { + origCmd, _ = mcp["command"].(string) + origArgs, _ = mcp["args"].([]any) + origEnv, _ = mcp["env"].(map[string]any) + } + + args := []any{"mcp-proxy", "--name", extID, "--", origCmd} + args = append(args, origArgs...) + + env := map[string]any{} + for k, v := range origEnv { + env[k] = v + } + if _, ok := env["AGENTLOCK_DAEMON_URL"]; !ok { + env["AGENTLOCK_DAEMON_URL"] = daemonURL + } + + newMcp := map[string]any{ + "command": bin, + "args": args, + } + if len(env) > 0 { + newMcp["env"] = env + } + server["mcp_config"] = newMcp + + // Bump manifest_version if the current value would reject _meta. + // MCPB v0.3 added the field; v0.1 and v0.2 schemas are + // additionalProperties:false at root and would reject it. + mv, hasMV := manifest["manifest_version"].(string) + shouldBump := !hasMV || mv == "0.1" || mv == "0.2" + if shouldBump { + // Stash the original on the FIRST wrap. Re-wrap re-uses what + // we already recovered above from prior so we don't lose the + // real source over multiple install runs. + if prior == nil { + if hasMV { + origManifestVersion = mv + } else { + origManifestVersionMiss = true + } + } + manifest["manifest_version"] = "0.3" + } + + // dxt_version is the deprecated alias for manifest_version (kept for + // older bundles that haven't migrated yet, e.g. Anthropic's own + // Control Chrome ships dxt_version "0.1" with no manifest_version). + // The v0.3 schema pins it to const "0.3" when present, so a stale + // value must be bumped in lockstep or the validator rejects the + // whole manifest with `dxt_version: Invalid literal value`. + var ( + origDxtVersion string + origDxtVersionMiss bool + ) + if prior != nil { + origDxtVersion, _ = prior["original_dxt_version"].(string) + if a, ok := prior["original_dxt_version_absent"].(bool); ok && a { + origDxtVersionMiss = true + } + } + dxt, hasDxt := manifest["dxt_version"].(string) + if hasDxt && dxt != "0.3" { + if prior == nil { + origDxtVersion = dxt + } + manifest["dxt_version"] = "0.3" + } else if !hasDxt && prior == nil { + origDxtVersionMiss = true + } + + if meta == nil { + meta = map[string]any{} + } + agentlockMeta := map[string]any{ + "wrapped": true, + "original_command": origCmd, + "original_args": origArgs, + } + if len(origEnv) > 0 { + agentlockMeta["original_env"] = origEnv + } + if origManifestVersion != "" { + agentlockMeta["original_manifest_version"] = origManifestVersion + } + if origManifestVersionMiss { + agentlockMeta["original_manifest_version_absent"] = true + } + if origDxtVersion != "" { + agentlockMeta["original_dxt_version"] = origDxtVersion + } + if origDxtVersionMiss { + agentlockMeta["original_dxt_version_absent"] = true + } + meta["agentlock"] = agentlockMeta + manifest["_meta"] = meta + + return true +} + +// restoreManifest is wrapManifest's inverse: reads _meta.agentlock, +// restores server.mcp_config + manifest_version to their pre-wrap +// values, and deletes the agentlock namespace from _meta. If _meta +// has no other namespaces left it's removed entirely so the manifest +// returns to byte-equivalent shape on a v0.3 host. Returns true if +// a wrap was undone. +func restoreManifest(manifest map[string]any) bool { + meta, _ := manifest["_meta"].(map[string]any) + if meta == nil { + return false + } + agentlockMeta, _ := meta["agentlock"].(map[string]any) + if agentlockMeta == nil { + return false + } + server, _ := manifest["server"].(map[string]any) + if server == nil { + return false + } + + origCmd, _ := agentlockMeta["original_command"].(string) + origArgs, _ := agentlockMeta["original_args"].([]any) + origEnv, _ := agentlockMeta["original_env"].(map[string]any) + + restoredMcp := map[string]any{ + "command": origCmd, + "args": origArgs, + } + if len(origEnv) > 0 { + restoredMcp["env"] = origEnv + } + server["mcp_config"] = restoredMcp + + if origMV, ok := agentlockMeta["original_manifest_version"].(string); ok && origMV != "" { + manifest["manifest_version"] = origMV + } else if a, ok := agentlockMeta["original_manifest_version_absent"].(bool); ok && a { + delete(manifest, "manifest_version") + } + + if origDxt, ok := agentlockMeta["original_dxt_version"].(string); ok && origDxt != "" { + manifest["dxt_version"] = origDxt + } else if a, ok := agentlockMeta["original_dxt_version_absent"].(bool); ok && a { + delete(manifest, "dxt_version") + } + + delete(meta, "agentlock") + if len(meta) == 0 { + delete(manifest, "_meta") + } + + return true +} + +// mergeExtensionRegistry walks each entry in extensions-installations.json +// and applies wrapManifest to its nested manifest. The registry is +// not the launch source (claudeDesktopPlan does not call this today) +// but the helper stays around so a future install pipeline that +// needs registry coherency can rely on it. Disabled extensions are +// unwrapped if previously wrapped, otherwise left alone. +// +// Idempotent: wrapManifest reads any prior _meta.agentlock first. +func mergeExtensionRegistry(existing []byte, daemonURL, agentlockBinary string, settings map[string]extensionSettings) ([]byte, error) { + if len(existing) == 0 { + return nil, fmt.Errorf("empty registry") + } + root := map[string]any{} + if err := json.Unmarshal(existing, &root); err != nil { + return nil, fmt.Errorf("parse extensions registry: %w", err) + } + extensions, _ := root["extensions"].(map[string]any) + if extensions == nil { + return json.MarshalIndent(root, "", " ") + } + + for extID, raw := range extensions { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + manifest, ok := entry["manifest"].(map[string]any) + if !ok { + continue + } + if s, ok := settings[extID]; ok && !s.IsEnabled { + restoreManifest(manifest) + continue + } + wrapManifest(manifest, extID, daemonURL, agentlockBinary) + } + return json.MarshalIndent(root, "", " ") +} + +// stripExtensionRegistry reverses the wrap for uninstall. Each +// extension whose nested manifest carries _meta.agentlock is +// restored. Non-wrapped entries pass through. Returns (newBytes, +// removalCount, error). +func stripExtensionRegistry(existing []byte) ([]byte, int, error) { + if len(existing) == 0 { + return nil, 0, nil + } + root := map[string]any{} + if err := json.Unmarshal(existing, &root); err != nil { + return nil, 0, fmt.Errorf("parse extensions registry: %w", err) + } + extensions, _ := root["extensions"].(map[string]any) + if extensions == nil { + return nil, 0, nil + } + + removed := 0 + for _, raw := range extensions { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + manifest, ok := entry["manifest"].(map[string]any) + if !ok { + continue + } + if restoreManifest(manifest) { + removed++ + } + } + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, 0, fmt.Errorf("marshal: %w", err) + } + return out, removed, nil } // stripClaudeDesktopConfig reverses the wrap. For each entry tagged diff --git a/control-plane/internal/api/install_claude_desktop_test.go b/control-plane/internal/api/install_claude_desktop_test.go index ca2f429..6b3c4a5 100644 --- a/control-plane/internal/api/install_claude_desktop_test.go +++ b/control-plane/internal/api/install_claude_desktop_test.go @@ -221,11 +221,14 @@ func TestClaudeDesktopPlan_WarningsCarryEnforcementCaveat(t *testing.T) { } // TestHarnessForPath_ClaudeDesktop ensures the manifest uninstall path -// dispatches strip ops back to stripClaudeDesktopConfig. +// dispatches strip ops back to the right helper. Both +// claude_desktop_config.json and extensions-installations.json must +// resolve to "claude-desktop" so the uninstall switch can find them. func TestHarnessForPath_ClaudeDesktop(t *testing.T) { cases := []struct{ path, want string }{ {"/Users/x/Library/Application Support/Claude/claude_desktop_config.json", "claude-desktop"}, {"/home/x/AppData/Roaming/Claude/claude_desktop_config.json", "claude-desktop"}, + {"/Users/x/Library/Application Support/Claude/extensions-installations.json", "claude-desktop"}, {"/Users/x/.claude/settings.json", "claude-code"}, } for _, c := range cases { @@ -235,3 +238,536 @@ func TestHarnessForPath_ClaudeDesktop(t *testing.T) { } } } + +// --- Desktop Extensions registry tests -------------------------------- + +// sampleRegistry returns a canonical extensions-installations.json +// fixture matching the real shape Claude Desktop writes (registry-side +// metadata + nested manifest + mcp_config). One enabled extension and +// one disabled extension cover the wrap/skip dispatch. The fixture +// uses manifest_version "0.2" because that's what Anthropic's +// real-world Filesystem extension ships with — wrap must bump it to +// "0.3" so the _meta slot is schema-valid. +func sampleRegistry(t *testing.T) []byte { + t.Helper() + return []byte(`{ + "extensions": { + "ant.dir.ant.anthropic.filesystem": { + "id": "ant.dir.ant.anthropic.filesystem", + "version": "0.2.2", + "hash": "504c1ac54eee79c4592c568e63790edf8713d12c8676507b6ce33a003172368c", + "installedAt": "2026-05-07T06:05:05.968Z", + "manifest": { + "manifest_version": "0.2", + "name": "Filesystem", + "server": { + "type": "node", + "entry_point": "dist/index.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/dist/index.js", "${user_config.allowed_directories}"] + } + } + }, + "signatureInfo": {"status": "unsigned"}, + "source": "registry" + }, + "ant.dir.disabled-thing": { + "id": "ant.dir.disabled-thing", + "version": "0.0.1", + "manifest": { + "manifest_version": "0.2", + "server": { + "mcp_config": {"command": "node", "args": ["server.js"]} + } + }, + "source": "registry" + } + } + }`) +} + +// TestExtensionRegistry_WrapsEnabledEntries: one extension enabled, +// one disabled — only the enabled one gets wrapped, the disabled one +// is left alone. +func TestExtensionRegistry_WrapsEnabledEntries(t *testing.T) { + settings := map[string]extensionSettings{ + "ant.dir.ant.anthropic.filesystem": {IsEnabled: true}, + "ant.dir.disabled-thing": {IsEnabled: false}, + } + out, err := mergeExtensionRegistry(sampleRegistry(t), "http://127.0.0.1:7878", "agentlock", settings) + if err != nil { + t.Fatalf("merge: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + exts, _ := got["extensions"].(map[string]any) + + // Enabled extension must be wrapped. + enabled, _ := exts["ant.dir.ant.anthropic.filesystem"].(map[string]any) + manifest := enabled["manifest"].(map[string]any) + if manifest["manifest_version"] != "0.3" { + t.Fatalf("enabled extension manifest_version not bumped to 0.3: %v", manifest["manifest_version"]) + } + mcp := manifest["server"].(map[string]any)["mcp_config"].(map[string]any) + if mcp["command"] != "agentlock" { + t.Fatalf("enabled extension command not wrapped: %v", mcp["command"]) + } + args, _ := mcp["args"].([]any) + if len(args) < 5 || args[0] != "mcp-proxy" || args[1] != "--name" || args[2] != "ant.dir.ant.anthropic.filesystem" || args[3] != "--" || args[4] != "node" { + t.Fatalf("enabled extension args not wrapped correctly: %+v", args) + } + // Markers live under _meta.agentlock at the manifest root, not + // inside mcp_config (the v0.3 schema is additionalProperties:false + // on mcp_config). + if _, ok := mcp["_agentlock_original"]; ok { + t.Fatalf("legacy _agentlock_original leaked into mcp_config: %+v", mcp) + } + meta, _ := manifest["_meta"].(map[string]any) + agentlockMeta, _ := meta["agentlock"].(map[string]any) + if agentlockMeta == nil { + t.Fatalf("enabled extension missing _meta.agentlock block") + } + if agentlockMeta["original_command"] != "node" { + t.Fatalf("_meta.agentlock.original_command wrong: %v", agentlockMeta["original_command"]) + } + if agentlockMeta["original_manifest_version"] != "0.2" { + t.Fatalf("_meta.agentlock.original_manifest_version not stashed: %v", agentlockMeta["original_manifest_version"]) + } + + // Disabled extension must be untouched (still raw "node", no _meta). + disabled, _ := exts["ant.dir.disabled-thing"].(map[string]any) + dManifest := disabled["manifest"].(map[string]any) + dmcp := dManifest["server"].(map[string]any)["mcp_config"].(map[string]any) + if dmcp["command"] != "node" { + t.Fatalf("disabled extension was wrapped (should be untouched): %v", dmcp) + } + if dManifest["manifest_version"] != "0.2" { + t.Fatalf("disabled extension manifest_version was modified: %v", dManifest["manifest_version"]) + } + if _, ok := dManifest["_meta"]; ok { + t.Fatalf("disabled extension grew a _meta block: %+v", dManifest) + } +} + +// TestExtensionRegistry_PreservesUnknownKeys: registry entries carry +// audit trail fields (hash, installedAt, signatureInfo, source) that +// Claude Desktop uses for update checks and signature verification. +// Wrap + strip MUST round-trip these unchanged or we'd break Anthropic's +// trust path. +func TestExtensionRegistry_PreservesUnknownKeys(t *testing.T) { + settings := map[string]extensionSettings{ + "ant.dir.ant.anthropic.filesystem": {IsEnabled: true}, + "ant.dir.disabled-thing": {IsEnabled: false}, + } + wrapped, err := mergeExtensionRegistry(sampleRegistry(t), "http://127.0.0.1:7878", "agentlock", settings) + if err != nil { + t.Fatalf("merge: %v", err) + } + var got map[string]any + _ = json.Unmarshal(wrapped, &got) + exts := got["extensions"].(map[string]any) + enabled := exts["ant.dir.ant.anthropic.filesystem"].(map[string]any) + if enabled["hash"] != "504c1ac54eee79c4592c568e63790edf8713d12c8676507b6ce33a003172368c" { + t.Fatalf("hash field lost: %v", enabled["hash"]) + } + if enabled["installedAt"] != "2026-05-07T06:05:05.968Z" { + t.Fatalf("installedAt field lost: %v", enabled["installedAt"]) + } + if enabled["source"] != "registry" { + t.Fatalf("source field lost: %v", enabled["source"]) + } + sig, _ := enabled["signatureInfo"].(map[string]any) + if sig == nil || sig["status"] != "unsigned" { + t.Fatalf("signatureInfo lost: %v", enabled["signatureInfo"]) + } +} + +// TestExtensionRegistry_Idempotent: re-running install (same daemon +// URL, same settings) on already-wrapped registry produces byte- +// identical output. Drift-correcting: a user who edited the wrapped +// args is reset to the canonical wrap on re-run. +func TestExtensionRegistry_Idempotent(t *testing.T) { + settings := map[string]extensionSettings{ + "ant.dir.ant.anthropic.filesystem": {IsEnabled: true}, + "ant.dir.disabled-thing": {IsEnabled: false}, + } + first, err := mergeExtensionRegistry(sampleRegistry(t), "http://127.0.0.1:7878", "agentlock", settings) + if err != nil { + t.Fatalf("first merge: %v", err) + } + second, err := mergeExtensionRegistry(first, "http://127.0.0.1:7878", "agentlock", settings) + if err != nil { + t.Fatalf("second merge: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatalf("non-idempotent re-wrap:\nfirst: %s\nsecond: %s", first, second) + } +} + +// TestExtensionRegistry_StripRestoresOriginal: wrap then strip yields +// an mcp_config + manifest_version equivalent to the pre-wrap original. +// We don't compare byte-for-byte against sampleRegistry because the +// json.MarshalIndent roundtrip may reorder keys. +func TestExtensionRegistry_StripRestoresOriginal(t *testing.T) { + // Mark the second extension explicitly disabled so it doesn't get + // wrapped (default-when-missing is enabled, see collectExtensionSettings). + settings := map[string]extensionSettings{ + "ant.dir.ant.anthropic.filesystem": {IsEnabled: true}, + "ant.dir.disabled-thing": {IsEnabled: false}, + } + wrapped, err := mergeExtensionRegistry(sampleRegistry(t), "http://127.0.0.1:7878", "agentlock", settings) + if err != nil { + t.Fatalf("merge: %v", err) + } + stripped, removed, err := stripExtensionRegistry(wrapped) + if err != nil { + t.Fatalf("strip: %v", err) + } + if removed != 1 { + t.Fatalf("expected 1 entry restored, got %d", removed) + } + + var got map[string]any + _ = json.Unmarshal(stripped, &got) + exts := got["extensions"].(map[string]any) + enabled := exts["ant.dir.ant.anthropic.filesystem"].(map[string]any) + manifest := enabled["manifest"].(map[string]any) + if manifest["manifest_version"] != "0.2" { + t.Fatalf("strip didn't restore manifest_version: %v", manifest["manifest_version"]) + } + mcp := manifest["server"].(map[string]any)["mcp_config"].(map[string]any) + if mcp["command"] != "node" { + t.Fatalf("strip didn't restore command: %v", mcp["command"]) + } + if _, ok := manifest["_meta"]; ok { + t.Fatalf("_meta leaked through strip: %+v", manifest) + } +} + +// TestExtensionRegistry_DisabledExtensionUnwindsExistingWrap: if an +// extension was wrapped at install time and then disabled by the user +// before re-running install, the next install pass should unwind the +// wrap so the on-disk manifest stops routing through agentlock. +func TestExtensionRegistry_DisabledExtensionUnwindsExistingWrap(t *testing.T) { + enabledFirst := map[string]extensionSettings{ + "ant.dir.ant.anthropic.filesystem": {IsEnabled: true}, + "ant.dir.disabled-thing": {IsEnabled: false}, + } + wrapped, err := mergeExtensionRegistry(sampleRegistry(t), "http://127.0.0.1:7878", "agentlock", enabledFirst) + if err != nil { + t.Fatalf("first merge: %v", err) + } + // Now flip the formerly-enabled extension to disabled and re-merge. + disabledNow := map[string]extensionSettings{ + "ant.dir.ant.anthropic.filesystem": {IsEnabled: false}, + "ant.dir.disabled-thing": {IsEnabled: false}, + } + out, err := mergeExtensionRegistry(wrapped, "http://127.0.0.1:7878", "agentlock", disabledNow) + if err != nil { + t.Fatalf("second merge: %v", err) + } + var got map[string]any + _ = json.Unmarshal(out, &got) + exts := got["extensions"].(map[string]any) + fs := exts["ant.dir.ant.anthropic.filesystem"].(map[string]any) + manifest := fs["manifest"].(map[string]any) + mcp := manifest["server"].(map[string]any)["mcp_config"].(map[string]any) + if mcp["command"] != "node" { + t.Fatalf("disabling didn't unwind wrap: command=%v", mcp["command"]) + } + if manifest["manifest_version"] != "0.2" { + t.Fatalf("disabling didn't restore manifest_version: %v", manifest["manifest_version"]) + } +} + +// --- Bundle manifest tests (the actual launch-source path) ----------- + +// sampleBundleManifest mirrors a real Claude Desktop Extension bundle +// manifest.json — the file under /Claude Extensions// +// that Claude Desktop reads to spawn the extension's MCP server. +// manifest_version "0.2" is what Anthropic's filesystem extension +// ships today; wrap must bump it to "0.3" so _meta validates. +func sampleBundleManifest(t *testing.T) []byte { + t.Helper() + return []byte(`{ + "manifest_version": "0.2", + "name": "Filesystem", + "version": "0.2.2", + "description": "Read and write files.", + "author": {"name": "Anthropic"}, + "server": { + "type": "node", + "entry_point": "dist/index.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/dist/index.js", "${user_config.allowed_directories}"] + } + } + }`) +} + +// TestBundleManifest_WrapsAndBumpsManifestVersion is the load-bearing +// test for Desktop Extension gating: the wrap rewrites mcp_config to +// route through agentlock, parks the original under _meta.agentlock, +// and bumps manifest_version 0.2 → 0.3 so the _meta block is schema- +// valid against Claude Desktop's MCPB validator. +func TestBundleManifest_WrapsAndBumpsManifestVersion(t *testing.T) { + out, ok := mergeBundleManifest(sampleBundleManifest(t), + "ant.dir.ant.anthropic.filesystem", + "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("merge returned ok=false") + } + var manifest map[string]any + if err := json.Unmarshal(out, &manifest); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if manifest["manifest_version"] != "0.3" { + t.Fatalf("manifest_version not bumped to 0.3: %v", manifest["manifest_version"]) + } + mcp := manifest["server"].(map[string]any)["mcp_config"].(map[string]any) + if mcp["command"] != "agentlock" { + t.Fatalf("command not rewritten to agentlock: %v", mcp["command"]) + } + args, _ := mcp["args"].([]any) + if len(args) < 5 || args[0] != "mcp-proxy" || args[3] != "--" || args[4] != "node" { + t.Fatalf("args not wrapped correctly: %+v", args) + } + // No legacy markers under mcp_config (would fail v0.3 schema). + if _, ok := mcp["_agentlock_original"]; ok { + t.Fatalf("legacy _agentlock_original leaked into mcp_config: %+v", mcp) + } + agentlockMeta, _ := manifest["_meta"].(map[string]any)["agentlock"].(map[string]any) + if agentlockMeta == nil { + t.Fatalf("missing _meta.agentlock block") + } + if agentlockMeta["original_command"] != "node" { + t.Fatalf("original_command not stashed: %v", agentlockMeta["original_command"]) + } + if agentlockMeta["original_manifest_version"] != "0.2" { + t.Fatalf("original_manifest_version not stashed: %v", agentlockMeta["original_manifest_version"]) + } +} + +// TestBundleManifest_StripRestoresPreInstallShape: wrap + strip returns +// the manifest to its pre-wrap mcp_config + manifest_version, with no +// _meta.agentlock leakage. +func TestBundleManifest_StripRestoresPreInstallShape(t *testing.T) { + wrapped, ok := mergeBundleManifest(sampleBundleManifest(t), + "ant.dir.ant.anthropic.filesystem", + "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("merge returned ok=false") + } + stripped, removed, err := stripBundleManifest(wrapped) + if err != nil { + t.Fatalf("strip: %v", err) + } + if removed != 1 { + t.Fatalf("expected 1 wrap undone, got %d", removed) + } + var manifest map[string]any + _ = json.Unmarshal(stripped, &manifest) + if manifest["manifest_version"] != "0.2" { + t.Fatalf("strip didn't restore manifest_version: %v", manifest["manifest_version"]) + } + mcp := manifest["server"].(map[string]any)["mcp_config"].(map[string]any) + if mcp["command"] != "node" { + t.Fatalf("strip didn't restore command: %v", mcp["command"]) + } + if _, ok := manifest["_meta"]; ok { + t.Fatalf("_meta leaked through strip: %+v", manifest) + } +} + +// TestBundleManifest_Idempotent: re-running merge on already-wrapped +// bytes produces byte-identical output. Drift-correcting too — a user +// who edited the wrapped command directly is reset to the canonical +// wrap on re-run because wrapManifest reads _meta.agentlock first to +// recover the real source state. +func TestBundleManifest_Idempotent(t *testing.T) { + first, ok := mergeBundleManifest(sampleBundleManifest(t), + "ant.dir.ant.anthropic.filesystem", + "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("first merge ok=false") + } + second, ok := mergeBundleManifest(first, + "ant.dir.ant.anthropic.filesystem", + "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("second merge ok=false") + } + if !bytes.Equal(first, second) { + t.Fatalf("non-idempotent re-wrap:\nfirst: %s\nsecond: %s", first, second) + } +} + +// TestBundleManifest_PreservesOtherMetaNamespaces: if the manifest +// already has _meta entries from another vendor, our wrap (and our +// strip) must leave them alone — _meta is a shared two-level +// namespace, so collisions would corrupt unrelated tooling state. +func TestBundleManifest_PreservesOtherMetaNamespaces(t *testing.T) { + src := []byte(`{ + "manifest_version": "0.3", + "name": "Filesystem", + "version": "0.2.2", + "description": "Read and write files.", + "author": {"name": "Anthropic"}, + "_meta": {"some.other.tool": {"key": "value"}}, + "server": { + "type": "node", + "entry_point": "dist/index.js", + "mcp_config": { + "command": "node", + "args": ["dist/index.js"] + } + } + }`) + wrapped, ok := mergeBundleManifest(src, "ext.id", "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("merge ok=false") + } + var got map[string]any + _ = json.Unmarshal(wrapped, &got) + meta := got["_meta"].(map[string]any) + if _, ok := meta["agentlock"]; !ok { + t.Fatalf("our agentlock namespace missing") + } + other, _ := meta["some.other.tool"].(map[string]any) + if other == nil || other["key"] != "value" { + t.Fatalf("unrelated _meta namespace lost: %+v", meta) + } + + stripped, _, err := stripBundleManifest(wrapped) + if err != nil { + t.Fatalf("strip: %v", err) + } + var after map[string]any + _ = json.Unmarshal(stripped, &after) + afterMeta, _ := after["_meta"].(map[string]any) + if afterMeta == nil { + t.Fatalf("strip removed entire _meta block (should keep other namespaces): %s", stripped) + } + if _, ok := afterMeta["agentlock"]; ok { + t.Fatalf("strip left our agentlock namespace behind: %+v", afterMeta) + } + if afterMeta["some.other.tool"].(map[string]any)["key"] != "value" { + t.Fatalf("strip mutated unrelated _meta namespace: %+v", afterMeta) + } +} + +// TestBundleManifest_PreservesAlreadyV03: a manifest already at v0.3 +// must NOT have its manifest_version touched, and strip must NOT +// downgrade it (we only restore manifest_version when we bumped it). +func TestBundleManifest_PreservesAlreadyV03(t *testing.T) { + src := []byte(`{ + "manifest_version": "0.3", + "name": "X", "version": "1", "description": "x", + "author": {"name": "x"}, + "server": {"type": "node", "entry_point": "i.js", + "mcp_config": {"command": "node", "args": ["i.js"]}} + }`) + wrapped, ok := mergeBundleManifest(src, "ext.id", "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("merge ok=false") + } + var got map[string]any + _ = json.Unmarshal(wrapped, &got) + if got["manifest_version"] != "0.3" { + t.Fatalf("manifest_version changed unnecessarily: %v", got["manifest_version"]) + } + agentlockMeta := got["_meta"].(map[string]any)["agentlock"].(map[string]any) + if _, ok := agentlockMeta["original_manifest_version"]; ok { + t.Fatalf("stashed original_manifest_version when no bump happened: %+v", agentlockMeta) + } + + stripped, _, _ := stripBundleManifest(wrapped) + var after map[string]any + _ = json.Unmarshal(stripped, &after) + if after["manifest_version"] != "0.3" { + t.Fatalf("strip downgraded a v0.3 manifest: %v", after["manifest_version"]) + } +} + +// TestBundleManifest_BumpsLegacyDxtVersion: bundles that ship with the +// deprecated dxt_version field (e.g. Anthropic's own Control Chrome +// publishes dxt_version "0.1" with no manifest_version) must have it +// bumped in lockstep with manifest_version. The v0.3 schema pins +// dxt_version to const "0.3" when present, so leaving a stale value +// causes Claude Desktop to reject the whole manifest with +// `dxt_version: Invalid literal value`. Strip restores the original. +func TestBundleManifest_BumpsLegacyDxtVersion(t *testing.T) { + src := []byte(`{ + "dxt_version": "0.1", + "name": "Control Chrome", "version": "0.1.6", "description": "x", + "author": {"name": "Anthropic"}, + "server": {"type": "node", "entry_point": "server/index.js", + "mcp_config": {"command": "node", "args": ["server/index.js"]}} + }`) + wrapped, ok := mergeBundleManifest(src, "ext.id", "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("merge ok=false") + } + var got map[string]any + _ = json.Unmarshal(wrapped, &got) + if got["dxt_version"] != "0.3" { + t.Fatalf("dxt_version not bumped: %v", got["dxt_version"]) + } + if got["manifest_version"] != "0.3" { + t.Fatalf("manifest_version not added on legacy-dxt bundle: %v", got["manifest_version"]) + } + agentlockMeta := got["_meta"].(map[string]any)["agentlock"].(map[string]any) + if agentlockMeta["original_dxt_version"] != "0.1" { + t.Fatalf("original_dxt_version not stashed: %v", agentlockMeta["original_dxt_version"]) + } + // manifest_version was originally absent on this bundle. + if a, _ := agentlockMeta["original_manifest_version_absent"].(bool); !a { + t.Fatalf("original_manifest_version_absent flag missing: %+v", agentlockMeta) + } + + stripped, _, err := stripBundleManifest(wrapped) + if err != nil { + t.Fatalf("strip: %v", err) + } + var after map[string]any + _ = json.Unmarshal(stripped, &after) + if after["dxt_version"] != "0.1" { + t.Fatalf("strip didn't restore dxt_version: %v", after["dxt_version"]) + } + if _, ok := after["manifest_version"]; ok { + t.Fatalf("strip left bumped manifest_version on a bundle that didn't have one: %+v", after) + } +} + +// TestBundleManifest_DisabledUnwindsWrap: an extension that's flipped +// to disabled in the settings sidecar should have its wrap unwound. +// An already-clean disabled extension should produce no write op. +func TestBundleManifest_DisabledUnwindsWrap(t *testing.T) { + wrapped, ok := mergeBundleManifest(sampleBundleManifest(t), + "ext.id", "http://127.0.0.1:7878", "agentlock", nil) + if !ok { + t.Fatalf("merge ok=false") + } + out, ok := mergeBundleManifest(wrapped, "ext.id", + "http://127.0.0.1:7878", "agentlock", + map[string]extensionSettings{"ext.id": {IsEnabled: false}}) + if !ok { + t.Fatalf("disable-pass merge ok=false") + } + var got map[string]any + _ = json.Unmarshal(out, &got) + mcp := got["server"].(map[string]any)["mcp_config"].(map[string]any) + if mcp["command"] != "node" { + t.Fatalf("disable didn't restore command: %v", mcp["command"]) + } + // Already-clean disabled = no-op (caller writes no file op). + if _, ok := mergeBundleManifest(out, "ext.id", "http://127.0.0.1:7878", + "agentlock", map[string]extensionSettings{"ext.id": {IsEnabled: false}}); ok { + t.Fatalf("expected no-op for already-clean disabled extension") + } +} From fd8186c974b5928b366a00938fbbf1e760d644f6 Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Thu, 7 May 2026 18:35:36 -0700 Subject: [PATCH 6/7] Wire Desktop Extensions through agentlock install --- cli/src/commands/install.ts | 25 +++++++++++++++++++ cli/src/util/install-fs.ts | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 7294d31..fb2093f 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -36,6 +36,8 @@ import { checkSafeTarget, executeFileOps, executeUninstallOps, + listExtensionBundleManifests, + listJsonFiles, readExistingFiles, } from "../util/install-fs.ts"; import { claudeDesktopConfigPath } from "../detect/claude-desktop.ts"; @@ -351,6 +353,11 @@ export async function runInstall(argv: string[] = []): Promise { uninstallPaths.push(resolve(join(dir, "settings.json"))); } else if (id === "claude-desktop") { uninstallPaths.push(resolve(join(dir, "claude_desktop_config.json"))); + // Bundle manifests live one dir over and are the actual launch + // source for Desktop Extensions — the daemon needs each to + // unwind the wrap on uninstall. + const bundlesDir = resolve(join(dir, "Claude Extensions")); + uninstallPaths.push(...(await listExtensionBundleManifests(bundlesDir))); } else if (id === "codex" || id === "cursor") { uninstallPaths.push(resolve(join(dir, "hooks.json"))); } else if (id === "gemini") { @@ -411,6 +418,22 @@ export async function runInstall(argv: string[] = []): Promise { const geminiSettings = resolve( join(hostConfigDirs["gemini"], "settings.json"), ); + // Per-extension bundle manifests are THE launch source for Desktop + // Extensions installed via Settings → Extensions UI — claudeDesktopPlan + // wraps each one in place using the schema-blessed _meta.agentlock + // slot (MCPB v0.3+). The Claude Extensions Settings sidecar JSONs + // tell us which extensions are isEnabled so disabled ones get + // unwound rather than re-wrapped. + const claudeDesktopBundlesDir = resolve( + join(hostConfigDirs["claude-desktop"], "Claude Extensions"), + ); + const claudeDesktopExtSettingsDir = resolve( + join(hostConfigDirs["claude-desktop"], "Claude Extensions Settings"), + ); + const bundleManifests = await listExtensionBundleManifests( + claudeDesktopBundlesDir, + ); + const extSettingsFiles = await listJsonFiles(claudeDesktopExtSettingsDir); const existingFiles = await readExistingFiles([ claudeSettings, claudeDesktopConfig, @@ -418,6 +441,8 @@ export async function runInstall(argv: string[] = []): Promise { codexConfig, cursorHooks, geminiSettings, + ...bundleManifests, + ...extSettingsFiles, ]); // Write the status-line script alongside the binary wrapper. Daemon diff --git a/cli/src/util/install-fs.ts b/cli/src/util/install-fs.ts index 8556150..122c35a 100644 --- a/cli/src/util/install-fs.ts +++ b/cli/src/util/install-fs.ts @@ -55,6 +55,56 @@ export function checkSafeTarget( ); } +// listJsonFiles returns the absolute path of every *.json file +// directly under `dir`. Returns an empty array if `dir` doesn't exist +// or isn't a directory — matches readExistingFiles's "missing is +// fine" posture so callers can chain them safely. +export async function listJsonFiles(dir: string): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code === "ENOENT" || code === "ENOTDIR") return []; + throw err; + } + const out: string[] = []; + for (const e of entries) { + if (!e.isFile()) continue; + if (!e.name.endsWith(".json")) continue; + out.push(resolve(dir, e.name)); + } + return out; +} + +// listExtensionBundleManifests scans the "Claude Extensions" dir for +// each /manifest.json — the on-disk bundle manifest Claude +// Desktop launches from. Returns absolute paths. Missing dir is fine. +export async function listExtensionBundleManifests( + bundlesDir: string, +): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(bundlesDir, { withFileTypes: true }); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code === "ENOENT" || code === "ENOTDIR") return []; + throw err; + } + const out: string[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const manifestPath = resolve(bundlesDir, e.name, "manifest.json"); + try { + const stat = await fs.stat(manifestPath); + if (stat.isFile()) out.push(manifestPath); + } catch { + // No manifest.json in this bundle dir — skip silently. + } + } + return out; +} + // readExistingFiles loads utf8 contents for each absolute path that // exists; silently skips ENOENT so the caller can pass a list of // "maybe present" paths and the daemon merges against whatever it From d5a16836aaac31c43c134939bb348efba0882004 Mon Sep 17 00:00:00 2001 From: RonCodes88 Date: Thu, 7 May 2026 18:35:36 -0700 Subject: [PATCH 7/7] Document Desktop Extensions in Claude Desktop scope --- cli/src/detect/claude-desktop.ts | 36 +++++++++++++++++++++++++++++--- docs/status.md | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/cli/src/detect/claude-desktop.ts b/cli/src/detect/claude-desktop.ts index 1097146..855b30d 100644 --- a/cli/src/detect/claude-desktop.ts +++ b/cli/src/detect/claude-desktop.ts @@ -8,7 +8,7 @@ // therefore install agentlock as an MCP server entry — that's the only // honest write we can do here. -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { appSupport } from "../util/paths.ts"; import { claudeDesktopAgentlockState } from "./agentlock-state.ts"; @@ -31,6 +31,7 @@ export const claudeDesktop: Detector = { async detect(): Promise { const configPath = claudeDesktopConfigPath(); const dir = join(configPath, ".."); + const extensionsRegistry = join(dir, "extensions-installations.json"); const evidence: string[] = []; const dirExists = existsSync(dir); @@ -38,6 +39,17 @@ export const claudeDesktop: Detector = { if (dirExists) evidence.push(`found ${dir}`); if (configExists) evidence.push(`found ${configPath}`); + // Count Desktop Extensions installed via Settings → Extensions UI. + // This is the registry agentlock now wraps in addition to the + // manual mcpServers path; surfacing the count tells the user up + // front how much surface area they're hardening. + const extensionCount = countInstalledExtensions(extensionsRegistry); + if (extensionCount > 0) { + evidence.push( + `found ${extensionCount} Desktop Extension${extensionCount === 1 ? "" : "s"} (${extensionsRegistry})`, + ); + } + const scopes: DetectedScope[] = [ { kind: "global", path: configPath, exists: configExists }, ]; @@ -57,15 +69,33 @@ export const claudeDesktop: Detector = { surfaces: ["mcp-stdio"], notes: dirExists ? [ - "Install wraps every MCP server entry with `agentlock mcp-proxy` so each tools/call is gated by daemon policy. Originals preserved under _agentlock_original for clean uninstall.", + "Install wraps every MCP server entry (manual mcpServers + Desktop Extensions installed via Settings → Extensions UI) with `agentlock mcp-proxy` so each tools/call is gated by daemon policy. Manual mcpServers entries preserve originals under _agentlock_original; Desktop Extension bundle manifests stash originals under _meta.agentlock (MCPB v0.3+ schema slot).", + "Anthropic auto-updates may overwrite the wrap on extension version bumps — re-run `agentlock install` after extension updates.", "Coverage is the MCP slice only: not gated are Computer Use, integrated terminal, native connectors (Slack/GCal), Cowork's non-MCP paths, and Anthropic cloud features. For full local enforcement, use Claude Code.", ] : [ "Claude Desktop not detected. Selecting it will create the config dir on install.", - "When Claude Desktop is in use, install wraps each MCP server — coverage is MCP-slice only (not Computer Use, terminal, connectors, or cloud features).", + "When Claude Desktop is in use, install wraps each MCP server (mcpServers + Desktop Extensions) — coverage is MCP-slice only (not Computer Use, terminal, connectors, or cloud features).", ], agentlockInstalled: al.installed, agentlockDaemonURL: al.daemonURL, }; }, }; + +// countInstalledExtensions parses extensions-installations.json and +// returns the number of installed Desktop Extensions. Returns 0 on any +// parse error or missing file — the count is informational; we don't +// want detection to fail loud on a malformed registry that the install +// pipeline will gracefully no-op past anyway. +function countInstalledExtensions(registryPath: string): number { + if (!existsSync(registryPath)) return 0; + try { + const parsed = JSON.parse(readFileSync(registryPath, "utf8")) as { + extensions?: Record; + }; + return Object.keys(parsed.extensions ?? {}).length; + } catch { + return 0; + } +} diff --git a/docs/status.md b/docs/status.md index 20c12d7..7dfc43b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -8,7 +8,7 @@ Live status of every component shipped to the public repo. Shipped | | `agentlock install` (Claude Code, Codex CLI, Cursor, Gemini CLI) | Shipped | -| `agentlock install` (Claude Desktop) | Shipped — **MCP-slice enforcement** via `agentlock mcp-proxy`. Wraps every user-installed MCP server and `.mcpb` Desktop Extension; each `tools/call` goes through daemon policy. **Not gated:** Computer Use (direct mouse/keyboard), integrated terminal, native connectors (Slack/GCal), and server-side features (web search, code interpreter). **Cowork coverage uncertain:** any MCP-mediated tool call Cowork makes IS gated; whether Cowork has separate non-MCP code paths is unverified — verify in your environment by running a Cowork task and checking the agentlock ledger. For full local enforcement of an agent harness, use Claude Code. Tracks [anthropics/claude-code#45514](https://github.com/anthropics/claude-code/issues/45514) for native PreToolUse parity. | +| `agentlock install` (Claude Desktop) | Shipped — wraps every MCP server entry through `agentlock mcp-proxy` so each `tools/call` goes through daemon policy. Both install paths covered: (a) manual `mcpServers` entries in `~/Library/Application Support/Claude/claude_desktop_config.json` (originals preserved under `_agentlock_original`); (b) Desktop Extensions installed via *Settings → Extensions* UI — each per-extension bundle manifest at `Claude Extensions//manifest.json` is rewritten in place using the schema-blessed `_meta.agentlock` slot (MCPB v0.3+), with `manifest_version` bumped from 0.1/0.2 → 0.3 when needed so the slot validates. Originals stashed under `_meta.agentlock.original_*` for byte-clean restore. **Caveat:** Anthropic auto-updates overwrite the wrap on extension version bumps — re-run `agentlock install` after extension updates (a watcher closes this gap; tracked separately). Other surfaces remain out of scope: Computer Use, integrated terminal, native connectors (Slack/GCal), Cowork's non-MCP paths, server-side cloud features. For full local enforcement of an agent harness, use Claude Code. Tracks [anthropics/claude-code#45514](https://github.com/anthropics/claude-code/issues/45514) for native PreToolUse parity. | | `agentlock install` (OpenCode, Cline, Continue, VS Code Copilot) | Not yet implemented — detected but disabled in selector | | `agentlock install` (Codex Desktop, Openclaw, Nemoclaw, Hermesagent, Pi) | Not yet implemented — roadmap; awaiting per-app hook/config investigation | | `agentlock install --tier {unattested,software,totp}` | Shipped |