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 |
The HTTP transport (serve --http)
Network serving arrives with OIDC, not with a flag: there is no way to
run the HTTP transport without a token verifier. Every request must carry a
bearer token the configured issuer signed, or it is refused with a 401
before any handler runs.
| Control | How |
|---|---|
| Caller identity | Verified per request: signature against the issuer’s published JWKS, iss, aud, exp and sub all required. The role comes from the token’s role claim — --role is refused together with --http. |
| Algorithm downgrade | Asymmetric signatures only. HS* (which would turn the public JWKS into a signing secret) and none are rejected before any key lookup. |
| Unknown roles | A token whose roles the ontology does not declare is served read-only — never guessed into a role. |
| Audit identity | Audit rows and proposals record the token subject (sub) and the issuer — who the IdP says acted, not who launched the process. |
| Sessions | Bound to the credential that opened them; a different credential cannot continue someone else’s session. |
| Transport security | Binding beyond loopback requires --public-url https://… — TLS termination is the reverse proxy’s job, and the port refuses to open without the declared https URL. Bearer tokens never ride plain http. |
| Still no approve | The MCP surface has no approve capability over HTTP either, for any token. Approval remains a separate verb in the terminal. |
ABAC (policy.where) | Evaluated with actor.* bound from the verified claims — as SQL literals through sqlglot, never as SQL structure. Pinned on the proposal and re-evaluated at execution against live data: a record that moved out of the actor’s scope while the proposal waited is refused. A transport with no verified claims (stdio, the terminal) cannot propose such an action at all, and a missing attribute fails the policy rather than passing it. |
The workbench surface
ontologiq workbench puts an HTTP server on localhost, and
an HTTP server is addressable — including by an agent, which already knows
its proposal_id from the propose response. The guarantee survives because
the surface is read-only by construction, and each property is pinned by a
test:
| Property | How |
|---|---|
| No write route | The only POST is the dry run, which evaluates and reports. Approve-shaped routes answer 405; approval remains a terminal verb. |
| Loopback only | The socket binds 127.0.0.1; there is no flag to widen it. Network serving arrives with OIDC, not with a flag. |
| DNS rebinding | A request whose Host header is not local is refused, so a page on an attacker’s domain resolving to 127.0.0.1 cannot read the records API. |
| Sensitive properties | Reads use the same query builders as the MCP tools: sensitive columns are never selected, so their values never reach a browser. |
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. For the views Ontologiq deploys, read_policy
compiles a row predicate into the view definition using the warehouse’s own
identity functions (Unity Catalog first), which is the one honest place for
read-side ABAC: the warehouse evaluates it for whoever runs the query. The
base tables remain yours to protect with the warehouse’s own controls.
Caller identity over stdio. 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
stdio serving process as running as one role, and give each role its own
process — or serve over HTTP with OIDC,
where identity is verified per request.
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.