Skip to content

Restricted data: govern restricted reads by observer verification - #382

Open
Maximo-Guk wants to merge 6 commits into
restricted-data-flag-renamefrom
restricted-data
Open

Restricted data: govern restricted reads by observer verification#382
Maximo-Guk wants to merge 6 commits into
restricted-data-flag-renamefrom
restricted-data

Conversation

@Maximo-Guk

@Maximo-Guk Maximo-Guk commented Aug 28, 2026

Copy link
Copy Markdown
Member

Stacked on #381.

Why:

Before this PR: Once a user has observed a sensitive data source, we block sharing the resource altogether. We decided we can lift this restriction now that we have mechanisms in place to verify the observers permissions.

What:

Makes it so that now when restricted data is observed, the workspace can still be shared by verifying the observers access against the resource by reusing the same mechanisms we had already introduced in the original observer verification PR.

Frontend behavior is unchanged here, so the UI still declines to share a workspace even though the server now permits it. The UI will be done in #308

@github-actions github-actions Bot added kernel Changes to the Workshop kernel workshop/shared Changes to shared Workshop APIs labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown

Preview: pr382-restricted-data

https://pr382-restricted-data-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown
  • High: packages/workshop-backend/src/overseer.ts:8028 excludes unbound producers from use verification. Restricted data can be copied into gadget state/UI, then exposed to a use collaborator who never passes that producer’s addObserver(). Include all restricted producers in use scope or prevent their data reaching gadget-visible state.

github run

@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown
  • High: packages/workshop-backend/src/overseer.ts:5819 ignores observer IDs before their record is persisted. If one addObserver() finishes while another stalls, a restricted observation can exclude that pending observer, be admitted, then become visible after verification completes. Track pending IDs before registration.

  • High: packages/workshop-backend/src/overseer.ts:5863 treats an unbound producer as unreachable, but retained binding loopbacks remain callable after unbind. An excluded collaborator can directly receive the restricted observation. Revalidate the binding edge per call or keep them in scope.

  • Medium: packages/workshop-backend/src/overseer.ts:5706 ignores active share links. An unverifiable restricted read succeeds, then permanently makes an already-distributed link unredeemable while it remains active and listed. Include outstanding links in this check.

  • Medium: packages/workshop-frontend/src/ShareModal.tsx:778 still hides all sharing controls after restricted data is observed. This prevents the newly supported verified sharing and also blocks removing collaborators or revoking links.

github run

Restate what `ObservationDescription.containsRestrictedData` means now that the
enforcement is per-collaborator observer verification rather than an
all-or-nothing sharing lockdown, and state the two limits of the model plainly:
verification is held to the collaborator's role scope, and enforcement is at
admission rather than at each read.

No functional change; the implementation follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown
  • High: packages/workshop-backend/src/overseer.ts:9479 allows concurrent first opens to mint different observer IDs. The last persisted record orphans the other live ID, so later exclusions for it are ignored. Serialize verification per profile.

  • High: packages/workshop-backend/src/overseer.ts:5563 assumes scope restarts reverify every consumer, but persisted collaborator-initiated agent turns resume without authorization. They can send newly reachable restricted data to the collaborator’s model or external response target. Persist turn ownership and reauthorize on resume.

  • Medium: packages/workshop-backend/src/overseer.ts:7578 awaits after snapshotting ambient accounts. A stale reconciliation can subsequently delete a newly installed restricted producer, leaving its historical ID missing and permanently blocking future sharing. Fetch the sharing manager first or revalidate the account snapshot.

github run

@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown
  • High: packages/workshop-backend/src/overseer.ts:7067 does not count active collaborator agent turns as live sessions. After disconnect, an owner can add/bind a producer before the turn lazily seeds bindings, exposing restricted data to the collaborator’s model without verification. Hold a role lease through #unregisterRunningAgent().

  • High: packages/workshop-backend/src/overseer.ts:2734 only restarts on hook enable when counted wrappers remain. Gadget-returned child capabilities are independently retained, so a collaborator can dispose wrappers, avoid restart, and read restricted hook data through the child. Unconditionally restart the target gadget facet when enabling a hook.

github run

Maximo-Guk and others added 4 commits September 10, 2026 18:13
…ion.

