feat(workspace): route warehouse tools through the bound workspace's engine
Issue for this PR
Closes #1155
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
Stacked on #1154 — review that first; this PR is the commits on top.
When a bound workspace's engine attaches, the model gets two ways to do the same thing: the native warehouse tools over local keychain connections, and the engine's MCP tools over the workspace's SaaS connection. Nothing chose between them, so the model picked whichever description read better — and that pick decided which credentials ran the query and whether it was audited. Engine calls are audited server-side; native ones aren't.
This adds a per-session decision: shadow only what materialised and is attributable to the bound workspace; anything undetermined runs locally and says why; nothing is silent. Why it works:
- Materialised, not declared — it reads the engine tool keys actually present, so a declared-but-absent integration shadows nothing.
- Attributed — an IDE writes its entry unpinned, and such an engine serves whichever teammate is active there, changing at runtime. #1154 makes attach guarantee the pin; this re-checks it and refuses to shadow otherwise, reusing that PR's parser rather than writing a second one.
- Per capability, not per warehouse type — the integrations aren't symmetric. Snowflake has execute, explain and table stats; BigQuery and Postgres have execute only. Keying on the type would send an explain on a BigQuery connection to a tool that doesn't exist, and there is deliberately no fallback.
- Calls naming no warehouse are judged against the target they'd really reach, mirroring each handler's own resolution — and the registry connection behind dbt is weighed too, since dbt falls back there whenever it yields nothing.
- The redirect names the exact engine tool, executes nothing, and is marked in metadata so telemetry can tell it from a real execution.
- It runs after every native safety check — the hard deny on destructive statements and the write confirmation both guard things the engine side has no equivalent for.
--integrations=localturns it off for a session.
Two deliberate deviations: the guard needs a companion call to attach the fail-open notice, which a pre-execution check can't do; and I left an adjacent warehouse-type reporting bug alone, since fixing it changes a shipped telemetry field.
How did you verify your code works?
bun run typecheck and the workspace test suites pass; lint adds no new errors. 250 unit tests, including each decision rather than just the happy path.
End-to-end against a live workspace, engine serving 12 tools:
| Scenario | Outcome |
|---|---|
| Shadow marking | The served connection is marked per capability; local DuckDB rows unmarked |
| Redirect, nothing executed | Redirect returned, and no matching row in query history — proven with a positive control: the marker from a run that followed the redirect appears, the one that stopped at it does not |
| Control untouched | DuckDB connection runs normally |
| Default target | No warehouse named → resolved the default and redirected, naming it |
| Model behaviour | Unprompted, the model followed the redirect and ran the query through the engine tool |
| Write confirmation | An INSERT against the served connection stops at the permission gate, with no redirect |
| Escape hatch | --integrations=local → plain listing, local execution |
Not exercised: the asymmetry case needs a workspace serving only some capabilities (unit-tested instead); the dbt default path needs a bound dbt project; de-materialisation is held until other testing here finishes.
Automated review: four rounds plus one routed in, six findings, all real, all fixed — details in the Codex review log.
GitGuardian flags a masked placeholder in a help-text snapshot that only moved columns; it is present unchanged on the base commit and is not a credential.
Screenshots / recordings
n/a — CLI change, no UI.
Residuals
Shadowing turns itself off and the call runs locally. All fail open, all in the attach
machinery rather than in this change:
- Attach re-validation window — no attribution while a session's memo re-validates.
Being addressed in the attach layer. - Session-cache eviction — beyond 256 tracked sessions the oldest is dropped and
re-derived on the next call. Bounded and deliberate. - Cross-session teardown — a redirect can name a client another session has removed.
A tool-layer failure, not a query sent to the wrong warehouse.
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR