Chio/Docs
LOGIN · JOIN

Govern Database Queries

Configure SQL, vector, and warehouse guards for a database tool, dry-run them with chio check, and read each refusal.

Guide chio@5f63e6e

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.

Start

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.

Two checkpoints on a database callChoose an outcome.
gsqlgvectorgcostrowsdispatchaagentKkernelsdatabase toolρreceiptgresult hookgsqlgvectorgcostrowsdispatchaagentKkernelsdatabase toolρreceiptgresult hook

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.

The request checks of 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 guards

Each 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.

Enginechio.yaml keyGuardRuns
Postgres, MySQL, SQL Server, SQLiteguards.sql_querysql-queryBefore dispatch
Pinecone, Qdrant, Weaviate, Milvus, Chromaguards.vector_dbvector-dbBefore dispatch
BigQuery, Snowflake, Redshift, Athena, Databricks, Presto, Trinoguards.warehouse_costwarehouse-costBefore dispatch
Any engineguards.query_resultquery-resultOn 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 calljson
{
  "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.

chio.yaml · guards.sql_queryyaml
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: false

SqlQueryGuard 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.

A query against a table outside the allowlisttranscript
$ 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
exit 2deny

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.

chio.yaml · guards.vector_dbyaml
vendor_markers:
  - pinecone
  - qdrant
  - weaviate
  - milvus
collection_allowlist:
  - product-embeddings
  - faq-embeddings
namespace_allowlist:
  - production
denied_operations:
  - drop_index
  - delete_collection
allow_all: false

The 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.

A search in a collection outside the allowlisttranscript
$ 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
exit 2deny

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.

chio.yaml · guards.warehouse_costyaml
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: false

A 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.

An estimate fifty times the byte ceilingtranscript
$ 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
exit 2deny

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.

A warehouse query with no dry_run fieldtranscript
$ 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
exit 2deny

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.

chio.yaml · guards.query_resultyaml
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 phone

With a grant carrying MaxRowsReturned(2) and ColumnDenylist(["email"]), three rows from the tool server become two, with every email replaced by the marker.

Tool server responsejson
{
  "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" }
  ]
}
Agent receives
{
  "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.

tool_server/authorize_warehouse.tstool_server/authorize_sql.py
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() };
}

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.

A scoped SELECT over allowlisted columnstranscript
$ 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
exit 0allow

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.

The result guard evaluated against a fixturetranscript
$ 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.json
verdict:    ALLOW
tool:       sql
server:     analytics-db
receipt_id: df9ccc316c2c0e7891a6c827e001bc2c4fbf07245fcd8ffd65e056c4a4c74cd4
policy:     d65009577abff5b093ed6a72dfa3806795ef8cf5e30c05c082e42487c6eb1eb5
source:     4f8335e41d3b93b782107c0279ea5fd7662062ff4615a276668e272080b3fb3e
mode:       full
fixture:    true
exit 0allow

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.

The refusal in the receipt storetranscript
$ 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"
    }
  ]
}
exit 0deny

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>.

Preflight against a policy with a result guardtranscript
$ 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
exit 1
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.

An unclosed group in redact_pii_patternstranscript
$ 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.json
error[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
exit 1

Data guard reference

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

FieldRequired forPurpose
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_cost_usd }

A warehouse submission carries the dry-run estimate beside the query. A vector submission carries the collection, namespace, operation, and top_k.

Warehouse tool calljson
{
  "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"
    }
  }
}
Vector tool calljson
{
  "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:

Statements against guards.sql_querysql
-- 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:

vector_search argumentsjavascript
// 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.

Dry-run flowtext
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
bigquery argumentsjavascript
// 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.

GuardCodeWhat to fix
sql-query
parse_error
Malformed query or syntax the dialect rejects; check dialect
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 table's allowlist, or drop it from the SELECT
sql-query
select_star_denied
Name columns; the table has a column allowlist
sql-query
missing_where_clause
Add a WHERE to the UPDATE or DELETE, or set require_where_for_mutations to false
sql-query
predicate_denylisted
The WHERE matched denylisted_predicates; rewrite the query or the rule
vector-db
collection_not_allowed
Add the collection to collection_allowlist
vector-db
top_k_exceeds_limit
Lower top_k, or raise MaxRowsReturned on the grant
warehouse-cost
missing_estimate
Attach dry_run.bytes_scanned and dry_run.estimated_cost_usd
warehouse-cost
bytes_exceeds_limit
Add filters or partition predicates, or raise max_bytes_scanned
warehouse-cost
cost_exceeds_limit
Narrow the query, or raise max_cost_per_query_usd 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.

crates/guards/chio-data-guards/src/sql_guard.rs304-327rust
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.

src/kernel_setup.rs · embedding the kernelrust
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.

analytics-layers.hushspec.yamlyaml
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: block

chio 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.

Next: count refusals across your receipts