From 6804849ee6c356611ad3eeead22c4c25f1b277fb Mon Sep 17 00:00:00 2001 From: jake sciotto Date: Tue, 14 Jul 2026 15:43:58 -0600 Subject: [PATCH 1/3] fix(agent): let MCP publish channel CONTEXT.md and canvases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel's CONTEXT.md and freeform canvases are not files on disk — they're stored in PostHog and published by the agent via the PostHog MCP sub-tools `desktop-file-system-instructions-partial-update` and `desktop-file-system-canvas-partial-update`. The destructive-sub-tool gate classified any `*-partial-update` sub-tool as destructive, so these publishes were forced behind an extra "modify live PostHog data" approval on top of the plan approval the user already gave — redundant on desktop and inconsistent in cloud runs. That gate exists to protect PostHog product data (flags/experiments/notebooks), but these two sub-tools write the app's own artifacts, which are the explicit output of the generation task. Exempt exactly those two sub-tool names from `isPostHogDestructiveSubTool` so they ride the same silent-allow path as create-family exec sub-tools. Uses an exact-name allowlist, not a prefix, so the broader `desktop-file-system-{partial-update,destroy}` sub-tools that rename/delete whole channels stay gated. Plan mode still blocks the write until the plan is approved, so CONTEXT.md co-design is unchanged. Generated-By: PostHog Code Task-Id: ab56d0cc-fb60-4554-a576-88307e823e67 --- .../permissions/posthog-exec-gate.test.ts | 23 +++++++++++++++++++ .../claude/permissions/posthog-exec-gate.ts | 14 +++++++++++ 2 files changed, 37 insertions(+) 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..d7f4ab3138 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 @@ -81,4 +81,27 @@ describe("isPostHogDestructiveSubTool", () => { expect(isPostHogDestructiveSubTool("get-updated-events")).toBe(false); expect(isPostHogDestructiveSubTool("deleter-test")).toBe(false); }); + + it("exempts sanctioned first-party desktop-file-system writes", () => { + // A channel's CONTEXT.md and freeform canvases are the app's own artifacts, + // not PostHog product data — they publish without the destructive gate. + expect( + isPostHogDestructiveSubTool( + "desktop-file-system-instructions-partial-update", + ), + ).toBe(false); + expect( + isPostHogDestructiveSubTool("desktop-file-system-canvas-partial-update"), + ).toBe(false); + }); + + it("still gates broader desktop-file-system mutations (exact-match, not prefix)", () => { + // These rename/delete whole channels and must stay gated. + expect( + isPostHogDestructiveSubTool("desktop-file-system-partial-update"), + ).toBe(true); + expect(isPostHogDestructiveSubTool("desktop-file-system-destroy")).toBe( + true, + ); + }); }); 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..0768df35d2 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts @@ -13,6 +13,19 @@ const POSTHOG_CALL_COMMAND_RE = /^\s*call\s+(?:--json\s+)?([a-zA-Z0-9_-]+)/; 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). These are the explicit, +// user-initiated output of a generation task — not the PostHog product data +// (flags/experiments/notebooks) the destructive gate exists to protect — so +// they don't need the extra per-call approval; they ride the same silent-allow +// path as create-family exec sub-tools. Exact names only: the broader +// desktop-file-system-{partial-update,destroy} sub-tools rename/delete whole +// channels and must stay 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); } @@ -26,5 +39,6 @@ export function extractPostHogSubTool(toolInput: unknown): string | null { } export function isPostHogDestructiveSubTool(subTool: string): boolean { + if (SANCTIONED_FIRSTPARTY_WRITE_SUBTOOLS.has(subTool)) return false; return POSTHOG_DESTRUCTIVE_SUBTOOL_RE.test(subTool); } From 0bb836712d4086881547ea20f2f2e36a45f0d387 Mon Sep 17 00:00:00 2001 From: jake sciotto Date: Tue, 14 Jul 2026 16:15:58 -0600 Subject: [PATCH 2/3] fix(agent): bind first-party publish auto-allow to the task's own artifact id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach exempted the CONTEXT.md/canvas publish sub-tools from the destructive gate by name, which let the agent publish to ANY artifact id without approval — it could silently overwrite an arbitrary channel's CONTEXT.md or any canvas. Bind the auto-allow to the exact artifact the task owns instead: - Revert `isPostHogDestructiveSubTool` to a pure classifier (these publishes are destructive again). - The generation prompts now embed a machine-readable authorized-write marker (sub-tool + id) via a shared helper. The agent captures those ids off the *user* message only (it can't forge one) onto the session. - The permission gate skips the destructive prompt for a sanctioned publish only when the call's `id` matches an authorized id — and never in plan mode, so CONTEXT.md co-design is preserved. Any other id still prompts. This carries the target id through the app-authored prompt, the one trusted source available on both local and cloud runs without backend changes. Generated-By: PostHog Code Task-Id: ab56d0cc-fb60-4554-a576-88307e823e67 --- .../agent/src/adapters/claude/claude-agent.ts | 40 +++++++- .../permissions/permission-handlers.test.ts | 98 +++++++++++++++++++ .../claude/permissions/permission-handlers.ts | 23 +++++ .../permissions/posthog-exec-gate.test.ts | 87 +++++++++++++--- .../claude/permissions/posthog-exec-gate.ts | 50 ++++++++-- packages/agent/src/adapters/claude/types.ts | 10 ++ .../src/authorized-write-marker.test.ts | 50 ++++++++++ .../shared/src/authorized-write-marker.ts | 41 ++++++++ packages/shared/src/index.ts | 5 + .../ui/src/features/canvas/contextPrompt.ts | 12 ++- .../ui/src/features/canvas/freeformPrompt.ts | 11 ++- 11 files changed, 403 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/authorized-write-marker.test.ts create mode 100644 packages/shared/src/authorized-write-marker.ts 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 d7f4ab3138..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", () => { @@ -82,26 +84,89 @@ describe("isPostHogDestructiveSubTool", () => { expect(isPostHogDestructiveSubTool("deleter-test")).toBe(false); }); - it("exempts sanctioned first-party desktop-file-system writes", () => { - // A channel's CONTEXT.md and freeform canvases are the app's own artifacts, - // not PostHog product data — they publish without the destructive gate. + 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(false); + ).toBe(true); expect( isPostHogDestructiveSubTool("desktop-file-system-canvas-partial-update"), - ).toBe(false); + ).toBe(true); }); +}); - it("still gates broader desktop-file-system mutations (exact-match, not prefix)", () => { - // These rename/delete whole channels and must stay gated. +describe("isSanctionedFirstPartyWriteSubTool", () => { + it("matches the two first-party publish sub-tools exactly", () => { expect( - isPostHogDestructiveSubTool("desktop-file-system-partial-update"), + isSanctionedFirstPartyWriteSubTool( + "desktop-file-system-instructions-partial-update", + ), ).toBe(true); - expect(isPostHogDestructiveSubTool("desktop-file-system-destroy")).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 0768df35d2..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,17 +10,23 @@ 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). These are the explicit, -// user-initiated output of a generation task — not the PostHog product data -// (flags/experiments/notebooks) the destructive gate exists to protect — so -// they don't need the extra per-call approval; they ride the same silent-allow -// path as create-family exec sub-tools. Exact names only: the broader +// 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 must stay gated. +// 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 @@ -39,6 +45,34 @@ export function extractPostHogSubTool(toolInput: unknown): string | null { } export function isPostHogDestructiveSubTool(subTool: string): boolean { - if (SANCTIONED_FIRSTPARTY_WRITE_SUBTOOLS.has(subTool)) return false; 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..f7411849f2 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 })}`; } From d0d8b838309443c934eebddfc8acc73b19a24220 Mon Sep 17 00:00:00 2001 From: jake sciotto Date: Tue, 14 Jul 2026 16:34:07 -0600 Subject: [PATCH 3/3] fix(agent): keep the canvas authorized-write marker inside the instructions tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freeform authorized-write marker was appended after ``, so it leaked into `extractCanvasInstructions(...).stripped` and broke freeformPrompt.test.ts, which asserts the stripped text is exactly the user's instruction. Move the marker inside the tag body. It's still in the raw prompt text the agent scans, and the conversation UI already collapses that tag — so the visible message stays the bare instruction. Generated-By: PostHog Code Task-Id: ab56d0cc-fb60-4554-a576-88307e823e67 --- packages/ui/src/features/canvas/freeformPrompt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index f7411849f2..36c010aca5 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -94,7 +94,7 @@ only when no insight can express the metric.`; ${instructions} - -${buildAuthorizedWriteMarker({ subTool: FREEFORM_PUBLISH_TOOL, id: dashboardId })}`; +${buildAuthorizedWriteMarker({ subTool: FREEFORM_PUBLISH_TOOL, id: dashboardId })} +`; }