Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/write-gatekeeper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {

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

- **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).
- **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). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: the workspace then refuses sensitive observations while any unverified collaborator has access (with strategy A that is every collaborator) and latches into a restricted mode that blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers.
- **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.
- **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.
- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services).
Expand Down
14 changes: 7 additions & 7 deletions docs/observers.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ Gadgets enforce a core security invariant (see `overview.md` §"Security Model")
> able to read that information will also be prohibited from interacting with the Gadget,
> to prevent data leaks.

Today the only mechanism enforcing this is the blunt **`prohibitAllSharing`** flag
(`packages/workshop-shared/src/gatekeeper.ts`, `ObservationDescription.prohibitAllSharing`).
Today the only mechanism enforcing this is the blunt **`containsRestrictedData`** flag
(`packages/workshop-shared/src/gatekeeper.ts`, `ObservationDescription.containsRestrictedData`).
When a gatekeeper marks an observation as maximally sensitive, the Gadget can no longer be
shared with *anyone*, and it drops into "lockdown" (no further actions, no web fetches). This
is a deliberate stopgap — it cannot express "this data may be shared, but only with people who
Expand Down Expand Up @@ -99,7 +99,7 @@ This feature replaces that all-or-nothing posture with a per-user, gatekeeper-me
| Session restart when verification scope widens | `overseer.ts` (`#restartIfSessionsAffected`, `joinSession`, `scheduleAccessRestart`) |
| Server `openGadget` path | `packages/workshop-backend/src/server.ts:206` |
| Role resolution / permission graph | `packages/workshop-backend/src/sharing.ts` (`getEffectiveRole`, `computeEffectiveRoles`, `hasAnyShares`) |
| `prohibitAllSharing` enforcement | `overseer.ts:1171` (`authorizeObservation`), `:1207` (web fetch), `:1258` (`submitAction`) |
| `containsRestrictedData` enforcement | `overseer.ts:1171` (`authorizeObservation`), `:1207` (web fetch), `:1258` (`submitAction`) |
| Observation recording | `overseer.ts:1169` `authorizeObservation()`; `ApprovalQueueImpl` `overseer.ts:4856` |
| Gatekeeper storage record | `overseer.ts:110` `GatekeeperRecord` (has `creationSpec.vendorId`) |
| `GatekeeperCreationSpec` | `packages/workshop-shared/src/api.ts:1345` |
Expand Down Expand Up @@ -233,7 +233,7 @@ type ObserverAccountChoice = {
### Step 3 — Overseer: observer configuration & re-verification at `open()`

Hook into `open()` in the non-owner branch, after `effectiveRole` is confirmed and before
constructing the client interface. Keep the existing `prohibitAllSharing` short-circuit ahead of
constructing the client interface. Keep the existing `containsRestrictedData` short-circuit ahead of
this -- lockdown still wins. The `NeedsConnections` signal is produced only *after* a valid role is
confirmed, so it never reveals a workspace's gatekeeper or resource metadata to an unauthorized
user.
Expand Down Expand Up @@ -648,7 +648,7 @@ already in the JSDoc in `gatekeeper.ts`; add anything missing there rather than
operational failure (vendor outage, expired credential) is treated the same way — the overseer
cannot tell it from a settled denial — and the collaborator gets back in as soon as a repaired
open re-verifies them.
4. **`prohibitAllSharing` interaction** — unchanged and still authoritative: if set, no non-owner
4. **`containsRestrictedData` interaction** — unchanged and still authoritative: if set, no non-owner
can open at all (`overseer.ts:2770`). Observer checks only matter when sharing is allowed.
5. **Owner adds a new binding after sharing** — existing observers see an incremental modal for
just the new binding on their next open, and may be denied if they lack access to the new
Expand Down Expand Up @@ -733,8 +733,8 @@ gatekeeper package — a single package (e.g. `gatekeeper-google`) may use sever
its resource types.

- **A — Private-only.** Non-owner observers are refused: `addObserver()` unconditionally throws.
This is the replacement for today's reliance on `prohibitAllSharing` for these resources (the
`prohibitAllSharing` lockdown mechanism itself is unchanged and remains available separately).
This is the replacement for today's reliance on `containsRestrictedData` for these resources (the
`containsRestrictedData` lockdown mechanism itself is unchanged and remains available separately).
`getVerifier()` must still exist (the overseer mints one on every open) but is never consulted.

- **B — ACL check (single unit).** The resource is treated as one atomic unit.
Expand Down
2 changes: 1 addition & 1 deletion docs/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Authorization is only checked at `open()`, so a session that is *already* open i

Two precautions surround the abort (`OverseerImpl.scheduleAccessRestart`): the severed edge is flushed with `ctx.storage.sync()` first (because `ctx.abort()` does not respect the output gate, a restart could otherwise come back with the change lost), and the abort is delayed ~100ms so the triggering RPC's response reaches the caller -- typically the owner, who is also connected -- before their own connection drops. The disconnect reaches the browser through the existing `notifyClosed` plumbing: when the Overseer DO aborts, the per-session `notifyClosed` stub is disposed without being called, which `AuthenticatedApiImpl` treats as a lost connection and reacts to by killing the browser WebSocket, forcing a reconnect.

Granting or raising access never strands anyone: a live session's capability is fixed at open, so a `use` collaborator promoted to `build` in the graph still holds `UseOverseerInterface` until they re-open, and nobody is newly excluded from anything. `prohibitAllSharing` cannot strand a session either: an observation that would set that flag is *blocked* (rather than applied) if the gadget is already shared, so the flag only ever flips to true on a gadget with no other sessions to evict.
Granting or raising access never strands anyone: a live session's capability is fixed at open, so a `use` collaborator promoted to `build` in the graph still holds `UseOverseerInterface` until they re-open, and nobody is newly excluded from anything. `containsRestrictedData` cannot strand a session either: an observation that would set that flag is *blocked* (rather than applied) if the gadget is already shared, so the flag only ever flips to true on a gadget with no other sessions to evict.

The same abort serves a second purpose, though, and there the trigger is a *grant*: observer verification (see docs/observers.md) also runs only at `open()`, so widening the set of gatekeepers a collaborator must be verified against leaves their live session holding access they were never verified for. `OverseerImpl.#restartIfSessionsAffected` restarts the workspace whenever that happens -- a connection is added, one is bound into a gadget, or a merge promotes such a binding -- so every client re-opens and re-runs `ensureObserver` at the new scope. It is a no-op unless a collaborator session of the affected role is live -- severing sessions is all a restart does -- so a solo workspace, or one whose collaborators are all disconnected, is never disturbed. See docs/observers.md, "Restarting when verification scope widens", for the full trigger list and the reasoning about what deliberately does *not* trigger it.

Expand Down
2 changes: 1 addition & 1 deletion packages/gatekeeper-google/__tests__/drive-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe("Drive session scope", () => {
description: expect.stringContaining('name starts with "missing"'),
excludeObservers: ["excluded"],
})]);
expect(authorizations[0]).not.toHaveProperty("prohibitAllSharing");
expect(authorizations[0]).not.toHaveProperty("containsRestrictedData");
expect(authorizations[0].description).not.toContain("0");
expect(events).toEqual(["authorize"]);
});
Expand Down
16 changes: 8 additions & 8 deletions packages/gatekeeper-google/src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3361,7 +3361,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
`Referenced tables: ${estimate.referencedTables.join(", ")}\n` +
`Estimated bytes processed: ${estimate.bytesProcessed.toLocaleString()}\n` +
`Maximum bytes billed: ${maxBytes.toLocaleString()}.`,
prohibitAllSharing: true,
containsRestrictedData: true,
});

let result = await this.#api.query(billingProject, sql, {
Expand Down Expand Up @@ -3393,7 +3393,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
description:
`Estimated bytes processed: ${estimate.bytesProcessed.toLocaleString()}\n` +
`Referenced tables: ${estimate.referencedTables.join(", ") || "(none)"}`,
prohibitAllSharing: true,
containsRestrictedData: true,
});

