diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index ec096d79d..771aa4399 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -7,7 +7,16 @@ vi.mock("../../enrichment/file-enricher", () => ({ enrichFileForAgent: enrichFileMock, })); -import { createReadEnrichmentHook, type EnrichedReadCache } from "./hooks"; +import { Logger } from "../../utils/logger"; +import { + createPreToolUseHook, + createReadEnrichmentHook, + type EnrichedReadCache, +} from "./hooks"; +import type { + PermissionCheckResult, + SettingsManager, +} from "./session/settings"; const stubDeps = {} as FileEnrichmentDeps; @@ -187,3 +196,118 @@ describe("createReadEnrichmentHook", () => { expect(content).toBe("foo"); }); }); + +function buildPreToolUseHookInput( + toolName: string, + toolInput: Record, +): HookInput { + return { + session_id: "test-session", + transcript_path: "/tmp/transcript", + cwd: "/tmp", + hook_event_name: "PreToolUse", + tool_name: toolName, + tool_use_id: "toolu_1", + tool_input: toolInput, + } as HookInput; +} + +function buildSettingsManagerStub( + result: PermissionCheckResult, +): SettingsManager { + return { + checkPermission: () => result, + } as unknown as SettingsManager; +} + +describe("createPreToolUseHook", () => { + const logger = new Logger({ debug: false }); + + test("defers destructive PostHog exec sub-tool to canUseTool via ask", async () => { + const settingsManager = buildSettingsManagerStub({ + decision: "allow", + rule: "mcp__posthog__exec", + source: "allow", + }); + const hook = createPreToolUseHook(settingsManager, logger); + const result = await hook( + buildPreToolUseHookInput("mcp__posthog__exec", { + command: 'call dashboard-update {"id": 1, "name": "x"}', + }), + undefined, + { signal: new AbortController().signal }, + ); + + expect(result).toMatchObject({ + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }); + }); + + test("allows non-destructive PostHog exec sub-tool via settings rule", async () => { + const settingsManager = buildSettingsManagerStub({ + decision: "allow", + rule: "mcp__posthog__exec", + source: "allow", + }); + const hook = createPreToolUseHook(settingsManager, logger); + const result = await hook( + buildPreToolUseHookInput("mcp__posthog__exec", { + command: 'call experiment-get {"id": 1}', + }), + undefined, + { signal: new AbortController().signal }, + ); + + expect(result).toEqual({ + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: + "Allowed by settings rule: mcp__posthog__exec", + }, + }); + }); + + test("allows non-PostHog tool via settings rule unchanged", async () => { + const settingsManager = buildSettingsManagerStub({ + decision: "allow", + rule: "Bash(ls:*)", + source: "allow", + }); + const hook = createPreToolUseHook(settingsManager, logger); + const result = await hook( + buildPreToolUseHookInput("Bash", { command: "ls -la" }), + undefined, + { signal: new AbortController().signal }, + ); + + expect(result).toMatchObject({ + hookSpecificOutput: { permissionDecision: "allow" }, + }); + }); + + test("defers when destructive rule is partial-update", async () => { + const settingsManager = buildSettingsManagerStub({ + decision: "allow", + rule: "mcp__posthog__exec", + source: "allow", + }); + const hook = createPreToolUseHook(settingsManager, logger); + const result = await hook( + buildPreToolUseHookInput("mcp__posthog__exec", { + command: 'call cohorts-partial-update {"id": 1}', + }), + undefined, + { signal: new AbortController().signal }, + ); + + expect(result).toMatchObject({ + hookSpecificOutput: { permissionDecision: "ask" }, + }); + }); +}); diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index a9a3073f1..3dc59be89 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -5,6 +5,11 @@ import { } from "../../enrichment/file-enricher"; import type { Logger } from "../../utils/logger"; import { stripCatLineNumbers } from "./conversion/sdk-to-acp"; +import { + extractPostHogSubTool, + isPostHogDestructiveSubTool, + isPostHogExecTool, +} from "./permissions/posthog-exec-gate"; import type { SettingsManager } from "./session/settings"; import type { CodeExecutionMode } from "./tools"; @@ -237,6 +242,25 @@ export const createPreToolUseHook = ); } + // Defer destructive PostHog exec sub-tools to canUseTool so the + // sub-tool gate can re-prompt. Returning `{ continue: true }` is + // not enough — the SDK then falls back to its default permission + // flow which re-checks the same allow rule. We must force "ask" + // so the SDK invokes canUseTool. + if (permissionCheck.decision === "allow" && isPostHogExecTool(toolName)) { + const subTool = extractPostHogSubTool(toolInput); + if (subTool && isPostHogDestructiveSubTool(subTool)) { + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "ask" as const, + permissionDecisionReason: `Destructive PostHog sub-tool '${subTool}' requires explicit approval`, + }, + }; + } + } + switch (permissionCheck.decision) { case "allow": return { diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts index 6b7139367..5d138c2f1 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -143,6 +143,158 @@ describe("canUseTool MCP approval enforcement", () => { expect(result.behavior).toBe("allow"); }); + it("bypasses the PostHog exec gate in auto mode", async () => { + setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); + const hasApproval = vi.fn().mockReturnValue(false); + const addApproval = vi.fn().mockResolvedValue(undefined); + + const context = createContext("mcp__posthog__exec", { + toolInput: { command: "call experiment-update {}" }, + session: { + permissionMode: "auto", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: hasApproval, + addPostHogExecApproval: addApproval, + }, + }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).not.toHaveBeenCalled(); + expect(hasApproval).not.toHaveBeenCalled(); + expect(addApproval).not.toHaveBeenCalled(); + }); + + it("bypasses the PostHog exec gate in bypassPermissions mode", async () => { + setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); + + const context = createContext("mcp__posthog__exec", { + toolInput: { command: "call feature-flag-delete {}" }, + session: { + permissionMode: "bypassPermissions", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: vi.fn().mockReturnValue(false), + addPostHogExecApproval: vi.fn(), + }, + }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).not.toHaveBeenCalled(); + }); + + it("short-circuits when a PostHog exec sub-tool was previously approved", async () => { + setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); + + const context = createContext("mcp__posthog__exec", { + toolInput: { command: "call experiment-update {}" }, + session: { + permissionMode: "default", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: vi + .fn() + .mockImplementation((s: string) => s === "experiment-update"), + addPostHogExecApproval: vi.fn(), + }, + }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).not.toHaveBeenCalled(); + }); + + it("prompts for an unapproved destructive PostHog sub-tool and persists on allow_always", async () => { + setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); + const addApproval = vi.fn().mockResolvedValue(undefined); + + const context = createContext("mcp__posthog__exec", { + toolInput: { command: "call notebooks-destroy {}" }, + session: { + permissionMode: "default", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: vi.fn().mockReturnValue(false), + addPostHogExecApproval: addApproval, + }, + }, + client: { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission: vi.fn().mockResolvedValue({ + outcome: { outcome: "selected", optionId: "allow_always" }, + }), + }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + title: "The agent wants to run `notebooks-destroy` on PostHog", + }), + }), + ); + expect(addApproval).toHaveBeenCalledWith("notebooks-destroy"); + }); + + it("prompts but does not persist on allow_once", async () => { + setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); + const addApproval = vi.fn(); + + const context = createContext("mcp__posthog__exec", { + toolInput: { command: "call experiment-delete {}" }, + session: { + permissionMode: "default", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: vi.fn().mockReturnValue(false), + addPostHogExecApproval: addApproval, + }, + }, + client: { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission: vi.fn().mockResolvedValue({ + outcome: { outcome: "selected", optionId: "allow" }, + }), + }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(addApproval).not.toHaveBeenCalled(); + }); + + it("does not gate non-destructive PostHog sub-tools", async () => { + setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); + const addApproval = vi.fn(); + + const context = createContext("mcp__posthog__exec", { + toolInput: { command: "call experiment-get-all {}" }, + session: { + permissionMode: "default", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: vi.fn().mockReturnValue(false), + addPostHogExecApproval: addApproval, + }, + }, + }); + const result = await canUseTool(context); + + // Non-destructive sub-tool falls through the gate. With approved MCP state + // and non-read-only tool metadata, it hits the default permission flow, + // which auto-allows via our mocked requestPermission. The gate must not + // have prompted with a PostHog-specific title, and must not have persisted. + expect(result.behavior).toBe("allow"); + expect(addApproval).not.toHaveBeenCalled(); + }); + it("emits tool denial notification for do_not_use", async () => { setMcpToolApprovalStates({ mcp__server__denied_tool: "do_not_use", diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index f9e6293c7..f8bb8d5d5 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -31,6 +31,11 @@ import { buildExitPlanModePermissionOptions, buildPermissionOptions, } from "./permission-options"; +import { + extractPostHogSubTool, + isPostHogDestructiveSubTool, + isPostHogExecTool, +} from "./posthog-exec-gate"; export type ToolPermissionResult = | { @@ -78,6 +83,18 @@ async function emitToolDenial( }); } +async function buildDenialResult( + context: ToolHandlerContext, + response: RequestPermissionResponse, +): Promise { + const feedback = (response._meta?.customInput as string | undefined)?.trim(); + const message = feedback + ? `User refused permission to run tool with feedback: ${feedback}` + : "User refused permission to run tool"; + await emitToolDenial(context, message); + return { behavior: "deny", message, interrupt: !feedback }; +} + function getPlanFromFile( session: Session, fileContentCache: { [key: string]: string }, @@ -389,16 +406,9 @@ async function handleDefaultPermissionFlow( behavior: "allow", updatedInput: toolInput as Record, }; - } else { - const feedback = ( - response._meta?.customInput as string | undefined - )?.trim(); - const message = feedback - ? `User refused permission to run tool with feedback: ${feedback}` - : "User refused permission to run tool"; - await emitToolDenial(context, message); - return { behavior: "deny", message, interrupt: !feedback }; } + + return buildDenialResult(context, response); } function parseMcpToolName(toolName: string): { @@ -479,12 +489,74 @@ async function handleMcpApprovalFlow( }; } - const feedback = (response._meta?.customInput as string | undefined)?.trim(); - const message = feedback - ? `User refused permission to run tool with feedback: ${feedback}` - : "User refused permission to run tool"; - await emitToolDenial(context, message); - return { behavior: "deny", message, interrupt: !feedback }; + return buildDenialResult(context, response); +} + +async function handlePostHogExecApprovalFlow( + context: ToolHandlerContext, + subTool: string, +): Promise { + const { toolName, toolInput, toolUseID, client, sessionId, session } = + context; + + const response = await client.requestPermission({ + options: [ + { kind: "allow_once", name: "Yes", optionId: "allow" }, + { + kind: "allow_always", + name: "Yes, always allow", + optionId: "allow_always", + }, + { + kind: "reject_once", + name: "Type here to tell the agent what to do differently", + optionId: "reject", + _meta: { customInput: true }, + }, + ], + sessionId, + toolCall: { + toolCallId: toolUseID, + title: `The agent wants to run \`${subTool}\` on PostHog`, + kind: "other", + content: [ + { + type: "content" as const, + content: text( + "This will modify live PostHog data. Approve to run this sub-tool.", + ), + }, + ], + rawInput: { ...(toolInput as Record), toolName }, + }, + }); + + if (context.signal?.aborted || response.outcome?.outcome === "cancelled") { + throw new Error("Tool use aborted"); + } + + if ( + response.outcome?.outcome === "selected" && + (response.outcome.optionId === "allow" || + response.outcome.optionId === "allow_always") + ) { + if (response.outcome.optionId === "allow_always") { + try { + await session.settingsManager.addPostHogExecApproval(subTool); + } catch (error) { + context.logger.warn( + "[canUseTool] Failed to persist PostHog exec approval", + { error: error instanceof Error ? error.message : String(error) }, + ); + } + } + return { + behavior: "allow", + updatedInput: toolInput as Record, + }; + } + + return buildDenialResult(context, response); } function handlePlanFileException( @@ -602,6 +674,28 @@ export async function canUseTool( if (approvalState === "needs_approval") { return handleMcpApprovalFlow(context); } + + if (isPostHogExecTool(toolName)) { + const subTool = extractPostHogSubTool(toolInput); + if (subTool && isPostHogDestructiveSubTool(subTool)) { + if ( + session.permissionMode === "auto" || + session.permissionMode === "bypassPermissions" + ) { + return { + behavior: "allow", + updatedInput: toolInput as Record, + }; + } + if (session.settingsManager.hasPostHogExecApproval(subTool)) { + return { + behavior: "allow", + updatedInput: toolInput as Record, + }; + } + return handlePostHogExecApprovalFlow(context, subTool); + } + } } if (isToolAllowedForMode(toolName, session.permissionMode)) { diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts new file mode 100644 index 000000000..6c2e82a93 --- /dev/null +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + extractPostHogSubTool, + isPostHogDestructiveSubTool, + isPostHogExecTool, +} from "./posthog-exec-gate"; + +describe("isPostHogExecTool", () => { + it("matches the bare posthog exec tool", () => { + expect(isPostHogExecTool("mcp__posthog__exec")).toBe(true); + }); + + it("matches plugin-prefixed variants", () => { + expect(isPostHogExecTool("mcp__posthog_posthog__exec")).toBe(true); + expect(isPostHogExecTool("mcp__posthog_cloud__exec")).toBe(true); + }); + + it("rejects other MCP tools", () => { + expect(isPostHogExecTool("mcp__posthog__list")).toBe(false); + expect(isPostHogExecTool("mcp__other__exec")).toBe(false); + expect(isPostHogExecTool("mcp__acp__Bash")).toBe(false); + expect(isPostHogExecTool("Bash")).toBe(false); + }); +}); + +describe("extractPostHogSubTool", () => { + it("parses a bare `call ` command", () => { + expect(extractPostHogSubTool({ command: "call experiment-update" })).toBe( + "experiment-update", + ); + }); + + it("parses `call --json `", () => { + expect( + extractPostHogSubTool({ + command: 'call --json experiment-update {"id":1}', + }), + ).toBe("experiment-update"); + }); + + it("tolerates leading whitespace", () => { + expect(extractPostHogSubTool({ command: " call foo-delete" })).toBe( + "foo-delete", + ); + }); + + it("returns null for non-`call` verbs", () => { + expect(extractPostHogSubTool({ command: "tools" })).toBeNull(); + expect(extractPostHogSubTool({ command: "search experiments" })).toBeNull(); + expect(extractPostHogSubTool({ command: "info flag-get" })).toBeNull(); + }); + + it("returns null for missing or malformed input", () => { + expect(extractPostHogSubTool(undefined)).toBeNull(); + expect(extractPostHogSubTool(null)).toBeNull(); + expect(extractPostHogSubTool({})).toBeNull(); + expect(extractPostHogSubTool({ command: 42 })).toBeNull(); + expect(extractPostHogSubTool({ command: "" })).toBeNull(); + }); +}); + +describe("isPostHogDestructiveSubTool", () => { + it("matches update/delete/destroy/partial-update as whole segments", () => { + expect(isPostHogDestructiveSubTool("experiment-update")).toBe(true); + expect(isPostHogDestructiveSubTool("feature-flag-delete")).toBe(true); + expect(isPostHogDestructiveSubTool("notebooks-destroy")).toBe(true); + expect(isPostHogDestructiveSubTool("experiment-partial-update")).toBe(true); + expect(isPostHogDestructiveSubTool("update-something")).toBe(true); + expect(isPostHogDestructiveSubTool("delete")).toBe(true); + }); + + it("does not match read verbs or unrelated tokens", () => { + expect(isPostHogDestructiveSubTool("experiment-get")).toBe(false); + expect(isPostHogDestructiveSubTool("feature-flag-list")).toBe(false); + expect(isPostHogDestructiveSubTool("experiment-create")).toBe(false); + expect(isPostHogDestructiveSubTool("insights-pause")).toBe(false); + }); + + it("does not match substrings inside other words", () => { + // "updated" should not count — must be a whole segment + expect(isPostHogDestructiveSubTool("get-updated-events")).toBe(false); + expect(isPostHogDestructiveSubTool("deleter-test")).toBe(false); + }); +}); diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts new file mode 100644 index 000000000..2ecdb8c6e --- /dev/null +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts @@ -0,0 +1,30 @@ +/** + * The PostHog MCP exposes a single `exec` dispatcher tool that runs + * subcommands like `call [--json] [json]`. Once the user approves + * `mcp__posthog__exec` once, every subsequent call goes through silently — + * including destructive ones. These helpers let `canUseTool` re-gate the + * destructive subset (update/delete family) at sub-tool granularity. + */ + +const POSTHOG_EXEC_TOOL_RE = /^mcp__posthog(?:_[^_]+)*__exec$/; + +const POSTHOG_CALL_COMMAND_RE = /^\s*call\s+(?:--json\s+)?([a-zA-Z0-9_-]+)/; + +const POSTHOG_DESTRUCTIVE_SUBTOOL_RE = + /(^|-)(partial-update|update|delete|destroy)(-|$)/i; + +export function isPostHogExecTool(toolName: string): boolean { + return POSTHOG_EXEC_TOOL_RE.test(toolName); +} + +export function extractPostHogSubTool(toolInput: unknown): string | null { + if (!toolInput || typeof toolInput !== "object") return null; + const command = (toolInput as { command?: unknown }).command; + if (typeof command !== "string") return null; + const match = command.match(POSTHOG_CALL_COMMAND_RE); + return match ? (match[1] ?? null) : null; +} + +export function isPostHogDestructiveSubTool(subTool: string): boolean { + return POSTHOG_DESTRUCTIVE_SUBTOOL_RE.test(subTool); +} diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index de31eb3d3..960c5d4f6 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -127,6 +127,56 @@ describe("SettingsManager per-repo persistence", () => { expect(await fs.promises.readFile(filePath, "utf-8")).toBe(original); }); + it("persists PostHog exec approvals and sees them across worktrees", async () => { + const writer = new SettingsManager(worktree); + await writer.initialize(); + await writer.addPostHogExecApproval("experiment-update"); + + const filePath = path.join(mainRepo, ".claude", "settings.local.json"); + const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8")); + expect(contents.posthogApprovedExecTools).toEqual(["experiment-update"]); + + const sibling = path.join(tmpRoot, "wt-ph"); + runGit(mainRepo, ["worktree", "add", "-b", "other-ph", sibling]); + const reader = new SettingsManager(sibling); + await reader.initialize(); + expect(reader.hasPostHogExecApproval("experiment-update")).toBe(true); + expect(reader.hasPostHogExecApproval("experiment-delete")).toBe(false); + }); + + it("dedupes repeated PostHog exec approvals", async () => { + const manager = new SettingsManager(worktree); + await manager.initialize(); + + await manager.addPostHogExecApproval("foo-update"); + await manager.addPostHogExecApproval("foo-update"); + await manager.addPostHogExecApproval("bar-delete"); + + const filePath = path.join(mainRepo, ".claude", "settings.local.json"); + const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8")); + expect(contents.posthogApprovedExecTools).toEqual([ + "foo-update", + "bar-delete", + ]); + }); + + it("concurrent addPostHogExecApproval calls do not clobber each other", async () => { + const manager = new SettingsManager(worktree); + await manager.initialize(); + + await Promise.all([ + manager.addPostHogExecApproval("a-update"), + manager.addPostHogExecApproval("b-delete"), + manager.addPostHogExecApproval("c-destroy"), + ]); + + const filePath = path.join(mainRepo, ".claude", "settings.local.json"); + const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8")); + expect(contents.posthogApprovedExecTools).toEqual( + expect.arrayContaining(["a-update", "b-delete", "c-destroy"]), + ); + }); + it("concurrent addAllowRules calls do not clobber each other", async () => { const manager = new SettingsManager(worktree); await manager.initialize(); diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index 67ce16670..0a2b8e39b 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -196,6 +196,7 @@ export interface ClaudeCodeSettings { permissions?: PermissionSettings; env?: Record; model?: string; + posthogApprovedExecTools?: string[]; } export type PermissionDecision = "allow" | "deny" | "ask"; @@ -295,6 +296,7 @@ export class SettingsManager { ask: [], }; const merged: ClaudeCodeSettings = { permissions }; + const posthogApprovedExecTools = new Set(); for (const settings of allSettings) { if (settings.permissions) { @@ -323,6 +325,15 @@ export class SettingsManager { if (settings.model) { merged.model = settings.model; } + if (settings.posthogApprovedExecTools) { + for (const tool of settings.posthogApprovedExecTools) { + posthogApprovedExecTools.add(tool); + } + } + } + + if (posthogApprovedExecTools.size > 0) { + merged.posthogApprovedExecTools = Array.from(posthogApprovedExecTools); } this.mergedSettings = merged; @@ -405,6 +416,43 @@ export class SettingsManager { } } + hasPostHogExecApproval(subTool: string): boolean { + return ( + this.mergedSettings.posthogApprovedExecTools?.includes(subTool) ?? false + ); + } + + /** + * Persists an approved PostHog MCP `exec` sub-tool (e.g. `experiment-update`) + * to the local settings file so future calls skip the prompt. Mirrors + * `addAllowRules` — serialised via `writeMutex`, atomic temp-file + rename. + */ + async addPostHogExecApproval(subTool: string): Promise { + if (!subTool) return; + if (!this.initialized) await this.initialize(); + await this.writeMutex.acquire(); + try { + const filePath = this.getLocalSettingsPath(); + const existing = await readSettingsFileForUpdate(filePath); + const current = new Set(existing.posthogApprovedExecTools ?? []); + if (current.has(subTool)) { + return; + } + current.add(subTool); + const next: ClaudeCodeSettings = { + ...existing, + posthogApprovedExecTools: Array.from(current), + }; + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); + + this.localSettings = next; + this.mergeAllSettings(); + } finally { + this.writeMutex.release(); + } + } + async setCwd(cwd: string): Promise { if (this.cwd === cwd) return; if (this.initPromise) await this.initPromise;