Skip to content

feat(plugin-sqlite): load SQLite extensions such as sqlite-vec and SpatiaLite on SQLite and local libSQL connections - #3064

Merged
datlechin merged 7 commits into
mainfrom
feat/sqlite-extensions
Sep 23, 2026
Merged

datlechin merged 7 commits into
mainfrom
feat/sqlite-extensions

Conversation

@datlechin

@datlechin datlechin commented Sep 23, 2026

Copy link
Copy Markdown
Member

Fixes #2502

What was wrong

Two separate things stopped a SQLite extension from ever loading:

  1. Both plugins linked macOS's own libsqlite3, which Apple builds with SQLITE_OMIT_LOAD_EXTENSION. On macOS 27 (SQLite 3.54.0), sqlite3_load_extension and sqlite3_enable_load_extension are neither declared in the SDK header nor exported from the dylib (dlsym returns NULL), and sqlite3_compileoption_get lists OMIT_LOAD_EXTENSION. No code change inside the plugin could get past that.
  2. The SQLite plugin's authorizer blocks the load_extension() SQL function on purpose. It still does.

What this changes

The plugins link their own SQLite

  • scripts/build-sqlite.sh builds SQLite 3.53.4 from the canonical source tree.
    • It pins the version and a SHA-256. I checked the download against sqlite.org's published SHA3-256 first.
    • It configures with --enable-update-limit, so DELETE ... LIMIT keeps working.
    • It targets macOS 13, compiles with -fvisibility=hidden, and archives with ZERO_AR_DATE, so two builds are byte-identical.
    • It carries the options the system build has: FTS3/4/5, R-Tree, math functions, dbstat, bytecode vtab, carray, session, snapshot, percentile, column metadata, API armor, THREADSAFE=2. It leaves out FTS3_TOKENIZER.
    • The archive is libsqlite3_vendored, so it can never win a -lsqlite3 lookup from a target that has Libs/ on its search path.
  • scripts/check-sqlite-build.sh is a committed probe that checks the archive itself:
    • the compile options;
    • that SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION opens the C API only while load_extension() stays not authorized;
    • that a loaded extension keeps working after loading is closed, and only on its own connection;
    • that no sqlite3_ symbol is exported;
    • that SQLiteBuiltinNames.swift matches the library's functions, table-valued functions and keywords.
  • CSQLite (headers plus a db_config shim, since sqlite3_db_config is variadic and Swift cannot call it) and TableProSQLiteCore (the authorizer, moved, and the two C calls loading needs) are new TableProCore package targets. Both plugins depend on them and -force_load the archive in place of -lsqlite3.
  • The built SQLiteDriver and LibSQLDriverPlugin no longer link libsqlite3.dylib, and their sqlite3_* symbols are non-external.
  • The four archives are published to libs-v1 with scripts/publish-libs.sh, and Libs/checksums.sha256 gains exactly those four lines. A checkout with an older Libs/ needs scripts/download-libs.sh --force once.

Loading

LoadableExtensionLoader (PluginKit, pure, tested) opens loading with the C API only, loads each file in list order, and closes loading again whatever happened. A handle that refuses to close fails the connect. Before SQLite sees a file, the loader checks:

  • the path is absolute, with ~ expanded explicitly (NSString.expandingTildeInPath silently truncates at 1,024 bytes);
  • the path holds no control or line-break characters;
  • the entry point is a C identifier;
  • the file exists at exactly that path, without SQLite's .dylib guess, so the approved file is the file that loads;
  • the same file, symlinks resolved, is not loaded twice;
  • a code signature that is present is intact. A damaged signature would get the app SIGKILLed on page-in.

When SQLite reports a failed open, the loader opens the file itself to get dyld's reason for the file actually listed: slice is not valid mach-o file, incompatible architecture, library load disallowed by system policy. It never re-opens a library that did open, so an initializer never runs twice.

The list is a ConnectionField whose new content is .loadableExtensions. That is an additive change and reuses the pending kit 33; check-pluginkit-abi.sh reports additions only. It syncs, exports and imports with additionalFields. libSQL declares it for Local File mode only, in the plugin and in both curated snapshots.

Trust

