Help us shape the future of Go! Take the 2026 Go Developer Survey

locksql

module
v0.11.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: Apache-2.0

README

CI Apache 2.0

Let Claude Code, Codex, Cursor or Gemini CLI query your databases
without ever handing them a password.

Quick start · How it works · Safety model · Configuration · Docs


locksql puts a human-held console between your AI agent and MariaDB, MySQL, PostgreSQL or SQLite. The agent proposes SQL; the console, running in a terminal you keep in view, holds the credentials, parses and weighs every statement, masks personal data and waits for your approval. The agent never sees a credential and never opens a connection.

Claude Code (left, account alice) and the locksql console (right, account locksql, started with --show-results). Claude gets <redacted:rN.R.C> references, filters on them without seeing a value, then searches on an email the human types in the console.

[!NOTE] Pre-release (v0.11.x). See Install.

Why locksql?

Agent tools already ask before running a command, but that approval lives inside the agent's own process: the agent holds the credentials, the prompt can be configured away, and a long session trains you to press "yes". locksql moves the decision out of the agent.

In-agent approval locksql
Agent never sees the DB password ❌ ✅
Agent has no DB connection ❌ ✅
Approval cannot be switched off from the agent side ❌ ✅
Every read parsed down to its source columns ❌ ✅
EXPLAIN cost check before running ❌ ✅
PII masked on its source column, not its label ❌ ✅
k-anonymity warning on PII filters and aggregates ❌ ✅
Search on a PII value the agent never sees ❌ ✅
Policy loosening needs a human confirmation ❌ ✅
Append-only audit log ❌ ✅

In-agent approval is a convenience; a separate approval process is a boundary.

Highlights

🔑 One key holder
Credentials are typed at console start or fetched from HashiCorp Vault. Never in argv, env, files, logs, the socket or error messages.

👤 Human in the loop
Every statement is shown with its plan, cost and PII footprint. On production you type the profile name, not y.

🧱 Fail closed
A dialect-aware classifier refuses anything it cannot classify with certainty. Reads are parsed in full; unknown syntax is refused.

🕶️ PII masking
Every output column is traced to its source through aliases, CTEs, unions and joins, then masked (redact by default, partial or email).

⚖️ Weight check
LIMIT is mandatory, EXPLAIN runs first, and plans that would scan too many rows are refused before they run.

🛡️ OS separation
The console runs under its own account in its own container; the kernel checks every peer on the socket.

🔎 Blind search
The agent filters on '${email}' and you type the value in the console, or on a redacted cell's reference ('${r1.2.3}'). The value never reaches it.

👁️ Clear for you only
Ctrl-O in the console prints the clear rows there while the agent gets them masked; --allow-unmask (or :unmask in the console) is the only way an agent can ask for raw PII.

🔐 Remote, verified
TLS verifies the server certificate by default (tls = "verify-full"), and an ssh table reaches a database behind a bastion: the console opens the tunnel itself, checks the bastion's host key and listens on no local port.

Quick start

locksql runs in Docker: nothing on your machine but Docker and the locksql launcher, which runs every command in the image. A first approved query, from your project directory:

# 1. Install the launcher (it runs every locksql command in Docker)
V=vX.Y.Z
curl -fsSLO "https://raw.githubusercontent.com/DavidGodefroid/locksql/$V/scripts/install.sh"
less install.sh && LOCKSQL_VERSION=$V sh install.sh

# 2. In your project: Claude Code's files (its MCP server is `locksql mcp`),
#    a database profile, then give .locksql/ to the console account
locksql init claude
locksql profile add
locksql doctor --fix --yes

# 3. In a terminal you keep in view, start the console
locksql console

Then ask your agent, in the project: "how many orders were placed yesterday?". Each query waits for your y in the console.

The console runs as the locksql account (uid 10001) in its own container, the only one that holds the database credentials. The agent's MCP server runs as agent (uid 10002) in a throwaway container. The two share only the socket volume locksql-run, and the kernel checks every peer on the socket.

init claude writes .mcp.json with locksql mcp: the launcher, which starts the MCP server in the image (Codex, Gemini CLI and Cursor take the same command). profile add asks for the engine, host, TLS and where the password comes from (typed at console start, or Vault), never for the password itself. Inside a container localhost is the container: a database on your machine is host.docker.internal, which the launcher maps to your machine. locksql version prints the launcher's version and its image's, locksql update updates both. What runs where, Vault, and an agent that itself runs in a container are in docs/docker.md.

