Repository navigation
fix(workspace): scope a client's agent page to their own runs (#3139) - #3155
Merged
Merged
Conversation
An external Workspace client opening an agent's page received other people's runs of that agent: their run ids, triggers, start and end times, and durations. The rows were never scoped to the viewer. Message, cost and model were already projected away, but the timing still leaked (10 of 12 rows in the ent#610 review walk). For a non-platform viewer, every executions read on the page is now scoped in SQL by one predicate, db/query_helpers.viewer_scope(). The viewer sees their own turns (source_user_email), the runs those turns spawned (the inherited source_channel_client), and the agent's scheduled runs, except a run of a schedule that delivers to one other person (deliver_to_workspace_email, #498, a seat's brief). Emails compare lower-cased, and a missing viewer admits shared schedule runs only, so the scope fails closed. The scope applies before the LIMIT, as the #2423 loop exclusion does, so another person's busy day cannot push the viewer's own turns off the list. It also covers every analytics aggregate, first_try_stats and "last active", so a client's counts and rates are over exactly the rows they can see. first_try_stats moves from text() to Core to share the predicate. The platform view and every other caller of these accessors are unchanged (new keywords, defaults off). Tests: test_3139_client_recent_work_scope.py, 12 tests on real SQLite through the real accessors and page module. They cover two clients on one agent, a spawned run, case-insensitive email matching, scope before the LIMIT, last active, stats agreeing with the rows, rates, seat-delivered briefs, a missing viewer, and an unchanged platform view. Verified by mutation: with the fix reverted 9 of 12 fail; with a scope that admits every row, 7 fail; without the seat-brief exclusion, 3 fail. The #2423 stubs take the new keywords. The seat-brief exclusion came from the /cso --diff pass (one LOW finding). Fixes #3139 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
/review report:
|
| changed symbol | executed by | live consumer | verdict |
|---|---|---|---|
db/query_helpers.viewer_scope |
test_3139_client_recent_work_scope.py (real SQLite) |
the three accessors below | ✅ |
get_agent_executions_summary(scope_to_viewer=) |
same | agent_page._recent_work, _last_active |
✅ |
get_agent_analytics(scope_to_viewer=) |
same | agent_page._stats |
✅ |
first_try_stats(scope_to_viewer=) (now Core) |
same | agent_page._stats |
✅ |
agent_page._client_scope / viewer_email threading |
same, via build_page |
client_portal/router.py portal_agent_page |
✅ |
Fix mutations (each from a scratch copy):
agent_page.pyreverted: 9 of 12 red;- a scope admitting every row: 7 red;
- the seat-brief exclusion removed: 3 red.
Live check (local dev stack, 2026-10-01)
Two test clients were shared on proj-alpha and signed in through the real portal code flow. Each got one marked turn, and a seat brief was delivered to Bob. Reading GET /api/enterprise/client-portal/agents/proj-alpha/page:
| viewer | rows | runs from this test | total_executions |
|---|---|---|---|
| alice | 1 | alice only |
1 |
| bob | 2 | bob, bob's seat brief |
2 |
| admin (platform) | 18 | all 3 + 15 public-link runs by others | 18 |
Before this PR, a client was served all 18, including other people's run ids and timings. The test data was removed afterwards (demo DB reset).
Critical findings
None. /cso --diff found one LOW (seat-delivered briefs visible to other clients), and it is fixed in this PR.
Informational
- [I1] Performance:
lower()on the email columns can't use an index (confidence 6/10).db/query_helpers.py:func.lower(table.c.source_user_email) == v. Theagent_namepredicate narrows first, so per-agent row counts keep this cheap. Revisit only if a single agent's executions reach the millions; a lower-cased stored column would be the fix. - [I2] Scope: the ratings tally stays agent-wide for clients (
_rating_tally). It shows counts only, never run ids or timings, so it's outside this issue. Noted so it's a decision, not an oversight.
Clean categories
- SQL: bound parameters only; the
NOT INsubquery compiles for PostgreSQL. - Auth: the viewer email comes from the verified portal session token.
- Callers: every other caller of the three accessors passes no scope, so its behaviour is unchanged (grepped).
- Docs: feature flow and requirement updated; guard: 0 hits.
Summary: Critical 0 · Informational 2 · Scope clean.
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/cso --diffpass.How
One filter:
db/query_helpers.viewer_scope(table, viewer_email).Applied in SQL before the LIMIT, as the Workspace shows a client 12 loop runs it cannot open, control, or explain #2423 loop exclusion is, so other people's activity can't push the viewer's own turns off the list. It also applies inside every analytics aggregate.
New keyword arguments, off by default:
scope_to_viewer/viewer_emailon:get_agent_executions_summary;get_agent_analytics;first_try_stats, which moves from raw SQL text to the SQL builder to share the filter.Every other caller, including the operator analytics and Tasks routes, is unchanged.
Wiring:
client_portal/agent_page.pypasses the viewer's email from the verified portal session token into the list, stats and last-active reads, via_client_scope().Changes
src/backend/db/query_helpers.py:viewer_scope()src/backend/db/schedules/executions.py,db/schedules/analytics.py,database.py,client_portal/db.py: scoped accessorssrc/backend/client_portal/agent_page.py: the viewer threaded through_recent_work,_statsand_last_activetests/unit/test_3139_client_recent_work_scope.py(new)tests/unit/test_2423_client_loop_visibility.py: the stubs accept the new keyword argumentsdocs/memory/feature-flows/workspace-agent-page.md,docs/memory/requirements/core-agent.mdTest Plan
cd tests && pytest unit/test_3139_client_recent_work_scope.py -v: 12 passed, on real SQLite through the real accessors and the page module. Covered:test_736_a2a_outbound_edges(IPv6-mapped address classification), is unrelated: its result depends on the local Python 3.12 runtime, and CI runs 3.13.viewer_scopeSQL compiles for PostgreSQL./cso --diff:Fixes #3139
🤖 Generated with Claude Code