fix(reports): the five review findings on ent#365, which merged before the review landed - #2394
Conversation
…e the review landed (ent#365) PR #2383 merged at 12:57; the re-review posted after. Its findings therefore live in `dev`, so they are fixed here rather than on the branch. **Client email addresses reached agent LLM context through MCP `list_reports`.** `addressed_to` was appended to `_SUMMARY_COLUMNS` so an operator can answer "who was this produced for" — but `list_reports` returns the backend's rows verbatim and `filterReportsForAgentScope` filters ROWS by agent, never FIELDS. An agent holding an `agent_permissions` edge to another agent could call `list_reports({agent_name: "A"})` and pull A's clients' addresses into its own context, where none were exposed before. It also contradicted the tool's own advertised contract ("Returns METADATA only"). Stripped for every principal, not just agent keys: a tool result is LLM context wherever it lands, and an operator still gets the field from the REST surface the UI uses. **The publish gate was broader than the read gate, so a report could be addressed to someone who could never see it.** `db.email_has_agent_access` returns True for ANY admin regardless of sharing; the Workspace read gate is `agent_on_roster` (`agent_sharing` ∪ owned). A report addressed to a platform admin who neither owns nor is shared the agent stored happily and 404'd in that admin's Workspace — falsifying the invariant the code comment states. The publish now gates on the same function the reader uses, which is the only form that cannot drift. **In-chat cards never appeared for a synchronous turn.** `_resolve_portal_session` reads the ent#286 reverse marker, and `mark_turn_inflight` had exactly one caller — inside `start_portal_turn`. The `POST .../chat` path never set it, so a report published from a turn that came through there stored `portal_session_id = NULL` and the card silently never rendered. Not an edge: `/chat` is ent#83's documented headless surface AND the browser's fallback when streaming is unavailable. The row is now pre-created on that path too, exactly as the streaming one does, so there is an id to mark with — and cleared in a `finally`, because a marker outliving its turn is worse than none (the UI reattaches to a turn that ended and waits out the TTL). **The chat card fetched the whole payload.** It called `fetchAgentReport` with no options, so `rows_limit` was omitted and a tabular deliverable near the 5 MiB ceiling shipped in full to the browser and rendered every row — inside a chat card, while the sibling Reports tab pages the identical report through #2162's window. The card now takes a first page and says so; the agent page stays where a full table is read, which is more honest than paging a preview inside a conversation. **The create response's `addressed_to` was always null.** `Report` extends `ReportSummary`, so the field IS in `model_fields` — the filter was dropping it because the row carries the DB name `addressed_to_email`. So the same model meant two different things depending on the route, with the MCP tool papering over it by echoing back the caller's own argument. Mapped explicitly. The `test_portal_stamps_the_surface_on_its_executions` assertion is generalised from a hard count of 2 to "one stamp per creation site", so adding the third is a decision rather than a broken test. Related to Abilityai/trinity-enterprise#365
Code review —
|
…port too (ent#365)
**The build was broken.** `stripAudienceFromReports<T extends Record<string,
unknown>>` cannot accept `ReportSummary[]`: `ReportSummary` is an `interface`,
and TypeScript gives interfaces no implicit index signature. So `tsc` failed at
the call site and `npm run build` did not compile — which is what reddened the
`e2e` check, in the Docker build of the mcp-server image, not in a test:
src/tools/reports.ts(359,72): error TS2345: Argument of type
'ReportSummary[]' is not assignable to parameter of type
'Record<string, unknown>[]'.
`<T extends object>` accepts both an interface and a record, and `in` plus rest
destructuring need nothing more than that.
**And the strip only closed half the hole.** `_mapping_to_report` sets
`addressed_to`, `GET /api/reports/{id}` returns it, and `get_report` did a bare
`JSON.stringify(report)` — so the path this fix exists to close still worked
with one extra hop: a permitted sibling agent calls `list_reports` for the ids
(which is by design) and then `get_report(id)`, and the client's email lands in
its context anyway. Same strip, same reason.
The new cases pin the type constraint as a REGRESSION — one of them declares a
local `interface` and passes it through, which is the shape that failed to
compile — rather than only asserting the field disappears. A test that used a
record literal would have passed against the broken signature.
Related to Abilityai/trinity-enterprise#365
Review sweep —
|
obasilakis
left a comment
There was a problem hiding this comment.
Reviewed. No blocking findings, and I am deliberately leaving this as a comment rather than an approval because one item below is a scope decision rather than a defect — it needs an answer, not a patch.
Five review findings on a PR that merged before its review landed, fixed on dev rather than on a dead branch: that is the right call and the right place.
Verified fixed
The disclosure. stripAudienceFromReports is applied to both list_reports and get_report, which is the important part — stripping only the list would have left the same address one hop further on, since _mapping_to_report sets addressed_to and the detail route returns it. Applying it to every principal rather than only agent-scoped keys is the right reading: a tool result is LLM context wherever it lands. The T extends object signature with the regression test explaining why Record<string, unknown> broke tsc on an interface is a good test — it encodes the failure in the type system rather than in a comment.
The publish/read gate divergence. agent_on_roster(name, audience, include_owned=False) replacing db.email_has_agent_access is correct, and test_the_publish_gate_is_the_read_gate pinning it structurally is the part that keeps it correct. The comment is right that two predicates that happen to agree today is exactly how this drifted. include_owned=False is the right choice and the reasoning for it is stated.
The synchronous-turn marker. I checked the concern that the pre-created row might be orphaned — it is not: execution_id=execution_id is passed through to run_resumable_turn at client_portal/service.py:1544, so the row is adopted rather than duplicated, and the two paths genuinely converge on start_portal_turn's shape. _run_sync_turn_and_clear_marker's finally covers the raising exits including ResumeLockBusy, which is raised from inside run_resumable_turn and therefore inside the wrapper. owns_marker correctly means only the caller that set it takes it down.
The card's unbounded fetch. 50 rows plus row_meta.total, and saying what the slice is of rather than offering a "load more" inside a chat card, is the right shape — the agent page is the reading surface.
The always-null addressed_to on create. The diagnosis is the interesting part: the field was in model_fields, and the filter was dropping it on the DB column name. Mapping it explicitly and pinning it with a test is right.
Needs a decision, not a fix
The audience strip is at the MCP layer only.
stripAudienceFromReports lives in src/mcp-server/src/tools/reports.ts. An agent-scoped MCP key is a valid bearer token against the backend directly — that is how the heartbeat, the #1083 result callback and the reports write path all authenticate. So an agent can reach GET /api/reports/{id} and GET /api/reports over plain HTTP and read addressed_to for every agent its owner can access, which is a strictly wider set than the {self} ∪ permitted scope the MCP layer enforces.
The PR's own stated rationale is "a tool result is LLM context wherever it lands". That argument applies identically to a curl result in the agent's own shell — arguably more so, since the threat model here is a prompt-injected agent and such an agent has Bash.
I do not think this blocks: it is a residual on the same surface rather than a regression introduced here, and the operator genuinely needs the field on REST for the UI. But it should be resolved one way or the other rather than left implicit:
- strip for agent principals at the REST read (the field stays for JWT callers, which is where the UI reads it), or
- record the residual explicitly in the tool's docstring and in the flow doc, so the next reader does not conclude from the MCP strip that the address is unreachable by an agent.
Happy either way. What I would not want is the current state, where the code reads as though the hole is closed.
Minor
-
_precreate_sync_executiondocstring — "run_resumable_turnthen creates the row itself, exactly as before" reads as though the pre-created row goes unused. It only describes the fail-softNonepath. Worth one clause, because I spent a while confirming this was not creating an orphanrunningrow per sync portal turn. -
PortalDeliverables.vue:shownRows— if the server omitslimit,meta.limit ?? 0makesshownRowszero whilehasMoreRowsstays true, and the card renders "Showing the first 0 of N rows". Cheap guard. -
test_portal_stamps_the_surface_on_its_executions— generalising from a hard count of 2 to "one stamp per creation site" is a real improvement over the version it replaces. Noting only thatsource.count(...) == creates + 1still couples the assertion to the number of non-creation dispatch stamps, so a second dispatch-site stamp would break it for a reason unrelated to what it tests.
Verdict
Content is good and the diagnoses are precise. Approving as soon as the MCP-versus-REST question above has an answer — either fix or a written residual is fine by me.
…n the MCP tool (ent#365 review) The review's one open item, answered by closing it rather than by recording a residual. `stripAudienceFromReports` lives in the MCP tool, but an agent-scoped MCP key is a valid bearer token against this API directly — that is how the heartbeat, the #1083 result callback and the reports WRITE path all authenticate. So an agent could `curl` `GET /api/reports` and `GET /api/reports/{id}` and read `addressed_to` for every agent its owner can access: strictly wider than the `{self} u permitted` scope the MCP layer enforces. The PR's own rationale for stripping at the tool was "a tool result is LLM context wherever it lands". That applies at least as strongly to a shell result, since the threat model is a prompt-injected agent and such an agent has Bash. Recording a residual was the other option the reviewer offered, but it leaves code that READS as though the hole is shut, which is the state they said they did not want. `_hide_audience` withholds the field from any caller that is not an interactive human. The UI is unaffected: it reads over a JWT, exactly the allowlisted case. The predicate is `dependencies.is_interactive_principal`, extracted from `reject_non_interactive_principal` so the redact and the refuse share ONE definition and cannot drift. It stays an ALLOWLIST for the reason that function already documents: `mcp_api_keys.scope` is free text with no CHECK constraint, so a denylist naming `agent` and `connector` is open at the top and admits the next scope silently (#2323). A principal with no `mcp_scope` attribute fails closed via a sentinel rather than `getattr(..., None)`, which would make the absent attribute the privileged value. ## The three minors `_precreate_sync_execution`'s docstring said `run_resumable_turn` "then creates the row itself, exactly as before", which reads as though the pre-created row goes unused. It only described the fail-soft None path; on the success path the id is threaded through and the row is ADOPTED. Said explicitly, because confirming it was not an orphan `running` row per sync turn cost the reviewer time. `shownRows` rendered "Showing the first 0 of N rows" when the server omitted `limit` — zero shown while `hasMoreRows` stayed true, a sentence that cannot be right. An absent limit means the slice was not bounded, so what is shown is everything after the offset. `test_portal_stamps_the_surface_on_its_executions` asserted `count(stamp) == creates + 1`, where the `+ 1` stood for the turn's own dispatch stamp — so a second dispatch-site stamp would have broken it for a reason unrelated to what it tests. It now walks the AST and asserts the real rule: every `create_task_execution` site in this module carries a `source_channel` stamp, however many other stamps exist. ## Verification 8 new tests, mutation-checked: disabling the strip turns 8 red. 394 pass across the reports / ent#365 / dependencies / auth-wiring suites, and the full frontend suite is green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MCP-vs-REST question, answered by closing it —
|
…owups # Conflicts: # src/frontend/src/components/portal/PortalDeliverables.vue
obasilakis
left a comment
There was a problem hiding this comment.
/validate-pr — approving. CI is fully green as of this review.
Fixing these on a branch off dev rather than on the original branch is the right call given #2383 merged at 12:57 and the re-review posted after — the findings live in dev, so this is where they belong.
The disclosure one is the important one, and it is closed in the right place
Stripping addressed_to in the MCP tool only would have left the hole open, because an agent-scoped MCP key is a valid bearer token against the REST API directly — that is how the heartbeat, the #1083 result callback and the reports write path all authenticate. An agent could have curled GET /api/reports and read client email addresses for every agent its owner can access, which is strictly wider than the {self} ∪ permitted scope the MCP layer enforces. The PR's own rationale for stripping at the tool — a tool result is LLM context wherever it lands — applies at least as strongly to a shell result, since the threat model is a prompt-injected agent and such an agent has Bash. Closing it at the REST read rather than recording a residual is right; the alternative leaves code that reads as though the hole is shut.
is_interactive_principal extracted from reject_non_interactive_principal so the two cannot drift is the correct shape — not every use of a predicate is a refusal, and this surface needs to redact a field rather than reject the request. The allowlist reasoning holds: mcp_api_keys.scope is free text with no CHECK constraint, so a denylist naming agent and connector is open at the top and silently admits the next scope that ships. The getattr(current_user, "mcp_scope", "__missing__") is None sentinel avoiding the #2323 trap — where an absent attribute becomes the privileged value — is the detail that makes it fail closed rather than nearly closed.
"Can be addressed" drifting from "can reach"
db.email_has_agent_access returns True for any admin regardless of sharing, while the Workspace read gate is agent_on_roster (agent_sharing ∪ owned). So a report addressed to a platform admin who neither owns nor is shared the agent stored happily and then 404'd in that admin's Workspace — a publish nobody could read, falsifying the invariant stated in the comment directly above the check. Gating the publish on the same function the reader uses, and pinning it with a test, is the fix; two predicates that happen to agree today is exactly how this drifted in the first place.
include_owned=False is the right argument, and the reason is worth having in the comment: an owner reads their agent's reports on the operator surface, so addressing one to themselves is not what the audience column is for.
The in-chat card never appearing for a synchronous turn
mark_turn_inflight having exactly one caller inside start_portal_turn meant /chat — ent#83's documented headless integration surface and the browser's fallback when streaming dispatch is unavailable — stored portal_session_id = NULL and the card silently never rendered. Both are live paths, so this was not an edge case.
Pre-creating the execution row on that path so there is an id to mark with, rather than growing separate turn machinery, converges the two paths instead of forking them. The finally clear matters for the stated reason: a marker that outlives its turn is worse than none, because the UI reattaches to a turn that ended and waits out the whole TTL. And the docstring now says explicitly that run_resumable_turn adopts the pre-created row rather than creating a second one — the earlier wording read as though the row went unused, which is a question that should not cost a reader ten minutes.
The response-shape contradiction
Good catch that the model_fields filter stopped meaning what its comment claimed the moment ReportSummary gained addressed_to. Report extends it, so the field is in model_fields, and the filter was silently dropping it only because db.create_report returns the column under its DB name. The same model meant two different things depending on the route — populated on GET /reports/{id}, always null on create — with the MCP tool papering over it by echoing back the caller's own argument. Mapping it explicitly so the response says what the row says is right.
The card fetching the whole payload
fetchAgentReport with no options omitted rows_limit, so a tabular deliverable near the 5 MiB ceiling shipped whole to the browser and rendered every row inside a chat card, while the sibling Reports tab paged the identical report through #2162's window. A first page is the right default for a preview inside a conversation. The shownRows fix is the subtler one — meta.limit ?? 0 rendered "Showing the first 0 of N rows" while hasMoreRows stayed true, which is a sentence that cannot be right; treating an absent limit as "everything after the offset" is correct.
The TypeScript regression test is a nice touch: constraining on Record<string, unknown> broke tsc because an interface has no implicit index signature, and expressing that regression in the type system is better than a comment asking someone not to do it again.
Checklist: base dev, security scan clean, dependencies.py and the other touched top-level modules are covered by the Dockerfile's COPY src/backend/*.py wildcard, no new os.getenv() vars, no schema change. All three surfaces stay in sync (backend router, MCP tool, frontend). CI green across all seeds, e2e and prod-image-smoke.
Two process notes, neither blocking:
No labels on this PR. Every sibling in this batch carries priority / type / theme. Given this is a P1-adjacent disclosure fix, priority-p1 + type-bug and a theme would be worth adding.
No issue reference with a closing keyword. This is a follow-up to a merged PR rather than to an open issue, so there is nothing that would auto-close correctly — but ent#365 is still status-in-progress and will need its status set by hand, since the cross-tracker automation is same-repo only.
Architecture doc. architecture.md describes the reports MCP tool's metadata contract; the audience redaction now also applies at the REST read for any key-authenticated caller. One line under Agent Reports would keep the doc honest about who sees addressed_to.
PR #2383 merged at 12:57; its re-review posted after. The findings therefore live in
dev, so they are fixed here rather than on the branch.The disclosure one
Client email addresses reached agent LLM context through MCP
list_reports.addressed_towas appended to_SUMMARY_COLUMNSso an operator can answer "who was this produced for" — the support question a deliverable creates. Butlist_reportsreturns the backend's rows verbatim, andfilterReportsForAgentScopefilters rows by agent, never fields.So an agent holding an
agent_permissionsedge to another agent could calllist_reports({agent_name: "A"})and pull A's clients' email addresses into its own context, where none were exposed before. It also contradicted the tool's own advertised contract — "Returns METADATA only (id, type, title, period, created_at)".Stripped for every principal rather than only agent-scoped keys: a tool result is LLM context wherever it lands, and an operator still gets the field from the REST surface the UI uses.
"Can be addressed" had drifted from "can reach"
db.email_has_agent_accessreturnsTruefor any user whose role isadmin, regardless of sharing; the Workspace read gate isagent_on_roster—agent_sharing∪ owned. A report addressed to a platform admin who neither owns nor is shared the agent stored happily and then 404'd in that admin's Workspace: a publish nobody could read, falsifying the invariant stated in the code comment directly above the check.The publish now gates on the same function the reader uses. A test pins that, because two predicates that happen to agree today is exactly how this drifted.
In-chat cards never appeared for a synchronous turn
_resolve_portal_sessionresolves a report's chat from the ent#286 reverse marker — andmark_turn_inflighthad exactly one caller, insidestart_portal_turn. ThePOST .../chatpath never set it, so a report published from a turn that arrived there storedportal_session_id = NULLand the card silently never rendered.Not an edge case:
/chatis ent#83's documented headless integration surface and the browser's fallback when the streaming dispatch route is unavailable.The execution row is now pre-created on that path too — exactly what
start_portal_turnalready does, so the two converge instead of this one growing its own turn machinery — giving an id to mark with. Cleared in afinally: a marker that outlives its turn is worse than none, because the UI reattaches to a turn that ended and waits out the whole TTL.The chat card fetched the whole payload
It called
fetchAgentReportwith no options, sorows_limitwas omitted and the detail route returned the payload whole. A tabular deliverable near the 5 MiBREPORT_PAYLOAD_MAX_BYTESceiling shipped all of it to the browser and rendered every row — inside a chat card — while the sibling Reports tab pages the identical report through #2162's window. There was no bounded read available here at all.The card now takes a first page (50 rows) and says what it is a slice of. Deliberately not a "load more": the card is a preview inside a conversation, and the agent page is the reading surface — pointing there is more honest than paging a preview.
The create response's
addressed_towas always nullReportextendsReportSummary, so the field is inmodel_fields— the filter was silently dropping it becausedb.create_reportreturns the column under its DB nameaddressed_to_email. The same model therefore meant two different things depending on the route: populated onGET /reports/{id}, always null on create. The MCP tool papered over it by echoing back the caller's own argument. Mapped explicitly.Tests
test_ent365_report_audience.pygains: an admin who is not on the roster cannot be addressed; the publish gate is structurally the read gate; the create response reports the audience it stored. Existing stubs repointed fromdb.email_has_agent_accesstoagent_on_roster.test_portal_stamps_the_surface_on_its_executionsis generalised from a hard count of 2 to "one stamp per creation site", so adding the third is a decision rather than a broken test.Full frontend suite green (58 files / 1244). Backend: 676 passed across the report/portal slice.
Related to Abilityai/trinity-enterprise#365
🤖 Generated with Claude Code