Skip to content

fix(security): operator-queue access control — scope items to accessible agents (#470) - #472

Merged
vybe merged 2 commits into
mainfrom
feature/470-operator-queue-access-control
Apr 23, 2026
Merged

vybe merged 2 commits into
mainfrom
feature/470-operator-queue-access-control

Conversation

@vybe

@vybe vybe commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes High-severity broken access control (pentest finding 3.1.3 Broken Access Control [User]): GET /api/operator-queue and related endpoints were returning items for all agents on the platform regardless of requester ownership
  • All 6 operator-queue endpoints now enforce user-scoped filtering: list, stats, get, respond, cancel, and /agents/{name}
  • Admin users retain full visibility (no regression)
  • Unblocks the Letter of Attestation (A grade) from the security vendor

Changes

  • src/backend/db/operator_queue.py — list_items() and get_stats() accept accessible_agent_names: Optional[Set[str]]; empty set short-circuits before SQL executes (prevents IN () syntax error); all 5 stats sub-queries apply the filter so by_agent never leaks foreign agent names
  • src/backend/routers/operator_queue.py — _accessible_set() helper computes user's allowed agents via db.get_accessible_agent_names(); _assert_agent_accessible() raises 403 on unauthorized access; applied to all 6 endpoints
  • src/backend/database.py — get_operator_queue_stats() passes **kwargs through to support the new param
  • tests/test_operator_queue.py — 8 new tests in TestOperatorQueueAccessControl: non-admin list/get/respond/cancel/agents/stats isolation + admin regression

Test Plan

  • All 42 existing tests pass, 3 skipped (no live pending items): pytest tests/test_operator_queue.py -v
  • 8 new isolation tests pass: non-admin gets 403/filtered-list on all endpoints; admin sees everything
  • Manual: non-admin user's list returns only their own agents' items; stats by_agent contains no foreign agents

Closes #470

Note: also includes a separate UI commit (feat(agents): overflow-visible avatar) that was an unrelated pre-existing working-tree change.

🤖 Generated with Claude Code

vybe and others added 2 commits April 23, 2026 15:13
…ow card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ble agents (#470)

Fixes High-severity broken access control (pentest finding 3.1.3): all six
operator-queue endpoints now enforce user-scoped filtering.

- list + stats: pass accessible_agent_names set to DB layer; empty set
  short-circuits before any SQL executes (prevents IN () syntax error)
- get / respond / cancel: 403 if item's agent_name not in accessible set
- /agents/{name}: 403 if agent_name not in accessible set
- Admin bypass: role==admin returns None (no filter), sees all items
- All five stats sub-queries (by_status/type/priority/agent/avg/today)
  apply the same access filter so by_agent never leaks foreign agent names
- 8 new isolation tests in TestOperatorQueueAccessControl covering all
  endpoints for non-admin and admin regression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vybe
vybe merged commit 092f984 into main Apr 23, 2026
vybe pushed a commit that referenced this pull request Sep 6, 2026
…s its rail (#2540, ent#474) (#2543)

Two operator rulings from the 2026-09-06 Workspace review, one PR because the
second is built under the first.

#2540 — the loading rule. Design-system principle 12 is amended: the scanline
beam + wipe-in reveal is the CHART-loading motion only; every other first load
— pages, panels, lists, message threads — is a skeleton placeholder keyed on
"no data yet". The three Workspace zones #2163 wrapped in `ScanlineReveal`
(the stage in Portal.vue, the thread in PortalConversation.vue, the hint zone
in PortalBriefing.vue) render `PortalSkeleton` (stage / thread / briefing)
instead, gated on `stage.state === 'loading'`, `!historyLoaded` and
`zone.state === 'pending'` — never a bare `<x>.loading` path, which the #1927
ratchet counts as a bare gate. The `reveal` verdict the zones computed for the
primitive is gone. The footprint (`min-h`, `max-w`) moves onto a wrapper both
faces sit inside, so the swap never shifts. `tests/unit/portalLoadingTreatment.spec.js`
pins the `ScanlineReveal` importer set as an allowlist: three chart consumers
plus the two pre-ruling non-chart holdovers (LibrarySkillsSection,
FinishSetupCard), recorded on #1921 — whose sweep is re-pointed by comment:
bespoke spinners on non-chart surfaces become skeletons, not scanlines. The
doc names the canonical forms (`SkeletonLoader.vue`, or content-shaped) and
the recipe; `/audit-design-system` reads the amended doc without a skill change.

ent#474 — the rail shell, to the approved design pass (48px collapsed /
`w-96` open, `trinity-workspace-rail`, mobile strip + sheet, two signal
shapes). Scope is the operator's own split: the three-column layout, the tab
contract, the collapsed signal, Work docked EMPTY — its content is #457's and
the re-homing of loops / canvas / files is #472's second child; the sidebar,
thread tab strip, top band, Agent-details panel and drop target of the approved
conversation page are later steps of the same build, and #492 lands the grid
variables the rail's widths then follow.

- `portalRail.js` is the contract: a tab declares door (platform / audience /
  agent), participant scope, empty state and signal shape; `visibleTabs` is
  the ONE gate for render AND mount, so a tab whose door the session fails
  has no icon, no label and no mounted body — nothing it would fetch is ever
  requested (the per-door test; an external client sees no rail until an
  audience tab docks). An unknown door fails closed.
- `PortalRail.vue` is a sibling of <main>, outside `convKey`, so a chat switch
  remounts the conversation and never the rail; visibility keys on the route
  and the stage verdict, never on data still arriving. Collapsed by default;
  open/collapsed + active tab persist under one key, read before first paint.
- The Work signal is DERIVED — from the conversation's in-flight `sending`
  (1:1) and the room's server-reported `working` list — reset on every chat
  switch and cleared on unmount, so it structurally cannot stick.
- A room groups by participant over `portalLoopUtils.byAgent`, absence
  visible; `1 running` is a `BaseBadge`. `OverflowTabs` gains an optional
  per-tab `signal` dot, measured in its mirror row.
- Mobile: `PortalRailStrip` above the composer through a `#rail-strip` slot on
  both conversations, opening the same component as a bottom sheet (Esc closes).

Verified live on the Docker frontend: collapsed → open → persisted across
reload; light and dark; mobile strip → sheet → Escape; the stage skeleton
under a slowed roster; no console errors. Unit suite 1889/1889; both ratchets
flat (no bare gate added, no touched file's raw-gray count grew).

Fixes #2540
Part of Abilityai/trinity-enterprise#474


Claude-Session: https://claude.ai/code/session_01FgAncnTAbgjJS3EGwuEtd4

Co-authored-by: sim <sim@example.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Sep 6, 2026
…p and the drawer are gone (Abilityai/trinity-enterprise#475) (#2544)

Slice 2 of the conversation rail (ent#472): the three placements #472 found
move into the ent#474 frame. The loops strip above the composer becomes the
Loops tab, the Files slide-over becomes the Files tab (PortalFilesPanel
deleted), and the agent canvas gets its conversation-side placement as the
Canvas tab — the same CanvasPanel and client-portal store the Workspace agent
page uses, audience-narrowed by the ent#438 ruling.

One owner for what the tabs read (composables/usePortalRailFeeds.js): it
feeds stores/portalLoops.js and the new stores/portalRailFeeds.js off the
rail's door gate (feedsFor over visibleTabs) and only while the rail is on
screen, so a session that fails a tab's door never fetches its data, a deep
link to an unreachable agent issues no request, and the collapsed rail can
signal with no body mounted. Loops = live (the store's active loops); Canvas
and Files = "updated since last view" — newest server timestamp per
participant vs a seen marker (epoch compare via parseUTC, Invariant #16),
persisted under trinity-workspace-rail-seen. Refresh is push/event-driven
(turn end, room idle, loop events, terminal agent_activity — debounced), on
tab open and after an upload; no timer while idle.

The header paperclip opens the rail on Files (column at sm+, sheet below,
never persisting open from a phone). "Ask for a canvas" pre-fills the
composer in a conversation AND a room; never sends. CanvasPanel re-reads
blocks when the selected canvas's updated_at moves, so a lit dot never opens
onto stale content. The Files spinner is replaced by a rail skeleton keyed on
the feed's verdict (AC 6 as amended 2026-09-06); LoadFailed / InlineError on
failure.

Review finding folded in: no rail.reset() in the shell's chat-switch watch —
watchers run in creation order and the owner had already re-scoped both
stores, so the reset wiped the new chat's data (docs/memory/learnings.md).

Tests: portalRail.spec (four-tab registry, doors → fetch, timestamps as
instants, updatedSignal, seen markers, feedView, railOpenPlan, removed
placements, owner wiring), new portalRailFeeds.spec (store + owner under
Pinia), portalLoops.spec re-pinned to the one owner, loading-treatment guard
extended, loading-gate baseline regenerated. Live pass on the Docker frontend:
four tabs, ask → prefill, paperclip → rail, room grouping, mobile strip →
sheet → Esc, zero console errors.

Fixes Abilityai/trinity-enterprise#475


Claude-Session: https://claude.ai/code/session_01PBTEMcpMsvPC5U4QnKevLC

Co-authored-by: sim <sim@example.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: operator-queue endpoint leaks other users' agent approval prompts

1 participant