Skip to content

Instantly share code, notes, and snippets.

@gc-victor
Last active March 14, 2026 23:24
Show Gist options
  • Select an option

  • Save gc-victor/8e5c980e357b90858d2af2735aa2f504 to your computer and use it in GitHub Desktop.

Select an option

Save gc-victor/8e5c980e357b90858d2af2735aa2f504 to your computer and use it in GitHub Desktop.
oc-database

oc-database scripts

This folder contains Bun CLIs used by the oc-database skill to:

  • run read-only SQLite queries against an opencode.db file (sqlite_readonly.ts)
  • validate SQL strings against a strict “SELECT-only” policy (sql_validate.ts)

Run these scripts directly with Bun (there are no npm scripts wired up for them).

Prerequisites

  • Bun installed and available as bun
  • For sqlite_readonly.ts: a SQLite database file on disk (defaults to your local opencode.db path)

Installation (for running/tests)

From this folder:

bun install

Notes:

  • This folder includes a package.json and TypeScript config for type-checking/tests.

File overview

  • sqlite_readonly.ts — read-only query runner (Bun + bun:sqlite)
  • sql_validate.ts — SQL policy validator (exported as a reusable module)
  • utils.ts — shared helpers (arg parsing, exit codes, output directory safety)

Related tests (useful for behavior reference):

  • sql_validate.test.ts
  • sqlite_readonly.test.ts
  • utils.test.ts

sqlite_readonly.ts

Executes a validated SQL query against a SQLite database using Bun’s native SQLite driver.

What it does

  • validates the SQL with validateSql() from sql_validate.ts
  • opens the database with readonly: true
  • sets PRAGMA query_only = ON before running the query
  • formats results and writes them:
    • to stdout
    • and to a file under /tmp/oc-database/

Usage

bun sqlite_readonly.ts [OPTIONS] "<SQL>"

Options:

  • -h, --help — show help
  • -q, --quiet — suppress informational lines (DB:, Out:, Results saved to:)
  • -d, --db <path> — database file path
    • default: ($XDG_DATA_HOME || $HOME/.local/share)/opencode/opencode.db
  • -o, --out <slug> — output filename slug written under /tmp/oc-database/<slug>
    • allowed characters: A–Z a–z 0–9 . _ -
    • path traversal (.., ., .. segments) is rejected

Output behavior

  • Output directory is always /tmp/oc-database/.
  • If --out is not provided, the script generates a timestamp-based filename like:
    • YYYYMMDDTHHmmssSSS_result.txt
  • Output is always formatted as aligned columns with headers and separator lines
  • When the query returns zero rows, an empty string is emitted

Safety constraints

In addition to SQLite’s readonly open mode:

  • the DB path MUST exist and MUST be a regular file
  • the DB path MUST NOT be a symlink
  • SQL MUST pass the sql_validate.ts policy (see below)
  • the output filename MUST be a safe slug (no directories)

Examples

Print help:

bun sqlite_readonly.ts --help

End-to-end example using a temporary database:

# 1) Create a small example database
bun -e "import { Database } from 'bun:sqlite'; const db = new Database('/tmp/oc-db-readme.db', { create: true }); db.run('CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT)'); db.run(\"INSERT INTO project (name) VALUES ('alpha'), ('beta')\"); db.close();"

# 2) Run a safe SELECT query (quiet mode = machine-friendly output)
bun sqlite_readonly.ts -q --db /tmp/oc-db-readme.db "SELECT id, name FROM project"

# 3) Write results to a chosen file under /tmp/oc-database/
bun sqlite_readonly.ts -q --db /tmp/oc-db-readme.db --out projects.txt "SELECT id, name FROM project"

sql_validate.ts

Exports validateSql(sql: string) which enforces a strict “read-only SELECT” policy.

import { validateSql } from "./sql_validate.js";

const result = validateSql("SELECT id, name FROM project");
if (!result.valid) throw new Error(result.error);

Validation rules (enforced)

The validator:

  • rejects embedded LF/CR (\n or \r)
  • rejects semicolons (;) to prevent multiple statements
  • rejects SQLite dot-commands (queries starting with .)
  • only allows queries starting with:
    • SELECT ..., or
    • WITH ... SELECT ... (CTE must be followed by SELECT)
  • rejects DML/DDL keywords inside a WITH ... query string:
    • DELETE, UPDATE, INSERT, REPLACE, CREATE, DROP, ALTER, ATTACH, DETACH, VACUUM, ANALYZE, REINDEX, BEGIN, COMMIT, ROLLBACK
  • rejects references to forbidden identifiers (case-insensitive after normalization):
    • ACCESS_TOKEN, REFRESH_TOKEN, SECRET
  • rejects wildcard selection:
    • SELECT * and , *
    • table.*
    • (aggregate wildcards like COUNT(*) are allowed; arithmetic * is allowed)
  • only allows SQL comment markers at the very start of the query:
    • leading /* ... */ blocks are stripped
    • a leading -- ... line comment is treated as an empty query
    • comment markers appearing after the query starts are rejected

Notes:

  • validateSql("") returns { valid: true } (see sql_validate.test.ts).
  • A “valid” result only means the string passes policy checks; it does not guarantee the SQL will execute successfully.

Shared helpers (utils.ts)

These helpers are imported by scripts in this folder.

  • parseArgs(argv)
    • parses common flags: -h/--help, -q/--quiet, -r/--recursive, -d/--db, -o/--out, and -- separator
    • never calls process.exit() (unknown flags are collected in unknown[])
  • info(message, quiet) — prints # <message> unless quiet
  • die(message) — prints ERROR: <message> and exits with EXIT_USAGE (2)
  • requirePositionalArg(args, usageLine, argName) — enforces “no unknown flags” and “at least one positional”
  • passFailStatus(pass) — returns { status: "PASS"|"FAIL", exitCode: 0|1 }
  • ensureOutputDir(dir) — creates directory recursively; throws if the target path is a symlink
  • createTempFile(prefix) — creates a unique empty temp file under /tmp/ and schedules cleanup on process exit

Exit code constants:

  • EXIT_OK = 0
  • EXIT_FAIL = 1
  • EXIT_USAGE = 2

Troubleshooting

  • sqlite_readonly.ts prints ERROR: ... and exits 1 on validation, file, query, or write failures.
  • If your SQL is rejected, run it through validateSql() (or see sql_validate.test.ts for accepted/rejected examples).
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "oc-database-scripts",
"devDependencies": {
"@types/node": "^25.5.0",
"bun-types": "latest",
},
},
},
"packages": {
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
}
}

OpenCode DB Schema Reference

