CedarCopilot wants to merge 1 commit into staging from fix/gate-tool-execute-instanceof-clean
Live on prod, no production signal yet
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.
gateToolExecute (tool-grant.ts) built its wrapped tool with a plain object spread:
return { ...tool, execute: wrapped } as T;
That produces a plain object, stripping whatever Tool class prototype the original tool had. Mastra's own dispatcher decides which calling convention to use for a tool based on exactly one check , tool instanceof Tool (isVercelTool in @mastra/core). A tool that fails that check gets invoked with (args, execOptions) instead of (args, toolContext), and only toolContext carries requestContext. So every one of the five gated alias tools this wrapper actively wraps (search-live-emails, read-live-email-thread, search-live-slack, read-live-slack-thread, save-draft) silently lost requestContext on every call , independent of what the grant check itself decided.
Confirmed empirically, not just from reading the code:
original instanceof Tool: true
wrapped instanceof Tool: false ā the bug
wrapped misclassified as Vercel tool: true
--- fix verification ---
fixed instanceof Tool: true
fixed misclassified as Vercel tool: false
Traced via Sherlock (2026-09-06) from two independent duplicate-draft investigations (Concentrate, Aspireiq), which led to search-live-emails/read-live-email-thread failing "Missing userId in request context." in production. Confirmed fleet-wide across ā„29 distinct users, multiple orgs , not account-specific. Ruled out several other hypotheses along the way (toolset-vs-base-tool delivery, a misconfigured connection scope, Mastra dispatching by function arity) before finding the actual mechanism in Mastra's own bundled CoreToolBuilder/isVercelTool source.
gateToolExecute now builds its wrapped tool via the same prototype-preserving Object.create + Object.defineProperty pattern instrumentToolExecute (tool-call-timing.ts) already used correctly for this exact reason.cloneToolWithExecute() helper both wrappers now call, so the two can't drift apart the way the plain-spread version drifted from this one.createTool()-produced Tool instance , the file's existing fakeTool() plain-object helper could never have caught this, since it was never instanceof Tool on either side of the fix.pnpm --dir apps/server exec vitest run src/mastra/tools/__tests__/tool-grant.test.ts , 26/26, including the new instanceof-preservation and refusal-still-works assertions.expected false to be true on the pre-fix code while every other test still passes, proving this specific assertion is what the bug breaks.pnpm --dir apps/server run types, pnpm deps:check, and the full tool-grant/family-tool-grant-enforcement/*.recorder-grant/drafting-executor-live-mail-grant test set (124 tests total) all pass.This closes the mechanism, not the whole investigation. Two related things traced in the same session are not covered here and are worth separate follow-ups:
drafter reaches for these same gated tools in production , sampled failures all trace back to drafter specifically, but that may just reflect which agent happens to call them today, not a structural immunity for others.š¤ Generated with Claude Code
https://claude.ai/code/session_01TdeWk6Xnh5bt6eRLfgvAk7
This PR fixes gated Mastra tools losing their Tool prototype and consolidates prototype-preserving execution wrapping into a shared helper.
gateToolExecute withLibra has not measured any production surfaces for this change yet.