Reading restricted data no longer locks the workspace down. The old model
blocked the observation outright if the workspace was shared and then refused
all future sharing, which made every sensitive data source unusable the moment
a workspace had a single collaborator. The observer verification machinery
already answers the real question -- does this collaborator have access to the
same data? -- at every open, and a widening of that scope now restarts every
live session, so admission is a sound enforcement point.

So: drop the `hasAnyShares()` block in `authorizeObservation` and the three
guards on the sharing mutators. Keep the two guards that are about leaking data
back out rather than about who may see it -- no actions and no public web
fetches once the latch is set.

What replaces them is narrower. A producer nobody can ever be verified against
(a vendorless connection, or a legacy record with no `creationSpec`) is still
refused while the workspace is shared, because `#inScopeGatekeepers` skips it
and so admission cannot see it at all. Removing a producer's record is blocked
while the workspace is shared, since that record is what verification runs
against. And a new grant -- a collaborator, a share link, another key for one,
or a redemption -- is refused if some producer can no longer verify anyone.
Each of those checks runs in the same synchronous block as the write it gates,
after every await, so a concurrent change cannot slip between check and write.

`sharing.ts` loses `hasAnyShares()` and gains an optional `assertGrantAllowed`
on each grant-writing method, invoked at that write.

Two smaller things fall out. `getSharingManager()` moves inside the
`containsRestrictedData` branch, so an ordinary observation on a cold DO no
longer pays for an owner User DO round trip; the producer record is then read
after that await, since latching against a stale record would permanently brick
sharing. And the restart on a terminal re-verification failure is hoisted ahead
of the best-effort rollback, taking a gatekeeper RPC fan-out off the path
between determining the denial and the abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the latch (what sets it, and the cases that must refuse the read rather
than latch), the producer-removal guard and its exemptions, the grant checks on
each sharing mutator, and the tolerance for action records written before the
flag's rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives the model end to end through the test gatekeeper: a restricted read on a
shared workspace, the unverifiable-producer refusal, the removal guard, the
action and web-fetch blocks, and the restart that forces re-verification when
scope widens.

`TestSession.readThing()` takes an optional `restricted` flag so a test can trip
the latch through the same `ApprovalQueue` funnel a shipping gatekeeper uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrites the observer document's model section around admission-time
enforcement, states the two limits (role-scoped verification, and enforcement
at admission rather than at each read) as edge cases with their reasoning, and
records the design under plans/restricted-data-sharing.md -- including the
known risk of a producer no gadget binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown
  • High: packages/workshop-backend/src/overseer.ts:9685 denies only the failed re-open while prior collaborator capabilities remain live. After addObserver() confirms revoked access, those owner-backed sessions can continue reading restricted data. Terminal re-verification failures must revoke existing sessions.

github run

@Maximo-Guk

Maximo-Guk commented Sep 11, 2026

Copy link
Copy Markdown
Member Author
  • High: packages/workshop-backend/src/overseer.ts:9685 denies only the failed re-open while prior collaborator capabilities remain live. After addObserver() confirms revoked access, those owner-backed sessions can continue reading restricted data. Terminal re-verification failures must revoke existing sessions.

github run

This is the lazy-revocation we already document explicitly, at the throw site, in docs/sharing.md docs/observers.md edge case 3, and in the Known limitations of plans/restricted-data-sharing.md. The integration test "a failed re-verification denies that open and nothing else" also pins it.

@Maximo-Guk

Copy link
Copy Markdown
Member Author
  • High: packages/workshop-backend/src/overseer.ts:8028 excludes unbound producers from use verification. Restricted data can be copied into gadget state/UI, then exposed to a use collaborator who never passes that producer’s addObserver(). Include all restricted producers in use scope or prevent their data reaching gadget-visible state.

github run

This is the "never-bound producers" item under Accepted tradeoffs in plans/restricted-data-sharing.md, described as a known security risk and marked at the site by the TODO(known-risk)

@Maximo-Guk

Maximo-Guk commented Sep 11, 2026

Copy link
Copy Markdown
Member Author
  • High: packages/workshop-backend/src/overseer.ts:5819 ignores observer IDs before their record is persisted. If one addObserver() finishes while another stalls, a restricted observation can exclude that pending observer, be admitted, then become visible after verification completes. Track pending IDs before registration.

