Restricted data: govern restricted reads by observer verification - #382
Restricted data: govern restricted reads by observer verification#382Maximo-Guk wants to merge 6 commits into
Conversation
Preview:
|
|
cdd6c1f to
4a3f383
Compare
|
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>
4a3f383 to
a323357
Compare
|
|
…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>
a323357 to
4a88685
Compare
|
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. |
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) |
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)
This is the Step 5 gap in docs/observers.md (the "left their scope does not yet imply cannot reach" paragraph)
Fixed on the #assertUnverifiableProducerUnshared now treats any outstanding share link as "shared", the same predicate
The UI changes land in a follow up PR |
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 )
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.
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 |
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
Documented in docs/observers.md "Known gap — enableHook neither counts nor aborts gadget-minted children", deferring |
There was a problem hiding this comment.
Devin Review found 4 potential issues.
3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
…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>
|
LGTM! |
| 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 |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
I think this change should be significantly simplified.
After simplification the change should be mostly (entirely?) deletions.
| // 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); |
There was a problem hiding this comment.
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.
| // 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); |
| // 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."); | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| // 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()) { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
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