How it works

Two processes in two containers. Only the console holds the key.

sequenceDiagram
    autonumber
    participant A as 🤖 AI agent<br/>(locksql mcp / CLI)
    participant C as 🔐 locksql console<br/>(your terminal)
    actor H as 👤 You
    participant D as 🗄️ Database
    A->>C: plan "SELECT … LIMIT 20"
    C->>C: classify · parse · resolve PII columns
    C->>D: EXPLAIN
    D-->>C: plan & row estimate
    C-->>A: plan id + verdict (OK / WARN / REFUSE)
    A->>C: run PLAN_ID
    C->>D: k-anonymity counts
    C->>H: approval screen (warns of a count under k)
    H-->>C: y
    C->>D: the statement
    D-->>C: rows
    C-->>A: masked, capped rows (untrusted data)
  1. The agent submits SQL with locksql plan (or the locksql_plan MCP tool). The console classifies it, parses and analyses a read down to the source column of every output, runs EXPLAIN, weighs the plan and returns a one-shot plan id with a verdict (OK, WARN or REFUSE).

  2. The agent calls locksql run PLAN_ID. When the statement filters, groups or aggregates PII, the console first runs its k-anonymity counts. It shows you the approval screen, with a ▲ k-anonymity warning above the prompt for each count under k_anonymity, and waits:

    ╭─ DEV · 127.0.0.1 / app · user alice · tier read ─────
    │ requested by uid 1000 (alice) · pid 48211 (claude)
    │ agent: list the Belgian customers to check the email campaign
    │
    │   SELECT id, email FROM customers WHERE country = 'BE' LIMIT 20
    │
    │ class READ · EXPLAIN: customers range ~410 · est. 410 rows examined · verdict OK
    │ reads: app.customers
    │ returns at most 20 rows
    │ PII columns touched: customers.email (select)
    │ masked outputs: email → redact
    │ PII: masked (4 column rules; detectors: email, phone, iban, card)
    ╰─ Approve? [y/N]
    
  3. On approval the console runs the statement, masks PII, caps the output and hands the rows back to the agent, framed as untrusted data.

Process layout
 Console container (your terminal)                Agent's container
 ┌───────────────────────────────────┐            ┌──────────────────────────────┐
 │ locksql console --profile dev     │ socket     │ locksql mcp   (stdio MCP)    │◄── AI agent
 │  credentials (ask | Vault)        │ volume     │ locksql plan|run|status|pii  │◄── AI via shell
 │  policy · classifier · EXPLAIN    │◄──────────►│ no secrets, no DB connection │
 │  approval prompt · masking · audit│  JSON-RPC  └──────────────────────────────┘
 └─────────────────┬─────────────────┘
                   │ native protocol
                   ▼
        MariaDB · MySQL · PostgreSQL · SQLite

Full walkthrough: docs/usage.md.

Engines

Engine Versions Read-only session (tier read) Server-side timeout Plan source
mariadb 10.1+ (tested 10.11, 11.4) SET SESSION TRANSACTION READ ONLY + START TRANSACTION READ ONLY max_statement_time + KILL QUERY EXPLAIN FORMAT=JSON
mysql 8.0+ (tested 8.0, 8.4) same as MariaDB max_execution_time (SELECT only; writes rely on KILL QUERY) + KILL QUERY EXPLAIN FORMAT=JSON
postgres 13+ (tested 13, 17) SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY + BEGIN READ ONLY … ROLLBACK statement_timeout + cancel request EXPLAIN (FORMAT JSON, VERBOSE)
sqlite 3 (pure Go, in-process) mode=ro + query_only interrupt on deadline EXPLAIN QUERY PLAN + sqlite_stat1 (run ANALYZE once, or every full scan is a WARN of unknown size)

All engines are pure Go: the binary is built with CGO_ENABLED=0. locksql runs in Linux containers, on amd64 and arm64, wherever Docker runs (docs/docker.md).

Safety model

