Skip to content

Commit 675d27d

Browse files
committed
feat(agent): configure PostHog exec permissions
1 parent 836f029 commit 675d27d

24 files changed

Lines changed: 616 additions & 402 deletions

packages/agent/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ 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
78+
one-time client approval for matching PostHog MCP `exec` sub-tools in every
79+
interactive Claude and Codex permission mode. Matching is case-insensitive
80+
against the delegated name in `call [--json] <sub-tool> ...`. These prompts do
81+
not offer an always-allow choice and are not remembered. Background runs keep
82+
their existing auto-approval behavior. The default is
83+
`(^|-)(partial-update|update|patch|delete|destroy)(-|$)`.
84+
7785
## ACP connection layer
7886

7987
`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 +150,12 @@ JWT validation (`src/server/jwt.ts`) uses RS256 with a configurable public key.
142150

143151
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.
144152

145-
### Auto-approval in cloud mode
153+
### Permission routing in cloud mode
146154

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.
155+
The `AgentServer` provides the `requestPermission` callback to the
156+
`ClientSideConnection`. Background mode selects an allow option automatically.
157+
Interactive mode relays approvals that need a person over SSE and parks them
158+
until a client responds; other requests follow the selected permission mode.
148159

149160
### Checkpoint capture
150161

@@ -157,6 +168,7 @@ npx agent-server \
157168
--port 3001 \
158169
--mode interactive \
159170
--repositoryPath /path/to/repo \
171+
--posthogExecPermissionRegex '(^|-)(partial-update|update|patch|delete|destroy)(-|$)' \
160172
--taskId task_123 \
161173
--runId run_456
162174
```

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

Lines changed: 6 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 { compilePostHogExecPermissionRegex } from "../../posthog-exec-permission";
6061
import {
6162
classifyPostHogExecCall,
6263
isUnclassifiedPostHogSubTool,
@@ -1950,12 +1951,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19501951
CODE_EXECUTION_MODES.includes(meta.permissionMode as CodeExecutionMode)
19511952
? (meta.permissionMode as CodeExecutionMode)
19521953
: "default";
1954+
const posthogExecPermissionRegex = meta?.posthogExecPermissionRegex
1955+
? compilePostHogExecPermissionRegex(meta.posthogExecPermissionRegex)
1956+
: undefined;
19531957

19541958
const taskState: TaskState = new Map();
19551959
const options = buildSessionOptions({
19561960
cwd,
19571961
mcpServers,
19581962
permissionMode,
1963+
posthogExecPermissionRegex,
19591964
canUseTool: this.createCanUseTool(sessionId, meta?.allowedDomains),
19601965
logger: this.logger,
19611966
systemPrompt,
@@ -2010,6 +2015,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
20102015
cancelled: false,
20112016
settingsManager,
20122017
permissionMode,
2018+
posthogExecPermissionRegex,
20132019
abortController,
20142020
accumulatedUsage: {
20152021
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}',

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ import {
33
enrichFileForAgent,
44
type FileEnrichmentDeps,
55
} from "../../enrichment/file-enricher";
6+
import {
7+
extractPostHogSubTool,
8+
isPostHogExecTool,
9+
matchesPostHogExecPermission,
10+
} from "../../posthog-exec-permission";
611
import type { Logger } from "../../utils/logger";
712
import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
813
import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
914
import type { TaskState } from "./conversion/task-state";
1015
import { gitSubcommand } from "./git-command";
1116
import { neutralizeUnprocessableImages } from "./image-sanitization";
12-
import {
13-
extractPostHogSubTool,
14-
isPostHogDestructiveSubTool,
15-
isPostHogExecTool,
16-
} from "./permissions/posthog-exec-gate";
1717
import type { SettingsManager } from "./session/settings";
1818
import type { CodeExecutionMode } from "./tools";
1919

@@ -381,7 +381,11 @@ export const createSignedCommitGuardHook =
381381
};
382382

383383
export const createPreToolUseHook =
384-
(settingsManager: SettingsManager, logger: Logger): HookCallback =>
384+
(
385+
settingsManager: SettingsManager,
386+
logger: Logger,
387+
posthogExecPermissionRegex?: RegExp,
388+
): HookCallback =>
385389
async (input: HookInput, _toolUseID: string | undefined) => {
386390
if (input.hook_event_name !== "PreToolUse") {
387391
return { continue: true };
@@ -405,15 +409,22 @@ export const createPreToolUseHook =
405409
// not enough — the SDK then falls back to its default permission
406410
// flow which re-checks the same allow rule. We must force "ask"
407411
// so the SDK invokes canUseTool.
408-
if (permissionCheck.decision === "allow" && isPostHogExecTool(toolName)) {
412+
if (
413+
posthogExecPermissionRegex &&
414+
permissionCheck.decision === "allow" &&
415+
isPostHogExecTool(toolName)
416+
) {
409417
const subTool = extractPostHogSubTool(toolInput);
410-
if (subTool && isPostHogDestructiveSubTool(subTool)) {
418+
if (
419+
subTool &&
420+
matchesPostHogExecPermission(subTool, posthogExecPermissionRegex)
421+
) {
411422
return {
412423
continue: true,
413424
hookSpecificOutput: {
414425
hookEventName: "PreToolUse" as const,
415426
permissionDecision: "ask" as const,
416-
permissionDecisionReason: `Destructive PostHog sub-tool '${subTool}' requires explicit approval`,
427+
permissionDecisionReason: `PostHog sub-tool '${subTool}' matches the configured permission regex`,
417428
},
418429
};
419430
}

0 commit comments

Comments
 (0)