BuildPolicy
Govern Database Queries
Use four data guards to constrain database queries, warehouse cost, and returned rows and columns.
Why Govern Database Access
Chio governs which tools an agent may invoke. Data guards also check what a tool asks the database to do. A valid query can still cause harm:
- Prompt injection writing SQL. A poisoned document convinces the model to emit
SELECT * FROM salariesinstead of the intended query. The tool call and SQL are valid, but the result exposes the table. - Text-to-SQL emitting destructive writes. The model decides the fastest way to "clean up" is
DELETE FROM userswith no WHERE clause. The query can delete every row in the table. - Unbounded RAG retrieval. An agent queries a vector index with
top_k=10000against a collection outside its tenant, pulling every embedding into the session. - Warehouse cost bombs. A bad JOIN against a billion-row BigQuery table scans terabytes. The agent spent five dollars of budget on tokens and fifteen thousand dollars on cloud compute.
chio-data-guards answers all four with sql-query, vector-db, warehouse-cost, and the post-invocation query-result guard. Three of the four sit in the same pre-invocation pipeline as forbidden-path, egress-allowlist, and velocity; the fourth runs on the way back. Each denial carries a structured reason with a stable code, and Debugging Denials shows where that code actually lands.
Prerequisites
- A tool server that submits the query as tool-call arguments before it executes. Chio never connects to your data store.
- A
chio.yamlpolicy file. All four guards are first-class keys under itsguards:block. - A tool name the action extractor recognizes as a database call. The full list is 15 exact names:
sql,query,db_query,database,execute_sql,run_sql,postgres,mysql,sqlite,snowflake,bigquery,redshift,mongo,mongodb, andredis. A name outside that set is not a database call as far as these guards are concerned. - For a dry run, a session database and a receipt database.
chio checkneeds both, and each check needs a fresh session database of its own.
How It Works
Chio intercepts at the kernel boundary. It never runs inside the database driver and never connects to your data store directly. Your tool server wraps the database, captures the query it is about to execute, and submits it to Chio as tool-call arguments. The guard pipeline parses and enforces the policy before the query runs. If the verdict is allow, the tool server executes. If the verdict is deny, the tool server returns the deny reason to the agent and does not contact the database.
Submit requests as JSON. Each data guard reads a defined set of argument keys and recognizes which requests are its concern from the tool name and the database identifier. Two things are not per-call arguments. The SQL dialect is fixed once on the guard's config (SqlGuardConfig.dialect) when the operator constructs it, not sent with each query. And for the SQL and warehouse guards to see a call at all, the tool_name must be one of the 15 the action extractor classifies as a database call, listed under Prerequisites. Here is a representative submission for a relational query:
{
"tool_name": "sql",
"arguments": {
"database": "analytics",
"query": "SELECT name, email FROM users WHERE tenant_id = 'acme' LIMIT 100"
}
}A warehouse submission carries a dry-run estimate so the warehouse-cost guard can price the query before it runs:
{
"tool_name": "bigquery",
"arguments": {
"database": "my-project.analytics",
"query": "SELECT user_id, SUM(amount) FROM orders GROUP BY user_id",
"dry_run": {
"bytes_scanned": 52428800,
"estimated_cost_usd": "0.25"
}
}
}The warehouse-cost guard fires because a warehouse marker matches: here the tool name bigquery contains one. A tool named generically (query) works too, as long as the database value contains a marker such as bigquery or snowflake. If neither the tool name nor the database identifier carries a marker, the guard treats the call as none of its business and passes it through.
A vector submission carries the collection, namespace, operation, and top_k:
{
"tool_name": "vector_search",
"arguments": {
"database": "pinecone-prod",
"collection": "product-embeddings",
"namespace": "production",
"operation": "query",
"top_k": 10
}
}Each guard reads its own fields, applies its constraints, and either passes the request through or returns a Verdict::Deny with a structured reason (for example TableNotAllowed, TopKExceedsLimit, BytesExceedsLimit). The structured reason goes to a tracing::warn! record carrying its stable code; the receipt records which guard denied. Debugging Denials shows both, captured side by side.
Why Chio does not talk to the warehouse directly
Pick Your Guard
The four guards split cleanly by engine type. Three run pre-invocation, one runs post-invocation. You typically register all four on the pipeline and each one short-circuits to allow when the request is not its shape (thevector-db guard passes through SQL calls, the sql-query guard passes through vector calls, and so on).
These are chio.yaml keys, not HushSpec rule blocks
chio.yaml guards: block: sql_query, vector_db, warehouse_cost, and query_result. The policy compiler builds the first three into the pre-invocation pipeline and the fourth into the post-invocation one, so chio check --policy and chio run --policy both honor them. HushSpec is a different document, routed by a top-level hushspec: key, and it has no guards key. The YAML blocks that follow are the bodies of those four keys, and each one deserializes into the config struct named above it. Registering them in Rust, shown in Composing with Other Guards, is the alternative for an embedded host that has no policy file.| Engine | Guard | Phase | Key constraints |
|---|---|---|---|
| Relational (Postgres, MySQL, SQL Server, SQLite) | sql-query | pre | operation allowlist, table allowlist, column allowlist, predicate denylist, WHERE for mutations |
| Vector (Pinecone, Qdrant, Weaviate, Milvus, Chroma) | vector-db | pre | collection allowlist, namespace allowlist, denied operations, top_k ceiling |
| Warehouse (BigQuery, Snowflake, Redshift, Athena, Databricks, Presto, Trino) | warehouse-cost | pre | max bytes scanned, max cost per query (USD), dry-run required |
| Any (applied to tool response) | query-result | post | row truncation via MaxRowsReturned, column redaction via ColumnDenylist, PII regex patterns |
The post-invocation query-result guard is defense in depth. Pre-invocation guards reason about the query text; query-result reasons about what actually came back. A column the parser missed, a LIMIT the tool server ignored, a PII string that slipped through: the post-invocation pass truncates and redacts before the agent ever sees the response.
Relational SQL
SqlQueryGuard parses SQL with sqlparser and enforces four knobs: operation allowlist, table allowlist, per-table column allowlist, and a regex denylist against the canonicalized WHERE clause. A fifth knob, require_where_for_mutations, defaults to true and denies UPDATE and DELETE without a WHERE clause regardless of any other policy.
The guard is fail-closed. An empty config denies all queries. Parse errors deny, even when allow_all is enabled. SELECT * is denied whenever the referenced table has a column allowlist entry, because the guard cannot prove the expansion stays inside the allowed set.
A representative SqlGuardConfig, as it appears under guards.sql_query in a chio.yaml:
dialect: postgres
operation_allowlist:
- select
table_allowlist:
- users
- orders
- products
column_allowlist:
users:
- id
- name
- email
- created_at
orders:
- id
- user_id
- total
- status
products:
- "*"
denylisted_predicates:
- '\bor\s+1\s*=\s*1\b'
- '\bunion\s+select\b'
require_where_for_mutations: true
allow_all: falseThese deny scenarios each correspond to a variant of SqlGuardDenyReason, whose code() is the stable tag the guard logs:
-- DENIED: TableNotAllowed
SELECT id, total FROM salaries;
-- DENIED: OperationNotAllowed (DELETE is not in operation_allowlist)
DELETE FROM users WHERE id = 42;
-- DENIED: ColumnNotAllowed (ssn is not on the users allowlist)
SELECT id, ssn FROM users WHERE tenant_id = 'acme';
-- DENIED: SelectStarDenied (users has a column allowlist entry)
SELECT * FROM users;
-- DENIED: PredicateDenylisted (matches OR 1=1 pattern)
SELECT id FROM orders WHERE user_id = 1 OR 1=1;
-- DENIED: MissingWhereClause (require_where_for_mutations = true)
DELETE FROM orders;
-- DENIED: OperationNotAllowed (DDL requires an explicit Ddl entry)
DROP TABLE users;
-- DENIED: ParseError (fail-closed on unparseable SQL)
SELEKT oops;
-- ALLOWED
SELECT id, name, email FROM users WHERE tenant_id = 'acme' LIMIT 100;Column restrictions compose with the post-invocation guard. The sql-query guard rejects a SELECT that names a disallowed column. For cases the parser cannot see through (alias chains, computed expressions like lower(ssn), view expansions), the query-result guard runs after the tool returns and redacts any denied column that shows up in the response. The SELECT list is checked before execution and the returned data is redacted afterward.
DELETE and UPDATE without WHERE are always denied
require_where_for_mutations = true (the default), any mutation that lacks a WHERE clause produces SqlGuardDenyReason::MissingWhereClause before the query reaches the database. The safety net is independent of the operation allowlist. Even a capability with delete on the allowlist cannot issue a table-wide DELETE.Vector Databases
VectorDbGuard covers the four risks unique to vector databases: cross-collection access, cross-namespace access, write verbs under a read-only grant, and top_k overreach. It reads four fields from the arguments by configurable JSON paths, each independently overridable via field_paths. The default collection keys are collection, index, class, and store; the default namespace keys are namespace, tenant, and partition; the default operation keys are operation, op, and action; and the default top_k keys are top_k, topK, k, and limit.
The guard recognizes a request as vector-shaped when the database or tool name contains one of the configured vendor markers. The defaults cover vector, pinecone, weaviate, qdrant, chroma, and milvus. Non-vector traffic falls through to allow so the guard composes cleanly in a pipeline that also sees SQL and warehouse traffic.
The VectorGuardConfig shape, under guards.vector_db:
vendor_markers:
- pinecone
- qdrant
- weaviate
- milvus
collection_allowlist:
- product-embeddings
- faq-embeddings
namespace_allowlist:
- production
denied_operations:
- drop_index
- delete_collection
allow_all: falseThe top_k ceiling and read-only enforcement are not part of VectorGuardConfig; the guard reads them from the active capability token's scope constraints. The top_k ceiling is read from the scope's MaxRowsReturned constraint. When a ceiling is configured and the call omits top_k, the guard fails closed with TopKExceedsLimit. A SqlOperationClass::ReadOnly constraint on the scope blocks write verbs (upsert, insert, update, delete, write, index, reindex) regardless of the denied_operations list.
// DENIED: CollectionNotAllowed
{ "collection": "internal-hr-embeddings", "namespace": "production", "operation": "query", "top_k": 10 }
// DENIED: NamespaceNotAllowed
{ "collection": "product-embeddings", "namespace": "staging", "operation": "query", "top_k": 10 }
// DENIED: OperationNotAllowed (upsert under SqlOperationClass::ReadOnly)
{ "collection": "product-embeddings", "namespace": "production", "operation": "upsert", "top_k": 10 }
// DENIED: TopKExceedsLimit { requested: 500, max: 50 }
{ "collection": "product-embeddings", "namespace": "production", "operation": "query", "top_k": 500 }
// DENIED: TopKExceedsLimit { requested: u64::MAX, max: 50 } (fail-closed when top_k missing)
{ "collection": "product-embeddings", "namespace": "production", "operation": "query" }
// ALLOWED
{ "collection": "product-embeddings", "namespace": "production", "operation": "query", "top_k": 10 }Block embedding exfiltration
include_vectors: false on your tool server unless the grant explicitly needs vectors. The vector guard treats collection identity as the primary boundary; the tool server is responsible for stripping vectors from the response before the query-result guard even sees it.Data Warehouses
Warehouses are especially cost-sensitive. A single bad JOIN can scan terabytes and cost thousands of dollars. WarehouseCostGuard enforces two ceilings · bytes scanned and USD per query · using a dry-run estimate the tool server attaches to every request.
The pattern is straightforward. Your tool server calls the warehouse's dry-run API (BigQuery and Snowflake both support it natively), reads back bytes scanned and estimated cost, and submits both to chio as dry_run.bytes_scanned and dry_run.estimated_cost_usd. The guard compares the estimate to the configured limits and denies before the query is dispatched.
max_bytes_scanned: 1073741824 # 1 GiB
max_cost_per_query_usd: "5.00"
warehouse_markers:
- bigquery
- snowflake
- redshift
- athena
- databricks
- presto
- trino
field_paths:
bytes_scanned: "dry_run.bytes_scanned"
estimated_cost_usd: "dry_run.estimated_cost_usd"
allow_all: falseThe dry-run flow:
1. Agent submits: "summarize orders from last month"
2. Tool server generates SQL:
SELECT user_id, SUM(amount) FROM analytics.orders
WHERE order_date >= '2026-03-01' GROUP BY user_id
3. Tool server calls BigQuery dry-run:
bytes_scanned = 52_428_800 (50 MiB)
estimated_cost = "0.25" (25 cents)
4. Tool server submits to chio:
tool_name = "bigquery"
arguments = {
"database": "my-project.analytics",
"query": "...",
"dry_run": { "bytes_scanned": 52428800, "estimated_cost_usd": "0.25" }
}
5. warehouse-cost guard:
50 MiB < 1 GiB limit -> OK
$0.25 < $5.00 limit -> OK
verdict: allow
6. Tool server runs the approved queryDeny scenarios with structured reasons. Each one is logged with a stable code you can alert on:
// DENIED: BytesExceedsLimit { bytes_scanned: 53687091200, limit: 1073741824 }
{ "dry_run": { "bytes_scanned": 53687091200, "estimated_cost_usd": "0.25" } }
// DENIED: CostExceedsLimit { estimated_cost_usd: "25.00", limit_usd: "5.00" }
{ "dry_run": { "bytes_scanned": 5242880, "estimated_cost_usd": "25.00" } }
// DENIED: MissingEstimate { path: "dry_run.bytes_scanned" }
{ "query": "SELECT 1" }
// DENIED: ParseError { error: "dry_run.estimated_cost_usd is not a non-negative decimal string" }
{ "dry_run": { "bytes_scanned": 1024, "estimated_cost_usd": "-5.00" } }
// ALLOWED
{ "dry_run": { "bytes_scanned": 52428800, "estimated_cost_usd": "0.25" } }Require a dry-run estimate
dry_run field denies the request fail-closed with MissingEstimate. Tool servers that cannot dry-run (some flavors of Athena, older Redshift) must refuse to submit queries without an estimate.Post-Invocation: Redact and Truncate Results
QueryResultGuard is the one post-invocation guard in the set. It runs after the tool server returns, before the response reaches the agent. Three jobs:
- Row truncation. If the active scope has any
MaxRowsReturnedconstraint, the rows array is truncated to the strictest limit across grants. A tool server that ignored the pre-invocation LIMIT still cannot deliver more rows than the policy allows. - Column redaction. Every column on
ColumnDenylistis replaced with[REDACTED](configurable viaredaction_marker). Qualified entries likeusers.emailmatch both flat rows whereemailis the key and nested rows whereuserswraps the column. - PII pattern matching. The guard takes a list of regex patterns via
redact_pii_patterns. Matches in any string value in the response are replaced with the redaction marker. A pattern that is empty, over-long, over-complex, or does not compile rejects guard construction, so the policy fails to load rather than serving traffic with a rule that silently does nothing.
A representative config with common PII patterns:
redaction_marker: "[REDACTED]"
rows_keys:
- rows
- results
- records
- data
redact_pii_patterns:
- '\b\d{3}-\d{2}-\d{4}\b' # US SSN
- '\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b' # credit card
- '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' # email
- '\+?1?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}' # US phoneExample input from the tool server:
{
"rows": [
{ "id": 1, "name": "Ada", "email": "ada@example.com", "ssn": "123-45-6789" },
{ "id": 2, "name": "Bob", "email": "bob@example.com", "ssn": "987-65-4321" },
{ "id": 3, "name": "Cam", "email": "cam@example.com", "ssn": "555-12-3456" }
]
}With a scope carrying MaxRowsReturned(2) and ColumnDenylist(["ssn"]), the agent receives:
{
"rows": [
{ "id": 1, "name": "Ada", "email": "ada@example.com", "ssn": "[REDACTED]" },
{ "id": 2, "name": "Bob", "email": "bob@example.com", "ssn": "[REDACTED]" }
]
}The guard runs on rows, results, records, or data, in that order. A top-level JSON array is treated as the rows list directly. If the response is constrained (a column denylist is active) but the guard cannot find a rows shape, it redacts the entire payload fail-closed.
Use post-invocation redaction
sql-query guard checks the SELECT list, but table structures change, aliases mask column names, views expand transparently, and tool servers can add metadata columns the agent did not request. Install both guards. If the pre-invocation guard misses a case, the post-invocation guard redacts the response before the agent sees it.Tool Server Submission Contract
The guards depend on your tool server submitting structured arguments. Use this request shape so data guards can read the same fields.
| Field | Required for | Purpose |
|---|---|---|
tool_name | all | names the call so the kernel classifies it as a database query (sql, query, postgres, bigquery, ...) and so warehouse and vendor markers can match; the SQL dialect is a static guard config, not a submitted field |
database | all | target database, schema, namespace, or cluster identifier |
query | SQL, warehouse | raw query text for parsing |
collection / namespace | vector | collection path and namespace for scoping |
operation | vector | verb (query, upsert, get, set) |
top_k | vector | result volume (also topK, k, limit) |
dry_run | warehouse | { bytes_scanned, estimated_cost_usd } |
A tool server asks the kernel before it touches the database. The two published sidecar clients do the same thing, so pick your language: ChioClient from chio_sdk (pip install chio-sdk-python), or ChioSidecarClient from @chio-protocol/node-http (npm install @chio-protocol/node-http). Both take the tool name and the arguments and return the verdict plus a signed receipt.
from chio_sdk import ChioClient
async def sql_query(tool_call, conn, capability_token: dict) -> dict:
query: str = tool_call.arguments["query"]
# Authorize BEFORE executing. The kernel runs:
# velocity -> sql-query -> (vector-db, warehouse-cost, ...) pass through
# tool_name must be a recognized database tool ("sql", "postgres", ...);
# the parser dialect is fixed on SqlGuardConfig, not sent per call.
async with ChioClient() as chio:
decision = await chio.evaluate_tool_call_mediated(
capability=capability_token,
tool_server="analytics-db",
tool_name="sql",
parameters={"database": "analytics", "query": query},
)
# status is "authorized", "deny", or "pending_approval".
if decision["status"] != "authorized":
return {"error": decision["status"], "receipt_id": decision["receipt"]["id"]}
rows = await conn.fetch(query)
# Response shaping is a guard, not a second call: QueryResultGuard runs
# inside the pipeline registered below.
return {"rows": [dict(r) for r in rows]}import {
ChioSidecarClient,
isAllowed,
verdictReason,
} from "@chio-protocol/node-http";
import { BigQuery } from "@google-cloud/bigquery";
const chio = new ChioSidecarClient({ sidecarUrl: "http://127.0.0.1:9090" });
const bq = new BigQuery();
export async function warehouseQuery(toolCall: ToolCall, capabilityToken: string) {
const query = toolCall.arguments.query as string;
// Run the dry-run first. BigQuery charges zero for dry-run jobs.
const [dryRunJob] = await bq.createQueryJob({ query, dryRun: true });
const dryRun = {
bytes_scanned: Number(dryRunJob.metadata.statistics.totalBytesProcessed),
estimated_cost_usd: (
Number(dryRunJob.metadata.statistics.totalBytesProcessed) /
1024 ** 4 *
5.0
).toFixed(6),
};
// tool_name "bigquery" is both a recognized database tool and carries the
// warehouse marker, so the warehouse-cost guard sees and prices the call.
const result = await chio.evaluate(
{
request_id: crypto.randomUUID(),
method: "POST",
route_pattern: "/tools/bigquery",
path: "/tools/bigquery",
query: {},
headers: {},
caller: toolCall.caller,
body_length: 0,
tool_server: "warehouse",
tool_name: "bigquery",
arguments: { database: "my-project.analytics", query, dry_run: dryRun },
timestamp: Math.floor(Date.now() / 1000),
},
capabilityToken,
);
if (!isAllowed(result.verdict)) {
return { error: verdictReason(result.verdict), receipt_id: result.receipt?.id };
}
const [rows] = await bq.query({ query });
return { rows, receipt_id: result.receipt?.id };
}Two shapes are worth noticing because they are load-bearing. The TypeScript verdict is a tagged union, not a string, so isAllowed narrows it and verdictReason reads the reason off the deny arm. And on the Python side capability must be the full signed token: a bare capability id cannot authorize execution, so the id-only evaluate_tool_call always raises ChioDeniedError instead of returning.
Composing with Other Guards
Data-layer guards do not replace the existing guards; they layer on top. You typically register them in a pipeline that already has velocity-guard, data-flow-guard, and egress-allowlist-guard. Cheap guards run first so the pipeline short-circuits quickly on denial.
use chio_core::capability::scope::ChioScope;
use chio_guards::GuardPipeline;
use chio_data_guards::{
QueryResultGuard, QueryResultGuardConfig,
SqlGuardConfig, SqlQueryGuard,
VectorDbGuard, VectorGuardConfig,
WarehouseCostGuard, WarehouseCostGuardConfig,
};
let mut pipeline = GuardPipeline::default_pipeline();
// Data-layer pre-invocation guards.
pipeline.add(Box::new(SqlQueryGuard::new(sql_config())));
pipeline.add(Box::new(VectorDbGuard::new(vector_config())));
pipeline.add(Box::new(WarehouseCostGuard::new(warehouse_config())));
kernel.add_guard(Box::new(pipeline));
// Post-invocation shaping runs on its own pipeline. QueryResultGuard::new
// returns Result<Self, String>: it rejects invalid or over-broad PII regexes
// at construction so policy loading fails closed.
let result_guard = QueryResultGuard::new(QueryResultGuardConfig {
redact_pii_patterns: vec![
r"\b\d{3}-\d{2}-\d{4}\b".into(),
],
..Default::default()
})
.expect("PII patterns compile");
// The PostInvocationHook impl is on the adapter, not the guard. Pair the
// guard with the scope it should read constraints from, then install it.
kernel.add_post_invocation_hook(
Box::new(result_guard.into_owned_hook(ChioScope::default())),
);A query-result guard on the pre-invocation pipeline does nothing
QueryResultGuard also carries a Guard impl so it can be handed to GuardPipeline::add without the caller branching on two registries. That impl never denies and never reshapes anything: response shaping only happens through the post-invocation hook. If redaction is not taking effect, check that the guard went through into_owned_hook and add_post_invocation_hook rather than add.Three compositions you should plan for:
- Velocity. Rate-limit queries per minute with
velocity-guard. Data-layer guards are latency-sensitive (SQL parsing, regex matching), so velocity runs first and drops runaway loops before they spend parser time. - Data flow. Session-level bytes-read limits live on
data-flow. Data-layer guards enforce per-query ceilings (top_k, max bytes scanned); data-flow enforces the cumulative total across every query in the session. Both apply. It reads the session journal, so it is constructed with anArc<SessionJournal>rather than compiled from a HushSpec rule block; there is nodata_flowkey. - Egress allowlist. If your tool server talks to an external database over the network,
egress-allowlistensures it reaches only the approved hosts. The data-layer guards check the query content; the egress guard checks the endpoint. Both apply.
Velocity and egress are HushSpec rule blocks, so they live in a separate document from the chio.yaml that carries the data guards. The block names are velocity and egress, both under a top-level rules: key, and every block validates with deny_unknown_fields:
hushspec: "0.1.0"
name: analytics-layers
rules:
velocity:
enabled: true
max_invocations_per_window: 120
window_secs: 60
max_spend_per_window: 5000 # minor units, so $50.00
egress:
enabled: true
allow:
- "bigquery.googleapis.com"
- "db.internal:5432"
default: blockRun chio policy analyze <file> against it. The two velocity notices it prints are expected: a rate window is a stateful predicate, so the static analyzer reports it as not analyzed rather than as a finding.
Verify the Result
Dry-run the policy before any tool server points at it. No database is involved: the guards read the query string out of the submitted arguments, so chio check exercises the same code path a live call would. The policy behind the runs below is the SQL, vector and warehouse config from the sections above, in one chio.yaml.
Start with a query that should pass. A scoped SELECT over an allowlisted table, projecting only allowlisted columns:
$ chio --session-db ./admission-1.db --receipt-db ./receipts.db \
check --policy ./chio.yaml --server analytics-db --tool sql \
--params '{"database": "analytics", "query": "SELECT id, name FROM users WHERE tenant_id = '\''acme'\'' LIMIT 100"}'verdict: ALLOW tool: sql server: analytics-db receipt_id: a8ffe5a66925146b7520f28368b8f0d9f7b5cd350e62d6cd82b3114a974d1798 policy: 076d1752182f31fa644fb4c81e2ac069bc8801d18b70a8a70204c4082da87c7c source: 5753fa45e47effca953010500379863c6c7013f60b3cb863a6be9c90f4ab17da mode: preflight fixture: false
Then confirm each guard actually fires. The collection allowlist:
$ chio --session-db ./admission-3.db --receipt-db ./receipts.db \
check --policy ./chio.yaml --server analytics-db --tool vector_search \
--params '{"database": "pinecone-prod", "collection": "internal-hr-embeddings", "namespace": "production", "operation": "query", "top_k": 10}'verdict: DENY tool: vector_search server: analytics-db reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: 0ee7ebf8b07c9b104d17e146ea3c8e0b1b7faf43292690e8d9053fa0433e68d9 policy: 076d1752182f31fa644fb4c81e2ac069bc8801d18b70a8a70204c4082da87c7c source: 5753fa45e47effca953010500379863c6c7013f60b3cb863a6be9c90f4ab17da mode: preflight fixture: false
WARN chio.data-guards.vector message=vector-db-guard denied code=collection_not_allowed reason=collection \'internal-hr-embeddings\' is not in the allowlist database=internal-hr-embeddings collection=internal-hr-embeddings WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
The byte ceiling, from a dry-run estimate fifty times over the limit:
$ chio --session-db ./admission-4.db --receipt-db ./receipts.db \
check --policy ./chio.yaml --server analytics-db --tool bigquery \
--params '{"database": "my-project.analytics", "query": "SELECT id FROM orders WHERE id > 0",
$ "dry_run": {"bytes_scanned": 53687091200, "estimated_cost_usd": "0.25"}}'verdict: DENY tool: bigquery server: analytics-db reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: c6f16f1877eafbfb9b9051451258ace822738d21ebf52694b4e95f555dd28593 policy: 076d1752182f31fa644fb4c81e2ac069bc8801d18b70a8a70204c4082da87c7c source: 5753fa45e47effca953010500379863c6c7013f60b3cb863a6be9c90f4ab17da mode: preflight fixture: false
WARN chio.data-guards.warehouse message=warehouse-cost-guard denied code=bytes_exceeds_limit reason=bytes_scanned 53687091200 exceeds limit 1073741824 database=my-project.analytics WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
And the fail-closed case that catches a tool server which forgot to dry-run at all:
$ chio --session-db ./admission-5.db --receipt-db ./receipts.db \
check --policy ./chio.yaml --server analytics-db --tool bigquery \
--params '{"database": "my-project.analytics", "query": "SELECT id FROM orders WHERE id > 0"}'verdict: DENY tool: bigquery server: analytics-db reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: 2f75b66dff5b13599148b5c4cc49cdcf1af052e7dcd79b57625b4150734c5265 policy: 076d1752182f31fa644fb4c81e2ac069bc8801d18b70a8a70204c4082da87c7c source: 5753fa45e47effca953010500379863c6c7013f60b3cb863a6be9c90f4ab17da mode: preflight fixture: false
WARN chio.data-guards.warehouse message=warehouse-cost-guard denied: missing or invalid estimate code=missing_estimate reason=missing dry-run metadata at `dry_run.bytes_scanned` database=my-project.analytics WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
Each check needs its own session database. The dry-run command reuses one request id, so a second check against the same session database is refused as a conflict with the operation the first one retained.
Adding query_result changes how you dry-run
guards.query_result block puts a hook on the post-invocation pipeline, and preflight mode has no tool output to give it, so it refuses rather than reporting a verdict it cannot stand behind. Supply the response the tool server would have returned: --mode full --output-fixture <path>. The flag takes a file path, not inline JSON.$ chio --session-db ./admission-6.db --receipt-db ./receipts.db \
check --policy ./with-result-guard.yaml --server analytics-db --tool sql \
--params '{"database": "analytics", "query": "SELECT id, name FROM users WHERE id = 1"}'error [urn:chio:error:cli:other]: chio check preflight cannot evaluate post-output guards; use --mode full --output-fixture <JSON> so output-sensitive policy is evaluated against explicit fixture output
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.$ chio --session-db ./admission-7.db --receipt-db ./receipts.db \
check --policy ./with-result-guard.yaml --server analytics-db --tool sql \
--params '{"database": "analytics", "query": "SELECT id, name FROM users WHERE id = 1"}' \
--mode full --output-fixture ./tool-output.jsonverdict: ALLOW tool: sql server: analytics-db receipt_id: e5d22006f6953f6d0bd43ca679555f891046522c1446c0b1cbc2a0f22f479922 policy: d65009577abff5b093ed6a72dfa3806795ef8cf5e30c05c082e42487c6eb1eb5 source: 4f8335e41d3b93b782107c0279ea5fd7662062ff4615a276668e272080b3fb3e mode: full fixture: true
Debugging Denials
Each data-guard deny reason is a structured enum with a short stable code, and the code goes to a log record rather than to the receipt. The denial is written twice, in two places, and triage needs both.
The log line carries the code and the human reason. A SELECT * FROM salaries against the SQL guard configured earlier:
$ chio --session-db ./admission-2.db --receipt-db ./receipts.db \
check --policy ./chio.yaml --server analytics-db --tool sql \
--params '{"database": "analytics", "query": "SELECT * FROM salaries"}'verdict: DENY tool: sql server: analytics-db reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: ee5db28815ce72c660beaf1c98d52c1f407efd510593af9a11f4b6017aad40f4 policy: 076d1752182f31fa644fb4c81e2ac069bc8801d18b70a8a70204c4082da87c7c source: 5753fa45e47effca953010500379863c6c7013f60b3cb863a6be9c90f4ab17da mode: preflight fixture: false
WARN chio.data-guards.sql message=sql-query-guard denied query database=analytics code=table_not_allowed reason=table \'salaries\' is not in the allowlist WARN chio_kernel::kernel::evaluation::async_evaluation_core message=guard denied request_id=check-001 reason=guard denied the request: guard \"guard-pipeline\" denied the request
The first warning is the guard's, with code=table_not_allowed and the table it objected to. The second is the kernel's, and it names guard-pipeline, not sql-query. The reason: line on the verdict prints the same pipeline name for every guard denial, so it is never where the answer is.
The receipt carries which guard denied, and nothing about why:
$ chio --receipt-db ./receipts.db receipt list --admin-all \
| jq 'select(.action.parameters.query == "SELECT * FROM salaries")
$ | {id, tool_name, tool_server, decision, evidence}'{
"id": "ee5db28815ce72c660beaf1c98d52c1f407efd510593af9a11f4b6017aad40f4",
"tool_name": "sql",
"tool_server": "analytics-db",
"decision": {
"verdict": "deny",
"reason": "guard denied the request: guard \"guard-pipeline\" denied the request",
"guard": "kernel"
},
"evidence": [
{
"guard_name": "sql-query",
"verdict": false,
"details": "action=deny; reason=guard denied request"
}
]
}decision.guard is kernel on every guard denial. The guard identity is evidence[0].guard_name, and details is a constant string that is the same for every guard and every reason. Aggregating denials by guard_name works from receipts alone; aggregating by reason code means shipping the guard's log records somewhere queryable.
That split is visible in the guard itself. Its whole evaluate is a dozen lines: log the reason, return a deny with an empty evidence vector.
fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
let action = match extract_action_checked(&ctx.request.tool_name, &ctx.request.arguments) {
Ok(action) => action,
Err(_) => return Ok(GuardDecision::deny(Vec::new())),
};
let (database, query) = match &action {
ToolAction::DatabaseQuery { database, query } => (database.as_str(), query.as_str()),
_ => return Ok(GuardDecision::allow()),
};
match self.analyze(query) {
Ok(_) => Ok(GuardDecision::allow()),
Err(reason) => {
warn!(
target: "chio.data-guards.sql",
database = %database,
code = reason.code(),
reason = %reason,
"sql-query-guard denied query"
);
Ok(GuardDecision::deny(Vec::new()))
}
}
}GuardDecision::deny(Vec::new()) is the reason the receipt has nothing to add. The vector and warehouse guards end the same way.
The codes to know when triaging a denial. Each is the code() of a deny-reason variant and appears in the guard's tracing::warn! record:
| Guard | Code | What to fix |
|---|---|---|
sql-query | parse_error | query is malformed or uses syntax the dialect parser rejects; check dialect setting |
sql-query | table_not_allowed | add the table to table_allowlist or fix the query |
sql-query | column_not_allowed | add the column to the per-table allowlist or remove it from the SELECT |
sql-query | select_star_denied | enumerate columns explicitly; SELECT * is denied when the table has a column allowlist |
sql-query | missing_where_clause | add a WHERE to the UPDATE or DELETE, or disable require_where_for_mutations (not recommended) |
sql-query | predicate_denylisted | the WHERE matched a regex in denylisted_predicates; rewrite or prune the rule |
vector-db | collection_not_allowed | add the collection to collection_allowlist |
vector-db | top_k_exceeds_limit | reduce top_k or raise MaxRowsReturned on the grant |
warehouse-cost | missing_estimate | tool server must attach dry_run.bytes_scanned and dry_run.estimated_cost_usd |
warehouse-cost | bytes_exceeds_limit | query would scan too much; add filters, partition predicates, or raise max_bytes_scanned |
warehouse-cost | cost_exceeds_limit | same fix path as bytes, or raise max_cost_per_query_usd with approval |
| all | no_config | the guard is registered but has no allowlists; either populate the policy or remove the guard |
The query audit receipts guide walks through the query-specific receipt fields and shows how to aggregate denials by code for dashboards and alerting.
Failures and Recovery
A bad PII pattern is the one failure that stops the policy loading rather than denying a call. QueryResultGuard::new rejects a pattern that is empty, over-long, over-complex, or does not compile, and the policy compiler turns that into a load error:
$ chio --session-db ./admission-8.db --receipt-db ./receipts.db \
check --policy ./broken-pattern.yaml --server analytics-db --tool sql \
--params '{"database": "analytics", "query": "SELECT 1"}' \
--mode full --output-fixture ./tool-output.jsonerror [CHIO-CLI-POLICY]: invalid policy: invalid query_result.redact_pii_patterns entry `(`: regex parse error:\n (\n ^\nerror: unclosed group
context: {"source":"invalid policy: invalid query_result.redact_pii_patterns entry `(`: regex parse error:\n (\n ^\nerror: unclosed group"}
suggested fix: Fix the policy file contents or path so the requested command can load a valid policy document.This is deliberate. A redaction rule that silently fails to compile is a rule that silently stops redacting.
| Symptom | Cause | Fix |
|---|---|---|
Every query denies, code no_config | The guard is registered with empty allowlists. It is fail-closed, so an empty config denies everything. | Populate the allowlists, or remove the guard from the policy. |
Code parse_error on valid-looking SQL | The configured dialect parser rejects the syntax. Parse errors deny even when allow_all is set. | Set dialect to the engine you actually query. |
| The guard never fires; every call allows | The tool name is not one of the 15 database names, or the vendor and warehouse markers do not match the tool name or the database value. | Rename the tool, or add the marker your identifier carries. Non-matching traffic short-circuits to allow by design. |
Preflight refuses with cannot evaluate post-output guards | The policy has a query_result block. | Add --mode full --output-fixture <path>. The value is a file path. |
request id conflicts with retained operation | A second dry run against a session database that already holds one. | Use a fresh session database per check. |
| Redaction is not happening | The guard went onto the pre-invocation pipeline, where its Guard impl is a no-op. | Install it with into_owned_hook plus add_post_invocation_hook, or set guards.query_result in the policy and let the compiler do it. |
| A denial you cannot explain from the receipt | Reason codes go to log records, not receipts. | Read the guard's tracing::warn! line. The receipt gives you evidence[].guard_name. |
Summary
| Engine | chio.yaml key | Guard name | Key constraint |
|---|---|---|---|
| Postgres, MySQL, SQL Server, SQLite | guards.sql_query | sql-query | table_allowlist, column_allowlist |
| Pinecone, Qdrant, Weaviate, Milvus, Chroma | guards.vector_db | vector-db | collection_allowlist, MaxRowsReturned |
| BigQuery, Snowflake, Redshift, Athena, Databricks, Presto, Trino | guards.warehouse_cost | warehouse-cost | max_bytes_scanned, max_cost_per_query_usd |
| any (post-invocation) | guards.query_result | query-result | MaxRowsReturned, ColumnDenylist, redact_pii_patterns |
The guard name in the third column is what a denial writes to evidence[].guard_name on the receipt. The submitted arguments, including the query text, land under action.parameters with a parameter_hash beside them. There is no query-specific receipt field beyond that: no separate table list, no row count, no redaction count.
Next Steps
- Guards · how the four data-layer guards fit into the broader guard model
- Write a Policy · HushSpec syntax for constraints, allowlists, and grant scoping
- Custom Guards · add organization-specific logic on top of the built-in four
- Native Tool Server · build a tool server that submits the contract these guards expect
- Receipts · signed audit records for database queries
- Query Audit Receipts · aggregate database receipts for compliance and cost reporting