Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 5 additions & 2 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ 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 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)(-|$)`.

## 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 +144,9 @@ 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 +159,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
167 changes: 164 additions & 3 deletions packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { HookInput, Options } from "@anthropic-ai/claude-agent-sdk";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_GATEWAY_MODEL } from "../../gateway-models";

Expand Down Expand Up @@ -38,11 +39,13 @@ function makeQueryHandle(): SdkQueryHandle {
}

const createdQueries: SdkQueryHandle[] = [];
const createdQueryOptions: Options[] = [];

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: vi.fn(() => {
query: vi.fn(({ options }: { options: Options }) => {
const handle = makeQueryHandle();
createdQueries.push(handle);
createdQueryOptions.push(options);
return handle;
}),
getSessionMessages: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -87,6 +90,14 @@ const cwd = mkdtempSync(path.join(os.tmpdir(), "claude-agent-test-cwd-"));
const configDir = mkdtempSync(
path.join(os.tmpdir(), "claude-agent-test-config-"),
);
const permissionCwd = mkdtempSync(
path.join(os.tmpdir(), "claude-agent-permission-test-cwd-"),
);
mkdirSync(path.join(permissionCwd, ".claude"), { recursive: true });
writeFileSync(
path.join(permissionCwd, ".claude", "settings.json"),
JSON.stringify({ permissions: { allow: ["mcp__posthog__exec"] } }),
);
const savedEnv = {
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
Expand All @@ -95,6 +106,7 @@ const savedEnv = {
afterAll(() => {
rmSync(cwd, { recursive: true, force: true });
rmSync(configDir, { recursive: true, force: true });
rmSync(permissionCwd, { recursive: true, force: true });
process.env.ANTHROPIC_BASE_URL = savedEnv.ANTHROPIC_BASE_URL;
process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
if (savedEnv.ANTHROPIC_BASE_URL === undefined) {
Expand All @@ -105,9 +117,10 @@ afterAll(() => {
}
});

describe("ClaudeAcpAgent session model on resume", () => {
describe("ClaudeAcpAgent session creation", () => {
beforeEach(() => {
createdQueries.length = 0;
createdQueryOptions.length = 0;
nextInitPromise = Promise.resolve({
result: "success",
commands: [],
Expand All @@ -119,6 +132,154 @@ describe("ClaudeAcpAgent session model on resume", () => {
process.env.CLAUDE_CONFIG_DIR = configDir;
});

async function runPostHogExecPreToolUse(
options: Options,
subTool: string,
): Promise<string | undefined> {
const input = {
session_id: "permission-session",
transcript_path: "/tmp/transcript",
cwd: permissionCwd,
hook_event_name: "PreToolUse",
tool_name: "mcp__posthog__exec",
tool_use_id: "toolu_permission",
tool_input: { command: `call ${subTool} {}` },
} as HookInput;

for (const hook of (options.hooks?.PreToolUse ?? []).flatMap(
(entry) => entry.hooks ?? [],
)) {
const result = await hook(input, undefined, {
signal: new AbortController().signal,
});
const decision = (
result as {
hookSpecificOutput?: { permissionDecision?: string };
}
).hookSpecificOutput?.permissionDecision;
if (decision) return decision;
}

return undefined;
}

it.each(["new", "resume", "load"] as const)(
"uses the default PostHog exec permission regex for local %s sessions when metadata omits it",
async (sessionKind) => {
const agent = makeAgent();
const sessionIds = {
new: "0197a000-0000-7000-8000-000000000101",
resume: "0197a000-0000-7000-8000-000000000102",
load: "0197a000-0000-7000-8000-000000000103",
};
const params = {
sessionId: sessionIds[sessionKind],
cwd: permissionCwd,
mcpServers: [],
_meta: { taskRunId: `run-permission-${sessionKind}` },
};

if (sessionKind === "new") {
await agent.newSession(params);
} else if (sessionKind === "resume") {
await agent.resumeSession(params);
} else {
await agent.loadSession(params);
}

expect(createdQueryOptions).toHaveLength(1);
await expect(
runPostHogExecPreToolUse(
createdQueryOptions[0] as Options,
"dashboard-update",
),
).resolves.toBe("ask");
},
);

it("uses an explicit PostHog exec permission regex instead of the default", async () => {
const agent = makeAgent();

await agent.newSession({
cwd: permissionCwd,
mcpServers: [],
_meta: {
taskRunId: "run-permission-custom",
posthogExecPermissionRegex: "(^|-)archive(-|$)",
},
});

expect(createdQueryOptions).toHaveLength(1);
await expect(
runPostHogExecPreToolUse(
createdQueryOptions[0] as Options,
"dashboard-update",
),
).resolves.toBe("allow");
await expect(
runPostHogExecPreToolUse(
createdQueryOptions[0] as Options,
"dashboard-archive",
),
).resolves.toBe("ask");
});

it.each(["[", ""])(
"warns and falls back to the default regex when metadata carries the invalid regex %j",
async (posthogExecPermissionRegex) => {
const agent = makeAgent();
const warnSpy = vi.spyOn(agent.logger, "warn");

await agent.newSession({
cwd: permissionCwd,
mcpServers: [],
_meta: {
taskRunId: "run-permission-invalid",
posthogExecPermissionRegex,
},
});

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid posthogExecPermissionRegex"),
expect.anything(),
);
expect(createdQueryOptions).toHaveLength(1);
await expect(
runPostHogExecPreToolUse(
createdQueryOptions[0] as Options,
"dashboard-update",
),
).resolves.toBe("ask");
await expect(
runPostHogExecPreToolUse(
createdQueryOptions[0] as Options,
"dashboard-get",
),
).resolves.toBe("allow");
},
);

it.each([
{ environment: "local", expectedCloudMode: false },
{ environment: "cloud", expectedCloudMode: true },
] as const)(
"records $environment sessions as cloudMode=$expectedCloudMode",
async ({ environment, expectedCloudMode }) => {
const agent = makeAgent();

await agent.newSession({
cwd,
mcpServers: [],
_meta: { environment, taskRunId: `run-${environment}` },
});

expect(
(agent as unknown as { session: { cloudMode: boolean } }).session
.cloudMode,
).toBe(expectedCloudMode);
},
);

// The SDK does not carry the model across resume — without an explicit
// setModel the resumed session silently runs the SDK default (opus).
it.each([
Expand Down
12 changes: 12 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 { resolvePostHogExecPermissionRegex } from "../../posthog-exec-permission";
import {
classifyPostHogExecCall,
isUnclassifiedPostHogSubTool,
Expand Down Expand Up @@ -1977,12 +1978,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
CODE_EXECUTION_MODES.includes(meta.permissionMode as CodeExecutionMode)
? (meta.permissionMode as CodeExecutionMode)
: "default";
const posthogExecPermissionRegex = resolvePostHogExecPermissionRegex(
meta?.posthogExecPermissionRegex,
(message) =>
this.logger.warn(
"Invalid posthogExecPermissionRegex in session metadata; using default",
{ message },
),
);

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 @@ -2037,6 +2047,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
cancelled: false,
settingsManager,
permissionMode,
cloudMode: cloudRun,
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
Loading
Loading