Database file: ~/.local/share/opencode/opencode.db SQLite version: 3.51.2 | Engine: Drizzle ORM migrations | Tables: 9 | Indexes: 15

Table Overview

Table Purpose Primary Key
__drizzle_migrations Schema migration log id (SERIAL)
control_account Authenticated accounts (email, url)
project Workspaces / projects id (TEXT)
session Conversations within projects id (TEXT)
message Messages within sessions id (TEXT)
part Message content parts id (TEXT)
permission Per-project permissions project_id (TEXT)
session_share Shared session links session_id (TEXT)
todo To-do items within sessions (session_id, position)

Column Reference

control_account

Column Type Notes
email TEXT PK ⛔ REDACT — always omit or mask
url TEXT PK ⛔ REDACT
access_token TEXT 🚫 NEVER OUTPUT
refresh_token TEXT 🚫 NEVER OUTPUT
token_expiry INTEGER Unix ms
active INTEGER 0/1 boolean
time_created INTEGER Unix ms
time_updated INTEGER Unix ms

project

Column Type Notes
id TEXT PK UUID-style
worktree TEXT ⛔ REDACT (filesystem path)
vcs TEXT VCS type, safe to show
name TEXT Human name, safe to show
icon_url TEXT ⛔ REDACT
icon_color TEXT Safe
time_created INTEGER Unix ms
time_updated INTEGER Unix ms
time_initialized INTEGER Unix ms, nullable
sandboxes TEXT 🔒 Opaque blob — do not dump
commands TEXT 🔒 Opaque blob — do not dump

session

Column Type Notes
id TEXT PK
project_id TEXT FK → project.id
parent_id TEXT FK → session.id Nullable; set for sub-sessions
slug TEXT Short human identifier, safe
directory TEXT ⛔ REDACT (filesystem path)
title TEXT Safe to show
version TEXT App version string, safe
share_url TEXT ⛔ REDACT
summary_additions INTEGER Line addition count
summary_deletions INTEGER Line deletion count
summary_files INTEGER File count touched
summary_diffs TEXT 🔒 Opaque blob
revert TEXT 🔒 Opaque blob
permission TEXT 🔒 Opaque blob
time_created INTEGER Unix ms
time_updated INTEGER Unix ms
time_compacting INTEGER Unix ms, nullable
time_archived INTEGER Unix ms, nullable; NULL = active

message

Column Type Notes
id TEXT PK
session_id TEXT FK → session.id Indexed
time_created INTEGER Unix ms
time_updated INTEGER Unix ms
data TEXT 🔒 Opaque blob — do not dump

part

Column Type Notes
id TEXT PK
message_id TEXT FK → message.id Indexed
session_id TEXT FK → session.id Indexed
time_created INTEGER Unix ms
time_updated INTEGER Unix ms
data TEXT 🔒 Opaque blob — do not dump

permission

Column Type Notes
project_id TEXT PK FK → project.id
time_created INTEGER Unix ms
time_updated INTEGER Unix ms
data TEXT 🔒 Opaque blob

session_share

Column Type Notes
session_id TEXT PK FK → session.id
id TEXT Share ID, safe to show
secret TEXT 🚫 NEVER OUTPUT
url TEXT ⛔ REDACT
time_created INTEGER Unix ms
time_updated INTEGER Unix ms

todo

Column Type Notes
session_id TEXT PK FK → session.id
content TEXT Task text, safe to show
status TEXT e.g. pending, done
priority TEXT e.g. high, medium, low
position INTEGER PK Ordering within session
time_created INTEGER Unix ms
time_updated INTEGER Unix ms

__drizzle_migrations

Internal table — do not query directly; counts only if needed.

Indexes

Index Table Column(s)
message_session_idx message session_id
part_message_idx part message_id
part_session_idx part session_id
session_parent_idx session parent_id
session_project_idx session project_id
todo_session_idx todo session_id

Plus implicit PK indexes on every table.

Foreign Keys

Child Child Column Parent Parent Column On Delete
message session_id session id CASCADE
part message_id message id CASCADE
permission project_id project id CASCADE
session project_id project id CASCADE
session_share session_id session id CASCADE
todo session_id session id CASCADE
{
"name": "oc-database-scripts",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@types/node": "^25.5.0",
"bun-types": "latest"
}
}

Query Cookbook

Copy-paste queries for OpenCode's database. Run via: bun shared/skills/oc-database/scripts/sqlite_readonly.ts "<query>" (Worklog agents must use the absolute path: bun ~/.config/opencode-profiles/shared/skills/oc-database/scripts/sqlite_readonly.ts "<query>")

All queries are SELECT-only and avoid forbidden/sensitive columns. Timestamps use datetime(col / 1000, 'unixepoch') for human-readable UTC.


Session Verification Report (Worklog)

This query set is used by the worklog agent to generate session verification evidence files.

Execution requirements:

  • SQL passed to sqlite_readonly.ts MUST be one line (no newlines) and MUST NOT include semicolons — the validator rejects both.
  • Queries MUST be SELECT-only with explicit column lists (no SELECT * / table.*).
  • Follow the existing oc-database safety rules in shared/skills/oc-database/SKILL.md.

Wrapper (reuse for each query):

bun ~/.config/opencode-profiles/shared/skills/oc-database/scripts/sqlite_readonly.ts -o <EVIDENCE_SLUG>.txt "<SQL>"

session-id-lookup.txt — list recent sessions for ID lookup

Purpose: Verify a user-supplied title/slug/ID exists and retrieve the canonical session ID; also useful for showing the last 20 sessions.

Placeholders: none

SELECT s.id, s.slug, s.title, p.name AS project, datetime(s.time_created / 1000, 'unixepoch') AS created_at FROM session s JOIN project p ON p.id = s.project_id ORDER BY s.time_updated DESC LIMIT 20

session-header.txt — session metadata (header)

Purpose: Capture the session header fields used to scaffold a report (project, version, timestamps, state).

Placeholders: <SESSION_ID>

SELECT s.id, s.slug, s.title, s.version, p.name AS project, datetime(s.time_created / 1000, 'unixepoch') AS started_at, datetime(s.time_updated / 1000, 'unixepoch') AS last_active_at, CASE WHEN s.time_archived IS NULL THEN 'active' ELSE 'archived' END AS state FROM session s JOIN project p ON p.id = s.project_id WHERE s.id = '<SESSION_ID>'

session-diff-summary.txt — aggregate code change summary

Purpose: Pull the session's aggregate change counters (no file paths or diffs).

Placeholders: <SESSION_ID>

SELECT summary_files AS files_changed, summary_additions AS additions, summary_deletions AS deletions FROM session WHERE id = '<SESSION_ID>'