return estimate;
Expand All @@ -3405,7 +3405,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
await this.#authorizeDatasets([], {
title: "Get BigQuery project",
description: `Returned the scoped project: \`${this.#scopedProjectId}\`.`,
prohibitAllSharing: true,
containsRestrictedData: true,
});
return result;
}
Expand All @@ -3426,7 +3426,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
await this.#authorizeDatasets([{ projectId: p, datasetId: this.#scopedDatasetId }], {
title: `List datasets in ${p}`,
description: `Returned scoped dataset \`${p}.${this.#scopedDatasetId}\` (1 dataset).`,
prohibitAllSharing: true,
containsRestrictedData: true,
});
return [dataset];
}
Expand All @@ -3436,7 +3436,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
await this.#authorizeDatasets(result.map(ds => ({ projectId: p, datasetId: ds.datasetId })), {
title: `List datasets in ${p}`,
description: `Listed ${result.length} dataset(s) in \`${p}\`.`,
prohibitAllSharing: true,
containsRestrictedData: true,
});
return result;
}
Expand All @@ -3462,7 +3462,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
await this.#authorizeDatasets([{ projectId: p, datasetId: d }], {
title: `List tables in ${p}.${d}`,
description: `Returned scoped table \`${p}.${d}.${this.#scopedTableId}\` (1 table).`,
prohibitAllSharing: true,
containsRestrictedData: true,
});
return [table];
}
Expand All @@ -3471,7 +3471,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
await this.#authorizeDatasets([{ projectId: p, datasetId: d }], {
title: `List tables in ${p}.${d}`,
description: `Listed ${result.length} table(s) in \`${p}.${d}\`.`,
prohibitAllSharing: true,
containsRestrictedData: true,
});
return result;
}
Expand Down Expand Up @@ -3508,7 +3508,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession {
title: `Describe ${p}.${d}.${t}`,
description:
`Described table \`${p}.${d}.${t}\` (${result.schema.length} columns).`,
prohibitAllSharing: true,
containsRestrictedData: true,
});
return result;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/gatekeeper-kit/__tests__/observers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ describe("ObservationGate", () => {
expect(authorizeObservation).not.toHaveBeenCalled();
});

it("leaves the caller's prohibitAllSharing alone, being a gadget-wide escalation", async () => {
it("leaves the caller's containsRestrictedData alone, being a gadget-wide escalation", async () => {
const authorizeObservation = vi.fn(async () => {});
const strategy: ObserverStrategy = {
aclChecks: "per-read",
Expand All @@ -1067,11 +1067,11 @@ describe("ObservationGate", () => {
};

await new ObservationGate(fakeAuthorizer(authorizeObservation), strategy)
.authorize({ ...read, prohibitAllSharing: true }, { kind: "collections", ids: ["p1"] });
.authorize({ ...read, containsRestrictedData: true }, { kind: "collections", ids: ["p1"] });

expect(authorizeObservation).toHaveBeenCalledWith({
...read,
prohibitAllSharing: true,
containsRestrictedData: true,
excludeObservers: ["limited"],
});
});
Expand Down
11 changes: 6 additions & 5 deletions packages/gatekeeper-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ rules.
A Gadget bound to an MCP server can only be opened by its owner: `addObserver` refuses
unconditionally. Being able to authenticate to a server is not evidence of being allowed to see what
the *owner* read from it, and the Gadget runs on the owner's credentials throughout. Writes still
work — the alternative, marking every observation `prohibitAllSharing`, would latch a lockdown that
blocks every action for the rest of the session. See
work — the alternative, marking every observation `containsRestrictedData`, would latch a
restricted mode that blocks every action for the rest of the session. See
[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).

To share the work rather than the binding, publish the Gadget as a blueprint and let each person
Expand Down Expand Up @@ -206,9 +206,10 @@ connect their own server.
compatibility flag in `wrangler.jsonc`, which makes workerd reject reserved IP ranges after
resolution on every request and redirect hop. It does not apply under `wrangler dev`, which is
what keeps `MCP_ALLOW_INSECURE` usable locally.
- **Sharing UI reports late.** `GadgetMetadata.sharingProhibited` derives only from
`prohibitAllSharing`, so creating a share key appears to succeed and fails when the recipient
opens it. Fixing this needs a kernel change.
- **Sharing UI reports late.** `GadgetMetadata.containsRestrictedData` derives only from
`ObservationDescription.containsRestrictedData`, so creating a share key appears to succeed and
fails when the recipient opens it (their observer verification is refused). Fixing this needs a
kernel change.

## Layout

Expand Down
Loading
Loading