diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 2ed43af846..d98e9d7f75 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -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 { @@ -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 { @@ -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; @@ -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) { @@ -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(); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts index ad6102f4d6..69b4b6fb0b 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -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", diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 8eddc69777..3a06974a8c 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -33,9 +33,11 @@ import { buildPermissionOptions, } from "./permission-options"; import { + extractPostHogCallId, extractPostHogSubTool, isPostHogDestructiveSubTool, isPostHogExecTool, + isSanctionedFirstPartyWriteSubTool, } from "./posthog-exec-gate"; export type ToolPermissionResult = @@ -773,6 +775,27 @@ export async function canUseTool( updatedInput: toolInput as Record, }; } + // 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, + }; + } + } if (session.settingsManager.hasPostHogExecApproval(subTool)) { return { behavior: "allow", diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts index 6c2e82a930..02ce8c9c30 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + extractPostHogCallId, extractPostHogSubTool, isPostHogDestructiveSubTool, isPostHogExecTool, + isSanctionedFirstPartyWriteSubTool, } from "./posthog-exec-gate"; describe("isPostHogExecTool", () => { @@ -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 ` 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(); + }); }); diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts index 2ecdb8c6ec..226d097e70 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts @@ -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] ` 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 +]); + export function isPostHogExecTool(toolName: string): boolean { return POSTHOG_EXEC_TOOL_RE.test(toolName); } @@ -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; +} diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3d423746d1..4a184c1a9d 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -71,6 +71,16 @@ export type Session = BaseSession & { modelId?: string; cwd: string; taskRunId?: string; + /** + * Sub-tool → artifact ids this session may publish to via the first-party + * PostHog MCP `*-partial-update` sub-tools (a channel's CONTEXT.md, a freeform + * canvas) without the destructive-write approval. Seeded ONLY from + * authorized-write markers found in *user* messages (the app-authored + * generation prompt) — the agent can't forge those — so the permission gate + * auto-allows a sanctioned publish only when it targets one of these exact + * ids. Empty for ordinary sessions. + */ + authorizedFirstPartyWriteIds: Map>; lastPlanFilePath?: string; lastPlanContent?: string; effort?: EffortLevel; diff --git a/packages/shared/src/authorized-write-marker.test.ts b/packages/shared/src/authorized-write-marker.test.ts new file mode 100644 index 0000000000..0b016deaa1 --- /dev/null +++ b/packages/shared/src/authorized-write-marker.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + type AuthorizedWrite, + buildAuthorizedWriteMarker, + parseAuthorizedWriteMarkers, +} from "./authorized-write-marker"; + +describe("authorized-write markers", () => { + const write: AuthorizedWrite = { + subTool: "desktop-file-system-instructions-partial-update", + id: "folder-abc-123", + }; + + it("round-trips a marker embedded in a larger prompt", () => { + const prompt = `Build a CONTEXT.md.\n\n${buildAuthorizedWriteMarker(write)}`; + expect(parseAuthorizedWriteMarkers(prompt)).toEqual([write]); + }); + + it("parses multiple markers in order", () => { + const a: AuthorizedWrite = { + subTool: "desktop-file-system-canvas-partial-update", + id: "dash-1", + }; + const b: AuthorizedWrite = { + subTool: "desktop-file-system-instructions-partial-update", + id: "folder-2", + }; + const text = `${buildAuthorizedWriteMarker(a)} then ${buildAuthorizedWriteMarker(b)}`; + expect(parseAuthorizedWriteMarkers(text)).toEqual([a, b]); + }); + + it("returns nothing when there is no marker", () => { + expect(parseAuthorizedWriteMarkers("just a normal prompt")).toEqual([]); + expect(parseAuthorizedWriteMarkers("")).toEqual([]); + }); + + it("ignores malformed markers (missing subtool or id)", () => { + expect( + parseAuthorizedWriteMarkers(''), + ).toEqual([]); + expect( + parseAuthorizedWriteMarkers( + '', + ), + ).toEqual([]); + expect( + parseAuthorizedWriteMarkers(""), + ).toEqual([]); + }); +}); diff --git a/packages/shared/src/authorized-write-marker.ts b/packages/shared/src/authorized-write-marker.ts new file mode 100644 index 0000000000..e84526b2f2 --- /dev/null +++ b/packages/shared/src/authorized-write-marker.ts @@ -0,0 +1,41 @@ +// Machine-readable marker embedded in an app-authored generation prompt to +// declare which PostHog first-party artifact the task is authorized to write — +// a channel's CONTEXT.md or a freeform canvas. Both live in PostHog (not on +// disk) and are published via the PostHog MCP `*-partial-update` sub-tools, +// which the permission gate otherwise treats as destructive. +// +// The agent's permission gate reads these markers off the *user* message (which +// the agent cannot forge — it only produces assistant messages and tool calls) +// and auto-allows the sanctioned publish sub-tool only when the call targets one +// of the declared ids. So the agent can never silently publish to an arbitrary +// artifact id; any other id still requires explicit approval. Rendered as an +// HTML comment so it stays invisible in the rendered chat. + +export interface AuthorizedWrite { + // The PostHog MCP sub-tool the task may call, e.g. + // "desktop-file-system-instructions-partial-update". + subTool: string; + // The exact artifact id that sub-tool is authorized to target. + id: string; +} + +// Global so `matchAll` finds every marker in the prompt. `id` accepts anything +// except a quote or angle bracket, which keeps the match bounded to the comment. +const MARKER_RE = + //g; + +export function buildAuthorizedWriteMarker(write: AuthorizedWrite): string { + return ``; +} + +export function parseAuthorizedWriteMarkers(text: string): AuthorizedWrite[] { + const writes: AuthorizedWrite[] = []; + for (const match of text.matchAll(MARKER_RE)) { + const subTool = match[1]; + const id = match[2]; + if (subTool && id) { + writes.push({ subTool, id }); + } + } + return writes; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 35fbca6805..63afc43120 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,11 @@ export * from "./adapter"; export * from "./analytics-events"; export { type ArchivedTask, archivedTaskSchema } from "./archive-domain"; export { withTimeout } from "./async"; +export { + type AuthorizedWrite, + buildAuthorizedWriteMarker, + parseAuthorizedWriteMarkers, +} from "./authorized-write-marker"; export { type BackoffOptions, getBackoffDelay, diff --git a/packages/ui/src/features/canvas/contextPrompt.ts b/packages/ui/src/features/canvas/contextPrompt.ts index 29e6dc68c4..5a262f6302 100644 --- a/packages/ui/src/features/canvas/contextPrompt.ts +++ b/packages/ui/src/features/canvas/contextPrompt.ts @@ -1,3 +1,5 @@ +import { buildAuthorizedWriteMarker } from "@posthog/shared"; + // Builds the prompt for the task that generates a context's CONTEXT.md. The // task runs as a normal repo-less agent task (no repo picked up front), so the // agent has full tools; this is the task's content (its first user message). @@ -18,6 +20,10 @@ export function contextMdTaskTitle(channelName: string): string { return `${CONTEXT_MD_TASK_TITLE_PREFIX} for ${channelName}`; } +// The PostHog MCP sub-tool that publishes a channel's CONTEXT.md. Named once so +// the publish instruction and the authorized-write marker below stay in sync. +const CONTEXT_PUBLISH_TOOL = "desktop-file-system-instructions-partial-update"; + export function buildContextGenerationPrompt(input: { channelName: string; channelId: string; @@ -49,7 +55,7 @@ let the user refine it before you publish. Investigate two sources: analytics, and persons. Operate only on this project. Once the plan is approved, PUBLISH the document by calling the PostHog MCP -tool \`desktop-file-system-instructions-partial-update\` exactly once with: +tool \`${CONTEXT_PUBLISH_TOOL}\` exactly once with: - id: "${channelId}" - content: the full CONTEXT.md markdown - base_version: the current instructions version, or 0 if none exists yet @@ -65,5 +71,7 @@ Write the document in terse, high-signal language: drop articles and filler, prefer fragments and short phrases over full sentences, cut anything that does not carry technical substance. Keep it concise. CONTEXT.md lives in PostHog, not on disk, so publishing via the MCP tool is what saves it — do not just write a -local file.`; +local file. + +${buildAuthorizedWriteMarker({ subTool: CONTEXT_PUBLISH_TOOL, id: channelId })}`; } diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index 32631fc0c7..36c010aca5 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -1,5 +1,10 @@ import { freeformSystemPromptFor } from "@posthog/core/canvas/canvasTemplates"; import { FREEFORM_STARTER_CODE } from "@posthog/core/canvas/freeformStarter"; +import { buildAuthorizedWriteMarker } from "@posthog/shared"; + +// The PostHog MCP sub-tool that publishes a freeform canvas. Named once so the +// publish instruction and the authorized-write marker below stay in sync. +const FREEFORM_PUBLISH_TOOL = "desktop-file-system-canvas-partial-update"; // Builds the prompt for the task that generates a freeform (React) canvas. Like // CONTEXT.md generation, this runs as a normal repo-less agent task (no repo @@ -71,7 +76,7 @@ ${contract} PUBLISHING — this OVERRIDES any instruction above about replying with the code in a fenced \`\`\`tsx block. In this task you do NOT reply with the code. When the canvas is ready, PUBLISH it by calling the PostHog MCP tool -\`desktop-file-system-canvas-partial-update\` exactly once with: +\`${FREEFORM_PUBLISH_TOOL}\` exactly once with: - id: "${dashboardId}" - code: the COMPLETE single-file React source for the canvas. @@ -89,5 +94,7 @@ only when no insight can express the metric.`; ${instructions} + +${buildAuthorizedWriteMarker({ subTool: FREEFORM_PUBLISH_TOOL, id: dashboardId })} `; }