Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(agents): the drafter's live-mail reach is bounded, and Bcc stays hidden

merged#2657CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/live-mail-tool-scope-and-bcc

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 2, 2026, 8:01 AM
  2. Merged
    Sep 2, 2026, 8:12 AM
  3. Live on prod
    Sep 2, 2026, 8:12 AM
  4. Observed 1 hour
    Sep 2, 2026, 8:12 AM
  5. Unobserved

    Live on prod, no production signal yet

    Sep 2, 2026, 8:12 AM
  6. Pipelines steady after this deploy
    Sep 2, 2026, 8:12 AM
  7. Sep 2, 2026, 8:12 AM

Behaviors Libra is checking

Document reads performed by an on-event drafter resolve the drafter's agent identity and organization when enforcing the files grant rather than inheriting an unscoped context.Not checked
prod
Post-event drafting runs as the resolved drafter within its organization so search-live-emails, read-live-email-thread, and fetch-email-attachments can access only the live-mail connection scope granted to that agent instead of the whole mailbox.Not checked
prod
A drafter executing an event workflow can update conversation fields owned by that same drafter while fields owned by other agents remain forbidden.Not checked
prod
Reading a live email thread returns visible To and Cc recipients but never exposes Bcc recipients, even when Gmail supplies a Bcc header on the user's sent message.Not checked
prod

Libra has verdicts on 0 of 4 tracked behaviors on prod; 4 are still being checked. Libra checks hourly for 3 days after each deploy.

Three review findings on the live-mail grant shipped in #2649. All three were valid.

1. The live-mail grant bypassed the agent's connection scope 🔴

run-post-event-executor resolves the drafter's canonical agentId, stamps it on the sub-execution row it creates, and then built the executor's RequestContext without it.

Nothing errored, because that is exactly what the gate's fail-open looks like:

  • agentIdForRun returns null for a context with no agentId , indistinguishable from a CLI run or a system job
  • resolveConnectionScopeForRun therefore degrades to inherit, which is correct: a missing answer must not read as a permissions boundary
  • inherit allows everything

So the two gated mail tools this executor is granted (search-live-emails, read-live-email-thread, plus fetch-email-attachments on the agent's own tool set) read the whole live mailbox however narrowly the drafter was scoped on its Connections screen. The permission looked enforced and was not.

Fix: set agentId on the sub-context, beside the ids it already carries, when a real drafter resolved. An executor running on defaults has no agent identity to enforce against, so it keeps inheriting.

Two other fences ride the same key and were also answering about nobody , both now correct:

  • the files: grant on read-document, same absent-means-inherit shape
  • field ownership in updateConversationFieldsTool → buildFieldPermissions. This one was failing closed: ownerId !== runningAgentId forbade every owned field, including a field owned by this very drafter. Naming the run fixes that in the direction the owner intended. This is a behaviour change beyond the reported bug and is called out here deliberately.

2. Historical Bcc recipients could become reply recipients 🔴

read-live-email-thread returned each message's bcc list as ordinary participant data, and its own description tells the model to reuse the returned recipient lists when asked to include everyone on a thread. draft-email merges an agent-chosen recipient into the visible To.

Gmail keeps the Bcc header only on the user's own sent messages , so the one case where the tool could return a bcc at all is the case where returning it does damage: the sender's deliberately hidden recipient, disclosed to the visible ones, in an email a human reviewer has no reason to question because the addresses came off the thread.

Nothing downstream can undo it , a recipient list carries no provenance by the time it reaches the draft , so it is enforced at the only place the distinction still exists: the tool stops emitting it. mail's read-thread action derives its payload from this schema, so it follows automatically.

3. The eval bypassed payload validation 🟡

live-thread-search-eval.ts read draft-email's recorded arguments through a hand-written cast, against the repo's no-cast rule. The shape is now declared once (DraftEmailArgs), used by the stub that validates it and parsed by the scoring that reads it. A changed payload stops the run instead of being scored through a stale type as an abstention , which is one of the answers this eval grades, so the failure would have been invisible.

Tests

  • never returns Bcc recipients, even when the message carries them , asserts the whole result, then that the address appears under no key, so a later edit folding it into to/cc fails too
  • allows that same call on a run carrying NO agent id , the fail-open, stated , the bug expressed as behaviour, paired with the existing refusal test
  • two call-site assertions that the executor sets agentId and orgId, source-read for the reason that file's header already documents

Verified

  • vitest run over the 5 affected files , 60 passed
  • pnpm --filter @zero/server run types , exit 0
  • pnpm --filter @zero/server run types:test , no errors in any touched file; the failures it reports are pre-existing in src/trpc/routes/__tests__/ (`
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/.claude/skills/playbook-instruction-eval/live-thread-search-eval.tsno production surface mapped
  • apps/server/src/mastra/tools/__tests__/drafting-executor-live-mail-grant.test.tsno production surface mapped
  • apps/server/src/mastra/tools/__tests__/tool-grant.test.tsno production surface mapped
  • apps/server/src/mastra/tools/event-execution/orchestrator-dispatch-tools.tsno production surface mapped
  • apps/server/src/mastra/tools/mail/__tests__/liveMailTools.test.tsno production surface mapped
  • apps/server/src/mastra/tools/mail/__tests__/mailTool.live-actions.test.tsno production surface mapped
  • apps/server/src/mastra/tools/mail/readLiveThreadTool.tsno production surface mapped