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

ControlHow
RoleChecked before the warehouse is touched; asserted once at startup, never read from tool input
PreconditionEvaluated against live data, in the same query that fetches the record — and again at execution
ApprovalA proposal is stored; only the CLI can approve it
Sensitive propertiesNever selected, so the values never leave the database
Read limitsClamped server-side, ordered, truncation reported
AuditAppend-only, intent recorded before the effect fires
Effect targetURL rebuilt from the template each time; identity values percent-encoded; scheme, host and port re-checked after substitution; redirects never followed
Double executionA 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.

ControlHow
Caller identityVerified 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 downgradeAsymmetric signatures only. HS* (which would turn the public JWKS into a signing secret) and none are rejected before any key lookup.
Unknown rolesA token whose roles the ontology does not declare is served read-only — never guessed into a role.
Audit identityAudit rows and proposals record the token subject (sub) and the issuer — who the IdP says acted, not who launched the process.
SessionsBound to the credential that opened them; a different credential cannot continue someone else’s session.
Transport securityBinding 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 approveThe 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:

PropertyHow
No write routeThe only POST is the dry run, which evaluates and reports. Approve-shaped routes answer 405; approval remains a terminal verb.
Loopback onlyThe socket binds 127.0.0.1; there is no flag to widen it. Network serving arrives with OIDC, not with a flag.
DNS rebindingA 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 propertiesReads 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

ThreatResponse
SQL injection through model-supplied valuesValues 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 leakageEnv-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 dataControl characters, ANSI escapes, Unicode tag and bidi characters stripped; fields capped. Structurally: injected instructions still cannot approve anything.
Transport corruptionstdout carries only JSON-RPC; every diagnostic and any driver output is redirected to stderr.
Forged approval queue entriesValues 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.