AWS Lambda
Evaluate each governed operation in a Lambda handler before its body runs, and keep every decision in a DynamoDB receipt table.
On this page
Each governed operation in a Lambda handler gets a kernel decision before the function body touches a database, an API, or a file. A refused operation never runs, and allowed or refused, the decision lands in a DynamoDB table as a signed receipt.
Chio runs as a Lambda extension: a Rust co-process, shipped as a layer, inside the same execution environment as your handler. The handler sends each operation to POST /v1/evaluate on 127.0.0.1:9090 and branches on the verdict. IAM keeps deciding what the function may do in AWS; the kernel decides whether this caller's capability covers this tool and scope for this invocation.
r1INVOKE 1r2INVOKE 2r3INVOKE 3
Evaluation runs before the tool body
The handler sends each governed operation to POST /v1/evaluate on 127.0.0.1:9090, through ChioLambdaClient or the @chio_tool decorator. Only an allow runs the body, and the extension beside the handler records the decision. The mediated call.
The check runs on loopback beside the handler, so a warm invocation adds no network hop. Refusals get receipts as well, and the table holds each refused operation beside the ones that ran.
1. Install the client in your function
The companion client is chio-lambda-python, which imports as chio_lambda. It is a thin synchronous client built on httpx, since Lambda handlers are usually synchronous, and it depends on the Chio Python SDK.
Before you start
Python 3.11+ and pip in a macOS or Linux shell. Run the commands from your project directory.
Create a Python environment
Terminal · your projectpython3 -m venv .venv source .venv/bin/activateInstall the SDK and the adapter
Terminal · your project, with .venv activepython -m pip install chio-sdk chio-lambda-pythonCheck the import
Terminal · the same Python environmentpython -c 'from chio_lambda import ChioLambdaClient; print("Lambda adapter ready")'Expected checkpointLambda adapter ready
The client imports. A handler using ChioLambdaClient sends each governed operation to the Chio extension before the body runs; a refusal raises ChioLambdaError.
You should see Lambda adapter ready. Package the client with your function code as you package any other dependency.
2. Publish the extension layer for your architecture
Each Chio release ships the extension as a Lambda layer archive per architecture. Download the one for the function's architecture, verify it, and publish it as a layer version from Larkfield's artifact bucket.
curl -fsSLO https://github.com/backbay-labs/chio/releases/download/v0.2.0/chio-lambda-extension-0.2.0-arm64.zip
chio release verify chio-lambda-extension-0.2.0-arm64.zip
aws s3 cp chio-lambda-extension-0.2.0-arm64.zip s3://larkfield-artifacts/
aws lambda publish-layer-version --layer-name chio-extension \
--content S3Bucket=larkfield-artifacts,S3Key=chio-lambda-extension-0.2.0-arm64.zip \
--compatible-architectures arm64archive chio-lambda-extension-0.2.0-arm64.zip checksum ok
signed by release.yml on backbay-labs/chio, issuer token.actions.githubusercontent.com
provenance SLSA v1, tag v0.2.0, subject present
verified
upload: ./chio-lambda-extension-0.2.0-arm64.zip to s3://larkfield-artifacts/chio-lambda-extension-0.2.0-arm64.zip
{
"LayerArn": "arn:aws:lambda:us-east-1:111122223333:layer:chio-extension",
"LayerVersionArn": "arn:aws:lambda:us-east-1:111122223333:layer:chio-extension:1",
"Version": 1,
"CompatibleArchitectures": [
"arm64"
]
}You should see the archive verified against the release signature, then the new layer version. The archive places the binary at extensions/chio, where Lambda starts registered extensions. An x86_64 function takes chio-lambda-extension-0.2.0-x86_64.zip instead; an arm64 binary serves arm64 functions only.
3. Deploy the layer, the receipt table, and the function
Declare the layer, a DynamoDB table keyed on receipt_id and timestamp, and your function with CHIO_RECEIPT_TABLE pointing at the table. The extension writes to the table and never creates it, so provision it here.
Resources:
ChioExtensionLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: chio-kernel-extension
ContentUri: chio-lambda-extension-0.2.0-arm64.zip
CompatibleRuntimes:
- python3.11
- python3.12
- python3.13
- nodejs22.x
CompatibleArchitectures:
- arm64
ReceiptTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: receipt_id
AttributeType: S
- AttributeName: timestamp
AttributeType: N
KeySchema:
- AttributeName: receipt_id
KeyType: HASH # partition key
- AttributeName: timestamp
KeyType: RANGE # sort key
ToolFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
Runtime: python3.13
Architectures: [arm64]
Layers:
- !Ref ChioExtensionLayer
Environment:
Variables:
CHIO_RECEIPT_TABLE: !Ref ReceiptTable
CHIO_TRUSTED_ISSUERS: '["<issuer-public-key-hex>"]'
Policies:
- DynamoDBCrudPolicy: { TableName: !Ref ReceiptTable }Deploy with sam deploy. The extension shares the function's execution role, so its writes run as the function's IAM identity with no separate credential or secret. Set CHIO_TRUSTED_ISSUERS to the hex public keys of the issuers your capabilities come from; the extension takes its issuer set from the deployment environment and from nowhere else.
4. Evaluate each governed operation in the handler
Ask the extension for a verdict before the operation, and return early on a refusal.
import json
from chio_lambda import ChioLambdaClient
client = ChioLambdaClient() # defaults to http://127.0.0.1:9090
def handler(event, context):
verdict = client.evaluate(
capability_id=event["chio_capability_id"],
tool_server="tools.example",
tool_name="database-query",
scope="db:read",
arguments={"sql": event["body"]},
)
if verdict.denied:
return {
"statusCode": 403,
"body": json.dumps({
"error": "capability_denied",
"reason": verdict.reason,
"receipt_id": verdict.receipt_id,
}),
}
result = execute_query(event["body"])
return {
"statusCode": 200,
"body": json.dumps(result),
"headers": {"X-Chio-Receipt": verdict.receipt_id},
}A refused call returns 403 with capability_denied, the reason, and the receipt ID, and execute_query never runs. The client fails closed: an unreachable extension or a malformed response is a refusal too. To keep the check out of the handler body, wrap the handler instead.
from chio_lambda import chio_tool
@chio_tool(
scope="db:read",
tool_server="tools.example",
tool_name="database-query",
)
def handler(event, context, capability_id, verdict):
# Runs only on allow. The decorator injects capability_id and
# verdict when the signature declares them.
return run_query(event["body"])@chio_tool evaluates before the body. A refusal or an unreachable extension raises ChioLambdaError, and the wrapped body never runs.
5. Invoke the function and read its receipt
Invoke the function with a capability ID, then look up the receipt the extension recorded.
aws lambda invoke --function-name "$FUNCTION_NAME" \
--cli-binary-format raw-in-base64-out \
--payload '{"chio_capability_id": "cap-01e4…6a19", "body": "SELECT 1"}' \
out.json && cat out.json{"statusCode": 200, "body": "[[1]]", "headers": {"X-Chio-Receipt": "rcpt-01b4…2e71"}}You should see status 200 and the receipt ID in X-Chio-Receipt. The extension buffers each decision during INVOKE and writes the batch when Lambda shuts the environment down, so the row appears after SHUTDOWN.
aws dynamodb query --table-name "$CHIO_RECEIPT_TABLE" \
--key-condition-expression "receipt_id = :r" \
--expression-attribute-values '{":r": {"S": "rcpt-01b4…2e71"}}'receipt_id rcpt-01b4…2e71
timestamp 1713225600
capability_id cap-01e4…6a19
tool_server tools.example
tool_name database-query
decision allow
payload {"capability_id":"cap-01e4…6a19","decision":"allow",…}You should see one item holding the capability ID, the tool server and tool name, the decision and its reason, and the canonical-JSON payload of the receipt.
Three ways to reach the evaluator
The evaluator is plain JSON over HTTP/1.1 on loopback, so a handler in any runtime can call it.
| Caller | Where the check sits | On refusal |
|---|---|---|
ChioLambdaClient.evaluate | Inside the handler, before the operation | verdict.denied; your code returns 403 |
@chio_tool | Around the handler, before the body | Raises ChioLambdaError |
POST /v1/evaluate | Any runtime, standard library only | decision: "deny" with a reason |
Every request field defaults to empty, so a caller sends only what it has. The response has the same shape on allow and on deny; only decision, authorized, and reason move.
The extension lives as long as the execution environment
An extension starts before the handler, serves every warm invocation from the same process, and gets one last hook when Lambda tears the environment down.
rinvocationsKchio extension 127.0.0.1:9090dfunction handlerdraingrace
The extension registers and binds before the handler
On a cold start the extension registers with the Extensions API for INVOKE and SHUTDOWN and binds its evaluator on CHIO_EXTENSION_ADDR, 127.0.0.1:9090 by default. A missing CHIO_RECEIPT_TABLE, an unreachable Runtime API, or a failed bind exits non-zero during INIT. Extension lifecycle.
At INIT the extension registers with the Extensions API for INVOKE and SHUTDOWN and binds its evaluator on CHIO_EXTENSION_ADDR, 127.0.0.1:9090 by default. A missing CHIO_RECEIPT_TABLE, an unreachable Runtime API, or a failed bind exits non-zero, and table connectivity is first exercised by the flush. The binary is a small Rust build for arm64 and x86_64, and publishing it as a layer avoids a download on each cold start.
SHUTDOWN is the only flush. The hook drains the buffer into DynamoDB with BatchWriteItem in chunks of 25, and retries unprocessed or throttled items with backoff at 100, 200, 400, 800, and 1600 milliseconds. A 1.5-second ceiling on the whole hook returns the extension to the Runtime API inside Lambda's 2-second shutdown limit, and items still unwritten when the ceiling ends the hook stay out of the table.
Extension reference
Try CHIO_TRUSTED_ISSUERS, x-chio-capability, capability_event_key, or SnapStart.
Environment variables
| Variable | Required | Default | Meaning |
|---|---|---|---|
CHIO_ | yes | none | DynamoDB table receipts flush into. Absent, the extension fails closed and exits non-zero. |
CHIO_ | yes | none | JSON array of hex-encoded issuer public keys: the whole issuer set. Absent, every evaluation denies with missing CHIO_. |
CHIO_ | conditional | none | JSON object mapping capability ID to capability token, read only when a request carries no inline capability. |
CHIO_ | no | 127.0.0.1:9090 | Local socket address for the evaluator. |
RUST_LOG | no | chio_ | Tracing filter. |
Evaluator API
The HTTP API has a health probe and one evaluation endpoint.
GET /health
GET /chio/health
-> {"status": "ok", "extension": "chio"}
POST /v1/evaluate
request: {capability_id, capability, tool_server, tool_name,
scope, arguments, metadata, trusted_issuers}
response: {receipt_id, decision, authorized, authoritative,
receipt_kind, boundary_class, reason, metadata,
capability_id, tool_server, tool_name, timestamp}The capability travels in the body as capability or in the x-chio-capability header as JSON; when only the token is sent, the extension reads capability_id from it. trusted_issuers is accepted for wire compatibility and ignored: the issuer set comes from CHIO_TRUSTED_ISSUERS.
{
"capability_id": "cap-01e4…6a19",
"tool_server": "tools.example",
"tool_name": "database-query",
"scope": "db:read",
"arguments": { "sql": "SELECT 1" }
}{
"receipt_id": "rcpt-01b4…2e72",
"decision": "deny",
"authorized": false,
"authoritative": true,
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"reason": "missing tool_name",
"metadata": null,
"capability_id": "cap-01e4…6a19",
"tool_server": "tools.example",
"tool_name": "",
"timestamp": 1713225600
}Refusal reasons
evaluate_request checks in this order and returns the first reason that applies.
missing tool_servermissing tool_name- A capability-resolution failure, checked before
capability_id, with a formatted reason:invalid capability token: ...for a malformed inline token or, when the request carries none,missing CHIO_CAPABILITY_TOKENS_JSON and no inline capability token,invalid CHIO_CAPABILITY_TOKENS_JSON: ...,unknown capability_id ..., orinvalid capability token for ... missing capability_idcapability_id does not match resolved capability token- An issuer-set failure:
missing CHIO_TRUSTED_ISSUERS deployment configurationwhen the variable is unset,invalid CHIO_TRUSTED_ISSUERS: ...orinvalid trusted issuer ...when it does not parse, andmissing trusted_issuerswhen it parses to an empty array - The reason capability evaluation returns, falling back to
capability evaluation denied the request
Python client
EvaluateVerdict.allowed is true only when decision == "allow", authorized and authoritative are true, receipt_kind == "mediated_decision", and boundary_class == "prevent"; denied is its negation, so any decision value the client does not recognize is a refusal. The extension buffers and flushes each receipt itself, so the client has no record() call.
@chio_tool resolves capability_id from an explicit keyword argument, then event["chio_capability_id"] (key set by capability_event_key), then $CHIO_CAPABILITY_ID (variable set by capability_env).
IAM
The extension needs one permission on the receipt table, attached to the function role.
# Permission the chio extension needs (attached to the function role)
- Effect: Allow
Action:
- dynamodb:BatchWriteItem # receipt flush on SHUTDOWN
Resource: !GetAtt ReceiptTable.ArnLifecycle source
The INVOKE arm of the lifecycle loop logs the request ID and does nothing else (src/lifecycle.rs lines 190 to 196). The SHUTDOWN hook and its 1.5-second timeout are at lines 206 to 210; the batch writer and its backoff are in src/dynamodb_flush.rs lines 43 to 46, 121 to 153, and 210 to 215.
Deployment decisions
- SnapStart. Test the extension through snapshot and restore before you enable it, and revalidate connections, expiration times, and any state that must be unique per execution environment, as the AWS SnapStart compatibility guidance describes.
- Multi-function workflows. Scope each function's capability explicitly. Step Functions scheduling transfers no grant between functions, renews no lifetime, and links no receipts on its own.
Package layout
sdks/lambda/
chio-lambda-extension/ # Rust binary, compiles to a Lambda Extension
src/main.rs # extension entry point
src/lifecycle.rs # INVOKE / SHUTDOWN lifecycle loop
src/dynamodb_flush.rs # buffered receipt flush to DynamoDB
chio-lambda-python/ # deps: httpx, chio-sdk
src/chio_lambda/client.py # ChioLambdaClient, EvaluateVerdict, ChioLambdaError
src/chio_lambda/decorators.py # @chio_tool