From 3d06747f9f9f56484a258af8902cb6ed125ad492 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 21:34:14 +0000 Subject: [PATCH 01/14] Add dbt hosted MCP integration preset for organizations --- .changeset/add-dbt-mcp-preset.md | 5 +++ .../resources/mcp-integration-catalog.spec.ts | 41 ++++++++++++++++++- .../resources/mcp-integration-catalog.ts | 31 ++++++++++++++ .../client/resources/mcp-integration-logos.ts | 4 ++ .../core/src/localization/default-messages.ts | 7 ++++ 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-dbt-mcp-preset.md diff --git a/.changeset/add-dbt-mcp-preset.md b/.changeset/add-dbt-mcp-preset.md new file mode 100644 index 00000000000..ad78667b508 --- /dev/null +++ b/.changeset/add-dbt-mcp-preset.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add a dbt hosted MCP integration preset with organization-scoped manual setup for account-specific endpoints and service-token headers. diff --git a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts index ef6938da081..c2a9d5e631a 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts @@ -102,6 +102,43 @@ describe("MCP integration catalog", () => { ).toEqual(["sentry"]); }); + it("registers an editable organization-scoped dbt hosted MCP preset", () => { + const dbt = DEFAULT_MCP_INTEGRATIONS.find( + (integration) => integration.id === "dbt", + )!; + + for (const query of ["dbt", "MetricFlow", "Semantic Layer", "lineage"]) { + expect(filterMcpIntegrations(query).map((item) => item.id)).toContain( + "dbt", + ); + } + expect(dbt).toMatchObject({ + url: "https:///api/ai/v1/mcp/", + authMode: "headers", + connectionMode: "manual", + availability: "provider-setup", + verification: "restricted", + supportsOrganizationScope: true, + setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", + headerPlaceholder: + "Authorization: Token \nx-dbt-prod-environment-id: ", + }); + expect(dbt.docsUrl).toBeUndefined(); + expect(createMcpIntegrationFormDefaults(dbt)).toEqual({ + name: "dbt", + url: "https:///api/ai/v1/mcp/", + description: "Explore governed dbt metadata, lineage, and metrics.", + headersText: "", + }); + expect(supportsMcpIntegrationOrganizationScope(dbt)).toBe(true); + expect(shouldOfferMcpIntegrationOrganizationScope(dbt, true, true)).toBe( + true, + ); + expect(shouldOfferMcpIntegrationOrganizationScope(dbt, true, false)).toBe( + false, + ); + }); + it("prefills form values from a selected preset without fabricating headers", () => { const sentry = DEFAULT_MCP_INTEGRATIONS.find( (integration) => integration.id === "sentry", @@ -177,11 +214,11 @@ describe("MCP integration catalog", () => { }); expect(getMcpIntegrationApiFallback(figma, "analytics")).toBeNull(); expect(getMcpIntegrationApiFallback(figma, null)).toBeNull(); - expect(DEFAULT_MCP_INTEGRATIONS).toHaveLength(35); + expect(DEFAULT_MCP_INTEGRATIONS).toHaveLength(36); expect( new Set(DEFAULT_MCP_INTEGRATIONS.map((integration) => integration.id)) .size, - ).toBe(35); + ).toBe(36); for (const integration of DEFAULT_MCP_INTEGRATIONS) { expect(integration.logoUrl).toMatch( /^data:image\/(?:png|svg\+xml|x-icon|vnd\.microsoft\.icon)(?:;base64,|,)/, diff --git a/packages/core/src/client/resources/mcp-integration-catalog.ts b/packages/core/src/client/resources/mcp-integration-catalog.ts index 2c3fbe143f7..24a5ce83aff 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.ts @@ -478,6 +478,36 @@ export const DEFAULT_MCP_INTEGRATIONS: DefaultMcpIntegration[] = [ "Grafana Cloud", ], }, + { + id: "dbt", + name: "dbt", + provider: "dbt", + description: "Explore governed dbt metadata, lineage, and metrics.", + descriptionKey: "mcpIntegrations.catalog.dbt.description", + useCase: + "data models, sources, lineage, model health, Semantic Layer, MetricFlow", + useCaseKey: "mcpIntegrations.catalog.dbt.useCase", + url: "https:///api/ai/v1/mcp/", + authMode: "headers", + connectionMode: "manual", + availability: "provider-setup", + verification: "restricted", + logoUrl: mcpIntegrationLogo("dbt"), + supportsOrganizationScope: true, + setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", + headerPlaceholder: + "Authorization: Token \nx-dbt-prod-environment-id: ", + aliases: ["MetricFlow", "Semantic Layer", "lineage"], + keywords: [ + "dbt", + "MetricFlow", + "Semantic Layer", + "lineage", + "models", + "sources", + "metrics", + ], + }, { id: "gitlab", name: "GitLab", @@ -1027,6 +1057,7 @@ const MCP_LINK_HOSTS: Record = { sentry: ["sentry.io", "sentry.dev"], gong: ["gong.io"], grafana: ["grafana.com", "grafana.net"], + dbt: ["getdbt.com"], "builder-cms": ["builder.io"], notion: ["notion.so", "notion.site"], granola: ["granola.ai"], diff --git a/packages/core/src/client/resources/mcp-integration-logos.ts b/packages/core/src/client/resources/mcp-integration-logos.ts index 3b7a5e9dae1..80c41092766 100644 --- a/packages/core/src/client/resources/mcp-integration-logos.ts +++ b/packages/core/src/client/resources/mcp-integration-logos.ts @@ -1,4 +1,8 @@ const LOGOS = { + dbt: { + mime: "image/x-icon", + data: "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", + }, context7: { mime: "image/vnd.microsoft.icon", data: "iVBORw0KGgoAAAANSUhEUgAAAEoAAABLCAYAAADXjBHUAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaWlDQ1BEaXNwbGF5IFAzAAB4nHWQvUvDUBTFT6tS0DqIDh0cMolD1NIKdnFoKxRFMFQFq1OafgltfCQpUnETVyn4H1jBWXCwiFRwcXAQRAcR3Zw6KbhoeN6XVNoi3sfl/Ticc7lcwBtQGSv2AijplpFMxKS11Lrke4OHnlOqZrKooiwK/v276/PR9d5PiFlNu3YQ2U9cl84ul3aeAlN//V3Vn8maGv3f1EGNGRbgkYmVbYsJ3iUeMWgp4qrgvMvHgtMunzuelWSc+JZY0gpqhrhJLKc79HwHl4plrbWD2N6f1VeXxRzqUcxhEyYYilBRgQQF4X/8044/ji1yV2BQLo8CLMpESRETssTz0KFhEjJxCEHqkLhz634PrfvJbW3vFZhtcM4v2tpCAzidoZPV29p4BBgaAG7qTDVUR+qh9uZywPsJMJgChu8os2HmwiF3e38M6Hvh/GMM8B0CdpXzryPO7RqFn4Er/QcXKWq8MSlPPgAABA5JREFUeAHtmzFME2EUgB+GQXCgGCSBpeiICRjERROKoYtG4kANC6kDasLCAgNLO+BcBxgYxIHiZhkgRJcahcgmJB1k1HaBBBLtomHTezXVu/+u9N3de03Q9yUM/cPlb7/+79279/9tap4bfwdKXZqtv2FQ6nIOFBIqioiKIqKiiKgoIiqKiIoioqKIqCgiKoqIiiKioog0Q0hil3th+cEU+GX78z5M5pZ8XZObmIX+7ij4JbGagcJhEcIQWhTS034J/FIKcE2kpTXQXHhdWDT0iKgoIqFDr3BYguK3Y0dIlKzXK7tbp16H/+OX7N4WbFm57TSS12Ou9xJkLpPQoson3+GRlZTzj9N/xqLWGy2f/IDFndfASbaO/NRIwpXDxl5mKl9kWFhCD7/lmc2sY+zZvSTErvRCo+jv7oF0POEYe/o2B4WDInDAlqMWrNVjhsWLxBREzl8AaXCO3MSMY2x9/yPM53PABWsyx7oIQ64KhmAqPgbS4BxmyM1urgAnrKIwaS58cOal6Vt3ob+rB6TALwPnsIMhx5GX7LCXB4s7bxyrCslY+UqK9EjCNVYv6QeBXRTeBbO77x1jmNQlVhWuJiwH7KAk7tWEiBScG1YiNUkODAE3nqtpj381ISKiCgclV/glB4eBmyGP8qNeQRoUEVEYfqWvR46xyPlW1roKuxbmnU5KEiL2rIePNiZ9jHmqzyowTUoCuamKmCivhHqty38vqRa4oihzciEmqlR2v+kgTbdaePWYzuSK8oLzcSYaoIEXhoaKamvhExVpkX+GtNPUPDf+E5S6aIeTiIoioqKIqCgiKooIywaoF1jneFXPXE/3yYGYa2z7y75YdS4mCh+AsWduBytnLlGpuHvHZfLVkiXqDLVZ/kXERLV5PK5whgX2vFxzClbrYqJ62jtcY1x7bEipfOSeM9IBUoiJikY6XWOYbLnw6ndFL3aCFGKivFoqXh8uKBuf3H15zn6XiYgoLA282rScOQrbzWbrtzJvRKb9IiLKqzcusde27rHbM3r1BkggImr65h3Ha876yc6qJd/c7bnfOwgSsIvCarzfaPxzHpawg+FnbuHjapY4RcMuytw+l1pNVXAL38x9KY+N0bCwisLt7UatpirVg2x2cEV5PQuGgU1U5YiP8U3iiTvJ1VQF737m6b7M6MNAJ4hrwSZqbWLGdXZyPr8GjQLnsocg7kwvJ/yff68FiyhcSfaQQ0nx5/OVsGgUONfvOf/eBTEEuY4chd6Fwbtc/kna93UYLvjB/IAHaoPc0QYX5kL/cuG/aLPoLxcaiIoiEroVXCwfB6qVpH65wDWXiW6pE9HQI6KiiKgoIiqKiIoioqKIqCgiKoqIiiKiooioKCIqiojVPWi6DUpdfgE8wFukKedIwQAAAABJRU5ErkJggg==", diff --git a/packages/core/src/localization/default-messages.ts b/packages/core/src/localization/default-messages.ts index 8ab7edcb961..3eb53abb8a6 100644 --- a/packages/core/src/localization/default-messages.ts +++ b/packages/core/src/localization/default-messages.ts @@ -1363,6 +1363,13 @@ const messages = { setupNote: "Grafana Cloud MCP is in public preview and requires Grafana Cloud Assistant MCP access. It is hosted Grafana Cloud only; self-hosted Grafana needs the local MCP server.", }, + dbt: { + description: "Explore governed dbt metadata, lineage, and metrics.", + useCase: + "Data models, sources, lineage, model health, Semantic Layer, MetricFlow", + setupNote: + "A workspace owner or admin must copy the account-specific MCP Endpoint URL from dbt Account settings, then add a dbt service token and production environment ID. This organization connection uses one shared dbt service identity, so access and dbt-side auditing are workspace-level rather than per-user.", + }, gitlab: { description: "Read and manage GitLab projects, issues, and merge requests.", From 10eda3b80ea81d84429ab8e6162015345de782f7 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 21:36:47 +0000 Subject: [PATCH 02/14] Add dbt MCP integration documentation link --- .../core/src/client/resources/mcp-integration-catalog.spec.ts | 2 +- packages/core/src/client/resources/mcp-integration-catalog.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts index c2a9d5e631a..94e5d11ae48 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts @@ -119,11 +119,11 @@ describe("MCP integration catalog", () => { availability: "provider-setup", verification: "restricted", supportsOrganizationScope: true, + docsUrl: "https://docs.getdbt.com/docs/dbt-cloud-apis/mcp-server", setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", headerPlaceholder: "Authorization: Token \nx-dbt-prod-environment-id: ", }); - expect(dbt.docsUrl).toBeUndefined(); expect(createMcpIntegrationFormDefaults(dbt)).toEqual({ name: "dbt", url: "https:///api/ai/v1/mcp/", diff --git a/packages/core/src/client/resources/mcp-integration-catalog.ts b/packages/core/src/client/resources/mcp-integration-catalog.ts index 24a5ce83aff..6baf9407349 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.ts @@ -494,6 +494,7 @@ export const DEFAULT_MCP_INTEGRATIONS: DefaultMcpIntegration[] = [ verification: "restricted", logoUrl: mcpIntegrationLogo("dbt"), supportsOrganizationScope: true, + docsUrl: "https://docs.getdbt.com/docs/dbt-cloud-apis/mcp-server", setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", headerPlaceholder: "Authorization: Token \nx-dbt-prod-environment-id: ", From b0d875dcf71cde3ef213da9fe710883ee76615f7 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 21:37:59 +0000 Subject: [PATCH 03/14] Update dbt MCP integration documentation URL --- .../core/src/client/resources/mcp-integration-catalog.spec.ts | 2 +- packages/core/src/client/resources/mcp-integration-catalog.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts index 94e5d11ae48..72256c7bbdf 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts @@ -119,7 +119,7 @@ describe("MCP integration catalog", () => { availability: "provider-setup", verification: "restricted", supportsOrganizationScope: true, - docsUrl: "https://docs.getdbt.com/docs/dbt-cloud-apis/mcp-server", + docsUrl: "https://docs.getdbt.com/docs/dbt-ai/mcp-quickstart-remote", setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", headerPlaceholder: "Authorization: Token \nx-dbt-prod-environment-id: ", diff --git a/packages/core/src/client/resources/mcp-integration-catalog.ts b/packages/core/src/client/resources/mcp-integration-catalog.ts index 6baf9407349..1c49e73e334 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.ts @@ -494,7 +494,7 @@ export const DEFAULT_MCP_INTEGRATIONS: DefaultMcpIntegration[] = [ verification: "restricted", logoUrl: mcpIntegrationLogo("dbt"), supportsOrganizationScope: true, - docsUrl: "https://docs.getdbt.com/docs/dbt-cloud-apis/mcp-server", + docsUrl: "https://docs.getdbt.com/docs/dbt-ai/mcp-quickstart-remote", setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", headerPlaceholder: "Authorization: Token \nx-dbt-prod-environment-id: ", From 850ee821be5aedda11940700fe6dd4101db95dca Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 21:43:44 +0000 Subject: [PATCH 04/14] Expose dbt MCP capabilities in analytics data sources --- .../analytics/actions/data-source-status.ts | 23 ++- .../analytics/app/lib/data-source-status.ts | 41 ++++++ .../server/lib/dbt-mcp-status.spec.ts | 117 +++++++++++++++ .../analytics/server/lib/dbt-mcp-status.ts | 138 ++++++++++++++++++ 4 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 templates/analytics/server/lib/dbt-mcp-status.spec.ts create mode 100644 templates/analytics/server/lib/dbt-mcp-status.ts diff --git a/templates/analytics/actions/data-source-status.ts b/templates/analytics/actions/data-source-status.ts index b15cfd325a7..363c453cb16 100644 --- a/templates/analytics/actions/data-source-status.ts +++ b/templates/analytics/actions/data-source-status.ts @@ -19,6 +19,7 @@ import { } from "../server/lib/credential-keys"; import { hasCredential } from "../server/lib/credentials"; import { tryRequestCredentialContext } from "../server/lib/credentials-context"; +import { readDbtMcpStatus } from "../server/lib/dbt-mcp-status"; import { getGitHubAccessToken } from "../server/lib/github-oauth"; import { resolveAnalyticsProviderCredential } from "../server/lib/provider-credentials"; @@ -86,7 +87,7 @@ async function listWorkspaceConnectionsForStatus(): Promise<{ export default defineAction({ description: - "List which analytics data sources are available without revealing secret values. This always includes the built-in first-party Analytics event store, which is queried with `query-agent-native-analytics`; it also reports configured credentials and granted workspace connections. The result includes `hasConnectedExternalDataSources`, `connectedExternalDataSourceCount`, and `dataSourcesSetupLink`; each provider also includes a focused `setupLink`. When a requested provider is unavailable, use its focused link for contextual setup guidance. The `key` arg accepts exact credential names like JIRA_API_TOKEN and provider aliases like jira, pylon, bigquery, github, hubspot, gong, or slack.", + "List which analytics data sources are available without revealing secret values. This always includes the built-in first-party Analytics event store, which is queried with `query-agent-native-analytics`; it also reports configured credentials, granted workspace connections, and the authenticated dbt MCP capability status. The result includes `hasConnectedExternalDataSources`, `connectedExternalDataSourceCount`, and `dataSourcesSetupLink`; each credential provider also includes a focused `setupLink`. When a requested provider is unavailable, use its focused link for contextual setup guidance. The `key` arg accepts exact credential names like JIRA_API_TOKEN and provider aliases like dbt, jira, pylon, bigquery, github, hubspot, gong, or slack.", schema: z.object({ key: z .string() @@ -106,12 +107,18 @@ export default defineAction({ }); } - const { configs, known } = resolveCredentialConfigs(args.key); + const requestedDbt = args.key?.trim().toLowerCase() === "dbt"; + const { configs, known } = requestedDbt + ? { configs: [], known: true } + : resolveCredentialConfigs(args.key); if (args.key && !known) { return { error: `Unknown credential key: ${args.key}` }; } - const workspace = await listWorkspaceConnectionsForStatus(); + const [workspace, dbt] = await Promise.all([ + listWorkspaceConnectionsForStatus(), + readDbtMcpStatus(), + ]); const workspaceCatalog = listWorkspaceConnectionProvidersForTemplate(APP_ID); const workspaceProviderIds = [ @@ -228,6 +235,15 @@ export default defineAction({ via: "built-in", queryAction: BUILT_IN_FIRST_PARTY_PROVIDER.queryAction, }, + ...(dbt.configured + ? [ + { + provider: "dbt", + label: "dbt", + via: "mcp", + }, + ] + : []), ...providers .filter((provider) => provider.configured) .map((provider) => ({ @@ -262,6 +278,7 @@ export default defineAction({ : false, connectedExternalDataSourceCount: connectedExternalDataSources.length, dataSourcesSetupLink: DATA_SOURCES_SETUP_LINK, + dbt, credentials: results, providers: [BUILT_IN_FIRST_PARTY_PROVIDER, ...providers], total: results.length, diff --git a/templates/analytics/app/lib/data-source-status.ts b/templates/analytics/app/lib/data-source-status.ts index d1c030d03ad..0fe7c8971c4 100644 --- a/templates/analytics/app/lib/data-source-status.ts +++ b/templates/analytics/app/lib/data-source-status.ts @@ -27,6 +27,37 @@ export interface WorkspaceConnectionProviderSummary { hasActiveWorkspaceConnection: boolean; } +export interface DbtMcpStatus { + available: boolean; + error?: string; + configured: boolean | null; + serverId?: string; + capabilities: { + discovery: boolean; + lineage: boolean; + healthAndFreshness: boolean; + semanticLayer: boolean; + }; + sqlTools: { + available: boolean; + intentionallyUnused: true; + }; + toolCount: number; + setupLink: "/data-sources?source=dbt"; +} + +export interface ConfiguredDataSourceSummary { + provider: string; + label: string; + via: + | "built-in" + | "credentials" + | "workspace" + | "credentials-and-workspace" + | "mcp"; + queryAction?: string; +} + export interface DataSourceProviderStatus { provider: string; label: string; @@ -40,6 +71,13 @@ export interface DataSourceProviderStatus { } export interface DataSourceStatusResponse { + hasConfiguredDataSources?: boolean; + configuredDataSourceCount?: number; + configuredDataSources?: ConfiguredDataSourceSummary[]; + hasConnectedExternalDataSources?: boolean | null; + connectedExternalDataSourceCount?: number; + dataSourcesSetupLink?: string; + dbt?: DbtMcpStatus; credentials?: EnvKeyStatus[]; providers?: DataSourceProviderStatus[]; workspaceConnections?: { @@ -203,6 +241,8 @@ export function isSourceConfigured( source: DataSource, envStatus: EnvKeyStatus[], ): boolean { + if (source.id === "dbt") return false; + const statusMap = new Map( envStatus.map((s) => [normalizeCredentialKey(s.key), s.configured]), ); @@ -299,6 +339,7 @@ export function isSourceReady( envStatus: EnvKeyStatus[], ): boolean { return ( + (source.id === "dbt" && data?.dbt?.configured === true) || isSourceConfigured(source, envStatus) || getProviderStatusForSource(source, data)?.configured === true || getSharedConnectionStatus(source, data, envStatus)?.kind === "ready" diff --git a/templates/analytics/server/lib/dbt-mcp-status.spec.ts b/templates/analytics/server/lib/dbt-mcp-status.spec.ts new file mode 100644 index 00000000000..099c3d5b2c0 --- /dev/null +++ b/templates/analytics/server/lib/dbt-mcp-status.spec.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const listVisibleMcpTools = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/mcp-client", () => ({ + listVisibleMcpTools, +})); + +const { readDbtMcpStatus } = await import("./dbt-mcp-status"); + +const fullContractNames = [ + "get_all_models", + "get_all_sources", + "get_node_details", + "get_related_models", + "get_mart_models", + "get_lineage", + "get_model_health", + "get_model_performance", + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + "list_saved_queries", + "execute_sql", + "text_to_sql", +]; + +function tool(name: string, serverId = "org-dbt") { + return { + serverId, + name, + description: `${name} description`, + inputSchema: { type: "object", secret: "must not be projected" }, + }; +} + +describe("readDbtMcpStatus", () => { + beforeEach(() => { + listVisibleMcpTools.mockReset(); + }); + + it("projects the full official dbt capability contract", async () => { + listVisibleMcpTools.mockResolvedValue( + fullContractNames.map((name) => tool(name)), + ); + + await expect(readDbtMcpStatus()).resolves.toEqual({ + available: true, + configured: true, + serverId: "org-dbt", + capabilities: { + discovery: true, + lineage: true, + healthAndFreshness: true, + semanticLayer: true, + }, + sqlTools: { + available: true, + intentionallyUnused: true, + }, + toolCount: fullContractNames.length, + setupLink: "/data-sources?source=dbt", + }); + }); + + it("reports discovery-only dbt without treating SQL tools as a capability", async () => { + listVisibleMcpTools.mockResolvedValue([ + tool("get_all_models", "dbt-discovery"), + tool("execute_sql", "dbt-discovery"), + tool("unrelated_tool", "dbt-discovery"), + ]); + + await expect(readDbtMcpStatus()).resolves.toEqual({ + available: true, + configured: true, + serverId: "dbt-discovery", + capabilities: { + discovery: true, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: true, + intentionallyUnused: true, + }, + toolCount: 2, + setupLink: "/data-sources?source=dbt", + }); + }); + + it("reports a successful empty list as disconnected", async () => { + listVisibleMcpTools.mockResolvedValue([]); + + await expect(readDbtMcpStatus()).resolves.toMatchObject({ + available: true, + configured: false, + toolCount: 0, + }); + }); + + it("reports manager or list failure as unreadable rather than disconnected", async () => { + listVisibleMcpTools.mockRejectedValue( + new Error("MCP client is not configured."), + ); + + await expect(readDbtMcpStatus()).resolves.toMatchObject({ + available: false, + configured: null, + error: "MCP client is not configured.", + toolCount: 0, + }); + }); +}); diff --git a/templates/analytics/server/lib/dbt-mcp-status.ts b/templates/analytics/server/lib/dbt-mcp-status.ts new file mode 100644 index 00000000000..49cf9ff5893 --- /dev/null +++ b/templates/analytics/server/lib/dbt-mcp-status.ts @@ -0,0 +1,138 @@ +import { + listVisibleMcpTools, + type AppMcpTool, +} from "@agent-native/core/mcp-client"; + +const DBT_DISCOVERY_TOOLS = new Set([ + "get_all_models", + "get_all_sources", + "get_node_details", + "get_related_models", + "get_mart_models", +]); +const DBT_LINEAGE_TOOLS = new Set(["get_lineage"]); +const DBT_HEALTH_TOOLS = new Set([ + "get_model_health", + "get_model_performance", + "get_all_sources", +]); +const DBT_SEMANTIC_LAYER_TOOLS = new Set([ + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + "list_saved_queries", +]); +const DBT_SQL_TOOLS = new Set(["execute_sql", "text_to_sql"]); +const DBT_CAPABILITY_TOOLS = new Set([ + ...DBT_DISCOVERY_TOOLS, + ...DBT_LINEAGE_TOOLS, + ...DBT_HEALTH_TOOLS, + ...DBT_SEMANTIC_LAYER_TOOLS, +]); +const DBT_CONTRACT_TOOLS = new Set([...DBT_CAPABILITY_TOOLS, ...DBT_SQL_TOOLS]); + +export interface DbtMcpStatus { + available: boolean; + error?: string; + configured: boolean | null; + serverId?: string; + capabilities: { + discovery: boolean; + lineage: boolean; + healthAndFreshness: boolean; + semanticLayer: boolean; + }; + sqlTools: { + available: boolean; + intentionallyUnused: true; + }; + toolCount: number; + setupLink: "/data-sources?source=dbt"; +} + +function emptyStatus(configured: false | null, error?: string): DbtMcpStatus { + return { + available: !error, + ...(error ? { error } : {}), + configured, + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: false, + intentionallyUnused: true, + }, + toolCount: 0, + setupLink: "/data-sources?source=dbt", + }; +} + +function selectDbtServer(tools: AppMcpTool[]): AppMcpTool[] { + const byServer = new Map(); + for (const tool of tools) { + if (!DBT_CONTRACT_TOOLS.has(tool.name)) continue; + const current = byServer.get(tool.serverId) ?? []; + current.push(tool); + byServer.set(tool.serverId, current); + } + + return ( + [...byServer.values()] + .filter((serverTools) => + serverTools.some((tool) => DBT_CAPABILITY_TOOLS.has(tool.name)), + ) + .sort((left, right) => { + const capabilityDifference = + right.filter((tool) => DBT_CAPABILITY_TOOLS.has(tool.name)).length - + left.filter((tool) => DBT_CAPABILITY_TOOLS.has(tool.name)).length; + return ( + capabilityDifference || + left[0].serverId.localeCompare(right[0].serverId) + ); + })[0] ?? [] + ); +} + +export async function readDbtMcpStatus(): Promise { + let tools: AppMcpTool[]; + try { + tools = await listVisibleMcpTools(); + } catch (error) { + return emptyStatus( + null, + error instanceof Error ? error.message : String(error), + ); + } + + const dbtTools = selectDbtServer(tools); + if (dbtTools.length === 0) return emptyStatus(false); + + const names = new Set(dbtTools.map((tool) => tool.name)); + return { + available: true, + configured: true, + serverId: dbtTools[0].serverId, + capabilities: { + discovery: dbtTools.some((tool) => DBT_DISCOVERY_TOOLS.has(tool.name)), + lineage: dbtTools.some((tool) => DBT_LINEAGE_TOOLS.has(tool.name)), + healthAndFreshness: dbtTools.some((tool) => + DBT_HEALTH_TOOLS.has(tool.name), + ), + semanticLayer: dbtTools.some((tool) => + DBT_SEMANTIC_LAYER_TOOLS.has(tool.name), + ), + }, + sqlTools: { + available: [...DBT_SQL_TOOLS].some((name) => names.has(name)), + intentionallyUnused: true, + }, + toolCount: dbtTools.length, + setupLink: "/data-sources?source=dbt", + }; +} From 86942f54b7e9dfb82a958f3805c3aa05bc97f9c7 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 21:45:47 +0000 Subject: [PATCH 05/14] Track unreadable dbt MCP status and preserve setup return path --- .../actions/data-source-status.spec.ts | 55 +++++++++++++++++++ .../analytics/actions/data-source-status.ts | 6 +- .../analytics/app/lib/data-source-status.ts | 2 +- .../server/lib/dbt-mcp-status.spec.ts | 4 +- .../analytics/server/lib/dbt-mcp-status.ts | 6 +- 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/templates/analytics/actions/data-source-status.spec.ts b/templates/analytics/actions/data-source-status.spec.ts index c496a169a0c..6079a423f0d 100644 --- a/templates/analytics/actions/data-source-status.spec.ts +++ b/templates/analytics/actions/data-source-status.spec.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ getGitHubAccessToken: vi.fn(), tryRequestCredentialContext: vi.fn(), resolveAnalyticsProviderCredential: vi.fn(), + readDbtMcpStatus: vi.fn(), workspaceSummary: { appId: "analytics", provider: "hubspot", @@ -71,6 +72,10 @@ vi.mock("../server/lib/credentials-context", () => ({ tryRequestCredentialContext: mocks.tryRequestCredentialContext, })); +vi.mock("../server/lib/dbt-mcp-status", () => ({ + readDbtMcpStatus: mocks.readDbtMcpStatus, +})); + vi.mock("../server/lib/github-oauth", () => ({ getGitHubAccessToken: mocks.getGitHubAccessToken, })); @@ -87,6 +92,23 @@ describe("data-source-status", () => { mocks.getGitHubAccessToken.mockReset(); mocks.tryRequestCredentialContext.mockReset(); mocks.resolveAnalyticsProviderCredential.mockReset(); + mocks.readDbtMcpStatus.mockReset(); + mocks.readDbtMcpStatus.mockResolvedValue({ + available: true, + configured: false, + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: false, + intentionallyUnused: true, + }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); mocks.tryRequestCredentialContext.mockReturnValue({ userEmail: "ada@example.com", orgId: "org-1", @@ -141,6 +163,39 @@ describe("data-source-status", () => { }); }); + it("keeps external source status unknown when dbt MCP tools are unreadable", async () => { + mocks.readDbtMcpStatus.mockResolvedValue({ + available: false, + configured: null, + error: "MCP client is not configured.", + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: false, + intentionallyUnused: true, + }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + + const result = (await dataSourceStatus.run({ key: "dbt" })) as any; + + expect(result).toMatchObject({ + hasConnectedExternalDataSources: null, + connectedExternalDataSourceCount: 0, + dbt: { + available: false, + configured: null, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }, + }); + expect(result.error).toBeUndefined(); + }); + it("uses the canonical PostgreSQL source id in focused setup links", async () => { const result = (await dataSourceStatus.run({ key: "postgres" })) as any; const postgresql = result.providers.find( diff --git a/templates/analytics/actions/data-source-status.ts b/templates/analytics/actions/data-source-status.ts index 363c453cb16..61876c0cf64 100644 --- a/templates/analytics/actions/data-source-status.ts +++ b/templates/analytics/actions/data-source-status.ts @@ -261,9 +261,9 @@ export default defineAction({ const connectedExternalDataSources = configuredDataSources.filter( (source) => source.provider !== BUILT_IN_FIRST_PARTY_PROVIDER.provider, ); - const hasUnknownProviderStatus = providers.some( - (provider) => provider.configured === null, - ); + const hasUnknownProviderStatus = + dbt.configured === null || + providers.some((provider) => provider.configured === null); return { // Keep a compact, explicit summary first so models do not infer source // availability from the much larger per-credential list below. diff --git a/templates/analytics/app/lib/data-source-status.ts b/templates/analytics/app/lib/data-source-status.ts index 0fe7c8971c4..e7804beeb3f 100644 --- a/templates/analytics/app/lib/data-source-status.ts +++ b/templates/analytics/app/lib/data-source-status.ts @@ -43,7 +43,7 @@ export interface DbtMcpStatus { intentionallyUnused: true; }; toolCount: number; - setupLink: "/data-sources?source=dbt"; + setupLink: "/data-sources?source=dbt&returnTo=ask"; } export interface ConfiguredDataSourceSummary { diff --git a/templates/analytics/server/lib/dbt-mcp-status.spec.ts b/templates/analytics/server/lib/dbt-mcp-status.spec.ts index 099c3d5b2c0..b5d79c9b891 100644 --- a/templates/analytics/server/lib/dbt-mcp-status.spec.ts +++ b/templates/analytics/server/lib/dbt-mcp-status.spec.ts @@ -62,7 +62,7 @@ describe("readDbtMcpStatus", () => { intentionallyUnused: true, }, toolCount: fullContractNames.length, - setupLink: "/data-sources?source=dbt", + setupLink: "/data-sources?source=dbt&returnTo=ask", }); }); @@ -88,7 +88,7 @@ describe("readDbtMcpStatus", () => { intentionallyUnused: true, }, toolCount: 2, - setupLink: "/data-sources?source=dbt", + setupLink: "/data-sources?source=dbt&returnTo=ask", }); }); diff --git a/templates/analytics/server/lib/dbt-mcp-status.ts b/templates/analytics/server/lib/dbt-mcp-status.ts index 49cf9ff5893..38586ffd000 100644 --- a/templates/analytics/server/lib/dbt-mcp-status.ts +++ b/templates/analytics/server/lib/dbt-mcp-status.ts @@ -50,7 +50,7 @@ export interface DbtMcpStatus { intentionallyUnused: true; }; toolCount: number; - setupLink: "/data-sources?source=dbt"; + setupLink: "/data-sources?source=dbt&returnTo=ask"; } function emptyStatus(configured: false | null, error?: string): DbtMcpStatus { @@ -69,7 +69,7 @@ function emptyStatus(configured: false | null, error?: string): DbtMcpStatus { intentionallyUnused: true, }, toolCount: 0, - setupLink: "/data-sources?source=dbt", + setupLink: "/data-sources?source=dbt&returnTo=ask", }; } @@ -133,6 +133,6 @@ export async function readDbtMcpStatus(): Promise { intentionallyUnused: true, }, toolCount: dbtTools.length, - setupLink: "/data-sources?source=dbt", + setupLink: "/data-sources?source=dbt&returnTo=ask", }; } From 8fefa3bef3ced25a9a840a8d04c3d84e2a3bdc7b Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 21:56:57 +0000 Subject: [PATCH 06/14] Add shared dbt MCP connection card to analytics --- .../data-sources/DbtMcpDataSourceCard.tsx | 210 ++++++++++++++++++ templates/analytics/app/i18n-data.ts | 35 +++ templates/analytics/app/i18n/zh-TW.ts | 3 + .../app/lib/data-source-status.spec.ts | 24 ++ .../analytics/app/lib/data-source-status.ts | 3 +- templates/analytics/app/lib/data-sources.ts | 23 +- templates/analytics/app/pages/DataSources.tsx | 150 ++++++++----- 7 files changed, 387 insertions(+), 61 deletions(-) create mode 100644 templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx new file mode 100644 index 00000000000..a7c37dac7e7 --- /dev/null +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx @@ -0,0 +1,210 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { + formatMcpServersLoadError, + getDefaultMcpIntegrations, + McpIntegrationDialog, + McpIntegrationLogo, + useCreateMcpServer, + useMcpServers, + useReconnectMcpServer, +} from "@agent-native/core/client/resources"; +import { + IconAlertCircle, + IconCheck, + IconCircle, + IconLoader2, +} from "@tabler/icons-react"; +import { useState } from "react"; +import { Link } from "react-router"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { DbtMcpStatus } from "@/lib/data-source-status"; + +const dbtIntegration = getDefaultMcpIntegrations().find( + (integration) => integration.id === "dbt", +); +// This card owns organization scope, so skip the dialog's personal/workspace +// choice and enforce the shared scope again at the create boundary. +const dbtDialogIntegration = dbtIntegration + ? { ...dbtIntegration, supportsOrganizationScope: false } + : undefined; + +export function DbtMcpDataSourceCard({ + status, + isLoading, + canManageOrg, + hasOrg, + focused, + showAskContinuation, + onSaved, +}: { + status: DbtMcpStatus | undefined; + isLoading: boolean; + canManageOrg: boolean; + hasOrg: boolean; + focused: boolean; + showAskContinuation: boolean; + onSaved: () => void; +}) { + const t = useT(); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState<"connect" | "manage">("connect"); + const serversQuery = useMcpServers(); + const createServer = useCreateMcpServer(); + const reconnectServer = useReconnectMcpServer(); + const connected = status?.configured === true; + const hasError = + !isLoading && + (status?.configured === null || status?.available === false || !status); + const dbtServer = status?.serverId + ? [ + ...(serversQuery.data?.org ?? []), + ...(serversQuery.data?.user ?? []), + ].find((server) => server.id === status.serverId) + : undefined; + const errorMessage = reconnectServer.error + ? reconnectServer.error instanceof Error && reconnectServer.error.message + ? reconnectServer.error.message + : t("dataSources.connectionFailed") + : serversQuery.error + ? formatMcpServersLoadError(serversQuery.error) + : hasError && status?.error + ? formatMcpServersLoadError(status.error) + : null; + + const openDialog = (mode: "connect" | "manage") => { + setDialogMode(mode); + setDialogOpen(true); + }; + + const handlePrimaryAction = () => { + if (hasError) { + if (dbtServer) { + reconnectServer.mutate( + { id: dbtServer.id, scope: dbtServer.scope }, + { onSuccess: onSaved }, + ); + } else { + openDialog("connect"); + } + return; + } + openDialog(connected ? "manage" : "connect"); + }; + + return ( + + +
+
+
+ {dbtIntegration ? ( + + ) : null} +
+
+ dbt +

+ {t("dataSources.dbtSharedIdentity")} +

+
+
+ +
+ {isLoading ? ( + + ) : hasError ? ( + + + {t("dataSources.connectionFailed")} + + ) : connected ? ( + + + {t("dataSources.dbtConnectedTools", { + count: status.toolCount, + })} + + ) : ( + + + {t("dataSources.notConfigured")} + + )} + + {!isLoading && canManageOrg && hasOrg ? ( + + ) : null} +
+
+
+ + {(errorMessage || (focused && connected && showAskContinuation)) && ( + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + {focused && connected && showAskContinuation ? ( +
+ + + {t("dataSources.connectionSuccessful")} + + +
+ ) : null} +
+ )} + + {canManageOrg && hasOrg && dbtDialogIntegration ? ( + + createServer.mutateAsync({ ...args, scope: "org" }) + } + onCreated={onSaved} + integrations={[dbtDialogIntegration]} + /> + ) : null} +
+ ); +} diff --git a/templates/analytics/app/i18n-data.ts b/templates/analytics/app/i18n-data.ts index c86b2aa849c..08df35f9549 100644 --- a/templates/analytics/app/i18n-data.ts +++ b/templates/analytics/app/i18n-data.ts @@ -889,6 +889,9 @@ const enUS = { opening: "Opening...", reconnect: "Reconnect", connect: "Connect", + manage: "Manage", + dbtSharedIdentity: "Uses the workspace's shared dbt service identity.", + dbtConnectedTools: "Connected · {{count}} tools", connectedAs: "Connected as {{viewer}}", githubConnected: "GitHub is connected", githubReconnectNeeded: "Saved GitHub token needs to be reconnected.", @@ -1464,6 +1467,9 @@ const analyticsSliceTranslations: { opening: "开幕...", reconnect: "重新连接", connect: "连接", + manage: "管理", + dbtSharedIdentity: "使用工作区共享的 dbt 服务身份。", + dbtConnectedTools: "已连接 · {{count}} 个工具", connectedAs: "连接为 {{viewer}}", githubConnected: "GitHub已连接", githubReconnectNeeded: "已保存的GitHub令牌需要重新连接。", @@ -1844,6 +1850,10 @@ const analyticsSliceTranslations: { opening: "Apertura...", reconnect: "Reconectar", connect: "Conectar", + manage: "Administrar", + dbtSharedIdentity: + "Usa la identidad de servicio dbt compartida del espacio de trabajo.", + dbtConnectedTools: "Conectado · {{count}} herramientas", connectedAs: "Conectado como {{viewer}}", githubConnected: "GitHub está conectado", githubReconnectNeeded: @@ -2251,6 +2261,10 @@ const analyticsSliceTranslations: { opening: "Ouverture...", reconnect: "Reconnecter", connect: "Connecter", + manage: "Gérer", + dbtSharedIdentity: + "Utilise l’identité de service dbt partagée de l’espace de travail.", + dbtConnectedTools: "Connecté · {{count}} outils", connectedAs: "Connecté en tant que {{viewer}}", githubConnected: "Le GitHub est connecté", githubReconnectNeeded: "Le jeton GitHub enregistré doit être reconnecté.", @@ -2654,6 +2668,10 @@ const analyticsSliceTranslations: { opening: "Öffnung...", reconnect: "Wieder verbinden", connect: "Verbinden", + manage: "Verwalten", + dbtSharedIdentity: + "Verwendet die gemeinsame dbt-Serviceidentität des Arbeitsbereichs.", + dbtConnectedTools: "Verbunden · {{count}} Tools", connectedAs: "Verbunden als {{viewer}}", githubConnected: "GitHub ist angeschlossen", githubReconnectNeeded: @@ -3057,6 +3075,10 @@ const analyticsSliceTranslations: { opening: "オープニング...", reconnect: "再接続", connect: "接続する", + manage: "管理", + dbtSharedIdentity: + "ワークスペースで共有される dbt サービス ID を使用します。", + dbtConnectedTools: "接続済み · {{count}} 個のツール", connectedAs: "{{viewer}}として接続", githubConnected: "GitHubを接続しました", githubReconnectNeeded: @@ -3452,6 +3474,9 @@ const analyticsSliceTranslations: { opening: "열기...", reconnect: "다시 연결", connect: "연결하다", + manage: "관리", + dbtSharedIdentity: "워크스페이스의 공유 dbt 서비스 ID를 사용합니다.", + dbtConnectedTools: "연결됨 · 도구 {{count}}개", connectedAs: "{{viewer}}로 연결됨", githubConnected: "GitHub가 연결되었습니다", githubReconnectNeeded: "저장된 GitHub 토큰을 다시 연결해야 합니다.", @@ -3843,6 +3868,10 @@ const analyticsSliceTranslations: { opening: "Abertura...", reconnect: "Reconectar", connect: "Conectar", + manage: "Gerenciar", + dbtSharedIdentity: + "Usa a identidade de serviço dbt compartilhada do workspace.", + dbtConnectedTools: "Conectado · {{count}} ferramentas", connectedAs: "Conectado como {{viewer}}", githubConnected: "GitHub está conectado", githubReconnectNeeded: "O token GitHub salvo precisa ser reconectado.", @@ -4240,6 +4269,9 @@ const analyticsSliceTranslations: { opening: "खुल रहा है...", reconnect: "रिकनेक्ट", connect: "जोड़ना", + manage: "प्रबंधित करें", + dbtSharedIdentity: "वर्कस्पेस की साझा dbt सेवा पहचान का उपयोग करता है।", + dbtConnectedTools: "कनेक्टेड · {{count}} टूल", connectedAs: "{{viewer}} के रूप में जुड़ा हुआ है", githubConnected: "GitHub जुड़ा हुआ है", githubReconnectNeeded: "सहेजे गए GitHub टोकन को पुनः कनेक्ट करने की आवश्यकता है।", @@ -4625,6 +4657,9 @@ const analyticsSliceTranslations: { opening: "افتتاح...", reconnect: "أعد الاتصال", connect: "يتصل", + manage: "إدارة", + dbtSharedIdentity: "يستخدم هوية خدمة dbt المشتركة لمساحة العمل.", + dbtConnectedTools: "متصل · {{count}} أداة", connectedAs: "متصل كـ {{viewer}}", githubConnected: "GitHub متصل", githubReconnectNeeded: "يجب إعادة توصيل الرمز المميز GitHub المحفوظ.", diff --git a/templates/analytics/app/i18n/zh-TW.ts b/templates/analytics/app/i18n/zh-TW.ts index 19ac29400b3..1de2b7087fd 100644 --- a/templates/analytics/app/i18n/zh-TW.ts +++ b/templates/analytics/app/i18n/zh-TW.ts @@ -430,6 +430,9 @@ const messages = { opening: "開幕...", reconnect: "重新連線", connect: "連線", + manage: "管理", + dbtSharedIdentity: "使用工作區共用的 dbt 服務身分。", + dbtConnectedTools: "已連線 · {{count}} 個工具", connectedAs: "連線為 {{viewer}}", githubConnected: "GitHub已連線", githubReconnectNeeded: "已儲存的GitHub權杖需要重新連線。", diff --git a/templates/analytics/app/lib/data-source-status.spec.ts b/templates/analytics/app/lib/data-source-status.spec.ts index 9206e7a0274..e43d47e4402 100644 --- a/templates/analytics/app/lib/data-source-status.spec.ts +++ b/templates/analytics/app/lib/data-source-status.spec.ts @@ -268,6 +268,30 @@ describe("data source status", () => { ).toBe(false); }); + it("declares dbt as MCP-backed and preserves its focused Ask return", () => { + const dbt = dataSources.find((source) => source.id === "dbt"); + const resolution = focusedDataSourceFromSearchParams( + new URLSearchParams("source=dbt&returnTo=ask"), + ); + + expect(dbt).toMatchObject({ + category: "analytics", + connectionKind: "mcp", + }); + expect(dbt && "envKeys" in dbt).toBe(false); + expect(dbt && "walkthroughSteps" in dbt).toBe(false); + expect(resolution).toMatchObject({ + status: "found", + source: { id: "dbt" }, + }); + expect( + dataSourceOAuthReturnPath( + resolution.status === "found" ? resolution.source : undefined, + true, + ), + ).toBe("/data-sources?source=dbt&returnTo=ask"); + }); + it("focuses a provider-specific setup link and preserves the Ask return", () => { const resolution = focusedDataSourceFromSearchParams( new URLSearchParams("source=HubSpot&returnTo=ask"), diff --git a/templates/analytics/app/lib/data-source-status.ts b/templates/analytics/app/lib/data-source-status.ts index e7804beeb3f..ef190c7cf76 100644 --- a/templates/analytics/app/lib/data-source-status.ts +++ b/templates/analytics/app/lib/data-source-status.ts @@ -227,6 +227,7 @@ export function credentialRowsFromStatus( } export function getOptionalCredentialKeys(source: DataSource): Set { + if (source.connectionKind === "mcp") return new Set(); return new Set( source.walkthroughSteps .filter((step) => step.optional) @@ -241,7 +242,7 @@ export function isSourceConfigured( source: DataSource, envStatus: EnvKeyStatus[], ): boolean { - if (source.id === "dbt") return false; + if (source.connectionKind === "mcp") return false; const statusMap = new Map( envStatus.map((s) => [normalizeCredentialKey(s.key), s.configured]), diff --git a/templates/analytics/app/lib/data-sources.ts b/templates/analytics/app/lib/data-sources.ts index fad755cda8c..fcb6cbf95d3 100644 --- a/templates/analytics/app/lib/data-sources.ts +++ b/templates/analytics/app/lib/data-sources.ts @@ -45,18 +45,28 @@ export interface WalkthroughStep { inputAcceptFile?: string; } -export interface DataSource { +interface DataSourceDescriptor { id: string; name: string; description: string; category: DataSourceCategory; icon: ComponentType>; + docsUrl: string; +} + +export interface CredentialDataSource extends DataSourceDescriptor { + connectionKind?: "credentials"; envKeys: string[]; credentialRequirementMode?: "all" | "any"; walkthroughSteps: WalkthroughStep[]; - docsUrl: string; } +export interface McpDataSource extends DataSourceDescriptor { + connectionKind: "mcp"; +} + +export type DataSource = CredentialDataSource | McpDataSource; + export const categoryLabels: Record = { analytics: "Analytics & Product", database: "Database", @@ -203,6 +213,15 @@ export const dataSources: DataSource[] = [ }, ], }, + { + id: "dbt", + name: "dbt", + description: "Governed metadata, lineage, model health, and metrics", + category: "analytics", + icon: IconDatabase, + connectionKind: "mcp", + docsUrl: "https://docs.getdbt.com/docs/dbt-ai/mcp-quickstart-remote", + }, { id: "amplitude", name: "Amplitude", diff --git a/templates/analytics/app/pages/DataSources.tsx b/templates/analytics/app/pages/DataSources.tsx index ea78cee6b3e..9a09a0b04ed 100644 --- a/templates/analytics/app/pages/DataSources.tsx +++ b/templates/analytics/app/pages/DataSources.tsx @@ -94,6 +94,7 @@ import { dataSources, categoryLabels, categoryOrder, + type CredentialDataSource, type DataSource, type WalkthroughStep, } from "@/lib/data-sources"; @@ -103,6 +104,7 @@ import { type ConnectionTestResult, } from "../components/ConnectionTestStatus"; import { CustomApiCard } from "../components/CustomApiCard"; +import { DbtMcpDataSourceCard } from "../components/data-sources/DbtMcpDataSourceCard"; interface AnalyticsPublicKeyRow { id: string; @@ -348,7 +350,9 @@ async function deleteCredentials(keys: string[]): Promise { await callAction("delete-data-source-credentials", { keys }); } -async function disconnectDataSource(source: DataSource): Promise { +async function disconnectDataSource( + source: CredentialDataSource, +): Promise { if (source.id === "github") { const res = await fetch( agentNativePath("/_agent-native/oauth/github/disconnect"), @@ -771,7 +775,7 @@ function ConnectedView({ onSaved, envStatus, }: { - source: DataSource; + source: CredentialDataSource; onSaved: () => void; envStatus: EnvKeyStatus[]; }) { @@ -842,7 +846,10 @@ function ConnectedView({ } const sharedCredentialKeys = source.envKeys.filter((key) => dataSources.some( - (other) => other.id !== source.id && other.envKeys.includes(key), + (other) => + other.connectionKind !== "mcp" && + other.id !== source.id && + other.envKeys.includes(key), ), ); const sharedSourceNames = Array.from( @@ -850,6 +857,7 @@ function ConnectedView({ dataSources .filter( (other) => + other.connectionKind !== "mcp" && other.id !== source.id && other.envKeys.some((key) => source.envKeys.includes(key)), ) @@ -1205,7 +1213,7 @@ function DataSourceCard({ showAskContinuation, onSaved, }: { - source: DataSource; + source: CredentialDataSource; locallyConfigured: boolean; ready: boolean; sharedConnectionStatus: SharedConnectionStatus | null; @@ -2169,33 +2177,46 @@ export default function DataSources() { filteredSources.length > 0 || firstPartyAnalyticsMatchesSearch ? (
{firstPartyAnalyticsMatchesSearch && } - {filteredSources.map((source) => ( - - ))} + {filteredSources.map((source) => + source.connectionKind === "mcp" ? ( + + ) : ( + + ), + )}
) : (

@@ -2213,33 +2234,46 @@ export default function DataSources() {

{category === "analytics" && } - {sources.map((source) => ( - - ))} + {sources.map((source) => + source.connectionKind === "mcp" ? ( + + ) : ( + + ), + )}
); From 18857debb9fba99be2028838f83c60957cb0f1f6 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 22:03:13 +0000 Subject: [PATCH 07/14] Fix dbt data source reconnection and admin controls --- .../DbtMcpDataSourceCard.spec.tsx | 143 ++++++++++++++++++ .../data-sources/DbtMcpDataSourceCard.tsx | 54 +++---- templates/analytics/app/i18n-data.ts | 36 ++--- templates/analytics/app/i18n/zh-TW.ts | 3 +- 4 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx new file mode 100644 index 00000000000..fd55e1129bd --- /dev/null +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + reconnect: vi.fn(), +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => key, +})); + +vi.mock("@agent-native/core/client/resources", () => ({ + formatMcpServersLoadError: (error: unknown) => String(error), + getDefaultMcpIntegrations: () => [ + { + id: "dbt", + name: "dbt", + logoUrl: "", + supportsOrganizationScope: true, + }, + ], + McpIntegrationDialog: ({ open }: { open: boolean }) => + open ?
: null, + McpIntegrationLogo: () => null, + useCreateMcpServer: () => ({ mutateAsync: vi.fn() }), + useMcpServers: () => ({ + data: { + user: [], + org: [ + { + id: "stored-dbt", + mergedId: "org_workspace_stored-dbt", + scope: "org", + }, + ], + }, + error: null, + }), + useReconnectMcpServer: () => ({ + mutate: mocks.reconnect, + isPending: false, + error: null, + }), +})); + +import { DbtMcpDataSourceCard } from "./DbtMcpDataSourceCard"; + +describe("DbtMcpDataSourceCard", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.reconnect.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("reconnects the existing organization server instead of opening create", async () => { + await act(async () => { + root.render( + , + ); + }); + + const reconnectButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent === "dataSources.reconnect"); + expect(reconnectButton).toBeTruthy(); + + await act(async () => reconnectButton?.click()); + + expect(mocks.reconnect).toHaveBeenCalledWith( + { id: "stored-dbt", scope: "org" }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect( + container.querySelector('[data-testid="mcp-create-dialog"]'), + ).toBeNull(); + }); + + it("shows connected state without mutation controls to non-admin members", async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("dataSources.dbtConnectedTools"); + expect(container.querySelector("button")).toBeNull(); + expect(mocks.reconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx index a7c37dac7e7..2b545bb3f4b 100644 --- a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx @@ -50,11 +50,11 @@ export function DbtMcpDataSourceCard({ }) { const t = useT(); const [dialogOpen, setDialogOpen] = useState(false); - const [dialogMode, setDialogMode] = useState<"connect" | "manage">("connect"); const serversQuery = useMcpServers(); const createServer = useCreateMcpServer(); const reconnectServer = useReconnectMcpServer(); const connected = status?.configured === true; + const notConfigured = status?.configured === false && status.available; const hasError = !isLoading && (status?.configured === null || status?.available === false || !status); @@ -62,7 +62,10 @@ export function DbtMcpDataSourceCard({ ? [ ...(serversQuery.data?.org ?? []), ...(serversQuery.data?.user ?? []), - ].find((server) => server.id === status.serverId) + ].find( + (server) => + server.id === status.serverId || server.mergedId === status.serverId, + ) : undefined; const errorMessage = reconnectServer.error ? reconnectServer.error instanceof Error && reconnectServer.error.message @@ -74,24 +77,16 @@ export function DbtMcpDataSourceCard({ ? formatMcpServersLoadError(status.error) : null; - const openDialog = (mode: "connect" | "manage") => { - setDialogMode(mode); - setDialogOpen(true); - }; - const handlePrimaryAction = () => { - if (hasError) { - if (dbtServer) { - reconnectServer.mutate( - { id: dbtServer.id, scope: dbtServer.scope }, - { onSuccess: onSaved }, - ); - } else { - openDialog("connect"); - } + if (notConfigured) { + setDialogOpen(true); return; } - openDialog(connected ? "manage" : "connect"); + if (!dbtServer) return; + reconnectServer.mutate( + { id: dbtServer.id, scope: dbtServer.scope }, + { onSuccess: onSaved }, + ); }; return ( @@ -115,12 +110,12 @@ export function DbtMcpDataSourceCard({ /> ) : null}
-
- dbt -

+ + dbt + {t("dataSources.dbtSharedIdentity")} -

-
+ +
@@ -150,17 +145,17 @@ export function DbtMcpDataSourceCard({ size="sm" variant={connected ? "outline" : "default"} onClick={handlePrimaryAction} - disabled={reconnectServer.isPending} + disabled={ + reconnectServer.isPending || (!notConfigured && !dbtServer) + } className="text-xs" > {reconnectServer.isPending ? ( - ) : hasError ? ( - t("dataSources.reconnect") - ) : connected ? ( - t("dataSources.manage") - ) : ( + ) : notConfigured ? ( t("dataSources.connect") + ) : ( + t("dataSources.reconnect") )} ) : null} @@ -193,8 +188,7 @@ export function DbtMcpDataSourceCard({ Date: Wed, 2 Sep 2026 22:10:41 +0000 Subject: [PATCH 08/14] Add governed dbt analytics querying guidance --- .../.agents/skills/data-querying/SKILL.md | 2 +- .../analytics/.agents/skills/dbt/SKILL.md | 36 ++++++++ templates/analytics/AGENTS.md | 41 +++------ templates/analytics/actions/navigate.spec.ts | 14 +++ templates/analytics/actions/navigate.ts | 16 +++- .../app/hooks/use-navigation-state.spec.ts | 35 +++++++- .../app/hooks/use-navigation-state.ts | 25 +++++- .../server/plugins/agent-chat.spec.ts | 86 +++++++++++++++++++ .../analytics/server/plugins/agent-chat.ts | 30 ++++++- 9 files changed, 250 insertions(+), 35 deletions(-) create mode 100644 templates/analytics/.agents/skills/dbt/SKILL.md diff --git a/templates/analytics/.agents/skills/data-querying/SKILL.md b/templates/analytics/.agents/skills/data-querying/SKILL.md index 23fff626986..a1c0dcf9a87 100644 --- a/templates/analytics/.agents/skills/data-querying/SKILL.md +++ b/templates/analytics/.agents/skills/data-querying/SKILL.md @@ -13,7 +13,7 @@ The analytics app connects to multiple data sources. This skill covers general p 0. **Orient catalog-first** — before querying, consult what already exists: the injected `` and data-source status tell you which sources are configured and which table/columns/join paths to use. Use them to pick the one source that owns the fact instead of fanning out blind queries. 1. **Route named account health deliberately** — for a customer/org health, QBR, renewal, contract-utilization, risk, or adoption request, read `account-health` before writing SQL. It adds identity-lock and metric-definition checks that an ordinary lookup does not need. -2. **Read the relevant provider skill first** — check `.agents/skills//SKILL.md` for table names, column mappings, auth, and gotchas. For BigQuery, read `.agents/skills/bigquery/SKILL.md` and use `search-bigquery-schema` before guessing table or column names. +2. **Read the relevant source skill first** — for dbt-backed models or metrics, read `.agents/skills/dbt/SKILL.md` before deciding semantics, lineage, grain, or SQL. Otherwise check `.agents/skills//SKILL.md` for source-specific mappings and gotchas. For BigQuery, read `.agents/skills/bigquery/SKILL.md` and use `search-bigquery-schema` before guessing table or column names. 3. **Clarify if ambiguous** — if the metric definition, date range, or grain is unclear and a wrong guess would change the numbers, use the `ask-question` clarifying tool (multiple-choice) before querying. Ask at most once per turn; skip it when the dictionary or the user already answered. 4. **Use existing actions or connected provider MCP tools** — call the provider action/tool with structured arguments, then filter or aggregate the returned records in your answer 5. **Write ad-hoc scripts** — if no existing script covers the question, create one in `actions/` diff --git a/templates/analytics/.agents/skills/dbt/SKILL.md b/templates/analytics/.agents/skills/dbt/SKILL.md new file mode 100644 index 00000000000..a35dd1d48c4 --- /dev/null +++ b/templates/analytics/.agents/skills/dbt/SKILL.md @@ -0,0 +1,36 @@ +--- +name: dbt +description: >- + Governed dbt model, lineage, freshness, and Semantic Layer routing for analytics. + Use when a question depends on dbt models, sources, metrics, or warehouse SQL. +scope: runtime +--- + +# dbt + +dbt is authoritative for dbt model semantics, lineage, freshness, and governed metrics. Connected dbt MCP tools are dynamic: find them with `tool-search` when needed rather than assuming they are on the initial tool surface. + +## Decision Order + +1. Reuse a current certified query or dashboard when it already answers the request with the right definition, filters, grain, and time range. +2. Before guessing a dbt table, grain, join, meaning, freshness, or lineage, use dbt Discovery. Use the exact official tools that fit the question: `get_node_details` for model semantics and relation metadata, `get_lineage` for dependencies, `get_model_health` or `get_model_performance` for health and performance, and `get_all_sources` for declared sources and freshness context. +3. Before writing SQL, verify the physical BigQuery relation and columns with `search-bigquery-schema`. dbt metadata describes governed meaning; warehouse schema proves what can be queried now. +4. Run direct SQL with the existing `bigquery` action. Never use dbt `execute_sql` or `text_to_sql`. +5. If dbt metadata does not establish a grain or relationship, keep it unknown. Do not infer a join or silently turn uncertainty into a metric. + +## Phase 2: MetricFlow / Semantic Layer + +When Semantic Layer tools are available: + +1. Call `list_metrics` to find the governed metric. +2. Inspect `get_dimensions` and `get_entities`; use `get_dimension_values` when a requested filter value needs validation. +3. Call `query_metrics` with the requested dimensions, filters, range, and time grain. +4. Call `get_metrics_compiled_sql` only when the user requests provenance or query validation. It is not a default preflight. + +Report the metric name, dimensions, filters, date range and time grain, plus environment or freshness caveats. dbt calls use the shared workspace dbt identity, not a personal warehouse identity. + +If MetricFlow cannot express the request, fall back to dbt metadata plus the `bigquery` action and label the result as ad hoc SQL. Missing Semantic Layer tools are a capability gap, not evidence that no metrics exist. Use Discovery metadata and BigQuery only when they can answer without inventing semantic definitions. + +## Failure Semantics + +A dbt connection or tool-list error means capability status is unreadable, not that dbt is disconnected or that no dbt models or metrics exist. Preserve the actual error, try dynamic tool discovery when appropriate, and do not replace a failed dbt lookup with guessed semantics. diff --git a/templates/analytics/AGENTS.md b/templates/analytics/AGENTS.md index 0b867c9a24b..341f5e835e3 100644 --- a/templates/analytics/AGENTS.md +++ b/templates/analytics/AGENTS.md @@ -8,9 +8,10 @@ analyses remain readable. Read the relevant skill before deeper work: -- `data-querying` for source inspection, SQL generation, result handling, and - `/chart` embeds; `bigquery`, `hubspot`, `gong`, `prometheus` for provider - specifics. +- `data-querying` for source inspection, SQL, result handling, and `/chart` embeds. +- `dbt` for governed model semantics, lineage, freshness, MetricFlow, and dbt-backed SQL. + Read provider skills such as `bigquery`, `hubspot`, `gong`, and `prometheus` + for provider specifics. - `account-health` for named customer health, QBR, renewal, contract usage, identity, and product adoption. - `cross-source-analysis` for questions spanning sources (identity stitching, @@ -47,23 +48,18 @@ Read the relevant skill before deeper work: ## Core Rules -- A sibling app sends natural-language or shaped input over A2A, never SQL; this - app owns schema, source selection, and tools. Prefer natural-language - delegation; shaped reads are stable contracts. -- Analytics owns first-party product usage, app/template events, agent-native - signups, conversions, and other curated product metrics. Answer sibling-app - delegations with the built-in source and query catalog; sibling agents should - send a natural-language question, never SQL. +- A sibling app delegates natural-language or shaped input over A2A, never SQL; + this app owns schema, source selection, and tools. - Delegated requests: choose a safe default; label partial. - Data integrity first. Never invent numbers, dimensions, filters, or source semantics; present only retrieved values with source, window, filters, row-count/sample-size, join method, and caveats. +- dbt is authoritative for dbt model semantics and lineage. When connected, + discover its dynamic tools with `tool-search`; read `dbt` before using them. - Use actions for data and sharing; don't bypass ownable-resource access checks with raw SQL. - Provider actions are bounded shortcuts, not limits. For broad or - absence-sensitive Gong work, stage raw API data and use `query-staged-dataset` - or a Data Program; see `provider-api`, `data-programs`, and `gong` for secure - provider and hosted-endpoint boundaries. + absence-sensitive work, read `provider-api` and `data-programs`. - Create dashboards, panels, or saved artifacts only when explicitly asked; suggest and wait otherwise. Scope them to the question, avoid decorative metrics, and never modify existing dashboards without a directive. @@ -80,12 +76,8 @@ Read the relevant skill before deeper work: - Never hardcode API keys, tokens, webhook URLs, secrets, private Builder data, or customer data. Use secrets/OAuth and obvious placeholders in examples. - For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. -- External MCP callers should prefer a cataloged direct action for a bounded - read or explicitly requested mutation. Use `ask_app` for interpretation, - source selection, multi-step analysis, or when the requested capability is - not exposed directly. Writes remain limited to intentionally allowlisted - actions. -- Reports/alerts use SQL actions; reports cap at five recipients. +- External MCP callers use cataloged actions for bounded reads and `ask_app` for + interpretation or multi-step analysis. Writes require intentional allowlisting. ## Actions @@ -95,19 +87,12 @@ Read the relevant skill before deeper work: | `search-dashboard-references` | Find dashboards to replicate. | | `get-sql-dashboard` | Read the dashboard and exact panel SQL. | | `certify-dashboard` | Admin-only approval of its current version. | -| DB | `list-db-admin-connections`, `list-connected-database-tables`, `db-admin-federated-read`: registry, schema, bounded joins. | +| DB | Registry, schema, and bounded federated reads. | ## Application State - `navigation` exposes the current dashboard, analysis, source, chart, and - selection. `navigate` moves the user between supported Analytics surfaces, - `"sessions"`, `"monitoring"`, and `"agents"`. Use `view-screen` when the - active context is unclear. + selection. Use `view-screen` when active context is unclear. - Clicking a panel stages it as a chat context chip and writes `selected-object` with `type="dashboard-panel"`. Read `dashboard-management` for the `/dashboards` overview and folder actions. - -## Shared UI - -Before building common workspace or agent UI, read `agent-native-toolkit`; read -`customizing-agent-native` before adapting shared UI. diff --git a/templates/analytics/actions/navigate.spec.ts b/templates/analytics/actions/navigate.spec.ts index a6b475366f3..bdb5ffee98c 100644 --- a/templates/analytics/actions/navigate.spec.ts +++ b/templates/analytics/actions/navigate.spec.ts @@ -68,6 +68,20 @@ describe("navigate action", () => { expect(writeAppStateForCurrentTab).not.toHaveBeenCalled(); }); + it("opens a focused data source with a same-origin encoded path", async () => { + const result = await navigateAction.run({ + view: "data-sources", + dataSourceId: "dbt/core", + } as never); + + expect(writeAppStateForCurrentTab).toHaveBeenCalledWith("navigate", { + view: "data-sources", + dataSourceId: "dbt/core", + path: "/data-sources?source=dbt%2Fcore", + }); + expect(result).toBe("Navigating to data-sources data-source:dbt/core"); + }); + it("routes a monitoring subview to the monitoring tab", async () => { await navigateAction.run({ monitoringView: "errors" } as never); expect(writeAppStateForCurrentTab).toHaveBeenCalledWith("navigate", { diff --git a/templates/analytics/actions/navigate.ts b/templates/analytics/actions/navigate.ts index c0d17777a86..c0dbb30cfca 100644 --- a/templates/analytics/actions/navigate.ts +++ b/templates/analytics/actions/navigate.ts @@ -67,6 +67,10 @@ export default defineAction({ .string() .optional() .describe("Analysis ID to open (used with view=analyses)"), + dataSourceId: z + .string() + .optional() + .describe("Data source ID to focus (used with view=data-sources)"), extensionId: z .string() .optional() @@ -119,6 +123,7 @@ export default defineAction({ !args.dashboardId && !args.dashboardName && !args.analysisId && + !args.dataSourceId && !args.extensionId && !args.recordingId && !args.agentsView && @@ -129,7 +134,7 @@ export default defineAction({ !args.errorIssueId ) { throw new Error( - "At least --view, --dashboardId, --dashboardName, --analysisId, --extensionId, --recordingId, --agentsView, --dbAdminConnectionId, --monitoringView, --monitorId, --statusPageId, or --errorIssueId is required.", + "At least --view, --dashboardId, --dashboardName, --analysisId, --dataSourceId, --extensionId, --recordingId, --agentsView, --dbAdminConnectionId, --monitoringView, --monitorId, --statusPageId, or --errorIssueId is required.", ); } const dashboardId = @@ -147,6 +152,14 @@ export default defineAction({ nav.analysisId = args.analysisId; if (!args.view) nav.view = "analyses"; } + if (args.dataSourceId) { + nav.dataSourceId = args.dataSourceId; + if (!args.view) nav.view = "data-sources"; + } + if (nav.view === "data-sources" && nav.dataSourceId) { + const params = new URLSearchParams({ source: nav.dataSourceId }); + nav.path = `/data-sources?${params.toString()}`; + } if (args.extensionId) { nav.extensionId = args.extensionId; if (!args.view) nav.view = "extensions"; @@ -192,6 +205,7 @@ export default defineAction({ if (nav.view) parts.push(nav.view); if (nav.dashboardId) parts.push(`dashboard:${nav.dashboardId}`); if (nav.analysisId) parts.push(`analysis:${nav.analysisId}`); + if (nav.dataSourceId) parts.push(`data-source:${nav.dataSourceId}`); if (nav.extensionId) parts.push(`extension:${nav.extensionId}`); if (nav.recordingId) parts.push(`recording:${nav.recordingId}`); if (nav.agentsView) parts.push(`agents:${nav.agentsView}`); diff --git a/templates/analytics/app/hooks/use-navigation-state.spec.ts b/templates/analytics/app/hooks/use-navigation-state.spec.ts index 27b630bed4f..dfd2784216c 100644 --- a/templates/analytics/app/hooks/use-navigation-state.spec.ts +++ b/templates/analytics/app/hooks/use-navigation-state.spec.ts @@ -1,6 +1,39 @@ import { describe, expect, it } from "vitest"; -import { preserveActiveDashboardTab } from "./use-navigation-state"; +import { + commandPathForNavigation, + dataSourcesNavigationState, + preserveActiveDashboardTab, +} from "./use-navigation-state"; + +describe("data source navigation", () => { + it("extracts only the focused source as semantic navigation state", () => { + expect( + dataSourcesNavigationState( + new URLSearchParams("source=dbt&returnTo=ask&metadata=ignored"), + ), + ).toEqual({ view: "data-sources", dataSourceId: "dbt" }); + }); + + it("builds an encoded command path for the focused source", () => { + expect( + commandPathForNavigation({ + view: "data-sources", + dataSourceId: "dbt/core", + }), + ).toBe("/data-sources?source=dbt%2Fcore"); + }); + + it("preserves an existing command path including returnTo", () => { + expect( + commandPathForNavigation({ + view: "data-sources", + dataSourceId: "dbt", + path: "/data-sources?source=dbt&returnTo=ask", + }), + ).toBe("/data-sources?source=dbt&returnTo=ask"); + }); +}); describe("preserveActiveDashboardTab", () => { it("keeps the active tab when an agent reopens the current dashboard", () => { diff --git a/templates/analytics/app/hooks/use-navigation-state.ts b/templates/analytics/app/hooks/use-navigation-state.ts index 4cbd136112f..f0081a37174 100644 --- a/templates/analytics/app/hooks/use-navigation-state.ts +++ b/templates/analytics/app/hooks/use-navigation-state.ts @@ -7,6 +7,7 @@ import { TAB_ID } from "@/lib/tab-id"; interface NavigationState { view: string; dashboardId?: string; + dataSourceId?: string; analysisId?: string; extensionId?: string; recordingId?: string; @@ -19,13 +20,17 @@ interface NavigationState { filters?: Record; } +interface NavigateCommand extends NavigationState { + path?: string; +} + const SESSION_FILTER_KEYS = ["range", "app", "q"] as const; const DASHBOARD_PATH_RE = /^\/(?:adhoc|dashboards)\/([^/]+)\/?$/; export function useNavigationState() { const location = useLocation(); - useAgentRouteState({ + useAgentRouteState({ browserTabId: TAB_ID, getNavigationState: ({ pathname, searchParams }) => { const state: NavigationState = { view: "ask" }; @@ -99,7 +104,7 @@ export function useNavigationState() { } } } else if (pathname === "/data-sources") { - state.view = "data-sources"; + Object.assign(state, dataSourcesNavigationState(searchParams)); } else if (pathname === "/data-dictionary") { state.view = "data-dictionary"; } else if ( @@ -120,7 +125,8 @@ export function useNavigationState() { }); } -function commandPathForNavigation(cmd: NavigationState): string { +export function commandPathForNavigation(cmd: NavigateCommand): string { + if (cmd.path) return cmd.path; if (cmd.view === "adhoc" && cmd.dashboardId) return `/dashboards/${cmd.dashboardId}`; if (cmd.view === "analyses" && cmd.analysisId) @@ -158,6 +164,10 @@ function commandPathForNavigation(cmd: NavigationState): string { const qs = params.toString(); return qs ? `/monitoring?${qs}` : "/monitoring"; } + if (cmd.view === "data-sources" && cmd.dataSourceId) { + const params = new URLSearchParams({ source: cmd.dataSourceId }); + return `/data-sources?${params.toString()}`; + } if (cmd.view === "data-sources") return "/data-sources"; if (cmd.view === "data-dictionary") return "/data-dictionary"; if (cmd.view === "ask") return "/ask"; @@ -193,6 +203,15 @@ function dashboardIdFromPath(pathname: string): string | undefined { return pathname.match(DASHBOARD_PATH_RE)?.[1]; } +export function dataSourcesNavigationState( + searchParams: URLSearchParams, +): NavigationState { + const state: NavigationState = { view: "data-sources" }; + const dataSourceId = searchParams.get("source"); + if (dataSourceId) state.dataSourceId = dataSourceId; + return state; +} + function sessionFilters( searchParams?: URLSearchParams | Record, ): Record | undefined { diff --git a/templates/analytics/server/plugins/agent-chat.spec.ts b/templates/analytics/server/plugins/agent-chat.spec.ts index 3aac9c4415a..45edc774e83 100644 --- a/templates/analytics/server/plugins/agent-chat.spec.ts +++ b/templates/analytics/server/plugins/agent-chat.spec.ts @@ -11,6 +11,10 @@ const accountHealthSkill = readFileSync( new URL("../../.agents/skills/account-health/SKILL.md", import.meta.url), "utf8", ); +const dbtSkill = readFileSync( + new URL("../../.agents/skills/dbt/SKILL.md", import.meta.url), + "utf8", +); const { agentChatPluginOptions, representativeAnalyticsActions } = vi.hoisted( () => ({ @@ -88,6 +92,7 @@ import { } from "../lib/real-data-actions"; import { analyticsDataDictionaryRoutingContext, + analyticsDbtRoutingContext, analyticsSourceGuidanceOpening, ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE, ANALYTICS_CROSS_APP_ROUTING_GUIDANCE, @@ -252,6 +257,68 @@ describe("Analytics agent Plan mode policy", () => { expect(context.length).toBeLessThan(1_000); }); + it("keeps dbt routing bounded and distinguishes connection errors", () => { + const connected = analyticsDbtRoutingContext({ + available: true, + configured: true, + capabilities: { + discovery: true, + lineage: true, + healthAndFreshness: true, + semanticLayer: false, + }, + sqlTools: { available: true, intentionallyUnused: true }, + toolCount: 8, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + const unreadable = analyticsDbtRoutingContext({ + available: false, + configured: null, + error: "MCP client is not configured.", + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { available: false, intentionallyUnused: true }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + + expect(connected).toContain("discover the exact dynamic dbt tools"); + expect(connected).toContain("direct SQL only through the bigquery action"); + expect(connected).toContain( + "Missing Semantic Layer tools are a capability gap", + ); + expect(unreadable).toContain("status is unreadable"); + expect(unreadable).toContain("Do not infer that dbt is disconnected"); + expect(connected.length).toBeLessThan(700); + expect(unreadable.length).toBeLessThan(500); + }); + + it("documents the governed dbt and MetricFlow decision order", () => { + for (const toolName of [ + "get_node_details", + "get_lineage", + "get_model_health", + "get_model_performance", + "get_all_sources", + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + ]) { + expect(dbtSkill).toContain(`\`${toolName}\``); + } + expect(dbtSkill).toContain("Never use dbt `execute_sql` or `text_to_sql`"); + expect(dbtSkill).toContain("keep it unknown"); + expect(dbtSkill).toContain("label the result as ad hoc SQL"); + expect(dbtSkill).toContain("shared workspace dbt identity"); + }); + it("leaves representative read-only Analytics tools available to the shared Plan-mode policy", () => { const pluginActions = agentChatPluginOptions[0]?.actions as Record< string, @@ -295,6 +362,24 @@ describe("Analytics agent Plan mode policy", () => { ); }); + it("keeps dynamic dbt MCP tools off the initial tool surface", () => { + expect(INITIAL_TOOL_NAMES).not.toEqual( + expect.arrayContaining([ + "get_node_details", + "get_lineage", + "get_model_health", + "get_model_performance", + "get_all_sources", + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + ]), + ); + }); + it("keeps named-session incident evidence on the initial tool surface", () => { expect(INITIAL_TOOL_NAMES).toEqual( expect.arrayContaining([ @@ -320,6 +405,7 @@ describe("Analytics agent Plan mode policy", () => { | (() => Promise) | undefined; const context = await extraContext?.(); + expect(context).toContain(""); expect(context).toContain("EXPORT DELIVERY"); expect(context).toContain("call `show-workspace-file`"); expect(context).toContain("Never save an error or failed response"); diff --git a/templates/analytics/server/plugins/agent-chat.ts b/templates/analytics/server/plugins/agent-chat.ts index c9cef13c469..366758f8c43 100644 --- a/templates/analytics/server/plugins/agent-chat.ts +++ b/templates/analytics/server/plugins/agent-chat.ts @@ -14,6 +14,7 @@ import actionsRegistry from "../../.generated/actions-registry.js"; import { INITIAL_TOOL_NAMES } from "../lib/agent-chat-plan-mode"; import { ANALYTICS_CONNECTOR_CATALOG } from "../lib/analytics-connector-catalog"; import { credentialProviderConfigs } from "../lib/credential-keys"; +import { readDbtMcpStatus, type DbtMcpStatus } from "../lib/dbt-mcp-status"; import { isProductionServerlessRuntime } from "../lib/production-serverless-runtime.js"; import { deriveGroundingActionNames, @@ -331,6 +332,32 @@ Data-dictionary definitions are available through \`search-analytics-query-catal `; } +export function analyticsDbtRoutingContext(status: DbtMcpStatus): string { + if (status.configured === null) { + return ` +dbt capability status is unreadable. Do not infer that dbt is disconnected or that no dbt models or metrics exist. For a dbt-backed request, read the dbt skill and use tool-search to look for the official dynamic dbt tools; preserve a real connection error if discovery fails. +`; + } + if (!status.configured) { + return ` +No dynamic dbt capability was visible in a successful connection check. This is a connection capability gap, not evidence that no governed metrics exist. If the request requires dbt semantics, explain the missing connection and link to ${status.setupLink}. +`; + } + + const capabilities = [ + status.capabilities.discovery ? "Discovery" : null, + status.capabilities.lineage ? "lineage" : null, + status.capabilities.healthAndFreshness ? "health/freshness" : null, + status.capabilities.semanticLayer ? "Semantic Layer" : null, + ].filter((capability): capability is string => Boolean(capability)); + const semanticLayerGuidance = status.capabilities.semanticLayer + ? "Use MetricFlow for governed metric requests." + : "Missing Semantic Layer tools are a capability gap, not evidence that no metrics exist."; + return ` +dbt is connected with these visible capabilities: ${capabilities.join(", ") || "none classified"}. Read the dbt skill, then discover the exact dynamic dbt tools with tool-search. dbt owns model semantics and lineage; verify physical BigQuery relations with search-bigquery-schema and run direct SQL only through the bigquery action. ${semanticLayerGuidance} +`; +} + export { INITIAL_TOOL_NAMES } from "../lib/agent-chat-plan-mode"; function latestUserText( @@ -1135,6 +1162,7 @@ export default createAgentChatPlugin({ // Always inject compact source-routing guidance. Dictionary definitions // stay behind list-data-dictionary so prompt assembly does not read and // render every organization metric before the model request starts. + const dbtRouting = analyticsDbtRoutingContext(await readDbtMcpStatus()); const sourceGuidance = analyticsSourceGuidanceOpening() + "DASHBOARD CREATION RULE — You may create dashboard artifacts, SQL panels, or other resources only when the user explicitly asks you to (e.g. 'build me a dashboard for...', 'save this analysis', 'add a chart for...'). Treat a requested saved analysis or deep-dive report as a dashboard request. Never create any resource proactively during research, trend analysis, or answering questions. If you think a dashboard would be useful, suggest it and wait for explicit confirmation before creating anything. Never add new items to the sidebar or modify existing dashboards without an explicit user directive. " + @@ -1161,7 +1189,7 @@ export default createAgentChatPlugin({ "For schema questions, prefer data-dictionary entries and configured warehouse schemas over assumptions; use `search-bigquery-schema` for BigQuery metadata before inventing datasets, tables, or columns. " + "Before finalizing any analytics answer, make the evidence trail explicit enough to audit: answer the user's question, name the source(s), time window, sample size or row count, filters, join/match method, caveats/gaps, and recommended next action when useful. Never substitute fabricated numbers for a failed query or unavailable provider. It is fine to ask a clarifying question, provide a plan, or say exactly which source is unavailable as long as you do not present metrics or source-record conclusions without evidence.\n" + ""; - return `${sourceGuidance}\n\n${ANALYTICS_CUSTOM_BLOCK_GUIDANCE}\n\n${analyticsDataDictionaryRoutingContext()}`; + return `${sourceGuidance}\n\n${ANALYTICS_CUSTOM_BLOCK_GUIDANCE}\n\n${analyticsDataDictionaryRoutingContext()}\n\n${dbtRouting}`; }, mentionProviders: { dashboards: { From d95534680bce6264e5f5668bd117e24a54057e90 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 2 Sep 2026 22:32:03 +0000 Subject: [PATCH 09/14] Enable direct shared dbt metadata connections in analytics --- packages/core/agent-native.eject.json | 1 + templates/analytics/AGENTS.md | 3 + .../DbtMcpDataSourceCard.spec.tsx | 69 ++++++++++++++++++- .../data-sources/DbtMcpDataSourceCard.tsx | 11 ++- ...-to-shared-dbt-metadata-lineage-freshne.md | 6 ++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md diff --git a/packages/core/agent-native.eject.json b/packages/core/agent-native.eject.json index 1e02c983860..6cb8862856f 100644 --- a/packages/core/agent-native.eject.json +++ b/packages/core/agent-native.eject.json @@ -15,6 +15,7 @@ "catalog": "remote-mcp-presets", "catalogItems": [ "context7", + "dbt", "sentry", "notion", "granola", diff --git a/templates/analytics/AGENTS.md b/templates/analytics/AGENTS.md index 341f5e835e3..c3b13aa645a 100644 --- a/templates/analytics/AGENTS.md +++ b/templates/analytics/AGENTS.md @@ -25,6 +25,9 @@ Read the relevant skill before deeper work: - `creative-context` for governed contexts and immutable dashboard revisions. - `admin-surfaces` for the `/agents` fleet flags, usage audit, and connected DBs. +Before building common workspace or agent UI, read `agent-native-toolkit` and use +`customizing-agent-native` to follow the customization ladder. + ## How To Answer A Data Question 1. **Search existing work first.** For a metric question, call diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx index fd55e1129bd..87bee5c3aaa 100644 --- a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx @@ -19,11 +19,35 @@ vi.mock("@agent-native/core/client/resources", () => ({ id: "dbt", name: "dbt", logoUrl: "", + availability: "provider-setup", + connectionMode: "manual", supportsOrganizationScope: true, }, ], - McpIntegrationDialog: ({ open }: { open: boolean }) => - open ?
: null, + McpIntegrationDialog: ({ + open, + defaultScope, + integrations, + }: { + open: boolean; + defaultScope: string; + integrations: Array<{ + availability?: string; + connectionMode?: string; + supportsOrganizationScope?: boolean; + }>; + }) => + open ? ( +
+ ) : null, McpIntegrationLogo: () => null, useCreateMcpServer: () => ({ mutateAsync: vi.fn() }), useMcpServers: () => ({ @@ -108,6 +132,47 @@ describe("DbtMcpDataSourceCard", () => { ).toBeNull(); }); + it("opens the dbt manual form with organization scope enforced", async () => { + await act(async () => { + root.render( + , + ); + }); + + const connectButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "dataSources.connect", + ); + expect(connectButton).toBeTruthy(); + + await act(async () => connectButton?.click()); + + const dialog = container.querySelector('[data-testid="mcp-create-dialog"]'); + expect(dialog?.getAttribute("data-scope")).toBe("org"); + expect(dialog?.getAttribute("data-availability")).toBe("ready"); + expect(dialog?.getAttribute("data-connection-mode")).toBe("direct"); + expect(dialog?.getAttribute("data-supports-org-scope")).toBe("false"); + }); + it("shows connected state without mutation controls to non-admin members", async () => { await act(async () => { root.render( diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx index 2b545bb3f4b..787aeaf94c1 100644 --- a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx @@ -25,10 +25,15 @@ import type { DbtMcpStatus } from "@/lib/data-source-status"; const dbtIntegration = getDefaultMcpIntegrations().find( (integration) => integration.id === "dbt", ); -// This card owns organization scope, so skip the dialog's personal/workspace -// choice and enforce the shared scope again at the create boundary. +// This card owns provider setup and organization scope, so open the manual +// connection form directly and enforce the shared scope again at create time. const dbtDialogIntegration = dbtIntegration - ? { ...dbtIntegration, supportsOrganizationScope: false } + ? { + ...dbtIntegration, + availability: "ready" as const, + connectionMode: "direct" as const, + supportsOrganizationScope: false, + } : undefined; export function DbtMcpDataSourceCard({ diff --git a/templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md b/templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md new file mode 100644 index 00000000000..c481c8fe922 --- /dev/null +++ b/templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-09-02 +--- + +Analytics can connect to shared dbt metadata, lineage, freshness, and governed metrics. From acb205527e8f7c473367118b6198a38eeea50cc1 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 3 Sep 2026 14:59:50 +0000 Subject: [PATCH 10/14] Enforce restricted schema access and dbt catalog parity --- .changeset/add-dbt-mcp-preset.md | 2 +- .../core/src/eject/catalog-wrappers.spec.ts | 21 ++++ packages/core/src/eject/remote-mcp-presets.ts | 1 + .../.agents/skills/bigquery/SKILL.md | 4 +- .../.agents/skills/data-querying/SKILL.md | 11 ++ .../analytics/.agents/skills/dbt/SKILL.md | 8 ++ templates/analytics/actions/bigquery.spec.ts | 44 ++++++- templates/analytics/actions/bigquery.ts | 48 +++++++- .../actions/search-bigquery-schema.spec.ts | 113 +++++++++++++++++- .../actions/search-bigquery-schema.ts | 24 +++- .../analytics/server/lib/bigquery.spec.ts | 102 +++++++++++++++- templates/analytics/server/lib/bigquery.ts | 113 +++++++++++++++++- .../server/plugins/agent-chat.spec.ts | 51 +++++++- .../analytics/server/plugins/agent-chat.ts | 6 +- 14 files changed, 533 insertions(+), 15 deletions(-) diff --git a/.changeset/add-dbt-mcp-preset.md b/.changeset/add-dbt-mcp-preset.md index ad78667b508..ab680a506d8 100644 --- a/.changeset/add-dbt-mcp-preset.md +++ b/.changeset/add-dbt-mcp-preset.md @@ -2,4 +2,4 @@ "@agent-native/core": patch --- -Add a dbt hosted MCP integration preset with organization-scoped manual setup for account-specific endpoints and service-token headers. +Add a dbt hosted MCP integration preset with organization-scoped manual setup for account-specific endpoints and service-token headers, including parity for source-ejected integration catalogs. diff --git a/packages/core/src/eject/catalog-wrappers.spec.ts b/packages/core/src/eject/catalog-wrappers.spec.ts index 1ec0b655263..149b210be70 100644 --- a/packages/core/src/eject/catalog-wrappers.spec.ts +++ b/packages/core/src/eject/catalog-wrappers.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_MCP_INTEGRATIONS as RUNTIME_DEFAULT_MCP_INTEGRATIONS } from "../client/resources/mcp-integration-catalog.js"; import { getProviderApiConfig, listProviderApiCatalog, @@ -20,6 +21,26 @@ describe("ejected catalog wrappers", () => { vi.unstubAllGlobals(); }); + it("keeps the dbt preset identical in runtime and ejected catalogs", () => { + const runtimeDbt = RUNTIME_DEFAULT_MCP_INTEGRATIONS.find( + (integration) => integration.id === "dbt", + ); + const ejectedDbt = DEFAULT_MCP_INTEGRATIONS.find( + (integration) => integration.id === "dbt", + ); + + expect(ejectedDbt).toEqual(runtimeDbt); + expect(ejectedDbt).toMatchObject({ + authMode: "headers", + connectionMode: "manual", + availability: "provider-setup", + supportsOrganizationScope: true, + setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", + headerPlaceholder: + "Authorization: Token \nx-dbt-prod-environment-id: ", + }); + }); + it("lets caller MCP overrides win and injects the merged catalog into UI", () => { const github = DEFAULT_MCP_INTEGRATIONS.find( (integration) => integration.id === "github", diff --git a/packages/core/src/eject/remote-mcp-presets.ts b/packages/core/src/eject/remote-mcp-presets.ts index 0bf4627a1b2..f449d87bfd6 100644 --- a/packages/core/src/eject/remote-mcp-presets.ts +++ b/packages/core/src/eject/remote-mcp-presets.ts @@ -62,6 +62,7 @@ export const DEFAULT_MCP_INTEGRATIONS: DefaultMcpIntegration[] = [ preset("fullstory"), preset("notion"), preset("granola"), + preset("dbt"), preset("semgrep"), preset("linear"), preset("atlassian"), diff --git a/templates/analytics/.agents/skills/bigquery/SKILL.md b/templates/analytics/.agents/skills/bigquery/SKILL.md index 503608e46b2..cb618f5798c 100644 --- a/templates/analytics/.agents/skills/bigquery/SKILL.md +++ b/templates/analytics/.agents/skills/bigquery/SKILL.md @@ -55,7 +55,7 @@ table; `BIGQUERY_PROJECT_ID` is only the default project. are deduplicated, tested, and have canonical column names. Raw tables may have duplicates, schema drift, and inconsistent naming. -**Avoid `dbt_dev.*`** — development schema, excluded globally. +**Restrict `dbt_dev.*` and `dbt_backup.*` by default.** These schemas hold development, testing, or archived data. Do not discover or query them unless the latest end-user request explicitly names the schema and asks to inspect or query it. Only then pass `restrictedSchemaAccess: "user-explicit-request"` to the direct `search-bigquery-schema` or `bigquery` action. Never infer consent from agent-generated SQL. Broad schema search, saved dashboards, dry runs, and background query paths remain production-schema-only. ## Always Bound Queries by Date @@ -151,7 +151,7 @@ ON signups.user_id = contacts.user_id ``` IDs can be reassigned after deletes/merges. Email alone over-matches shared addresses. -Require both for exact matches; flag email-only or id-only joins as low-confidence caveats. +Require both for exact matches. When the relationship or grain is undocumented and the query proceeds with an inferred, email-only, ID-only, fuzzy, or row-multiplying join, state that the join was inferred and the result is lower confidence. Ask for clarification instead when the ambiguity could materially change the answer. Documented joins need no generic hedge. ## SQL Patterns diff --git a/templates/analytics/.agents/skills/data-querying/SKILL.md b/templates/analytics/.agents/skills/data-querying/SKILL.md index a1c0dcf9a87..48a80a26bff 100644 --- a/templates/analytics/.agents/skills/data-querying/SKILL.md +++ b/templates/analytics/.agents/skills/data-querying/SKILL.md @@ -196,6 +196,17 @@ Keep each entry short and actionable: what to do, what not to do, and why. This is the learnings flywheel — discoveries persist across sessions and improve future analyses. +## Conditional Caveats + +Keep routine exploratory answers direct. Add a concise caveat only when the evidence or requested use triggers one: + +- **Known stale data:** warn only when authoritative source metadata explicitly reports data beyond its expected refresh window. Include the observed refresh timestamp or window when available. Query-cache age and availability of freshness tools do not establish freshness. +- **Unknown freshness:** do not claim that data is current. Mention unverified freshness only when it materially affects the answer. +- **High-stakes distribution:** when the user explicitly says the output is for a client, board, investor, QBR, or executive distribution, recommend verifying figures against the source of record before distribution. Do not add this warning to routine internal exploration. +- **Complex inferred joins:** if an undocumented relationship or grain requires an inferred, email-only, ID-only, fuzzy, or row-multiplying join, label the join as inferred and the result as lower confidence. If the ambiguity could materially change the answer, clarify instead of merely hedging. Documented joins need no generic warning. + +Combine applicable caveats into one short note rather than stacking repetitive warnings. + ## Important Notes - Always query real data — never guess or approximate. Only present numbers you actually retrieved; do not claim a figure you did not query. diff --git a/templates/analytics/.agents/skills/dbt/SKILL.md b/templates/analytics/.agents/skills/dbt/SKILL.md index a35dd1d48c4..ac52fe25e3a 100644 --- a/templates/analytics/.agents/skills/dbt/SKILL.md +++ b/templates/analytics/.agents/skills/dbt/SKILL.md @@ -29,6 +29,14 @@ When Semantic Layer tools are available: Report the metric name, dimensions, filters, date range and time grain, plus environment or freshness caveats. dbt calls use the shared workspace dbt identity, not a personal warehouse identity. +## Restricted Schemas + +`dbt_dev` and `dbt_backup` are testing or archival schemas. Do not discover or query either schema by default. The direct `search-bigquery-schema` and `bigquery` actions may receive `restrictedSchemaAccess: "user-explicit-request"` only when the latest end-user request explicitly names the restricted schema and asks to inspect or query it. Never infer consent from SQL that the agent generated. Saved dashboards, dry runs, background reports, and broad schema searches remain production-schema-only. + +## Freshness + +A visible dbt health or freshness capability does not mean the data is fresh. Warn that data is stale only when returned dbt source/model metadata explicitly says it is beyond the expected refresh window, and include the observed timestamp or window when available. If freshness is unknown, do not claim freshness; mention that it could not be verified only when freshness materially affects the answer. + If MetricFlow cannot express the request, fall back to dbt metadata plus the `bigquery` action and label the result as ad hoc SQL. Missing Semantic Layer tools are a capability gap, not evidence that no metrics exist. Use Discovery metadata and BigQuery only when they can answer without inventing semantic definitions. ## Failure Semantics diff --git a/templates/analytics/actions/bigquery.spec.ts b/templates/analytics/actions/bigquery.spec.ts index 82257a30a39..2a4abfb77cb 100644 --- a/templates/analytics/actions/bigquery.spec.ts +++ b/templates/analytics/actions/bigquery.spec.ts @@ -4,8 +4,13 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const runQuery = vi.fn(); vi.mock("../server/lib/bigquery", () => ({ - runQuery: (sql: string, options?: { signal?: AbortSignal }) => - runQuery(sql, options), + runQuery: ( + sql: string, + options?: { + signal?: AbortSignal; + restrictedSchemaAccess?: "user-explicit-request"; + }, + ) => runQuery(sql, options), })); // Imported after the mock is registered so the action picks up the stub. @@ -68,6 +73,40 @@ describe("bigquery action error handling", () => { expect(String(result.hint)).toMatch(/LIMIT|narrow the date range/i); }); + it("returns a terminal dedicated error for a restricted schema", async () => { + const error = new Error( + 'BigQuery dataset "dbt_backup" is restricted because it is reserved for archived or testing data.', + ) as Error & { code: string; datasetId: string }; + error.code = "bigquery_restricted_schema"; + error.datasetId = "dbt_backup"; + runQuery.mockRejectedValue(error); + + await expect( + bigquery.run({ sql: "SELECT * FROM dbt_backup.signups" }), + ).rejects.toSatisfy((err: unknown) => { + if (!isAgentActionStopError(err)) return false; + expect(err.errorCode).toBe("bigquery_restricted_schema"); + expect(err.toolResult).toContain('"datasetId": "dbt_backup"'); + expect(err.toolResult).toContain('"recoverable": false'); + expect(err.toolResult).not.toMatch(/retry|search-bigquery-schema/i); + return true; + }); + }); + + it("forwards explicit restricted-schema access to the direct query path", async () => { + runQuery.mockResolvedValue({ rows: [], totalRows: 0 }); + + await bigquery.run({ + sql: "SELECT * FROM dbt_dev.signups", + restrictedSchemaAccess: "user-explicit-request", + }); + + expect(runQuery).toHaveBeenCalledWith("SELECT * FROM dbt_dev.signups", { + signal: undefined, + restrictedSchemaAccess: "user-explicit-request", + }); + }); + it("still stops the turn (non-recoverable) when BigQuery is not configured", async () => { runQuery.mockRejectedValue( new Error("GOOGLE_APPLICATION_CREDENTIALS_JSON not configured"), @@ -109,6 +148,7 @@ describe("bigquery action error handling", () => { expect(runQuery).toHaveBeenCalledWith("SELECT 1", { signal: controller.signal, + restrictedSchemaAccess: undefined, }); }); }); diff --git a/templates/analytics/actions/bigquery.ts b/templates/analytics/actions/bigquery.ts index 5f49aa404bf..6e81c55d717 100644 --- a/templates/analytics/actions/bigquery.ts +++ b/templates/analytics/actions/bigquery.ts @@ -51,6 +51,35 @@ function stopForBigQueryNotConfigured(message: string): never { }); } +function stopForRestrictedBigQuerySchema(err: unknown): never { + const error = err as { + code?: unknown; + datasetId?: unknown; + message?: unknown; + }; + const datasetId = + typeof error.datasetId === "string" + ? error.datasetId + : "restricted dataset"; + const message = + typeof error.message === "string" + ? error.message + : `BigQuery dataset "${datasetId}" is restricted.`; + throw new AgentActionStopError(message, { + errorCode: "bigquery_restricted_schema", + toolResult: JSON.stringify( + { + error: "bigquery_restricted_schema", + datasetId, + message, + recoverable: false, + }, + null, + 2, + ), + }); +} + function stopForBigQueryCancellation(): never { const message = "The BigQuery query was cancelled because the agent run ended before it could finish."; @@ -129,6 +158,12 @@ export default defineAction({ "Query the user-configured BigQuery data warehouse. Use this when the user asks for warehouse SQL, BigQuery, or a data-dictionary metric/table that lives in BigQuery. If the user names a provider action such as Jira or Pylon, use that provider action first and do not use BigQuery unless the user explicitly asks for a warehouse copy. For a named customer or organization ID, resolve the canonical CRM/contract identity first and verify the returned rows carry the same customer and org/root-org identifiers. For account health, distinguish completed-month usage from current partial snapshots, contract metrics from similarly named platform metrics, total distinct contracted users from DAU/WAU, and actual usage from contracted capacity. Pass standard SQL via the `sql` arg. Do NOT use `db-query` for warehouse data (it only reaches the app's own SQL database). If a query fails with a schema or SQL error (unknown dataset/table/column, syntax), treat it as a normal debugging signal: inspect the real schema with `search-bigquery-schema` (or query INFORMATION_SCHEMA), correct the query based on the error, and run it again — a few corrective attempts are expected. Surface the error to the user only if it still fails after a few attempts or is non-recoverable (missing credentials, permission, quota). Never rerun identical failing SQL, and never substitute made-up numbers for data you could not query.", schema: z.object({ sql: z.string().describe("SQL query to execute"), + restrictedSchemaAccess: z + .literal("user-explicit-request") + .optional() + .describe( + "Set only when the latest end-user request explicitly names dbt_dev or dbt_backup and asks to query it", + ), }), readOnly: true, toolCallable: true, @@ -138,7 +173,10 @@ export default defineAction({ stopForRepeatedBigQueryQuery(); } try { - return await runQuery(args.sql, { signal: context?.signal }); + return await runQuery(args.sql, { + signal: context?.signal, + restrictedSchemaAccess: args.restrictedSchemaAccess, + }); } catch (err) { // A run cancellation is terminal for this invocation. Returning it as a // recoverable SQL error would invite the agent to retry work after the @@ -146,6 +184,14 @@ export default defineAction({ // the generic tool-error path cannot record it as a warehouse failure. if (context?.signal?.aborted) stopForBigQueryCancellation(); + const errorCode = + err && typeof err === "object" && "code" in err + ? (err as { code?: unknown }).code + : undefined; + if (errorCode === "bigquery_restricted_schema") { + stopForRestrictedBigQuerySchema(err); + } + const msg = err instanceof Error ? err.message : String(err); if ( /GOOGLE_APPLICATION_CREDENTIALS_JSON not configured/i.test(msg) || diff --git a/templates/analytics/actions/search-bigquery-schema.spec.ts b/templates/analytics/actions/search-bigquery-schema.spec.ts index 7ded8c71446..9cc8107c998 100644 --- a/templates/analytics/actions/search-bigquery-schema.spec.ts +++ b/templates/analytics/actions/search-bigquery-schema.spec.ts @@ -11,6 +11,25 @@ vi.mock("@agent-native/core", () => ({ })); vi.mock("../server/lib/bigquery", () => ({ getBigQueryProjectId: mocks.getBigQueryProjectId, + isRestrictedBigQueryDataset: (datasetId: string) => + ["dbt_dev", "dbt_backup"].includes(datasetId.trim().toLowerCase()), + enforceBigQueryRestrictedDatasetPolicy: ( + datasetId: string, + options?: { restrictedSchemaAccess?: string }, + ) => { + const normalized = datasetId.trim().toLowerCase(); + if ( + ["dbt_dev", "dbt_backup"].includes(normalized) && + options?.restrictedSchemaAccess !== "user-explicit-request" + ) { + const error = new Error( + `BigQuery dataset "${normalized}" is restricted.`, + ) as Error & { code: string; datasetId: string }; + error.code = "bigquery_restricted_schema"; + error.datasetId = normalized; + throw error; + } + }, })); vi.mock("../server/lib/gcloud", () => ({ getAccessToken: mocks.getAccessToken, @@ -46,6 +65,18 @@ beforeEach(() => { datasetId: "product", }, }, + { + datasetReference: { + projectId: "test-project", + datasetId: "dbt_dev", + }, + }, + { + datasetReference: { + projectId: "test-project", + datasetId: "dbt_backup", + }, + }, ], }); } @@ -73,6 +104,32 @@ beforeEach(() => { }); } + if (path.endsWith("/datasets/dbt_backup/tables")) { + return jsonResponse({ + tables: [ + { + tableReference: { + projectId: "test-project", + datasetId: "dbt_backup", + tableId: "archived_signups", + }, + type: "TABLE", + }, + ], + }); + } + + if (path.endsWith("/datasets/dbt_backup/tables/archived_signups")) { + return jsonResponse({ + tableReference: { + projectId: "test-project", + datasetId: "dbt_backup", + tableId: "archived_signups", + }, + schema: { fields: [{ name: "created_at", type: "TIMESTAMP" }] }, + }); + } + if (path.endsWith("/datasets/product/tables/branch_creation")) { return jsonResponse({ tableReference: { @@ -148,13 +205,61 @@ describe("search-bigquery-schema", () => { ]); }); - it("keeps the no-argument call as a lightweight dataset listing", async () => { - const result = await action.run({}); + it("keeps restricted datasets out of no-argument and global searches", async () => { + const datasetsResult = await action.run({}); + const searchResult = await action.run({ search: "created", limit: 10 }); - expect(result).toMatchObject({ + expect(datasetsResult).toMatchObject({ mode: "datasets", datasets: [{ datasetId: "product" }], }); - expect(mocks.fetch).toHaveBeenCalledTimes(1); + expect(searchResult.datasetsScanned).toBe(1); + expect( + mocks.fetch.mock.calls.some(([input]) => + String(input).includes("/datasets/dbt_backup/tables"), + ), + ).toBe(false); + expect( + mocks.fetch.mock.calls.some(([input]) => + String(input).includes("/datasets/dbt_dev/tables"), + ), + ).toBe(false); + }); + + it.each([ + [{ dataset: "dbt_backup" }, "dbt_backup"], + [{ table: "dbt_dev.signups" }, "dbt_dev"], + ])( + "rejects direct restricted metadata access by default", + async (args, datasetId) => { + await expect(action.run(args)).rejects.toMatchObject({ + code: "bigquery_restricted_schema", + datasetId, + }); + }, + ); + + it("allows a specifically requested restricted dataset with the explicit marker", async () => { + const tablesResult = await action.run({ + dataset: "dbt_backup", + restrictedSchemaAccess: "user-explicit-request", + }); + const tableResult = await action.run({ + table: "dbt_backup.archived_signups", + restrictedSchemaAccess: "user-explicit-request", + }); + + expect(tablesResult).toMatchObject({ + mode: "tables", + datasetId: "dbt_backup", + tables: [{ tableId: "archived_signups" }], + }); + expect(tableResult).toMatchObject({ + mode: "table", + table: { + datasetId: "dbt_backup", + tableId: "archived_signups", + }, + }); }); }); diff --git a/templates/analytics/actions/search-bigquery-schema.ts b/templates/analytics/actions/search-bigquery-schema.ts index c63cbe8a6db..6271699a8a6 100644 --- a/templates/analytics/actions/search-bigquery-schema.ts +++ b/templates/analytics/actions/search-bigquery-schema.ts @@ -1,7 +1,11 @@ import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; -import { getBigQueryProjectId } from "../server/lib/bigquery"; +import { + enforceBigQueryRestrictedDatasetPolicy, + getBigQueryProjectId, + isRestrictedBigQueryDataset, +} from "../server/lib/bigquery"; import { getAccessToken } from "../server/lib/gcloud"; import { cliBoolean } from "./schema-helpers"; @@ -237,6 +241,12 @@ async function listDatasets(projectId: string, limit: number, search: string) { location: dataset.location, })) .filter((dataset) => { + if ( + typeof dataset.datasetId === "string" && + isRestrictedBigQueryDataset(dataset.datasetId) + ) { + return false; + } if (!q) return true; return [dataset.datasetId, dataset.friendlyName, dataset.location] .filter(Boolean) @@ -407,6 +417,12 @@ export default defineAction({ .max(200) .optional() .describe("Maximum results to return (default 50, max 200)"), + restrictedSchemaAccess: z + .literal("user-explicit-request") + .optional() + .describe( + "Set only when the latest end-user request explicitly names dbt_dev or dbt_backup and asks to inspect it", + ), }), http: { method: "GET" }, readOnly: true, @@ -418,6 +434,9 @@ export default defineAction({ if (args.table) { const ref = parseTableRef(configuredProjectId, args.dataset, args.table); + enforceBigQueryRestrictedDatasetPolicy(ref.datasetId, { + restrictedSchemaAccess: args.restrictedSchemaAccess, + }); const meta = await getTableMetadata( ref.projectId, ref.datasetId, @@ -443,6 +462,9 @@ export default defineAction({ } const datasetId = assertIdentifier("dataset", args.dataset); + enforceBigQueryRestrictedDatasetPolicy(datasetId, { + restrictedSchemaAccess: args.restrictedSchemaAccess, + }); const tables = await listTables(configuredProjectId, datasetId, limit); const includeColumns = args.includeColumns === true || !!search; diff --git a/templates/analytics/server/lib/bigquery.spec.ts b/templates/analytics/server/lib/bigquery.spec.ts index 2c226b63a89..ae1ebb1f656 100644 --- a/templates/analytics/server/lib/bigquery.spec.ts +++ b/templates/analytics/server/lib/bigquery.spec.ts @@ -19,7 +19,13 @@ vi.mock("./credentials-context", () => ({ vi.mock("./gcloud", () => ({ getAccessToken })); -const { dryRunQuery, runQuery } = await import("./bigquery"); +const { + BigQueryRestrictedSchemaError, + dryRunQuery, + enforceBigQueryRestrictedSchemaPolicy, + findRestrictedBigQueryDataset, + runQuery, +} = await import("./bigquery"); function jsonResponse(data: unknown): Response { return { @@ -30,6 +36,51 @@ function jsonResponse(data: unknown): Response { } as Response; } +describe("restricted BigQuery schema policy", () => { + it("allows production datasets", () => { + expect(() => + enforceBigQueryRestrictedSchemaPolicy( + "SELECT * FROM `example-project.dbt_analytics.signups`", + ), + ).not.toThrow(); + }); + + it.each([ + ["SELECT * FROM `example-project.dbt_dev.signups`", "dbt_dev"], + ["SELECT * FROM dbt_backup.signups", "dbt_backup"], + ["SELECT * FROM example_project.dbt_backup.signups", "dbt_backup"], + ])("detects restricted table references in %s", (sql, datasetId) => { + expect(findRestrictedBigQueryDataset(sql)).toBe(datasetId); + expect(() => enforceBigQueryRestrictedSchemaPolicy(sql)).toThrow( + BigQueryRestrictedSchemaError, + ); + }); + + it("ignores comments, string literals, and similarly named datasets", () => { + const sql = ` + -- SELECT * FROM dbt_dev.signups + /* JOIN \`example-project.dbt_backup.users\` ON TRUE */ + SELECT 'dbt_backup.signups' AS example, + "dbt_dev.users" AS another_example + FROM \`example-project.dbt_backup_copy.signups\` + `; + + expect(findRestrictedBigQueryDataset(sql)).toBeNull(); + }); + + it.each(["dbt_dev", "dbt_backup"])( + "allows %s only with the internal explicit-request marker", + (datasetId) => { + expect(() => + enforceBigQueryRestrictedSchemaPolicy( + `SELECT * FROM \`example-project.${datasetId}.signups\``, + { restrictedSchemaAccess: "user-explicit-request" }, + ), + ).not.toThrow(); + }, + ); +}); + describe("runQuery cancellation", () => { beforeEach(() => { execute.mockReset(); @@ -47,6 +98,55 @@ describe("runQuery cancellation", () => { vi.unstubAllGlobals(); }); + it("rejects restricted schemas before credentials, cache, or network access", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + runQuery("SELECT * FROM `example-project.dbt_backup.signups`"), + ).rejects.toMatchObject({ + code: "bigquery_restricted_schema", + datasetId: "dbt_backup", + }); + + expect(resolveCredential).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows restricted schemas on the explicitly opted-in direct path", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + jobComplete: true, + schema: { fields: [{ name: "value", type: "INT64" }] }, + rows: [{ f: [{ v: "1" }] }], + totalBytesProcessed: "1", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + runQuery("SELECT * FROM `example-project.dbt_dev.explicit_test`", { + restrictedSchemaAccess: "user-explicit-request", + }), + ).resolves.toMatchObject({ rows: [{ value: 1 }] }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps dry-run validation blocked for restricted schemas", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + dryRunQuery("SELECT * FROM `example-project.dbt_backup.signups`"), + ).rejects.toMatchObject({ + code: "bigquery_restricted_schema", + datasetId: "dbt_backup", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("stops an incomplete job's poll wait immediately when the agent run aborts", async () => { vi.useFakeTimers(); const controller = new AbortController(); diff --git a/templates/analytics/server/lib/bigquery.ts b/templates/analytics/server/lib/bigquery.ts index 420e1359db1..65423b33ef0 100644 --- a/templates/analytics/server/lib/bigquery.ts +++ b/templates/analytics/server/lib/bigquery.ts @@ -244,7 +244,116 @@ export interface QueryResult { truncated?: boolean; } -export interface RunQueryOptions { +export type RestrictedSchemaAccess = "user-explicit-request"; + +export interface RestrictedSchemaPolicyOptions { + restrictedSchemaAccess?: RestrictedSchemaAccess; +} + +export class BigQueryRestrictedSchemaError extends Error { + readonly code = "bigquery_restricted_schema"; + + constructor(readonly datasetId: string) { + super( + `BigQuery dataset "${datasetId}" is restricted because it is reserved for archived or testing data. Access it only when the latest end-user request explicitly names this dataset.`, + ); + this.name = "BigQueryRestrictedSchemaError"; + } +} + +const RESTRICTED_BIGQUERY_DATASETS = new Set(["dbt_dev", "dbt_backup"]); + +export function isRestrictedBigQueryDataset(datasetId: string): boolean { + return RESTRICTED_BIGQUERY_DATASETS.has(datasetId.trim().toLowerCase()); +} + +export function enforceBigQueryRestrictedDatasetPolicy( + datasetId: string, + options: RestrictedSchemaPolicyOptions = {}, +): void { + const normalizedDatasetId = datasetId.trim().toLowerCase(); + if ( + isRestrictedBigQueryDataset(normalizedDatasetId) && + options.restrictedSchemaAccess !== "user-explicit-request" + ) { + throw new BigQueryRestrictedSchemaError(normalizedDatasetId); + } +} + +function stripSqlCommentsAndStrings(sql: string): string { + let sanitized = ""; + let index = 0; + + const replaceUntil = (end: number) => { + sanitized += sql.slice(index, end).replace(/[^\r\n]/g, " "); + index = end; + }; + + while (index < sql.length) { + if (sql.startsWith("--", index) || sql[index] === "#") { + const newline = sql.indexOf("\n", index); + replaceUntil(newline === -1 ? sql.length : newline); + continue; + } + if (sql.startsWith("/*", index)) { + const close = sql.indexOf("*/", index + 2); + replaceUntil(close === -1 ? sql.length : close + 2); + continue; + } + + const quote = sql[index]; + if (quote !== "'" && quote !== '"') { + sanitized += quote; + index += 1; + continue; + } + + const delimiter = sql.startsWith(quote.repeat(3), index) + ? quote.repeat(3) + : quote; + let cursor = index + delimiter.length; + while (cursor < sql.length) { + if (sql[cursor] === "\\") { + cursor += 2; + continue; + } + if (sql.startsWith(delimiter, cursor)) { + cursor += delimiter.length; + break; + } + cursor += 1; + } + replaceUntil(cursor); + } + + return sanitized; +} + +export function findRestrictedBigQueryDataset(sql: string): string | null { + const sanitized = stripSqlCommentsAndStrings(sql); + const referencePattern = + /(?:`(?:[A-Za-z][A-Za-z0-9-]*\.)?(dbt_dev|dbt_backup)\.[^`]+`|(?:^|[^A-Za-z0-9_-])(?:[A-Za-z][A-Za-z0-9-]*\.)?(dbt_dev|dbt_backup)\.[A-Za-z_][A-Za-z0-9_$]*)/gi; + + for (const match of sanitized.matchAll(referencePattern)) { + const datasetId = (match[1] ?? match[2])?.toLowerCase(); + if (datasetId && RESTRICTED_BIGQUERY_DATASETS.has(datasetId)) { + return datasetId; + } + } + return null; +} + +export function enforceBigQueryRestrictedSchemaPolicy( + sql: string, + options: RestrictedSchemaPolicyOptions = {}, +): void { + const datasetId = findRestrictedBigQueryDataset(sql); + if (datasetId && options.restrictedSchemaAccess !== "user-explicit-request") { + throw new BigQueryRestrictedSchemaError(datasetId); + } +} + +export interface RunQueryOptions extends RestrictedSchemaPolicyOptions { /** * The current agent run's abort signal. This cancels in-flight BigQuery * requests and, importantly, stops the one-second job polling wait without @@ -392,6 +501,7 @@ export async function dryRunQuery( sql: string, options: DryRunQueryOptions = {}, ): Promise { + enforceBigQueryRestrictedSchemaPolicy(sql); if (options.signal?.aborted) { throw new Error("BigQuery validation was cancelled before it started"); } @@ -462,6 +572,7 @@ export async function runQuery( sql: string, options: RunQueryOptions = {}, ): Promise { + enforceBigQueryRestrictedSchemaPolicy(sql, options); const { signal } = options; throwIfAborted(signal); const { projectId, cacheScope, appEventsTable } = await getProjectInfo(); diff --git a/templates/analytics/server/plugins/agent-chat.spec.ts b/templates/analytics/server/plugins/agent-chat.spec.ts index 45edc774e83..6234ac158dd 100644 --- a/templates/analytics/server/plugins/agent-chat.spec.ts +++ b/templates/analytics/server/plugins/agent-chat.spec.ts @@ -15,6 +15,14 @@ const dbtSkill = readFileSync( new URL("../../.agents/skills/dbt/SKILL.md", import.meta.url), "utf8", ); +const bigquerySkill = readFileSync( + new URL("../../.agents/skills/bigquery/SKILL.md", import.meta.url), + "utf8", +); +const dataQueryingSkill = readFileSync( + new URL("../../.agents/skills/data-querying/SKILL.md", import.meta.url), + "utf8", +); const { agentChatPluginOptions, representativeAnalyticsActions } = vi.hoisted( () => ({ @@ -99,6 +107,7 @@ import { ANALYTICS_CUSTOM_BLOCK_GUIDANCE, ANALYTICS_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS, ANALYTICS_ACCOUNT_HEALTH_GUIDANCE, + ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE, INTERNAL_PRODUCT_USAGE_GUIDANCE, BOUNDED_STRUCTURED_LOOKUP_GUIDANCE, DASHBOARD_REFERENCE_GUIDANCE, @@ -131,6 +140,7 @@ describe("Analytics agent Plan mode policy", () => { expect(guidance).toContain(""); expect(guidance).toContain(BOUNDED_STRUCTURED_LOOKUP_GUIDANCE); expect(guidance).toContain(ANALYTICS_ACCOUNT_HEALTH_GUIDANCE); + expect(guidance).toContain(ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE); expect(guidance).toContain(ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE); expect(guidance).toContain(ANALYTICS_CROSS_APP_ROUTING_GUIDANCE); expect(guidance).toContain(BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE); @@ -293,10 +303,49 @@ describe("Analytics agent Plan mode policy", () => { ); expect(unreadable).toContain("status is unreadable"); expect(unreadable).toContain("Do not infer that dbt is disconnected"); - expect(connected.length).toBeLessThan(700); + expect(connected).toContain( + "visible health/freshness capability does not mean the underlying data is fresh", + ); + expect(connected.length).toBeLessThan(900); expect(unreadable.length).toBeLessThan(500); }); + it("applies restricted-schema access only to explicit direct requests", () => { + for (const skill of [dbtSkill, bigquerySkill]) { + expect(skill).toMatch(/dbt_dev/); + expect(skill).toMatch(/dbt_backup/); + expect(skill).toContain( + 'restrictedSchemaAccess: "user-explicit-request"', + ); + expect(skill).toMatch(/latest end-user request explicitly names/i); + expect(skill).toMatch( + /Never infer consent from (?:SQL|agent-generated SQL)/i, + ); + } + expect(dbtSkill).toMatch(/Saved dashboards, dry runs, background reports/); + expect(bigquerySkill).toMatch( + /Broad schema search, saved dashboards, dry runs/, + ); + }); + + it("keeps stale, high-stakes, and inferred-join caveats conditional", () => { + const guidance = ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE; + + expect(guidance).toContain("explicitly reports"); + expect(guidance).toContain("does not prove that data is fresh or stale"); + expect(guidance).toContain("client-facing, board, investor, QBR"); + expect(guidance).toContain( + "routine internal exploration needs no such warning", + ); + expect(guidance).toContain("join was inferred"); + expect(guidance).toContain("Documented joins need no generic hedge"); + expect(guidance).toContain("Combine multiple applicable caveats"); + + expect(dataQueryingSkill).toMatch(/Known stale data/); + expect(dataQueryingSkill).toMatch(/High-stakes distribution/); + expect(dataQueryingSkill).toMatch(/Complex inferred joins/); + }); + it("documents the governed dbt and MetricFlow decision order", () => { for (const toolName of [ "get_node_details", diff --git a/templates/analytics/server/plugins/agent-chat.ts b/templates/analytics/server/plugins/agent-chat.ts index 366758f8c43..7bdab8be68a 100644 --- a/templates/analytics/server/plugins/agent-chat.ts +++ b/templates/analytics/server/plugins/agent-chat.ts @@ -258,6 +258,9 @@ export const ANALYTICS_ACCOUNT_HEALTH_GUIDANCE = export const DASHBOARD_REFERENCE_GUIDANCE = "DASHBOARD REFERENCE DISCOVERY — When the user asks to replicate, clone, or adapt an existing dashboard, this branch takes precedence over the ordinary metric fast path: call `search-dashboard-references` with focused terms before creating, editing, or querying anything. It searches accessible active saved dashboard ids, names, descriptions, and serialized config with bounded SQL wildcard matches, including legacy saved dashboards. Treat each result as a reference to inspect with `get-sql-dashboard` when `kind` is `sql` or `get-explorer-dashboard` when `kind` is `explorer`, not as proof that its source is authoritative for the new request. Do not automatically route a replication request to first-party Analytics or copy its source semantics without checking the user's requested provider and scope. "; +export const ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE = + "CONDITIONAL CAVEATS — Do not add generic confidence or verification warnings. Treat data as stale only when dbt health/source freshness or another authoritative source explicitly reports that it is beyond its expected refresh window; include the observed refresh timestamp or window when available. A freshness-capable tool, query-cache age, or unknown freshness does not prove that data is fresh or stale. Mention unverified freshness only when it materially affects the requested answer. For explicitly client-facing, board, investor, QBR, or executive-distribution output, recommend verifying figures against the source of record before distribution; routine internal exploration needs no such warning. When an undocumented relationship or grain requires an inferred, email-only, ID-only, fuzzy, or row-multiplying join, say that the join was inferred and the result is lower confidence. If the ambiguity could materially change the answer, clarify instead of merely hedging. Documented joins need no generic hedge. Combine multiple applicable caveats into one concise note. "; + export const BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE = "BUILT-IN FIRST-PARTY SOURCE — Analytics always provides one built-in first-party source alongside connected external providers such as BigQuery, HubSpot, Gong, Slack, and the other configured integrations. This does not replace or restrict external sources. When `search-analytics-query-catalog` identifies a first-party dashboard/chart definition, preserve its event semantics and use `query-agent-native-analytics` over `analytics_events` or `session_recordings` as appropriate. When the user names an external provider, or the catalog identifies one as authoritative, query that provider instead. Do not report the first-party source as disconnected merely because an external provider is not configured. If the authoritative query returns no rows, report that grounded result with its scope and time window. "; @@ -310,6 +313,7 @@ export function analyticsSourceGuidanceOpening(): string { BOUNDED_STRUCTURED_LOOKUP_GUIDANCE + INTERNAL_PRODUCT_USAGE_GUIDANCE + ANALYTICS_ACCOUNT_HEALTH_GUIDANCE + + ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE + BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE + ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE + `DATA-SOURCE SETUP UX — Chat remains available when no external data source is connected. For a live-data request that needs an unavailable external provider, explain what is missing in the context of the user's question and guide them naturally to [Connect data sources](${ANALYTICS_DATA_SOURCES_LINK}). Use that real link from the app; do not emit a generic canned no-data sentence. For general conversation, conceptual questions, and questions the built-in first-party source can answer, continue helping normally. ` + @@ -354,7 +358,7 @@ No dynamic dbt capability was visible in a successful connection check. This is ? "Use MetricFlow for governed metric requests." : "Missing Semantic Layer tools are a capability gap, not evidence that no metrics exist."; return ` -dbt is connected with these visible capabilities: ${capabilities.join(", ") || "none classified"}. Read the dbt skill, then discover the exact dynamic dbt tools with tool-search. dbt owns model semantics and lineage; verify physical BigQuery relations with search-bigquery-schema and run direct SQL only through the bigquery action. ${semanticLayerGuidance} +dbt is connected with these visible capabilities: ${capabilities.join(", ") || "none classified"}. Read the dbt skill, then discover the exact dynamic dbt tools with tool-search. dbt owns model semantics and lineage; verify physical BigQuery relations with search-bigquery-schema and run direct SQL only through the bigquery action. A visible health/freshness capability does not mean the underlying data is fresh; use the returned status and timestamps. ${semanticLayerGuidance} `; } From 668c4e44c4ea4116116d26e055b9081dc7cccd01 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 3 Sep 2026 15:17:37 +0000 Subject: [PATCH 11/14] Harden BigQuery schema access and paginate dataset searches --- .../actions/search-bigquery-schema.spec.ts | 68 +++++++- .../actions/search-bigquery-schema.ts | 163 ++++++++++++++---- .../analytics/server/lib/bigquery.spec.ts | 42 +++++ templates/analytics/server/lib/bigquery.ts | 50 ++++-- 4 files changed, 267 insertions(+), 56 deletions(-) diff --git a/templates/analytics/actions/search-bigquery-schema.spec.ts b/templates/analytics/actions/search-bigquery-schema.spec.ts index 9cc8107c998..7469116bc43 100644 --- a/templates/analytics/actions/search-bigquery-schema.spec.ts +++ b/templates/analytics/actions/search-bigquery-schema.spec.ts @@ -7,6 +7,19 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@agent-native/core", () => ({ + AgentActionStopError: class AgentActionStopError extends Error { + errorCode?: string; + toolResult?: string; + + constructor( + message: string, + details?: { errorCode?: string; toolResult?: string }, + ) { + super(message); + this.errorCode = details?.errorCode; + this.toolResult = details?.toolResult; + } + }, defineAction: (definition: unknown) => definition, })); vi.mock("../server/lib/bigquery", () => ({ @@ -230,15 +243,62 @@ describe("search-bigquery-schema", () => { [{ dataset: "dbt_backup" }, "dbt_backup"], [{ table: "dbt_dev.signups" }, "dbt_dev"], ])( - "rejects direct restricted metadata access by default", + "rejects direct restricted metadata access before credentials or network", async (args, datasetId) => { - await expect(action.run(args)).rejects.toMatchObject({ - code: "bigquery_restricted_schema", - datasetId, + await expect(action.run(args)).rejects.toSatisfy((err: unknown) => { + if (!err || typeof err !== "object") return false; + const stopped = err as { errorCode?: unknown; toolResult?: unknown }; + expect(stopped.errorCode).toBe("bigquery_restricted_schema"); + expect(String(stopped.toolResult)).toContain( + `"datasetId": "${datasetId}"`, + ); + expect(String(stopped.toolResult)).toContain('"recoverable": false'); + return true; }); + expect(mocks.getBigQueryProjectId).not.toHaveBeenCalled(); + expect(mocks.getAccessToken).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); }, ); + it("pages past restricted datasets to return allowed datasets", async () => { + mocks.fetch.mockImplementation(async (input: URL | string) => { + const url = new URL(String(input)); + if (url.searchParams.get("pageToken") === "next-page") { + return jsonResponse({ + datasets: [ + { + datasetReference: { + projectId: "test-project", + datasetId: "product", + }, + }, + ], + }); + } + return jsonResponse({ + datasets: [ + { + datasetReference: { + projectId: "test-project", + datasetId: "dbt_backup", + }, + }, + ], + nextPageToken: "next-page", + }); + }); + + const result = await action.run({ limit: 1 }); + + expect(result).toMatchObject({ + mode: "datasets", + datasets: [{ datasetId: "product" }], + truncated: false, + }); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + }); + it("allows a specifically requested restricted dataset with the explicit marker", async () => { const tablesResult = await action.run({ dataset: "dbt_backup", diff --git a/templates/analytics/actions/search-bigquery-schema.ts b/templates/analytics/actions/search-bigquery-schema.ts index 6271699a8a6..92ff89665af 100644 --- a/templates/analytics/actions/search-bigquery-schema.ts +++ b/templates/analytics/actions/search-bigquery-schema.ts @@ -1,3 +1,4 @@ +import { AgentActionStopError } from "@agent-native/core"; import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; @@ -24,6 +25,7 @@ interface DatasetListResponse { labels?: Record; location?: string; }>; + nextPageToken?: string; } interface TableListResponse { @@ -83,6 +85,71 @@ function assertIdentifier( return clean; } +function requestedDatasetId( + dataset: string | undefined, + table: string, +): string | undefined { + const cleanTable = table.trim().replace(/^`|`$/g, ""); + const parts = cleanTable.split("."); + if (parts.length === 2) return assertIdentifier("dataset", parts[0]); + if (parts.length === 3) return assertIdentifier("dataset", parts[1]); + if (parts.length === 1 && dataset) { + return assertIdentifier("dataset", dataset); + } + return undefined; +} + +function stopForRestrictedSchema(err: unknown): never { + const error = err as { + code?: unknown; + datasetId?: unknown; + message?: unknown; + }; + const datasetId = + typeof error.datasetId === "string" + ? error.datasetId + : "restricted dataset"; + const message = + typeof error.message === "string" + ? error.message + : `BigQuery dataset "${datasetId}" is restricted.`; + throw new AgentActionStopError(message, { + errorCode: "bigquery_restricted_schema", + toolResult: JSON.stringify( + { + error: "bigquery_restricted_schema", + datasetId, + message, + recoverable: false, + }, + null, + 2, + ), + }); +} + +function enforceRequestedDatasetPolicy( + datasetId: string | undefined, + restrictedSchemaAccess: "user-explicit-request" | undefined, +): void { + if (!datasetId) return; + try { + enforceBigQueryRestrictedDatasetPolicy(datasetId, { + restrictedSchemaAccess, + }); + } catch (err) { + if ( + err && + typeof err === "object" && + "code" in err && + (err as { code?: unknown }).code === "bigquery_restricted_schema" + ) { + stopForRestrictedSchema(err); + } + throw err; + } +} + function parseTableRef( projectId: string, dataset: string | undefined, @@ -226,35 +293,56 @@ function matchesSearch(meta: TableMetadata, search: string): boolean { } async function listDatasets(projectId: string, limit: number, search: string) { - const url = new URL( - `https://bigquery.googleapis.com/bigquery/v2/projects/${projectId}/datasets`, - ); - url.searchParams.set("maxResults", String(Math.min(limit, 1000))); - const result = await bigQueryGet(url.toString()); + const datasets: Array<{ + projectId?: string; + datasetId?: string; + friendlyName?: string; + labels?: Record; + location?: string; + }> = []; const q = search.toLowerCase(); - return (result.datasets ?? []) - .map((dataset) => ({ - projectId: dataset.datasetReference?.projectId, - datasetId: dataset.datasetReference?.datasetId, - friendlyName: dataset.friendlyName, - labels: dataset.labels, - location: dataset.location, - })) - .filter((dataset) => { + let pageToken: string | undefined; + + do { + const url = new URL( + `https://bigquery.googleapis.com/bigquery/v2/projects/${projectId}/datasets`, + ); + url.searchParams.set("maxResults", String(Math.min(limit + 2, 1000))); + if (pageToken) url.searchParams.set("pageToken", pageToken); + const result = await bigQueryGet(url.toString()); + for (const dataset of result.datasets ?? []) { + const compact = { + projectId: dataset.datasetReference?.projectId, + datasetId: dataset.datasetReference?.datasetId, + friendlyName: dataset.friendlyName, + labels: dataset.labels, + location: dataset.location, + }; if ( - typeof dataset.datasetId === "string" && - isRestrictedBigQueryDataset(dataset.datasetId) + typeof compact.datasetId === "string" && + isRestrictedBigQueryDataset(compact.datasetId) ) { - return false; + continue; } - if (!q) return true; - return [dataset.datasetId, dataset.friendlyName, dataset.location] - .filter(Boolean) - .join(" ") - .toLowerCase() - .includes(q); - }) - .slice(0, limit); + if ( + q && + ![compact.datasetId, compact.friendlyName, compact.location] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(q) + ) { + continue; + } + datasets.push(compact); + } + pageToken = result.nextPageToken; + } while (datasets.length < limit && pageToken); + + return { + datasets: datasets.slice(0, limit), + truncated: datasets.length > limit || Boolean(pageToken), + }; } async function listTables(projectId: string, datasetId: string, limit: number) { @@ -278,18 +366,20 @@ async function searchAcrossDatasets( search: string, limit: number, ) { - const datasets = await listDatasets( + const datasetPage = await listDatasets( projectId, GLOBAL_SEARCH_DATASET_LIMIT + 1, "", ); + const datasets = datasetPage.datasets; const scannableDatasets = datasets.slice(0, GLOBAL_SEARCH_DATASET_LIMIT); const tables: BigQueryTableSummary[] = []; const datasetCount = scannableDatasets.filter( (dataset) => typeof dataset.datasetId === "string" && dataset.datasetId, ).length; let datasetsScanned = 0; - let truncated = datasets.length > GLOBAL_SEARCH_DATASET_LIMIT; + let truncated = + datasetPage.truncated || datasets.length > GLOBAL_SEARCH_DATASET_LIMIT; for (const dataset of scannableDatasets) { const datasetId = dataset.datasetId; @@ -428,15 +518,22 @@ export default defineAction({ readOnly: true, toolCallable: true, run: async (args) => { + enforceRequestedDatasetPolicy( + args.table + ? requestedDatasetId(args.dataset, args.table) + : args.dataset + ? assertIdentifier("dataset", args.dataset) + : undefined, + args.restrictedSchemaAccess, + ); + const configuredProjectId = await getBigQueryProjectId(); const limit = args.limit ?? 50; const search = (args.search ?? "").trim(); if (args.table) { const ref = parseTableRef(configuredProjectId, args.dataset, args.table); - enforceBigQueryRestrictedDatasetPolicy(ref.datasetId, { - restrictedSchemaAccess: args.restrictedSchemaAccess, - }); + enforceRequestedDatasetPolicy(ref.datasetId, args.restrictedSchemaAccess); const meta = await getTableMetadata( ref.projectId, ref.datasetId, @@ -455,16 +552,14 @@ export default defineAction({ return { mode: "datasets", projectId: configuredProjectId, - datasets: await listDatasets(configuredProjectId, limit, search), + ...(await listDatasets(configuredProjectId, limit, search)), nextStep: "Pass dataset= to list tables, or table=dataset.table to inspect columns.", }; } const datasetId = assertIdentifier("dataset", args.dataset); - enforceBigQueryRestrictedDatasetPolicy(datasetId, { - restrictedSchemaAccess: args.restrictedSchemaAccess, - }); + enforceRequestedDatasetPolicy(datasetId, args.restrictedSchemaAccess); const tables = await listTables(configuredProjectId, datasetId, limit); const includeColumns = args.includeColumns === true || !!search; diff --git a/templates/analytics/server/lib/bigquery.spec.ts b/templates/analytics/server/lib/bigquery.spec.ts index ae1ebb1f656..c4dfad193a7 100644 --- a/templates/analytics/server/lib/bigquery.spec.ts +++ b/templates/analytics/server/lib/bigquery.spec.ts @@ -49,6 +49,9 @@ describe("restricted BigQuery schema policy", () => { ["SELECT * FROM `example-project.dbt_dev.signups`", "dbt_dev"], ["SELECT * FROM dbt_backup.signups", "dbt_backup"], ["SELECT * FROM example_project.dbt_backup.signups", "dbt_backup"], + ["SELECT * FROM `dbt_backup`.signups", "dbt_backup"], + ["SELECT * FROM `example-project`.`dbt_dev`.`signups`", "dbt_dev"], + ["SELECT * FROM dbt_backup /* hidden */ . signups", "dbt_backup"], ])("detects restricted table references in %s", (sql, datasetId) => { expect(findRestrictedBigQueryDataset(sql)).toBe(datasetId); expect(() => enforceBigQueryRestrictedSchemaPolicy(sql)).toThrow( @@ -68,6 +71,20 @@ describe("restricted BigQuery schema policy", () => { expect(findRestrictedBigQueryDataset(sql)).toBeNull(); }); + it("rejects dynamic SQL unless the direct path has explicit access", () => { + const sql = + "EXECUTE IMMEDIATE CONCAT('SELECT * FROM dbt_', 'backup.signups')"; + + expect(() => enforceBigQueryRestrictedSchemaPolicy(sql)).toThrow( + BigQueryRestrictedSchemaError, + ); + expect(() => + enforceBigQueryRestrictedSchemaPolicy(sql, { + restrictedSchemaAccess: "user-explicit-request", + }), + ).not.toThrow(); + }); + it.each(["dbt_dev", "dbt_backup"])( "allows %s only with the internal explicit-request marker", (datasetId) => { @@ -134,6 +151,31 @@ describe("runQuery cancellation", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("rechecks the configured app-events relation after placeholder expansion", async () => { + resolveCredential.mockImplementation(async (key: string) => { + if (key === "BIGQUERY_PROJECT_ID") return "test-project"; + if (key === "ANALYTICS_BIGQUERY_EVENTS_TABLE") { + return "test-project.dbt_backup.events"; + } + return null; + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(runQuery("SELECT * FROM @app_events")).rejects.toMatchObject({ + code: "bigquery_restricted_schema", + datasetId: "dbt_backup", + }); + await expect( + dryRunQuery("SELECT * FROM @app_events"), + ).rejects.toMatchObject({ + code: "bigquery_restricted_schema", + datasetId: "dbt_backup", + }); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("keeps dry-run validation blocked for restricted schemas", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/templates/analytics/server/lib/bigquery.ts b/templates/analytics/server/lib/bigquery.ts index 65423b33ef0..8664ded7550 100644 --- a/templates/analytics/server/lib/bigquery.ts +++ b/templates/analytics/server/lib/bigquery.ts @@ -253,10 +253,11 @@ export interface RestrictedSchemaPolicyOptions { export class BigQueryRestrictedSchemaError extends Error { readonly code = "bigquery_restricted_schema"; - constructor(readonly datasetId: string) { - super( - `BigQuery dataset "${datasetId}" is restricted because it is reserved for archived or testing data. Access it only when the latest end-user request explicitly names this dataset.`, - ); + constructor( + readonly datasetId: string, + message = `BigQuery dataset "${datasetId}" is restricted because it is reserved for archived or testing data. Access it only when the latest end-user request explicitly names this dataset.`, + ) { + super(message); this.name = "BigQueryRestrictedSchemaError"; } } @@ -329,28 +330,39 @@ function stripSqlCommentsAndStrings(sql: string): string { return sanitized; } -export function findRestrictedBigQueryDataset(sql: string): string | null { - const sanitized = stripSqlCommentsAndStrings(sql); - const referencePattern = - /(?:`(?:[A-Za-z][A-Za-z0-9-]*\.)?(dbt_dev|dbt_backup)\.[^`]+`|(?:^|[^A-Za-z0-9_-])(?:[A-Za-z][A-Za-z0-9-]*\.)?(dbt_dev|dbt_backup)\.[A-Za-z_][A-Za-z0-9_$]*)/gi; +function normalizedSqlIdentifiers(sql: string): string { + return stripSqlCommentsAndStrings(sql) + .replace(/`([^`]*)`/g, "$1") + .replace(/\s*\.\s*/g, "."); +} - for (const match of sanitized.matchAll(referencePattern)) { - const datasetId = (match[1] ?? match[2])?.toLowerCase(); - if (datasetId && RESTRICTED_BIGQUERY_DATASETS.has(datasetId)) { - return datasetId; - } - } - return null; +export function findRestrictedBigQueryDataset(sql: string): string | null { + const normalized = normalizedSqlIdentifiers(sql); + const match = normalized.match( + /(?:^|[^A-Za-z0-9_-])(dbt_dev|dbt_backup)\.[A-Za-z_][A-Za-z0-9_$]*/i, + ); + const datasetId = match?.[1]?.toLowerCase(); + return datasetId && RESTRICTED_BIGQUERY_DATASETS.has(datasetId) + ? datasetId + : null; } export function enforceBigQueryRestrictedSchemaPolicy( sql: string, options: RestrictedSchemaPolicyOptions = {}, ): void { - const datasetId = findRestrictedBigQueryDataset(sql); - if (datasetId && options.restrictedSchemaAccess !== "user-explicit-request") { - throw new BigQueryRestrictedSchemaError(datasetId); + if (options.restrictedSchemaAccess === "user-explicit-request") return; + + const sanitized = stripSqlCommentsAndStrings(sql); + if (/\bEXECUTE\s+IMMEDIATE\b/i.test(sanitized)) { + throw new BigQueryRestrictedSchemaError( + "dynamic-sql", + "BigQuery dynamic SQL is restricted because its table references cannot be safely verified. Use static SQL, or use it only when the latest end-user request explicitly names dbt_dev or dbt_backup.", + ); } + + const datasetId = findRestrictedBigQueryDataset(sql); + if (datasetId) throw new BigQueryRestrictedSchemaError(datasetId); } export interface RunQueryOptions extends RestrictedSchemaPolicyOptions { @@ -511,6 +523,7 @@ export async function dryRunQuery( projectId, appEventsTable, ); + enforceBigQueryRestrictedSchemaPolicy(resolvedSql); const token = await getAccessToken(); const url = `https://bigquery.googleapis.com/bigquery/v2/projects/${projectId}/jobs`; @@ -581,6 +594,7 @@ export async function runQuery( projectId, appEventsTable, ); + enforceBigQueryRestrictedSchemaPolicy(resolvedSql, options); const cacheKey = getCacheKey(resolvedSql, projectId, cacheScope); const l1Hit = getL1(cacheKey); From 728f6902ba6190f125d73a35e9012131cedda582 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 3 Sep 2026 15:29:02 +0000 Subject: [PATCH 12/14] Harden BigQuery restricted schema identifier scanning --- .../analytics/server/lib/bigquery.spec.ts | 14 ++++ templates/analytics/server/lib/bigquery.ts | 74 +++++++++++++++---- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/templates/analytics/server/lib/bigquery.spec.ts b/templates/analytics/server/lib/bigquery.spec.ts index c4dfad193a7..346c75698b4 100644 --- a/templates/analytics/server/lib/bigquery.spec.ts +++ b/templates/analytics/server/lib/bigquery.spec.ts @@ -52,6 +52,12 @@ describe("restricted BigQuery schema policy", () => { ["SELECT * FROM `dbt_backup`.signups", "dbt_backup"], ["SELECT * FROM `example-project`.`dbt_dev`.`signups`", "dbt_dev"], ["SELECT * FROM dbt_backup /* hidden */ . signups", "dbt_backup"], + ["SELECT r'\\' AS example FROM dbt_dev.signups", "dbt_dev"], + ["SELECT * FROM `dbt_backup`.`2024_signups`", "dbt_backup"], + [ + "SELECT * FROM `example-project.allowed.foo--bar` JOIN `example-project.dbt_dev.signups` ON TRUE", + "dbt_dev", + ], ])("detects restricted table references in %s", (sql, datasetId) => { expect(findRestrictedBigQueryDataset(sql)).toBe(datasetId); expect(() => enforceBigQueryRestrictedSchemaPolicy(sql)).toThrow( @@ -71,6 +77,14 @@ describe("restricted BigQuery schema policy", () => { expect(findRestrictedBigQueryDataset(sql)).toBeNull(); }); + it("fails closed for escaped quoted identifiers", () => { + expect(() => + enforceBigQueryRestrictedSchemaPolicy( + "SELECT * FROM `example-project`.`dbt_\\\\u0064ev`.`signups`", + ), + ).toThrow(/escape sequences are restricted/); + }); + it("rejects dynamic SQL unless the direct path has explicit access", () => { const sql = "EXECUTE IMMEDIATE CONCAT('SELECT * FROM dbt_', 'backup.signups')"; diff --git a/templates/analytics/server/lib/bigquery.ts b/templates/analytics/server/lib/bigquery.ts index 8664ded7550..21fd82f1ea4 100644 --- a/templates/analytics/server/lib/bigquery.ts +++ b/templates/analytics/server/lib/bigquery.ts @@ -281,24 +281,57 @@ export function enforceBigQueryRestrictedDatasetPolicy( } } -function stripSqlCommentsAndStrings(sql: string): string { +interface SqlScanResult { + sanitized: string; + hasEscapedIdentifier: boolean; +} + +function scanBigQuerySql(sql: string): SqlScanResult { let sanitized = ""; + let hasEscapedIdentifier = false; let index = 0; - const replaceUntil = (end: number) => { - sanitized += sql.slice(index, end).replace(/[^\r\n]/g, " "); - index = end; + const appendWhitespace = (start: number, end: number) => { + sanitized += sql.slice(start, end).replace(/[^\r\n]/g, " "); }; while (index < sql.length) { + if (sql[index] === "`") { + let cursor = index + 1; + let identifier = ""; + while (cursor < sql.length && sql[cursor] !== "`") { + if (sql[cursor] === "\\") hasEscapedIdentifier = true; + identifier += sql[cursor]; + cursor += 1; + } + sanitized += identifier; + index = cursor < sql.length ? cursor + 1 : cursor; + continue; + } + if (sql.startsWith("--", index) || sql[index] === "#") { const newline = sql.indexOf("\n", index); - replaceUntil(newline === -1 ? sql.length : newline); + const end = newline === -1 ? sql.length : newline; + appendWhitespace(index, end); + index = end; continue; } if (sql.startsWith("/*", index)) { - const close = sql.indexOf("*/", index + 2); - replaceUntil(close === -1 ? sql.length : close + 2); + let cursor = index + 2; + let depth = 1; + while (cursor < sql.length && depth > 0) { + if (sql.startsWith("/*", cursor)) { + depth += 1; + cursor += 2; + } else if (sql.startsWith("*/", cursor)) { + depth -= 1; + cursor += 2; + } else { + cursor += 1; + } + } + appendWhitespace(index, cursor); + index = cursor; continue; } @@ -312,9 +345,13 @@ function stripSqlCommentsAndStrings(sql: string): string { const delimiter = sql.startsWith(quote.repeat(3), index) ? quote.repeat(3) : quote; + const prefix = sql + .slice(0, index) + .match(/(?:^|[^A-Za-z0-9_])([rRbB]{1,2})$/)?.[1]; + const raw = prefix?.toLowerCase().includes("r") === true; let cursor = index + delimiter.length; while (cursor < sql.length) { - if (sql[cursor] === "\\") { + if (!raw && sql[cursor] === "\\") { cursor += 2; continue; } @@ -324,22 +361,21 @@ function stripSqlCommentsAndStrings(sql: string): string { } cursor += 1; } - replaceUntil(cursor); + appendWhitespace(index, cursor); + index = cursor; } - return sanitized; + return { sanitized, hasEscapedIdentifier }; } function normalizedSqlIdentifiers(sql: string): string { - return stripSqlCommentsAndStrings(sql) - .replace(/`([^`]*)`/g, "$1") - .replace(/\s*\.\s*/g, "."); + return scanBigQuerySql(sql).sanitized.replace(/\s*\.\s*/g, "."); } export function findRestrictedBigQueryDataset(sql: string): string | null { const normalized = normalizedSqlIdentifiers(sql); const match = normalized.match( - /(?:^|[^A-Za-z0-9_-])(dbt_dev|dbt_backup)\.[A-Za-z_][A-Za-z0-9_$]*/i, + /(?:^|[^A-Za-z0-9_-])(dbt_dev|dbt_backup)\.[A-Za-z0-9_$-]+/i, ); const datasetId = match?.[1]?.toLowerCase(); return datasetId && RESTRICTED_BIGQUERY_DATASETS.has(datasetId) @@ -353,8 +389,14 @@ export function enforceBigQueryRestrictedSchemaPolicy( ): void { if (options.restrictedSchemaAccess === "user-explicit-request") return; - const sanitized = stripSqlCommentsAndStrings(sql); - if (/\bEXECUTE\s+IMMEDIATE\b/i.test(sanitized)) { + const scan = scanBigQuerySql(sql); + if (scan.hasEscapedIdentifier) { + throw new BigQueryRestrictedSchemaError( + "escaped-identifier", + "BigQuery quoted identifiers with escape sequences are restricted because their dataset names cannot be safely verified. Use ordinary identifiers instead.", + ); + } + if (/\bEXECUTE\s+IMMEDIATE\b/i.test(scan.sanitized)) { throw new BigQueryRestrictedSchemaError( "dynamic-sql", "BigQuery dynamic SQL is restricted because its table references cannot be safely verified. Use static SQL, or use it only when the latest end-user request explicitly names dbt_dev or dbt_backup.", From d9cc94c6981e791abb5a19de81a8bd452f229247 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 3 Sep 2026 15:35:48 +0000 Subject: [PATCH 13/14] Fix BigQuery scan handling for nested comment text --- .../analytics/server/lib/bigquery.spec.ts | 1 + templates/analytics/server/lib/bigquery.ts | 19 ++++--------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/templates/analytics/server/lib/bigquery.spec.ts b/templates/analytics/server/lib/bigquery.spec.ts index 346c75698b4..3a2758b35ac 100644 --- a/templates/analytics/server/lib/bigquery.spec.ts +++ b/templates/analytics/server/lib/bigquery.spec.ts @@ -52,6 +52,7 @@ describe("restricted BigQuery schema policy", () => { ["SELECT * FROM `dbt_backup`.signups", "dbt_backup"], ["SELECT * FROM `example-project`.`dbt_dev`.`signups`", "dbt_dev"], ["SELECT * FROM dbt_backup /* hidden */ . signups", "dbt_backup"], + ["SELECT 1 /* /* */ FROM dbt_backup.signups -- */", "dbt_backup"], ["SELECT r'\\' AS example FROM dbt_dev.signups", "dbt_dev"], ["SELECT * FROM `dbt_backup`.`2024_signups`", "dbt_backup"], [ diff --git a/templates/analytics/server/lib/bigquery.ts b/templates/analytics/server/lib/bigquery.ts index 21fd82f1ea4..12b538fd4c2 100644 --- a/templates/analytics/server/lib/bigquery.ts +++ b/templates/analytics/server/lib/bigquery.ts @@ -317,21 +317,10 @@ function scanBigQuerySql(sql: string): SqlScanResult { continue; } if (sql.startsWith("/*", index)) { - let cursor = index + 2; - let depth = 1; - while (cursor < sql.length && depth > 0) { - if (sql.startsWith("/*", cursor)) { - depth += 1; - cursor += 2; - } else if (sql.startsWith("*/", cursor)) { - depth -= 1; - cursor += 2; - } else { - cursor += 1; - } - } - appendWhitespace(index, cursor); - index = cursor; + const close = sql.indexOf("*/", index + 2); + const end = close === -1 ? sql.length : close + 2; + appendWhitespace(index, end); + index = end; continue; } From 184648eea5ddd57cb5d03a8c1aad5d212044a956 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 3 Sep 2026 18:23:32 +0000 Subject: [PATCH 14/14] Keep restricted schema guidance without enforced query blocking --- .../.agents/skills/bigquery/SKILL.md | 2 +- .../analytics/.agents/skills/dbt/SKILL.md | 2 +- templates/analytics/actions/bigquery.spec.ts | 44 +---- templates/analytics/actions/bigquery.ts | 48 +---- .../actions/search-bigquery-schema.spec.ts | 171 +----------------- .../actions/search-bigquery-schema.ts | 169 +++-------------- .../analytics/server/lib/bigquery.spec.ts | 159 +--------------- templates/analytics/server/lib/bigquery.ts | 158 +--------------- .../server/plugins/agent-chat.spec.ts | 12 +- 9 files changed, 39 insertions(+), 726 deletions(-) diff --git a/templates/analytics/.agents/skills/bigquery/SKILL.md b/templates/analytics/.agents/skills/bigquery/SKILL.md index cb618f5798c..8335b090675 100644 --- a/templates/analytics/.agents/skills/bigquery/SKILL.md +++ b/templates/analytics/.agents/skills/bigquery/SKILL.md @@ -55,7 +55,7 @@ table; `BIGQUERY_PROJECT_ID` is only the default project. are deduplicated, tested, and have canonical column names. Raw tables may have duplicates, schema drift, and inconsistent naming. -**Restrict `dbt_dev.*` and `dbt_backup.*` by default.** These schemas hold development, testing, or archived data. Do not discover or query them unless the latest end-user request explicitly names the schema and asks to inspect or query it. Only then pass `restrictedSchemaAccess: "user-explicit-request"` to the direct `search-bigquery-schema` or `bigquery` action. Never infer consent from agent-generated SQL. Broad schema search, saved dashboards, dry runs, and background query paths remain production-schema-only. +**Avoid `dbt_dev.*` and `dbt_backup.*` unless explicitly requested.** These schemas hold development, testing, or archived data. Do not discover or query them unless the latest end-user request explicitly names the schema and asks to inspect or query it. Never infer permission from agent-generated SQL. ## Always Bound Queries by Date diff --git a/templates/analytics/.agents/skills/dbt/SKILL.md b/templates/analytics/.agents/skills/dbt/SKILL.md index ac52fe25e3a..4ed1fc58492 100644 --- a/templates/analytics/.agents/skills/dbt/SKILL.md +++ b/templates/analytics/.agents/skills/dbt/SKILL.md @@ -31,7 +31,7 @@ Report the metric name, dimensions, filters, date range and time grain, plus env ## Restricted Schemas -`dbt_dev` and `dbt_backup` are testing or archival schemas. Do not discover or query either schema by default. The direct `search-bigquery-schema` and `bigquery` actions may receive `restrictedSchemaAccess: "user-explicit-request"` only when the latest end-user request explicitly names the restricted schema and asks to inspect or query it. Never infer consent from SQL that the agent generated. Saved dashboards, dry runs, background reports, and broad schema searches remain production-schema-only. +`dbt_dev` and `dbt_backup` are testing or archival schemas. Do not discover or query either schema unless the latest end-user request explicitly names it and asks to inspect or query it. Never infer permission from SQL that the agent generated. ## Freshness diff --git a/templates/analytics/actions/bigquery.spec.ts b/templates/analytics/actions/bigquery.spec.ts index 2a4abfb77cb..82257a30a39 100644 --- a/templates/analytics/actions/bigquery.spec.ts +++ b/templates/analytics/actions/bigquery.spec.ts @@ -4,13 +4,8 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const runQuery = vi.fn(); vi.mock("../server/lib/bigquery", () => ({ - runQuery: ( - sql: string, - options?: { - signal?: AbortSignal; - restrictedSchemaAccess?: "user-explicit-request"; - }, - ) => runQuery(sql, options), + runQuery: (sql: string, options?: { signal?: AbortSignal }) => + runQuery(sql, options), })); // Imported after the mock is registered so the action picks up the stub. @@ -73,40 +68,6 @@ describe("bigquery action error handling", () => { expect(String(result.hint)).toMatch(/LIMIT|narrow the date range/i); }); - it("returns a terminal dedicated error for a restricted schema", async () => { - const error = new Error( - 'BigQuery dataset "dbt_backup" is restricted because it is reserved for archived or testing data.', - ) as Error & { code: string; datasetId: string }; - error.code = "bigquery_restricted_schema"; - error.datasetId = "dbt_backup"; - runQuery.mockRejectedValue(error); - - await expect( - bigquery.run({ sql: "SELECT * FROM dbt_backup.signups" }), - ).rejects.toSatisfy((err: unknown) => { - if (!isAgentActionStopError(err)) return false; - expect(err.errorCode).toBe("bigquery_restricted_schema"); - expect(err.toolResult).toContain('"datasetId": "dbt_backup"'); - expect(err.toolResult).toContain('"recoverable": false'); - expect(err.toolResult).not.toMatch(/retry|search-bigquery-schema/i); - return true; - }); - }); - - it("forwards explicit restricted-schema access to the direct query path", async () => { - runQuery.mockResolvedValue({ rows: [], totalRows: 0 }); - - await bigquery.run({ - sql: "SELECT * FROM dbt_dev.signups", - restrictedSchemaAccess: "user-explicit-request", - }); - - expect(runQuery).toHaveBeenCalledWith("SELECT * FROM dbt_dev.signups", { - signal: undefined, - restrictedSchemaAccess: "user-explicit-request", - }); - }); - it("still stops the turn (non-recoverable) when BigQuery is not configured", async () => { runQuery.mockRejectedValue( new Error("GOOGLE_APPLICATION_CREDENTIALS_JSON not configured"), @@ -148,7 +109,6 @@ describe("bigquery action error handling", () => { expect(runQuery).toHaveBeenCalledWith("SELECT 1", { signal: controller.signal, - restrictedSchemaAccess: undefined, }); }); }); diff --git a/templates/analytics/actions/bigquery.ts b/templates/analytics/actions/bigquery.ts index 6e81c55d717..5f49aa404bf 100644 --- a/templates/analytics/actions/bigquery.ts +++ b/templates/analytics/actions/bigquery.ts @@ -51,35 +51,6 @@ function stopForBigQueryNotConfigured(message: string): never { }); } -function stopForRestrictedBigQuerySchema(err: unknown): never { - const error = err as { - code?: unknown; - datasetId?: unknown; - message?: unknown; - }; - const datasetId = - typeof error.datasetId === "string" - ? error.datasetId - : "restricted dataset"; - const message = - typeof error.message === "string" - ? error.message - : `BigQuery dataset "${datasetId}" is restricted.`; - throw new AgentActionStopError(message, { - errorCode: "bigquery_restricted_schema", - toolResult: JSON.stringify( - { - error: "bigquery_restricted_schema", - datasetId, - message, - recoverable: false, - }, - null, - 2, - ), - }); -} - function stopForBigQueryCancellation(): never { const message = "The BigQuery query was cancelled because the agent run ended before it could finish."; @@ -158,12 +129,6 @@ export default defineAction({ "Query the user-configured BigQuery data warehouse. Use this when the user asks for warehouse SQL, BigQuery, or a data-dictionary metric/table that lives in BigQuery. If the user names a provider action such as Jira or Pylon, use that provider action first and do not use BigQuery unless the user explicitly asks for a warehouse copy. For a named customer or organization ID, resolve the canonical CRM/contract identity first and verify the returned rows carry the same customer and org/root-org identifiers. For account health, distinguish completed-month usage from current partial snapshots, contract metrics from similarly named platform metrics, total distinct contracted users from DAU/WAU, and actual usage from contracted capacity. Pass standard SQL via the `sql` arg. Do NOT use `db-query` for warehouse data (it only reaches the app's own SQL database). If a query fails with a schema or SQL error (unknown dataset/table/column, syntax), treat it as a normal debugging signal: inspect the real schema with `search-bigquery-schema` (or query INFORMATION_SCHEMA), correct the query based on the error, and run it again — a few corrective attempts are expected. Surface the error to the user only if it still fails after a few attempts or is non-recoverable (missing credentials, permission, quota). Never rerun identical failing SQL, and never substitute made-up numbers for data you could not query.", schema: z.object({ sql: z.string().describe("SQL query to execute"), - restrictedSchemaAccess: z - .literal("user-explicit-request") - .optional() - .describe( - "Set only when the latest end-user request explicitly names dbt_dev or dbt_backup and asks to query it", - ), }), readOnly: true, toolCallable: true, @@ -173,10 +138,7 @@ export default defineAction({ stopForRepeatedBigQueryQuery(); } try { - return await runQuery(args.sql, { - signal: context?.signal, - restrictedSchemaAccess: args.restrictedSchemaAccess, - }); + return await runQuery(args.sql, { signal: context?.signal }); } catch (err) { // A run cancellation is terminal for this invocation. Returning it as a // recoverable SQL error would invite the agent to retry work after the @@ -184,14 +146,6 @@ export default defineAction({ // the generic tool-error path cannot record it as a warehouse failure. if (context?.signal?.aborted) stopForBigQueryCancellation(); - const errorCode = - err && typeof err === "object" && "code" in err - ? (err as { code?: unknown }).code - : undefined; - if (errorCode === "bigquery_restricted_schema") { - stopForRestrictedBigQuerySchema(err); - } - const msg = err instanceof Error ? err.message : String(err); if ( /GOOGLE_APPLICATION_CREDENTIALS_JSON not configured/i.test(msg) || diff --git a/templates/analytics/actions/search-bigquery-schema.spec.ts b/templates/analytics/actions/search-bigquery-schema.spec.ts index 7469116bc43..7ded8c71446 100644 --- a/templates/analytics/actions/search-bigquery-schema.spec.ts +++ b/templates/analytics/actions/search-bigquery-schema.spec.ts @@ -7,42 +7,10 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@agent-native/core", () => ({ - AgentActionStopError: class AgentActionStopError extends Error { - errorCode?: string; - toolResult?: string; - - constructor( - message: string, - details?: { errorCode?: string; toolResult?: string }, - ) { - super(message); - this.errorCode = details?.errorCode; - this.toolResult = details?.toolResult; - } - }, defineAction: (definition: unknown) => definition, })); vi.mock("../server/lib/bigquery", () => ({ getBigQueryProjectId: mocks.getBigQueryProjectId, - isRestrictedBigQueryDataset: (datasetId: string) => - ["dbt_dev", "dbt_backup"].includes(datasetId.trim().toLowerCase()), - enforceBigQueryRestrictedDatasetPolicy: ( - datasetId: string, - options?: { restrictedSchemaAccess?: string }, - ) => { - const normalized = datasetId.trim().toLowerCase(); - if ( - ["dbt_dev", "dbt_backup"].includes(normalized) && - options?.restrictedSchemaAccess !== "user-explicit-request" - ) { - const error = new Error( - `BigQuery dataset "${normalized}" is restricted.`, - ) as Error & { code: string; datasetId: string }; - error.code = "bigquery_restricted_schema"; - error.datasetId = normalized; - throw error; - } - }, })); vi.mock("../server/lib/gcloud", () => ({ getAccessToken: mocks.getAccessToken, @@ -78,18 +46,6 @@ beforeEach(() => { datasetId: "product", }, }, - { - datasetReference: { - projectId: "test-project", - datasetId: "dbt_dev", - }, - }, - { - datasetReference: { - projectId: "test-project", - datasetId: "dbt_backup", - }, - }, ], }); } @@ -117,32 +73,6 @@ beforeEach(() => { }); } - if (path.endsWith("/datasets/dbt_backup/tables")) { - return jsonResponse({ - tables: [ - { - tableReference: { - projectId: "test-project", - datasetId: "dbt_backup", - tableId: "archived_signups", - }, - type: "TABLE", - }, - ], - }); - } - - if (path.endsWith("/datasets/dbt_backup/tables/archived_signups")) { - return jsonResponse({ - tableReference: { - projectId: "test-project", - datasetId: "dbt_backup", - tableId: "archived_signups", - }, - schema: { fields: [{ name: "created_at", type: "TIMESTAMP" }] }, - }); - } - if (path.endsWith("/datasets/product/tables/branch_creation")) { return jsonResponse({ tableReference: { @@ -218,108 +148,13 @@ describe("search-bigquery-schema", () => { ]); }); - it("keeps restricted datasets out of no-argument and global searches", async () => { - const datasetsResult = await action.run({}); - const searchResult = await action.run({ search: "created", limit: 10 }); - - expect(datasetsResult).toMatchObject({ - mode: "datasets", - datasets: [{ datasetId: "product" }], - }); - expect(searchResult.datasetsScanned).toBe(1); - expect( - mocks.fetch.mock.calls.some(([input]) => - String(input).includes("/datasets/dbt_backup/tables"), - ), - ).toBe(false); - expect( - mocks.fetch.mock.calls.some(([input]) => - String(input).includes("/datasets/dbt_dev/tables"), - ), - ).toBe(false); - }); - - it.each([ - [{ dataset: "dbt_backup" }, "dbt_backup"], - [{ table: "dbt_dev.signups" }, "dbt_dev"], - ])( - "rejects direct restricted metadata access before credentials or network", - async (args, datasetId) => { - await expect(action.run(args)).rejects.toSatisfy((err: unknown) => { - if (!err || typeof err !== "object") return false; - const stopped = err as { errorCode?: unknown; toolResult?: unknown }; - expect(stopped.errorCode).toBe("bigquery_restricted_schema"); - expect(String(stopped.toolResult)).toContain( - `"datasetId": "${datasetId}"`, - ); - expect(String(stopped.toolResult)).toContain('"recoverable": false'); - return true; - }); - expect(mocks.getBigQueryProjectId).not.toHaveBeenCalled(); - expect(mocks.getAccessToken).not.toHaveBeenCalled(); - expect(mocks.fetch).not.toHaveBeenCalled(); - }, - ); - - it("pages past restricted datasets to return allowed datasets", async () => { - mocks.fetch.mockImplementation(async (input: URL | string) => { - const url = new URL(String(input)); - if (url.searchParams.get("pageToken") === "next-page") { - return jsonResponse({ - datasets: [ - { - datasetReference: { - projectId: "test-project", - datasetId: "product", - }, - }, - ], - }); - } - return jsonResponse({ - datasets: [ - { - datasetReference: { - projectId: "test-project", - datasetId: "dbt_backup", - }, - }, - ], - nextPageToken: "next-page", - }); - }); - - const result = await action.run({ limit: 1 }); + it("keeps the no-argument call as a lightweight dataset listing", async () => { + const result = await action.run({}); expect(result).toMatchObject({ mode: "datasets", datasets: [{ datasetId: "product" }], - truncated: false, - }); - expect(mocks.fetch).toHaveBeenCalledTimes(2); - }); - - it("allows a specifically requested restricted dataset with the explicit marker", async () => { - const tablesResult = await action.run({ - dataset: "dbt_backup", - restrictedSchemaAccess: "user-explicit-request", - }); - const tableResult = await action.run({ - table: "dbt_backup.archived_signups", - restrictedSchemaAccess: "user-explicit-request", - }); - - expect(tablesResult).toMatchObject({ - mode: "tables", - datasetId: "dbt_backup", - tables: [{ tableId: "archived_signups" }], - }); - expect(tableResult).toMatchObject({ - mode: "table", - table: { - datasetId: "dbt_backup", - tableId: "archived_signups", - }, }); + expect(mocks.fetch).toHaveBeenCalledTimes(1); }); }); diff --git a/templates/analytics/actions/search-bigquery-schema.ts b/templates/analytics/actions/search-bigquery-schema.ts index 92ff89665af..c63cbe8a6db 100644 --- a/templates/analytics/actions/search-bigquery-schema.ts +++ b/templates/analytics/actions/search-bigquery-schema.ts @@ -1,12 +1,7 @@ -import { AgentActionStopError } from "@agent-native/core"; import { defineAction } from "@agent-native/core/action"; import { z } from "zod"; -import { - enforceBigQueryRestrictedDatasetPolicy, - getBigQueryProjectId, - isRestrictedBigQueryDataset, -} from "../server/lib/bigquery"; +import { getBigQueryProjectId } from "../server/lib/bigquery"; import { getAccessToken } from "../server/lib/gcloud"; import { cliBoolean } from "./schema-helpers"; @@ -25,7 +20,6 @@ interface DatasetListResponse { labels?: Record; location?: string; }>; - nextPageToken?: string; } interface TableListResponse { @@ -85,71 +79,6 @@ function assertIdentifier( return clean; } -function requestedDatasetId( - dataset: string | undefined, - table: string, -): string | undefined { - const cleanTable = table.trim().replace(/^`|`$/g, ""); - const parts = cleanTable.split("."); - if (parts.length === 2) return assertIdentifier("dataset", parts[0]); - if (parts.length === 3) return assertIdentifier("dataset", parts[1]); - if (parts.length === 1 && dataset) { - return assertIdentifier("dataset", dataset); - } - return undefined; -} - -function stopForRestrictedSchema(err: unknown): never { - const error = err as { - code?: unknown; - datasetId?: unknown; - message?: unknown; - }; - const datasetId = - typeof error.datasetId === "string" - ? error.datasetId - : "restricted dataset"; - const message = - typeof error.message === "string" - ? error.message - : `BigQuery dataset "${datasetId}" is restricted.`; - throw new AgentActionStopError(message, { - errorCode: "bigquery_restricted_schema", - toolResult: JSON.stringify( - { - error: "bigquery_restricted_schema", - datasetId, - message, - recoverable: false, - }, - null, - 2, - ), - }); -} - -function enforceRequestedDatasetPolicy( - datasetId: string | undefined, - restrictedSchemaAccess: "user-explicit-request" | undefined, -): void { - if (!datasetId) return; - try { - enforceBigQueryRestrictedDatasetPolicy(datasetId, { - restrictedSchemaAccess, - }); - } catch (err) { - if ( - err && - typeof err === "object" && - "code" in err && - (err as { code?: unknown }).code === "bigquery_restricted_schema" - ) { - stopForRestrictedSchema(err); - } - throw err; - } -} - function parseTableRef( projectId: string, dataset: string | undefined, @@ -293,56 +222,29 @@ function matchesSearch(meta: TableMetadata, search: string): boolean { } async function listDatasets(projectId: string, limit: number, search: string) { - const datasets: Array<{ - projectId?: string; - datasetId?: string; - friendlyName?: string; - labels?: Record; - location?: string; - }> = []; + const url = new URL( + `https://bigquery.googleapis.com/bigquery/v2/projects/${projectId}/datasets`, + ); + url.searchParams.set("maxResults", String(Math.min(limit, 1000))); + const result = await bigQueryGet(url.toString()); const q = search.toLowerCase(); - let pageToken: string | undefined; - - do { - const url = new URL( - `https://bigquery.googleapis.com/bigquery/v2/projects/${projectId}/datasets`, - ); - url.searchParams.set("maxResults", String(Math.min(limit + 2, 1000))); - if (pageToken) url.searchParams.set("pageToken", pageToken); - const result = await bigQueryGet(url.toString()); - for (const dataset of result.datasets ?? []) { - const compact = { - projectId: dataset.datasetReference?.projectId, - datasetId: dataset.datasetReference?.datasetId, - friendlyName: dataset.friendlyName, - labels: dataset.labels, - location: dataset.location, - }; - if ( - typeof compact.datasetId === "string" && - isRestrictedBigQueryDataset(compact.datasetId) - ) { - continue; - } - if ( - q && - ![compact.datasetId, compact.friendlyName, compact.location] - .filter(Boolean) - .join(" ") - .toLowerCase() - .includes(q) - ) { - continue; - } - datasets.push(compact); - } - pageToken = result.nextPageToken; - } while (datasets.length < limit && pageToken); - - return { - datasets: datasets.slice(0, limit), - truncated: datasets.length > limit || Boolean(pageToken), - }; + return (result.datasets ?? []) + .map((dataset) => ({ + projectId: dataset.datasetReference?.projectId, + datasetId: dataset.datasetReference?.datasetId, + friendlyName: dataset.friendlyName, + labels: dataset.labels, + location: dataset.location, + })) + .filter((dataset) => { + if (!q) return true; + return [dataset.datasetId, dataset.friendlyName, dataset.location] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(q); + }) + .slice(0, limit); } async function listTables(projectId: string, datasetId: string, limit: number) { @@ -366,20 +268,18 @@ async function searchAcrossDatasets( search: string, limit: number, ) { - const datasetPage = await listDatasets( + const datasets = await listDatasets( projectId, GLOBAL_SEARCH_DATASET_LIMIT + 1, "", ); - const datasets = datasetPage.datasets; const scannableDatasets = datasets.slice(0, GLOBAL_SEARCH_DATASET_LIMIT); const tables: BigQueryTableSummary[] = []; const datasetCount = scannableDatasets.filter( (dataset) => typeof dataset.datasetId === "string" && dataset.datasetId, ).length; let datasetsScanned = 0; - let truncated = - datasetPage.truncated || datasets.length > GLOBAL_SEARCH_DATASET_LIMIT; + let truncated = datasets.length > GLOBAL_SEARCH_DATASET_LIMIT; for (const dataset of scannableDatasets) { const datasetId = dataset.datasetId; @@ -507,33 +407,17 @@ export default defineAction({ .max(200) .optional() .describe("Maximum results to return (default 50, max 200)"), - restrictedSchemaAccess: z - .literal("user-explicit-request") - .optional() - .describe( - "Set only when the latest end-user request explicitly names dbt_dev or dbt_backup and asks to inspect it", - ), }), http: { method: "GET" }, readOnly: true, toolCallable: true, run: async (args) => { - enforceRequestedDatasetPolicy( - args.table - ? requestedDatasetId(args.dataset, args.table) - : args.dataset - ? assertIdentifier("dataset", args.dataset) - : undefined, - args.restrictedSchemaAccess, - ); - const configuredProjectId = await getBigQueryProjectId(); const limit = args.limit ?? 50; const search = (args.search ?? "").trim(); if (args.table) { const ref = parseTableRef(configuredProjectId, args.dataset, args.table); - enforceRequestedDatasetPolicy(ref.datasetId, args.restrictedSchemaAccess); const meta = await getTableMetadata( ref.projectId, ref.datasetId, @@ -552,14 +436,13 @@ export default defineAction({ return { mode: "datasets", projectId: configuredProjectId, - ...(await listDatasets(configuredProjectId, limit, search)), + datasets: await listDatasets(configuredProjectId, limit, search), nextStep: "Pass dataset= to list tables, or table=dataset.table to inspect columns.", }; } const datasetId = assertIdentifier("dataset", args.dataset); - enforceRequestedDatasetPolicy(datasetId, args.restrictedSchemaAccess); const tables = await listTables(configuredProjectId, datasetId, limit); const includeColumns = args.includeColumns === true || !!search; diff --git a/templates/analytics/server/lib/bigquery.spec.ts b/templates/analytics/server/lib/bigquery.spec.ts index 3a2758b35ac..2c226b63a89 100644 --- a/templates/analytics/server/lib/bigquery.spec.ts +++ b/templates/analytics/server/lib/bigquery.spec.ts @@ -19,13 +19,7 @@ vi.mock("./credentials-context", () => ({ vi.mock("./gcloud", () => ({ getAccessToken })); -const { - BigQueryRestrictedSchemaError, - dryRunQuery, - enforceBigQueryRestrictedSchemaPolicy, - findRestrictedBigQueryDataset, - runQuery, -} = await import("./bigquery"); +const { dryRunQuery, runQuery } = await import("./bigquery"); function jsonResponse(data: unknown): Response { return { @@ -36,83 +30,6 @@ function jsonResponse(data: unknown): Response { } as Response; } -describe("restricted BigQuery schema policy", () => { - it("allows production datasets", () => { - expect(() => - enforceBigQueryRestrictedSchemaPolicy( - "SELECT * FROM `example-project.dbt_analytics.signups`", - ), - ).not.toThrow(); - }); - - it.each([ - ["SELECT * FROM `example-project.dbt_dev.signups`", "dbt_dev"], - ["SELECT * FROM dbt_backup.signups", "dbt_backup"], - ["SELECT * FROM example_project.dbt_backup.signups", "dbt_backup"], - ["SELECT * FROM `dbt_backup`.signups", "dbt_backup"], - ["SELECT * FROM `example-project`.`dbt_dev`.`signups`", "dbt_dev"], - ["SELECT * FROM dbt_backup /* hidden */ . signups", "dbt_backup"], - ["SELECT 1 /* /* */ FROM dbt_backup.signups -- */", "dbt_backup"], - ["SELECT r'\\' AS example FROM dbt_dev.signups", "dbt_dev"], - ["SELECT * FROM `dbt_backup`.`2024_signups`", "dbt_backup"], - [ - "SELECT * FROM `example-project.allowed.foo--bar` JOIN `example-project.dbt_dev.signups` ON TRUE", - "dbt_dev", - ], - ])("detects restricted table references in %s", (sql, datasetId) => { - expect(findRestrictedBigQueryDataset(sql)).toBe(datasetId); - expect(() => enforceBigQueryRestrictedSchemaPolicy(sql)).toThrow( - BigQueryRestrictedSchemaError, - ); - }); - - it("ignores comments, string literals, and similarly named datasets", () => { - const sql = ` - -- SELECT * FROM dbt_dev.signups - /* JOIN \`example-project.dbt_backup.users\` ON TRUE */ - SELECT 'dbt_backup.signups' AS example, - "dbt_dev.users" AS another_example - FROM \`example-project.dbt_backup_copy.signups\` - `; - - expect(findRestrictedBigQueryDataset(sql)).toBeNull(); - }); - - it("fails closed for escaped quoted identifiers", () => { - expect(() => - enforceBigQueryRestrictedSchemaPolicy( - "SELECT * FROM `example-project`.`dbt_\\\\u0064ev`.`signups`", - ), - ).toThrow(/escape sequences are restricted/); - }); - - it("rejects dynamic SQL unless the direct path has explicit access", () => { - const sql = - "EXECUTE IMMEDIATE CONCAT('SELECT * FROM dbt_', 'backup.signups')"; - - expect(() => enforceBigQueryRestrictedSchemaPolicy(sql)).toThrow( - BigQueryRestrictedSchemaError, - ); - expect(() => - enforceBigQueryRestrictedSchemaPolicy(sql, { - restrictedSchemaAccess: "user-explicit-request", - }), - ).not.toThrow(); - }); - - it.each(["dbt_dev", "dbt_backup"])( - "allows %s only with the internal explicit-request marker", - (datasetId) => { - expect(() => - enforceBigQueryRestrictedSchemaPolicy( - `SELECT * FROM \`example-project.${datasetId}.signups\``, - { restrictedSchemaAccess: "user-explicit-request" }, - ), - ).not.toThrow(); - }, - ); -}); - describe("runQuery cancellation", () => { beforeEach(() => { execute.mockReset(); @@ -130,80 +47,6 @@ describe("runQuery cancellation", () => { vi.unstubAllGlobals(); }); - it("rejects restricted schemas before credentials, cache, or network access", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - await expect( - runQuery("SELECT * FROM `example-project.dbt_backup.signups`"), - ).rejects.toMatchObject({ - code: "bigquery_restricted_schema", - datasetId: "dbt_backup", - }); - - expect(resolveCredential).not.toHaveBeenCalled(); - expect(execute).not.toHaveBeenCalled(); - expect(getAccessToken).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("allows restricted schemas on the explicitly opted-in direct path", async () => { - const fetchMock = vi.fn().mockResolvedValue( - jsonResponse({ - jobComplete: true, - schema: { fields: [{ name: "value", type: "INT64" }] }, - rows: [{ f: [{ v: "1" }] }], - totalBytesProcessed: "1", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - runQuery("SELECT * FROM `example-project.dbt_dev.explicit_test`", { - restrictedSchemaAccess: "user-explicit-request", - }), - ).resolves.toMatchObject({ rows: [{ value: 1 }] }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("rechecks the configured app-events relation after placeholder expansion", async () => { - resolveCredential.mockImplementation(async (key: string) => { - if (key === "BIGQUERY_PROJECT_ID") return "test-project"; - if (key === "ANALYTICS_BIGQUERY_EVENTS_TABLE") { - return "test-project.dbt_backup.events"; - } - return null; - }); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - await expect(runQuery("SELECT * FROM @app_events")).rejects.toMatchObject({ - code: "bigquery_restricted_schema", - datasetId: "dbt_backup", - }); - await expect( - dryRunQuery("SELECT * FROM @app_events"), - ).rejects.toMatchObject({ - code: "bigquery_restricted_schema", - datasetId: "dbt_backup", - }); - expect(getAccessToken).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("keeps dry-run validation blocked for restricted schemas", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - await expect( - dryRunQuery("SELECT * FROM `example-project.dbt_backup.signups`"), - ).rejects.toMatchObject({ - code: "bigquery_restricted_schema", - datasetId: "dbt_backup", - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it("stops an incomplete job's poll wait immediately when the agent run aborts", async () => { vi.useFakeTimers(); const controller = new AbortController(); diff --git a/templates/analytics/server/lib/bigquery.ts b/templates/analytics/server/lib/bigquery.ts index 12b538fd4c2..420e1359db1 100644 --- a/templates/analytics/server/lib/bigquery.ts +++ b/templates/analytics/server/lib/bigquery.ts @@ -244,159 +244,7 @@ export interface QueryResult { truncated?: boolean; } -export type RestrictedSchemaAccess = "user-explicit-request"; - -export interface RestrictedSchemaPolicyOptions { - restrictedSchemaAccess?: RestrictedSchemaAccess; -} - -export class BigQueryRestrictedSchemaError extends Error { - readonly code = "bigquery_restricted_schema"; - - constructor( - readonly datasetId: string, - message = `BigQuery dataset "${datasetId}" is restricted because it is reserved for archived or testing data. Access it only when the latest end-user request explicitly names this dataset.`, - ) { - super(message); - this.name = "BigQueryRestrictedSchemaError"; - } -} - -const RESTRICTED_BIGQUERY_DATASETS = new Set(["dbt_dev", "dbt_backup"]); - -export function isRestrictedBigQueryDataset(datasetId: string): boolean { - return RESTRICTED_BIGQUERY_DATASETS.has(datasetId.trim().toLowerCase()); -} - -export function enforceBigQueryRestrictedDatasetPolicy( - datasetId: string, - options: RestrictedSchemaPolicyOptions = {}, -): void { - const normalizedDatasetId = datasetId.trim().toLowerCase(); - if ( - isRestrictedBigQueryDataset(normalizedDatasetId) && - options.restrictedSchemaAccess !== "user-explicit-request" - ) { - throw new BigQueryRestrictedSchemaError(normalizedDatasetId); - } -} - -interface SqlScanResult { - sanitized: string; - hasEscapedIdentifier: boolean; -} - -function scanBigQuerySql(sql: string): SqlScanResult { - let sanitized = ""; - let hasEscapedIdentifier = false; - let index = 0; - - const appendWhitespace = (start: number, end: number) => { - sanitized += sql.slice(start, end).replace(/[^\r\n]/g, " "); - }; - - while (index < sql.length) { - if (sql[index] === "`") { - let cursor = index + 1; - let identifier = ""; - while (cursor < sql.length && sql[cursor] !== "`") { - if (sql[cursor] === "\\") hasEscapedIdentifier = true; - identifier += sql[cursor]; - cursor += 1; - } - sanitized += identifier; - index = cursor < sql.length ? cursor + 1 : cursor; - continue; - } - - if (sql.startsWith("--", index) || sql[index] === "#") { - const newline = sql.indexOf("\n", index); - const end = newline === -1 ? sql.length : newline; - appendWhitespace(index, end); - index = end; - continue; - } - if (sql.startsWith("/*", index)) { - const close = sql.indexOf("*/", index + 2); - const end = close === -1 ? sql.length : close + 2; - appendWhitespace(index, end); - index = end; - continue; - } - - const quote = sql[index]; - if (quote !== "'" && quote !== '"') { - sanitized += quote; - index += 1; - continue; - } - - const delimiter = sql.startsWith(quote.repeat(3), index) - ? quote.repeat(3) - : quote; - const prefix = sql - .slice(0, index) - .match(/(?:^|[^A-Za-z0-9_])([rRbB]{1,2})$/)?.[1]; - const raw = prefix?.toLowerCase().includes("r") === true; - let cursor = index + delimiter.length; - while (cursor < sql.length) { - if (!raw && sql[cursor] === "\\") { - cursor += 2; - continue; - } - if (sql.startsWith(delimiter, cursor)) { - cursor += delimiter.length; - break; - } - cursor += 1; - } - appendWhitespace(index, cursor); - index = cursor; - } - - return { sanitized, hasEscapedIdentifier }; -} - -function normalizedSqlIdentifiers(sql: string): string { - return scanBigQuerySql(sql).sanitized.replace(/\s*\.\s*/g, "."); -} - -export function findRestrictedBigQueryDataset(sql: string): string | null { - const normalized = normalizedSqlIdentifiers(sql); - const match = normalized.match( - /(?:^|[^A-Za-z0-9_-])(dbt_dev|dbt_backup)\.[A-Za-z0-9_$-]+/i, - ); - const datasetId = match?.[1]?.toLowerCase(); - return datasetId && RESTRICTED_BIGQUERY_DATASETS.has(datasetId) - ? datasetId - : null; -} - -export function enforceBigQueryRestrictedSchemaPolicy( - sql: string, - options: RestrictedSchemaPolicyOptions = {}, -): void { - if (options.restrictedSchemaAccess === "user-explicit-request") return; - - const scan = scanBigQuerySql(sql); - if (scan.hasEscapedIdentifier) { - throw new BigQueryRestrictedSchemaError( - "escaped-identifier", - "BigQuery quoted identifiers with escape sequences are restricted because their dataset names cannot be safely verified. Use ordinary identifiers instead.", - ); - } - if (/\bEXECUTE\s+IMMEDIATE\b/i.test(scan.sanitized)) { - throw new BigQueryRestrictedSchemaError( - "dynamic-sql", - "BigQuery dynamic SQL is restricted because its table references cannot be safely verified. Use static SQL, or use it only when the latest end-user request explicitly names dbt_dev or dbt_backup.", - ); - } - - const datasetId = findRestrictedBigQueryDataset(sql); - if (datasetId) throw new BigQueryRestrictedSchemaError(datasetId); -} - -export interface RunQueryOptions extends RestrictedSchemaPolicyOptions { +export interface RunQueryOptions { /** * The current agent run's abort signal. This cancels in-flight BigQuery * requests and, importantly, stops the one-second job polling wait without @@ -544,7 +392,6 @@ export async function dryRunQuery( sql: string, options: DryRunQueryOptions = {}, ): Promise { - enforceBigQueryRestrictedSchemaPolicy(sql); if (options.signal?.aborted) { throw new Error("BigQuery validation was cancelled before it started"); } @@ -554,7 +401,6 @@ export async function dryRunQuery( projectId, appEventsTable, ); - enforceBigQueryRestrictedSchemaPolicy(resolvedSql); const token = await getAccessToken(); const url = `https://bigquery.googleapis.com/bigquery/v2/projects/${projectId}/jobs`; @@ -616,7 +462,6 @@ export async function runQuery( sql: string, options: RunQueryOptions = {}, ): Promise { - enforceBigQueryRestrictedSchemaPolicy(sql, options); const { signal } = options; throwIfAborted(signal); const { projectId, cacheScope, appEventsTable } = await getProjectInfo(); @@ -625,7 +470,6 @@ export async function runQuery( projectId, appEventsTable, ); - enforceBigQueryRestrictedSchemaPolicy(resolvedSql, options); const cacheKey = getCacheKey(resolvedSql, projectId, cacheScope); const l1Hit = getL1(cacheKey); diff --git a/templates/analytics/server/plugins/agent-chat.spec.ts b/templates/analytics/server/plugins/agent-chat.spec.ts index 6234ac158dd..40e2fd07c8d 100644 --- a/templates/analytics/server/plugins/agent-chat.spec.ts +++ b/templates/analytics/server/plugins/agent-chat.spec.ts @@ -310,22 +310,16 @@ describe("Analytics agent Plan mode policy", () => { expect(unreadable.length).toBeLessThan(500); }); - it("applies restricted-schema access only to explicit direct requests", () => { + it("guides the agent away from restricted schemas unless explicitly requested", () => { for (const skill of [dbtSkill, bigquerySkill]) { expect(skill).toMatch(/dbt_dev/); expect(skill).toMatch(/dbt_backup/); - expect(skill).toContain( - 'restrictedSchemaAccess: "user-explicit-request"', - ); expect(skill).toMatch(/latest end-user request explicitly names/i); expect(skill).toMatch( - /Never infer consent from (?:SQL|agent-generated SQL)/i, + /Never infer permission from (?:SQL|agent-generated SQL)/i, ); + expect(skill).not.toContain("restrictedSchemaAccess"); } - expect(dbtSkill).toMatch(/Saved dashboards, dry runs, background reports/); - expect(bigquerySkill).toMatch( - /Broad schema search, saved dashboards, dry runs/, - ); }); it("keeps stale, high-stakes, and inferred-join caveats conditional", () => {