Skip to content

Commit 98d2308

Browse files
committed
fix(agent): default Claude PostHog exec guard
1 parent c2ca91a commit 98d2308

7 files changed

Lines changed: 224 additions & 31 deletions

File tree

packages/agent/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,12 @@ In cloud background mode, permissions are always auto-approved. In interactive m
7676

7777
Cloud provisioning can pass `--posthogExecPermissionRegex <regex>` to require
7878
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-
offer Claude users an always-allow choice remembered in local repository
82-
settings; Codex approvals remain one-time. Background runs keep their existing
83-
auto-approval behavior. The default is
79+
interactive cloud Claude and Codex permission mode. Local Claude `auto` and
80+
`bypassPermissions` modes remain hands-off. Matching is case-insensitive against
81+
the delegated name in `call [--json] <sub-tool> ...`. These prompts offer Claude
82+
users an always-allow choice remembered in local repository settings; Codex
83+
approvals remain one-time. Background runs keep their existing auto-approval
84+
behavior. The default is
8485
`(^|-)(partial-update|update|patch|delete|destroy)(-|$)`.
8586

8687
## ACP connection layer

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

Lines changed: 129 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,119 @@ 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+
{ environment: "local", expectedCloudMode: false },
229+
{ environment: "cloud", expectedCloudMode: true },
230+
] as const)(
231+
"records $environment sessions as cloudMode=$expectedCloudMode",
232+
async ({ environment, expectedCloudMode }) => {
233+
const agent = makeAgent();
234+
235+
await agent.newSession({
236+
cwd,
237+
mcpServers: [],
238+
_meta: { environment, taskRunId: `run-${environment}` },
239+
});
240+
241+
expect(
242+
(agent as unknown as { session: { cloudMode: boolean } }).session
243+
.cloudMode,
244+
).toBe(expectedCloudMode);
245+
},
246+
);
247+
122248
// The SDK does not carry the model across resume — without an explicit
123249
// setModel the resumed session silently runs the SDK default (opus).
124250
it.each([

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ import {
5757
type Enrichment,
5858
type FileEnrichmentDeps,
5959
} from "../../enrichment/file-enricher";
60-
import { compilePostHogExecPermissionRegex } from "../../posthog-exec-permission";
60+
import {
61+
compilePostHogExecPermissionRegex,
62+
DEFAULT_POSTHOG_EXEC_PERMISSION_REGEX_SOURCE,
63+
} from "../../posthog-exec-permission";
6164
import {
6265
classifyPostHogExecCall,
6366
isUnclassifiedPostHogSubTool,
@@ -1951,9 +1954,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19511954
CODE_EXECUTION_MODES.includes(meta.permissionMode as CodeExecutionMode)
19521955
? (meta.permissionMode as CodeExecutionMode)
19531956
: "default";
1954-
const posthogExecPermissionRegex = meta?.posthogExecPermissionRegex
1955-
? compilePostHogExecPermissionRegex(meta.posthogExecPermissionRegex)
1956-
: undefined;
1957+
const posthogExecPermissionRegex = compilePostHogExecPermissionRegex(
1958+
meta?.posthogExecPermissionRegex ??
1959+
DEFAULT_POSTHOG_EXEC_PERMISSION_REGEX_SOURCE,
1960+
);
19571961

19581962
const taskState: TaskState = new Map();
19591963
const options = buildSessionOptions({
@@ -2015,6 +2019,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
20152019
cancelled: false,
20162020
settingsManager,
20172021
permissionMode,
2022+
cloudMode: cloudRun,
20182023
posthogExecPermissionRegex,
20192024
abortController,
20202025
accumulatedUsage: {

packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function createContext(
2727
return {
2828
session: {
2929
permissionMode: "default" as const,
30+
cloudMode: false,
3031
settingsManager: {
3132
getRepoRoot: vi.fn().mockReturnValue("/repo"),
3233
},
@@ -215,14 +216,15 @@ describe("canUseTool MCP approval enforcement", () => {
215216
"auto",
216217
"bypassPermissions",
217218
] as const)(
218-
"prompts for a configured PostHog exec match in %s mode with a remembered choice",
219+
"prompts for a configured PostHog exec match in cloud %s mode with a remembered choice",
219220
async (permissionMode) => {
220221
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
221222

222223
const context = createContext("mcp__posthog__exec", {
223224
toolInput: { command: "call notebooks-destroy {}" },
224225
session: {
225226
permissionMode,
227+
cloudMode: true,
226228
posthogExecPermissionRegex,
227229
settingsManager: {
228230
getRepoRoot: vi.fn().mockReturnValue("/repo"),
@@ -250,6 +252,32 @@ describe("canUseTool MCP approval enforcement", () => {
250252
},
251253
);
252254

255+
it.each(["auto", "bypassPermissions"] as const)(
256+
"keeps local %s mode hands-off for a configured PostHog exec match",
257+
async (permissionMode) => {
258+
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
259+
260+
const context = createContext("mcp__posthog__exec", {
261+
toolInput: { command: "call notebooks-destroy {}" },
262+
session: {
263+
permissionMode,
264+
cloudMode: false,
265+
posthogExecPermissionRegex,
266+
settingsManager: {
267+
getRepoRoot: vi.fn().mockReturnValue("/repo"),
268+
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
269+
addPostHogExecApproval: vi.fn(),
270+
},
271+
},
272+
});
273+
274+
const result = await canUseTool(context);
275+
276+
expect(result.behavior).toBe("allow");
277+
expect(context.client.requestPermission).not.toHaveBeenCalled();
278+
},
279+
);
280+
253281
it("skips the prompt for a remembered PostHog exec sub-tool", async () => {
254282
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
255283

packages/agent/src/adapters/claude/permissions/permission-handlers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,20 @@ export async function canUseTool(
776776
updatedInput: toolInput as Record<string, unknown>,
777777
};
778778
}
779+
// Local hands-off modes retain their normal no-prompt behavior. Cloud
780+
// sessions must send the request to AgentServer, which uses the run's
781+
// effective mode to relay interactive approvals and auto-approve
782+
// background runs.
783+
if (
784+
!session.cloudMode &&
785+
(session.permissionMode === "auto" ||
786+
session.permissionMode === "bypassPermissions")
787+
) {
788+
return {
789+
behavior: "allow",
790+
updatedInput: toolInput as Record<string, unknown>,
791+
};
792+
}
779793
return handlePostHogExecApprovalFlow(context, subTool);
780794
}
781795
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export type Session = BaseSession & {
6767
input: Pushable<SDKUserMessage>;
6868
settingsManager: SettingsManager;
6969
permissionMode: CodeExecutionMode;
70+
/** Whether permission decisions are delegated to the cloud AgentServer. */
71+
cloudMode: boolean;
7072
posthogExecPermissionRegex?: RegExp;
7173
modeBeforePlan?: CodeExecutionMode;
7274
modelId?: string;

packages/agent/src/server/agent-server.test.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,26 +1575,43 @@ describe("AgentServer HTTP Mode", () => {
15751575
});
15761576
});
15771577

1578-
it("keeps background PostHog exec matches auto-approved", async () => {
1579-
const testServer = exposeCloudClient(
1580-
createServer({ posthogExecPermissionRegex: "delete|destroy" }),
1581-
);
1582-
const relaySpy = vi.spyOn(testServer, "relayPermissionToClient");
1578+
it.each([
1579+
{
1580+
modeSource: "JWT payload",
1581+
configMode: "interactive",
1582+
payloadMode: "background",
1583+
},
1584+
{
1585+
modeSource: "server config",
1586+
configMode: "background",
1587+
payloadMode: undefined,
1588+
},
1589+
] as const)(
1590+
"keeps PostHog exec matches auto-approved in background mode from $modeSource",
1591+
async ({ configMode, payloadMode }) => {
1592+
const testServer = exposeCloudClient(
1593+
createServer({
1594+
mode: configMode,
1595+
posthogExecPermissionRegex: "delete|destroy",
1596+
}),
1597+
);
1598+
const relaySpy = vi.spyOn(testServer, "relayPermissionToClient");
15831599

1584-
const { requestPermission } = testServer.createCloudClient({
1585-
...basePayload,
1586-
mode: "background",
1587-
});
1588-
const result = await requestPermission(
1589-
codexPosthogExecPermissionRequest("call experiment-delete {}"),
1590-
);
1600+
const { requestPermission } = testServer.createCloudClient({
1601+
...basePayload,
1602+
...(payloadMode ? { mode: payloadMode } : {}),
1603+
});
1604+
const result = await requestPermission(
1605+
codexPosthogExecPermissionRequest("call experiment-delete {}"),
1606+
);
15911607

1592-
expect(relaySpy).not.toHaveBeenCalled();
1593-
expect(result.outcome).toEqual({
1594-
outcome: "selected",
1595-
optionId: "allow_once",
1596-
});
1597-
});
1608+
expect(relaySpy).not.toHaveBeenCalled();
1609+
expect(result.outcome).toEqual({
1610+
outcome: "selected",
1611+
optionId: "allow_once",
1612+
});
1613+
},
1614+
);
15981615

15991616
it("rejects permission responses for options that were not offered", async () => {
16001617
const testServer = exposeCloudClient(createServer());

0 commit comments

Comments
 (0)