diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d61b2c83..0565d7b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Cost firewall — estimate a query's scan cost before it runs and confirm before going over budget.** A new opt-in `governance` config (`max_query_cost_usd`, `max_bytes_scanned`, `cost_per_tib_usd`) makes `sql_execute` consult a pre-execution estimate and prompt via the `sql_execute_cost` permission when a query would exceed the configured budget, with a hint to run `sql_optimize` first. Estimation uses a new optional `Connector.estimateCost()` capability — implemented for BigQuery (dry-run, exact bytes processed) and Snowflake (`EXPLAIN`, planner-estimated bytes; note Snowflake bills by credits so its USD figure is a proxy) — surfaced through the `sql.estimate_cost` dispatcher method and a standalone `sql_cost_estimate` tool. The configured budget is shown in the `/status` panel. Disabled by default; warehouses without estimation support skip the guard, so it never blocks work it can't price. (#906) + ## [0.8.4] - 2026-06-05 A trace-durability patch. Open `/traces` mid-session and you'd see a rich waterfall — then the moment the agent finished its turn the view collapsed to a single "system-prompt" span, the Summary tab's *"What was asked"* showed *"No prompt recorded"*, and the Chat tab dropped every user turn but the last. The data was genuinely gone from disk, not just hidden in the viewer. This release stops the on-disk trace from being overwritten after each turn and makes the file authoritative across worker restarts. A five-persona pre-release review drove a follow-up wording fix so a reconstructed trace isn't misread as a failed run. diff --git a/docs/docs/configure/governance.md b/docs/docs/configure/governance.md index b04a30c44..ce3e0d3a2 100644 --- a/docs/docs/configure/governance.md +++ b/docs/docs/configure/governance.md @@ -8,7 +8,7 @@ Task-scoped permissions aren't just about safety — they're about **focus**. Wh There's an audit angle too. In regulated industries, prescribed tooling eliminates unnecessary audit cycles. When your tools generate SQL the same way every time, auditors can verify consistency. Change the SQL — even if the results are conceptually identical — and you trigger an investigation to prove equivalence. Deterministic tooling removes that overhead entirely. -Altimate Code enforces governance at the **harness level**, not via prompt instructions the model can ignore. Four mechanisms work together: +Altimate Code enforces governance at the **harness level**, not via prompt instructions the model can ignore. Five mechanisms work together: ## Rules @@ -34,6 +34,35 @@ Every file edit is auto-formatted before it's written. This isn't optional consi [:octicons-arrow-right-24: Formatters reference](formatters.md) +## Cost Firewall + +An agent can write a `SELECT` that scans terabytes and runs up a real warehouse bill before anyone notices. The cost firewall estimates a query's scan cost **before** it runs and asks for confirmation when it exceeds a budget you set — turning a surprise bill into an approve-or-optimize decision. + +It's **off by default**. Set a threshold under the top-level `governance` config to enable it: + +```json +{ + "governance": { + "max_query_cost_usd": 1.0, + "max_bytes_scanned": 53687091200, + "cost_per_tib_usd": 6.25 + } +} +``` + +- `max_query_cost_usd` — prompt before running a query whose estimated cost exceeds this many USD. +- `max_bytes_scanned` — prompt before running a query estimated to scan more than this many bytes. +- `cost_per_tib_usd` — price per TiB scanned, used to convert estimated bytes to cost (default `6.25`). + +When a query is over budget, `sql_execute` prompts via the `sql_execute_cost` permission with the estimate and a hint to run `sql_optimize` first. The standalone `sql_cost_estimate` tool also reports an estimate on demand without running anything. + +Estimates require a warehouse that supports cheap pre-flight estimation: + +- **BigQuery** — via a dry-run (exact bytes processed, no execution and no charge). +- **Snowflake** — via `EXPLAIN`, which compiles the query and returns the planner's estimated bytes to scan without resuming a warehouse. Note that Snowflake bills by warehouse **credits** (compute time), not bytes, so the dollar figure is an approximate proxy — prefer `max_bytes_scanned` as the meaningful threshold for Snowflake. + +Warehouses without estimation support skip the guard, so the firewall never blocks legitimate work it can't price. + --- -Together, these four mechanisms mean governance is not an afterthought — it's built into every agent interaction. The harness enforces the rules so your team doesn't have to police the output. +Together, these five mechanisms mean governance is not an afterthought — it's built into every agent interaction. The harness enforces the rules so your team doesn't have to police the output. diff --git a/packages/drivers/src/bigquery.ts b/packages/drivers/src/bigquery.ts index abc7a8f05..a6eed7390 100644 --- a/packages/drivers/src/bigquery.ts +++ b/packages/drivers/src/bigquery.ts @@ -2,7 +2,7 @@ * BigQuery driver using the `@google-cloud/bigquery` package. */ -import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, SchemaColumn } from "./types" +import type { ConnectionConfig, Connector, ConnectorResult, CostEstimate, ExecuteOptions, SchemaColumn } from "./types" export async function connect(config: ConnectionConfig): Promise { let BigQueryModule: any @@ -71,6 +71,31 @@ export async function connect(config: ConnectionConfig): Promise { } }, + // Estimate scan cost via a BigQuery dry-run. The dry-run validates and + // plans the query server-side and returns the exact bytes it would + // process, without running it or incurring charges. This is the most + // accurate pre-flight estimate available for any warehouse. + async estimateCost(sql: string): Promise { + const query = sql.replace(/;\s*$/, "") + const options: Record = { query, dryRun: true } + if (config.dataset) { + options.defaultDataset = { + datasetId: config.dataset, + projectId: config.project, + } + } + const [job] = await client.createQueryJob(options) + const stats = job.metadata?.statistics ?? {} + // BigQuery reports total bytes processed at the statistics root and, + // redundantly, under statistics.query — prefer whichever is present. + const raw = stats.totalBytesProcessed ?? stats.query?.totalBytesProcessed + const bytesScanned = raw != null ? Number(raw) : undefined + return { + bytesScanned: Number.isFinite(bytesScanned) ? bytesScanned : undefined, + note: "BigQuery dry-run (exact bytes processed)", + } + }, + async listSchemas(): Promise { const [datasets] = await client.getDatasets() return datasets.map((ds: any) => ds.id as string) diff --git a/packages/drivers/src/index.ts b/packages/drivers/src/index.ts index d3c755c31..ce7cccb23 100644 --- a/packages/drivers/src/index.ts +++ b/packages/drivers/src/index.ts @@ -1,5 +1,5 @@ // Re-export types -export type { Connector, ConnectorResult, SchemaColumn, ConnectionConfig } from "./types" +export type { Connector, ConnectorResult, SchemaColumn, ConnectionConfig, CostEstimate } from "./types" // Re-export config normalization export { normalizeConfig, sanitizeConnectionString } from "./normalize" diff --git a/packages/drivers/src/snowflake.ts b/packages/drivers/src/snowflake.ts index 6a37c6caa..d12e4aff0 100644 --- a/packages/drivers/src/snowflake.ts +++ b/packages/drivers/src/snowflake.ts @@ -3,7 +3,7 @@ */ import * as fs from "fs" -import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, SchemaColumn } from "./types" +import type { ConnectionConfig, Connector, ConnectorResult, CostEstimate, ExecuteOptions, SchemaColumn } from "./types" export async function connect(config: ConnectionConfig): Promise { let snowflake: any @@ -258,6 +258,38 @@ export async function connect(config: ConnectionConfig): Promise { } }, + // Estimate scan cost via `EXPLAIN USING JSON`, which compiles the query + // and returns the planner's estimated bytes/partitions to scan WITHOUT + // executing it or resuming a warehouse (compilation is metadata-only). + // + // Caveat surfaced in `note`: Snowflake bills by warehouse *credits* + // (compute time), not bytes scanned, so the bytes figure is an accurate + // expense proxy but the derived USD is approximate. The `max_bytes_scanned` + // guard is the meaningful threshold for Snowflake. + async estimateCost(sql: string): Promise { + const query = sql.replace(/;\s*$/, "") + const explain = await executeQuery(`EXPLAIN USING JSON ${query}`) + const raw = explain.rows?.[0]?.[0] + if (raw == null) { + return { note: "Snowflake EXPLAIN returned no plan; bytes estimate unavailable" } + } + // EXPLAIN USING JSON yields one VARIANT cell — a JSON string via the + // Node SDK, or an already-parsed object depending on the driver version. + let plan: any + try { + plan = typeof raw === "string" ? JSON.parse(raw) : raw + } catch { + return { note: "Snowflake EXPLAIN plan was not parseable JSON" } + } + const globalStats = plan?.GlobalStats ?? {} + const assigned = globalStats.bytesAssigned + const bytesScanned = assigned != null ? Number(assigned) : undefined + return { + bytesScanned: Number.isFinite(bytesScanned) ? bytesScanned : undefined, + note: "Snowflake EXPLAIN estimate — bytes scanned (Snowflake bills by warehouse credits, so USD is a rough proxy)", + } + }, + async listSchemas(): Promise { const result = await executeQuery("SHOW SCHEMAS") // SHOW SCHEMAS returns rows with a "name" column diff --git a/packages/drivers/src/types.ts b/packages/drivers/src/types.ts index 3bc3760d6..f40b43488 100644 --- a/packages/drivers/src/types.ts +++ b/packages/drivers/src/types.ts @@ -26,6 +26,19 @@ export interface ExecuteOptions { noLimit?: boolean } +/** + * Pre-execution cost/scan estimate for a query, produced without running it + * (e.g. BigQuery dry-run, warehouse EXPLAIN). Powers the cost firewall in + * sql_execute. All fields are optional because estimation accuracy varies by + * warehouse — a connector returns only what it can determine cheaply. + */ +export interface CostEstimate { + /** Estimated bytes the query will scan/process. */ + bytesScanned?: number + /** Free-form note about estimation method or caveats (e.g. "BigQuery dry-run"). */ + note?: string +} + export interface Connector { connect(): Promise execute(sql: string, limit?: number, binds?: any[], options?: ExecuteOptions): Promise @@ -33,4 +46,10 @@ export interface Connector { listTables(schema: string): Promise> describeTable(schema: string, table: string): Promise close(): Promise + /** + * Optionally estimate a query's scan cost without executing it. Connectors + * that cannot estimate cheaply omit this method; callers must treat it as + * "estimation unsupported" and proceed without a guard. + */ + estimateCost?(sql: string): Promise } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8f1528218..2460ed74c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -153,6 +153,11 @@ export namespace Agent { question: "allow", plan_enter: "allow", sql_execute_write: "ask", + // altimate_change start — cost firewall: must be "ask" so the guard + // prompts; the inherited `"*": "allow"` default would otherwise + // silently approve over-budget queries. + sql_execute_cost: "ask", + // altimate_change end }), userWithSafety, ), @@ -170,6 +175,7 @@ export namespace Agent { "*": "deny", // SQL read tools sql_execute: "allow", + sql_cost_estimate: "allow", altimate_core_validate: "allow", sql_analyze: "allow", sql_translate: "allow", @@ -182,6 +188,10 @@ export namespace Agent { sql_diff: "allow", // SQL writes denied sql_execute_write: "deny", + // altimate_change start — cost firewall: prompt (not hard-deny) when a + // query exceeds the configured cost budget, so the analyst can approve. + sql_execute_cost: "ask", + // altimate_change end // Warehouse/schema/finops warehouse_list: "allow", warehouse_test: "allow", diff --git a/packages/opencode/src/altimate/index.ts b/packages/opencode/src/altimate/index.ts index f6dae26fc..7d5863fd1 100644 --- a/packages/opencode/src/altimate/index.ts +++ b/packages/opencode/src/altimate/index.ts @@ -60,6 +60,7 @@ export * from "./tools/sql-analyze" export * from "./tools/sql-autocomplete" export * from "./tools/sql-diff" export * from "./tools/sql-execute" +export * from "./tools/sql-cost-estimate" export * from "./tools/sql-explain" export * from "./tools/sql-fix" export * from "./tools/sql-format" diff --git a/packages/opencode/src/altimate/native/connections/register.ts b/packages/opencode/src/altimate/native/connections/register.ts index 5b32cb560..7a928cd13 100644 --- a/packages/opencode/src/altimate/native/connections/register.ts +++ b/packages/opencode/src/altimate/native/connections/register.ts @@ -14,6 +14,8 @@ import { runDataDiff } from "./data-diff" import type { SqlExecuteParams, SqlExecuteResult, + SqlEstimateCostParams, + SqlEstimateCostResult, SqlExplainParams, SqlExplainResult, SqlAutocompleteParams, @@ -438,6 +440,54 @@ register("sql.execute", async (params: SqlExecuteParams): Promise => { + const warehouseType = getWarehouseType(params.warehouse) + try { + // Resolve the connector the same way sql.execute does (named warehouse, else first). + let connector + if (params.warehouse) { + connector = await Registry.get(params.warehouse) + } else { + const warehouses = Registry.list().warehouses + if (warehouses.length === 0) { + return { supported: false, warehouse_type: warehouseType, error: "No warehouse configured." } + } + connector = await Registry.get(warehouses[0].name) + } + + if (typeof connector.estimateCost !== "function") { + return { + supported: false, + warehouse_type: warehouseType, + note: `Cost estimation is not supported for warehouse type ${JSON.stringify(warehouseType)}.`, + } + } + + const estimate = await connector.estimateCost(params.sql) + const costPerTib = params.cost_per_tib_usd ?? DEFAULT_COST_PER_TIB_USD + const estimatedCost = + estimate.bytesScanned != null ? (estimate.bytesScanned / TIB_BYTES) * costPerTib : undefined + + return { + supported: true, + warehouse_type: warehouseType, + bytes_scanned: estimate.bytesScanned, + estimated_cost_usd: estimatedCost, + cost_per_tib_usd: costPerTib, + note: estimate.note, + } + } catch (e) { + return { supported: false, warehouse_type: warehouseType, error: String(e) } + } +}) + // --- sql.explain --- register("sql.explain", async (params: SqlExplainParams): Promise => { let warehouseName: string | undefined diff --git a/packages/opencode/src/altimate/native/types.ts b/packages/opencode/src/altimate/native/types.ts index 7b47f3068..5811c6bdc 100644 --- a/packages/opencode/src/altimate/native/types.ts +++ b/packages/opencode/src/altimate/native/types.ts @@ -19,6 +19,27 @@ export interface SqlExecuteResult { error?: string } +// --- SQL Cost Estimate (cost firewall) --- + +export interface SqlEstimateCostParams { + sql: string + warehouse?: string + /** USD price per TiB scanned, used to convert bytes → cost. */ + cost_per_tib_usd?: number +} + +export interface SqlEstimateCostResult { + /** True when the resolved warehouse can estimate cost without executing. */ + supported: boolean + warehouse_type: string + bytes_scanned?: number + estimated_cost_usd?: number + cost_per_tib_usd?: number + /** Estimation method or caveat (e.g. "BigQuery dry-run"). */ + note?: string + error?: string +} + // --- SQL Analyze --- export interface SqlAnalyzeParams { @@ -1165,6 +1186,7 @@ export interface DataDiffResult { export const BridgeMethods = { "sql.execute": {} as { params: SqlExecuteParams; result: SqlExecuteResult }, + "sql.estimate_cost": {} as { params: SqlEstimateCostParams; result: SqlEstimateCostResult }, "sql.analyze": {} as { params: SqlAnalyzeParams; result: SqlAnalyzeResult }, "sql.optimize": {} as { params: SqlOptimizeParams; result: SqlOptimizeResult }, "sql.translate": {} as { params: SqlTranslateParams; result: SqlTranslateResult }, diff --git a/packages/opencode/src/altimate/tools/sql-cost-estimate.ts b/packages/opencode/src/altimate/tools/sql-cost-estimate.ts new file mode 100644 index 000000000..53402b6dc --- /dev/null +++ b/packages/opencode/src/altimate/tools/sql-cost-estimate.ts @@ -0,0 +1,70 @@ +import z from "zod" +import { Tool } from "../../tool/tool" +import { Dispatcher } from "../native" +import { Config } from "@/config/config" + +/** Format a byte count as a human-readable string (e.g. "4.2 GB"). */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "unknown" + if (bytes === 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB", "PB"] + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const value = bytes / Math.pow(1024, i) + return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}` +} + +/** Format a USD cost, using more precision for small values. */ +export function formatCost(usd: number): string { + if (!Number.isFinite(usd)) return "unknown" + if (usd < 0.01) return `$${usd.toFixed(4)}` + return `$${usd.toFixed(2)}` +} + +export const SqlCostEstimateTool = Tool.define("sql_cost_estimate", { + description: + "Estimate how much data a SQL query will scan and what it will cost — WITHOUT running it. Uses a BigQuery dry-run (exact bytes processed) where supported. Use this before running large analytical queries to avoid surprise warehouse bills. Returns 'estimation unsupported' for warehouses that cannot estimate cheaply.", + parameters: z.object({ + query: z.string().describe("SQL query to estimate. Inline all values — bind placeholders are not supported."), + warehouse: z + .string() + .optional() + .describe("Warehouse connection name. Omit to use the first configured warehouse."), + }), + async execute(args, _ctx) { + const cfg = await Config.get().catch(() => ({}) as Awaited>) + const costPerTib = cfg.governance?.cost_per_tib_usd + + const result = await Dispatcher.call("sql.estimate_cost", { + sql: args.query, + warehouse: args.warehouse, + cost_per_tib_usd: costPerTib, + }) + + if (!result.supported) { + const reason = result.error ?? result.note ?? "Cost estimation is not supported for this warehouse." + return { + title: "Cost estimate: unsupported", + metadata: { supported: false, warehouse_type: result.warehouse_type, error: result.error }, + output: `Cost estimation unavailable for ${result.warehouse_type}: ${reason}`, + } + } + + const lines: string[] = [] + if (result.bytes_scanned != null) lines.push(`Bytes scanned (est.): ${formatBytes(result.bytes_scanned)}`) + if (result.estimated_cost_usd != null) { + lines.push(`Estimated cost: ${formatCost(result.estimated_cost_usd)} (at ${formatCost(result.cost_per_tib_usd ?? 0)}/TiB)`) + } + if (result.note) lines.push(`Method: ${result.note}`) + + return { + title: `Cost estimate: ${result.estimated_cost_usd != null ? formatCost(result.estimated_cost_usd) : "n/a"}`, + metadata: { + supported: true, + warehouse_type: result.warehouse_type, + bytes_scanned: result.bytes_scanned, + estimated_cost_usd: result.estimated_cost_usd, + }, + output: lines.join("\n") || "No estimate available.", + } + }, +}) diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index 4647c7564..22b7a0df6 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -13,6 +13,10 @@ import { PostConnectSuggestions } from "./post-connect-suggestions" import { getCache } from "../native/schema/cache" import * as Registry from "../native/connections/registry" // altimate_change end +// altimate_change start — cost firewall: pre-execution query cost guardrail +import { Config } from "@/config/config" +import { formatBytes, formatCost } from "./sql-cost-estimate" +// altimate_change end export const SqlExecuteTool = Tool.define("sql_execute", { description: "Execute SQL against a connected data warehouse. Returns results as a formatted table.", @@ -38,6 +42,13 @@ export const SqlExecuteTool = Tool.define("sql_execute", { } // altimate_change end + // altimate_change start — cost firewall: estimate scan cost and ask for + // confirmation before running queries that exceed configured budgets. + // Outside try/catch so a denial propagates to the framework. No-op unless + // a threshold is configured AND the warehouse supports estimation. + await enforceCostFirewall(args.query, args.warehouse, ctx) + // altimate_change end + // altimate_change start — shadow-mode pre-execution SQL validation // Runs validation against cached schema and emits sql_pre_validation telemetry, // but does NOT block execution. Used to measure catch rate before deciding @@ -103,6 +114,58 @@ export const SqlExecuteTool = Tool.define("sql_execute", { }, }) +// altimate_change start — cost firewall: pre-execution query cost guardrail +/** + * Estimate a query's scan cost and prompt for confirmation if it exceeds the + * budgets in `config.governance`. Throws (via ctx.ask denial) to abort an + * over-budget query. No-op when no threshold is configured, the warehouse + * can't estimate, or estimation fails — the query then runs normally so this + * never blocks legitimate work due to estimator gaps. + */ +async function enforceCostFirewall(query: string, warehouse: string | undefined, ctx: Tool.Context): Promise { + const cfg = await Config.get().catch(() => undefined) + const governance = cfg?.governance + const maxCost = governance?.max_query_cost_usd + const maxBytes = governance?.max_bytes_scanned + // Disabled unless at least one threshold is set. + if (maxCost == null && maxBytes == null) return + + let estimate + try { + estimate = await Dispatcher.call("sql.estimate_cost", { + sql: query, + warehouse, + cost_per_tib_usd: governance?.cost_per_tib_usd, + }) + } catch { + // Estimation failed — fail open (run the query) rather than blocking work. + return + } + if (!estimate.supported) return + + const overCost = maxCost != null && estimate.estimated_cost_usd != null && estimate.estimated_cost_usd > maxCost + const overBytes = maxBytes != null && estimate.bytes_scanned != null && estimate.bytes_scanned > maxBytes + if (!overCost && !overBytes) return + + const reasons: string[] = [] + if (overCost) reasons.push(`estimated cost ${formatCost(estimate.estimated_cost_usd!)} exceeds budget ${formatCost(maxCost!)}`) + if (overBytes) reasons.push(`estimated scan ${formatBytes(estimate.bytes_scanned!)} exceeds limit ${formatBytes(maxBytes!)}`) + + // Denial throws and aborts execution; approval lets the query proceed. + await ctx.ask({ + permission: "sql_execute_cost", + patterns: [query.slice(0, 200)], + always: ["*"], + metadata: { + reason: reasons.join("; "), + bytes_scanned: estimate.bytes_scanned, + estimated_cost_usd: estimate.estimated_cost_usd, + hint: "Consider sql_optimize to reduce scan cost before running.", + }, + }) +} +// altimate_change end + // altimate_change start — pre-execution SQL validation via cached schema const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours // High ceiling so large warehouses aren't arbitrarily truncated; we emit diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 8fe35268e..282eee0f0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -7,6 +7,16 @@ import { For, Match, Switch, Show, createMemo } from "solid-js" export type DialogStatusProps = {} +// altimate_change start — cost firewall: human-readable byte budget for the status panel +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "unknown" + if (bytes === 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB", "PB"] + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 2)} ${units[i]}` +} +// altimate_change end + export function DialogStatus() { const sync = useSync() const { theme } = useTheme() @@ -14,6 +24,23 @@ export function DialogStatus() { const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) + // altimate_change start — cost firewall budget. The SDK Config type is + // regenerated from the server schema at build time; read governance through + // a local shape so this compiles before that regen lands. + const governance = createMemo( + () => + ( + sync.data.config as { + governance?: { max_query_cost_usd?: number; max_bytes_scanned?: number; cost_per_tib_usd?: number } + } + ).governance, + ) + const firewallEnabled = createMemo(() => { + const g = governance() + return !!g && (g.max_query_cost_usd != null || g.max_bytes_scanned != null) + }) + // altimate_change end + const plugins = createMemo(() => { const list = sync.data.config.plugin ?? [] const result = list.map((item) => { @@ -165,6 +192,35 @@ export function DialogStatus() { + {/* altimate_change start — cost firewall budget */} + Cost Firewall: off}> + + Cost Firewall: on + + + + • + + + Max cost{" "} + ${governance()!.max_query_cost_usd!.toFixed(2)}/query + + + + + + + • + + + Max scan{" "} + {formatBytes(governance()!.max_bytes_scanned!)}/query + + + + + + {/* altimate_change end */} ) } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 99295821c..0327a7dfd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1297,6 +1297,32 @@ export namespace Config { }) .optional(), // altimate_change end + // altimate_change start — cost firewall: pre-execution query cost guardrails + governance: z + .object({ + max_query_cost_usd: z + .number() + .nonnegative() + .optional() + .describe( + "Cost firewall: ask for confirmation before running a query whose estimated cost (USD) exceeds this value. Requires a warehouse that supports cost estimation (e.g. BigQuery). Unset = disabled.", + ), + max_bytes_scanned: z + .number() + .nonnegative() + .optional() + .describe( + "Cost firewall: ask for confirmation before running a query estimated to scan more than this many bytes. Unset = disabled.", + ), + cost_per_tib_usd: z + .number() + .positive() + .optional() + .describe("USD price per TiB scanned, used to convert estimated bytes to cost (default: 6.25)."), + }) + .optional() + .describe("Data governance guardrails applied before queries run."), + // altimate_change end experimental: z .object({ disable_paste_summary: z.boolean().optional(), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 0a20269b0..2e7772cc0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -39,6 +39,7 @@ import { pathToFileURL } from "url" // altimate_change start - import custom data engineering tools import { SqlExecuteTool } from "../altimate/tools/sql-execute" +import { SqlCostEstimateTool } from "../altimate/tools/sql-cost-estimate" import { SchemaInspectTool } from "../altimate/tools/schema-inspect" import { SqlAnalyzeTool } from "../altimate/tools/sql-analyze" import { SqlOptimizeTool } from "../altimate/tools/sql-optimize" @@ -226,6 +227,7 @@ export namespace ToolRegistry { ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), // altimate_change start - register custom data engineering tools SqlExecuteTool, + SqlCostEstimateTool, SchemaInspectTool, SqlAnalyzeTool, SqlOptimizeTool, diff --git a/packages/opencode/test/altimate/sql-cost-estimate-formatters.test.ts b/packages/opencode/test/altimate/sql-cost-estimate-formatters.test.ts new file mode 100644 index 000000000..3d6933df4 --- /dev/null +++ b/packages/opencode/test/altimate/sql-cost-estimate-formatters.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect } from "bun:test" +import { formatBytes, formatCost } from "../../src/altimate/tools/sql-cost-estimate" + +describe("formatBytes", () => { + test("formats zero and sub-KB without decimals", () => { + expect(formatBytes(0)).toBe("0 B") + expect(formatBytes(512)).toBe("512 B") + }) + + test("scales to KB/MB/GB/TB with two decimals", () => { + expect(formatBytes(1024)).toBe("1.00 KB") + expect(formatBytes(1536)).toBe("1.50 KB") + expect(formatBytes(1024 ** 3)).toBe("1.00 GB") + expect(formatBytes(1024 ** 4)).toBe("1.00 TB") + }) + + test("returns 'unknown' for invalid input", () => { + expect(formatBytes(NaN)).toBe("unknown") + expect(formatBytes(-1)).toBe("unknown") + }) +}) + +describe("formatCost", () => { + test("uses 4 decimals for sub-cent values", () => { + expect(formatCost(0.0021)).toBe("$0.0021") + expect(formatCost(0)).toBe("$0.0000") + }) + + test("uses 2 decimals for cent-and-above values", () => { + expect(formatCost(6.25)).toBe("$6.25") + expect(formatCost(40)).toBe("$40.00") + }) + + test("returns 'unknown' for non-finite input", () => { + expect(formatCost(NaN)).toBe("unknown") + expect(formatCost(Infinity)).toBe("unknown") + }) +})