Skip to content
Open
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
40 changes: 39 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
type SDKUserMessage,
type SlashCommand,
} from "@anthropic-ai/claude-agent-sdk";
import { serializeError } from "@posthog/shared";
import { parseAuthorizedWriteMarkers, serializeError } from "@posthog/shared";
import { v7 as uuidv7 } from "uuid";
import packageJson from "../../../package.json" with { type: "json" };
import {
Expand Down Expand Up @@ -106,6 +106,7 @@ import {
setMcpToolApprovalStates,
} from "./mcp/tool-metadata";
import { canUseTool } from "./permissions/permission-handlers";
import { isSanctionedFirstPartyWriteSubTool } from "./permissions/posthog-exec-gate";
import { getAvailableSlashCommands } from "./session/commands";
import { parseMcpServers } from "./session/mcp-config";
import {
Expand Down Expand Up @@ -455,6 +456,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {

// Detect local-only slash commands that return results without model invocation
const msgContent = userMessage.message.content;
// Record authorized-write markers (id-scoped first-party publish grants)
// from this user message before any early return; only user messages are
// trusted to seed them. See Session.authorizedFirstPartyWriteIds.
this.captureAuthorizedWrites(msgContent);
let firstTextPart = "";
if (typeof msgContent === "string") {
firstTextPart = msgContent;
Expand Down Expand Up @@ -525,6 +530,38 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
return response;
}

/**
* Record authorized-write markers from a user message so the permission gate
* can auto-allow a first-party artifact publish (a channel's CONTEXT.md, a
* freeform canvas) only when it targets one of these exact ids. Only markers
* for the sanctioned publish sub-tools are kept; see
* `Session.authorizedFirstPartyWriteIds`.
*/
private captureAuthorizedWrites(
content: SDKUserMessage["message"]["content"],
): void {
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const block of content) {
if ("type" in block && block.type === "text" && "text" in block) {
text += `${block.text as string}\n`;
}
}
}
if (!text.includes("posthog:authorized-write")) return;
for (const { subTool, id } of parseAuthorizedWriteMarkers(text)) {
if (!isSanctionedFirstPartyWriteSubTool(subTool)) continue;
let ids = this.session.authorizedFirstPartyWriteIds.get(subTool);
if (!ids) {
ids = new Set();
this.session.authorizedFirstPartyWriteIds.set(subTool, ids);
}
ids.add(id);
}
}

private ensureConsumer(sessionId: string): void {
const session = this.session;
if (session.consumer) {
Expand Down Expand Up @@ -2034,6 +2071,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
cwd,
notificationHistory: [],
taskRunId: meta?.taskRunId,
authorizedFirstPartyWriteIds: new Map(),
};
// A replaced session's consumer never reaches closeQueryStream.
this.emittedToolCalls.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,104 @@ describe("canUseTool MCP approval enforcement", () => {
expect(addApproval).not.toHaveBeenCalled();
});

it("auto-allows a first-party publish whose id matches an authorized write", async () => {
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
const addApproval = vi.fn();

const context = createContext("mcp__posthog__exec", {
toolInput: {
command:
'call --json desktop-file-system-instructions-partial-update {"id":"folder-1","content":"# CONTEXT"}',
},
session: {
permissionMode: "default",
authorizedFirstPartyWriteIds: new Map([
[
"desktop-file-system-instructions-partial-update",
new Set(["folder-1"]),
],
]),
settingsManager: {
getRepoRoot: vi.fn().mockReturnValue("/repo"),
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
addPostHogExecApproval: addApproval,
},
},
});
const result = await canUseTool(context);

expect(result.behavior).toBe("allow");
// The authorized id skips the destructive prompt entirely.
expect(context.client.requestPermission).not.toHaveBeenCalled();
expect(addApproval).not.toHaveBeenCalled();
});

it("still prompts for a first-party publish to an unauthorized id", async () => {
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });

const context = createContext("mcp__posthog__exec", {
toolInput: {
command:
'call --json desktop-file-system-instructions-partial-update {"id":"someone-elses-folder","content":"x"}',
},
session: {
permissionMode: "default",
authorizedFirstPartyWriteIds: new Map([
[
"desktop-file-system-instructions-partial-update",
new Set(["folder-1"]),
],
]),
settingsManager: {
getRepoRoot: vi.fn().mockReturnValue("/repo"),
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
addPostHogExecApproval: vi.fn(),
},
},
});
await canUseTool(context);

// A non-matching id must fall through to the normal destructive approval.
expect(context.client.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
title:
"The agent wants to run `desktop-file-system-instructions-partial-update` on PostHog",
}),
}),
);
});