The console is the enforcement point. Skills, rules and MCP tool descriptions only guide the agent; the console's checks are the guarantee.

  • One key holder. Credentials are typed at console start (no echo), or fetched from HashiCorp Vault with the vault CLI at every connection (credentials = "vault", suited to the database secrets engine's per-user, short-lived logins). They never appear in argv, environment, files, logs, the socket protocol or error messages. The console disables core dumps. credentials_ttl makes it ask again after a set time; a Vault lease is renewed with a fresh login before it ends.
  • OS separation. The image holds a locksql console account and a locksql-clients group: the console runs as that account in its own container, the agent's MCP server runs as agent, a member of the group, in another, and the two share only the socket volume. The kernel checks every peer. The console refuses to start without this setup, since an agent in the console's own account could read or type into its terminal. Whoever can run docker commands can still reach the console's container: see what Docker does and does not separate.
  • Human approval. Every statement is shown and approved in the console terminal; no socket method can approve. On a production profile you type the profile name, not y. Pending keystrokes are flushed before each prompt, so type-ahead never approves. No answer within 5 minutes means denied. You can deny with a reason the agent reads (n use the orders table).
  • Fail closed. A dialect-aware classifier refuses anything it cannot classify with certainty: several statements, comments, variables, bind parameters, file and OS access, sleeps and locks, session tampering. Reads are limited to SELECT, WITH ... SELECT and EXPLAIN SELECT, parsed in full: unknown syntax, functions outside an allowlist and system schemas are refused.
  • Tiers. read < write < ddl < admin; the default is read. Tier read is also enforced server side (read-only session and transaction).
  • Weight check. A READ statement must carry LIMIT n with n <= max_rows. The console runs EXPLAIN and refuses statements that would examine too many rows (or cost more than explain_cost_refuse); a recursive CTE is at least WARN on PostgreSQL and SQLite, whose planners only guess how often it iterates; REFUSE cannot be overridden from the console.
  • PII masking. At every start the console scans the schema and proposes rules for columns that look like personal data (multilingual names and types) and that no rule names yet. Every output column is resolved to its source columns through aliases, functions, subqueries, CTEs, unions, joins and *, and masked on that source, not on its label; the engine's origin metadata is a second check, and a result whose columns do not match the analysis is dropped. Value detectors (email, phone, IBAN, card, opt-in national ids) mask the other cells. Mask modes: redact (the default), partial, email. Quasi-identifiers (birth date, postal code, gender) are never masked unless you accept them one by one, since masking blocks range filters, LIKE and ORDER BY on the column.
  • Statement text. While mask rules exist, the views that hold the text of past statements are refused, in reads and in writes: pg_stat_statements and pg_stat_activity; on MySQL and MariaDB, information_schema.PROCESSLIST, every performance_schema and sys relation, mysql.general_log and mysql.slow_log.
  • PII usage. PII columns may be selected, counted, joined with = and filtered with =, IN (literals) or IS NULL. Expressions over them, LIKE, ranges and ORDER BY are refused. A filter, grouping or aggregate on PII that covers fewer than k_anonymity rows (default 5, production 10) warns you on the approval screen, since it could single out one person; you decide. In auto mode it runs, and the warning goes to the console and the audit log. The agent is told nothing of the count.
  • Placeholders and references. To filter a PII column on a value the agent does not know, it writes col = '${email}' and the human types the value in the console (no echo); the value never reaches the agent, the audit log or a client error. Redacted cells come back as <redacted:r1.2.3> (result, row, column), and the agent filters on one with col = '${r1.2.3}'; the console keeps the clear values of the last results in memory only. Both work only compared with a PII column, and skip the k-anonymity count, since the agent did not choose the value. The approval screen warns, in red and in the audit log, when the agent put a clear value in its statement, when a reference is combined with a unique-key filter or an output with no plain column, and when one result's cells are filtered on one by one beyond limits.reference_probe. Warnings never refuse and never carry a value.
  • Quiet failures. Clients get a generic message, never the server's error text, and no timings; once the statement is approved, query.run answers on a 250 ms quantum (a refusal before approval is not quantized).
  • The AI tightens, the human loosens. Agents may add mask rules and request changes. A config edit that loosens the policy (higher tier, larger limits, new host, removed PII rule, ...) only takes effect after you confirm it in the console.
  • Audit. Every login, policy change, refusal, decision, run, catalog read and logout is appended to a JSONL audit log (mode 0600): the approval before the statement runs, its outcome after. The records of a client request name the requester: its uid, pid and account. Never secrets, never row data.

Details and threat model: docs/security-model.md.

Configuration

Profiles live in .locksql/config.toml at the project root (found by walking up from the current directory) and in <user config dir>/locksql/config.toml. A project profile wins over a user profile of the same name. No file ever holds a secret: keys named like password, passwd, pwd, secret or token, and DSNs with an embedded password, are refused.

Breaking change. A remote profile with no tls setting now verifies the server certificate (tls = "verify-full"). A server with a private CA needs tls_ca; to keep encryption without verification, set tls = "require". Until then its connection fails with an error that names both settings.

[profiles.uat]
engine      = "mariadb"             # mariadb | mysql | postgres | sqlite
host        = "db.uat.example.com"  # or path = "app.db" for sqlite
port        = 3306                  # default 3306, or 5432 for postgres
user        = ""                    # empty: asked at console start
database    = ""                    # empty: chosen per query (--db)
credentials = "ask"                 # ask | keychain | vault
tls         = "verify-full"         # disable | prefer | require | verify-ca | verify-full
tls_ca      = ""                    # optional PEM bundle replacing the system roots
credentials_ttl = "20m"             # optional: ask for the secret again
tier        = "read"                # read | write | ddl | admin
production  = false
detectors   = ["email", "phone", "iban", "card", "be_niss"]

[profiles.uat.limits]
statement_timeout   = "30s"
explain_rows_warn   = 100000
explain_rows_refuse = 1000000
max_rows            = 200
max_cell_chars      = 200
max_output_bytes    = 65536
k_anonymity         = 5
explain_cost_refuse = 0             # engine cost units; 0 = off
idle_timeout        = "20m"         # the session ends after this long idle
max_session         = "4h"          # ... and after this long in all

With credentials = "vault" the console runs vault read -format=json <vault_path> at every connection, as the console account and with its environment (VAULT_ADDR), and connects with the username and password of the secret. The console asks for the Vault token at start and keeps it in memory for the session. Nothing is stored. See Credentials from HashiCorp Vault.

[profiles.reporting]
engine      = "postgres"
host        = "db.internal"
tls         = "verify-full"
credentials = "vault"
vault_path  = "database/creds/reporting-ro"   # or a KV secret: "secret/data/reporting"

A database reachable only from a bastion gets an ssh table: the console opens the SSH connection itself, with no local listening port, and verifies the bastion against the console account's ~/.ssh/known_hosts (asking on first use, refusing a changed key). host is then the database as seen from the bastion; keep tls = "verify-full" unless the database runs on the bastion itself. See Reaching a remote server through SSH. A database that publishes no port (an internal Docker network, a server bound to its container's loopback) is reached with proxy_command, an argv whose stdin and stdout carry the connection, such as ["docker", "exec", "-i", "shop-db", "socat", "-", "TCP:127.0.0.1:3306"]; see Databases that publish no port.