Known it's the mid-registration item under Known limitations in both docs/observers.md and the plan doc, marked by the TODO(observer-races)

  • High: packages/workshop-backend/src/overseer.ts:5863 treats an unbound producer as unreachable, but retained binding loopbacks remain callable after unbind. An excluded collaborator can directly receive the restricted observation. Revalidate the binding edge per call or keep them in scope.

This is the Step 5 gap in docs/observers.md (the "left their scope does not yet imply cannot reach" paragraph)

  • Medium: packages/workshop-backend/src/overseer.ts:5706 ignores active share links. An unverifiable restricted read succeeds, then permanently makes an already-distributed link unredeemable while it remains active and listed. Include outstanding links in this check.

Fixed on the #assertUnverifiableProducerUnshared now treats any outstanding share link as "shared", the same predicate removalBlockedByRestrictedData uses

  • Medium: packages/workshop-frontend/src/ShareModal.tsx:778 still hides all sharing controls after restricted data is observed. This prevents the newly supported verified sharing and also blocks removing collaborators or revoking links.

github run

The UI changes land in a follow up PR

@Maximo-Guk

Maximo-Guk commented Sep 11, 2026

Copy link
Copy Markdown
Member Author
  • High: packages/workshop-backend/src/overseer.ts:9479 allows concurrent first opens to mint different observer IDs. The last persisted record orphans the other live ID, so later exclusions for it are ignored. Serialize verification per profile.

