Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3756778
Let admins list the deployment and inspect any workspace, audited
Maximo-Guk Nov 5, 2026
3ac3901
Show admin access to workspaces in the admin panel's Audit tab
Maximo-Guk Nov 5, 2026
25e2db2
Stream large files in admin reads, and prune nothing on a refused open
Maximo-Guk Nov 6, 2026
4efa536
Give the access select its focus back after a delayed save
Maximo-Guk Nov 6, 2026
9ed4581
Say where admin access is recorded, and give expiring test views room
Maximo-Guk Nov 6, 2026
f46a0cc
Clear unapplied admin and owner text with every filter
Maximo-Guk Nov 6, 2026
1e6ac2e
Drop half-typed admin and owner text once its filter changes
Maximo-Guk Nov 6, 2026
3986cc6
Keep an access record's owner cut, as its title is
Maximo-Guk Nov 6, 2026
9c38578
Keep long workspace owners apart in the audit log
Maximo-Guk Nov 6, 2026
6426acb
Show and filter each admin call's target user in the Audit tab
Maximo-Guk Nov 6, 2026
584d939
Drop a comment that restated the Audit log's target suggestions
Maximo-Guk Nov 6, 2026
dc723be
Fit the Audit log to its card, details and via under each row
Maximo-Guk Nov 6, 2026
7787e47
Drop the most inspected workspaces from the audit statistics
Maximo-Guk Nov 6, 2026
de70462
Remove Most inspected workspaces from the Audit tab
Maximo-Guk Nov 6, 2026
07ef303
Name the top-admins statistics test for what it still checks
Maximo-Guk Nov 6, 2026
6dfc1ba
Bring #1040's resizable columns, stripes and restarts into the Audit log
Maximo-Guk Nov 6, 2026
baa55fe
Give the Audit log's Sensitivity column room for both of a refused ca…
Maximo-Guk Nov 6, 2026
77a6d96
Diff trees, walk shallow history and fold a chat's code from stored o…
Maximo-Guk Nov 8, 2026
38458a1
Record who created each connection
Maximo-Guk Nov 8, 2026
a362fec
Move the blueprint, share-link and presence listings to OverseerImpl
Maximo-Guk Nov 8, 2026
a53f117
Show admins a chat's turn state and attachments
Maximo-Guk Nov 8, 2026
28a6ab6
Show admins a workspace's history, any stored commit and a chat's pen…
Maximo-Guk Nov 8, 2026
12771b3
Show admins a workspace's observers, sharing, auto-approvals and who …
Maximo-Guk Nov 8, 2026
7d3af00
Cover the admin view's workspace reads end to end
Maximo-Guk Nov 8, 2026
a8da002
Document the admin view's workspace reads, in the docs and in the Aud…
Maximo-Guk Nov 8, 2026
0b97d63
Read a chat's pending changes again when an accept lands as they are …
Maximo-Guk Nov 9, 2026
b3c2ef3
Name actions and hooks among the data an admin sees only at full
Maximo-Guk Nov 9, 2026
5c281ab
Say when a pending-changes listing leaves out a pinned worktree
Maximo-Guk Nov 9, 2026
27434f0
Complete the observers doc's list of what an admin sees only at full
Maximo-Guk Nov 9, 2026
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
22 changes: 18 additions & 4 deletions REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,26 @@ through logs and errors, then everything else.
compromised admin session cannot change it. Reject changes that relocate it.
- `AdminSettings` is the only writer of the authoritative `AdminConfig`. Other code reads through
`readAdminConfig(env)`, except `AdminApiImpl`, which deliberately reads the authoritative config
for the audit log's retention in `listAdminAudit()` and `getAdminAuditStats()`. The
admin-settings conventions are in `packages/workshop-backend/AGENTS.md`.
- `ADMIN_AUDIT_RETENTION_MIN_DAYS` is env-driven, like auth config. Flag code that enforces or
prunes with `adminAuditRetentionDays` without `adminAccessPolicy()`.
in two places: `inspectWorkspace()`, so that a KV mirror still holding a higher access level never
mints a view at it, and the audit log's retention in `listAdminAudit()` and
`getAdminAuditStats()`. The admin-settings conventions are in `packages/workshop-backend/AGENTS.md`.
- `OverseerDurableObject.openForAdmin()` stays reachable only over the Durable Object binding, never
through a capability a user holds, and refuses an uninitialised workspace without writing. Every
`AdminWorkspaceView` method runs through `#call()`, which checks the view again once it has read
the deployment's setting and once it has read its answer, with nothing awaited before its record
is sent; never reaches a connection (admin file reads never pull); and records the call, with what
its answer exposed, in the deployment-wide log before anything it read leaves the server, so that
a call whose record cannot be written returns nothing. The record follows the read because
`exposed` must describe the answer actually returned. Flag a path that returns workspace data to
an admin without these, or that sets `exposed` from anything but that answer.
- `ADMIN_WORKSPACE_ACCESS_MAX` and `ADMIN_AUDIT_RETENTION_MIN_DAYS` are env-driven, like auth
config, and the cap fails closed. Flag code that enforces `adminWorkspaceAccess`, or enforces or
prunes with `adminAuditRetentionDays`, without `adminAccessPolicy()`.
- A new `AdminSettings` mutator takes an `AdminSettingActor` and records through
`#mutateAdminConfig()`, or records explicitly before any other write.
- Text a user or workspace member controls (display names, titles) enters the directory or the
audit log only bounded: clamped to `MAX_DIRECTORY_TEXT_LENGTH`, or cut by `cutAdminAuditText()`.
Flag a new copy of such text into either store without a bound.
- `cf-os api` sends `User-Agent: cf-os-api/<version>`, and except on a local instance
(`WORKSHOP_ENVIRONMENT` `development` or `test`, set only by `pnpm dev-server` and the integration
harness) the Workshop lets such a session act only as a deployment admin. That is a speed bump
Expand Down
591 changes: 531 additions & 60 deletions docs/admin-access.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ Importing a version 2 archive stores the same release commit under a new bluepri