[profiles.prod]
engine      = "postgres"
host        = "db.internal"         # as seen from the bastion
tls         = "verify-full"
production  = true

[profiles.prod.ssh]
host        = "bastion.example.com"
port        = 22                    # default 22
user        = "deploy"
auth        = "key"                 # key | agent | password
key         = "~/.ssh/id_ed25519"   # auth = "key" only; the console account's home
credentials = "ask"                 # passphrase or SSH password; default: the profile's
All profile keys
Key Default Notes
engine required mariadb, mysql, postgres, sqlite
host, port required host; port 3306 / 5432 a host starting with / is a Unix socket (MariaDB/MySQL)
path required for sqlite relative to the project root; the file is never created
user asked at start with credentials = "vault": the secret's username; set it only for a secret without one
database none the only database served (and scanned for PII); empty: every database the account can see, chosen per query with --db
credentials ask keychain (not in a container) offers to save the secret after the first successful login; locksql forget --profile P removes it. vault reads the login from vault_path at every connection
vault_path required with credentials = "vault" the Vault path read with vault read, such as database/creds/<role> or secret/data/<name>; any change is a loosening
credentials_ttl none a duration of at least 1m ("20m", "1h"): the connection is closed and the secret asked again once it is that old
tier read highest statement class allowed
production false stricter limits, typed approval, auto mode unavailable
detectors email, phone, iban, card also be_niss, fr_nir, nl_bsn, us_ssn and phone_loose; [] disables them. phone masks + and 8 to 15 digits, 00 and 10 to 17 digits, or a national number with a trunk 0 (9 to 11 digits, a first group of at least 2 digits); phone_loose adds national numbers without a trunk 0 ((415) 555-2671, 612 345 678: 9 to 11 digits in at least two groups, the first of 2 to 4) and also masks some grouped amounts (123 456 789). Every detector reads U+00A0, U+202F and U+2009 as a space and full-width digits as digits
pii_rules the profile name, in lower case the PII rule set, pii/<set>.toml; 1 to 64 lower-case letters, digits, _, - or .; profiles on isomorphic databases share a set by naming it; any change is a loosening
limits.statement_timeout 30s (production 10s)
limits.explain_rows_warn 100 000 (production 20 000)
limits.explain_rows_refuse 1 000 000 (production 200 000)
limits.max_rows 200 upper bound for LIMIT n and for returned rows
limits.max_cell_chars 200 longer cells are cut with …
limits.max_output_bytes 65 536 output is cut with a marker
limits.k_anonymity 5 (production 10) a PII filter, a group or an aggregate of a PII column covering fewer rows warns on the approval screen (it no longer refuses); lowering it is a loosening
limits.reference_probe 5 distinct cells of one result the agent may filter on one by one before the console warns (an IN list counts once); raising it is a loosening
limits.idle_timeout 20m (production 10m), or max_session when that is shorter the console session ends after this long without activity; at least 1m and at most max_session; raising it is a loosening
limits.max_session 4h (production 2h) the console session ends after this long in all; at most 24h; raising it is a loosening
limits.explain_cost_refuse 0 (off) refuse plans above this total cost, in the engine's own units; SQLite reports no cost and is not checked
ssh.host, ssh.port, ssh.user required host and user; port 22 the bastion; not with sqlite or a Unix socket host
ssh.auth required key, agent (SSH_AUTH_SOCK of the console) or password
ssh.key required with auth = "key" private key of the console account, mode 0600
ssh.credentials the profile's credentials (ask when that is vault) ask or keychain for the passphrase or SSH password; locksql forget --profile P removes it too
proxy_command none argv run without a shell, as the console account, once per connection; its stdin and stdout carry it (docker exec, kubectl exec, ssh ... socat). Not with ssh, sqlite or a Unix socket host; any change is a loosening