Known, race documented in known limitations in observer.md doc, marked by the TODO at the site. ( pre-existing won't fix in this PR )

  • High: packages/workshop-backend/src/overseer.ts:5563 assumes scope restarts reverify every consumer, but persisted collaborator-initiated agent turns resume without authorization. They can send newly reachable restricted data to the collaborator’s model or external response target. Persist turn ownership and reauthorize on resume.

Gap is documented at receiveExternalMessage (overseer.ts and in docs/observers.md under "Known gap — the agent turn an external message starts". Nothing yet calls the external chat endpoint, so I'm continuing to defer this. And platform-gateway mode should be used if you want to restrict what AI providers your deployment uses.

  • Medium: packages/workshop-backend/src/overseer.ts:7578 awaits after snapshotting ambient accounts. A stale reconciliation can subsequently delete a newly installed restricted producer, leaving its historical ID missing and permanently blocking future sharing. Fetch the sharing manager first or revalidate the account snapshot.

github run

In ensureAmbientCapsules the only await after the accounts snapshot is getSharingManager(), which this branch added so that the guard could run synchronously: the gatekeeper records are snapshotted after it, and the loop from snapshot through removalBlockedByRestrictedData to removeGatekeeper has no await at all

@Maximo-Guk

Maximo-Guk commented Sep 11, 2026

Copy link
Copy Markdown
Member Author
  • High: packages/workshop-backend/src/overseer.ts:7067 does not count active collaborator agent turns as live sessions. After disconnect, an owner can add/bind a producer before the turn lazily seeds bindings, exposing restricted data to the collaborator’s model without verification. Hold a role lease through #unregisterRunningAgent().

This is pre-existing same root cause as the resumed-turn finding above as the comment in overseer.ts already says. The window is narrower than "any time after disconnect", deferring and on top of that if the deployment admin wants to restrict model egress they can just use platform gateway mode

  • High: packages/workshop-backend/src/overseer.ts:2734 only restarts on hook enable when counted wrappers remain. Gadget-returned child capabilities are independently retained, so a collaborator can dispose wrappers, avoid restart, and read restricted hook data through the child. Unconditionally restart the target gadget facet when enabling a hook.

github run

Documented in docs/observers.md "Known gap — enableHook neither counts nor aborts gadget-minted children", deferring

@Maximo-Guk
Maximo-Guk marked this pull request as ready for review September 11, 2026 01:21

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread packages/workshop-backend/src/overseer.ts
Comment thread packages/workshop-backend/src/overseer.ts
Comment thread packages/workshop-backend/src/overseer.ts
Comment thread packages/workshop-backend/src/overseer.ts
…pping no-op re-grants.

The overseer called assertNewSharingAllowed() unconditionally before
SharingManager.addCollaborator() could learn whether the caller already
had an edge to this profile, so a same-or-lower re-grant (a note update
or a pure no-op) was refused once the workspace became permanently
owner-only. Every other grant mutator takes an assertGrantAllowed hook
and runs it at the write; redeemShareKey skips it for an existing edge.

addCollaborator now takes the same hook and invokes it only when a grant
is created: a new record, a new edge from this sharer, or a role rise on
the existing edge. The check still runs in the same synchronous block as
the storage write, after every await. maxRole is gone with the rewrite.

Unreachable in practice (the removal guard refuses to remove a producer
while any reachable collaborator exists), fixed for consistency with the
documented design in plans/restricted-data-sharing.md §4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown

LGTM!

github run

Comment thread docs/observers.md
is what observer verification runs against, and the restricted data outlives it in chat
history and storage, so deleting it would let a never-verified collaborator open unchecked.
Outstanding share links block removal the same way: their keys never expire, and redemption
is gated at open() only while the record exists. The remedy is to remove collaborators and

@Maximo-Guk Maximo-Guk Sep 11, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not a big fan of this line so I'll likely reword it as it makes it seem like you'll be able to share again, but you won't, it's just the remedy to get the owner working again. This workspace will now be owner only forever until we add an escape hatch

@kentonv kentonv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this change should be significantly simplified.

After simplification the change should be mostly (entirely?) deletions.

Comment on lines +5543 to +5563
// Resolved here rather than up front: on a cold DO this is an RPC to the owner's User DO,
// and an ordinary unrestricted observation must not pay for it. The producer record is read
// *after* that await, so the check below and the latch are one synchronous block -- a record
// read before the await could be stale by the time it is checked, and latching against a
// stale one permanently bricks sharing.
let sharing = await this.getSharingManager();
let producer = this.storage.gatekeepers.get(gatekeeperId);

// An in-flight facet RPC can outlive removeGatekeeper, so a restricted observation can
// arrive naming a connection this workspace no longer has. Latching a missing producer id
// permanently bricks sharing (assertNewSharingAllowed's missing-record branch), so refuse
// the read instead -- including on an unshared workspace, where nothing else would stop it.
// This same read is what refuses a connection removed during the exclusion awaits above,
// where the latch is not yet set and so removalBlockedByRestrictedData does not yet protect
// the producer.
if (!producer) {
throw new Error(
"This observation was blocked because it contains sensitive data, but the " +
"connection it was read through has been removed from this workspace.");
}
this.#assertUnverifiableProducerUnshared(producer, sharing);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just delete all this, and delete #assertUnverifiableProducerUnshared, the check it is performing is not important and all this complexity and these slop comments are not worth it.

Suggested change
// Resolved here rather than up front: on a cold DO this is an RPC to the owner's User DO,
// and an ordinary unrestricted observation must not pay for it. The producer record is read
// *after* that await, so the check below and the latch are one synchronous block -- a record
// read before the await could be stale by the time it is checked, and latching against a
// stale one permanently bricks sharing.
let sharing = await this.getSharingManager();
let producer = this.storage.gatekeepers.get(gatekeeperId);
// An in-flight facet RPC can outlive removeGatekeeper, so a restricted observation can
// arrive naming a connection this workspace no longer has. Latching a missing producer id
// permanently bricks sharing (assertNewSharingAllowed's missing-record branch), so refuse
// the read instead -- including on an unshared workspace, where nothing else would stop it.
// This same read is what refuses a connection removed during the exclusion awaits above,
// where the latch is not yet set and so removalBlockedByRestrictedData does not yet protect
// the producer.
if (!producer) {
throw new Error(
"This observation was blocked because it contains sensitive data, but the " +
"connection it was read through has been removed from this workspace.");
}
this.#assertUnverifiableProducerUnshared(producer, sharing);

Comment on lines +5695 to +5735
// Refuse a restricted observation from a producer nobody can ever be verified against: a
// gatekeeper with no vendor account behind it (aiModel/agentSpawner) or a legacy record with no
// creationSpec. Every *other* producer is enforced at admission -- a collaborator cannot open
// the workspace without passing addObserver() for it, and anything that widens what they must
// pass restarts every live session (see #restartIfSessionsAffected) -- but #inScopeGatekeepers skips
// these, so no collaborator is ever asked about them and admission cannot see them at all.
// Consistent with assertNewSharingAllowed(), which treats the same case as unshareable.
//
// "Shared" means any collaborator *or* any outstanding share link -- the same predicate as
// removalBlockedByRestrictedData (and what the pre-verification hasAnyShares() refusal counted).
// Links matter because their keys never expire and are multi-redeemable: if this read were
// admitted, the latch would make assertNewSharingAllowed() refuse every later redemption, so a
// link the owner has already handed out would be permanently unredeemable with no way back.
//
// Deliberately synchronous (the sharing manager is a parameter, not an internal await) so the
// caller can check and latch in one synchronous block -- see authorizeObservation.
#assertUnverifiableProducerUnshared(gatekeeper: GatekeeperRecord, sharing: SharingManager): void {
if (sharing.listCollaborators().length === 0 &&
sharing.listShareLinkRecords().length === 0) {
return;
}

let vendorId: string | null = null;
try {
vendorId = observerVendorId(gatekeeper);
} catch {
// Legacy connection with no creationSpec: treat as unverifiable.
}
if (vendorId !== null) return;

// The message reaches sandboxed gadget code and agent output -- an audience that can't
// otherwise list collaborators -- so it reports only that the workspace is shared, naming
// neither the collaborators nor their profile ids (the full email on OAuth/CF Access
// deployments).
throw new Error(
"This observation was blocked because it contains sensitive data, but it was read " +
"through a connection that cannot verify anyone's access to that data, and this " +
"workspace is shared. Its collaborators must be removed and its share links revoked " +
"before this data can be read.");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This method is pedantic. We don't have to handle the case that aiModel and agentSpawner set constainsRestrictedData, because they never do. Legacy records with no creationSpec are also really not worth this much complexity -- users with very old workspaces will just have to remake those workspaces.

Suggested change
// Refuse a restricted observation from a producer nobody can ever be verified against: a
// gatekeeper with no vendor account behind it (aiModel/agentSpawner) or a legacy record with no
// creationSpec. Every *other* producer is enforced at admission -- a collaborator cannot open
// the workspace without passing addObserver() for it, and anything that widens what they must
// pass restarts every live session (see #restartIfSessionsAffected) -- but #inScopeGatekeepers skips
// these, so no collaborator is ever asked about them and admission cannot see them at all.
// Consistent with assertNewSharingAllowed(), which treats the same case as unshareable.
//
// "Shared" means any collaborator *or* any outstanding share link -- the same predicate as
// removalBlockedByRestrictedData (and what the pre-verification hasAnyShares() refusal counted).
// Links matter because their keys never expire and are multi-redeemable: if this read were
// admitted, the latch would make assertNewSharingAllowed() refuse every later redemption, so a
// link the owner has already handed out would be permanently unredeemable with no way back.
//
// Deliberately synchronous (the sharing manager is a parameter, not an internal await) so the
// caller can check and latch in one synchronous block -- see authorizeObservation.
#assertUnverifiableProducerUnshared(gatekeeper: GatekeeperRecord, sharing: SharingManager): void {
if (sharing.listCollaborators().length === 0 &&
sharing.listShareLinkRecords().length === 0) {
return;
}
let vendorId: string | null = null;
try {
vendorId = observerVendorId(gatekeeper);
} catch {
// Legacy connection with no creationSpec: treat as unverifiable.
}
if (vendorId !== null) return;
// The message reaches sandboxed gadget code and agent output -- an audience that can't
// otherwise list collaborators -- so it reports only that the workspace is shared, naming
// neither the collaborators nor their profile ids (the full email on OAuth/CF Access
// deployments).
throw new Error(
"This observation was blocked because it contains sensitive data, but it was read " +
"through a connection that cannot verify anyone's access to that data, and this " +
"workspace is shared. Its collaborators must be removed and its share links revoked " +
"before this data can be read.");
}

// through.
restrictedProducerIds(): Set<WorkpieceId> {
let producers = new Set<WorkpieceId>();
for (let record of this.storage.actions.list()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This loop is unacceptably expensive.

// that can no longer verify a recipient's access to it -- one that has since been removed, or
// that never had a vendor account behind it. Every other producer verifies its collaborators at
// each open, so sharing stays available.
assertNewSharingAllowed(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is too weird and pedantic.

I'm pretty sure at present there's actually no UI to delete a gatekeeper.

How about if and when we create one, we make the user check a box saying: "I certify that no sensitive data from this gatekeeper has been retained in this workspace."

This should apply to any gatekeeper, not just ones that have generated observations with containsRestrictedData.

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

Labels

kernel Changes to the Workshop kernel workshop/shared Changes to shared Workshop APIs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants