Skip to content

Commit 146ca0c

Browse files
committed
Enforce that people you share with have direct access to underlying gatekeepers
1 parent c6445a9 commit 146ca0c

39 files changed

Lines changed: 4142 additions & 195 deletions

File tree

.agents/skills/write-gatekeeper/SKELETON.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ Replace all `My`/`my`/`MY` prefixes with the service name.
88
import { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from "cloudflare:workers";
99
import {
1010
GatekeeperUser,
11+
GatekeeperUserVerifier,
1112
GatekeeperVendor as GatekeeperVendorIface,
1213
Gatekeeper,
1314
HookController, // Remove if no hooks
1415
HookInitiator, // Remove if no hooks
1516
ResourceDescription,
1617
ApprovalQueue,
18+
ObservationDescription,
1719
VendorDescription,
1820
GatekeeperConnectCallback,
1921
AccountDescription,
@@ -274,6 +276,42 @@ export class MyUserImpl extends WorkerEntrypoint<Env, MyUserImplProps>
274276
async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> {
275277
return {};
276278
}
279+
280+
// Mint a verifier representing this account (see Observers in SKILL.md). The overseer only ever
281+
// hands it back to a gatekeeper of THIS vendor, so MyGatekeeperImpl.addObserver may trust it.
282+
// For a strategy-D (low-stakes) gatekeeper, MyVerifier has no methods and this still works.
283+
async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {
284+
let props: MyVerifierProps = { userObjectId: this.ctx.props.userObjectId };
285+
return this.ctx.exports.MyVerifier({ props });
286+
}
287+
}
288+
289+
// ---------------------------------------------------------------------------
290+
// Verifier — answers "can this observer access X?" against the OBSERVER's own credentials.
291+
// Remove the non-standard method (and make it a strategy-D no-op verifier) if any collaborator may
292+
// observe; keep/extend it for strategy B (single-unit ACL) or C (data-set tracking). See SKILL.md.
293+
294+
type MyVerifierProps = {
295+
userObjectId: string;
296+
};
297+
298+
// A vendor-specific interface adding non-standard methods to the opaque GatekeeperUserVerifier.
299+
// addObserver casts the Fetcher back to this; the overseer's same-vendor guarantee makes that safe.
300+
export interface MyVerifierApi extends GatekeeperUserVerifier {
301+
hasResourceAccess(resourceId: string): Promise<boolean>;
302+
}
303+
304+
export class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>
305+
implements MyVerifierApi {
306+
async hasResourceAccess(resourceId: string): Promise<boolean> {
307+
let account = this.ctx.exports.UserAccount.get(
308+
this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId));
309+
void account; void resourceId;
310+
// TODO: query the service with the observer's own token. Return true on success; return false
311+
// for access errors (e.g. 401/403/404); rethrow anything else so the open fails loudly rather
312+
// than silently denying.
313+
return false;
314+
}
277315
}
278316

279317
// ---------------------------------------------------------------------------
@@ -362,6 +400,25 @@ export class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>
362400
// TODO: Undo the action (look up what was done from own storage)
363401
throw new Error("Revert not implemented");
364402
}
403+
404+
// Observers (see SKILL.md "Observer verification"). This skeleton shows strategy B — ACL check on
405+
// a single atomic resource. The overseer calls addObserver on EVERY open by every observer, so it
406+
// re-verifies live access; throw to deny.
407+
// - Strategy A (private-only): `async addObserver() { throw new Error("...not shareable..."); }`
408+
// - Strategy D (low-stakes): make both methods no-ops.
409+
// - Strategy C (data-set tracking): record observed sets + store verifiers, and route every read
410+
// through an authorizeSetObservation helper that sets `excludeObservers` (see SKILL.md).
411+
async addObserver(_id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> {
412+
let verifier = user as unknown as Fetcher<MyVerifierApi>;
413+
if (!(await verifier.hasResourceAccess(/* this.ctx.props.resourceId */ "TODO"))) {
414+
throw new Error(
415+
"This collaborator does not have access to the bound resource, so they cannot observe " +
416+
"data the Gadget read from it.");
417+
}
418+
}
419+
420+
// Idempotent: ignore unknown ids. A no-op for strategy A/B (nothing is tracked).
421+
async removeObserver(_id: string): Promise<void> {}
365422
}
366423