PII rules

PII column rules live in named rule sets, one file per set: pii/<set>.toml next to the config, so .locksql/pii/<set>.toml inside a project and <user config dir>/locksql/pii/<set>.toml (~/.config/locksql/pii/ on Linux) elsewhere. Each profile uses the set named by its pii_rules key, by default its own name, so a profile never sees the rules of an unrelated database. Profiles that point at isomorphic databases share a set on purpose:

[profiles.shop-dev]
pii_rules = "shop"
[profiles.shop-prod]
pii_rules = "shop"        # rules added from either console reach both
production = true
[profiles.billing]        # no pii_rules: the set "billing", its own

locksql pii and :pii act on the set of the profile in use, and the approval screen shows the set next to the profile. The console warns at start when a profile without pii_rules shares its set by accident: with a profile whose name differs only by case, with a profile whose pii_rules names it, or because it is named default. pii_rules is read at start; pointing a profile at another set is a loosening, approved again at the next start.

The single pii.toml of earlier versions becomes the set default at the first start (moved to pii/default.toml, with a one-time notice). The profiles approved before keep using it until they get a pii_rules key; any other profile uses its own set.

A set file:

[[mask]]
column = "app.users.email"            # db.table.column, * per segment
mode   = "email"                      # j***@example.com
[[mask]]
column = "app.users.customer_ref"
mode   = "partial"                    # j***(12)
[[mask]]
column = "*.*.recipient_reference"    # <redacted:rN.R.C> (default)
[[allow]]                             # explicit exception: never mask
column = "app.templates.name"
[[mask]]                              # '*' prefix/suffix inside a segment:
column = "*.*.*email*"                # contact_email, email_backup, Email
Mode Output Notes
redact (default) <redacted:rN.R.C> (result, row, column: a reference the agent can filter on; plain <redacted> if the console cannot hold the cell, or for any column but a plain one whose every source is under a mask rule); partial, email and detector-masked cells carry no reference a rule without mode is redact
partial j***(12) first character and length
email j***@example.com first character and domain; other values as partial

A column covered by several rules with different modes is redacted. Changing a mode is a loosening unless the new mode is redact.

mode = "hash" is no longer a mode: a rules file that uses it is rejected (unknown mode "hash" (want redact, partial or email)).

On PostgreSQL the first segment is the schema (public.users.email).

