Repository navigation
feat(auth): the user role is Workspace-only; sharing stops creating logins (abilityai/trinity-enterprise#837) - #3488
Conversation
…ogins (Abilityai/trinity-enterprise#837) Platform role `user` (and any role outside the ladder) now means a member who works with agents in the Workspace. The operator UI starts at `operator`, and the boundary is server-side. - The floor lives in get_current_user: resolve_platform_user_unfloored (every scope fence, unchanged) + enforce_operator_floor. Every route that authenticates through it, OSS and enterprise, answers a Workspace-only principal with 403 {code: workspace_only, message: "This account works in the Workspace. Open /workspace."}, before any agent lookup. - Exceptions: an interactive session on a route marked @workspace_route (the 11 operator-side routes the Workspace calls), and an agent of a Workspace-only owner on its own runtime surface (_AGENT_SELF_ROUTES = the ephemeral set + its own asks + the skill-gate check). The name is bound to the key AND must be a path parameter of the matched route, so a static route that spells it (GET /api/agents/slots) is not the agent's own; this closes the same case for the ghost-agent fence. - The Workspace doors (get_portal_principal, get_room_principal, portal_voice_start) call the unfloored resolver; the caller set is pinned. The rooms door still refuses an agent whose owner is Workspace-only: rooming with the owner's agents is agent-to-agent chat. - WebSockets: /ws/events and the agent terminal refuse the rung (4003). A Workspace-only /ws socket receives only the kinds the Workspace reads (agent_activity, loop_run_completed, loop_completed, agent_skills_changed, resync_required), each cut to type/event/agent_name/activity_state plus the _eid cursor, on fan-out and replay; connect defaults both allowlists to the empty set. - Share and access-request approval no longer write the login whitelist. - SPA: /login and operator URLs land a `user` in /workspace (an agent page opens that agent's conversation); a 403 workspace_only moves a stale tab there once the re-read profile says `user` (the body alone is not proof); /ws no longer drives operator-store refetches for a `user`; the empty-roster and Inbox links into the operator UI give way to the client copy. - Settings: the `user` option reads "user — Workspace only"; the email whitelist takes a role (default user) and shows it; User Management counts the Workspace-only accounts and filters to them. Access tab copy: adding a person grants Workspace access (placeholder name@company.com). - Inside the Workspace a `user` keeps the team view (every canvas audience, the agent's whole activity, voice, model choice); three comments that justified it with "can open Agent Detail" now give the team-member reason. - Upgrade note: docs/migrations/WORKSPACE_ONLY_USER_ROLE_2026-10.md. No DB migration; ROLE_HIERARCHY and VALID_ROLES unchanged. - /cso --diff: docs/security-reports/cso-diff-2026-10-09-ent837-workspace-only-role.{md,json} (5 findings, all fixed here). - Tests whose fixtures used `user` as an arbitrary non-admin now use `operator` (the two rungs were identical before); tests that patched the portal door's resolver patch the renamed one; the two ghost-fence suites' hand-built requests now carry the path parameter a routed request has. Mutations (each red, restored byte-identical): portal/rooms door back on the floored resolver; marker removed from get_my_preferences; skill-gate check dropped from the agent surface; name binding dropped; marked routes admitting keys; /ws/events reading the wrong row; an aliased module-level resolver use; a mark applied by call; the router guard redirect, the /ws queue gate, the Login role and the api.js interceptor each disabled; the /ws kind filter removed, not passed on fan-out, not passed on replay, the lifecycle `event` key ignored, identity never filtering, register dropping it, /ws forgetting it, connect's default opened; the field projection off in identity, on fan-out, on replay, in register, at /ws and in connect's default; the rooms refusal removed; the route-parameter binding removed; the SPA moving the tab on the body alone. Part of Abilityai/trinity-enterprise#837 (PR 1 of 2; the Access-tab role control and the companion-assignment check follow ent#810). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AndriiPasternak31
left a comment
There was a problem hiding this comment.
I reviewed this at 37829f0c6. I'm requesting changes for two bugs and one doc fix. The floor itself held up: a request to every protected route found no bypass.
What held up (each of these was run, not just read)
- The floor. I built the app with the private module mounted and sent one request to each of the 631 protected routes.
- A
userJWT gets through only on the 11 marked routes. - A
user-scope MCP key is refused everywhere. - An agent whose owner is a
userreaches only its own runtime routes. - The role is read from the DB on every request. Roles outside the ladder, suspended users and deleted users are all refused, and the refusal reveals nothing about which agents exist.
- A
- The Workspace for a
user. I traced all 204 files the Workspace loads and checked the result live with auseraccount.- Roster, chat, Inbox/asks, Work, files, projects and voice all work.
- The only call refused in practice is images a canvas references by path, which is #3491.
- A companion's ask reaches the Inbox, and the agent reads the answer.
/wsfiltering. It is a real allowlist on both live delivery and reconnect replay, and a socket with no filter set receives nothing.- Tests.
- 971 unit tests pass across 3 random seeds.
- Reverting any one of 17 guards turns at least one test red.
- The frontend specs, the ratchets and
npm run buildpass. - No existing test was weakened.
Please fix before merge
1. An old sharing-sourced whitelist row overrides a later operator grant.
get_or_create_email_user(db/email_auth.py:309) gives a new account the role from its whitelist row, and nothing replaces an existing row.- The whitelist form answers 409 "already whitelisted" (
routers/settings/whitelist.py:161). - The invite and SSO provisioning paths also keep the existing row.
- The whitelist form answers 409 "already whitelisted" (
- Scenario: Bob was shared an agent last month, which wrote a row with role
user. An admin now invites him asoperator. Bob signs in, gets roleuser, and every operator route refuses him. - On
devthis was harmless becauseuserandoperatorbehaved the same. This PR is what makes it matter, and the runbook doesn't mention it. - Suggestion: let a new grant overwrite a row whose source is
agent_sharingoraccess_request(or let the form update the role), and add a line to the runbook.
2. The authorized shared-canvas "Sign in" button leads nowhere for newly shared people.
SharedCanvas.vue:31sends them to/login?redirect=…, andGET /api/public/canvas/{token}(public.py:1086) reads only a platform JWT.- Sharing no longer adds the email to the whitelist, so someone shared after this PR never gets a code at
/login. - The Workspace pointer doesn't help either: it drops the redirect, and the canvas page never sends the Workspace token.
- The route's own
@workspace_routereason names exactly this audience. - Suggestion: give the canvas page a Workspace-token door and point "Sign in" at the Workspace with a return path. Otherwise, list it as a known limit with a follow-up issue.
3. Finding 1 in the committed CSO report (docs/security-reports/cso-diff-2026-10-09-ent837-*) says more than a public doc should.
- Please drop it before merge, or cut it down to "wording fixed" with no file list.
docs/security-reports/is outside the docs guard, so CI won't flag it.
Should fix (follow-ups are fine, please file them)
- Lowering a role doesn't affect sockets that are already open.
- The
/wsallowlists are fixed at connect (ws_identity_service.py:103). /ws/events(main.py:1678) and the terminal (terminal.py:127) check the role only at connect.update_user_rolecloses nothing.- Effect: an ex-admin's open
/wskeeps the whole fleet's stream with every field, and an open terminal keeps its shell. Suspension has the same gap. - At minimum, note this in the runbook and reconnect the socket in
reactToWorkspaceOnly.
- The
- The marker guards don't cover the private module.
- The route census and the caller pin on
resolve_platform_user_unfloored(test_837_workspace_only_floor.py:399-449) both skipenterprise/. - In a sandbox, marking a private-module route
@workspace_routekept both tests green, and ausergot past the floor on that route. - This needs a matching guard in the private repo.
- The route census and the caller pin on
- The census test missed 5 of the 6 ways I tried of adding a new unmarked Workspace call.
- It scans about 140 of the 204 files the Workspace loads, and misses the composables.
- It trusts whole path prefixes, which covers 21 floored routes under
/api/enterprise/client-portal/,/api/enterprise/projects/agent/and/api/public/. - Suggestion: walk
Portal.vue's import graph, and classify each call by the route's dependencies with the existingreaches()helper rather than by prefix.
- MCP turns
workspace_onlyinto a misleading message.getPermittedAgentsandgetAgentAccessInfo(src/mcp-server/src/client.ts:885-905,:789-803) swallow the error, so the caller sees "not permitted… configure permissions" or "not found". Rethrow when the body hascode: workspace_only(same class as #678). - Connector keys of a demoted owner now get 403 for external consumers. The connector principal matches neither floor exception, while shares and public links to the same agent keep working. Either exempt connectors or document it in the runbook.
- A
usercan't manage their own credentials.- These routes are all floored:
/api/users/me/email*,/api/users/me/github-pat, MCP key list/revoke/delete (mcp_keys.py:97,198,235), and 2FA enrol/recovery-codes/disable. - A key minted before the upgrade still reaches the portal, but its owner can't revoke it. #3497 covers only the keys.
roles-and-permissions.mdstill says any user can change their own sign-in email.
- These routes are all floored:
- The runbook's Postgres command and its claim about a lowered owner's agents are both wrong.
psql "$DATABASE_URL"(line 56) doesn't reach the bundled Postgres. Usedocker exec trinity-postgres psql -U trinity -d trinity ….- It says everything an agent of a lowered owner did is refused, but schedules, webhooks and channel bots keep running, and the owner can no longer pause them.
trinity initfor auserreports success while the CLI is unusable.src/cli/trinity_cli/commands/auth.py:58-69swallows the 403 and prints "ready", but every later command fails.- The
/chat/:tokenhistory dropdown returns 403 for a signed-inuser.public.py:1013,1049aren't marked. /mescapes both the redirect and the 403 reaction (router/index.js:335,workspaceOnly.js:76). Ausergets a mobile app where every call fails, with no pointer to the Workspace.- The whitelist "Role" column shows
default_role, which is the role at first sign-in, not the current role. Rename the column or show the current role. - No test covers the
main.jswiring ofnotifyWorkspaceOnly. Deleting that call leaves the suite green.
Nits
DELETE /api/users/me/preferences/{key}is marked, but only the operator UI calls it.- 9
file:lineanchors inworkspace-only-role.mdare stale. websocket.js:206and:226aren't gated, which contradicts the comment at:116.- The Workspace-only count line shifts the layout when it appears, and "Show only these" stays on after the list empties.
- The 403 de-duplication is a 2-second timer, not an in-flight check.
/ws/eventsrefuses beforeaccept(), so clients see HTTP 403 rather than close code 4003.
Scope note
"A user keeps the team view inside the Workspace" comes from your 2026-10-09 comment, not the 2026-10-07 ruling. It's worth an explicit ack from the issue owner.
Not run
- The four live-stack API suites.
- The MCP TypeScript tests. The
client.tspoints above come from reading the code.
…ty-enterprise#837) Must-fix: - A deliberate whitelist grant replaces a row that agent sharing or an access-request approval wrote (add_to_whitelist), so an admin can add such an email as `operator` before the first sign-in. The form names an existing account's role, which only User Management changes. - An `authorized` shared-canvas link recognises a Workspace session: with no platform credential the route reads one from the bearer token (portal_auth.workspace_email_from_request) and shows the canvas only for a roster agent and a `roster`-audience canvas (WorkspaceViewer). SharedCanvas sends the Workspace token when it has no platform one; "Sign in" goes through the Workspace and back (canvasReturnPath); /login stays for a Trinity account. - The public /cso report no longer carries the finding about this PR's own wording. Should-fix folded in: - Connector keys of a Workspace-only owner pass the floor on their two ent#46-fenced routes. - The public chat page's history routes are marked; the Dashboard-only DELETE preference route is unmarked. - reactToWorkspaceOnly reconnects /ws (a socket keeps its connect-time scope); /m (signs in by itself) sends a signed-in member to the Workspace; the loops/skills operator stores no longer follow a member's stream. - The MCP client rethrows `workspace_only` instead of reading it as "none"; `trinity init` names the refusal and exits non-zero. - Whitelist column "Role at first sign-in"; the Workspace-only count line keeps its space and the filter turns off when the list empties. - Docs: runbook Postgres command, what keeps running for a demoted owner's agent, open sessions after a role change, connectors, re-adding a sharing-written email; roles doc email line; symbol anchors instead of stale line numbers; /ws/events refuses at the handshake (HTTP 403). - main.js/api.js and page hand-offs pinned (platformSessionSync, workspaceOnlyWiring). Tests: test_837_whitelist_grant (real DB), test_837_shared_canvas_workspace_viewer, test_837_cli_workspace_only, and new cases in the floor, census, ghost-fence, websocket, router-guard and MCP access suites. Mutation battery: 29 mutants (whitelist overwrite, account hint, 409 hint, viewer branch, audience narrowing, route fallback, blocked check, connector exemption, both marks, rooms refusal, CLI exit and predicate, shareFetchClient, canvasReturnPath, existingAccountNotice, rescope, both store gates, reconnect, /m guard, operatorSurface, main.js wiring, the three page hand-offs, MCP rethrows and predicate), every one red, every file restored byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-apply default_role through settingsStore.addWhitelistEmail after #3479)
|
merge-train (2026-10-10): merged |
…eature-flows index keep-both with #3412)
|
merge-train (2026-10-10): second |
…egistry union after #3428)
|
merge-train (2026-10-10): third |
|
merge-train (2026-10-10): ejected at the merge step — validation (lane C: /validate-pr, /review, /cso --diff) found no critical, CI is green on the merged head (f900518), and the three |
|
✅ Nightly unit-suite clean when this PR is merged into |
…erges) into the review round The remote PR branch carried two merge-train merges of dev on top of 37829f0 (#3412, #3428, #3479 and others). One conflict, Settings.vue: - the whitelist add now goes through #3479's settingsStore.addWhitelistEmail, which returns { email, existingAccountRole } so the form can still name an existing account's role (emailWhitelist.spec updated, pass-through case added); - the add's refusals use #3455's InlineError under the form, beside the account notice, and the page-wide error is no longer set (the eyeball of d9473d3: a 409 landed at the bottom of Settings); pinned in workspaceOnlyWiring.spec; - #3479's guarded workspaceOnlyUsers computed, plus this round's filter reset. test_3455's whitelist fake gains the two reads the route now makes. Re-run on the merge: targeted backend 3983 passed (134 files incl. every unit test the merges changed), frontend 5501 passed (3 known host-only files), MCP 829/831 (2 = the host's pnpm layout), CLI suites green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven commits since the merge train's last merge (#3088, #3244, #3293, #3340, #3404, #3431, the #3509 enterprise pin). One conflict, tests/registry.json: rebuilt as dev's file plus this branch's six entries (purely additive against dev; dev's in-place change to the ent730 entry kept). #3404's new test patched `portal_auth.get_current_user`; the Workspace door resolves through `resolve_platform_user_unfloored` since ent#837, so the patch target follows (the same rename the branch made in four tests). Re-run on the merge (submodule at dev's pin): targeted backend 4128 passed (140 files incl. every unit test dev changed), frontend 5509 passed (3 known host-only files), MCP 837/839 (2 = the host's pnpm layout). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the thorough review. Everything is addressed in The branch is now at Must-fix
Should-fix
Nits
Scope noteI've asked the issue owner on ent#837 to confirm the team-view decision. Verification
|
|
Resolve by merging |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
I re-reviewed this at 6e7989188, covering d9473d334 and the five dev merges. Nothing regressed, and the three must-fix items are fixed. Two notes below need a quick follow-up but don't block merge.
What I verified (by running it, not just reading)
-
The floor. I sent one request to each of the 634 floored routes with the private module at
f7cf7dcmounted.Principal Passes on userJWT12 routes: round 1's 11, plus the two /chat/:tokenhistory routes, minusDELETE /me/preferences/{key}user-scope keythe public canvas only, as an anonymous visitor Agent of a userownerits 8 own routes Connector of a userownerchat, playbooks and the canvas - The routes merged in from
devrefuse every principal. - A
usercan't mint a connector key, through any of the three connector routes or/api/mcp/keys.
- The routes merged in from
-
#1, the whitelist grant. A deliberate grant now replaces an
agent_sharingoraccess_requestrow and rewrites its source tomanual. Any other row still answers 409, and no sharing-source write ever replaces a row. Checked on SQLite and on a real Postgres 16. -
#2, the canvas Workspace door. I treated this as a new auth path.
- No token can pass as another kind. Expired, revoked and bulk-revoked Workspace sessions are read as anonymous.
- The roster check is the same function the Workspace roster uses, and the audience check is pinned to the canvas the share row names.
- The status codes are the same set as at base, so there's no new oracle.
?redirect=can't leave the site, and the Workspace token is attached only to the canvas call.
-
#3, the CSO report. Finding 1 is gone from both files, and nothing else in the diff points at it.
-
Fixed: #7 (MCP: full suite 839/839), #8, #10 (the Postgres command and the three queries run against the hosted compose), #12, #13, #14, #15, and the nits.
-
Deferred, and fine to defer: #5 (ent#864), #6 (#3511), #9 (#3497), and the server-side close for #4 (ent#863).
-
Tests.
- Backend: 1,048 pass across 3 seeds, and 221 more under CI's FastAPI pin.
- Frontend: the full suite (5,529) and
npm run buildpass. - The
devmerges are clean.test_3404andtest_3455kept their assertions, andregistry.jsononly adds entries on top ofdev. - The branch still merges cleanly into the current
dev(96f88f982).
-
Mutants. I ran 20. Every mutant on the whitelist overwrite, the connector exemption, and the canvas roster and audience checks turns red. Two survived; see the test gaps below.
Needs a follow-up (doesn't block this PR)
- The invite path still keeps the old sharing row. Round-1 item 1 named both the invite path and SSO provisioning, but ent#864 only covers SSO. I checked on SQLite and Postgres: an operator invite over a legacy sharing row still gives a
useraccount. Please add the invite path to ent#864. - The Workspace page never reconnects
/wsafter a demotion.reactToWorkspaceOnlyreturns early on Workspace pages (workspaceOnly.js:99), before the role re-read andrescope(). So only a tab on an operator page gets its socket reconnected.- An admin who is lowered to
userwhile the Workspace tab is open keeps the fleet stream until a reload. The operator-store refetches keep getting 403, and each one exits early. - The runbook (
:106), your response and ent#863 all say the Workspace reconnects. Either fix the code (re-read the role and reconnect without navigating) or correct those three. - The security half is still ent#863 plus the restart advice, so this isn't a blocker.
Test gaps (two mutants survived)
trinity initexiting with code 1 is never executed by a test. Changeraise SystemExit(1)toreturnin_workspace_only_exitand all 7 tests intest_837_cli_workspace_only.pystay green, with the round-1 #11 bug back. Pin the exit code with a test that drives the call site, or with an AST check._workspace_viewer_may_seedocuments that it fails closed, but no test proves it. If theexceptbranch returns True, all tests stay green. One test that makes the lookup raise would pin it.
Smaller items
- A lapsed Workspace session loses the canvas return path. With an old Workspace token in storage, "Sign in" leads to
/workspace?redirect=….bootstrap()then replaces the route with/workspace/inbox, andresumePathwins overcanvasReturnPath, so the person lands in the Inbox. Reopening the link works.workspaceOnlyWiring.spec.js:26pins the current expression. - An admin's
user-scope key can now turn a legacy sharing row into anadmingrant. Before, it could only do this for new emails. This is the ent#717 class. - The whitelist replace isn't atomic.
email_auth.py:89reads the source and:98-107updates in a separate transaction. A concurrent delete in between gives a 200 with no row. Put the source guard in theUPDATEand check the rowcount. - A blocked client's stored Workspace token now breaks
publiccanvas links. It returns 403 "Access has been revoked" on a link any stranger can open. A blocked client should be treated as anonymous on that route. - The runbook says a restart makes everyone sign in again (
:107). That isn't so:SECRET_KEYis stable, so JWTs survive a restart. What the restart does is close every socket and terminal, and reconnecting re-reads the role. The advice holds; the wording doesn't. - The Postgres half of
test_837_whitelist_grant.pynever runs in CI. The file has norequires_postgresmarker, andschema-parityonly selects that marker. - Docs drift.
- One stale anchor remains:
_AGENT_SELF_ROUTESis atdependencies.py:1033. - The flow doc's Testing section doesn't list the three new test files.
- Its
websocket.jsrow doesn't mention the loops and skills gates. - The CSO report still says 11 marked routes; there are now 12.
- One stale anchor remains:
|
merge-train (2026-10-11): merged. The delta since the 2026-10-10 validation (d9473d3 plus two dev merges) was re-validated at lane C. All three must-fix items are confirmed fixed, there are no criticals, and the squash keeps the pre-fix CSO text out of dev's history. Follow-ups worth filing, none of them blocking: (a) a |
…cap room cost (#3495) (#3882) ent#387 made a room's budget an operator control, but drew the line at "platform principal" — which since #3488 includes the Workspace-only `user` role. Such an account kept whatever it sent: an uncapped cost, 500 messages, or a TTL of 0 (a room that never expires), while a portal client sending the same values was cut back to the operator defaults. - `_resolve_budget` now also requires a role at or above `operator` (`dependencies.is_workspace_only_role`, ent#837). Below it — the `user` rung, an MCP key such an account owns, a role outside the ladder, or no role at all — the caller's values are ignored and the operator defaults apply: the ent#387 rule, not a clamp. Operator and above may still bound a room, TTL 0 included. - `max_cost_usd` gets an upper bound, `MAX_COST_USD_CEILING` = $1,000 per room, on `RoomCreate` and on the operator default (`RoomBudgetDefaultsUpdate`; the two ranges mirror each other). A stored default above it is CLAMPED to it by `_coerce_cost` — reading it as invalid would mean no cap at all. The defaults response reports the ceiling beside the message and TTL limits. - MCP `create_room` declares the same 500 / $1,000 / 168 h bounds, and its stale "default 60" / "default 24" descriptions now point at the operator defaults. Fixes #3495 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
The platform role
usernow means a member who works with agents in the Workspace. The operator UI and every operator API route start atoperator. Sharing an agent, and approving an access request, no longer create a platform login.This is PR 1 of abilityai/trinity-enterprise#837 (the OSS core). The Access-tab role control and the companion check (AC5/AC7) follow in PR 2, after ent#810.
Action required on upgrade: raise every account that operates agents to
operator. No database migration runs and no role is rewritten. Runbook:docs/migrations/WORKSPACE_ONLY_USER_ROLE_2026-10.md.Changes
Backend
get_current_user. It is nowresolve_platform_user_unfloored(every scope fence, unchanged) followed byenforce_operator_floor. A principal belowoperator(user, or a role outside the ladder) gets403 {"code": "workspace_only", "message": "This account works in the Workspace. Open /workspace."}before any agent lookup. There are two exceptions:@workspace_route("<reason>");_AGENT_SELF_ROUTES). The name is bound to the key and must be a path parameter of the matched route, so a static route that merely spells it (GET /api/agents/slots) never counts.get_portal_principal,get_room_principaland the Workspace voice start resolve throughresolve_platform_user_unfloored. The set of callers is pinned by a test. The rooms door still refuses an agent whose owner is Workspace-only./ws/eventsand the agent terminal close with 4003 for theuserrung./wssends a Workspace-only socket only the event kinds the Workspace reads, each cut to the fields it reads (WORKSPACE_WS_EVENT_TYPES/_FIELDS), on live fan-out and on reconnect replay.Frontend
/loginlands auserin/workspace.userto the Workspace (/agents/:name/*→/workspace?agent=:name). Aworkspace_only403 moves the tab there too, but only once the re-read profile saysuser./wsfor auser.user, labelled "user — Workspace only") and a Role column;name@company.com)./workspace.Docs
auth.md§2.10.security.md,workspace.md,integrations.md, and a pointer from Invariant security: implement safe tar extraction with symlink/hardlink validation #8.workspace-only-role.md, plus seven updated flows.Review round 1
All of AndriiPasternak31's review is addressed in
d9473d334. Details are in the PR comment.authorizedshared-canvas link recognises a Workspace session, limited to a roster agent and aroster-audience canvas. Its "Sign in" goes through the Workspace and back./msends a member to the Workspace. A refused call reconnects/ws. The MCP server andtrinity initname theworkspace_onlyrefusal.Decisions (recorded on ent#837)
userkeeps the team view: every canvas audience, the agent's whole activity, voice and model choice. The narrower client view is for people without a platform account.Known limits (follow-ups)
user, because the image route is operator-side. A portal image route is a follow-up (Workspace canvases: images referenced by path fall back for auserand for portal clients #3491).userkeep only their own runtime routes until the owner is raised again.useris E2E: a Workspace journey signed in as auser#3493.Verification
/ws, the route census, whitelist grants, shared-canvas viewers and the CLI. Mutation batteries (17 at first, then 29 in review round 1) turned every new guard red with its change reverted. On the final merge, the targeted backend set (140 files) passes: 4,128./verify-local --skip-agenton the merged tree: build, import smoke, boot, preflight and integration all pass. The unit stage fails 3 tests: thetest_3262system-seed tests, which fail because the run usesTRINITY_DEFAULT_SYSTEM_MANIFEST=disabled./cso --diff: 4 findings (1 medium, 3 low), all fixed in this PR. Report:docs/security-reports/cso-diff-2026-10-09-ent837-workspace-only-role.md.workspace_only403, raising and lowering a role, the Settings aids). The review round's 4 steps also passed (whitelist hints, the count and filter, the shared-canvas sign-in round trip,/m).Test plan
user: you land in/workspace;/agents/<name>opens/workspace?agent=<name>; an operator API call answers403 workspace_only.operator: the operator UI returns.Part of abilityai/trinity-enterprise#837
🤖 Generated with Claude Code