## Admin Features and Featured Blueprints

Deployments can optionally configure a set of admins through the backend worker's `ADMINS` binding, an array of usernames or sign-in emails. Entries match regardless of the case of ASCII letters, since an identity provider may capitalize an email differently than it was typed. `ADMIN_AUDIT_RETENTION_MIN_DAYS` bounds how briefly those admins may choose to keep the admin audit log; see [admin-access.md](admin-access.md).
Deployments can optionally configure a set of admins through the backend worker's `ADMINS` binding, an array of usernames or sign-in emails. Entries match regardless of the case of ASCII letters, since an identity provider may capitalize an email differently than it was typed. `ADMIN_WORKSPACE_ACCESS_MAX` and `ADMIN_AUDIT_RETENTION_MIN_DAYS` bound what those admins may choose for workspace inspection and audit retention; see [admin-access.md](admin-access.md).

Admins get access to two extra RPCs:

Expand Down
24 changes: 17 additions & 7 deletions docs/integration-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,23 @@ RPC session the child needs. See `observer-exclusion.test.ts`'s `reopenAfterRest

`await harness.server.getWorker(name).getDurableObjectStorage(className, { id } | { name })` returns
a handle on one Durable Object's SQLite storage, whose `exec()` resolves to the result rows.
`{ name: "" }` addresses the object a Worker reaches with `getByName("")`. The admin suites use it to
fault a store: to show that a call fails when its audit record cannot be written, the suite installs
a trigger on `admin_audit`, the deployment-wide audit log's table
(`CREATE TRIGGER ... BEFORE INSERT ON admin_audit BEGIN SELECT RAISE(ABORT, ...); END`), and drops it
afterwards. The insert then fails exactly as a real storage failure would, inside the real Worker,
and production code carries no test hook, which would be a switch that disables the audit shipped
in every deployment.
`{ name: "" }` addresses the object a Worker reaches with `getByName("")`. Two uses shape the admin
suites:

