SafeCoQuery Documentation
Last updated: 22 August 2026
SafeCoQuery sits between an AI coding agent and a MySQL database. It decides what the agent may see, holds writes for you to approve, and records what was disclosed. This page describes how that works in practice.
Requirements
- macOS 14 or later.
- MySQL 8.0 or 8.4.
- An agent that speaks MCP — Claude Code, Codex, or any MCP-compatible client.
Setting up a connection
A connection needs a host, port, user and password. Credentials go into the macOS Keychain; they are never written to a policy file or a config file.
Give SafeCoQuery a least-privilege account. It shapes what the agent sees, but it acts with whatever permissions the credential carries — see Limits.
Through an SSH bastion
If the database is not directly routable, a connection can tunnel through a bastion host. Two things are deliberately different from an ordinary SSH client:
- The private key is pasted as material. SafeCoQuery never reads your
~/.sshdirectory — nothing walks it looking for keys. What you paste is held in the Keychain and used in-process. - Host keys are pinned in the app's own store, not
known_hosts. A host key that does not verify is refused; you are not offered the chance to accept it.
Reviewing tables
An unreviewed table is invisible to the agent — not merely unfiltered. Nothing is exposed because you have not got to it yet.
Review is per table, because that is the only granularity a human realistically finishes. Column-by- column sign-off across a real schema is the kind of task that gets abandoned halfway, leaving a policy nobody trusts.
That choice has a cost, and it is worth being blunt about it: inside a reviewed table, every column that no pattern matches is allowed. Reviewing a table is a statement that you are happy for the agent to read it, subject to the patterns. So the shipped default patterns have to be genuinely complete.
The trust demo once caught card_number reaching an agent in clear, precisely
because a reviewed table makes every unmatched column readable. That is why payment identifiers
are in the shipped defaults, and why they are locked.
If you review a table with columns the defaults do not anticipate, add them explicitly before you enable agent access rather than after.
Policy
Policy is a file. It reviews in a pull request like anything else, and the defaults are deliberately unhelpful to an agent.
version: 1
connection: production-replica
# Anything nobody has classified yet. Both default
# to block: a column added next month is invisible
# until a human looks at it.
unknown_column_policy: block
unknown_table_policy: block
disclose_policy: full
# Patterns give coverage; explicit columns give
# correctness. Without patterns, drift fails open.
patterns:
- match: [password, passwd, secret, token, apikey,
privatekey, cvv, cvc, pin, hash, salt]
policy: BLOCK
locked: true # an ALLOW cannot relax this
- match: [card, card_number, pan, iban, sort_code,
account_number]
policy: MASK(card)
locked: true
- match: [email, phone, mobile, telephone, msisdn]
policy: MASK(tokenize)
- match: [ssn, nino, dob, birthdate, passport, licence]
policy: MASK(redact)
locked: true
# Explicit rules. These win unless a pattern is
# locked.
columns:
customers.internal_notes: BLOCK
customers.signup_source: ALLOW
# The agent's query is rewritten to include these;
# it cannot see or remove them.
rows:
orders: "tenant_id = 42"
schemas:
mysql: HIDDEN
information_schema: HIDDEN
# Per-table sign-off. An unreviewed table is invisible.
reviewed:
- customers
- orders
Three things to understand
locked: truecannot be relaxed. A narrower per-columnALLOWdoes not override a locked pattern. An administrator pinningpasswordtoBLOCKstays pinned.- Unknown means blocked. Both
unknown_column_policyandunknown_table_policydefault toblock, so a column added next month is invisible until a human looks at it, rather than readable because nobody got round to it. - Patterns give coverage; explicit columns give correctness. You want both. Patterns catch what you have not thought about; explicit rules get the specific cases right.
Connecting an agent
The order matters, and it is not guessable.
- Open the connection in SafeCoQuery. The agent can only reach a database you
currently have open. No app running means no agent access.
This is deliberate rather than a limitation: a human who is not there cannot approve a write or press stop. - Enable agent access on that connection. It is off by default, and it is a positive, per-connection decision — not a global setting you forget you turned on.
- Copy the generated
claude mcp add …command from the Agent connection panel and run it.
It is a selector and a revocation token, not a secret that protects anything. You hand it to the agent by design. The gate is the agent-access toggle, not the handle.
Regenerating the handle immediately tears down any live agent connection. It is a real revoke, not a rename.
Write approval
The agent proposes a write; it does not perform one. You are shown:
- the SQL the agent asked for;
- the SQL that will actually run — these differ, because policy rewrites the statement;
- the tables touched;
- an estimated row count.
Approving binds that exact statement by hash, so an approved card cannot go on to execute different SQL. Execution runs inside a transaction that rolls back if the affected rows exceed the cap you set.
It does not show before and after values for the affected rows. Reading those needs a privilege and audit story this build has not designed, so the app says so in its own UI rather than implying a preview it cannot give you.
The audit log
Every disclosure is recorded: the requested statement, the executed statement, the policy applied, and how many rows the agent received. Records are hash-chained, and the head of the chain is anchored in the macOS Keychain.
The agent cannot read it. Every audit method is refused on the agent's connection, and refused in a way that is indistinguishable from a method that does not exist.
Export writes a file wherever you choose. It carries the original bytes of each record rather than a re-serialisation, so a third party can re-walk the chain and confirm nothing was altered, reordered or removed. If verification fails, the export still happens and is marked as failed — a tampered log is exactly what you would want to hand an investigator.
It records the principal and the session. It does not identify which agent connected: those fields are declared but not populated in this build. “Which session, and how many rows” is what you can rely on.
STOP AI
STOP AI cancels work that is already streaming. It reaches the database server to kill the running query rather than just dropping the results on the floor, and the revocation survives quitting the app — you do not have to stay running to stay stopped.
Limits
These are the same limits stated in the Terms of Use. They belong here too, because knowing where the boundary is tells you how to deploy this sensibly.
- It is not a database-side security boundary. It does not restrict what the credential you give it can do. Use a least-privilege account.
- Masking prevents a value from appearing in results. It does not prevent inference about that value from aggregates over related columns.
- It controls the agent's database access. It is not a defence against arbitrary local code — an agent with shell access on your machine is outside this boundary, as is anything else running as your user.
- The audit log's hash chain detects accidental corruption and naive in-place edits, deletions and reordering. With a Keychain-anchored chain head it also detects wholesale replacement and tail truncation. It is not an external anchor.
Masking is a transmission-time transform. It is applied to results passing through SafeCoQuery. It does not alter what is stored, and anything holding a database credential and a network route can read the raw table with SafeCoQuery not involved at all.
Troubleshooting
client sent no build id; refusing to trust an unstamped client
The app and the daemon must agree on what they were built from, and an unstamped client cannot
prove it. Rebuild the app. If you built the daemon yourself, run ./scripts/build_daemon.sh
first.
A SafeCoQuery service is already running at this socket that this app instance did not start
A previous daemon outlived its app. Quit it and reopen SafeCoQuery so it starts its own. This can happen after a crash or a force-quit — it is a known rough edge rather than a sign something is wrong with your setup.
…was built from different daemon/ source than this Xcode build sees right now
Developer-only. Re-run ./scripts/build_daemon.sh.
SafeCoQuery