@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/dist/agent.js +154 -10
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +166 -12
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +166 -12
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/adapters/claude/hooks.test.ts +125 -1
- package/src/adapters/claude/hooks.ts +24 -0
- package/src/adapters/claude/permissions/permission-handlers.test.ts +152 -0
- package/src/adapters/claude/permissions/permission-handlers.ts +109 -15
- package/src/adapters/claude/permissions/posthog-exec-gate.test.ts +84 -0
- package/src/adapters/claude/permissions/posthog-exec-gate.ts +30 -0
- package/src/adapters/claude/session/settings.test.ts +50 -0
- package/src/adapters/claude/session/settings.ts +48 -0
- package/src/server/agent-server.test.ts +43 -0
- package/src/server/agent-server.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
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/
|
|
107
|
-
"@posthog/
|
|
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 {
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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();
|