Share a configuration with your team

locksql config export writes the profiles of your user config (or of the project with --project) and the PII rule sets they use to one JSON file; locksql config import FILE applies it on another machine:

locksql config export --output team.json                 # all profiles, their PII rule sets
locksql config import team.json --dry-run                # show what would change
locksql config import team.json --local uat.ssh.key=~/.ssh/id_ed25519
  • Never exported: secrets (none is in a config file; a value that looks like one fails the export), user (unless --include-user), files of one machine (tls_ca, tls_cert, tls_key, ssh.key: listed in needs_local, given on import with --local NAME.KEY=VALUE), proxy_command (a command line: kept from your own config, never imported), SQLite and Unix-socket profiles, approved policies, the audit log and console flags. --no-pii leaves the rule sets out.
  • Import: a strict format (unknown fields, newer formats and invalid values are refused, nothing is written); imported profiles replace those of the same name but keep your user and local files; rule sets are merged (a mode conflict keeps redact); --replace replaces the profiles and the imported sets instead. A backup (*.bak) is kept.
  • Loosening: any change that loosens the policy (new profile or host, higher tier, weaker tls, higher limits, a removed mask rule, a new allow rule, another rule set, ...) is refused unless you pass --allow-loosen, and the console still asks you to approve it at its next start.

The file describes your databases: keep it in a private repository.

--allow-unmask

An agent can ask for unmasked PII output (locksql plan --unmask, or unmask in the MCP locksql_plan tool) only when the human allowed it in the console: locksql console --allow-unmask at start, or :unmask while it runs (confirmed with y; :unmask again turns it off). Without it, the console refuses every unmask request before anything runs and audits the refusal. Like auto mode, it is console-only: no client or config file can turn it on, and each change is audited (unmask record). With it, each unmasked query still needs the human's approval, shown as PII: UNMASKED in red, and is never auto-approved. The console then prints the clear result, so you see exactly what the agent received (the audit log still holds no row data).

Console keys and status line

The console runs its own line editor, so two keys and one command switch its behaviour while it runs, without a restart, and a status row fixed at the bottom of the terminal always shows the state: the profile and tier, the approval mode, whether results are shown in clear, unmask allowed, the open plans and what awaits :review. The switches are console-only (no client, config file or environment variable can set them) and each change is audited.

Key Typed Effect
Shift+Tab :mode manual ⇄ auto approval mode
Ctrl-O :results results masked ⇄ shown here in clear
:unmask unmasked output off ⇄ allowed (allowing asks for y)

Clear results (Ctrl-O)

Ctrl-O (or locksql console --show-results to start that way) prints in the console, in clear, the result of each masked query it runs, as an aligned table, so you see who the rows are while the agent still gets the masked cells. The cells the agent got masked are yellow and carry their reference (‹r1.1.2›); the output caps apply as for the agent. Off by default. The console warns when it goes on (in red on production), the status row reads results clear, every approval screen says result: shown here in clear, and locksql status reports show results on. The audit log still holds no row data. Unavailable when the console runs in a desktop terminal (security levels 1 and 2): the agent's account can read that window, so --show-results is refused and Ctrl-O leaves the results masked.

Approval mode (Shift+Tab)

The console starts in manual mode: every statement is prompted. Shift+Tab (or locksql console --auto to start that way) switches to auto mode, where statements that the tier and the weight check allow run without a prompt. It exists for fast local iteration, and it is deliberately narrow:

  • it switches only from the console's keyboard; no client or config file can turn it on;
  • it is unavailable on production profiles (the key says so and the console prompts as usual);
  • it never auto-approves an unmasked query and never overrides a REFUSE verdict;
  • the status row reads mode AUTO, each auto-approved frame ends with auto-approved, and each auto-approval is audited with decision: "auto"; the switch itself is audited as mode.

