@posthog/agent 2.3.507 → 2.3.510

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.507",
3
+ "version": "2.3.510",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -103,6 +103,7 @@ import type {
103
103
  SDKMessageFilter,
104
104
  Session,
105
105
  ToolUseCache,
106
+ ToolUseStreamCache,
106
107
  } from "./types";
107
108
 
108
109
  const SESSION_VALIDATION_TIMEOUT_MS = 30_000;
@@ -145,6 +146,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
145
146
  readonly adapterName = "claude";
146
147
  declare session: Session;
147
148
  toolUseCache: ToolUseCache;
149
+ toolUseStreamCache: ToolUseStreamCache;
148
150
  backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
149
151
  clientCapabilities?: ClientCapabilities;
150
152
  private options?: ClaudeAcpAgentOptions;
@@ -155,6 +157,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
155
157
  super(client);
156
158
  this.options = options;
157
159
  this.toolUseCache = {};
160
+ this.toolUseStreamCache = new Map();
158
161
  this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" });
159
162
  this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger);
160
163
  }
@@ -403,6 +406,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
403
406
  sessionId: params.sessionId,
404
407
  client: this.client,
405
408
  toolUseCache: this.toolUseCache,
409
+ toolUseStreamCache: this.toolUseStreamCache,
406
410
  fileContentCache: this.fileContentCache,
407
411
  enrichedReadCache: this.enrichedReadCache,
408
412
  logger: this.logger,
@@ -768,6 +772,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
768
772
  }
769
773
  throw error;
