BuildOperations
Trust Control Walkthrough
Use the trust-control HTTP API to issue and revoke a capability, create a receipt, and verify exported evidence offline.
Where the code lives
examples/hello-trust-control/. Run ./smoke.sh for the full flow or ./run-trust.sh to start trust-control alone for manual experimentation.What It Shows
- Capability issuance through the shared trust-control HTTP API.
- Capability status and revocation through the Chio CLI.
- Receipt creation without any HTTP app or framework, using
chio check. - Offline evidence export and verification with
chio evidence exportandchio evidence verify. - Bounded staleness: a revocation through trust-control is checked on subsequent kernel calls.
Files
examples/hello-trust-control/
README.md
policy.yaml minimal HushSpec: allow read_file only
run-trust.sh start trust-control alone (port 8051 by default)
smoke.sh full issue / revoke / receipt / export / verify flowRun It
# From the chio workspace root
cargo build --bin chio
cd examples/hello-trust-control
# Full smoke: issue, revoke, mint receipt, export, verify
./smoke.sh
# Or: start trust-control alone for manual exploration
./run-trust.sh
# trust-control listens on 127.0.0.1:8051
# state under .artifacts/manual-state/The last thing the script does is print five lines (examples/hello-trust-control/smoke.sh:123-129):
hello-trust-control smoke passed
artifacts: .../examples/hello-trust-control/.artifacts/<timestamp>
capability id: <id>
receipt id: <id>
evidence dir: .../evidenceThe smoke stops at Phase 5 on this commit
smoke.sh:83-89 passes --receipt-db without --session-db, and durable admission now requires both (crates/platform/chio-control-plane/src/durable_admission.rs:170). Everything from Phase 5 down is unreachable through ./smoke.sh until that line grows a second flag. The two phases below are captured by running the same commands with --session-db added, and Phase 5b already had it.Phase 1: Start Trust-Control
The smoke launches chio trust serve on a free port with all four SQLite stores rooted in the output directory for that run, and its log alongside them:
"${CHIO_BIN}" trust serve \
--listen "127.0.0.1:${TRUST_PORT}" \
--service-token "${SERVICE_TOKEN}" \
--receipt-db "${TRUST_RECEIPT_DB}" \
--revocation-db "${REVOCATION_DB}" \
--authority-db "${AUTHORITY_DB}" \
--budget-db "${BUDGET_DB}" \
>"${LOG_DIR}/trust.log" 2>&1 &Once /health returns 200, trust-control is ready to accept capability operations.
Phase 2: Issue a Capability
The shared helper issue_demo_capability posts to /v1/capabilities/issue and writes the capability JSON to disk. The example then materializes a wire-format token (the JSON the kernel passes through the X-Chio-Capability header) under capability.token.
The request body the helper posts:
{
"subjectPublicKey": "0000000000000000000000000000000000000000000000000000000000000000",
"scope": {
"grants": [
{
"server_id": "http-sidecar-client",
"tool_name": "hello_trust_control_invoke",
"operations": ["invoke"],
"constraints": []
}
],
"resource_grants": [],
"prompt_grants": []
},
"ttlSeconds": 3600
}The three outer keys are subjectPublicKey, scope and ttlSeconds: IssueCapabilityRequest carries #[serde(rename_all = "camelCase")] (crates/platform/chio-control-plane/src/trust_control/service_types/requests.rs:16-24). The keys inside scope stay snake_case, because ChioScope and ToolGrant have no such rename. Posting it and reading back the scope the service stored:
$ curl -sS -X POST $CHIO_CONTROL_URL/v1/capabilities/issue \
-H "Authorization: Bearer $CHIO_SERVICE_TOKEN" \
-H 'Content-Type: application/json' \
-d @issue-request.json | tee capability.json | jq .capability.scope{
"grants": [
{
"server_id": "http-sidecar-client",
"tool_name": "hello_trust_control_invoke",
"operations": [
"invoke"
]
}
]
}Get the casing wrong on the outer keys and the service says so rather than issuing something narrower than you asked for:
$ curl -sS -w '\nHTTP %{http_code}\n' -X POST $CHIO_CONTROL_URL/v1/capabilities/issue \
-H "Authorization: Bearer $CHIO_SERVICE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subject_pk":"00...00","scope":{...},"ttl":3600}'Failed to deserialize the JSON body into the target type: missing field `subjectPublicKey` at line 1 column 154 HTTP 422
The capability ID is parsed out of the response for the next steps:
CAPABILITY_ID="$(python3 - "${ARTIFACT_ROOT}/capability.json" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload["capability"]["id"])
PY
)"Phase 3: Query Status
chio trust status does not fetch the capability. There is no per-id capability route: the only two capability paths are /v1/capabilities/issue and /v1/federation/capabilities/issue. The command calls list_revocations, which is GET /v1/revocations filtered to that id (crates/products/chio-cli/src/cli/runtime.rs:1508-1526, service_types/paths.rs:124). So "status" here means "is there a revocation record", which is why the payload is three keys rather than a capability. The smoke asserts that the freshly issued capability is not yet revoked:
"${CHIO_BIN}" \
--control-url "${CONTROL_URL}" \
--control-token "${SERVICE_TOKEN}" \
trust status \
--capability-id "${CAPABILITY_ID}" \
--json \
> "${ARTIFACT_ROOT}/status-before.json"Phase 4: Revoke
chio trust revoke writes an idempotent revocation record. The first call returns {"revoked": true, "newly_revoked": true}; subsequent calls return "newly_revoked": false. The smoke asserts the first-call shape and re-queries status to confirm the revocation is now visible:
"${CHIO_BIN}" \
--control-url "${CONTROL_URL}" \
--control-token "${SERVICE_TOKEN}" \
trust revoke \
--capability-id "${CAPABILITY_ID}" \
--json \
> "${ARTIFACT_ROOT}/revoke.json"
"${CHIO_BIN}" \
--control-url "${CONTROL_URL}" \
--control-token "${SERVICE_TOKEN}" \
trust status \
--capability-id "${CAPABILITY_ID}" \
--json \
> "${ARTIFACT_ROOT}/status-after.json"The three payloads are checked afterwards, by verify_artifacts.py rather than inside the shell script:
require(status_before.get("revoked") is False, "status-before.json must be unrevoked")
require(revoke.get("revoked") is True, "revoke.json must report revoked")
require(revoke.get("newly_revoked") is True, "revoke.json must report a new revocation")
require(status_after.get("revoked") is True, "status-after.json must be revoked")The three payloads from one run, in the order the smoke writes them. Same capability id throughout, and revocation_backend naming the trust-control instance that holds the record:
$ chio --control-url $CHIO_CONTROL_URL --control-token $CHIO_SERVICE_TOKEN \
trust status --capability-id "$CAPABILITY_ID" --json{
"capability_id": "cap-01a07176-d877-7391-9c08-fda97d1baadc",
"revocation_backend": "http://127.0.0.1:43997",
"revoked": false
}$ chio --control-url $CHIO_CONTROL_URL --control-token $CHIO_SERVICE_TOKEN \
trust revoke --capability-id "$CAPABILITY_ID" --json{
"capability_id": "cap-01a07176-d877-7391-9c08-fda97d1baadc",
"newly_revoked": true,
"revocation_backend": "http://127.0.0.1:43997",
"revoked": true
}$ chio --control-url $CHIO_CONTROL_URL --control-token $CHIO_SERVICE_TOKEN \
trust status --capability-id "$CAPABILITY_ID" --json{
"capability_id": "cap-01a07176-d877-7391-9c08-fda97d1baadc",
"revocation_backend": "http://127.0.0.1:43997",
"revoked": true
}The port and the capability id come from that run. The port is the free one the run picked; the id is a cap- prefix over a UUID minted at issuance. A second trust revoke on the same id returns the same body with "newly_revoked": false, which is what makes the call idempotent.
Revocation checks
Phase 5: Mint a Receipt
Receipts are minted by running an evaluation. The example uses chio check, which runs the same kernel pipeline against a CLI-supplied tool name and params, signs the verdict, and persists it to a receipt sqlite store of your choosing.
"${CHIO_BIN}" check \
--policy "${EXAMPLE_ROOT}/policy.yaml" \
--tool read_file \
--params '{"path":"README.md"}' \
--receipt-db "${CHECK_RECEIPT_DB}" \
--json \
> "${ARTIFACT_ROOT}/check.json"As written that command now fails. Durable admission wants a session database alongside the receipt one, and refuses rather than running without it:
$ chio check \
--policy examples/hello-trust-control/policy.yaml \
--tool read_file \
--params '{"path":"README.md"}' \
--receipt-db ./receipts.sqlite3 \
--json{"code":"urn:chio:error:cli:other","message":"durable admission mode requires a database so operations and tool outcomes survive restart","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."}Add --session-db and the same call succeeds:
$ chio check \
--policy examples/hello-trust-control/policy.yaml \
--tool read_file \
--params '{"path":"README.md"}' \
--receipt-db ./receipts.sqlite3 \
--session-db ./sessions.sqlite3 \
--json{
"check_mode": "preflight",
"output_fixture": false,
"params": {
"path": "README.md"
},
"policy_hash": "38713f65fb71ee63932d7c7e75e97b3175ee37da4dadfc3137234cd6d672dd7d",
"policy_source_hash": "a05ecfd7869eabd59a143cc9cca316663076dd43d1e16586da71f592bf9456c6",
"reason": null,
"receipt_id": "3b37aa0284e8cf921ef8eb36cc9950f55b5318279d22f78e9f08f4f26e7a82b4",
"server": "*",
"tool": "read_file",
"verdict": "ALLOW"
}The policy is intentionally narrow: deny by default, allow only the read_file tool.
hushspec: "0.1.0"
name: hello-trust-control
description: Allow one narrow read-style tool so a receipt can be minted without an app surface.
rules:
tool_access:
enabled: true
default: block
allow:
- read_fileThe receipt fields are checked downstream: tool (read_file), server (the wildcard "*" stub), verdict (ALLOW), the receipt id, policy_hash, and policy_source_hash.
The smoke then dumps the check receipt store to receipts.ndjson so the later inspection step and verify_artifacts.py have a file to read. The listing fails closed the same way the export does: without --tenant or --admin-all it refuses with --tenant <id> or --admin-all is required for local receipt reads rather than showing one tenant's slice as if it were the store:
"${CHIO_BIN}" receipt \
--receipt-db "${CHECK_RECEIPT_DB}" \
list \
--admin-all \
--limit 20 \
> "${ARTIFACT_ROOT}/receipts.ndjson"Phase 5b: A Call the Policy Refuses
The policy allows one tool. Ask for a second one and the same pipeline that minted the allow mints a refusal. Run it from the workspace root. The two SQLite paths need a private directory: chio check refuses when an ancestor of either is group- or world-writable without the sticky bit, with private directory ancestry must not be group or world writable unless sticky, so a shared scratch directory on a umask-002 host will not do.
chio check \
--policy examples/hello-trust-control/policy.yaml \
--tool write_file \
--params '{"path":"/etc/passwd"}' \
--receipt-db ./receipts.sqlite3 \
--session-db ./sessions.sqlite3 \
--json$ chio check \
--policy examples/hello-trust-control/policy.yaml \
--tool write_file \
--params '{"path":"/etc/passwd"}' \
--receipt-db ./receipts.sqlite3 \
--session-db ./sessions.sqlite3 \
--json{
"check_mode": "preflight",
"output_fixture": false,
"params": {
"path": "/etc/passwd"
},
"policy_hash": "38713f65fb71ee63932d7c7e75e97b3175ee37da4dadfc3137234cd6d672dd7d",
"policy_source_hash": "a05ecfd7869eabd59a143cc9cca316663076dd43d1e16586da71f592bf9456c6",
"reason": "requested tool write_file on server * is not in capability scope",
"receipt_id": "1c2ded01926db18899233582e4731e097ffb87343dae395e513d1a052fd24156",
"server": "*",
"tool": "write_file",
"verdict": "DENY"
}WARN chio_kernel::kernel::evaluation::async_evaluation_core message=capability rejected request_id=check-001 reason=requested tool write_file on server * is not in capability scope
The process exits 2 on a DENY and 0 on an ALLOW, so a shell can branch on the verdict without parsing JSON. Both hashes are functions of the policy file, so they match the ones on the allowed read_file call: one policy, two verdicts. The receipt_id is minted per call, so yours differs from the one above.
The refusal is a receipt, not just a message. It lands in the same store as the allow and reads back with the guard that made the call:
chio receipt --receipt-db ./receipts.sqlite3 list --admin-all --limit 20 \
| jq -c 'select(.decision.verdict=="deny")
| {id, tool_name, decision, receipt_kind, boundary_class}'$ chio receipt --receipt-db ./receipts.sqlite3 list --admin-all --limit 20 \
| jq -c 'select(.decision.verdict=="deny")
$ | {id, tool_name, decision, receipt_kind, boundary_class}'{"id":"1c2ded01926db18899233582e4731e097ffb87343dae395e513d1a052fd24156","tool_name":"write_file","decision":{"verdict":"deny","reason":"requested tool write_file on server * is not in capability scope","guard":"kernel"},"receipt_kind":"mediated_decision","boundary_class":"prevent"}guard is kernel, the value the kernel stamps on its own refusals (crates/kernel/chio-kernel/src/kernel/responses/deny_responses.rs). The reason string is the display form of the scope error at crates/kernel/chio-kernel/src/kernel/error.rs. boundary_class is prevent, the strongest of the four values in crates/core/chio-core-types/src/receipt/kinds.rs (prevent, detect_only, advisory_only, cannot_see), which record what Chio can enforce on the call the receipt describes. Feed this store to chio evidence export and the denial travels with the allows.
Phase 6: Export + Verify Offline
chio evidence export bundles the receipt store into an offline package: receipts as NDJSON, capability lineage, child receipts, inclusion proofs, a manifest with SHA-256 hashes, and a retention record. The reading path fails closed when neither --tenant nor --admin-all is supplied, so this single-tenant demo passes --admin-all to surface the receipt. The example then runs chio evidence verify against the package without contacting trust-control:
"${CHIO_BIN}" evidence export \
--receipt-db "${CHECK_RECEIPT_DB}" \
--admin-all \
--output "${EVIDENCE_DIR}"
"${CHIO_BIN}" evidence verify \
--input "${EVIDENCE_DIR}" \
--json \
> "${ARTIFACT_ROOT}/verify.json"Running those two against the store the earlier phases wrote, the export names the thirteen files it produced:
$ chio evidence export \
--receipt-db ./receipts.sqlite3 \
--admin-all \
--output ./evidence
$ ls evidenceREADME.txt capability-lineage.ndjson checkpoint-consistency-proofs.ndjson checkpoint-equivocations.ndjson checkpoint-publications.ndjson checkpoint-witnesses.ndjson checkpoints.ndjson child-receipts.ndjson inclusion-proofs.ndjson manifest.json query.json receipts.ndjson retention.json
And the verify reads the package back with no network and no trust-control:
$ chio evidence verify --input ./evidence --json{
"schema": "chio.evidence_export_manifest.v1",
"verifiedAt": 1788610012,
"toolReceipts": 2,
"childReceipts": 0,
"checkpoints": 0,
"checkpointPublications": 0,
"checkpointWitnesses": 0,
"checkpointConsistencyProofs": 0,
"checkpointEquivocations": 0,
"capabilityLineage": 2,
"inclusionProofs": 0,
"uncheckpointedReceipts": 2,
"receiptSemantics": {
"mediatedDecisions": 2,
"traceObservations": 0,
"advisoryEvaluations": 0,
"prevent": 2,
"detectOnly": 0,
"advisoryOnly": 0,
"cannotSee": 0,
"authorized": 1
},
"verifiedFiles": 12,
"childReceiptScope": "full_query_window",
"claimBoundary": {
"schema": "chio.evidence_transparency_claims.v1",
"publicationState": "transparency_preview",
"audit": {
"checkpointLogs": [],
"signedCheckpoints": 0,
"checkpointPublications": 0,
"checkpointWitnesses": 0,
"checkpointConsistencyProofs": 0,
"inclusionProofs": 0,
"capabilityLineageRecords": 2
}
}
}toolReceipts: 2 is the allow and the refusal from Phases 5 and 5b, and publicationState is transparency_preview because nothing here was checkpointed to a log. Drop the read boundary and the export refuses rather than quietly exporting one tenant's view as if it were everything:
$ chio evidence export --receipt-db ./receipts.sqlite3 --output ./evidence-unscopederror [urn:chio:error:attest:provenance-missing]: receipt read boundary error: evidence export requires an explicit receipt read boundary
context: {"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"}
suggested fix: Regenerate the evidence bundle and include provenance before submitting the operation.Inspect the Run Output
cd .artifacts/<timestamp>
# Capability JSON and the wire-format token
cat capability.json
cat capability.token
# Status before and after revocation
cat status-before.json # revoked: false
cat revoke.json # revoked: true, newly_revoked: true
cat status-after.json # revoked: true
# Minted receipt and evidence
cat check.json
cat receipts.ndjson
ls evidence/
cat evidence/manifest.json
cat verify.json
cat summary.json
# Persistent state
ls state/
# trust-receipts.sqlite3 receipts ingested by trust-control
# trust-revocations.sqlite3 revocation table
# trust-authority.sqlite3 authority signing seed + history
# trust-budgets.sqlite3 (unused in this example, present for parity)
# check-receipts.sqlite3 receipts written by chio checkManual Experimentation
run-trust.sh starts trust-control alone on 127.0.0.1:8051 with state under .artifacts/manual-state/. Once running you can drive the same flow by hand:
# Terminal 1: start trust-control
./run-trust.sh
# Terminal 2: drive the flow
export CHIO_CONTROL_URL=http://127.0.0.1:8051
export CHIO_SERVICE_TOKEN=demo-token
# Issue. The outer keys are camelCase; snake_case gets a 422.
curl -sS -H "Authorization: Bearer $CHIO_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d @issue-request.json \
$CHIO_CONTROL_URL/v1/capabilities/issue | jq
# Status
chio --control-url $CHIO_CONTROL_URL --control-token $CHIO_SERVICE_TOKEN \
trust status --capability-id <id> --json | jq
# Revoke
chio --control-url $CHIO_CONTROL_URL --control-token $CHIO_SERVICE_TOKEN \
trust revoke --capability-id <id> --json | jqBounded staleness on the kernel side
Smoke Assertions
smoke.sh drives the phases and captures each output; verify_artifacts.py --write-summary contains the assertions, and test_verify_artifacts.py unit-tests the checker. The checks go past shape into exact scoping and identity:
Exact scoping first: one grant, on the demo server id, for the demo tool, with invoke and nothing else. Then the materialized token is required to be compact JSON of the issued capability, byte for byte.
scope = capability.get("scope")
require(isinstance(scope, dict), "capability scope must be an object")
grants = scope.get("grants")
require(isinstance(grants, list) and len(grants) == 1, "capability must carry one grant")
grant = grants[0]
require(isinstance(grant, dict), "capability grant must be an object")
require(
grant.get("server_id") == "http-sidecar-client",
"capability grant must stay scoped to the demo server id",
)
require(
grant.get("tool_name") == "hello_trust_control_invoke",
"capability grant must stay scoped to the trust-control demo tool",
)
require(grant.get("operations") == ["invoke"], "capability grant must allow invoke only")
token = (root / "capability.token").read_text(encoding="utf-8")
expected_token = json.dumps(capability, separators=(",", ":")) + "\n"
require(token == expected_token, "capability.token must be compact issued capability JSON")Then identity on the minted receipt: server stays the wildcard stub, the verdict is ALLOW, the params round-trip, and all three of receipt_id, policy_hash and policy_source_hash are present and are strings.
check = load_json(root / "check.json")
require(check.get("tool") == "read_file", "check.json.tool must be read_file")
require(check.get("server") == "*", "check.json.server must stay the wildcard stub")
require(check.get("verdict") == "ALLOW", "check.json.verdict must be ALLOW")
require(check.get("params") == {"path": "README.md"}, "check.json.params drifted")
check_receipt_id = require_string(check, "receipt_id", "check.json")
check_policy_hash = require_string(check, "policy_hash", "check.json")
require_string(check, "policy_source_hash", "check.json")Decision rule
Where to read more