Limitations

  • The console runs only in a separate account. The image sets it up; locksql console refuses to start without it and names locksql doctor. Docker does not separate the console from your own account: whoever can run docker commands, an agent in your shell included, can attach to the console's container. Run the agent in a container of its own, without the Docker socket, for that boundary (docs/docker.md). Malware running as the console account, or as root, is out of scope.
  • k-anonymity is a decision aid, not a guarantee. A count under k warns you before you approve; it refuses nothing, and in auto mode the statement runs. Even heeded, it is a query-set-size control: it does not stop differencing attacks that combine several approved queries.
  • Placeholders leave room for a patient agent. Equality probing through cell references only raises a warning: an agent that spreads its probes under limits.reference_probe, across results or sessions, is not stopped. The unmasked columns of a targeted row still identify it, and free text without a detector is not masked.
  • Clear results stay on the console's screen. With the clear results on (Ctrl-O, --show-results) and for an unmasked run, the clear rows stay in the scrollback of the console's terminal, visible to anyone who sees that screen (screen sharing, recording, a terminal that logs its output). locksql does not clear it.
  • Transport security depends on tls. A remote profile defaults to tls = "verify-full"; prefer is the default only for a Unix socket or a loopback host. production = true refuses disable and prefer on a remote host. Below verify-ca an active attacker on the path can intercept or downgrade the connection and read the traffic, but is not handed the password: a remote MariaDB/MySQL connection whose certificate is not verified (or plain TCP) refuses mysql_clear_password, sha256_password and a caching_sha2_password full authentication, so a MySQL 8 account logs in that way only while the server's authentication cache holds it; PostgreSQL refuses a clear-text or MD5 password request (require_auth=!password,!md5). Each refusal names the remedy, tls = "verify-full" (with tls_ca for a private CA). verify-ca and verify-full prevent the impersonation; tls_cert/tls_key add a client certificate. Through an ssh bastion the SSH leg is encrypted and verified, but the bastion-to-database leg is protected only by tls, unless the database runs on the bastion. See Transport security.
  • One console per profile and project, one request at a time. No remote or shared consoles, no data export.

Documentation

🐳 docs/docker.md setting up: the console and the agent's MCP server in Docker, with nothing installed
📘 docs/usage.md console, client commands, MCP server, agent integration
🛡️ docs/security-model.md threat model and mitigations, and an upstream recommendation (PII encrypted at rest with a blind index)
🔍 docs/pii-analysis.md how the PII analysis works: parser, provenance, usage rules, k-anonymity counts, masking (for contributors)
🏁 docs/1.0.md the 1.0 criteria and the versioning policy from 1.0
🚨 SECURITY.md reporting a vulnerability
🧑‍💻 CONTRIBUTING.md building and testing

Install

locksql runs in Docker. On the host, scripts/install.sh installs only the locksql launcher, a shell script that runs every command in the release's image (ghcr.io/davidgodefroid/locksql:X.Y.Z), each with the account it needs: the console as locksql in one container, the agent's MCP server as agent in another. Download the script from the release you install, read it, then run it:

V=vX.Y.Z
curl -fsSLO "https://raw.githubusercontent.com/DavidGodefroid/locksql/$V/scripts/install.sh"
less install.sh
LOCKSQL_VERSION=$V sh install.sh

It checks the release's cosign signature and checksum. locksql version prints the launcher's version and its image's; locksql update installs a newer release's launcher, checked the same way, and pulls its image. Linux and macOS; on Windows, WSL 2 with Docker Desktop. LOCKSQL_IMAGE runs an image of your own (docker build -t locksql . from a clone), LOCKSQL_VAULT=1 the image with the vault CLI. Read what Docker does and does not separate before you rely on it: docs/docker.md.

