Skip to content

Commit f248727

Browse files
authored
feat(agent): gate destructive PostHog MCP exec sub-tools (#1991)
1 parent 519dfd4 commit f248727

8 files changed

Lines changed: 622 additions & 16 deletions

File tree

packages/agent/src/adapters/claude/hooks.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@ vi.mock("../../enrichment/file-enricher", () => ({
77
enrichFileForAgent: enrichFileMock,
88
}));
99

10-
import { createReadEnrichmentHook, type EnrichedReadCache } from "./hooks";
10+
import { Logger } from "../../utils/logger";
11+
import {
12+
createPreToolUseHook,
13+
createReadEnrichmentHook,
14+
type EnrichedReadCache,
15+
} from "./hooks";
16+
import type {
17+
PermissionCheckResult,
18+
SettingsManager,
19+
} from "./session/settings";
1120

1221
const stubDeps = {} as FileEnrichmentDeps;
1322

@@ -187,3 +196,118 @@ describe("createReadEnrichmentHook", () => {
187196
expect(content).toBe("foo");
188197
});
189198
});
199+
200+
function buildPreToolUseHookInput(
201+
toolName: string,
202+
toolInput: Record<string, unknown>,
203+
): HookInput {
204+
return {
205+
session_id: "test-session",
206+
transcript_path: "/tmp/transcript",
207+
cwd: "/tmp",
208+
hook_event_name: "PreToolUse",
209+
tool_name: toolName,
210+
tool_use_id: "toolu_1",
211+
tool_input: toolInput,
212+
} as HookInput;
213+
}
214+
215+
function buildSettingsManagerStub(
216+
result: PermissionCheckResult,
217+
): SettingsManager {
218+
return {
219+
checkPermission: () => result,
220+
} as unknown as SettingsManager;
221+
}
222+
223+
describe("createPreToolUseHook", () => {
224+
const logger = new Logger({ debug: false });
225+
226+
test("defers destructive PostHog exec sub-tool to canUseTool via ask", async () => {
227+
const settingsManager = buildSettingsManagerStub({
228+
decision: "allow",
229+
rule: "mcp__posthog__exec",
230+
source: "allow",
231+
});
232+
const hook = createPreToolUseHook(settingsManager, logger);
233+
const result = await hook(
234+
buildPreToolUseHookInput("mcp__posthog__exec", {
235+
command: 'call dashboard-update {"id": 1, "name": "x"}',
236+
}),
237+
undefined,
238+
{ signal: new AbortController().signal },
239+
);
240+
241+
expect(result).toMatchObject({
242+
continue: true,
243+
hookSpecificOutput: {
244+
hookEventName: "PreToolUse",
245+
permissionDecision: "ask",
246+
},
247+
});
248+
});
249+
250+
test("allows non-destructive PostHog exec sub-tool via settings rule", async () => {
251+
const settingsManager = buildSettingsManagerStub({
252+
decision: "allow",
253+
rule: "mcp__posthog__exec",
254+
source: "allow",
255+
});
256+
const hook = createPreToolUseHook(settingsManager, logger);
257+
const result = await hook(
258+
buildPreToolUseHookInput("mcp__posthog__exec", {
259+
command: 'call experiment-get {"id": 1}',
260+
}),
261+
undefined,
262+
{ signal: new AbortController().signal },
263+
);
264+
265+
expect(result).toEqual({
266+
continue: true,
267+
hookSpecificOutput: {
268+
hookEventName: "PreToolUse",
269+
permissionDecision: "allow",
270+
permissionDecisionReason:
271+
"Allowed by settings rule: mcp__posthog__exec",
272+
},
273+
});
274+
});
275+
276+
test("allows non-PostHog tool via settings rule unchanged", async () => {
277+
const settingsManager = buildSettingsManagerStub({
278+
decision: "allow",
279+
rule: "Bash(ls:*)",
280+
source: "allow",
281+
});
282+
const hook = createPreToolUseHook(settingsManager, logger);
283+
const result = await hook(
284+
buildPreToolUseHookInput("Bash", { command: "ls -la" }),
285+
undefined,
286+
{ signal: new AbortController().signal },
287+
);
288+
289+
expect(result).toMatchObject({
290+
hookSpecificOutput: { permissionDecision: "allow" },
291+
});
292+
});
293+
294+
test("defers when destructive rule is partial-update", async () => {
295+
const settingsManager = buildSettingsManagerStub({
296+
decision: "allow",
297+
rule: "mcp__posthog__exec",
298+
source: "allow",
299+
});
300+
const hook = createPreToolUseHook(settingsManager, logger);
301+
const result = await hook(
302+
buildPreToolUseHookInput("mcp__posthog__exec", {
303+
command: 'call cohorts-partial-update {"id": 1}',
304+
}),
305+
undefined,
306+
{ signal: new AbortController().signal },
307+
);
308+
309+
expect(result).toMatchObject({
310+
hookSpecificOutput: { permissionDecision: "ask" },
311+
});
312+
});
313+
});