- **Faulting a store.** To show that a call fails when its audit record cannot be written, the suite
installs a trigger on `admin_audit`, the deployment-wide audit log's table
(`CREATE TRIGGER ... BEFORE INSERT ON admin_audit BEGIN SELECT RAISE(ABORT, ...); END`), and drops
it afterwards. The insert then fails exactly as a real storage failure would, inside the real
Worker, and production code carries no test hook, which would be a switch that disables the audit
shipped in every deployment.
- **Proving an object was never written.** `listDurableObjectIds()` cannot tell: it lists every
object with a storage file, and any visit creates one (miniflare records the object's name in it)
that a deletion leaves behind. What can tell is `sqlite_master` with miniflare's own `__miniflare*`
tables left out. The Overseer keeps its records in typed storage over the KV API, whose `_cf_KV`
table the first write creates and `deleteAll()` drops, so whether that table exists says whether
anything has been written. `_cf_KV` itself is unreadable (`SQLITE_AUTH`), hence `sqlite_master`. A
test client cannot mint an unused workspace id (creating a workspace opens it), so the suite uses
a deleted one.

### A redeploy keeps storage, so an upgrade can be tested

Expand Down
42 changes: 42 additions & 0 deletions docs/observers.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,48 @@ The check works as follows:
sharing graph (`computeEffectiveAccess`), and for a gadget, whether their access includes a
gadget that reaches it -- never off whether they currently have it open.

### Admin access: a deliberate exception

A deployment admin (a user the backend's `ADMINS` variable lists) can inspect a workspace through
`AdminApi.inspectWorkspace()` without being verified against its gatekeepers and without being
seen by its members: `OverseerDurableObject.openForAdmin()` skips `authorizeCollaborator()` and
observer verification by explicit policy, and the admin is never registered as an observer. So
`excludeObservers` never names an admin, and no observation is blocked on an admin's account.

The exception is bounded. Restricted data (chat messages and attachments, file contents, commit
messages, the argument summaries of queued agent calls, the descriptions and fields of actions,
observations and hooks, and blueprint ids; see
[What a call exposed](admin-access.md#what-a-call-exposed) for each call) is visible only at the
`"full"` access level, which only a deployment config change permits and which an inspection must
ask for: `ADMIN_WORKSPACE_ACCESS_MAX` caps the level, `metadata` by default, and fails closed to
`off` for any value that is not a level. Titles, names, notes and blueprint descriptions stay
visible at `"metadata"`, and can echo content.

What a connection supplied is read only as the workspace stores it, and the connection is never
asked. That includes a repository: a worktree can be a checkout through a connection, and the view
reads any commit the workspace stores. So an admin never verified against that connection sees, at
`"metadata"`, the repository's file names, commit ids, authors and times, and the paths that
differ between two of its commits; and at `"full"` its files and commit messages, as far as the
workspace pulled them, from the workspace's copy. `"metadata"` also shows each attachment's name,
type, size and `oid` (a hash of its bytes, which `listChatAttachments()` returns), and which
collaborators hold an observer record, with the id of the account each chose for each connection;
the account itself is in the collaborator's own user account and is not read.

The compensating controls are:

- the admin list and the access cap are environment variables, out of reach of an admin session;
- every inspection takes a reason, recorded with every call made through the view;
- an inspection gets `"metadata"` unless it asks for `"full"` and the deployment allows it, so
reading content is a separate, recorded step with its own reason;
- a view expires (15 minutes by default, an hour at most);
- every call re-checks that the view is live and its admin still an admin;
- every call is recorded, with whether its answer held content, before anything it read leaves the
server, in the deployment-wide audit log (nothing is written to the workspace); a call whose
record cannot be written fails, returning nothing;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
Comment on lines +145 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ WARNING · Documentation

docs/observers.md says inspection records are also written in the workspace. They go only to the deployment-wide audit log (contradicted by docs/admin-access.md:326).

This bullet lists the compensating controls for admin inspection. It says each call is recorded "in the workspace (apart from its action log)". The view writes nothing to the inspected workspace: records go only to the deployment-wide audit log, as docs/admin-access.md:326 states. A reader judging the security model could wrongly expect workspace members or workspace storage to keep a record of the inspection.

Suggested change
- every call is recorded, with whether its answer held content, before anything it read leaves the
server, in the workspace (apart from its action log) and in a deployment-wide log; a call whose
record cannot be written fails, returning nothing;
- every call is recorded, with whether its answer held content, before anything it read leaves the
server, in the deployment-wide audit log (nothing is written to the workspace); a call whose
record cannot be written fails, returning nothing;

- `ADMIN_AUDIT_RETENTION_MIN_DAYS` sets a floor on how long those records are kept.

[admin-access.md](admin-access.md) is the design record.

---

## 2. Background: relevant existing code
Expand Down
1 change: 1 addition & 0 deletions docs/public-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ connect the account's capabilities. There's no single switch — the pieces turn
| Each gatekeeper's OAuth credentials (on the gatekeeper Worker) | Required for that gatekeeper to actually authenticate. In dev, seeded from `GOOGLE_*` / `GITHUB_*` / `CLOUDFLARE_OAUTH_*` shell vars (see `run-dev-server.ts`). |
| `ENABLE_CLOUDFLARE_LIMITS=true` | Enables the free daily limit + Cloudflare-credits top-up flow. Billing reads a token from the connected Cloudflare gatekeeper. |
| `DISABLE_PASSWORD_AUTH=true` | Hides username/password, leaving gatekeeper sign-in only (ignored unless `AUTH_GATEKEEPERS` is non-empty, to avoid lockout). |
| `ADMIN_WORKSPACE_ACCESS_MAX=off\|metadata\|full` | The most of a workspace an admin may inspect (`AdminApi.inspectWorkspace()`); admins choose up to it in the admin panel. Default `metadata`; any other value counts as `off`. Set on the backend Worker (its vars, or `.dev.vars` locally); a deploy tool that rewrites the backend's variables must carry it, or it falls back to its default. See [admin-access.md](admin-access.md), which says where each kind of deployment sets it. |
| `ADMIN_AUDIT_RETENTION_MIN_DAYS=90` | The fewest days the admin audit log keeps a record; admins choose at least this in the admin panel. Default 90; at most 3650, and a larger value counts as 3650. Set on the backend Worker (its vars, or `.dev.vars` locally); a deploy tool that rewrites the backend's variables must carry it, or it falls back to its default. See [admin-access.md](admin-access.md), which says where each kind of deployment sets it. |

The primary account key is always the user's **verified email**: signing in with any allowlisted
Expand Down
2 changes: 2 additions & 0 deletions docs/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ Because access is recomputed from the graph on every `open()` and `getGadget()`,

A share-key redemption goes through the same gate. The redeeming open() then verifies a `build` recipient as an observer like any other collaborator, and a gadget recipient when they get the gadget; a recipient whose verification fails persists as an unverified collaborator until removed (see Known limitations).

Deployment admins can inspect a workspace outside the sharing graph: `AdminApi.inspectWorkspace()` mints a read-only `AdminWorkspaceView` through `openForAdmin()`, which never consults the permission graph, adds no collaborator and joins no session. It is a deliberate exception, bounded by an environment-set access cap and an audit recorded before anything an admin reads leaves the server; see [admin-access.md](admin-access.md).

### `ownerInvitesOnly`

A gatekeeper whose data source requires each recipient to be granted access individually can mark an observation `ownerInvitesOnly` (`ObservationDescription` in `packages/workshop-shared/src/gatekeeper.ts`, typically alongside `containsRestrictedData`). Once any such observation is authorized, the Overseer permanently sets the `ownerInvitesOnly` storage flag (in `authorizeObservation`, after the exclusion gate, so a refused observation sets nothing). From then on, **only direct grants from the owner count**: `computeEffectiveAccess` skips every share-link edge (even for a link the owner created) and every `user` edge from anyone but the owner. Because every access decision derives from that computation, `open()`, `getGadget()`, `listCollaborators`, and the removal previews agree without further checks. The flag covers the whole workspace: an owner grant of gadget A still grants only A.
Expand Down
9 changes: 5 additions & 4 deletions packages/api-cli/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ test("search reads a command's option names and the callback methods it may be a
assert.equal(rankOf(asking, "notice", / lock$/), 0);
// As a doc's words: 2, under the 3 of a command named by the word.
assert.equal(searchCommands(realTable, "configure observers")[0].command, "cf-os api open-workspace <id>");
assert.equal(rankOf(realTable, "observer", /^cf-os api open-workspace <id>$/), 2);
assert.equal(rankOf(realTable, "observer", /^cf-os api open-workspace <id>$/), 4);
});

test("search finds the commands for agents' everyday tasks in the API's own words", () => {
Expand Down Expand Up @@ -499,7 +499,7 @@ test("search finds the commands for agents' everyday tasks in the API's own word
...["redeem share link", "redeem a share link", "open workspace with share key", "join workspace from link",
"join a shared workspace", "open a workspace shared with me", "configure observers"]
.map((task): [string, RegExp, number] => [task, openWorkspace, 1]),
["observer verification", openWorkspace, 3],
["observer verification", openWorkspace, 4],
["choose accounts to observe a shared workspace", openWorkspace, 3],
["share key", openWorkspace, 3],
];
Expand Down Expand Up @@ -772,8 +772,9 @@ test("an unknown command or option says which was meant: a near name, or where t
real(["list-chats"], /list-chats is on Overseer: `cf-os api open-workspace <id> list-chats`/);
real(["open-workspace", "g", "list-actions", "--option", "{}"], /Did you mean --options\?/);
real(["list-workspace"], /Did you mean list-workspaces\?/);
// A name cut short wins over a typo that changes its verb: get-chat is two edits from new-chat.
real(["get-chat"], /Did you mean get-chat-[a-z-]+, on Overseer: `cf-os api open-workspace <id> get-chat-/);
// A name cut short wins over a typo that changes its verb: get-gatekeeper is two edits from
// new-gatekeeper.
real(["get-gatekeeper"], /Did you mean get-gatekeeper-app\? /);
// A short name a typo starts with is no run-on: list-chtas is list-chats, not a bare `list`.
real(["open-workspace", "g", "list-chtas"], /^Overseer has no command "list-chtas"\. Did you mean list-chats\? /);
real(["list-worksapces"], /Did you mean list-workspaces\? /);
Expand Down
Loading
Loading