The image refuses the former host install (locksql install, setup and the binary's own update): migrating from a host install.

lea does all of it in lea init and links a locksql command that opens the console.

Build

CGO_ENABLED=0 go build ./cmd/locksql

Requires Go 1.26 or later.

Licence

Apache License 2.0. See LICENSE and NOTICE.

Directories

Path Synopsis
cmd
locksql command
Command locksql lets AI agents query databases only through a human-approved console.
Command locksql lets AI agents query databases only through a human-approved console.
internal
agentinit
Package agentinit implements `locksql init <agent>` and the user-scope wiring of bare `locksql` and `locksql doctor --fix`: it holds locksql's texts (templates/) and wires them through lockkit's agentwire, which owns the file mechanics.
Package agentinit implements `locksql init <agent>` and the user-scope wiring of bare `locksql` and `locksql doctor --fix`: it holds locksql's texts (templates/) and wires them through lockkit's agentwire, which owns the file mechanics.
client
Package client is the secret-free side of locksql: lockkit's client (dial, hello, call) with locksql's names and its SQL methods.
Package client is the secret-free side of locksql: lockkit's client (dial, hello, call) with locksql's names and its SQL methods.
config
Package config loads locksql profiles from the project and user config files, applies defaults, refuses anything that looks like a secret, and computes the security-relevant policy of a profile.
Package config loads locksql profiles from the project and user config files, applies defaults, refuses anything that looks like a secret, and computes the security-relevant policy of a profile.
console
Package console is the human side of locksql: it holds the credentials and the only database connection, enforces the approved policy, shows every query to the human for approval, masks the results and writes the audit trail.
Package console is the human side of locksql: it holds the credentials and the only database connection, enforces the approved policy, shows every query to the human for approval, masks the results and writes the audit trail.
engine
Package engine defines the database engine interface the console drives, and the engine-independent types it returns: catalog descriptions, result sets and normalised query plans.
Package engine defines the database engine interface the console drives, and the engine-independent types it returns: catalog descriptions, result sets and normalised query plans.
engine/mysql
Package mysql is the MariaDB and MySQL engine, on the go-mysql-org client (chosen because it reports each result column's origin schema, table and column).
Package mysql is the MariaDB and MySQL engine, on the go-mysql-org client (chosen because it reports each result column's origin schema, table and column).
engine/postgres
Package postgres is the PostgreSQL engine, on pgx v5.
Package postgres is the PostgreSQL engine, on pgx v5.
engine/sqlite
Package sqlite is the SQLite engine, on the pure-Go modernc.org/sqlite driver.
Package sqlite is the SQLite engine, on the pure-Go modernc.org/sqlite driver.
ipc
Package ipc is locksql's console protocol: lockkit's transport and methods (package lockkit/ipc, re-exported here under the same names) plus the SQL methods and their types.
Package ipc is locksql's console protocol: lockkit's transport and methods (package lockkit/ipc, re-exported here under the same names) plus the SQL methods and their types.
mcpserver
Package mcpserver exposes the locksql console to agents as MCP tools (spec §12).
Package mcpserver exposes the locksql console to agents as MCP tools (spec §12).
pii
Package pii decides which result cells must be masked: column rules kept in .locksql/pii.toml, rules proposed from the schema, value detectors (email, phone, IBAN, card, national ids) and the masking itself.
Package pii decides which result cells must be masked: column rules kept in .locksql/pii.toml, rules proposed from the schema, value detectors (email, phone, IBAN, card, national ids) and the masking itself.
product
Package product is locksql's identity on lockkit: its names, accounts, directories and keychain service.
Package product is locksql's identity on lockkit: its names, accounts, directories and keychain service.
proxycmd
Package proxycmd reaches a database through a command whose stdin and stdout carry the connection, as OpenSSH's ProxyCommand: docker exec, kubectl exec, or ssh to a host that relays to a socket.
Package proxycmd reaches a database through a command whose stdin and stdout carry the connection, as OpenSSH's ProxyCommand: docker exec, kubectl exec, or ssh to a host that relays to a socket.
render
Package render turns a (masked) result into the text an agent sees: TSV or JSON, within the profile's row, cell and output caps.
Package render turns a (masked) result into the text an agent sees: TSV or JSON, within the profile's row, cell and output caps.
sqlast
Package sqlast parses the read statements an agent may run (SELECT, WITH ...
Package sqlast parses the read statements an agent may run (SELECT, WITH ...
sqlaudit
Package sqlaudit is locksql's audit record on lockkit's audit log: one JSONL record per event, never secrets and never row data.
Package sqlaudit is locksql's audit record on lockkit's audit log: one JSONL record per event, never secrets and never row data.
sqlclass
Package sqlclass is locksql's first gate: a conservative, dialect-aware lexer and statement classifier.
Package sqlclass is locksql's first gate: a conservative, dialect-aware lexer and statement classifier.
sqlrefusal
Package sqlrefusal holds locksql's refusal codes, registered with lockkit's refusal package, and re-exports that package so call sites import one name.
Package sqlrefusal holds locksql's refusal codes, registered with lockkit's refusal package, and re-exports that package so call sites import one name.
stats
Package stats reads the console's audit log and counts the refusals by code and by the token or node that triggered them, so that grammar work starts with the constructs users hit most.
Package stats reads the console's audit log and counts the refusals by code and by the token or node that triggered them, so that grammar work starts with the constructs users hit most.
weight
Package weight turns a normalised engine plan into a verdict: OK, WARN or REFUSE.
Package weight turns a normalised engine plan into a verdict: OK, WARN or REFUSE.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL