Repository navigation
Conversation
Adds a switch to the Share panel, "Agents can't edit the text of this". While it's on, agents can still read, comment and suggest edits, but a person has to accept those suggestions and make any direct text change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Media alt text and transcripts, collection Trash and import undo, row migrations, preview draft recovery, collection conversion, Blocks property deletes, slash rollback and local-file sharing all refuse an agent now. Receipts for writes that landed before the lock still replay, and a person's chat widget write isn't treated as an agent's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent could still write to a locked page three ways: importing a source file over it, changing a Blocks field to another type, and a block edit that was already waiting when the lock went on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
We've been automatically notified and are looking into it. Push a new commit to re-trigger the review, or contact support@builder.io if this keeps happening. Error ID: |
|
Here's a visual recap of what changed: Open the full interactive recap |
Format the changed files with the repository's formatter, which also drops unrelated rewrapping in two translation files. Keep the access call and the lock check within the strict compiler flags on changed lines, and list the lock's action in the parity matrix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Builder reviewed your changes and found 8 potential issues 🔴
Review Details
Code Review Summary
PR #8550 adds a page-level agent-text lock, persists it through a new additive migration, exposes it in the Share panel, and wires enforcement into many document and collection mutations. This is a high-risk behavior change because the lock is an agent-edit boundary. The central helper, translated UI, fresh-read checks in several queued writes, and broad action tests are good foundations, but important alternate write paths and provenance races still allow text changes after the lock is enabled.
Key findings
- 🔴 HIGH: An unverified
frontendcaller/header is treated as a person, allowing an agent request to bypass the lock; local-folder sync/conflict and Notion pull also write locked text without this check. - 🟡 MEDIUM: Trash checks only the root, so locked descendants can be trashed. Several writes check stale state before their commit, and long-running transcription drops caller context. A locked import reported as skipped can still change its layout.
The schema change is additive, the UI surfaces the control in the Share panel, and the tests cover many important allowed and refused paths. The remaining findings need coverage before the feature's stated guarantee is reliable.
🧪 Browser testing: Will run after this review (PR touches UI code)
| export function isAgentCall(ctx: CallProvenance | undefined) { | ||
| return ( | ||
| ctx !== undefined && | ||
| ctx.caller !== "mcp-widget-write" && | ||
| (ctx.caller === "tool" || isOutsideAgentCall(ctx)) |
There was a problem hiding this comment.
🔴 Unverified frontend callers bypass the lock
Core derives caller: "frontend" from a client-supplied header, while the separate verified-UI result is not included in ActionRunContext; this predicate therefore treats an unproven request as a person. An authenticated agent/client can set that header and make the locked page writable; base the exemption on verified UI provenance, not the raw caller value.
There was a problem hiding this comment.
Fixed in part. A token sent with the browser's header now counts as an agent, because the browser signs in with a cookie; MCP credentials were already labelled as agents, and an embedded session's token is exempt through its own header. A real request with a token and the browser's headers gets the 403. I left one case: a script holding a person's browser cookie isn't stopped, because telling it apart would mean requiring the browser's UI proof on every save, which the browser only mints on demand, and whoever holds the cookie could mint it too. The PR body lists this as a limit.
- Claude Code AI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lock turned on while a write was under way could still be overwritten: a save, a version restore, a Blocks value and a transcript each checked it once at the start, and an agent's preview draft recovery ran as the browser. Each now checks again at its commit, and the live copy refuses an agent's body change there too. An agent could also reach a locked page through a local folder sync, a folder conflict, a Notion pull or by trashing its parent, and a token sent with the browser's header passed as a person. Those are refused now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Builder reviewed your changes and found 7 potential issues 🔴
Review Details
Incremental Review Summary
This update adds caller checks to local-folder and Notion actions, forwards context through source sync, and moves several lock checks into row-locked save paths. It also fixes eight previously reported issues, which I verified in the diff; those threads have been resolved. The caller-provenance finding at _document-agent-lock.ts remains open and is not repeated.
I found new gaps in other writer paths. GitHub folder sync can still replace or trash locked pages, Notion's new action checks are preflight-only and do not cover all callers or recursively synced children, and several Blocks/migration writes check before acquiring document row locks. Two media fallbacks also drop caller context before the final save. Because this feature enforces a user data-mutation boundary, the PR remains high risk and should not merge until these paths are covered.
🧪 Browser testing: Skipped — browser-test-planner is unavailable; this PR has UI impact.
| label: "Lock a page's text against agents", | ||
| uiEntrypoints: ["app/components/editor/DocumentToolbar.tsx"], | ||
| durableEffect: | ||
| "The lock is stored on the document, and every action that writes a page's text refuses an agent's call while it is on.", |
There was a problem hiding this comment.
🔴 GitHub folder sync can overwrite or trash locked pages
The parity claim is not true for GitHub sync: the agent-callable sync-github-folder path writes linked page title/body directly and calls trashDocumentSubtree without passing caller context, so the new subtree guard is skipped too. Thread ctx through the GitHub sync writers and enforce the lock on the affected pages before applying content or trash changes.
There was a problem hiding this comment.
I don't think this needs a code change because a page following a GitHub file is already view-only in Content: assertSourceAllowsPageEdit refuses edits there with SOURCE_READ_ONLY, so this sync is only the file's own text arriving, and its Trash follows the file's deletion. An agent can't change that text by asking Content, and enforcing the lock here would only stop a page following its file. The PR body lists this as a limit, and I corrected the matrix wording so it no longer claims every action.
- Claude Code AI
Builder's second review found ways past the lock. An edit and a row migration checked before taking the page's row, so a switch landing in between was missed. They check again under the row lock. Notion's text is now checked at the write itself, and the caller reaches it from link, create-and-link, auto-sync, conflict resolution, push and child pulls. The plain Notion routes count a token as an agent and keep the refusal's 403. Alt text and transcript fallbacks keep who started them without leaving the server's save path. The parity matrix no longer claims every action refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builder's review showed a Builder doc pull names one page, so the exception for it was wrong: an agent's pull is now refused under the page's row lock. A push still reaches Builder, and a locked page keeps its own text. Accepting a suggestion, refreshing a local file's copy and edit-document's path without a revision now check again under the row lock, which a person's switch waits for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent can supply the text it pushes, and the page's next pull would bring that back, so the push is now refused before it goes out, including when it names no page but the entry belongs to a locked one. Create-and-link now says when a lock stopped the pull after it made the Notion page. A page linked to a local file follows its file once the file has taken the edit, rather than refusing and leaving the two apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… pages Deleting a Blocks property or changing a Blocks field's type held collection and membership locks, which don't stop the switch, so an agent could pass the check and then remove text a person had just locked. An agent's change now takes the page rows and checks under them. That follows Content's own lock order, which is why the deadlock worry I gave for leaving this open was wrong. A person's change takes no new locks. An agent's Builder push now looks for locked pages following the entry across every page the caller can reach, not only pages it owns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Builder reviewed your changes — no new findings
Review Details
Review Summary
This high-risk PR adds a document-level agent text lock and carries caller provenance through write actions and sync routes. The latest update serializes Blocks-field conversion/deletion with page-row locks and broadens Builder source lookup to pages the caller can access; the fixes are covered with targeted race and shared-page tests. I verified these changes and resolved the corresponding three open review threads before this submission. The implementation approach is sound, and I found no new actionable issues in this pass. Existing provenance and GitHub-folder-sync comments remain open and were not reposted.
🧪 Browser testing: Skipped — browser-test-planner is unavailable in this environment. The dev server was running.

Problem
Content lets agents edit a page's text wherever a person can. Some pages, like a finished essay or text someone wrote carefully by hand, should only change through a person. Today the only way to keep agents off a page is to take away their access, which also stops them commenting or proposing changes, and that's usually the part people still want.
Approach
A person who manages a page can turn on Agents can't edit the text of this in the Share panel. While it's on, an agent can't change that page's text, title or description, or move it to Trash. It can still comment and propose a change with
suggest-document-edit, and only a person can accept a proposal. People keep editing as usual.This is a guard rail for well-meaning agents, not a permission. It sits next to access checks and doesn't replace them.
The server decides who counts as an agent. A call is an agent's if it comes from the in-app agent's tools, MCP, A2A, an automation, or another caller that isn't the browser (
isOutsideAgentCallin core). A person's write through a chat widget doesn't count, because core grants that caller only with a widget capability, an embed session and a browser request. A token sent with the browser's header does count, because the browser signs in with a cookie. An embedded session's token is the exception, and it's marked by its own header. A refusal is a 403 witherrorCode: AGENT_TEXT_LOCKEDand a message pointing the agent at comments and suggestions.What changed
documents.agent_text_locked, an integer column defaulting to 0, added by migration 135.set-document-agent-text-lockis browser-only and needs admin access.AgentTextLockSetting.tsxputs it in the Share panel, translated into the app's languages. People without manage access see it disabled.actions/_document-agent-lock.tsholds the check, and every action that writes a page's text calls it: document updates and edits, block edits, row updates and batches, migrations, media alt text and transcripts, import undo, source imports, local folder syncs and conflicts, Builder doc pulls, Notion pulls, links and auto-syncs, collection conversion and Trash, Blocks property deletes and type changes, slash rollback, local-file sharing and preview draft recovery.server/lib/suggested-edits.tsrefuses an agent accepting a proposal on a locked page and lets a person do it.replaceDocumentFromExternalis where Notion's text reaches a page, so the check sits there, and the caller reaches it from link, create-and-link, auto-sync, conflict resolution, push and child pulls. A locked child page stays as it is with an error on its link, and the parent's pull still lands. A lock arriving during a link puts back the previous link. A lock arriving during create-and-link keeps the link to the new Notion page, so a retry doesn't make another, and the refusal names that page. An agent can still push a locked page to Notion, and the page keeps its own text if Notion tidies it. An agent's Builder doc push is refused before it goes out when any page the caller can reach, shared ones included, follows that entry and is locked, because its text can be whatever the agent supplies and the page's next pull would bring it back. A lock arriving during the push leaves the page's text alone, and the answer says so inrefreshRefused. The plain/api/documents/[id]/notion/*routes go throughrunAsRouteCaller, which counts a token as an agent and a browser's cookie as a person, and keeps the refusal's 403, because h3 would otherwise send a bare 500. Nothing in the app calls those routes today.Safety and operations
patchRowsInsideTransaction's title path lacks the GitHub source check.Verification
lifecycle-lock-order.postgres.integration.test.tspasses on a real PostgreSQL here, 112 tests, including a new one for an agent deleting a primary Blocks property. 130 neighboring test files give 2,821 passes and 4 failures on this PC, which also fail without this change: two incomment-ai-concurrency.db.test.ts(Unknown lab: creative-context.library),content-database-block-actions.db.test.ts"serializes a direct title edit" andgithub-source.db.test.ts"syncs every stale link". One unhandled error comes with that last group. CI runs them all. The repository guards pass, exceptguard:compiler-strictness, which couldn't read its config on this PC, so CI has to speak for that one.ui_capability_required.link-notion-pageandcreate-and-link-notion-pageare refused over real MCP, and the page text is unchanged. A token on the plain link and create-and-link routes gets a 403 withAGENT_TEXT_LOCKED._builder-docs-client.test.ts, which fakes both Builder and the database, and the check for a shared page following an entry runs on a test database inagent-text-lock.db.test.ts. The disabled look for someone without manage access came from mockingcanManage: false; a server test proves an editor is refused.edit-document's path without a revision. None of Builder's fixes has had a Sol round. Its sixth review, on the final commit, has no new findings. Two of its threads stay open with my reasons: how far a caller can be trusted, and the GitHub folder sync.Review focus
isAgentCallin_document-agent-lock.ts, which decides who's refused, and where the check sits around receipt replay._document-edit-mutation.tschecks after the replay, and_database-block-actions.tschecks inside its transaction, on a fresh read of the row, after waiting its turn. Also the commit check inserver/live-copy/persist.ts, which reads the caller from the write's cause,trashDocumentSubtreechecking a whole subtree,keepAgentCallProvenance, which keeps a relabeled save counted as an agent's, andserverSaveContext, which marks a server-run save as an agent's without leaving the server's save path.Follow-ups
Per-tab settings and access levels, like "these people can't edit this", are later work by design.
🤖 Generated with Claude Code