@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/dist/agent.js +257 -16
- 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 +257 -16
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +257 -16
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/claude/claude-agent.ts +10 -0
- package/src/adapters/claude/conversion/sdk-to-acp.ts +67 -6
- 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/adapters/claude/types.ts +10 -0
- package/src/utils/partial-json.test.ts +72 -0
- package/src/utils/partial-json.ts +68 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
[
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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)) {
|