packages/agent/src/adapters/claude/hooks.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import {
55
} from "../../enrichment/file-enricher";
66
import type { Logger } from "../../utils/logger";
77
import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
8+
import {
9+
extractPostHogSubTool,
10+
isPostHogDestructiveSubTool,
11+
isPostHogExecTool,
12+
} from "./permissions/posthog-exec-gate";
813
import type { SettingsManager } from "./session/settings";
914
import type { CodeExecutionMode } from "./tools";
1015

@@ -237,6 +242,25 @@ export const createPreToolUseHook =
237242
);
238243
}
239244

245+
// Defer destructive PostHog exec sub-tools to canUseTool so the
246+
// sub-tool gate can re-prompt. Returning `{ continue: true }` is
247+
// not enough — the SDK then falls back to its default permission
248+
// flow which re-checks the same allow rule. We must force "ask"
249+
// so the SDK invokes canUseTool.
250+
if (permissionCheck.decision === "allow" && isPostHogExecTool(toolName)) {
251+
const subTool = extractPostHogSubTool(toolInput);
252+
if (subTool && isPostHogDestructiveSubTool(subTool)) {
253+
return {
254+
continue: true,
255+
hookSpecificOutput: {
256+
hookEventName: "PreToolUse" as const,
257+
permissionDecision: "ask" as const,
258+
permissionDecisionReason: `Destructive PostHog sub-tool '${subTool}' requires explicit approval`,
259+
},
260+
};
261+
}
262+
}
263+
240264
switch (permissionCheck.decision) {
241265
case "allow":
242266
return {

packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,158 @@ describe("canUseTool MCP approval enforcement", () => {
143143
expect(result.behavior).toBe("allow");
144144
});
145145

146+
it("bypasses the PostHog exec gate in auto mode", async () => {
147+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
148+
const hasApproval = vi.fn().mockReturnValue(false);
149+
const addApproval = vi.fn().mockResolvedValue(undefined);
150+
151+
const context = createContext("mcp__posthog__exec", {
152+
toolInput: { command: "call experiment-update {}" },
153+
session: {
154+
permissionMode: "auto",
155+
settingsManager: {
156+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
157+
hasPostHogExecApproval: hasApproval,
158+
addPostHogExecApproval: addApproval,
159+
},
160+
},
161+
});
162+
const result = await canUseTool(context);
163+
164+
expect(result.behavior).toBe("allow");
165+
expect(context.client.requestPermission).not.toHaveBeenCalled();
166+
expect(hasApproval).not.toHaveBeenCalled();
167+
expect(addApproval).not.toHaveBeenCalled();
168+
});
169+
170+
it("bypasses the PostHog exec gate in bypassPermissions mode", async () => {
171+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
172+
173+
const context = createContext("mcp__posthog__exec", {
174+
toolInput: { command: "call feature-flag-delete {}" },
175+
session: {
176+
permissionMode: "bypassPermissions",
177+
settingsManager: {
178+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
179+
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
180+
addPostHogExecApproval: vi.fn(),
181+
},
182+
},
183+
});
184+
const result = await canUseTool(context);
185+
186+
expect(result.behavior).toBe("allow");
187+
expect(context.client.requestPermission).not.toHaveBeenCalled();
188+
});
189+
190+
it("short-circuits when a PostHog exec sub-tool was previously approved", async () => {
191+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
192+
193+
const context = createContext("mcp__posthog__exec", {
194+
toolInput: { command: "call experiment-update {}" },
195+
session: {
196+
permissionMode: "default",
197+
settingsManager: {
198+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
199+
hasPostHogExecApproval: vi
200+
.fn()
201+
.mockImplementation((s: string) => s === "experiment-update"),
202+
addPostHogExecApproval: vi.fn(),
203+
},
204+
},
205+
});
206+
const result = await canUseTool(context);
207+
208+
expect(result.behavior).toBe("allow");
209+
expect(context.client.requestPermission).not.toHaveBeenCalled();
210+
});
211+
212+
it("prompts for an unapproved destructive PostHog sub-tool and persists on allow_always", async () => {
213+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
214+
const addApproval = vi.fn().mockResolvedValue(undefined);
215+
216+
const context = createContext("mcp__posthog__exec", {
217+
toolInput: { command: "call notebooks-destroy {}" },
218+
session: {
219+
permissionMode: "default",
220+
settingsManager: {
221+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
222+
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
223+
addPostHogExecApproval: addApproval,
224+
},
225+
},
226+
client: {
227+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
228+
requestPermission: vi.fn().mockResolvedValue({
229+
outcome: { outcome: "selected", optionId: "allow_always" },
230+
}),
231+
},
232+
});
233+
const result = await canUseTool(context);
234+
235+
expect(result.behavior).toBe("allow");
236+
expect(context.client.requestPermission).toHaveBeenCalledWith(
237+
expect.objectContaining({
238+
toolCall: expect.objectContaining({
239+
title: "The agent wants to run `notebooks-destroy` on PostHog",
240+
}),
241+
}),
242+
);
243+
expect(addApproval).toHaveBeenCalledWith("notebooks-destroy");
244+
});
245+
246+
it("prompts but does not persist on allow_once", async () => {
247+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
248+
const addApproval = vi.fn();
249+
250+
const context = createContext("mcp__posthog__exec", {
251+
toolInput: { command: "call experiment-delete {}" },
252+
session: {
253+
permissionMode: "default",
254+
settingsManager: {
255+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
256+
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
257+
addPostHogExecApproval: addApproval,
258+
},
259+
},
260+
client: {
261+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
262+
requestPermission: vi.fn().mockResolvedValue({
263+
outcome: { outcome: "selected", optionId: "allow" },
264+
}),
265+
},
266+
});
267+
const result = await canUseTool(context);
268+
269+
expect(result.behavior).toBe("allow");
270+
expect(addApproval).not.toHaveBeenCalled();
271+
});
272+
273+
it("does not gate non-destructive PostHog sub-tools", async () => {
274+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
275+
const addApproval = vi.fn();
276+
277+
const context = createContext("mcp__posthog__exec", {
278+
toolInput: { command: "call experiment-get-all {}" },
279+
session: {
280+
permissionMode: "default",
281+
settingsManager: {
282+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
283+
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
284+
addPostHogExecApproval: addApproval,
285+
},
286+
},
287+
});
288+
const result = await canUseTool(context);
289+
290+
// Non-destructive sub-tool falls through the gate. With approved MCP state
291+
// and non-read-only tool metadata, it hits the default permission flow,
292+
// which auto-allows via our mocked requestPermission. The gate must not
293+
// have prompted with a PostHog-specific title, and must not have persisted.
294+
expect(result.behavior).toBe("allow");
295+
expect(addApproval).not.toHaveBeenCalled();
296+
});
297+
146298
it("emits tool denial notification for do_not_use", async () => {
147299
setMcpToolApprovalStates({
148300
mcp__server__denied_tool: "do_not_use",

0 commit comments

Comments
 (0)