it("does not auto-allow an authorized first-party publish in plan mode", async () => {
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });

const context = createContext("mcp__posthog__exec", {
toolInput: {
command:
'call --json desktop-file-system-instructions-partial-update {"id":"folder-1","content":"x"}',
},
session: {
permissionMode: "plan",
authorizedFirstPartyWriteIds: new Map([
[
"desktop-file-system-instructions-partial-update",
new Set(["folder-1"]),
],
]),
settingsManager: {
getRepoRoot: vi.fn().mockReturnValue("/repo"),
hasPostHogExecApproval: vi.fn().mockReturnValue(false),
addPostHogExecApproval: vi.fn(),
},
},
});
await canUseTool(context);

// Plan mode must still require approval — the publish waits for the user to
// approve the plan first, preserving CONTEXT.md co-design.
expect(context.client.requestPermission).toHaveBeenCalled();
});

it("emits tool denial notification for do_not_use", async () => {
setMcpToolApprovalStates({
mcp__server__denied_tool: "do_not_use",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ import {
buildPermissionOptions,
} from "./permission-options";
import {
extractPostHogCallId,
extractPostHogSubTool,
isPostHogDestructiveSubTool,
isPostHogExecTool,
isSanctionedFirstPartyWriteSubTool,
} from "./posthog-exec-gate";

export type ToolPermissionResult =
Expand Down Expand Up @@ -773,6 +775,27 @@ export async function canUseTool(
updatedInput: toolInput as Record<string, unknown>,
};
}
// First-party artifact publish (a channel's CONTEXT.md or a freeform
// canvas): skip the destructive prompt only when the call targets the
// exact artifact id the task's own app-authored prompt authorized. Any
// other id falls through to the normal approval, so the agent can't
// publish to an arbitrary artifact silently. Not in plan mode — the
// publish must wait until the user has approved the plan.
if (
session.permissionMode !== "plan" &&
isSanctionedFirstPartyWriteSubTool(subTool)
) {
const targetId = extractPostHogCallId(toolInput);
if (
targetId &&
session.authorizedFirstPartyWriteIds.get(subTool)?.has(targetId)
) {
return {
behavior: "allow",
updatedInput: toolInput as Record<string, unknown>,
};
}
}
if (session.settingsManager.hasPostHogExecApproval(subTool)) {
return {
behavior: "allow",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
extractPostHogCallId,
extractPostHogSubTool,
isPostHogDestructiveSubTool,
isPostHogExecTool,
isSanctionedFirstPartyWriteSubTool,
} from "./posthog-exec-gate";

describe("isPostHogExecTool", () => {
Expand Down Expand Up @@ -81,4 +83,90 @@ describe("isPostHogDestructiveSubTool", () => {
expect(isPostHogDestructiveSubTool("get-updated-events")).toBe(false);
expect(isPostHogDestructiveSubTool("deleter-test")).toBe(false);
});

it("classifies the first-party publish sub-tools as destructive", () => {
// They are partial-updates like any other; the id-scoped exemption lives in
// the permission handler, not in this pure classifier.
expect(
isPostHogDestructiveSubTool(
"desktop-file-system-instructions-partial-update",
),
).toBe(true);
expect(
isPostHogDestructiveSubTool("desktop-file-system-canvas-partial-update"),
).toBe(true);
});
});

describe("isSanctionedFirstPartyWriteSubTool", () => {
it("matches the two first-party publish sub-tools exactly", () => {
expect(
isSanctionedFirstPartyWriteSubTool(
"desktop-file-system-instructions-partial-update",
),
).toBe(true);
expect(
isSanctionedFirstPartyWriteSubTool(
"desktop-file-system-canvas-partial-update",
),
).toBe(true);
});

it("does not match broader desktop-file-system mutations or product writes", () => {
// These rename/delete whole channels (or touch product data) and stay gated.
expect(
isSanctionedFirstPartyWriteSubTool("desktop-file-system-partial-update"),
).toBe(false);
expect(
isSanctionedFirstPartyWriteSubTool("desktop-file-system-destroy"),
).toBe(false);
expect(isSanctionedFirstPartyWriteSubTool("experiment-update")).toBe(false);
});
});

describe("extractPostHogCallId", () => {
it("reads the id from a `call --json <tool> <json>` command", () => {
expect(
extractPostHogCallId({
command:
'call --json desktop-file-system-instructions-partial-update {"id":"abc-123","content":"# Hi"}',
}),
).toBe("abc-123");
});

it("reads the id without the --json flag", () => {
expect(
extractPostHogCallId({
command:
'call desktop-file-system-canvas-partial-update {"id":"dash_42","code":"export default 1"}',
}),
).toBe("dash_42");
});

it("parses JSON whose content contains braces", () => {
expect(
extractPostHogCallId({
command:
'call --json desktop-file-system-instructions-partial-update {"id":"folder-9","content":"a {b} c"}',
}),
).toBe("folder-9");
});

it("returns null with no id, no args, malformed JSON, or bad input", () => {
expect(
extractPostHogCallId({
command: 'call --json experiment-update {"name":"no id here"}',
}),
).toBeNull();
expect(
extractPostHogCallId({ command: "call desktop-file-system-list" }),
).toBeNull();
expect(
extractPostHogCallId({
command: "call --json foo-update {not valid json}",
}),
).toBeNull();
expect(extractPostHogCallId({ command: 42 })).toBeNull();
expect(extractPostHogCallId(null)).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,28 @@ const POSTHOG_EXEC_TOOL_RE = /^mcp__posthog(?:_[^_]+)*__exec$/;

const POSTHOG_CALL_COMMAND_RE = /^\s*call\s+(?:--json\s+)?([a-zA-Z0-9_-]+)/;

// Captures the raw argument payload after `call [--json] <sub-tool>` so the gate
// can read the target `id` a sanctioned publish is aiming at.
const POSTHOG_CALL_ARGS_RE =
/^\s*call\s+(?:--json\s+)?[a-zA-Z0-9_-]+\s+([\s\S]+)$/;

const POSTHOG_DESTRUCTIVE_SUBTOOL_RE =
/(^|-)(partial-update|update|delete|destroy)(-|$)/i;

// First-party writes to PostHog Code's own desktop-file-system artifacts — a
// channel's CONTEXT.md and its freeform canvases, both stored in PostHog and
// published via these `*-partial-update` sub-tools. They are destructive like
// any other partial-update, but a generation task is *expected* to publish one,
// so the permission handler skips the extra approval for them — BUT only when
// the call targets the exact artifact id the task was authorized (by its own
// app-authored prompt) to write. Exact names only: the broader
// desktop-file-system-{partial-update,destroy} sub-tools rename/delete whole
// channels and stay fully gated.
const SANCTIONED_FIRSTPARTY_WRITE_SUBTOOLS = new Set([
"desktop-file-system-instructions-partial-update", // CONTEXT.md publish
"desktop-file-system-canvas-partial-update", // freeform canvas publish
]);
Comment on lines +32 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Artifact Id Bypasses Approval

When mcp__posthog__exec is already allowed, these exact subtool names now skip the destructive prompt for any model-supplied id. A call to desktop-file-system-instructions-partial-update or desktop-file-system-canvas-partial-update can publish to a different channel or canvas than the approved generation task, so the live PostHog write is no longer scoped to the user-approved artifact.

Rule Used: When implementing new features, ensure that owners... (source)

Learned From
PostHog/posthog#31236


export function isPostHogExecTool(toolName: string): boolean {
return POSTHOG_EXEC_TOOL_RE.test(toolName);
}
Expand All @@ -28,3 +47,32 @@ export function extractPostHogSubTool(toolInput: unknown): string | null {
export function isPostHogDestructiveSubTool(subTool: string): boolean {
return POSTHOG_DESTRUCTIVE_SUBTOOL_RE.test(subTool);
}

export function isSanctionedFirstPartyWriteSubTool(subTool: string): boolean {
return SANCTIONED_FIRSTPARTY_WRITE_SUBTOOLS.has(subTool);
}

// Reads the top-level string `id` from a PostHog exec `call` command's JSON
// args, or null if absent/malformed. Used to check that a sanctioned publish
// targets the artifact the task is authorized to write; null fails safe by
// leaving the call on the normal approval path.
export function extractPostHogCallId(toolInput: unknown): string | null {
if (!toolInput || typeof toolInput !== "object") return null;
const command = (toolInput as { command?: unknown }).command;
if (typeof command !== "string") return null;
const rawArgs = command.match(POSTHOG_CALL_ARGS_RE)?.[1]?.trim();
if (!rawArgs) return null;
try {
const parsed: unknown = JSON.parse(rawArgs);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as { id?: unknown }).id === "string"
) {
return (parsed as { id: string }).id;
}
} catch {
// Non-JSON / malformed args → no id; caller falls back to approval.
}
return null;
}
Loading
Loading