367424
// ---------------------------------------------------------------------------
@@ -497,7 +554,7 @@ class MySessionImpl extends RpcTarget implements MySession {
497554
}
498555
```
499556

500-
Only Durable Object classes go in `new_sqlite_classes`. `MyHookControllerImpl` is a `WorkerEntrypoint`, so it needs no migration entry — but, like all entrypoints, it must be `export`ed from the worker's main module. If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.
557+
Only Durable Object classes go in `new_sqlite_classes`. `MyVerifier` and `MyHookControllerImpl` are `WorkerEntrypoint`s, so they need no migration entry — but, like all entrypoints, they must be `export`ed from the worker's main module (so `ctx.exports.MyVerifier(...)` resolves). If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.
501558

502559
## Creating the `types.txt` symlink
503560

.agents/skills/write-gatekeeper/SKILL.md

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget an
1313

1414
Read `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.
1515

16-
## Six responsibilities
16+
## Seven responsibilities
1717

1818
1. **Auth management** — Manage authorization to the external service via OAuth (or similar), on behalf of the human end user. This means managing "connected accounts" — token storage, refresh, and revocation in a `UserAccount` Durable Object.
1919

@@ -27,9 +27,11 @@ Read `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces a
2727

2828
6. **Simulation** — Actions submitted but not yet applied should be simulated as if they already occurred, to the maximum extent reasonable. If the caller reads back data, it should observe the data as if pending actions had been applied, even though they haven't yet. This allows the agent to continue working without waiting for each approval, and allows the end user to batch-approve a lot of work at once. Simulation may leverage caching (updating the cache on submit, clearing or repopulating it on reject), or it may work by storing pending actions separately and adjusting read results at query time — the latter is arguably cleaner but trickier to implement correctly. See Phase 2 for implementation guidance.
2929

30+
7. **Observer verification** — When a Gadget is shared, collaborators may "observe" data the Gadget previously read through the gatekeeper. The gatekeeper must ensure each collaborator could access that data themselves, via `getVerifier()` / `addObserver()` / `removeObserver()`. The interface methods are mandatory — a gatekeeper won't type-check without them — so include at least minimal versions in Phase 1; but *choosing and implementing the right strategy* is a Phase 2 security concern, like logging/approvals. See [Observer verification](#observer-verification).
31+
3032
## Phase 1: Core implementation
3133

32-
In the first phase, focus only on responsibilities 1 - 3, though keeping in mind that 4 - 6 will need to be implemented later.
34+
In the first phase, focus only on responsibilities 1 - 3, though keeping in mind that 4 - 7 will need to be implemented later. Note the observer methods of responsibility 7 (`getVerifier`/`addObserver`/`removeObserver`) are required for the code to type-check, so the skeleton includes minimal versions; you flesh out the actual strategy in Phase 2.
3335

3436
### Step 1: Understand the external service
3537

@@ -139,9 +141,9 @@ When something already knows the exact resource — most importantly an AI agent
139141

140142
The operator may prefer to implement phase 2 later, perhaps in a new context. Stop here and ask the operator whether to proceed.
141143

142-
## Phase 2: Logging, approvals, caching, and simulation
144+
## Phase 2: Logging, approvals, caching, simulation, and observers
143145

144-
In this phase, we focus on responsibilities 4-6. These are typically added as a second pass, after the core gatekeeper works. They may be implemented in a separate session.
146+
In this phase, we focus on responsibilities 4-7. These are typically added as a second pass, after the core gatekeeper works. They may be implemented in a separate session.
145147

146148
### Logging and approvals
147149

@@ -183,6 +185,108 @@ Keep in mind that the agent calling the API (or the agent writing a gadget to ca
183185

184186
For concrete examples, see the Google gatekeeper's Google Docs simulation/cache handling and BigQuery dry-run scope enforcement.
185187

188+
### Observer verification
189+
190+
This is responsibility 7. When a Gadget is shared, each non-owner collaborator becomes an **observer** of every gatekeeper bound to the Gadget, and may see data the Gadget previously read. The gatekeeper's job is to refuse — or forward-restrict — observers who couldn't access that data themselves.
191+
192+
Three methods implement this (full JSDoc in `gatekeeper.ts`):
193+
194+
- `GatekeeperUser.getVerifier()` — mints a `GatekeeperUserVerifier` (a persistent service stub) representing *this* user's account. The overseer mints one per open and **only ever passes it back to a gatekeeper of the same vendor**, so the gatekeeper may trust whatever it learns from it.
195+
- `Gatekeeper.addObserver(id, verifier)` — must **throw** if the user represented by `verifier` is not allowed to observe everything read through this gatekeeper so far. The overseer calls it on **every open by every authorized observer** (re-verification, so revoked access is caught at the next open); cache as needed if the check is expensive. `id` is an opaque, stable per-(user,gadget) string.
196+
- `Gatekeeper.removeObserver(id)` — idempotent; drop a tracked observer.
197+
198+
#### The verifier "non-standard method" pattern
199+
200+
`GatekeeperUserVerifier` has no methods of its own — it's an opaque token. To actually answer "can this observer access X?", define a vendor-specific interface that **extends `GatekeeperUserVerifier`** with your own methods, implement it on a `WorkerEntrypoint` that queries the service using the **observer's own token**, and cast the `Fetcher` back to that interface inside `addObserver`. The overseer's same-vendor guarantee is what makes the cast safe.
201+
202+
```typescript
203+
// In types/impl: a verifier interface with non-standard methods.
204+
export interface MyVerifierApi extends GatekeeperUserVerifier {
205+
hasResourceAccess(resourceId: string): Promise<boolean>;
206+
}
207+
208+
type MyVerifierProps = { userObjectId: string };
209+
210+
export class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>
211+
implements MyVerifierApi {
212+
async hasResourceAccess(resourceId: string): Promise<boolean> {
213+
// Query the service with the OBSERVER's own token (this.ctx.props.userObjectId).
214+
try {
215+
await myApiForObserver(this.ctx).getResource(resourceId);
216+
return true;
217+
} catch (error) {
218+
// Distinguish "no access" from "transient failure":
219+
// - auth/permission/not-found (401/403/404) → false (they can't see it)
220+
// - anything else → rethrow, so the open fails loudly rather than silently denying
221+
if (isNoAccessStatus(statusOf(error))) return false;
222+
throw error;
223+
}
224+
}
225+
}
226+
227+
// In GatekeeperUser:
228+
async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {
229+
return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } });
230+
}
231+
```
232+
233+
`MyVerifier` is a `WorkerEntrypoint`, so it needs **no migration entry**, but (like all entrypoints) it must be `export`ed from the worker's main module so `ctx.exports.MyVerifier(...)` resolves.
234+
235+
#### Choosing a strategy (per resource type / binding)
236+
237+
Strategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C).
238+
239+
- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox).
240+
- **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings.
241+
- **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings.
242+
- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier (no non-standard methods). Use when any collaborator may observe (personal, low-stakes services).
243+
244+
The **B-vs-C decision** (the "broad binding" lens): use C only when **both** (1) the binding spans sub-resources with distinct ACLs *and* (2) there's a per-observer oracle to check each against. If one ACL covers everything → B. If there's no oracle → A or D.
245+
246+
#### Implementing strategy C
247+
248+
Route **every data-revealing observation** through a helper that takes the set id(s) the observation reveals, instead of calling `authorizeObservation()` directly:
249+
250+
```typescript
251+
// On the Gatekeeper DO. `setIds` are the data sets this observation reveals.
252+
async authorizeSetObservation(
253+
queue: RpcStub<ApprovalQueue>, setIds: string[], description: ObservationDescription) {
254+
const check = setIds.length > 0
255+
? await this.#prepareSetObservation(setIds)
256+
: { pendingSets: [], excludeObservers: undefined };
257+
await queue.authorizeObservation({ ...description, excludeObservers: check.excludeObservers });
258+
for (const setId of check.pendingSets) this.#markSetObserved(setId);
259+
}
260+
261+
async #prepareSetObservation(setIds: string[]) {
262+
const pendingSets = [...new Set(setIds)].filter(id => !this.#isSetObserved(id));
263+
if (pendingSets.length === 0) return { pendingSets, excludeObservers: undefined };
264+
// This synchronous state change is visible to addObserver() before verifier RPCs can interleave.
265+
for (const setId of pendingSets) this.#markSetPendingIfUnknown(setId);
266+
const excluded = new Set<string>();
267+
for (const [id, verifier] of this.#listObservers()) {
268+
for (const setId of pendingSets) {
269+
if (!(await verifier.hasSetAccess(setId))) { excluded.add(id); break; }
270+
}
271+
}
272+
return {
273+
pendingSets,
274+
excludeObservers: excluded.size > 0 ? [...excluded] : undefined,
275+
};
276+
}
277+
```
278+
279+
Key points for C:
280+
281+
- **Use two durable states: pending and observed.** Mark unknown sets pending before the first await, recheck pending sets on every retry, and promote them only after `authorizeObservation()` succeeds. A failed authorization leaves them pending. `addObserver()` must check both states and loop until no unchecked sets remain before synchronously storing the verifier; this also closes admission races in either request ordering.
282+
- **The session impls must route through this helper**, not `approvalQueue.authorizeObservation()`. If sessions hold the raw queue (not the DO), thread a small prepare hook/callback into each session and any sub-sessions it spawns, and expose a completion step that promotes its pending sets after authorization. For a single broad binding the hook is active; for the narrow (B) sibling binding it is absent (passthrough). See Linear/Notion for the shared-session-impl case and Supabase for the context-object case.
283+
- **One observation may reveal several sets** (e.g. a workspace-wide list whose rows belong to different sub-resources). Pass all of them; union the exclusions over the newly-seen ones. Reads that reveal *no* set (workspace name, member directory, a bare "open") pass an empty list and rely on the membership baseline.
284+
- **`addObserver` baseline:** verify the coarse membership (e.g. same org/workspace) that gates the set-independent reads, then verify each already-observed set, then store the verifier. Fail closed if a needed identity is unknown (e.g. an account connected before you began persisting the workspace id → force a reconnect).
285+
286+
#### `excludeObservers` semantics (why conservative is safe)
287+
288+
When `authorizeObservation()` is given `excludeObservers`, the overseer **blocks the observation** if any named observer is still authorized, and only lets it proceed (tearing down the observer) if they've already lost access. So erring toward listing an observer is never a leak — at worst it blocks an observation that could in principle have been allowed. The leak-relevant gate is always the live sharing graph, so stale observer state self-heals on the next open.
289+
186290
## Hooks (push notifications)
187291

188292
Some services can push events to the Gadget (inbound email, webhooks, chat messages, etc.). A gatekeeper exposes this as a **hook**: the Gadget registers a callback, and the gatekeeper later invokes it when an event arrives. Hooks are persistent — they survive across sessions and server restarts — and are subject to the same observation/action approval model as everything else.
@@ -220,9 +324,13 @@ When defining a session interface with hooks, it's important to include comments
220324
- All DO classes must appear in `wrangler.jsonc` under `migrations[].new_sqlite_classes`.
221325
- Set a self-destruct alarm in `UserAccount.setCallback()` in case the OAuth flow is never completed.
222326
- `authorizeObservation()` may be called *after* fetching data (so the description can include details about what was fetched) but must be awaited *before* returning anything to the caller.
327+
- `getVerifier()` / `addObserver()` / `removeObserver()` are **mandatory** — the gatekeeper won't type-check without them. Even a read-only or push-only gatekeeper needs them (sharing is independent of whether the gatekeeper has actions). Pick a strategy per [Observers](#observer-verification): a low-stakes one can be A or D; otherwise B/C.
223328

224329
## Reference implementations
225330

226-
- `packages/gatekeeper-google/` — OAuth, multiple resource types (Gmail, Google Docs, BigQuery), actions, caching/simulation examples, multiple Session types.
227-
- `packages/gatekeeper-email/` — Hook-based push notifications, no actions, email address claiming.
228-
- `packages/workshop-shared/src/gatekeeper.ts` — Canonical interfaces with detailed JSDoc.
331+
- `packages/gatekeeper-google/` — OAuth, multiple resource types (Gmail, Google Docs, BigQuery), actions, caching/simulation examples, multiple Session types. **Observers:** all three strategies in one package — Gmail=A (always throw), Doc=B (single-unit ACL via `GoogleVerifier.hasDocAccess`), BigQuery=C (dataset tracking via `hasDatasetAccess`).
332+
- `packages/gatekeeper-email/` — Hook-based push notifications, no actions, email address claiming. **Observers:** strategy D (low-stakes no-ops + trivial verifier).
333+
- `packages/gatekeeper-github/`**Observers:** clean strategy B example — `GitHubVerifier.hasRepoAccess` plus a one-method `addObserver`.
334+
- `packages/gatekeeper-supabase/`**Observers:** strategy C with a per-session context object (`authorizeProjectObservation`) — good when sessions already hold a shared context.
335+
- `packages/gatekeeper-linear/` & `packages/gatekeeper-notion/`**Observers:** strategy C where the page/team session impls are shared between the narrow (B) and broad (C) bindings, threading an `observe` hook through sub-sessions; both also handle one observation revealing multiple sets.
336+
- `packages/workshop-shared/src/gatekeeper.ts` — Canonical interfaces with detailed JSDoc (`getVerifier`, `addObserver`, `removeObserver`, `GatekeeperUserVerifier`, `ObservationDescription.excludeObservers`).

0 commit comments

Comments
 (0)