session-message-count.txt — message volume

Purpose: Count total messages recorded for a session.

Placeholders: <SESSION_ID>

SELECT COUNT(id) AS message_count FROM message WHERE session_id = '<SESSION_ID>'

session-todos.txt — session todo items

Purpose: List todo items for the session, ordered by position.

Placeholders: <SESSION_ID>

SELECT content, status, priority, position FROM todo WHERE session_id = '<SESSION_ID>' ORDER BY position ASC

session-subsessions.txt — child sessions

Purpose: List sub-sessions whose parent_id is the session being reported.

Placeholders: <SESSION_ID>

SELECT id, slug, title, datetime(time_created / 1000, 'unixepoch') AS created_at, CASE WHEN time_archived IS NULL THEN 'active' ELSE 'archived' END AS state FROM session WHERE parent_id = '<SESSION_ID>' ORDER BY time_created ASC

recent-sessions.txt — recent sessions for Phase 1 disambiguation

Purpose: Provide a list of the 20 most recently active sessions for user selection when no identifier is supplied.

Placeholders: none

SELECT s.id, s.slug, s.title, p.name AS project, datetime(s.time_updated / 1000, 'unixepoch') AS last_active FROM session s JOIN project p ON p.id = s.project_id ORDER BY s.time_updated DESC LIMIT 20

Counts & Inventory

Total projects, sessions, messages, parts

SELECT
  (SELECT COUNT(*) FROM project)  AS projects,
  (SELECT COUNT(*) FROM session)  AS sessions,
  (SELECT COUNT(*) FROM message)  AS messages,
  (SELECT COUNT(*) FROM part)     AS parts,
  (SELECT COUNT(*) FROM todo)     AS todos;

Projects with session counts

SELECT
  p.name,
  p.vcs,
  COUNT(s.id) AS session_count,
  datetime(p.time_created / 1000, 'unixepoch') AS created_at
FROM project p
LEFT JOIN session s ON s.project_id = p.id
GROUP BY p.id
ORDER BY session_count DESC;

Active vs archived sessions

SELECT
  CASE WHEN time_archived IS NULL THEN 'active' ELSE 'archived' END AS state,
  COUNT(*) AS count
FROM session
GROUP BY state;

Session Activity

Most recent sessions (last 20)

SELECT
  s.title,
  p.name AS project,
  s.version,
  datetime(s.time_created / 1000, 'unixepoch') AS created_at,
  datetime(s.time_updated / 1000, 'unixepoch') AS updated_at
FROM session s
JOIN project p ON p.id = s.project_id
ORDER BY s.time_updated DESC
LIMIT 20;

Sessions with most messages

SELECT
  s.title,
  p.name AS project,
  COUNT(m.id) AS message_count
FROM session s
JOIN project p ON p.id = s.project_id
LEFT JOIN message m ON m.session_id = s.id
GROUP BY s.id
ORDER BY message_count DESC
LIMIT 20;

Sessions with code-change summaries

SELECT
  s.title,
  p.name AS project,
  s.summary_files   AS files_changed,
  s.summary_additions AS additions,
  s.summary_deletions AS deletions
FROM session s
JOIN project p ON p.id = s.project_id
WHERE s.summary_files IS NOT NULL
ORDER BY s.summary_additions DESC
LIMIT 20;

Sub-sessions (sessions with a parent)

SELECT
  child.title      AS sub_session,
  parent.title     AS parent_session,
  p.name           AS project,
  datetime(child.time_created / 1000, 'unixepoch') AS created_at
FROM session child
JOIN session parent ON parent.id = child.parent_id
JOIN project p ON p.id = child.project_id
ORDER BY child.time_created DESC;

Daily / Weekly Activity

Sessions created per calendar day (last 30 days)

SELECT
  date(time_created / 1000, 'unixepoch') AS day,
  COUNT(*) AS sessions_created
FROM session
WHERE time_created >= (strftime('%s', 'now', '-30 days') * 1000)
GROUP BY day
ORDER BY day DESC;

Messages created per day (last 14 days)

SELECT
  date(time_created / 1000, 'unixepoch') AS day,
  COUNT(*) AS messages
FROM message
WHERE time_created >= (strftime('%s', 'now', '-14 days') * 1000)
GROUP BY day
ORDER BY day DESC;

Sessions updated in the last 7 days

SELECT
  s.title,
  p.name AS project,
  datetime(s.time_updated / 1000, 'unixepoch') AS last_updated
FROM session s
JOIN project p ON p.id = s.project_id
WHERE s.time_updated >= (strftime('%s', 'now', '-7 days') * 1000)
ORDER BY s.time_updated DESC;

Todos

All open todos with their session title

SELECT
  t.content,
  t.priority,
  t.status,
  s.title AS session,
  p.name  AS project
FROM todo t
JOIN session s ON s.id = t.session_id
JOIN project p ON p.id = s.project_id
WHERE t.status != 'done'
ORDER BY t.priority DESC, t.position ASC;

Todo counts by status

SELECT status, COUNT(*) AS count
FROM todo
GROUP BY status
ORDER BY count DESC;

Sessions with most pending todos

SELECT
  s.title,
  p.name AS project,
  COUNT(*) AS pending_count
FROM todo t
JOIN session s ON s.id = t.session_id
JOIN project p ON p.id = s.project_id
WHERE t.status != 'done'
GROUP BY t.session_id
ORDER BY pending_count DESC
LIMIT 10;

Shared Sessions

Count of shared sessions (no secrets or URLs)

SELECT COUNT(*) AS shared_session_count FROM session_share;

Shared sessions with creation dates

SELECT
  ss.id AS share_id,
  s.title AS session_title,
  datetime(ss.time_created / 1000, 'unixepoch') AS shared_at
FROM session_share ss
JOIN session s ON s.id = ss.session_id
ORDER BY ss.time_created DESC;

Control Account (Safe Subset Only)

Account count and active status — NO tokens, NO email, NO URL

SELECT
  active,
  COUNT(*) AS count,
  datetime(MIN(time_created) / 1000, 'unixepoch') AS oldest_account
FROM control_account
GROUP BY active;

Migrations

Applied migration count

SELECT COUNT(*) AS migrations_applied FROM __drizzle_migrations;
Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 2 column 197
---
name: oc-database
description: Read-only database access to OpenCode's local SQLite database. Answers questions about projects, sessions, messages, todos, and usage patterns from the OpenCode app database. Use when: (1) Counting or listing projects and sessions, (2) Querying session activity or message volume, (3) Inspecting todo items across sessions, (4) Understanding OpenCode usage patterns, (5) Any query against opencode.db that requires safe, read-only data access.
---

OC Database

