Security model
A governance tool that overstates what it enforces is worse than no governance tool, because people trust it. This page is the honest account.
The one guarantee
An AI agent cannot complete a governed action on its own.
Everything else here supports that sentence. It holds because approval lives
in a separate process that the model has no way to address: there is no
approve tool on the MCP surface, no approved: true input field, and no
auto-approval on timeout. The test suite pins the exact set of
generated tool names, so a future change cannot quietly add one; validate
separately rejects colliding tool names and reserved action parameter names
such as approver and proposal_id.
What serve enforces
| Control | How |
|---|---|
| Role | Checked before the warehouse is touched; asserted once at startup, never read from tool input |
| Precondition | Evaluated against live data, in the same query that fetches the record — and again at execution |
| Approval | A proposal is stored; only the CLI can approve it |
| Sensitive properties | Never selected, so the values never leave the database |
| Read limits | Clamped server-side, ordered, truncation reported |
| Audit | Append-only, intent recorded before the effect fires |
| Effect target | URL rebuilt from the template each time; identity values percent-encoded; scheme, host and port re-checked after substitution; redirects never followed |
| Double execution | A single conditional UPDATE claims the proposal; exactly one caller can win |
What it does not enforce
Warehouse access. Anyone with database credentials can query your tables directly. Ontologiq governs the path through it — the path you give to agents — not the database. If you need enforcement at the data layer, use your warehouse’s own row filters and column masks; compiling policy down into them is on the roadmap.
Caller identity. MCP over stdio has no authentication primitive. The
role is asserted by whoever launched the process, from --role or
ONTOLOGIQ_ROLE. The server says so in its startup banner and records the
role, its source and the OS user in every audit row. Treat a serving process
as running as one role, and give each role its own process.
Handler effects. A type: handler effect is your Python, imported into
the serving process, running with its privileges — including its database
connection. The “actions never write SQL” guarantee is enforced for
webhooks; for handlers it is a convention you are choosing to keep.
validate warns on every handler effect for exactly this reason.
Audit immutability against an operator. The append-only log is enforced by SQLite triggers. That stops accidental code. It does not stop someone with write access to the file. Ship the audit somewhere append-only if you need more.
Deliberate design choices
Governance is read from the compiled models, never from target/. That
directory is generated and gitignored: if policy were read from
catalog.json, editing one untracked file would silently rewrite roles and
approval gates with no diff in git. serve compiles in process.
A lost response is unknown, never failed. If the request left and no
response came back, whether the effect applied is genuinely unknown.
Reporting failure invites a second refund, so that case ends the attempt and
says so rather than retrying.
Retries are therefore narrow: a connect error, which provably sent nothing,
and a 5xx, which is the endpoint asking to be asked again — up to three
attempts, all carrying the same Idempotency-Key, which is what makes the
5xx case safe. A 4xx or a redirect ends the attempt immediately.
Proposals are pinned to their arguments and their ontology. The
arguments a human approved are hashed on the proposal and re-verified before
execution, so the executed call cannot differ from the reviewed one.
Approving across a change to the ontology requires --force.
Expiry is a deadline, not a hint. Proposals expire (24h by default) and an expired proposal can never be approved.
Threats considered
| Threat | Response |
|---|---|
| SQL injection through model-supplied values | Values only ever become literals, never structure; strings go through sqlglot’s escaping, numbers through a strict pattern. Object, relation and state names are resolved against the ontology, never formatted into SQL. |
Effect URL retargeting (../../admin/purge, ?force=true, @evil.com) | Identity values percent-encoded; scheme, host and port asserted unchanged after substitution; credentials in the host refused; redirects not followed. |
| Secret leakage | Env-interpolated URLs are resolved at the moment of use and never stored. Audit and tool results carry the ontology template and the host, never the resolved URL. Raw exceptions never reach the model. |
| Prompt injection via warehouse data | Control characters, ANSI escapes, Unicode tag and bidi characters stripped; fields capped. Structurally: injected instructions still cannot approve anything. |
| Transport corruption | stdout carries only JSON-RPC; every diagnostic and any driver output is redirected to stderr. |
| Forged approval queue entries | Values are rendered as JSON in approvals list, so a newline in a model-supplied parameter cannot fabricate a second entry on the reviewer’s screen. |
Reporting a vulnerability
See SECURITY.md. Please do not open a public issue for something exploitable.