@posthog/agent 2.3.504 → 2.3.508

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.504",
3
+ "version": "2.3.508",
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,8 +103,8 @@
103
103
  "typescript": "^5.5.0",
104
104
  "vitest": "^2.1.8",
105
105
  "@posthog/shared": "1.0.0",
106
- "@posthog/enricher": "1.0.0",
107
- "@posthog/git": "1.0.0"
106
+ "@posthog/git": "1.0.0",
107
+ "@posthog/enricher": "1.0.0"
108
108
  },
109
109
  "dependencies": {
110
110
  "@agentclientprotocol/sdk": "0.19.0",
@@ -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)) {
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ extractPostHogSubTool,
4
+ isPostHogDestructiveSubTool,
5
+ isPostHogExecTool,
6
+ } from "./posthog-exec-gate";
7
+
8
+ describe("isPostHogExecTool", () => {
9
+ it("matches the bare posthog exec tool", () => {
10
+ expect(isPostHogExecTool("mcp__posthog__exec")).toBe(true);
11
+ });
12
+
13
+ it("matches plugin-prefixed variants", () => {
14
+ expect(isPostHogExecTool("mcp__posthog_posthog__exec")).toBe(true);
15
+ expect(isPostHogExecTool("mcp__posthog_cloud__exec")).toBe(true);
16
+ });
17
+
18
+ it("rejects other MCP tools", () => {
19
+ expect(isPostHogExecTool("mcp__posthog__list")).toBe(false);
20
+ expect(isPostHogExecTool("mcp__other__exec")).toBe(false);
21
+ expect(isPostHogExecTool("mcp__acp__Bash")).toBe(false);
22
+ expect(isPostHogExecTool("Bash")).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("extractPostHogSubTool", () => {
27
+ it("parses a bare `call <tool>` command", () => {
28
+ expect(extractPostHogSubTool({ command: "call experiment-update" })).toBe(
29
+ "experiment-update",
30
+ );
31
+ });
32
+
33
+ it("parses `call --json <tool>`", () => {
34
+ expect(
35
+ extractPostHogSubTool({
36
+ command: 'call --json experiment-update {"id":1}',
37
+ }),
38
+ ).toBe("experiment-update");
39
+ });
40
+
41
+ it("tolerates leading whitespace", () => {
42
+ expect(extractPostHogSubTool({ command: " call foo-delete" })).toBe(
43
+ "foo-delete",
44
+ );
45
+ });
46
+
47
+ it("returns null for non-`call` verbs", () => {
48
+ expect(extractPostHogSubTool({ command: "tools" })).toBeNull();
49
+ expect(extractPostHogSubTool({ command: "search experiments" })).toBeNull();
50
+ expect(extractPostHogSubTool({ command: "info flag-get" })).toBeNull();
51
+ });
52
+
53
+ it("returns null for missing or malformed input", () => {
54
+ expect(extractPostHogSubTool(undefined)).toBeNull();
55
+ expect(extractPostHogSubTool(null)).toBeNull();
56
+ expect(extractPostHogSubTool({})).toBeNull();
57
+ expect(extractPostHogSubTool({ command: 42 })).toBeNull();
58
+ expect(extractPostHogSubTool({ command: "" })).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe("isPostHogDestructiveSubTool", () => {
63
+ it("matches update/delete/destroy/partial-update as whole segments", () => {
64
+ expect(isPostHogDestructiveSubTool("experiment-update")).toBe(true);
65
+ expect(isPostHogDestructiveSubTool("feature-flag-delete")).toBe(true);
66
+ expect(isPostHogDestructiveSubTool("notebooks-destroy")).toBe(true);
67
+ expect(isPostHogDestructiveSubTool("experiment-partial-update")).toBe(true);
68
+ expect(isPostHogDestructiveSubTool("update-something")).toBe(true);
69
+ expect(isPostHogDestructiveSubTool("delete")).toBe(true);
70
+ });
71
+
72
+ it("does not match read verbs or unrelated tokens", () => {
73
+ expect(isPostHogDestructiveSubTool("experiment-get")).toBe(false);
74
+ expect(isPostHogDestructiveSubTool("feature-flag-list")).toBe(false);
75
+ expect(isPostHogDestructiveSubTool("experiment-create")).toBe(false);
76
+ expect(isPostHogDestructiveSubTool("insights-pause")).toBe(false);
77
+ });
78
+
79
+ it("does not match substrings inside other words", () => {
80
+ // "updated" should not count — must be a whole segment
81
+ expect(isPostHogDestructiveSubTool("get-updated-events")).toBe(false);
82
+ expect(isPostHogDestructiveSubTool("deleter-test")).toBe(false);
83
+ });
84
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The PostHog MCP exposes a single `exec` dispatcher tool that runs
3
+ * subcommands like `call [--json] <tool-name> [json]`. Once the user approves
4
+ * `mcp__posthog__exec` once, every subsequent call goes through silently —
5
+ * including destructive ones. These helpers let `canUseTool` re-gate the
6
+ * destructive subset (update/delete family) at sub-tool granularity.
7
+ */
8
+
9
+ const POSTHOG_EXEC_TOOL_RE = /^mcp__posthog(?:_[^_]+)*__exec$/;
10
+
11
+ const POSTHOG_CALL_COMMAND_RE = /^\s*call\s+(?:--json\s+)?([a-zA-Z0-9_-]+)/;
12
+
13
+ const POSTHOG_DESTRUCTIVE_SUBTOOL_RE =
14
+ /(^|-)(partial-update|update|delete|destroy)(-|$)/i;
15
+
16
+ export function isPostHogExecTool(toolName: string): boolean {
17
+ return POSTHOG_EXEC_TOOL_RE.test(toolName);
18
+ }
19
+
20
+ export function extractPostHogSubTool(toolInput: unknown): string | null {
21
+ if (!toolInput || typeof toolInput !== "object") return null;
22
+ const command = (toolInput as { command?: unknown }).command;
23
+ if (typeof command !== "string") return null;
24
+ const match = command.match(POSTHOG_CALL_COMMAND_RE);
25
+ return match ? (match[1] ?? null) : null;
26
+ }
27
+
28
+ export function isPostHogDestructiveSubTool(subTool: string): boolean {
29
+ return POSTHOG_DESTRUCTIVE_SUBTOOL_RE.test(subTool);
30
+ }
@@ -127,6 +127,56 @@ describe("SettingsManager per-repo persistence", () => {
127
127
  expect(await fs.promises.readFile(filePath, "utf-8")).toBe(original);
128
128
  });
129
129
 
130
+ it("persists PostHog exec approvals and sees them across worktrees", async () => {
131
+ const writer = new SettingsManager(worktree);
132
+ await writer.initialize();
133
+ await writer.addPostHogExecApproval("experiment-update");
134
+
135
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
136
+ const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
137
+ expect(contents.posthogApprovedExecTools).toEqual(["experiment-update"]);
138
+
139
+ const sibling = path.join(tmpRoot, "wt-ph");
140
+ runGit(mainRepo, ["worktree", "add", "-b", "other-ph", sibling]);
141
+ const reader = new SettingsManager(sibling);
142
+ await reader.initialize();
143
+ expect(reader.hasPostHogExecApproval("experiment-update")).toBe(true);
144
+ expect(reader.hasPostHogExecApproval("experiment-delete")).toBe(false);
145
+ });
146
+
147
+ it("dedupes repeated PostHog exec approvals", async () => {
148
+ const manager = new SettingsManager(worktree);
149
+ await manager.initialize();
150
+
151
+ await manager.addPostHogExecApproval("foo-update");
152
+ await manager.addPostHogExecApproval("foo-update");
153
+ await manager.addPostHogExecApproval("bar-delete");
154
+
155
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
156
+ const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
157
+ expect(contents.posthogApprovedExecTools).toEqual([
158
+ "foo-update",
159
+ "bar-delete",
160
+ ]);
161
+ });
162
+
163
+ it("concurrent addPostHogExecApproval calls do not clobber each other", async () => {
164
+ const manager = new SettingsManager(worktree);
165
+ await manager.initialize();
166
+
167
+ await Promise.all([
168
+ manager.addPostHogExecApproval("a-update"),
169
+ manager.addPostHogExecApproval("b-delete"),
170
+ manager.addPostHogExecApproval("c-destroy"),
171
+ ]);
172
+
173
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
174
+ const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
175
+ expect(contents.posthogApprovedExecTools).toEqual(
176
+ expect.arrayContaining(["a-update", "b-delete", "c-destroy"]),
177
+ );
178
+ });
179
+
130
180
  it("concurrent addAllowRules calls do not clobber each other", async () => {
131
181
  const manager = new SettingsManager(worktree);
132
182
  await manager.initialize();