Skip to content

Commit c688e59

Browse files
skoob13claude
andauthored
feat(agent): configure PostHog exec permissions (#3578)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 68adf20 commit c688e59

25 files changed

Lines changed: 1324 additions & 249 deletions

packages/agent/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ Four modes defined in `src/execution-mode.ts`:
7474

7575
In cloud background mode, permissions are always auto-approved. In interactive mode, the permission system is active and configurable per session. Tool categorization lives in `src/adapters/claude/tools.ts` — each tool belongs to a group (read, write, bash, search, web, agent) and modes whitelist groups.
7676

77+
Cloud provisioning can pass `--posthogExecPermissionRegex <regex>` to require one-time client approval for matching PostHog MCP `exec` sub-tools in every interactive cloud Claude and Codex permission mode. Non-matching sub-tools never prompt. Locally, hands-off modes stay hands-off: Claude `auto` and `bypassPermissions`, and Codex `auto` and `full-access`, auto-approve matching sub-tools; other local modes prompt. Matching is case-insensitive against the delegated name in `call [--json] <sub-tool> ...`. These prompts offer Claude users an always-allow choice remembered in local repository settings; Codex approvals remain one-time. An invalid or empty regex is logged and falls back to the default. Background runs keep their existing auto-approval behavior. The default is `(^|-)(partial-update|update|patch|delete|destroy)(-|$)`.
78+
7779
## ACP connection layer
7880

7981
`createAcpConnection()` in `src/adapters/acp-connection.ts` is the heart of the package. It's a factory that returns a `{ clientStreams, cleanup }` object — a pair of ndJson `ReadableStream`/`WritableStream` that the caller uses to speak ACP.
@@ -142,9 +144,9 @@ JWT validation (`src/server/jwt.ts`) uses RS256 with a configurable public key.
142144

143145
When `POST /command` receives a `user_message`, it doesn't handle it directly — it calls `clientConnection.prompt()` on the ACP `ClientSideConnection`, which sends a `session/prompt` message through the ACP streams to the agent. Similarly, `cancel` sends `session/cancel`. This means all commands follow the same path as in-process calls from PostHog Code, with the HTTP layer just being a thin translation.
144146

145-
### Auto-approval in cloud mode
147+
### Permission routing in cloud mode
146148

147-
The `AgentServer` provides a `requestPermission` callback to the `ClientSideConnection` that always selects the "allow" option. In background mode this is necessary (no human to ask). In interactive mode it currently does the same, with a TODO for future per-tool approval via SSE round-trips.
149+
The `AgentServer` provides the `requestPermission` callback to the `ClientSideConnection`. Background mode selects an allow option automatically. Interactive mode relays approvals that need a person over SSE and parks them until a client responds; other requests follow the selected permission mode.
148150

149151
### Checkpoint capture
150152

@@ -157,6 +159,7 @@ npx agent-server \
157159
--port 3001 \
158160
--mode interactive \
159161
--repositoryPath /path/to/repo \
162+
--posthogExecPermissionRegex '(^|-)(partial-update|update|patch|delete|destroy)(-|$)' \
160163
--taskId task_123 \
161164
--runId run_456
162165
```

packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { mkdtempSync, rmSync } from "node:fs";
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
import * as os from "node:os";
33
import * as path from "node:path";
44
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
5+
import type { HookInput, Options } from "@anthropic-ai/claude-agent-sdk";
56
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
67
import { DEFAULT_GATEWAY_MODEL } from "../../gateway-models";
78

@@ -38,11 +39,13 @@ function makeQueryHandle(): SdkQueryHandle {
3839
}
3940

4041
const createdQueries: SdkQueryHandle[] = [];
42+
const createdQueryOptions: Options[] = [];
4143

4244
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
43-
query: vi.fn(() => {
45+
query: vi.fn(({ options }: { options: Options }) => {
4446
const handle = makeQueryHandle();
4547
createdQueries.push(handle);
48+
createdQueryOptions.push(options);
4649
return handle;
4750
}),
4851
getSessionMessages: vi.fn().mockResolvedValue([]),
@@ -87,6 +90,14 @@ const cwd = mkdtempSync(path.join(os.tmpdir(), "claude-agent-test-cwd-"));
8790
const configDir = mkdtempSync(
8891
path.join(os.tmpdir(), "claude-agent-test-config-"),
8992
);
93+
const permissionCwd = mkdtempSync(
94+
path.join(os.tmpdir(), "claude-agent-permission-test-cwd-"),
95+
);
96+
mkdirSync(path.join(permissionCwd, ".claude"), { recursive: true });
97+
writeFileSync(
98+
path.join(permissionCwd, ".claude", "settings.json"),
99+
JSON.stringify({ permissions: { allow: ["mcp__posthog__exec"] } }),
100+
);
90101
const savedEnv = {
91102
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
92103
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
@@ -95,6 +106,7 @@ const savedEnv = {
95106
afterAll(() => {
96107
rmSync(cwd, { recursive: true, force: true });
97108
rmSync(configDir, { recursive: true, force: true });
109+
rmSync(permissionCwd, { recursive: true, force: true });
98110
process.env.ANTHROPIC_BASE_URL = savedEnv.ANTHROPIC_BASE_URL;
99111
process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
100112
if (savedEnv.ANTHROPIC_BASE_URL === undefined) {
@@ -105,9 +117,10 @@ afterAll(() => {
105117
}
106118
});
107119

108-
describe("ClaudeAcpAgent session model on resume", () => {
120+
describe("ClaudeAcpAgent session creation", () => {
109121
beforeEach(() => {
110122
createdQueries.length = 0;
123+
createdQueryOptions.length = 0;
111124
nextInitPromise = Promise.resolve({
112125
result: "success",
113126
commands: [],
@@ -119,6 +132,154 @@ describe("ClaudeAcpAgent session model on resume", () => {
119132
process.env.CLAUDE_CONFIG_DIR = configDir;
120133
});
121134

135+
async function runPostHogExecPreToolUse(
136+
options: Options,
137+
subTool: string,
138+
): Promise<string | undefined> {
139+
const input = {
140+
session_id: "permission-session",
141+
transcript_path: "/tmp/transcript",
142+
cwd: permissionCwd,
143+
hook_event_name: "PreToolUse",
144+
tool_name: "mcp__posthog__exec",
145+
tool_use_id: "toolu_permission",
146+
tool_input: { command: `call ${subTool} {}` },
147+
} as HookInput;
148+
149+
for (const hook of (options.hooks?.PreToolUse ?? []).flatMap(
150+
(entry) => entry.hooks ?? [],
151+
)) {
152+
const result = await hook(input, undefined, {
153+
signal: new AbortController().signal,
154+
});
155+
const decision = (
156+
result as {
157+
hookSpecificOutput?: { permissionDecision?: string };
158+
}
159+
).hookSpecificOutput?.permissionDecision;
160+
if (decision) return decision;
161+
}
162+
163+
return undefined;
164+
}
165+
166+
it.each(["new", "resume", "load"] as const)(
167+
"uses the default PostHog exec permission regex for local %s sessions when metadata omits it",
168+
async (sessionKind) => {
169+
const agent = makeAgent();
170+
const sessionIds = {
171+
new: "0197a000-0000-7000-8000-000000000101",
172+
resume: "0197a000-0000-7000-8000-000000000102",
173+
load: "0197a000-0000-7000-8000-000000000103",
174+
};
175+
const params = {
176+
sessionId: sessionIds[sessionKind],
177+
cwd: permissionCwd,
178+
mcpServers: [],
179+
_meta: { taskRunId: `run-permission-${sessionKind}` },
180+
};
181+
182+
if (sessionKind === "new") {
183+
await agent.newSession(params);
184+
} else if (sessionKind === "resume") {
185+
await agent.resumeSession(params);
186+
} else {
187+
await agent.loadSession(params);
188+
}
189+
190+
expect(createdQueryOptions).toHaveLength(1);
191+
await expect(
192+
runPostHogExecPreToolUse(
193+
createdQueryOptions[0] as Options,
194+
"dashboard-update",
195+
),
196+
).resolves.toBe("ask");
197+
},
198+
);
199+
200+
it("uses an explicit PostHog exec permission regex instead of the default", async () => {
201+
const agent = makeAgent();
202+
203+
await agent.newSession({
204+
cwd: permissionCwd,
205+
mcpServers: [],
206+
_meta: {
207+
taskRunId: "run-permission-custom",
208+
posthogExecPermissionRegex: "(^|-)archive(-|$)",
209+
},
210+
});
211+
212+
expect(createdQueryOptions).toHaveLength(1);
213+
await expect(
214+
runPostHogExecPreToolUse(
215+
createdQueryOptions[0] as Options,
216+
"dashboard-update",
217+
),
218+
).resolves.toBe("allow");
219+
await expect(
220+
runPostHogExecPreToolUse(
221+
createdQueryOptions[0] as Options,
222+
"dashboard-archive",
223+
),
224+
).resolves.toBe("ask");
225+
});
226+
227+
it.each(["[", ""])(
228+
"warns and falls back to the default regex when metadata carries the invalid regex %j",
229+
async (posthogExecPermissionRegex) => {
230+
const agent = makeAgent();
231+
const warnSpy = vi.spyOn(agent.logger, "warn");
232+
233+
await agent.newSession({
234+
cwd: permissionCwd,
235+
mcpServers: [],
236+
_meta: {
237+
taskRunId: "run-permission-invalid",
238+
posthogExecPermissionRegex,
239+
},
240+
});
241+
242+
expect(warnSpy).toHaveBeenCalledWith(
243+
expect.stringContaining("Invalid posthogExecPermissionRegex"),
244+
expect.anything(),
245+
);
246+
expect(createdQueryOptions).toHaveLength(1);
247+
await expect(
248+
runPostHogExecPreToolUse(
249+
createdQueryOptions[0] as Options,
250+
"dashboard-update",
251+
),
252+
).resolves.toBe("ask");
253+
await expect(
254+
runPostHogExecPreToolUse(
255+
createdQueryOptions[0] as Options,
256+
"dashboard-get",
257+
),
258+
).resolves.toBe("allow");
259+
},
260+
);
261+
262+
it.each([
263+
{ environment: "local", expectedCloudMode: false },
264+
{ environment: "cloud", expectedCloudMode: true },
265+
] as const)(
266+
"records $environment sessions as cloudMode=$expectedCloudMode",
267+
async ({ environment, expectedCloudMode }) => {
268+
const agent = makeAgent();
269+
270+
await agent.newSession({
271+
cwd,
272+
mcpServers: [],
273+
_meta: { environment, taskRunId: `run-${environment}` },
274+
});
275+
276+
expect(
277+
(agent as unknown as { session: { cloudMode: boolean } }).session
278+
.cloudMode,
279+
).toBe(expectedCloudMode);
280+
},
281+
);
282+
122283
// The SDK does not carry the model across resume — without an explicit
123284
// setModel the resumed session silently runs the SDK default (opus).
124285
it.each([

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
type Enrichment,
5858
type FileEnrichmentDeps,
5959
} from "../../enrichment/file-enricher";
60+
import { resolvePostHogExecPermissionRegex } from "../../posthog-exec-permission";
6061
import {
6162
classifyPostHogExecCall,
6263
isUnclassifiedPostHogSubTool,
@@ -1977,12 +1978,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19771978
CODE_EXECUTION_MODES.includes(meta.permissionMode as CodeExecutionMode)
19781979
? (meta.permissionMode as CodeExecutionMode)
19791980
: "default";
1981+
const posthogExecPermissionRegex = resolvePostHogExecPermissionRegex(
1982+
meta?.posthogExecPermissionRegex,
1983+
(message) =>
1984+
this.logger.warn(
1985+
"Invalid posthogExecPermissionRegex in session metadata; using default",
1986+
{ message },
1987+
),
1988+
);
19801989

19811990
const taskState: TaskState = new Map();
19821991
const options = buildSessionOptions({
19831992
cwd,
19841993
mcpServers,
19851994
permissionMode,
1995+
posthogExecPermissionRegex,
19861996
canUseTool: this.createCanUseTool(sessionId, meta?.allowedDomains),
19871997
logger: this.logger,
19881998
systemPrompt,
@@ -2037,6 +2047,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
20372047
cancelled: false,
20382048
settingsManager,
20392049
permissionMode,
2050+
cloudMode: cloudRun,
2051+
posthogExecPermissionRegex,
20402052
abortController,
20412053
accumulatedUsage: {
20422054
inputTokens: 0,

packages/agent/src/adapters/claude/hooks.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,14 +351,20 @@ function buildSettingsManagerStub(
351351

352352
describe("createPreToolUseHook", () => {
353353
const logger = new Logger({ debug: false });
354+
const posthogExecPermissionRegex =
355+
/(^|-)(partial-update|update|patch|delete|destroy)(-|$)/i;
354356

355357
test("defers destructive PostHog exec sub-tool to canUseTool via ask", async () => {
356358
const settingsManager = buildSettingsManagerStub({
357359
decision: "allow",
358360
rule: "mcp__posthog__exec",
359361
source: "allow",
360362
});
361-
const hook = createPreToolUseHook(settingsManager, logger);
363+
const hook = createPreToolUseHook(
364+
settingsManager,
365+
logger,
366+
posthogExecPermissionRegex,
367+
);
362368
const result = await hook(
363369
buildPreToolUseHookInput("mcp__posthog__exec", {
364370
command: 'call dashboard-update {"id": 1, "name": "x"}',
@@ -382,7 +388,11 @@ describe("createPreToolUseHook", () => {
382388
rule: "mcp__posthog__exec",
383389
source: "allow",
384390
});
385-
const hook = createPreToolUseHook(settingsManager, logger);
391+
const hook = createPreToolUseHook(
392+
settingsManager,
393+
logger,
394+
posthogExecPermissionRegex,
395+
);
386396
const result = await hook(
387397
buildPreToolUseHookInput("mcp__posthog__exec", {
388398
command: 'call experiment-get {"id": 1}',
@@ -408,7 +418,11 @@ describe("createPreToolUseHook", () => {
408418
rule: "Bash(ls:*)",
409419
source: "allow",
410420
});
411-
const hook = createPreToolUseHook(settingsManager, logger);
421+
const hook = createPreToolUseHook(
422+
settingsManager,
423+
logger,
424+
posthogExecPermissionRegex,
425+
);
412426
const result = await hook(
413427
buildPreToolUseHookInput("Bash", { command: "ls -la" }),
414428
undefined,
@@ -426,7 +440,11 @@ describe("createPreToolUseHook", () => {
426440
rule: "mcp__posthog__exec",
427441
source: "allow",
428442
});
429-
const hook = createPreToolUseHook(settingsManager, logger);
443+
const hook = createPreToolUseHook(
444+
settingsManager,
445+
logger,
446+
posthogExecPermissionRegex,
447+
);
430448
const result = await hook(
431449
buildPreToolUseHookInput("mcp__posthog__exec", {
432450
command: 'call cohorts-partial-update {"id": 1}',

0 commit comments

Comments
 (0)