CedarCopilot wants to merge 8 commits into staging from fix/sherlock-granola-routing-task-guards
Live on prod, no production signal yet
Strict CloudWatch fallback saw 13,970 success-shaped log lines matching calendar, meeting, meetings, but no tied operation was present, so Libra is not calling this working.
Libra has verdicts on 0 of 1 tracked behaviors on prod; 1 is still being checked. Libra checks hourly for 3 days after each deploy.
Two Sherlock investigations from 2026-08-27, both reported in Slack.
gong, fireflies, fathom and circleback all match a payload's participants against Cedar accounts before routing. granola did not: it authenticated a shared secret, then trusted an X-User-Id header naming the key's owner.
With a personal Granola key the key owner is also the attendee, so the difference never showed. With a workspace key it is not: every note the key can read arrives stamped with the key owner's id. The reporter accumulated ten deals for clients he had never met, and his own write-up called it exactly right ("Cedar has no attendee check").
The webhook was not the important half. process-meeting-background-sync.ts lists every meeting a connection's key can read and attributes all of them to that connection's owner, with no attendee check anywhere. Fixing only verifyGranolaRequest would have left the actual ingestion path untouched , this was caught reviewing the first version of this branch, not in the original diagnosis.
New services/integrations/meetings/attendee-routing.ts is the single answer to "whose meeting is this", used by both paths:
Both scope the lookup to the key owner's organization, so a shared workspace can never route across tenants. A workspace key now fans out correctly: every teammate who attended and has a Cedar account gets the meeting, whether or not they hold their own Granola connection.
When a payload carries no attendee emails at all, the sync keeps today's owner-attribution instead of dropping the meeting. Krisp routes on domains and some payloads arrive bare; skipping those would trade one silent data problem for another. Only a payload that has attendees and doesn't include the key owner gets re-routed or skipped.
One next-steps run marked two user-created tasks done and deleted a third 23ms apart, after judging the third "obsolete as a user action" because a counterparty said they would handle it. The user never asked for any of it.
cancelTaskTool already promised this protection in its own description ("Defaults to deleting only agent-created tasks") but enforced it only in bulk mode. The explicit-taskIds path had no such check, and updateTaskTool had none at all.
New tools/task/user-authored-task-guard.ts is the shared rule, applied to both.
The guard keys on "did a person ask for this in this turn", not on approvalMode , that axis files Slack/SMS/iMessage bots and external MCP as autonomous even though a human typed the request, and refusing those would be a new bug. What's left over is exactly the background tier that caused this one.
Scoped to status on updateTaskTool: enriching a user's task with a due date, notes or a draft stays allowed. Closing it is not the agent's call.
Refusals reuse each tool's existing "accepted but not written" shape (success: true, changed: false; protected ids returned in protectedTasks) so respecting the guard does not page #cedar-monitoring.
fellow.ts:108 and clari.ts:93 carry the identical header-only routing , both say so in their own comments ("Mirrors verifyGranolaRequest"). The reporter has a Fellow connection too, so he is still exposed through that provider.integrations.ts:2258 (manual sync) attributes to the requested user by the same pattern.Worth doing as a follow-up; kept out of here to keep the change reviewable.
tsc --noEmit cleanLibra has not measured any production surfaces for this change yet.