770
774
  } finally {
775
+ // Drop any leftover streaming-input buffers. Normally cleared per index
776
+ // on `content_block_stop`, but a cancelled or errored turn may leave
777
+ // entries behind; without this they'd carry over into the next turn
778
+ // and collide with new content-block indices.
779
+ this.toolUseStreamCache.clear();
771
780
  if (!handedOff) {
772
781
  this.session.promptRunning = false;
773
782
  // Resolve all remaining pending prompts so no callers get stuck.
@@ -1528,6 +1537,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1528
1537
  sessionId,
1529
1538
  client: this.client,
1530
1539
  toolUseCache: this.toolUseCache,
1540
+ toolUseStreamCache: this.toolUseStreamCache,
1531
1541
  fileContentCache: this.fileContentCache,
1532
1542
  enrichedReadCache: this.enrichedReadCache,
1533
1543
  logger: this.logger,
@@ -21,8 +21,14 @@ import { POSTHOG_NOTIFICATIONS } from "@/acp-extensions";
21
21
  import { image, text } from "../../../utils/acp-content";
22
22
  import { unreachable } from "../../../utils/common";
23
23
  import type { Logger } from "../../../utils/logger";
24
+ import { tryParsePartialJson } from "../../../utils/partial-json";
24
25
  import { type EnrichedReadCache, registerHookCallback } from "../hooks";
25
- import type { Session, ToolUpdateMeta, ToolUseCache } from "../types";
26
+ import type {
27
+ Session,
28
+ ToolUpdateMeta,
29
+ ToolUseCache,
30
+ ToolUseStreamCache,
31
+ } from "../types";
26
32
  import {
27
33
  type ClaudePlanEntry,
28
34
  planEntries,
@@ -67,6 +73,8 @@ export interface MessageHandlerContext {
67
73
  sessionId: string;
68
74
  client: AgentSideConnection;
69
75
  toolUseCache: ToolUseCache;
76
+ /** Buffers `input_json_delta` partial JSON per content-block index. */
77
+ toolUseStreamCache: ToolUseStreamCache;
70
78
  fileContentCache: { [key: string]: string };
71
79
  enrichedReadCache?: EnrichedReadCache;
72
80
  logger: Logger;
@@ -496,6 +504,7 @@ function streamEventToAcpNotifications(
496
504
  message: SDKPartialAssistantMessage,
497
505
  sessionId: string,
498
506
  toolUseCache: ToolUseCache,
507
+ toolUseStreamCache: ToolUseStreamCache,
499
508
  fileContentCache: { [key: string]: string },
500
509
  client: AgentSideConnection,
501
510
  logger: Logger,
@@ -507,9 +516,16 @@ function streamEventToAcpNotifications(
507
516
  ): SessionNotification[] {
508
517
  const event = message.event;
509
518
  switch (event.type) {
510
- case "content_block_start":
519
+ case "content_block_start": {
520
+ const block = event.content_block;
521
+ if (block.type === "tool_use" || block.type === "mcp_tool_use") {
522
+ toolUseStreamCache.set(event.index, {
523
+ toolUseId: block.id,
524
+ partialJson: "",
525
+ });
526
+ }
511
527
  return toAcpNotifications(
512
- [event.content_block],
528
+ [block],
513
529
  "assistant",
514
530
  sessionId,
515
531
  toolUseCache,
@@ -523,7 +539,16 @@ function streamEventToAcpNotifications(
523
539
  undefined,
524
540
  enrichedReadCache,
525
541
  );
526
- case "content_block_delta":
542
+ }
543
+ case "content_block_delta": {
544
+ if (event.delta.type === "input_json_delta") {
545
+ return inputJsonDeltaToAcpNotifications(
546
+ event.index,
547
+ event.delta.partial_json,
548
+ sessionId,
549
+ toolUseStreamCache,
550
+ );
551
+ }
527
552
  return toAcpNotifications(
528
553
  [event.delta],
529
554
  "assistant",
@@ -539,10 +564,13 @@ function streamEventToAcpNotifications(
539
564
  undefined,
540
565
  enrichedReadCache,
541
566
  );
567
+ }
568
+ case "content_block_stop":
569
+ toolUseStreamCache.delete(event.index);
570
+ return [];
542
571
  case "message_start":
543
572
  case "message_delta":
544
573
  case "message_stop":
545
- case "content_block_stop":
546
574
  return [];
547
575
 
548
576
  default:
@@ -551,6 +579,31 @@ function streamEventToAcpNotifications(
551
579
  }
552
580
  }
553
581
 
582
+ function inputJsonDeltaToAcpNotifications(
583
+ index: number,
584
+ partialJson: string,
585
+ sessionId: string,
586
+ toolUseStreamCache: ToolUseStreamCache,
587
+ ): SessionNotification[] {
588
+ const entry = toolUseStreamCache.get(index);
589
+ if (!entry) return [];
590
+ entry.partialJson += partialJson;
591
+
592
+ const parsed = tryParsePartialJson(entry.partialJson);
593
+ if (!parsed || typeof parsed !== "object") return [];
594
+
595
+ return [
596
+ {
597
+ sessionId,
598
+ update: {
599
+ sessionUpdate: "tool_call_update" as const,
600
+ toolCallId: entry.toolUseId,
601
+ rawInput: parsed as Record<string, unknown>,
602
+ },
603
+ },
604
+ ];
605
+ }
606
+
554
607
  export async function handleSystemMessage(
555
608
  message: Extract<SDKMessage, { type: "system" }>,
556
609
  context: MessageHandlerContext,
@@ -743,13 +796,21 @@ export async function handleStreamEvent(
743
796
  message: SDKPartialAssistantMessage,
744
797
  context: MessageHandlerContext,
745
798
  ): Promise<void> {
746
- const { sessionId, client, toolUseCache, fileContentCache, logger } = context;
799
+ const {
800
+ sessionId,
801
+ client,
802
+ toolUseCache,
803
+ toolUseStreamCache,
804
+ fileContentCache,
805
+ logger,
806
+ } = context;
747
807
  const parentToolCallId = message.parent_tool_use_id ?? undefined;
748
808
 
749
809
  for (const notification of streamEventToAcpNotifications(
750
810
  message,
751
811
  sessionId,
752
812
  toolUseCache,
813
+ toolUseStreamCache,
753
814
  fileContentCache,
754
815
  client,
755
816
  logger,
@@ -7,7 +7,16 @@ vi.mock("../../enrichment/file-enricher", () => ({
7
7
  enrichFileForAgent: enrichFileMock,
8
8
  }));
9
9
 
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";
11
20
 
12
21
  const stubDeps = {} as FileEnrichmentDeps;
13
22
 
@@ -187,3 +196,118 @@ describe("createReadEnrichmentHook", () => {
187
196
  expect(content).toBe("foo");
188
197
  });
189
198
  });
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
+ });
@@ -5,6 +5,11 @@ import {
5
5
  } from "../../enrichment/file-enricher";
6
6
  import type { Logger } from "../../utils/logger";
7
7
  import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
8
+ import {
9
+ extractPostHogSubTool,
10
+ isPostHogDestructiveSubTool,
11
+ isPostHogExecTool,
12
+ } from "./permissions/posthog-exec-gate";
8
13
  import type { SettingsManager } from "./session/settings";
9
14
  import type { CodeExecutionMode } from "./tools";
10
15
 
@@ -237,6 +242,25 @@ export const createPreToolUseHook =
237
242
  );
238
243
  }
239
244
 
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
+
240
264
  switch (permissionCheck.decision) {
241
265
  case "allow":
242
266
  return {
@@ -143,6 +143,158 @@ describe("canUseTool MCP approval enforcement", () => {
143
143
  expect(result.behavior).toBe("allow");
144
144
  });
145
145
 
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
+
146
298
  it("emits tool denial notification for do_not_use", async () => {
147
299
  setMcpToolApprovalStates({
148
300
  mcp__server__denied_tool: "do_not_use",
@@ -31,6 +31,11 @@ import {
31
31
  buildExitPlanModePermissionOptions,
32
32
  buildPermissionOptions,
33
33
  } from "./permission-options";
34
+ import {
35
+ extractPostHogSubTool,
36
+ isPostHogDestructiveSubTool,
37
+ isPostHogExecTool,
38
+ } from "./posthog-exec-gate";
34
39
 
35
40
  export type ToolPermissionResult =
36
41
  | {
@@ -78,6 +83,18 @@ async function emitToolDenial(
78
83
  });
79
84
  }
80
85
 
86
+ async function buildDenialResult(
87
+ context: ToolHandlerContext,
88
+ response: RequestPermissionResponse,
89
+ ): Promise<ToolPermissionResult> {
90
+ const feedback = (response._meta?.customInput as string | undefined)?.trim();
91
+ const message = feedback
92
+ ? `User refused permission to run tool with feedback: ${feedback}`
93
+ : "User refused permission to run tool";
94
+ await emitToolDenial(context, message);
95
+ return { behavior: "deny", message, interrupt: !feedback };
96
+ }
97
+
81
98
  function getPlanFromFile(
82
99
  session: Session,
83
100
  fileContentCache: { [key: string]: string },
@@ -389,16 +406,9 @@ async function handleDefaultPermissionFlow(
389
406
  behavior: "allow",
390
407
  updatedInput: toolInput as Record<string, unknown>,
391
408
  };
392
- } else {
393
- const feedback = (
394
- response._meta?.customInput as string | undefined
395
- )?.trim();
396
- const message = feedback
397
- ? `User refused permission to run tool with feedback: ${feedback}`
398
- : "User refused permission to run tool";
399
- await emitToolDenial(context, message);
400
- return { behavior: "deny", message, interrupt: !feedback };
401
409
  }
410
+
411
+ return buildDenialResult(context, response);
402
412
  }
403
413
 
404
414
  function parseMcpToolName(toolName: string): {
@@ -479,12 +489,74 @@ async function handleMcpApprovalFlow(
479
489
  };
480
490
  }
481
491
 
482
- const feedback = (response._meta?.customInput as string | undefined)?.trim();
483
- const message = feedback
484
- ? `User refused permission to run tool with feedback: ${feedback}`
485
- : "User refused permission to run tool";
486
- await emitToolDenial(context, message);
487
- return { behavior: "deny", message, interrupt: !feedback };
492
+ return buildDenialResult(context, response);
493
+ }
494
+
495
+ async function handlePostHogExecApprovalFlow(
496
+ context: ToolHandlerContext,
497
+ subTool: string,
498
+ ): Promise<ToolPermissionResult> {
499
+ const { toolName, toolInput, toolUseID, client, sessionId, session } =
500
+ context;
501
+
502
+ const response = await client.requestPermission({
503
+ options: [
504
+ { kind: "allow_once", name: "Yes", optionId: "allow" },
505
+ {
506
+ kind: "allow_always",
507
+ name: "Yes, always allow",
508
+ optionId: "allow_always",
509
+ },
510
+ {
511
+ kind: "reject_once",
512
+ name: "Type here to tell the agent what to do differently",
513
+ optionId: "reject",
514
+ _meta: { customInput: true },
515
+ },
516
+ ],
517
+ sessionId,
518
+ toolCall: {
519
+ toolCallId: toolUseID,
520
+ title: `The agent wants to run \`${subTool}\` on PostHog`,
521
+ kind: "other",
522
+ content: [
523
+ {
524
+ type: "content" as const,
525
+ content: text(
526
+ "This will modify live PostHog data. Approve to run this sub-tool.",
527
+ ),
528
+ },
529
+ ],
530
+ rawInput: { ...(toolInput as Record<string, unknown>), toolName },
531
+ },
532
+ });
533
+
534
+ if (context.signal?.aborted || response.outcome?.outcome === "cancelled") {
535
+ throw new Error("Tool use aborted");
536
+ }
537
+
538
+ if (
539
+ response.outcome?.outcome === "selected" &&
540
+ (response.outcome.optionId === "allow" ||
541
+ response.outcome.optionId === "allow_always")
542
+ ) {
543
+ if (response.outcome.optionId === "allow_always") {
544
+ try {
545
+ await session.settingsManager.addPostHogExecApproval(subTool);
546
+ } catch (error) {
547
+ context.logger.warn(
548
+ "[canUseTool] Failed to persist PostHog exec approval",
549
+ { error: error instanceof Error ? error.message : String(error) },
550
+ );
551
+ }
552
+ }
553
+ return {
554
+ behavior: "allow",
555
+ updatedInput: toolInput as Record<string, unknown>,
556
+ };
557
+ }
558
+
559
+ return buildDenialResult(context, response);
488
560
  }
489
561
 
490
562
  function handlePlanFileException(
@@ -602,6 +674,28 @@ export async function canUseTool(
602
674
  if (approvalState === "needs_approval") {
603
675
  return handleMcpApprovalFlow(context);
604
676
  }
677
+
678
+ if (isPostHogExecTool(toolName)) {
679
+ const subTool = extractPostHogSubTool(toolInput);
680
+ if (subTool && isPostHogDestructiveSubTool(subTool)) {
681
+ if (
682
+ session.permissionMode === "auto" ||
683
+ session.permissionMode === "bypassPermissions"
684
+ ) {
685
+ return {
686
+ behavior: "allow",
687
+ updatedInput: toolInput as Record<string, unknown>,
688
+ };
689
+ }
690
+ if (session.settingsManager.hasPostHogExecApproval(subTool)) {
691
+ return {
692
+ behavior: "allow",
693
+ updatedInput: toolInput as Record<string, unknown>,
694
+ };
695
+ }
696
+ return handlePostHogExecApprovalFlow(context, subTool);
697
+ }
698
+ }
605
699
  }
606
700
 
607
701
  if (isToolAllowedForMode(toolName, session.permissionMode)) {