Nothing is approved by default:

  • LoadableExtensionApprovalStore is device-local and never synced. It records approval per connection, file and entry point.
  • DatabaseDriverFactory refuses to build a driver whose visible list holds an unapproved or invalid entry. That covers user connects, reconnects, pooled metadata drivers, Test Connection, MCP and AppleScript alike. No stored field exempts a list.
  • Entries added or changed in the connection form on this Mac count as approved. Entries the connection already carried keep whatever approval they had, which for an imported, synced, linked or deep-linked list is none.
  • A connect a person started asks first. The Load Extensions alert names every file, and the connection name is flattened to one line. It sits next to the pre-connect script prompt, and both now go through one ConnectConsent call.
  • Launch restore waits in the not-connected state instead of prompting.
  • On a connection that loads extensions, MCP clients, AppleScript and the AI assistant may call only functions built into SQLite. Their queries are otherwise text-classified, so without this SELECT BlobToFile(...) would read as a read.
    • The check covers bare, quoted and commented callees, and table-valued functions.
    • A column list (INSERT INTO t(id), WITH ids(id) AS, CREATE TABLE t(...)) is not read as a call.
    • A virtual table's USING module(...) is checked like a call, because an extension module can read files.

libSQL

Local File mode gets the same loader, and the authorizer it never had. That second part fixes a released defect: fts3_tokenizer was reachable from SQL there. The system library has ENABLE_FTS3_TOKENIZER, and the SQLite plugin's own comment records it as a SIGSEGV.

Verification

  • End to end through the real SQLitePluginDriver sources, from a swiftc harness linked against the vendored archive:
    • loaded SQLite 3.53.4, sqlite-vec 0.1.9 and SpatiaLite 5.1.0;
    • a vec0 KNN query answered;
    • load_extension() and fts3_tokenizer came back not authorized;
    • DELETE ... LIMIT worked;
    • missing, non-library, wrong-architecture, tampered, bad-entry-point and relative entries each failed with their own reason.
  • Unit: the loadable extension list, preflight, loader, approval store, gate and list model suites, SQLiteExtensionCallScannerTests, AdvancedPaneExtensionTests, plus additions to ConnectionFailureClassifierTests, LibSQLConnectionFieldsTests, ExternalStatementGateTests and the MCP, AI and AppleScript suites. 227 of 227 pass, plus 34 of 34 after the last scanner fix and 148 of 148 in the neighbour suites.
  • UI: LoadableExtensionListUITests checks that SQLite's Options tab shows the list with a dimmed Remove, and MySQL's does not. Adding a row goes through the system file panel, which a UI test cannot drive deterministically: the panel dims anything that is not a library, and no library sits at a fixed path on every runner. The list arithmetic is unit-tested.
  • Build: the app and all 41 plugins.
  • Lint: 0 violations.
  • Docs checks pass.

Reviews

  • A security review found that a stored sqliteBackend field could exempt a libSQL list from approval. Fixed, with a regression test.
  • Codex review: publishing and checksums, shell-quoting in recovery commands, line injection into the consent alert, and duplicate loads under two spellings. All fixed.
  • Codex adversarial review:
    • Extension functions escaping read-only external access: fixed.
    • A missing entry point re-running initializers: fixed.
    • "Approval follows a path, not a digest": replacing an approved file needs write access to it, which already means same-user code execution, so digest pinning is not in this PR. The part that is not about write access is fixed: the prompt could name /x/foo while /x/foo.dylib loaded, and exact paths now close that.
  • A final Codex review of the fix commits:
    • Column lists read as calls: fixed.
    • The gate reads the statement's text, not what SQLite resolves: kept as residual risk. A view or trigger in the database that calls an extension function still runs it for an external reader. An extension that overrides a built-in name is not caught either. Creating either kind of view or trigger from outside the app is refused, because its text contains the call. What is left needs a database whose own author wrote the harmful call, which runs for anyone reading that view, in the editor too, or a malicious extension, which already runs native code at load. Closing it fully means a caller-scoped authorizer in the driver, a new execution API across every engine, and is left for a follow-up.

Screenshots

docs/images/sqlite-extensions.png and -dark.png come from a sandboxed Debug build of this branch, pinned light and dark with TABLEPRO_SCREENSHOT_APPEARANCE: the new-connection form's Options pane with /opt/homebrew/lib/mod_spatialite.dylib and ~/Downloads/vec0.dylib listed. The SQLite page's steps now say that a new connection has the same pane, that Cmd+Shift+G reaches the hidden /opt folder in the file panel, and that a row is created only through the panel and edited in place after that.

@mintlify

mintlify Bot commented Sep 23, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 23, 2026, 7:12 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit 7b79d16 into main Sep 23, 2026
9 checks passed
@datlechin
datlechin deleted the feat/sqlite-extensions branch September 23, 2026 08:14

This branch was successfully deployed

1 active deployment
staging - docs 4aa5e97c Deployed Sep 23, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Load SQLite extensions, including sqlite-vec

1 participant