Run safe, read-only queries against the OpenCode SQLite database at ~/.local/share/opencode/opencode.db.

Full schema — You MUST read references/db-schema.md for table and column reference.

Safety Guarantees (Script-Enforced)

The scripts/sqlite_readonly.ts script enforces the following safety measures:

  • Read-only execution: Opens SQLite with the -readonly flag and prepends PRAGMA query_only=ON.
  • Single-statement only: Rejects semicolons or newlines to prevent multiple statements.
  • Query type restriction: Only allows SELECT or WITH ... SELECT statements. Rejects WITH combined with DML/DDL (e.g., DELETE, UPDATE, INSERT).
  • Wildcard blocking: Robustly blocks SELECT * and table.* patterns (including versions obfuscated with whitespace or comments) to prevent accidental mass data exposure.
  • Comment control: Allows leading comments (-- or /* ... */) but rejects comment markers within the query body to prevent obfuscation.
  • Identifier protection: Rejects queries referencing sensitive identifiers like ACCESS_TOKEN, REFRESH_TOKEN, or SECRET.
  • Output confinement: Constrains output to /tmp/oc-database/ with safe filenames.

Redaction & Best Practices

While the script provides structural safety, agents MUST follow these guidelines:

  • PII Redaction: Replace email addresses (any column containing email) with [REDACTED] or omit them from final reports.
  • URL/Path Redaction: Redact URLs (url, share_url, icon_url) and local worktree paths.
  • Avoid Large Blobs: Do not dump raw content of TEXT blob columns such as data, sandboxes, commands, revert, summary_diffs, or permission.
  • Manual Review: Always review the columns you are selecting to ensure they do not contain sensitive information not covered by the script's blocklist.

Quick Start

Use scripts/sqlite_readonly.ts for all queries:

bun shared/skills/oc-database/scripts/sqlite_readonly.ts "SELECT COUNT(*) FROM project"

Output is written to /tmp/oc-database/<timestamp>_result.txt and printed to stdout.

Common queries — see references/query-cookbook.md for copy-paste queries.

Workflow

  1. Check DB exists: script exits with a clear error if the file is missing.
  2. Read full schema: read the references/db-schema.md for table and column reference.
  3. Compose query: use the cookbook for common questions; write custom SQL for one-offs.
  4. Apply safety rules: review query for forbidden columns before running.
  5. Run via script: never call sqlite3 directly — use sqlite_readonly.ts -o <slug>.txt "SELECT ...".
  6. Interpret results: timestamps are Unix epoch milliseconds; divide by 1000 for seconds.
  7. Save output: results land in /tmp/oc-database/ automatically.

Timestamp Conversion

All time_* columns are Unix milliseconds. To get a human-readable UTC date:

datetime(time_created / 1000, 'unixepoch')

Key Relationships

project (1) ──< session (N) ──< message (N) ──< part (N)
project (1) ──< permission (1)
session (1) ──< todo (N)
session (1) ──< session_share (1)

Joins always go through the FK chain. Use session_project_idx, message_session_idx, and part_session_idx indexes — they exist and are fast.

import { describe, expect, test } from "bun:test";
import { validateSql } from "./sql_validate.js";
/** Type helper for validation error results */
type ValidationError = { valid: false; error: string };
describe("validateSql", () => {
test("allows simple SELECT", () => {
expect(validateSql("SELECT name FROM project")).toEqual({ valid: true });
});
test("allows SELECT with explicit column list", () => {
expect(validateSql("SELECT id, name FROM project WHERE id = 1")).toEqual({ valid: true });
});
test("allows WITH...SELECT CTE", () => {
expect(validateSql("WITH cte AS (SELECT name FROM project) SELECT name FROM cte")).toEqual({ valid: true });
});
test("allows COUNT(*) aggregate", () => {
expect(validateSql("SELECT COUNT(*) FROM project")).toEqual({ valid: true });
});
test("allows arithmetic multiplication", () => {
expect(validateSql("SELECT 2 * 3 FROM project")).toEqual({ valid: true });
});
test("allows empty string", () => {
expect(validateSql("")).toEqual({ valid: true });
});
test("allows leading block comment", () => {
expect(validateSql("/* a comment */ SELECT name FROM project")).toEqual({ valid: true });
});
test("allows leading line comment", () => {
expect(validateSql("-- just a comment")).toEqual({ valid: true });
});
test("rejects trailing line comment", () => {
const result = validateSql("SELECT name FROM project -- some comment") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("Comment markers");
});
test("rejects mid-query block comment", () => {
const result = validateSql("SELECT /* evil */ name FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("Comment markers");
});
test("rejects unterminated block comment", () => {
const result = validateSql("/* unterminated SELECT name FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("Unterminated");
});
test("rejects semicolons", () => {
const result = validateSql("SELECT name FROM project; DROP TABLE project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("semicolon");
});
test("rejects embedded LF", () => {
const result = validateSql("SELECT name FROM project\nDELETE FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("line break");
});
test("rejects embedded CR", () => {
const result = validateSql("SELECT name FROM project\rDELETE FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("line break");
});
test("rejects .tables dot-command", () => {
const result = validateSql(".tables") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("Dot-commands");
});
test("rejects .schema dot-command", () => {
const result = validateSql(".schema") as ValidationError;
expect(result.valid).toBe(false);
});
test("rejects SELECT *", () => {
const result = validateSql("SELECT * FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("SELECT *");
});
test("rejects SELECT DISTINCT *", () => {
const result = validateSql("SELECT DISTINCT * FROM project") as ValidationError;
expect(result.valid).toBe(false);
});
test("rejects table.* wildcard", () => {
const result = validateSql("SELECT project.* FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("table.*");
});
test("rejects INSERT", () => {
expect(validateSql("INSERT INTO project (name) VALUES ('evil')")).toMatchObject({ valid: false });
});
test("rejects UPDATE", () => {
expect(validateSql("UPDATE project SET name = 'evil'")).toMatchObject({ valid: false });
});
test("rejects DELETE", () => {
expect(validateSql("DELETE FROM project")).toMatchObject({ valid: false });
});
test("rejects CREATE TABLE", () => {
expect(validateSql("CREATE TABLE evil (id INTEGER)")).toMatchObject({ valid: false });
});
test("rejects DROP TABLE", () => {
expect(validateSql("DROP TABLE project")).toMatchObject({ valid: false });
});
test("rejects ALTER TABLE", () => {
expect(validateSql("ALTER TABLE project ADD COLUMN x TEXT")).toMatchObject({ valid: false });
});
test("rejects ACCESS_TOKEN", () => {
const result = validateSql("SELECT ACCESS_TOKEN FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("ACCESS_TOKEN");
});
test("rejects access_token (case-insensitive)", () => {
expect(validateSql("SELECT access_token FROM project")).toMatchObject({ valid: false });
});
test("rejects REFRESH_TOKEN", () => {
const result = validateSql("SELECT REFRESH_TOKEN FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("REFRESH_TOKEN");
});
test("rejects SECRET", () => {
const result = validateSql("SELECT SECRET FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("SECRET");
});
test("rejects WITH clause containing DELETE", () => {
const result = validateSql("WITH cte AS (SELECT name FROM project) DELETE FROM project") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("DELETE");
});
test("rejects WITH clause not followed by SELECT", () => {
const result = validateSql("WITH cte AS (SELECT name FROM project)") as ValidationError;
expect(result.valid).toBe(false);
expect(result.error).toContain("SELECT");
});
test("rejects WITH clause referencing REFRESH_TOKEN", () => {
expect(validateSql("WITH cte AS (SELECT name FROM project) SELECT REFRESH_TOKEN FROM project")).toMatchObject({ valid: false });
});
});
/**
* Validates a SQL string against the read-only query policy.
*
* Rules enforced:
* - No embedded newlines or carriage returns
* - No semicolons (multi-statement prevention)
* - No dot-commands (.tables, .schema, etc.)
* - Only SELECT or WITH...SELECT queries
* - No DML/DDL keywords inside WITH clauses
* - No forbidden columns: ACCESS_TOKEN, REFRESH_TOKEN, SECRET
* - No SELECT * or table.* wildcards (COUNT(*) etc. are exempt)
* - No comment markers except at the very start
*/
export type ValidateResult = { valid: true } | { valid: false; error: string };
/** Forbidden DML/DDL keywords in WITH clauses */
const FORBIDDEN_WITH_KEYWORDS = [
"DELETE", "UPDATE", "INSERT", "REPLACE",
"CREATE", "DROP", "ALTER", "ATTACH", "DETACH",
"VACUUM", "ANALYZE", "REINDEX", "BEGIN", "COMMIT", "ROLLBACK",
];
/** Pre-compiled regex pattern for forbidden keywords */
const FORBIDDEN_KEYWORD_PATTERN = new RegExp(
`(^|[^A-Z0-9_])(${FORBIDDEN_WITH_KEYWORDS.join("|")})([^A-Z0-9_]|$)`
);
/** Forbidden column/identifier names */
const FORBIDDEN_COLUMNS = ["ACCESS_TOKEN", "REFRESH_TOKEN", "SECRET"];
/**
* Validates a SQL string for read-only query policy compliance.
* @param sql - The SQL query string to validate
* @returns Validation result with success or error details
*/
export function validateSql(sql: string): ValidateResult {
// Check for forbidden newline characters
if (sql.includes("\n") || sql.includes("\r")) {
return { valid: false, error: "SQL contains forbidden line break characters." };
}
// Prevent multi-statement queries
if (sql.includes(";")) {
return { valid: false, error: "Multiple statements or semicolons are forbidden." };
}
// Strip leading comments (only allowed at start)
let t = sql.replace(/^[\s]+/, "");
while (t.startsWith("/*")) {
const end = t.indexOf("*/");
if (end === -1) {
return { valid: false, error: "Unterminated block comment." };
}
t = t.slice(end + 2).replace(/^[\s]+/, "");
}
if (t.startsWith("--")) {
t = "";
}
// Reject comments anywhere after the query starts
if (t.includes("--") || t.includes("/*")) {
return { valid: false, error: "Comment markers are only allowed at the beginning of the query." };
}
// Reject dot-commands
if (t.trimStart().startsWith(".")) {
return { valid: false, error: "Dot-commands are forbidden." };
}
const tUpper = t.replace(/\s+/g, " ").trim().toUpperCase();
// Empty query is valid
if (tUpper === "") {
return { valid: true };
}
// Must start with SELECT or WITH (which must contain SELECT)
if (!tUpper.startsWith("SELECT") && !tUpper.startsWith("WITH")) {
return { valid: false, error: "Only SELECT or WITH ... SELECT queries are allowed." };
}
if (tUpper.startsWith("WITH")) {
if (FORBIDDEN_KEYWORD_PATTERN.test(tUpper)) {
const found = FORBIDDEN_WITH_KEYWORDS.find(kw =>
new RegExp(`(^|[^A-Z0-9_])${kw}([^A-Z0-9_]|$)`).test(tUpper)
);
return { valid: false, error: `WITH clause cannot be used with ${found}.` };
}
if (!/ SELECT /.test(` ${tUpper} `)) {
return { valid: false, error: "WITH clause must be followed by SELECT." };
}
}
// Check for forbidden column names
for (const col of FORBIDDEN_COLUMNS) {
if (tUpper.includes(col)) {
return { valid: false, error: `Query references forbidden column/identifier: ${col}` };
}
}
// Check for wildcard patterns (excluding aggregate functions like COUNT(*))
const check = tUpper.replace(/[A-Z_][A-Z0-9_]*\s*\(\s*\*\s*\)/g, "AGGREGATE_WILDCARD");
// Reject table.* syntax
if (/([A-Z_][A-Z0-9_]*|"[^"]+"|\[[^\]]+\]|`[^`]+`)\s*\.\s*\*/.test(check)) {
return { valid: false, error: "The 'table.*' syntax is forbidden. Please list columns explicitly." };
}
// Reject SELECT * (standalone or after comma)
if (/(^|[^A-Z0-9_])SELECT(\s+(DISTINCT|ALL))?\s*\*/.test(check) || /,\s*\*/.test(check)) {
return { valid: false, error: "SELECT * is forbidden. Please list columns explicitly." };
}
return { valid: true };
}
import { Database } from "bun:sqlite";
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const SCRIPT = path.join(import.meta.dir, "sqlite_readonly.ts");
const TEST_DB = "/tmp/oc-database-test.db";
/**
* Runs the sqlite_readonly.ts script with the given arguments.
* @param args - Command-line arguments to pass to the script
* @returns Object with stdout, stderr, and exitCode
*/
async function run(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", SCRIPT, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
return { stdout, stderr, exitCode };
}
beforeAll(() => {
try {
fs.unlinkSync(TEST_DB);
} catch {}
const db = new Database(TEST_DB, { create: true });
db.run("CREATE TABLE IF NOT EXISTS project (id INTEGER PRIMARY KEY, name TEXT, ACCESS_TOKEN TEXT, REFRESH_TOKEN TEXT, SECRET TEXT)");
db.run("INSERT INTO project (name, ACCESS_TOKEN, REFRESH_TOKEN, SECRET) VALUES ('test', 'secret123', 'refresh456', 'topsecret')");
db.close();
});
afterAll(() => {
try {
fs.unlinkSync(TEST_DB);
} catch {}
// clean up symlink if created by symlink safety test
try {
fs.unlinkSync("/tmp/oc-database-test-symlink.db");
} catch {}
});
describe("sqlite_readonly", () => {
// no arguments → exit 1, usage printed
test("no-arg: exits 1 and prints usage when no SQL given", async () => {
const { stdout, stderr, exitCode } = await run("--db", TEST_DB);
expect(exitCode).toBe(1);
expect(stdout + stderr).toContain("Usage");
});
// --help → exit 0, stdout contains "Usage"
test("--help: exits 0 and prints usage", async () => {
const { stdout, exitCode } = await run("--help");
expect(exitCode).toBe(0);
expect(stdout).toContain("Usage");
});
// valid SELECT → exit 0, output contains data
test("valid-select: exits 0 and returns query results", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project");
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
});
// CTE query with explicit column list → exit 0
test("cte-select: exits 0 for WITH...SELECT query", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "WITH cte AS (SELECT name FROM project) SELECT name FROM cte");
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
});
// semicolons → exit 1
test("injection-semicolon: exits 1 when SQL contains semicolon", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project; DROP TABLE project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DML — INSERT → exit 1
test("injection-insert: exits 1 for INSERT statement", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "INSERT INTO project (name) VALUES ('evil')");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DML — UPDATE → exit 1
test("injection-update: exits 1 for UPDATE statement", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "UPDATE project SET name = 'evil'");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DML — DELETE → exit 1
test("injection-delete: exits 1 for DELETE statement", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "DELETE FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DDL — CREATE → exit 1
test("injection-create: exits 1 for CREATE TABLE statement", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "CREATE TABLE evil (id INTEGER)");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DDL — DROP → exit 1
test("injection-drop: exits 1 for DROP TABLE statement", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "DROP TABLE project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DDL — ALTER → exit 1
test("injection-alter: exits 1 for ALTER TABLE statement", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "ALTER TABLE project ADD COLUMN x TEXT");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// dot-commands — .tables → exit 1
test("dot-command-tables: exits 1 for .tables dot-command", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, ".tables");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// dot-commands — .schema → exit 1
test("dot-command-schema: exits 1 for .schema dot-command", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, ".schema");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// newlines in SQL that hide a DML keyword → exit 1
test("newline-injection: exits 1 when SQL contains embedded newline", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project\nDELETE FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// SELECT * → exit 1
test("wildcard-star: exits 1 for SELECT * query", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT * FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// table.* → exit 1
test("wildcard-table-star: exits 1 for table.* wildcard", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT project.* FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// COUNT(*) → exit 0 (exempted)
test("count-star-exemption: exits 0 for COUNT(*) query", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT COUNT(*) FROM project");
expect(exitCode).toBe(0);
expect(stdout).toContain("1");
});
// arithmetic * → exit 0 (exempted)
test("arithmetic-star-exemption: exits 0 for arithmetic multiplication", async () => {
const { exitCode } = await run("--db", TEST_DB, "-q", "SELECT 2 * 3 FROM project");
expect(exitCode).toBe(0);
});
// forbidden column ACCESS_TOKEN → exit 1
test("forbidden-access-token: exits 1 for query referencing ACCESS_TOKEN", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT ACCESS_TOKEN FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// forbidden column case-insensitive → exit 1
test("forbidden-access-token-lowercase: exits 1 for lowercase access_token", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT access_token FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// forbidden column REFRESH_TOKEN → exit 1
test("forbidden-refresh-token: exits 1 for query referencing REFRESH_TOKEN", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT REFRESH_TOKEN FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// forbidden column SECRET → exit 1
test("forbidden-secret: exits 1 for query referencing SECRET column", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT SECRET FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// trailing line comment → exit 1 (only leading comments are allowed)
test("comment-trailing: exits 1 when SQL contains trailing -- comment", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project -- some comment");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// leading block comment stripped → exit 0
test("comment-block: exits 0 when leading /* */ block comment is stripped", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "/* a comment */ SELECT name FROM project");
expect(exitCode).toBe(0);
expect(stdout).toContain("test");
});
// mid-query comment → exit 1 (obfuscation prevention)
test("comment-mid-query: exits 1 when comment marker appears mid-query", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project -- hidden injection");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
const { stderr: stderr2, exitCode: exitCode2 } = await run("--db", TEST_DB, "SELECT /* evil */ name FROM project");
expect(exitCode2).toBe(1);
expect(stderr2).toContain("ERROR:");
});
// output format — column format is default
test("format-column: exits 0 with column format and headers", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project");
expect(exitCode).toBe(0);
expect(stdout).toContain("name");
});
// missing database → exit 1, stderr contains error
test("missing-db: exits 1 when database file does not exist", async () => {
const { stderr, exitCode } = await run("--db", "/tmp/this-does-not-exist-oc-database.db", "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// symlink safety: symlink to real DB → exit 1
test("symlink-safety: exits 1 when --db path is a symlink", async () => {
const symlinkPath = "/tmp/oc-database-test-symlink.db";
try {
fs.unlinkSync(symlinkPath);
} catch {}
fs.symlinkSync(TEST_DB, symlinkPath);
const { stderr, exitCode } = await run("--db", symlinkPath, "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// quiet mode: -q suppresses informational lines
test("quiet-mode: exits 0 and suppresses DB/Out info lines with -q", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project");
expect(exitCode).toBe(0);
expect(stdout).not.toContain("DB:");
expect(stdout).not.toContain("Out:");
expect(stdout).not.toContain("Results saved to:");
});
// normal mode: without -q, info lines are present
test("normal-mode: exits 0 and includes DB/Out info lines without -q", async () => {
const { stdout, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project");
expect(exitCode).toBe(0);
expect(stdout).toContain("DB:");
expect(stdout).toContain("Out:");
});
// --out slug validation: valid slug succeeds
test("out-slug-valid: exits 0 with valid --out slug", async () => {
const { exitCode } = await run("--db", TEST_DB, "-q", "--out", "test-output.txt", "SELECT name FROM project");
expect(exitCode).toBe(0);
// cleanup
try {
fs.unlinkSync("/tmp/oc-database/test-output.txt");
} catch {}
});
// --out slug with invalid chars → exit 1
test("out-slug-invalid: exits 1 when --out slug contains invalid characters", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "--out", "../../etc/passwd", "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// --out slug of '.' → exit 1
test("out-slug-dot: exits 1 when --out slug is '.'", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "--out", ".", "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// --format option should be rejected as unknown
test("format-option-rejected: exits 1 for --format option", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "--format", "column", "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
expect(stderr).toContain("Unknown option");
});
// unknown option → exit 1
test("unknown-option: exits 1 for unknown flag", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "--unknown-flag", "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// DML keyword inside WITH clause → exit 1
test("with-clause-dml: exits 1 when WITH clause contains DELETE keyword", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "WITH cte AS (SELECT name FROM project) DELETE FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// WITH clause without SELECT → exit 1
test("with-clause-no-select: exits 1 when WITH clause is not followed by SELECT", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "WITH cte AS (SELECT name FROM project)");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// output is written to /tmp/oc-database/ by default
test("output-saved: result file is written to /tmp/oc-database/", async () => {
const before = fs.readdirSync("/tmp/oc-database").filter((f: string) => f.endsWith("_result.txt")).length;
const { exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project");
expect(exitCode).toBe(0);
const after = fs.readdirSync("/tmp/oc-database").filter((f: string) => f.endsWith("_result.txt")).length;
expect(after).toBeGreaterThan(before);
});
// REFRESH_TOKEN in WITH clause → exit 1
test("with-clause-refresh-token: exits 1 when WITH clause references REFRESH_TOKEN", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "WITH cte AS (SELECT name FROM project) SELECT REFRESH_TOKEN FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// SELECT DISTINCT * → exit 1
test("select-distinct-star: exits 1 for SELECT DISTINCT * query", async () => {
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT DISTINCT * FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("ERROR:");
});
// ensureOutputDir behavior: creates output directory if it doesn't exist
test("output-dir-created: creates /tmp/oc-database directory if not present", async () => {
// Remove output directory to test creation
try {
fs.rmSync("/tmp/oc-database", { recursive: true, force: true });
} catch {}
// Run - should create /tmp/oc-database and write output
const { exitCode } = await run("--db", TEST_DB, "-q", "--out", "test-created.txt", "SELECT name FROM project");
expect(exitCode).toBe(0);
expect(fs.existsSync("/tmp/oc-database")).toBe(true);
expect(fs.existsSync("/tmp/oc-database/test-created.txt")).toBe(true);
// Cleanup
try {
fs.unlinkSync("/tmp/oc-database/test-created.txt");
} catch {}
});
// ensureOutputDir behavior: creates nested directories recursively in base output dir
test("output-dir-nested: ensures /tmp/oc-database is created recursively", async () => {
// Remove /tmp/oc-database to ensure it's created from scratch
try {
fs.rmSync("/tmp/oc-database", { recursive: true, force: true });
} catch {}
// Run script - it should create /tmp/oc-database
const { exitCode } = await run("--db", TEST_DB, "-q", "--out", "test-nested.txt", "SELECT name FROM project");
expect(exitCode).toBe(0);
expect(fs.existsSync("/tmp/oc-database")).toBe(true);
expect(fs.existsSync("/tmp/oc-database/test-nested.txt")).toBe(true);
// Cleanup
try {
fs.unlinkSync("/tmp/oc-database/test-nested.txt");
} catch {}
});
// ensureOutputDir behavior: throws error if output directory is a symlink
test("output-dir-symlink: exits 1 when /tmp/oc-database is a symlink", async () => {
// Create a real directory, then replace /tmp/oc-database with a symlink
const realDir = "/tmp/oc-database-real-backup";
const symlinkPath = "/tmp/oc-database";
try {
// Create the real directory
fs.mkdirSync(realDir, { recursive: true });
// Remove existing /tmp/oc-database if it exists (keep contents for safety)
let existingDirContents: string[] = [];
try {
existingDirContents = fs.readdirSync(symlinkPath);
} catch {}
fs.rmSync(symlinkPath, { recursive: true, force: true });
// Create symlink
fs.symlinkSync(realDir, symlinkPath);
// Run the script - should fail because output dir is a symlink
const { stderr, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project");
expect(exitCode).toBe(1);
expect(stderr).toContain("symlink");
} finally {
// Cleanup: remove symlink and restore directory
try {
fs.unlinkSync(symlinkPath);
} catch {}
try {
fs.rmSync(realDir, { recursive: true, force: true });
} catch {}
// Recreate original /tmp/oc-database
try {
fs.mkdirSync(symlinkPath, { recursive: true });
} catch {}
}
});
});
#!/usr/bin/env bun
// sqlite_readonly.ts — BunJS read-only SQLite query executor
// Port of sqlite_readonly.sh; uses bun:sqlite natively.
import { Database } from "bun:sqlite";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { validateSql as validateSqlPure } from "./sql_validate.js";
// Mirrors xdg-basedir@5.1.0 logic used by opencode (packages/opencode/src/global/index.ts):
// xdgData = $XDG_DATA_HOME ?? $HOME/.local/share (all platforms, including macOS)
// DB path: <xdgData>/opencode/opencode.db
// Ref: packages/opencode/src/storage/db.ts → Global.Path.data + "opencode.db"
const DEFAULT_DB = path.join(
process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"),
"opencode",
"opencode.db",
);
const OUTPUT_DIR = "/tmp/oc-database";
type SQLiteValue = string | number | null;
type Row = Record<string, SQLiteValue>;
/**
* Prints an error message to stderr and exits with code 1.
* @param message - Error message to display
* @returns never - always terminates the process
*/
function errorExit(message: string): never {
process.stderr.write(`ERROR: ${message}\n`);
process.exit(1);
}
/**
* Prints usage information to stdout and exits.
* @param code - Exit code (0 for help, 1 for usage error)
* @returns never - always terminates the process
*/
function usageExit(code: 0 | 1 = 1): never {
const usage = `sqlite_readonly.ts — Execute a read-only SELECT query against opencode.db
Usage: sqlite_readonly.ts [OPTIONS] "<SQL>"
Options:
-h, --help Show this help text
-q, --quiet Suppress informational lines (DB:, Out:, Results saved to:)
-d, --db <path> Database path (default: ${DEFAULT_DB})
-o, --out <slug> Write output to /tmp/oc-database/<slug> (only [A-Za-z0-9._-] allowed)
Safety:
- Opens DB with readonly: true
- Executes PRAGMA query_only = ON before any query
- Rejects DML, DDL, semicolons, dot-commands, SELECT *, forbidden columns
`;
process.stdout.write(usage);
process.exit(code);
}
interface Args {
help: boolean;
quiet: boolean;
db: string;
outSlug: string | null;
rawOutSlug: string | null; // original value before validation
sql: string | null;
unknownFlag: string | null;
}
/**
* Parses command-line arguments into an Args object.
* Supports: -h/--help, -q/--quiet, -d/--db, -o/--out, and SQL positional argument.
* @param argv - Command-line arguments (excluding node/bun path and script name)
* @returns Parsed arguments object
*/
function parseArgv(argv: string[]): Args {
const args: Args = {
help: false,
quiet: false,
db: DEFAULT_DB,
outSlug: null,
rawOutSlug: null,
sql: null,
unknownFlag: null,
};
let i = 0;
let pastSeparator = false;
while (i < argv.length) {
const arg = argv[i];
if (pastSeparator) {
// Everything after -- is the SQL
const rest = argv.slice(i).join(" ");
args.sql = rest || null;
break;
}
if (arg === "--") {
pastSeparator = true;
i++;
continue;
}
if (arg === "-h" || arg === "--help") {
args.help = true;
i++;
continue;
}
if (arg === "-q" || arg === "--quiet") {
args.quiet = true;
i++;
continue;
}
if (arg === "-d" || arg === "--db") {
if (i + 1 >= argv.length) {
errorExit("--db requires a path argument");
}
args.db = argv[i + 1] as string;
i += 2;
continue;
}
if (arg === "-o" || arg === "--out") {
if (i + 1 >= argv.length) {
errorExit("--out requires a filename slug");
}
args.rawOutSlug = argv[i + 1] as string;
i += 2;
continue;
}
if (arg.startsWith("-")) {
args.unknownFlag = arg;
i++;
continue;
}
// First non-flag positional: rest of argv is the SQL
const rest = argv.slice(i).join(" ");
args.sql = rest || null;
break;
}
return args;
}
/**
* Validates SQL query for safety (read-only SELECT only).
* Rejects DML, DDL, semicolons, dot-commands, wildcards, and forbidden columns.
* @param sql - SQL query to validate
* @throws Exits with error if validation fails
*/
function validateSql(sql: string): void {
const result = validateSqlPure(sql);
if (!result.valid) {
errorExit(result.error);
}
}
/**
* Converts a SQLite value to a string representation.
* @param v - SQLite value (string, number, or null)
* @returns String representation (empty string for null)
*/
function cellStr(v: SQLiteValue): string {
if (v === null) return "";
return String(v);
}
/**
* Formats query results as aligned columns with header and separator rows.
* @param rows - Array of row objects
* @returns Column-formatted string
*/
function formatRows(rows: Row[]): string {
if (rows.length === 0) return "";
const cols = Object.keys(rows[0] as Row);
// Compute column widths: max of header length and all data lengths
const widths = cols.map((c) => {
let w = c.length;
for (const row of rows) {
w = Math.max(w, cellStr(row[c] ?? null).length);
}
return w;
});
const lines: string[] = [];
// Header row
lines.push(cols.map((c, i) => c.padEnd(widths[i] as number)).join(" "));
// Separator row
lines.push(widths.map((w) => "-".repeat(w)).join(" "));
// Data rows
for (const row of rows) {
lines.push(cols.map((c, i) => cellStr(row[c] ?? null).padEnd(widths[i] as number)).join(" "));
}
return `${lines.join("\n")}\n`;
}
/**
* Validates and resolves the output filename slug to a full path.
* Rejects path traversal attempts and invalid characters.
* @param slug - User-provided filename slug
* @returns Full path in OUTPUT_DIR
* @throws Exits with error if slug is invalid
*/
function validateOutSlug(slug: string): string {
if (slug === "." || slug === ".." || slug.includes("..")) {
errorExit("--out cannot be '.' or '..' or contain '..'");
}
if (!/^[A-Za-z0-9._-]+$/.test(slug)) {
errorExit("--out only accepts a safe filename slug ([A-Za-z0-9._-])");
}
return path.join(OUTPUT_DIR, slug);
}
/**
* Creates the output directory if it doesn't exist.
* Throws if the target path is a symlink.
* @param dir - Directory path to ensure exists
*/
export function ensureOutputDir(dir: string): void {
if (fs.existsSync(dir)) {
const stat = fs.lstatSync(dir);
if (stat.isSymbolicLink()) {
throw new Error("Output directory is a symlink");
}
}
fs.mkdirSync(dir, { recursive: true });
}
/**
* Main entry point: parses args, validates SQL, executes query, formats and writes output.
* Enforces read-only access with multiple safety measures.
*/
function main(): void {
const argv = process.argv.slice(2);
const args = parseArgv(argv);
if (args.help) {
usageExit(0);
}
// Unknown flags → error
if (args.unknownFlag !== null) {
errorExit(`Unknown option: ${args.unknownFlag} (try --help)`);
}
// No SQL → usage + exit 1
if (!args.sql) {
usageExit(1);
}
const sql = args.sql;
const { quiet, db: dbPath } = args;
// Validate --out slug
let outFile: string;
if (args.rawOutSlug !== null) {
outFile = validateOutSlug(args.rawOutSlug);
} else {
// Auto-generate timestamp-based filename with millisecond precision
const ts = new Date()
.toISOString()
.replace(/[-:.Z]/g, "")
.slice(0, 18); // YYYYMMDDTHHmmssSSS
outFile = path.join(OUTPUT_DIR, `${ts}_result.txt`);
}
// Validate SQL
validateSql(sql);
// Validate DB path
if (!fs.existsSync(dbPath)) {
errorExit(`Database file not found: ${dbPath}`);
}
const dbStat = fs.lstatSync(dbPath);
if (dbStat.isSymbolicLink()) {
errorExit(`Database path is not a regular file: ${dbPath}`);
}
if (!dbStat.isFile()) {
errorExit(`Database path is not a regular file: ${dbPath}`);
}
// Ensure output directory
try {
ensureOutputDir(OUTPUT_DIR);
} catch (e) {
errorExit(String(e instanceof Error ? e.message : e));
}
// Print info lines (normal mode)
if (!quiet) {
process.stdout.write(`DB: ${dbPath}\n`);
process.stdout.write(`Out: ${outFile}\n`);
process.stdout.write("\n");
}
// Execute query
let rows: Row[];
try {
const db = new Database(dbPath, { readonly: true });
db.run("PRAGMA query_only = ON");
const stmt = db.prepare(sql);
rows = stmt.all() as Row[];
db.close();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
errorExit(`Query failed: ${msg}`);
}
// Format output (always uses column format)
const output = formatRows(rows);
// Write to file and stdout
try {
fs.writeFileSync(outFile, output, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
errorExit(`Failed to write output file: ${msg}`);
}
process.stdout.write(output);
if (!quiet) {
process.stdout.write("\n");
process.stdout.write(`Results saved to: ${outFile}\n`);
}
}
main();
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"target": "ESNext",
"types": ["bun-types", "node"],
},
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment