Skip to content

Commit fdeddbf

Browse files
hkaltaiclaude
andcommitted
fix: dialect-aware hard blocks, failed data_diff reported as an error, daemon shutdown and row-bound fixes
- isBlockedStatement classifies with the warehouse's dialect and, without one, checks only the leading statement, so dollar-quoted or backticked text is never hard-denied. - A failed or refused data_diff carries its error, so tool run and MCP report it as one. - Daemon: a run that finishes after stop neither releases files nor re-arms the idle timer; a chmod failure after listen leaves no server or socket behind. - {{label.column}} refuses a step with more than the row bound even when not truncated. - Managed output preferences strip the mobileconfig: prefix before resolving {file:...}. - readSql reuses the masked SQL; tests for each change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent fcc3c44 commit fdeddbf

11 files changed

Lines changed: 132 additions & 21 deletions

File tree

‎packages/opencode/src/altimate/mcp-server/output-settings.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,6 @@ export async function outputLimits() {
8282
await load(path.join(ConfigManaged.managedConfigDir(), name))
8383
}
8484
const preferences = await ConfigManaged.readManagedPreferences().catch(() => undefined)
85-
if (preferences) await apply(preferences.text, { source: preferences.source, dir: path.dirname(preferences.source) })
85+
if (preferences) await apply(preferences.text, { source: preferences.source, dir: path.dirname(preferences.source.replace(/^mobileconfig:/, "")) })
8686
return limits
8787
}

‎packages/opencode/src/altimate/tool-cli/daemon.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,9 @@ export async function startDaemon(options: DaemonOptions): Promise<Daemon> {
265265
// Release DuckDB/SQLite file locks once idle, before replying, so a client running `dbt build` next finds the file free.
266266
// A request that arrives meanwhile waits for the release (see `handle`). Whichever request
267267
// finishes last releases, so a run that finished while a status was in flight is covered.
268-
// A stop is answered at once; the daemon then shuts down.
269-
if (inflight === 0 && options.releaseFiles && dirty && !stopRequest) {
268+
// A stop is answered at once; the daemon then shuts down, and a run that finishes after it
269+
// neither releases nor re-arms (stop() closes the runner).
270+
if (inflight === 0 && options.releaseFiles && dirty && !stopRequest && !stopping) {
270271
dirty = false
271272
const release = options.releaseFiles().catch(() => {})
272273
releasing = release
@@ -276,7 +277,10 @@ export async function startDaemon(options: DaemonOptions): Promise<Daemon> {
276277
if (socket.destroyed) afterReply?.()
277278
else socket.write(JSON.stringify(response) + "\n", () => afterReply?.())
278279
})
279-
.finally(arm)
280+
// After stop() nothing re-arms the idle timer.
281+
.finally(() => {
282+
if (!stopping) arm()
283+
})
280284
})
281285
})
282286

@@ -305,6 +309,9 @@ export async function startDaemon(options: DaemonOptions): Promise<Daemon> {
305309
try {
306310
chmodSync(file, 0o600)
307311
} catch (error) {
312+
// Leave nothing bound behind: no socket file and no listening server.
313+
server.close()
314+
removeSocket(file)
308315
return reject(error)
309316
}
310317
}

‎packages/opencode/src/altimate/tool-cli/permissions.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Effect } from "effect"
22
import type { Tool } from "../../tool/tool"
33
import { classifyAndCheck } from "../tools/sql-classify"
4+
import * as Registry from "../native/connections/registry"
45

56
/** Permission kinds the data tools raise for reads; anything else is a write and needs `--allow-write`. */
67
export const READ_PERMISSIONS: ReadonlySet<string> = new Set([
@@ -10,10 +11,21 @@ export const READ_PERMISSIONS: ReadonlySet<string> = new Set([
1011
"answer_commit",
1112
])
1213

13-
// Same statements the agent's `safetyDenials` rules refuse (agent/agent.ts), found by the SQL classifier
14-
// so leading comments and later statements of a multi-statement pattern are seen too.
15-
export function isBlockedStatement(pattern: string): boolean {
16-
return classifyAndCheck(pattern).blocked
14+
const BLOCKED_STATEMENT = /^\s*(drop\s+(database|schema)\b|truncate\b)/i
15+
16+
// Same statements the agent's `safetyDenials` rules refuse (agent/agent.ts). With the warehouse's
17+
// dialect the SQL classifier finds them past leading comments and in later statements; without
18+
// one, quoting differs by dialect (dollar quotes, backticks), so only the leading statement is
19+
// checked rather than risk an unoverridable denial of a valid read.
20+
export function isBlockedStatement(pattern: string, dialect?: string): boolean {
21+
return dialect ? classifyAndCheck(pattern, dialect).blocked : BLOCKED_STATEMENT.test(pattern)
22+
}
23+
24+
function dialectOf(metadata: unknown): string | undefined {
25+
const warehouse = (metadata as { warehouse?: unknown } | undefined)?.warehouse
26+
return typeof warehouse === "string" && warehouse
27+
? (Registry.canonicalType(Registry.getConfig(warehouse)?.type) ?? undefined)
28+
: undefined
1729
}
1830

1931
export function denied(permission: string, retry = "Re-run"): Error {
@@ -25,8 +37,9 @@ export function denied(permission: string, retry = "Re-run"): Error {
2537
* kind is denied unless writes were enabled, and blocked destructive statements are refused either way.
2638
*/
2739
export function askFor(allowWrite: boolean, retry = "Re-run"): Tool.Context["ask"] {
28-
return ({ permission, patterns }) => {
29-
if (patterns?.some(isBlockedStatement)) {
40+
return ({ permission, patterns, metadata }) => {
41+
const dialect = dialectOf(metadata)
42+
if (patterns?.some((pattern) => isBlockedStatement(pattern, dialect))) {
3043
return Effect.die(
3144
new Error(`Permission denied: ${permission}. DROP DATABASE, DROP SCHEMA and TRUNCATE are blocked for safety. This cannot be overridden.`),
3245
)

‎packages/opencode/src/altimate/tools/data-diff.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const DataDiffTool = Tool.define("data_diff", {
113113
if (!result.success) {
114114
return {
115115
title: "Data diff: ERROR",
116-
metadata: { success: false, steps: result.steps },
116+
metadata: { success: false, steps: result.steps, error: result.error },
117117
output: `Data diff failed: ${result.error}`,
118118
}
119119
}

‎packages/opencode/src/altimate/tools/read-only-sql.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { maskSql, tokenizeSql } from "./sql-tokenize"
33
import { sqlFunctionCalls } from "./sql-functions"
44
import { hasSideEffectFunction } from "./sql-function-policy"
55

6-
/** The masked SQL when it is one complete read-only query that is safe to embed: the
6+
/** The masked SQL and its trimmed body when it is one complete read-only query that is safe to embed: the
77
* classifier reads it as a non-blocked read, its parentheses balance (so it cannot close a
88
* CTE or subquery it is placed in), it has no statement separator, and it calls no function
99
* that writes or runs SQL (dynamic query() included). Undefined otherwise. */
10-
function readOnlyBody(sql: string, dialect?: string): string | undefined {
10+
function readOnlyBody(sql: string, dialect?: string): { masked: string; body: string } | undefined {
1111
const { queryType, blocked } = classifyAndCheck(sql, dialect)
1212
const { tokens, complete } = tokenizeSql(sql, dialect)
1313
const masked = maskSql(tokens)
@@ -31,7 +31,7 @@ function readOnlyBody(sql: string, dialect?: string): string | undefined {
3131
balanced &&
3232
depth === 0 &&
3333
!body.includes(";")
34-
return safe ? body : undefined
34+
return safe ? { masked, body } : undefined
3535
}
3636

3737
/** Throws unless sql is one read-only query that is safe to embed in a larger query. */
@@ -44,9 +44,10 @@ export function requireReadOnlyQuery(sql: string, dialect?: string): void {
4444
* Stricter than requireReadOnlyQuery: sql_check also requires SELECT or WITH and refuses
4545
* write keywords anywhere, even where the parser reports a query. */
4646
export function readSql(sql: string, dialect?: string): string {
47-
const body = readOnlyBody(sql, dialect)
47+
const checked = readOnlyBody(sql, dialect)
48+
const body = checked?.body ?? ""
4849
if (
49-
body === undefined ||
50+
checked === undefined ||
5051
!/^(SELECT|WITH)\b/i.test(body) ||
5152
/\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|COPY|ATTACH|DETACH|PRAGMA|VACUUM|CALL|DO|INTO|INSTALL|LOAD|EXPORT|IMPORT|GRANT|REVOKE)\b/i.test(
5253
body,
@@ -57,7 +58,6 @@ export function readSql(sql: string, dialect?: string): string {
5758
"This operation only accepts a single read-only SELECT (or WITH ... SELECT); it never writes or fixes data.",
5859
)
5960
}
60-
const masked = maskSql(tokenizeSql(sql, dialect).tokens)
61-
const semicolon = masked.indexOf(";")
61+
const semicolon = checked.masked.indexOf(";")
6262
return semicolon < 0 ? sql : sql.slice(0, semicolon) + sql.slice(semicolon + 1)
6363
}

‎packages/opencode/src/altimate/tools/sql-execute.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,9 @@ function resolveStepRefs(
643643
if (!table) throw new Error(`${match}: storing step "${ref}" in scratch failed.`)
644644
return table
645645
}
646+
// A caller limit above the bound returns more rows without marking the step truncated.
647+
if (source.rows.length > MAX_BOUND_ROWS)
648+
throw new Error(`${match}: step "${ref}" has more than ${MAX_BOUND_ROWS} rows; filter or aggregate it first.`)
646649
const index = source.columns.findIndex((name) => name.toLowerCase() === column.toLowerCase())
647650
// Some drivers return no column names for an empty result; an empty step is an empty list either way.
648651
if (index < 0 && !source.rows.length && !source.columns.length) return isMongoWarehouse(warehouse) ? "null" : "NULL"

‎packages/opencode/test/altimate/analyst-effort.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ describe("analyst default reasoning effort", () => {
101101
const wrap = (options: Record<string, any>) => ({ agent: analyst, model: { ...opus, options } })
102102
const defaults = analystEffortOptions({ agent: analyst, model: opus })
103103
expect(analystEffortOptions(wrap({ modelParams: null }))).toEqual(defaults)
104+
expect(analystEffortOptions(wrap({ modelParams: "x" }))).toEqual(defaults)
104105
expect(analystEffortOptions(wrap({ output_config: null }))).toEqual(defaults)
105106
expect(analystEffortOptions(wrap({ output_config: { format: "json" } }))).toEqual(defaults)
106107
expect(analystEffortOptions(wrap({ output_config: { effort: "low" } }))).toEqual({})

‎packages/opencode/test/altimate/mcp-server/output-settings.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// altimate_change start — MCP output limits resolve like the agent's config loader
2-
import { afterEach, beforeEach, expect, test } from "bun:test"
2+
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"
33
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
44
import os from "node:os"
55
import path from "node:path"
66
import { outputLimits } from "../../../src/altimate/mcp-server/output-settings"
77
import { MAX_BYTES, MAX_LINES } from "../../../src/tool/truncate-core"
8+
import { ConfigManaged } from "../../../src/config/managed"
89

910
const VARS = [
1011
"OPENCODE_CONFIG",
@@ -109,4 +110,20 @@ test("an unreadable config path does not stop startup", async () => {
109110
await writeFile(path.join(repo, "opencode.jsonc"), output({ max_lines: 8 }))
110111
expect(await outputLimits()).toEqual({ maxLines: 8, maxBytes: MAX_BYTES })
111112
})
113+
114+
test("managed preferences resolve {file:...} next to their plist", async () => {
115+
const prefs = path.join(root, "Managed Preferences", "user")
116+
await mkdir(prefs, { recursive: true })
117+
await writeFile(path.join(prefs, "lines.txt"), "7")
118+
const spy = spyOn(ConfigManaged, "readManagedPreferences").mockResolvedValue({
119+
text: '{"tool_output":{"max_lines":{file:lines.txt}}}',
120+
source: `mobileconfig:${path.join(prefs, "com.example.plist")}`,
121+
} as never)
122+
try {
123+
expect((await outputLimits()).maxLines).toBe(7)
124+
} finally {
125+
spy.mockRestore()
126+
}
127+
})
128+
112129
// altimate_change end

‎packages/opencode/test/altimate/tool-cli/daemon.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,41 @@ describe("release ordering", () => {
396396
expect((await reply)?.status).toBe("ok")
397397
})
398398

399+
test("a run that finishes after stop releases nothing", async () => {
400+
const dir = await temp()
401+
const file = path.join(dir, "d.sock")
402+
const runner = fakeRunner()
403+
let finishRun!: () => void
404+
const gate = new Promise<void>((resolve) => (finishRun = resolve))
405+
let running!: () => void
406+
const begun = new Promise<void>((resolve) => (running = resolve))
407+
const run = runner.run.bind(runner)
408+
runner.run = async (...args: Parameters<typeof run>) => {
409+
running()
410+
await gate
411+
return run(...args)
412+
}
413+
let releases = 0
414+
const daemon = await startDaemon({
415+
directory: dir,
416+
version: "1",
417+
runner,
418+
file,
419+
idleMs: 60_000,
420+
releaseFiles: async () => {
421+
releases++
422+
},
423+
})
424+
cleanups.push(() => daemon.stop())
425+
const pending = send(file, { id: "1", name: "t" }).catch(() => undefined)
426+
await begun
427+
expect((await send(file, { id: "x", op: "stop" }))?.output).toBe("stopping")
428+
finishRun()
429+
await pending
430+
await daemon.closed
431+
expect(releases).toBe(0)
432+
})
433+
399434
test.skipIf(process.platform === "win32")("the socket is private to its owner", async () => {
400435
const { file } = await start()
401436
expect(statSync(file).mode & 0o777).toBe(0o600)

‎packages/opencode/test/altimate/tool-cli/runner.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,24 @@ describe("ask handler", () => {
165165
expect(Exit.isFailure(ask(true, "bash", ["truncate table x"]))).toBe(true)
166166
expect(isBlockedStatement(" Drop Schema s")).toBe(true)
167167
expect(isBlockedStatement("DROP TABLE t")).toBe(false)
168-
expect(isBlockedStatement("/* c */ DROP SCHEMA s")).toBe(true)
169-
expect(isBlockedStatement("SELECT 1; TRUNCATE t")).toBe(true)
168+
})
169+
170+
test("with the warehouse dialect, later statements and leading comments are checked too", () => {
171+
expect(isBlockedStatement("/* c */ DROP SCHEMA s", "postgres")).toBe(true)
172+
expect(isBlockedStatement("SELECT 1; TRUNCATE t", "postgres")).toBe(true)
173+
// Dollar-quoted text is a string in Postgres, not a second statement.
174+
expect(isBlockedStatement("SELECT $q$; TRUNCATE staging$q$", "postgres")).toBe(false)
175+
// Without a dialect quoting is unknown, so only the leading statement counts.
176+
expect(isBlockedStatement("SELECT $q$; TRUNCATE staging$q$")).toBe(false)
177+
Registry.setConfigs({ pg: { type: "postgres" } as any })
178+
try {
179+
const askWith = (patterns: string[]) =>
180+
Effect.runSyncExit(askFor(false)({ permission: "sql_execute", patterns, always: [], metadata: { warehouse: "pg" } }))
181+
expect(Exit.isFailure(askWith(["SELECT 1; TRUNCATE t"]))).toBe(true)
182+
expect(Exit.isSuccess(askWith(["SELECT $q$; TRUNCATE staging$q$"]))).toBe(true)
183+
} finally {
184+
Registry.reset()
185+
}
170186
})
171187
})
172188

@@ -228,6 +244,7 @@ describe("data_diff write-carrying inputs", () => {
228244
source_warehouse: "wh",
229245
[field]: value,
230246
})
247+
expect(result.status).toBe("error")
231248
expect(JSON.stringify(result)).toContain("read-only inputs")
232249
} finally {
233250
Registry.reset()

0 commit comments

Comments
 (0)