Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ Four modes defined in `src/execution-mode.ts`:

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.

Cloud provisioning can pass `--posthogExecPermissionRegex <regex>` to require
one-time client approval for matching PostHog MCP `exec` sub-tools in every
interactive Claude and Codex permission mode. Matching is case-insensitive
against the delegated name in `call [--json] <sub-tool> ...`. These prompts do
offer Claude users an always-allow choice remembered in local repository
settings; Codex approvals remain one-time. Background runs keep their existing
auto-approval behavior. The default is
`(^|-)(partial-update|update|patch|delete|destroy)(-|$)`.

## ACP connection layer

`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.
Expand Down Expand Up @@ -142,9 +151,12 @@ JWT validation (`src/server/jwt.ts`) uses RS256 with a configurable public key.

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.

### Auto-approval in cloud mode
### Permission routing in cloud mode

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

### Checkpoint capture

Expand All @@ -157,6 +169,7 @@ npx agent-server \
--port 3001 \
--mode interactive \
--repositoryPath /path/to/repo \
--posthogExecPermissionRegex '(^|-)(partial-update|update|patch|delete|destroy)(-|$)' \
--taskId task_123 \
--runId run_456
```
Expand Down
6 changes: 6 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type Enrichment,
type FileEnrichmentDeps,
} from "../../enrichment/file-enricher";
import { compilePostHogExecPermissionRegex } from "../../posthog-exec-permission";
import {
classifyPostHogExecCall,
isUnclassifiedPostHogSubTool,
Expand Down Expand Up @@ -1950,12 +1951,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
CODE_EXECUTION_MODES.includes(meta.permissionMode as CodeExecutionMode)
? (meta.permissionMode as CodeExecutionMode)
: "default";
const posthogExecPermissionRegex = meta?.posthogExecPermissionRegex
Comment thread
veria-ai[bot] marked this conversation as resolved.
Outdated
? compilePostHogExecPermissionRegex(meta.posthogExecPermissionRegex)
: undefined;

const taskState: TaskState = new Map();
const options = buildSessionOptions({
cwd,
mcpServers,
permissionMode,
posthogExecPermissionRegex,
canUseTool: this.createCanUseTool(sessionId, meta?.allowedDomains),
logger: this.logger,
systemPrompt,
Expand Down Expand Up @@ -2010,6 +2015,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
cancelled: false,
settingsManager,
permissionMode,
posthogExecPermissionRegex,
abortController,
accumulatedUsage: {
inputTokens: 0,
Expand Down
26 changes: 22 additions & 4 deletions packages/agent/src/adapters/claude/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,20 @@ function buildSettingsManagerStub(

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

test("defers destructive PostHog exec sub-tool to canUseTool via ask", async () => {
const settingsManager = buildSettingsManagerStub({
decision: "allow",
rule: "mcp__posthog__exec",
source: "allow",
});
const hook = createPreToolUseHook(settingsManager, logger);
const hook = createPreToolUseHook(
settingsManager,
logger,
posthogExecPermissionRegex,
);
const result = await hook(
buildPreToolUseHookInput("mcp__posthog__exec", {
command: 'call dashboard-update {"id": 1, "name": "x"}',
Expand All @@ -382,7 +388,11 @@ describe("createPreToolUseHook", () => {
rule: "mcp__posthog__exec",
source: "allow",
});
const hook = createPreToolUseHook(settingsManager, logger);
const hook = createPreToolUseHook(
settingsManager,
logger,
posthogExecPermissionRegex,
);
const result = await hook(
buildPreToolUseHookInput("mcp__posthog__exec", {
command: 'call experiment-get {"id": 1}',
Expand All @@ -408,7 +418,11 @@ describe("createPreToolUseHook", () => {
rule: "Bash(ls:*)",
source: "allow",
});
const hook = createPreToolUseHook(settingsManager, logger);
const hook = createPreToolUseHook(
settingsManager,
logger,
posthogExecPermissionRegex,
);
const result = await hook(
buildPreToolUseHookInput("Bash", { command: "ls -la" }),
undefined,
Expand All @@ -426,7 +440,11 @@ describe("createPreToolUseHook", () => {
rule: "mcp__posthog__exec",
source: "allow",
});
const hook = createPreToolUseHook(settingsManager, logger);
const hook = createPreToolUseHook(
settingsManager,
logger,
posthogExecPermissionRegex,
);
const result = await hook(
buildPreToolUseHookInput("mcp__posthog__exec", {
command: 'call cohorts-partial-update {"id": 1}',
Expand Down
29 changes: 20 additions & 9 deletions packages/agent/src/adapters/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import {
enrichFileForAgent,
type FileEnrichmentDeps,
} from "../../enrichment/file-enricher";
import {
extractPostHogSubTool,
isPostHogExecTool,
matchesPostHogExecPermission,
} from "../../posthog-exec-permission";
import type { Logger } from "../../utils/logger";
import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
import type { TaskState } from "./conversion/task-state";
import { gitSubcommand } from "./git-command";
import { neutralizeUnprocessableImages } from "./image-sanitization";
import {
extractPostHogSubTool,
isPostHogDestructiveSubTool,
isPostHogExecTool,
} from "./permissions/posthog-exec-gate";
import type { SettingsManager } from "./session/settings";
import type { CodeExecutionMode } from "./tools";

Expand Down Expand Up @@ -381,7 +381,11 @@ export const createSignedCommitGuardHook =
};

export const createPreToolUseHook =
(settingsManager: SettingsManager, logger: Logger): HookCallback =>
(
settingsManager: SettingsManager,
logger: Logger,
posthogExecPermissionRegex?: RegExp,
): HookCallback =>
async (input: HookInput, _toolUseID: string | undefined) => {
if (input.hook_event_name !== "PreToolUse") {
return { continue: true };
Expand All @@ -405,15 +409,22 @@ export const createPreToolUseHook =
// not enough — the SDK then falls back to its default permission
// flow which re-checks the same allow rule. We must force "ask"
// so the SDK invokes canUseTool.
if (permissionCheck.decision === "allow" && isPostHogExecTool(toolName)) {
if (
posthogExecPermissionRegex &&
permissionCheck.decision === "allow" &&
isPostHogExecTool(toolName)
) {
const subTool = extractPostHogSubTool(toolInput);
if (subTool && isPostHogDestructiveSubTool(subTool)) {
if (
subTool &&
matchesPostHogExecPermission(subTool, posthogExecPermissionRegex)
) {
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "ask" as const,
permissionDecisionReason: `Destructive PostHog sub-tool '${subTool}' requires explicit approval`,
permissionDecisionReason: `PostHog sub-tool '${subTool}' matches the configured permission regex`,
},
};
}
Expand Down
Loading
Loading