Govern Database Queries
Configure SQL, vector, and warehouse guards for a database tool, dry-run them with chio check, and read each refusal.
On this page
Give an agent a database tool that reaches only the tables, collections, and cost you allow. The kernel refuses an out-of-bounds query before your tool server runs it, then truncates and redacts the rows on their way back.
Chio never connects to your data store. Your tool server captures the query it is about to execute and submits it as tool-call arguments; the four guards in chio-data-guards read those arguments under one chio.yaml. The running example is an analytics Postgres database, a Pinecone collection, and a BigQuery project.
Each database call meets two checkpoints
Three guards read the submitted query before dispatch. The fourth reads the rows the tool returns, before the agent does.
An admitted query reaches the tool, and its rows return through the hook
The statement parses, its operation and tables are on the allowlists, and the cost estimate sits under the ceiling. The kernel dispatches it, and QueryResultGuard truncates the rows to MaxRowsReturned before the agent reads them. Govern database queries.
SqlQueryGuard, VectorDbGuard, and WarehouseCostGuard on the rail, dispatch to the database tool, and QueryResultGuard on the return path. The refused statement and the ssn column are illustrative; each guard's internal check order and the receipt fields are left out.Data-layer guardsEach guard answers one way a valid tool call goes wrong: an injected SELECT * FROM salaries, a DELETE FROM users with no WHERE clause, a top_k=10000 against another tenant's collection, or a JOIN that scans terabytes. Register all four. A guard passes any request outside its shape, so the SQL guard lets vector calls through and the vector guard lets SQL through.
| Engine | chio.yaml key | Guard | Runs |
|---|---|---|---|
| Postgres, MySQL, SQL Server, SQLite | guards.sql_query | sql-query | Before dispatch |
| Pinecone, Qdrant, Weaviate, Milvus, Chroma | guards.vector_db | vector-db | Before dispatch |
| BigQuery, Snowflake, Redshift, Athena, Databricks, Presto, Trino | guards.warehouse_cost | warehouse-cost | Before dispatch |
| Any engine | guards.query_result | query-result | On the returned rows |
The guard name in the third column is what a refusal writes to evidence[].guard_name on the receipt.
1. Submit the query as tool-call arguments
Have your tool server send the statement it is about to run, with the target database, before it executes anything.
{
"tool_name": "sql",
"arguments": {
"database": "analytics",
"query": "SELECT name, email FROM users WHERE tenant_id = 'harbor-goods' LIMIT 100"
}
}The SQL and warehouse guards see the call only when tool_name is one of the 15 names the action extractor classifies as a database call, such as sql, query, postgres, or bigquery. The SQL dialect is set once, in the guard's configuration. Vector and warehouse calls add their own fields, listed in the reference below.
2. Restrict tables and columns with sql_query
Add the SQL guard under the guards: block of your chio.yaml. The four data guards are keys of that block; HushSpec is a separate document with no guards key.
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: falseSqlQueryGuard parses each statement with sqlparser and checks the operation, the tables, each table's columns, and the WHERE clause against the predicate denylist. It fails closed: an empty configuration refuses every query, a parse error refuses even with allow_all, and SELECT * is refused on any table with a column allowlist. With require_where_for_mutations at its default of true, an UPDATE or DELETE without a WHERE clause is refused even when the operation allowlist includes it.
$ chio --session-db ./admission.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: c9698af3c56f16034375848fdaf2edbfbbc52f52d4ddb03dc081fb00e77ab362 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-547324fb-f50a-4404-ae31-aff05daf346f reason=guard denied the request: guard \"guard-pipeline\" denied the request
You should see DENY and, on stderr, the guard's warning with code=table_not_allowed and the table it refused. The statement stopped before the database.
3. Bound vector searches with vector_db
Name the collections and namespaces the tool may search, and the operations it refuses.
vendor_markers:
- pinecone
- qdrant
- weaviate
- milvus
collection_allowlist:
- product-embeddings
- faq-embeddings
namespace_allowlist:
- production
denied_operations:
- drop_index
- delete_collection
allow_all: falseThe result ceiling comes from the grant. VectorDbGuard reads top_k against the scope's MaxRowsReturned constraint and refuses a call that omits top_k when a ceiling is set. A SqlOperationClass::ReadOnly constraint on the scope refuses the write verbs upsert, insert, update, delete, write, index, and reindex, whatever denied_operations lists.
$ chio --session-db ./admission.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: 12f849160a37a9014aa6f9db122ced2b54975f34cbe60c5463b0659313fc7f94 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-141e43b5-4401-4e67-bf8e-57b84bc1b82f reason=guard denied the request: guard \"guard-pipeline\" denied the request
You should see code=collection_not_allowed from the vector guard. Keep include_vectors: false in your tool server unless the grant needs raw vectors, which can be used to reconstruct source text; the tool server strips them before the result guard runs.
4. Price warehouse queries before they run
Have the tool server dry-run each warehouse query and attach the estimate, then set the ceilings the estimate must clear. One bad JOIN can scan terabytes: an agent can spend five dollars on tokens and fifteen thousand on warehouse compute.
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: falseA BigQuery dry run estimates processed bytes, and the host computes a dollar figure from its own pricing; Snowflake EXPLAIN supplies a plan with upper-bound bytes. Bind the estimate to the submitted query. The ceilings apply to the reported estimate; the final warehouse bill can differ from it. Because the tool server runs the dry run, the kernel holds no warehouse credentials and performs no network calls during admission.
$ chio --session-db ./admission.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: d92645f0ddbaf43dfbf2b76c61aea6e4d89d2a6a19b492a72f62a9f31940e2a4 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-52202774-f99e-4bed-983e-974e6f9ae922 reason=guard denied the request: guard \"guard-pipeline\" denied the request
You should see code=bytes_exceeds_limit with the estimate and the limit. The guard matches a call when the tool name or the database value contains a warehouse marker; a generic query tool against larkfield-prod.orders on BigQuery needs the marker in its database identifier.
A tool server that forgot to dry-run is refused too, with missing_estimate. Make the dry run mandatory; an engine that cannot produce one has its warehouse queries refused.
$ chio --session-db ./admission.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: 63759f7cc063965505b64a5dc41254b05d8028796483ab242c3f11f3806a32d8 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-437e7f65-1b12-4488-83d8-85a2a0799b0d reason=guard denied the request: guard \"guard-pipeline\" denied the request
5. Truncate and redact rows with query_result
Add the result guard so returned rows are shaped before the agent reads them, whatever the query looked like.
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 phoneWith a grant carrying MaxRowsReturned(2) and ColumnDenylist(["email"]), three rows from the tool server become two, with every email replaced by the marker.
{
"rows": [
{ "id": 1, "name": "Priya Raman", "email": "priya@harbor-goods.example" },
{ "id": 2, "name": "Dev Malhotra", "email": "dev@harbor-goods.example" },
{ "id": 3, "name": "Ana Ruiz", "email": "ana@harbor-goods.example" }
]
}{
"rows": [
{ "id": 1, "name": "Priya Raman", "email": "[REDACTED]" },
{ "id": 2, "name": "Dev Malhotra", "email": "[REDACTED]" }
]
}You should see the third row dropped and every denied value set to [REDACTED]. The pre-dispatch SQL guard checks the SELECT list; the result guard catches what it cannot see, such as alias chains, lower(email), view expansions, and metadata columns the tool server adds. Install both.
6. Authorize each query from your tool server
Send the arguments to the kernel's tool-call evaluation before the driver sees them. The TypeScript example prices a BigQuery query first; the Python example uses the lower-level kernel client ChioClient.
import { BigQuery } from "@google-cloud/bigquery";
const bq = new BigQuery();
export async function authorizeWarehouseQuery(
sidecarUrl: string,
query: string,
capability: Record<string, unknown>,
usdPerTiB: number, // operator-configured estimate rate for this project
) {
if (!Number.isFinite(usdPerTiB) || usdPerTiB < 0) {
throw new Error("estimate rate must be finite and nonnegative");
}
const [job] = await bq.createQueryJob({ query, dryRun: true });
const bytes = Number(job.metadata.statistics?.totalBytesProcessed);
if (!Number.isSafeInteger(bytes) || bytes < 0) {
throw new Error("dry run did not supply a safe byte estimate");
}
const parameters = {
database: "larkfield-prod.orders",
query,
dry_run: {
bytes_scanned: bytes,
estimated_cost_usd: (bytes / 1024 ** 4 * usdPerTiB).toFixed(6),
},
};
const response = await fetch(new URL("/v1/evaluate", sidecarUrl), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
capability, tool_server: "warehouse", tool_name: "bigquery", parameters,
}),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error("tool authorization failed: " + response.status);
// No query is executed. The trusted executor consumes the original nonce.
return { parameters, authorization: await response.json() };
}from chio import ChioClient
async def authorize_sql(query: str, capability_token: dict) -> dict:
parameters = {"database": "analytics", "query": query}
async with ChioClient() as chio:
authorization = await chio.evaluate_tool_call_mediated(
capability=capability_token,
tool_server="analytics-db",
tool_name="sql",
parameters=parameters,
)
# No database execution occurs here. Return the original parameters
# and complete authorization, including its bound execution nonce.
return {"parameters": parameters, "authorization": authorization}Only an authorized response proceeds, to the trusted executor. It verifies and consumes the nonce bound to the unchanged arguments, executes once, and reconciles the reserved hold with its private control credential. Keep deny and pending_approval responses out of the database driver. A reservation receipt records admission, not a completed query.
Result shaping runs where the kernel dispatches. Register the database handler with the kernel and return its rows through the kernel's output pipeline, as the native tool-server guide shows. The HTTP evaluator at /chio/evaluate authorizes HTTP operations; tool_name or arguments added to that request do not make a database-tool invocation.
7. Dry-run the policy and read the receipt
Run chio check against the combined chio.yaml before any tool server points at it. The guards read the query from the arguments, so no database is involved. chio check needs a session database and a receipt database; reuse them for every check.
$ chio --session-db ./admission.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: d3a4974dcd9ff92875ae53c1a16a24515b34b119dbf70583a0d0fbd39678956c policy: 076d1752182f31fa644fb4c81e2ac069bc8801d18b70a8a70204c4082da87c7c source: 5753fa45e47effca953010500379863c6c7013f60b3cb863a6be9c90f4ab17da mode: preflight fixture: false
With a guards.query_result block, preflight has no tool output to shape. Pass the response the tool server would return with --mode full --output-fixture <path>; the flag takes a file path.
$ chio --session-db ./admission.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: df9ccc316c2c0e7891a6c827e001bc2c4fbf07245fcd8ffd65e056c4a4c74cd4 policy: d65009577abff5b093ed6a72dfa3806795ef8cf5e30c05c082e42487c6eb1eb5 source: 4f8335e41d3b93b782107c0279ea5fd7662062ff4615a276668e272080b3fb3e mode: full fixture: true
Each check gets its own request identity, and the receipt store keeps allowed and refused decisions together. Find the receipt for the refused SELECT * FROM salaries from step 2.
$ 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": "c9698af3c56f16034375848fdaf2edbfbbc52f52d4ddb03dc081fb00e77ab362",
"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"
}
]
}You should see verdict: deny and evidence[0].guard_name set to sql-query; decision.guard reads kernel for every guard refusal. The reason code, table_not_allowed here, is in the guard's tracing::warn! record from step 2, so count refusals by guard from receipts and by code from shipped logs.
A guard that cannot read a request refuses it or lets it pass
Data guards fail closed on configuration they cannot apply and stand aside on requests outside their shape. Most surprises trace to one of those two edges.
Every query is refused
Code no_config means the guard is registered with empty allowlists; populate them or remove the guard. Code parse_error on valid-looking SQL means the configured dialect rejects the syntax; set it to the engine you query. Parse errors refuse even with allow_all set.
Every call is allowed, or preflight refuses
If every call is allowed, the tool name is outside the 15 database names, or no vendor or warehouse marker matches the tool name or the database value. Rename the tool or add the marker your identifier carries. If preflight refuses with cannot evaluate post-output guards, the policy has a query_result block; add --mode full --output-fixture <path>.
$ chio --session-db ./admission.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[CHIO-CLI-PREFLIGHT-NEEDS-OUTPUT]: preflight cannot evaluate post-output guards, because it never runs the tool help: run the check with --mode full --output-fixture <JSON> so output-sensitive guards see explicit fixture output code: urn:chio:error:cli:preflight-needs-output docs: https://chio.computer/docs/reference/errors#error-cli-preflight-needs-output
Rows arrive unredacted, or the policy fails to load
A QueryResultGuard added to the pre-dispatch pipeline allows every call and reshapes nothing. Install it with into_owned_hook and add_post_invocation_hook, or set guards.query_result and let the policy compiler place it. A PII pattern that is empty, over-long, over-complex, or does not compile stops the policy from loading, so a broken redaction rule surfaces before any traffic.
$ chio --session-db ./admission.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 help: fix the policy entry the message names, then run the command again code: urn:chio:error:policy:compile-failed docs: https://chio.computer/docs/reference/errors#error-policy-compile-failed
Data guard reference
Try top_k, dry_run, table_not_allowed, rows_keys, or into_owned_hook.
Database tool names
The action extractor classifies a call as a database query when tool_name is exactly one of these 15 names: sql, query, db_query, database, execute_sql, run_sql, postgres, mysql, sqlite, snowflake, bigquery, redshift, mongo, mongodb, and redis. The SQL and warehouse guards pass any other name.
Submission fields
| Field | Required for | Purpose |
|---|---|---|
tool_name | all | Classifies the call as a database query; markers can match it |
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_ |
A warehouse submission carries the dry-run estimate beside the query. A vector submission carries the collection, namespace, operation, and top_k.
{
"tool_name": "bigquery",
"arguments": {
"database": "larkfield-prod.orders",
"query": "SELECT user_id, SUM(amount) FROM orders GROUP BY user_id",
"dry_run": {
"bytes_scanned": 52428800,
"estimated_cost_usd": "0.25"
}
}
}{
"tool_name": "vector_search",
"arguments": {
"database": "pinecone-prod",
"collection": "product-embeddings",
"namespace": "production",
"operation": "query",
"top_k": 10
}
}SQL statements and their deny reasons
Each refusal is a variant of SqlGuardDenyReason, whose code() is the stable tag the guard logs. Against the step 2 configuration:
-- DENIED: TableNotAllowed
SELECT id, total FROM salaries;
-- DENIED: OperationNotAllowed (DELETE is not in operation_allowlist)
DELETE FROM users WHERE id = 42;
-- DENIED: ColumnNotAllowed (phone is not on the users allowlist)
SELECT id, phone FROM users WHERE tenant_id = 'harbor-goods';
-- 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 = 'harbor-goods' LIMIT 100;Vector field paths and markers
VectorDbGuard reads four fields by configurable JSON paths, each overridable through field_paths. Collection keys default to collection, index, class, and store; namespace keys to namespace, tenant, and partition; operation keys to operation, op, and action; and top_k keys to top_k, topK, k, and limit.
A request is vector-shaped when the database or tool name contains a vendor marker. The default markers are vector, pinecone, weaviate, qdrant, chroma, and milvus. The guard covers cross-collection access, cross-namespace access, write verbs under a read-only grant, and top_k overreach; collection identity is its primary boundary. With a MaxRowsReturned ceiling of 50:
// 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 is missing)
{ "collection": "product-embeddings", "namespace": "production",
"operation": "query" }
// ALLOWED
{ "collection": "product-embeddings", "namespace": "production",
"operation": "query", "top_k": 10 }Warehouse dry-run flow and deny reasons
The tool server performs every external call; the kernel prices the query from the self-reported estimate, and the receipt log keeps that estimate auditable.
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" (example host estimate, not a provider quote)
4. Tool server submits to chio:
tool_name = "bigquery"
arguments = {
"database": "larkfield-prod.orders",
"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 query// 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" } }Result shaping rules
Row truncation cuts the rows array to the matched grant's MaxRowsReturned, so a tool server that ignored a LIMIT still delivers no more rows than the policy allows. Column redaction replaces every column on ColumnDenylist with the redaction_marker, [REDACTED] by default; a qualified entry such as users.email matches flat rows keyed email and nested rows under users. Each redact_pii_patterns match in any string value is replaced with the marker.
The guard looks for rows under rows, results, records, then data, and treats a top-level JSON array as the rows list. When a column denylist is active and no rows shape is found, it redacts the entire payload.
Deny reason codes
Each code is the code() of a deny-reason variant and appears in the guard's tracing::warn! record. The kernel's verdict line names guard-pipeline for every guard refusal.
| Guard | Code | What to fix |
|---|---|---|
sql-query | parse_error | Malformed query or syntax the dialect rejects; check dialect |
sql-query | table_ | Add the table to table_allowlist, or fix the query |
sql-query | column_ | Add the column to the table's allowlist, or drop it from the SELECT |
sql-query | select_ | Name columns; the table has a column allowlist |
sql-query | missing_ | Add a WHERE to the UPDATE or DELETE, or set require_ to false |
sql-query | predicate_ | The WHERE matched denylisted_; rewrite the query or the rule |
vector-db | collection_ | Add the collection to collection_ |
vector-db | top_ | Lower top_k, or raise MaxRowsReturned on the grant |
warehouse-cost | missing_ | Attach dry_ and dry_ |
warehouse-cost | bytes_ | Add filters or partition predicates, or raise max_ |
warehouse-cost | cost_ | Narrow the query, or raise max_ with approval |
all | no_config | Populate the allowlists, or remove the guard |
The SQL guard's evaluate logs the reason and returns a deny with an empty evidence vector; the vector and warehouse guards end the same way, and the kernel writes the guard's name to the receipt's evidence. The receipt stores the submitted arguments, query text included, under action.parameters beside a parameter_hash.
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()))
}
}
}Registering the guards in Rust
From chio.yaml, the policy compiler places the first three guards in the pre-dispatch pipeline and query_result on the return path, so chio check --policy and chio run --policy both honor them. A host that embeds the kernel without a policy file registers the guards directly. Data guards layer onto the default pipeline, after cheap guards such as velocity-guard, so runaway loops stop before they spend parser time.
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 a fallback scope, then install it. Kernel dispatch supplies
// the validated active scope and matched grant through its hook context.
kernel.add_post_invocation_hook(
Box::new(result_guard.into_owned_hook(ChioScope::default())),
);QueryResultGuard also implements Guard, so GuardPipeline::add accepts it, but that impl never denies or reshapes. Shaping happens only through into_owned_hook and add_post_invocation_hook.
Velocity, data flow, and egress
velocity rate-limits queries per window. data-flow enforces the cumulative bytes read across a session while the data guards enforce per-query ceilings; it reads the session journal, so it is constructed with an Arc<SessionJournal> and has no data_flow key. egress-allowlist limits which hosts the tool server reaches; the data guards check the query content.
Velocity and egress are HushSpec rule blocks under a top-level rules: key, in a document separate from the chio.yaml that carries the data guards. 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: blockchio policy analyze <file> prints two velocity notices for this file: a rate window is a stateful predicate, so the static analyzer reports it as not analyzed.