@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.
@@ -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();
@@ -196,6 +196,7 @@ export interface ClaudeCodeSettings {
196
196
  permissions?: PermissionSettings;
197
197
  env?: Record<string, string>;
198
198
  model?: string;
199
+ posthogApprovedExecTools?: string[];
199
200
  }
200
201
 
201
202
  export type PermissionDecision = "allow" | "deny" | "ask";
@@ -295,6 +296,7 @@ export class SettingsManager {
295
296
  ask: [],
296
297
  };
297
298
  const merged: ClaudeCodeSettings = { permissions };
299
+ const posthogApprovedExecTools = new Set<string>();
298
300
 
299
301
  for (const settings of allSettings) {
300
302
  if (settings.permissions) {
@@ -323,6 +325,15 @@ export class SettingsManager {
323
325
  if (settings.model) {
324
326
  merged.model = settings.model;
325
327
  }
328
+ if (settings.posthogApprovedExecTools) {
329
+ for (const tool of settings.posthogApprovedExecTools) {
330
+ posthogApprovedExecTools.add(tool);
331
+ }
332
+ }
333
+ }
334
+
335
+ if (posthogApprovedExecTools.size > 0) {
336
+ merged.posthogApprovedExecTools = Array.from(posthogApprovedExecTools);
326
337
  }
327
338
 
328
339
  this.mergedSettings = merged;
@@ -405,6 +416,43 @@ export class SettingsManager {
405
416
  }
406
417
  }
407
418
 
419
+ hasPostHogExecApproval(subTool: string): boolean {
420
+ return (
421
+ this.mergedSettings.posthogApprovedExecTools?.includes(subTool) ?? false
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Persists an approved PostHog MCP `exec` sub-tool (e.g. `experiment-update`)
427
+ * to the local settings file so future calls skip the prompt. Mirrors
428
+ * `addAllowRules` — serialised via `writeMutex`, atomic temp-file + rename.
429
+ */
430
+ async addPostHogExecApproval(subTool: string): Promise<void> {
431
+ if (!subTool) return;
432
+ if (!this.initialized) await this.initialize();
433
+ await this.writeMutex.acquire();
434
+ try {
435
+ const filePath = this.getLocalSettingsPath();
436
+ const existing = await readSettingsFileForUpdate(filePath);
437
+ const current = new Set(existing.posthogApprovedExecTools ?? []);
438
+ if (current.has(subTool)) {
439
+ return;
440
+ }
441
+ current.add(subTool);
442
+ const next: ClaudeCodeSettings = {
443
+ ...existing,
444
+ posthogApprovedExecTools: Array.from(current),
445
+ };
446
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
447
+ await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
448
+
449
+ this.localSettings = next;
450
+ this.mergeAllSettings();
451
+ } finally {
452
+ this.writeMutex.release();
453
+ }
454
+ }
455
+
408
456
  async setCwd(cwd: string): Promise<void> {
409
457
  if (this.cwd === cwd) return;
410
458
  if (this.initPromise) await this.initPromise;
@@ -76,6 +76,16 @@ export type ToolUseCache = {
76
76
  };
77
77
  };
78
78
 
79
+ /**
80
+ * Per-content-block-index buffer for tool inputs streamed via
81
+ * `input_json_delta` events. Keyed by the Anthropic content block index
82
+ * (which resets per assistant message). Cleared on `content_block_stop`.
83
+ */
84
+ export type ToolUseStreamCache = Map<
85
+ number,
86
+ { toolUseId: string; partialJson: string }
87
+ >;
88
+
79
89
  export type TerminalInfo = {
80
90
  terminal_id: string;
81
91
  };
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { tryParsePartialJson } from "./partial-json";
3
+
4
+ describe("tryParsePartialJson", () => {
5
+ it("returns null for empty / whitespace input", () => {
6
+ expect(tryParsePartialJson("")).toBeNull();
7
+ expect(tryParsePartialJson(" ")).toBeNull();
8
+ });
9
+
10
+ it("parses complete JSON unchanged", () => {
11
+ expect(tryParsePartialJson('{"a":1}')).toEqual({ a: 1 });
12
+ expect(tryParsePartialJson("[1,2,3]")).toEqual([1, 2, 3]);
13
+ expect(tryParsePartialJson('"hello"')).toBe("hello");
14
+ });
15
+
16
+ it("closes a single open object", () => {
17
+ expect(tryParsePartialJson("{")).toEqual({});
18
+ });
19
+
20
+ it("closes a partial string value and the surrounding object", () => {
21
+ expect(tryParsePartialJson('{"command": "call execute-')).toEqual({
22
+ command: "call execute-",
23
+ });
24
+ });
25
+
26
+ it("closes a complete string value with no closing brace", () => {
27
+ expect(tryParsePartialJson('{"command": "tools"')).toEqual({
28
+ command: "tools",
29
+ });
30
+ });
31
+
32
+ it("strips a trailing comma after a complete entry", () => {
33
+ expect(tryParsePartialJson('{"a": 1,')).toEqual({ a: 1 });
34
+ });
35
+
36
+ it("drops a trailing partial key with no value", () => {
37
+ expect(tryParsePartialJson('{"a": 1, "b":')).toEqual({ a: 1 });
38
+ expect(tryParsePartialJson('{"a": 1, "b"')).toEqual({ a: 1 });
39
+ });
40
+
41
+ it("handles nested objects and arrays mid-stream", () => {
42
+ expect(tryParsePartialJson('{"q": {"sql": "SELECT 1')).toEqual({
43
+ q: { sql: "SELECT 1" },
44
+ });
45
+ expect(tryParsePartialJson('{"items": [1, 2, 3')).toEqual({
46
+ items: [1, 2, 3],
47
+ });
48
+ });
49
+
50
+ it("respects escaped quotes inside strings", () => {
51
+ expect(tryParsePartialJson('{"q": "say \\"hi\\"')).toEqual({
52
+ q: 'say "hi"',
53
+ });
54
+ });
55
+
56
+ it("returns null when nothing parseable can be reconstructed", () => {
57
+ // Garbage that can't be balanced into valid JSON.
58
+ expect(tryParsePartialJson("not json at all")).toBeNull();
59
+ });
60
+
61
+ it("parses a typical exec command incrementally", () => {
62
+ // Simulate growth of a streamed { command: "call dashboard-update {...}" }
63
+ expect(tryParsePartialJson('{"command":')).toEqual({});
64
+ expect(tryParsePartialJson('{"command": "ca')).toEqual({ command: "ca" });
65
+ expect(
66
+ tryParsePartialJson('{"command": "call dashboard-update {\\"id\\":'),
67
+ ).toEqual({ command: 'call dashboard-update {"id":' });
68
+ expect(
69
+ tryParsePartialJson('{"command": "call dashboard-update {\\"id\\": 1}"}'),
70
+ ).toEqual({ command: 'call dashboard-update {"id": 1}' });
71
+ });
72
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Best-effort parser for incomplete JSON streamed via Anthropic's
3
+ * `input_json_delta` events. Used to surface tool inputs while they are still
4
+ * being generated so the UI can show the args during execution instead of
5
+ * waiting for the finalized assistant message.
6
+ *
7
+ * Strategy: walk the input tracking open `{`/`[` and quote/escape state, then
8
+ * try a few completions in order of likelihood (close any open string, drop
9
+ * trailing commas/colons or partial keys, then close any open brackets).
10
+ * Returns `null` when no completion parses — callers should silently skip
11
+ * that delta and wait for more input.
12
+ */
13
+ export function tryParsePartialJson(s: string): unknown {
14
+ const trimmed = s.trim();
15
+ if (!trimmed) return null;
16
+
17
+ // Fast path: complete JSON.
18
+ try {
19
+ return JSON.parse(trimmed);
20
+ } catch {}
21
+
22
+ const closers: string[] = [];
23
+ let inString = false;
24
+ let escaped = false;
25
+
26
+ for (let i = 0; i < trimmed.length; i++) {
27
+ const ch = trimmed[i];
28
+ if (inString) {
29
+ if (escaped) {
30
+ escaped = false;
31
+ } else if (ch === "\\") {
32
+ escaped = true;
33
+ } else if (ch === '"') {
34
+ inString = false;
35
+ }
36
+ continue;
37
+ }
38
+ if (ch === '"') inString = true;
39
+ else if (ch === "{") closers.push("}");
40
+ else if (ch === "[") closers.push("]");
41
+ else if (ch === "}" || ch === "]") closers.pop();
42
+ }
43
+
44
+ const closeBrackets = (str: string): string => {
45
+ let out = str;
46
+ for (let i = closers.length - 1; i >= 0; i--) out += closers[i];
47
+ return out;
48
+ };
49
+
50
+ const candidates: string[] = [];
51
+
52
+ // 1. Close any open string + brackets.
53
+ const closedString = inString ? `${trimmed}"` : trimmed;
54
+ candidates.push(closeBrackets(closedString));
55
+
56
+ // 2. Drop trailing partial token (comma, colon, or `"key":`/`"key"`)
57
+ // and close brackets.
58
+ let stripped = closedString.replace(/[,:]\s*$/, "");
59
+ stripped = stripped.replace(/,?\s*"[^"]*"\s*:?\s*$/, "");
60
+ candidates.push(closeBrackets(stripped));
61
+
62
+ for (const candidate of candidates) {
63
+ try {
64
+ return JSON.parse(candidate);
65
+ } catch {}
66
+ }
67
+ return null;
68
+ }