Skip to content

Let people lock a Content page's text against agents - #8550

Open
3mdistal wants to merge 13 commits into
mainfrom
work/content-agent-lock-20261102
Open

3mdistal wants to merge 13 commits into
mainfrom
work/content-agent-lock-20261102

Conversation

@3mdistal

@3mdistal 3mdistal commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 (isOutsideAgentCall in 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 with errorCode: AGENT_TEXT_LOCKED and a message pointing the agent at comments and suggestions.

What changed

  • Storage: documents.agent_text_locked, an integer column defaulting to 0, added by migration 135.
  • The switch: set-document-agent-text-lock is browser-only and needs admin access. AgentTextLockSetting.tsx puts it in the Share panel, translated into the app's languages. People without manage access see it disabled.
  • The refusal: actions/_document-agent-lock.ts holds 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.
  • Retries and waits: a write that landed before the lock went on still returns its receipt when retried, so an agent isn't told a finished write failed. A write that can take a while checks again at its commit, so a lock set in the meantime still stops it: saves, edits, version restores, Blocks values, transcripts, row migrations and their rollbacks, suggestion accepts, local-file sharing, Builder doc pulls and queued block edits. The live copy refuses an agent's body change at its commit too.
  • Source imports: an agent importing a batch skips a locked page and reports why, leaves that page where it is, and still imports the batch's other files.
  • Suggestions: server/lib/suggested-edits.ts refuses an agent accepting a proposal on a locked page and lets a person do it.
  • Notion: replaceDocumentFromExternal is 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 in refreshRefused. The plain /api/documents/[id]/notion/* routes go through runAsRouteCaller, 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

  • Rollout: the migration adds a column with a default and is safe to run first. Beta and production share one database, so production ignores the lock until it deploys. Until then a page locked on beta can still be edited by agents through production.
  • Rollback: code rolled back ignores the column, so locked pages simply stop being protected. The data stays.
  • What it doesn't cover:
    • It covers this page and its tabs, not its sub-pages. An agent can't trash a page that holds a locked sub-page, but a person can, and it's recoverable.
    • Syncs a person starts or that run on their own still write, because a person connected the source. So does a GitHub folder sync an agent starts: it doesn't name one page, so refusing it would have to work page by page, as source imports do. A page following a GitHub file is already view-only in Content, so that sync is only the file's own text arriving, and its Trash follows the file's deletion.
    • Non-text property values stay editable by agents, and agents can still delete non-Blocks columns. Row migrations are refused whole if any affected row is locked.
    • A space rename can set a system page's title.
    • A script holding a person's browser cookie isn't stopped. 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.
    • An agent's write and the switch can still race on the paths that check before taking the page's row lock, such as row updates, within a few milliseconds. A lock arriving between a linked local file's write and the page's row lock lets the page follow its file, because the file has already taken the edit.
  • Existing gap, unchanged: patchRowsInsideTransaction's title path lacks the GitHub source check.

Verification

  • The lock's database tests pass. I undid each fix from Builder's fifth review in turn, 6 undos, and a test caught every one, as with the 7 undos from its fourth review, the 10 from its third, the 25 from its second and the 18 from its first. lifecycle-lock-order.postgres.integration.test.ts passes 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 in comment-ai-concurrency.db.test.ts (Unknown lab: creative-context.library), content-database-block-actions.db.test.ts "serializes a direct title edit" and github-source.db.test.ts "syncs every stale link". One unhandled error comes with that last group. CI runs them all. The repository guards pass, except guard:compiler-strictness, which couldn't read its config on this PC, so CI has to speak for that one.
  • Real app on the final commit, 20235f7, in a local sandbox with a fresh database:
    • The switch turns on and off in the Share panel. A plain HTTP call to it gets a 403 with ui_capability_required.
    • With it on, an agent's edit, alt text, link-notion-page and create-and-link-notion-page are refused over real MCP, and the page text is unchanged. A token on the plain link and create-and-link routes gets a 403 with AGENT_TEXT_LOCKED.
    • An agent's comment and suggestion go through, and its accept is refused. A person clicking Accept in the editor changes the text, and a person typing on the locked page saves.
    • On a collection with a locked row, an agent deleting the Blocks property over real MCP is refused, and the column and the row's text stay. A token over plain HTTP changing a Blocks field's type gets the 403, and the type stays. MCP's request has no type field, so an MCP agent can't ask for that change. A person's delete and type change both land.
    • With the switch off, an agent's edit and alt text both land over real MCP.
  • Real app on earlier commits, not repeated on the final one: the switch stays on after a reload and isn't exposed as a tool. An agent's title change and Trash are refused over real MCP. An agent trashing a parent that holds a locked sub-page is refused, and it can still edit that parent's own text. A token sent with the browser's headers gets the 403.
  • Not run for real: the in-app agent, because the sandbox blocks network access to a model, and a real embedded session. Database tests cover the agent's caller and the embed exemption. A lock arriving partway through a write is timing, so database tests prove it by turning the lock on as the write's transaction opens, or just before it takes the page's row. The sandbox has no Notion connection, so pulls, pushes, auto-sync and child pages are proved by database tests with Notion's answers faked. It has no Builder connection either, so Builder doc pulls and pushes are proved by _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 in agent-text-lock.db.test.ts. The disabled look for someone without manage access came from mocking canManage: false; a server test proves an editor is refused.
  • Sol (GPT-6.1, high) reviewed two rounds, and every finding is fixed. Builder's review came after that and found 9 more, then 7 on the next push, 3 on the one after, 2 after that plus one in its summary, and 2 on the push after that. I declined one for the reason above, the GitHub folder sync. I also declined the race with collection column changes, saying a fix risked deadlocks. That was wrong: Content's lock order already puts pages after memberships, so an agent's column change now locks the pages and checks under those locks. Its other findings are fixed, apart from the limits listed above. Its third round showed I was wrong that a Builder doc pull doesn't name one page, so I checked every other writer for the same gap, and also fixed Builder doc pushes and 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

isAgentCall in _document-agent-lock.ts, which decides who's refused, and where the check sits around receipt replay. _document-edit-mutation.ts checks after the replay, and _database-block-actions.ts checks inside its transaction, on a fresh read of the row, after waiting its turn. Also the commit check in server/live-copy/persist.ts, which reads the caller from the write's cause, trashDocumentSubtree checking a whole subtree, keepAgentCallProvenance, which keeps a relabeled save counted as an agent's, and serverSaveContext, 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

3mdistal and others added 5 commits November 2, 2026 00:56
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>
@builder-io-integration

Copy link
Copy Markdown
Contributor

⚠️ Review Agent ran into a problem and couldn't finish reviewing the latest commit.

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: 7e010b27b7a14e52aae98284b733cb37

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

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>

@builder-io-integration builder-io-integration Bot left a comment

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.

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 frontend caller/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)

Comment on lines +19 to +23
export function isAgentCall(ctx: CallProvenance | undefined) {
return (
ctx !== undefined &&
ctx.caller !== "mcp-widget-write" &&
(ctx.caller === "tool" || isOutsideAgentCall(ctx))

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.

🔴 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.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
builder-io-integration[bot]

This comment was marked as outdated.

3mdistal and others added 2 commits November 2, 2026 07:52
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>

@builder-io-integration builder-io-integration Bot left a comment

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.

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.

Comment thread templates/content/parity/matrix.ts Outdated
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.",

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.

🔴 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.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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-io-integration[bot]

This comment was marked as outdated.

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>
builder-io-integration[bot]

This comment was marked as outdated.

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>
builder-io-integration[bot]

This comment was marked as outdated.

… 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>

@builder-io-integration builder-io-integration Bot left a comment

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.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant