Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/viewer/src/model_context/model_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../schemas.js";
import type { EmbeddingAtlasStore } from "../stores/embedding_atlas_store.js";
import { screenshot, type ScreenshotOptions } from "../utils/screenshot.js";
import { isReadonlyQuery } from "./readonly_query.js";

export interface ModelContextDelegate {
container: HTMLDivElement;
Expand Down Expand Up @@ -71,11 +72,13 @@ export class EmbeddingAtlasControl {

this.register("data_query", {
args: {
query: z.string().describe("The SQL query to run. Must keep this readonly - the server does not enforce it."),
query: z.string().describe("The readonly SQL query to run (SELECT/WITH/VALUES/DESCRIBE/SHOW/EXPLAIN only)."),
},
description: "Run a readonly SQL query in DuckDB",
handler: async ({ query }) => {
// TODO: enforce readonly query.
if (!isReadonlyQuery(query)) {
return { error: "only readonly queries (SELECT/WITH/VALUES/DESCRIBE/SHOW/EXPLAIN) are allowed" };
}
let result = await store.coordinator.query(query);
return result.toArray();
},
Expand Down Expand Up @@ -429,3 +432,5 @@ function parseImageDataUrl(dataUrl: string): { mimeType: string; data: string }

return { mimeType, data: base64Content };
}

export { isReadonlyQuery } from "./readonly_query.js";
47 changes: 47 additions & 0 deletions packages/viewer/src/model_context/readonly_query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2025 Apple Inc. Licensed under MIT License.

const READONLY_FIRST_WORD = new Set([
"SELECT",
"WITH",
"VALUES",
"TABLE",
"DESCRIBE",
"DESC",
"SHOW",
"EXPLAIN",
"SUMMARIZE",
"PIVOT",
]);

const WRITE_KEYWORDS =
/\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|COPY|ATTACH|DETACH|INSTALL|LOAD|CALL|PRAGMA|SET|VACUUM|CHECKPOINT|TRUNCATE|GRANT|REVOKE|USE)\b/i;

function stripStringsAndComments(sql: string): string {
// Remove '...', "...", `...`, /* ... */, -- ... to avoid false positives.
return sql
.replace(/'(?:[^']|'')*'/g, " ")
.replace(/"(?:[^"\\]|\\.)*"/g, " ")
.replace(/`(?:[^`\\]|\\.)*`/g, " ")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\n]*/g, " ");
}

export function isReadonlyQuery(query: string): boolean {
let cleaned = stripStringsAndComments(query).trim();
// Allow a single trailing semicolon; reject stacked statements.
cleaned = cleaned.replace(/;+\s*$/, "").trim();
if (cleaned === "" || cleaned.includes(";")) {
return false;
}
let firstWord = cleaned
.split(/\s+/, 1)[0]
?.replace(/[^A-Za-z]/g, "")
.toUpperCase();
if (firstWord == null || !READONLY_FIRST_WORD.has(firstWord)) {
return false;
}
if (WRITE_KEYWORDS.test(cleaned)) {
return false;
}
return true;
}
34 changes: 34 additions & 0 deletions packages/viewer/test/model_context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2025 Apple Inc. Licensed under MIT License.

import { describe, expect, it } from "vitest";

import { isReadonlyQuery } from "../src/model_context/readonly_query.js";

describe("isReadonlyQuery", () => {
it("allows plain reads", () => {
expect(isReadonlyQuery("SELECT * FROM dataset")).toBe(true);
expect(isReadonlyQuery(" with t AS (SELECT 1) SELECT * FROM t")).toBe(true);
expect(isReadonlyQuery("VALUES (1), (2);")).toBe(true);
expect(isReadonlyQuery("DESCRIBE dataset")).toBe(true);
expect(isReadonlyQuery("SHOW TABLES")).toBe(true);
expect(isReadonlyQuery("EXPLAIN SELECT 1")).toBe(true);
expect(isReadonlyQuery("-- comment\nSELECT version() AS version")).toBe(true);
});

it("rejects writes and stacked statements", () => {
expect(isReadonlyQuery("DROP TABLE dataset")).toBe(false);
expect(isReadonlyQuery("DELETE FROM dataset")).toBe(false);
expect(isReadonlyQuery("INSERT INTO dataset VALUES (1)")).toBe(false);
expect(isReadonlyQuery("UPDATE dataset SET x = 1")).toBe(false);
expect(isReadonlyQuery("CREATE TABLE t (x INT)")).toBe(false);
expect(isReadonlyQuery("COPY dataset TO 'out.parquet'")).toBe(false);
expect(isReadonlyQuery("ATTACH 'other.db'")).toBe(false);
expect(isReadonlyQuery("SELECT * FROM dataset; DROP TABLE dataset")).toBe(false);
expect(isReadonlyQuery("")).toBe(false);
});

it("ignores keywords inside strings and comments", () => {
expect(isReadonlyQuery("SELECT 'DROP TABLE not_a_table' AS x")).toBe(true);
expect(isReadonlyQuery("SELECT * FROM t -- DELETE")).toBe(true);
});
});