diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 11f18da..5658f72 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "agentic-control-plane", "source": "./", "description": "Control, audit, and cost-optimize every Claude Code tool call. Governance hook + bundled ACP MCP (cost X-ray, run traces, policy checks) + /cost-xray pre-ship report.", - "version": "0.25.0", + "version": "0.26.0", "author": { "name": "GatewayStack" }, diff --git a/bin/govern.mjs b/bin/govern.mjs index 0e45ba0..d53f389 100644 --- a/bin/govern.mjs +++ b/bin/govern.mjs @@ -67,7 +67,7 @@ const ACP_GOVERN = process.env.ACP_API_BASE || "https://govern.agenticcontrolplane.com"; -const PLUGIN_VERSION = "0.25.0"; +const PLUGIN_VERSION = "0.26.0"; // Console base for user-facing deep links (session receipt, #606). const ACP_CONSOLE = @@ -169,7 +169,8 @@ async function readContext(input) { // Each client's hooks.json sets this env var at invocation time: // "claude-code-plugin", "cursor", "codex", etc. Falls back to // claude-code-plugin for backward compat. -const ACP_CLIENT = process.env.ACP_CLIENT || "claude-code-plugin"; +// `let`, not `const`: a VS Code Copilot call is re-attributed below. +let ACP_CLIENT = process.env.ACP_CLIENT || "claude-code-plugin"; // Client-side disable for shadow-mode counterfactual notices // (gatewaystack-connect#607). The server also honors a tenant-level @@ -186,6 +187,103 @@ const SHADOW_OFF = /^(off|0|false)$/i.test(process.env.ACP_SHADOW ?? ""); // hooks.json sets ACP_HARNESS=codex. const HARNESS = process.env.ACP_HARNESS || "claude-code"; +// ── GitHub Copilot: one registration, two dialects ───────────────────── +// Copilot CLI reads ~/.copilot/hooks/*.json. PascalCase event names in that +// file select its "VS Code compatible" payload: snake_case fields and a +// tool_name ALREADY mapped to Claude Code's vocabulary (bash → Bash, view → +// Read, create → Write, edit → Edit …), so the CLI needs no renaming here. +// VS Code's agent mode reads the same directory (and ~/.claude/settings.json) +// but sends its OWN tool ids — run_in_terminal, create_file, +// replace_string_in_file … — with camelCase inputs (filePath). Every layer +// that keys on the Claude names (the hardline floor, dotted Bash.* policy, +// the gateway classifier) would skip those calls silently, so they are +// canonicalized here, once, before any decision. Exact-match only: a fuzzy +// rule would put shell-grade policy on some tenant's unrelated custom tool. +// +// Output: Copilot CLI documents only the bare {permissionDecision} form for +// preToolUse; VS Code documents only the hookSpecificOutput wrapper (CLI +// ≥ 1.0.66 parses the wrapper too). Under ACP_HARNESS=copilot every verdict +// is written in BOTH shapes — see the stdout mirror below. +// +// Ids only VS Code uses. These identify the dialect on ANY registration, +// because on a box where Claude Code was wired without the plugin, VS Code +// also runs the ~/.claude/settings.json hook — and that one carries no +// ACP_HARNESS. Names shared with other dialects (create_file, read_file, +// list_dir) are renamed but never used as the tell. +// grep_search, file_search and read_file are NOT tells: Cursor's agent +// spells its tools the same way, and a tell that matched them would +// re-attribute a Cursor call and make the Cursor hook stand down. +const VSCODE_ONLY_TOOLS = new Set([ + "run_in_terminal", "runTerminalCommand", "replace_string_in_file", + "multi_replace_string_in_file", "insert_edit_into_file", "edit_files", + "editFiles", "createFile", "runSubagent", "search_subagent", + "execution_subagent", +]); +const VSCODE_TOOL_ALIASES = { + run_in_terminal: "Bash", runTerminalCommand: "Bash", + create_file: "Write", createFile: "Write", + replace_string_in_file: "Edit", multi_replace_string_in_file: "Edit", + insert_edit_into_file: "Edit", edit_files: "Edit", editFiles: "Edit", + read_file: "Read", + list_dir: "Glob", file_search: "Glob", + grep_search: "Grep", + fetch_webpage: "WebFetch", + runSubagent: "Agent", search_subagent: "Agent", execution_subagent: "Agent", +}; +const VSCODE_INPUT_KEYS = { filePath: "file_path", oldString: "old_string", newString: "new_string" }; +const COPILOT_REGISTRATION = join(homedir(), ".copilot", "hooks", "acp.json"); + +function isVsCodeDialect(input) { + const name = input?.tool_name; + if (typeof name !== "string") return false; + if (VSCODE_ONLY_TOOLS.has(name)) return true; + const ti = input.tool_input; + return name in VSCODE_TOOL_ALIASES && !!ti && typeof ti === "object" && "filePath" in ti; +} + +// Returns false when this process should stand down: the call is VS Code's, +// it arrived through a registration that is not the Copilot one, and the +// Copilot registration exists — so that hook is already handling it. One +// governed call must produce one row, not two. +function normalizeCopilotInput(input) { + if (!input || typeof input !== "object") return true; + // Copilot CLI: tool_input is "parsed from JSON string when possible" — + // finish the job when it was not. Only under our Copilot registration; + // every other harness's payload keeps its shape. + if (HARNESS === "copilot" && typeof input.tool_input === "string") { + try { input.tool_input = JSON.parse(input.tool_input); } catch { /* leave the string */ } + } + const vscode = isVsCodeDialect(input); + if (vscode && HARNESS !== "copilot" && existsSync(COPILOT_REGISTRATION)) return false; + if (vscode) ACP_CLIENT = "copilot-vscode"; + if (HARNESS !== "copilot" && !vscode) return true; + const alias = VSCODE_TOOL_ALIASES[input.tool_name]; + if (alias) input.tool_name = alias; + const ti = input.tool_input; + if (ti && typeof ti === "object" && !Array.isArray(ti)) { + for (const [from, to] of Object.entries(VSCODE_INPUT_KEYS)) { + if (from in ti && !(to in ti)) ti[to] = ti[from]; + } + } + return true; +} + +if (HARNESS === "copilot") { + const rawWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk, ...rest) => { + try { + const o = JSON.parse(String(chunk)); + const h = o && o.hookSpecificOutput; + if (h && typeof h.permissionDecision === "string" && o.permissionDecision === undefined) { + o.permissionDecision = h.permissionDecision; + if (h.permissionDecisionReason !== undefined) o.permissionDecisionReason = h.permissionDecisionReason; + return rawWrite(JSON.stringify(o), ...rest); + } + } catch { /* not one of our JSON verdicts — pass through untouched */ } + return rawWrite(chunk, ...rest); + }; +} + // 200 KB ceiling on the tool_output payload we send to the backend. Matches // the backend's scan ceiling. const POST_HOOK_PAYLOAD_CEILING = 200 * 1024; @@ -652,6 +750,7 @@ try { } catch { process.exit(0); } +if (!normalizeCopilotInput(input)) process.exit(0); // UserPromptExpansion (the /acp-* terminal commands) owns its own // no-credential message — "/acp-connect first", not the tool-call floors @@ -1320,8 +1419,11 @@ async function handlePreToolUse() { // Codex rejects updatedInput (see HARNESS note) — attempting injection // would mark the hook failed and run the tool anyway, minus the token. - // Skip cleanly; the local-credential workflow continues unchanged. - if (HARNESS === "codex") { + // Copilot CLI ignores it (github/copilot-cli#2013) and VS Code drops any + // updatedInput that fails its per-tool schema, so the token would be + // silently lost. Skip cleanly; the local-credential workflow continues + // unchanged. + if (HARNESS === "codex" || HARNESS === "copilot") { exitAllow(); } diff --git a/plugin.json b/plugin.json index dfa75a2..8fc853b 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "agentic-control-plane", - "version": "0.25.0", + "version": "0.26.0", "description": "Identity, governance, and audit for every Claude Code tool call. Logs all tool usage, enforces policies, and gives teams full visibility \u2014 without changing how you use Claude.", "author": { "name": "GatewayStack", diff --git a/test/copilot-dialect.test.mjs b/test/copilot-dialect.test.mjs new file mode 100644 index 0000000..7a2829c --- /dev/null +++ b/test/copilot-dialect.test.mjs @@ -0,0 +1,242 @@ +// Tests for the GitHub Copilot harness support in bin/govern.mjs. +// +// Run with: node --test test/copilot-dialect.test.mjs +// +// One registration (~/.copilot/hooks/acp.json, ACP_HARNESS=copilot) serves +// two dialects. Copilot CLI's PascalCase payload arrives in Claude Code's +// vocabulary already; VS Code's agent mode sends its own tool ids +// (run_in_terminal, create_file …) with camelCase inputs. Invariants: +// a. A deny under ACP_HARNESS=copilot is written in BOTH output shapes — +// the bare {permissionDecision} Copilot CLI documents and the +// hookSpecificOutput wrapper VS Code documents — with the reason in both. +// b. An ask is likewise in both shapes, and stays "ask" (Copilot renders +// a native prompt; it is not Codex). +// c. VS Code's run_in_terminal reaches the gateway as Bash, attributed to +// copilot-vscode. +// d. VS Code's camelCase file inputs gain their snake_case twins so +// path-keyed policy sees them; the tool is canonical (Write). +// e. Copilot CLI's tool_input, when it arrives as a JSON string, is parsed. +// f. On a box wired for Claude Code WITHOUT the plugin, VS Code also runs +// the ~/.claude/settings.json hook. When the Copilot registration +// exists, that second hook stands down: no request, no output — one +// governed call, one row. +// g. Without the Copilot registration the same VS Code call is still +// governed by the Claude registration (canonicalized, re-attributed). +// h. Local mode: a hardline command through VS Code's shell tool is +// denied, in both shapes. +// i. No vendor-token injection under copilot (updatedInput is ignored by +// Copilot CLI and schema-checked by VS Code): an allow is a bare allow. +// +// Each test spawns the real hook exactly the way a harness does — JSON on +// stdin — against a throwaway fixture HOME, with ACP_GOVERN_BASE pointed at +// a local stub server that records what it saw. + +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, renameSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const GOVERN = join(ROOT, "bin", "govern.mjs"); + +let HOME; +let server; +let baseUrl; +let seen = []; +let nextResponse = { decision: "allow" }; + +const COPILOT_REG = () => join(HOME, ".copilot", "hooks", "acp.json"); + +before(async () => { + HOME = mkdtempSync(join(tmpdir(), "acp-copilot-test-")); + mkdirSync(join(HOME, ".acp"), { recursive: true }); + writeFileSync(join(HOME, ".acp", "credentials"), "gsk_test_deadbeef\n"); + + server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => { raw += c; }); + req.on("end", () => { + let body = null; + try { body = JSON.parse(raw); } catch { /* keep null */ } + seen.push({ url: req.url, body, client: req.headers["x-gs-client"] }); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(nextResponse)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + server?.close(); + rmSync(HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + seen = []; + nextResponse = { decision: "allow" }; + rmSync(join(HOME, ".copilot"), { recursive: true, force: true }); + rmSync(join(HOME, ".acp", "policy.json"), { force: true }); +}); + +function runHook(input, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [GOVERN], { + env: { + HOME, + PATH: process.env.PATH, + ACP_GOVERN_BASE: baseUrl, + ACP_FIRST_ATTEMPT_MS: "1000", + ACP_RETRY_ATTEMPT_MS: "1000", + ...env, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const killer = setTimeout(() => child.kill("SIGKILL"), 15000); + child.stdout.on("data", (c) => { stdout += c; }); + child.stderr.on("data", (c) => { stderr += c; }); + child.on("error", reject); + child.on("close", (status) => { + clearTimeout(killer); + try { + assert.equal(status, 0, `hook exited ${status}: ${stderr}`); + resolve(stdout.trim() ? JSON.parse(stdout) : null); + } catch (e) { + reject(e); + } + }); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +const COPILOT = { ACP_HARNESS: "copilot", ACP_CLIENT: "copilot" }; + +// Copilot CLI, PascalCase payload: already Claude Code's vocabulary. +const cliPre = (overrides = {}) => ({ + hook_event_name: "PreToolUse", + timestamp: "2026-09-21T10:00:00.000Z", + session_id: "sess-copilot-cli", + cwd: "/tmp", + tool_name: "Bash", + tool_input: { command: "git push origin main" }, + ...overrides, +}); + +// VS Code agent mode: VS Code's own tool ids and camelCase inputs. +const vscodeShell = (command) => ({ + hook_event_name: "PreToolUse", + timestamp: "2026-09-21T10:00:00.000Z", + session_id: "sess-copilot-vscode", + cwd: "/tmp", + tool_name: "run_in_terminal", + tool_input: { command, explanation: "test", isBackground: false }, + tool_use_id: "tool-123", +}); + +// A hardline payload assembled from parts, so authoring this test never +// trips the floor itself. +const ROOT_DELETE = ["rm", "-rf", "/"].join(" "); + +function writeCopilotRegistration() { + mkdirSync(join(HOME, ".copilot", "hooks"), { recursive: true }); + writeFileSync(COPILOT_REG(), JSON.stringify({ + version: 1, + hooks: { PreToolUse: [{ type: "command", command: "env ACP_CLIENT=copilot ACP_HARNESS=copilot node $HOME/.acp/govern.mjs", timeout: 5, timeoutSec: 5 }] }, + })); +} + +test("(a) a deny under copilot is emitted in both output shapes", async () => { + nextResponse = { decision: "deny", reason: "Bash.git.push is denied for interactive" }; + const out = await runHook(cliPre(), COPILOT); + assert.equal(out.permissionDecision, "deny"); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.match(out.permissionDecisionReason, /Bash\.git\.push is denied/); + assert.equal(out.permissionDecisionReason, out.hookSpecificOutput.permissionDecisionReason); +}); + +test("(b) an ask stays an ask under copilot, in both shapes", async () => { + nextResponse = { decision: "ask", reason: "step_up for Bash.git.push", approval_id: "ap1", approval_status: "pending" }; + const out = await runHook(cliPre(), COPILOT); + assert.equal(out.permissionDecision, "ask"); + assert.equal(out.hookSpecificOutput.permissionDecision, "ask"); + const req = seen.find((s) => s.url === "/govern/tool-use"); + assert.deepEqual(req.body.capabilities, ["native_ask"]); +}); + +test("(c) VS Code's run_in_terminal reaches the gateway as Bash, attributed to copilot-vscode", async () => { + await runHook(vscodeShell("git push origin main"), COPILOT); + const req = seen.find((s) => s.url === "/govern/tool-use"); + assert.ok(req, "expected a request to /govern/tool-use"); + assert.equal(req.body.tool_name, "Bash"); + assert.equal(req.body.tool_input.command, "git push origin main"); + assert.equal(req.body.call_id, "tool-123"); + assert.match(String(req.client), /^copilot-vscode\//); +}); + +test("(d) VS Code's camelCase file inputs gain snake_case twins; tool is canonical", async () => { + await runHook({ + ...vscodeShell("x"), + tool_name: "create_file", + tool_input: { filePath: "/tmp/proj/.env", content: "SECRET=1" }, + }, COPILOT); + const req = seen.find((s) => s.url === "/govern/tool-use"); + assert.equal(req.body.tool_name, "Write"); + assert.equal(req.body.tool_input.file_path, "/tmp/proj/.env"); + assert.equal(req.body.tool_input.filePath, "/tmp/proj/.env"); +}); + +test("(e) a tool_input that arrives as a JSON string is parsed", async () => { + await runHook(cliPre({ tool_input: JSON.stringify({ command: "ls -la" }) }), COPILOT); + const req = seen.find((s) => s.url === "/govern/tool-use"); + assert.deepEqual(req.body.tool_input, { command: "ls -la" }); +}); + +test("(f) the Claude registration stands down for a VS Code call when the Copilot registration exists", async () => { + writeCopilotRegistration(); + assert.ok(existsSync(COPILOT_REG())); + const out = await runHook(vscodeShell("git push origin main"), { ACP_CLIENT: "claude-code-plugin" }); + assert.equal(out, null, "expected no output from the standing-down hook"); + assert.equal(seen.length, 0, "expected no gateway request"); +}); + +test("(g) without the Copilot registration, the Claude registration still governs a VS Code call", async () => { + nextResponse = { decision: "deny", reason: "denied" }; + const out = await runHook(vscodeShell("git push origin main"), { ACP_CLIENT: "claude-code-plugin" }); + const req = seen.find((s) => s.url === "/govern/tool-use"); + assert.ok(req); + assert.equal(req.body.tool_name, "Bash"); + assert.match(String(req.client), /^copilot-vscode\//); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); +}); + +test("(h) local mode: a hardline command through VS Code's shell tool is denied in both shapes", async () => { + // Local mode is "no workspace token AND a policy file" — park the + // fixture's credentials for the duration of this test. + const creds = join(HOME, ".acp", "credentials"); + const parked = join(HOME, ".acp", "credentials.parked"); + renameSync(creds, parked); + try { + writeFileSync(join(HOME, ".acp", "policy.json"), JSON.stringify({ default: "allow", rules: {} })); + const out = await runHook(vscodeShell(ROOT_DELETE), COPILOT); + assert.ok(out, "expected a verdict on stdout"); + assert.equal(out.permissionDecision, "deny"); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.equal(seen.length, 0, "local mode makes no gateway request"); + } finally { + renameSync(parked, creds); + } +}); + +test("(i) an allow under copilot never injects updatedInput", async () => { + nextResponse = { decision: "allow" }; + const out = await runHook(cliPre({ tool_input: { command: "gh pr list" } }), COPILOT); + assert.ok(!out || !out.hookSpecificOutput || out.hookSpecificOutput.updatedInput === undefined); + assert.ok(!seen.some((s) => s.url.includes("scoped-tokens")), "no scoped-token request"); +});