BuildEvent-driven
AWS Lambda
Run Chio as a Lambda Extension to evaluate serverless tool calls and buffer their signed receipts.
Why Lambda Through Chio
Lambda's native authorization model is IAM: coarse, role-based, and attached to the function, not an invocation. This is useful for permissions the function itself needs to act in AWS, but it does not answer the agent governance question: does this specific caller, with this specific capability token, have scope to invoke this specific tool right now, within their remaining budget, and passing all guards? Chio layers that question on top of IAM without replacing it.
| Lambda alone | Lambda + Chio |
|---|---|
| IAM role-based authorization on the function | Capability-scoped, time-bounded, per-tool authorization per invocation |
| CloudWatch logs with structured fields | Merkle-committed, signed receipt log independent of CloudWatch |
| No tool-level policy API | Guard pipeline evaluates each invocation with evidence |
| Binary allow or deny semantics at the gateway | Budget-aware, scope-narrowing, conditional access |
| No cross-invocation audit trail | Receipt chain links related invocations in a workflow |
The Extension Model
Lambda Extensions run as co-processes in the same execution environment as the function. They start during the environment's INIT phase, receive lifecycle events during every INVOKE, and get one last hook on SHUTDOWN to drain buffered state. An extension does not replace the handler; it runs beside it. Chio uses a co-process that starts on cold start, answers evaluation calls on a localhost HTTP port during invocations, and flushes buffered receipts to DynamoDB before the environment is torn down.
Extension Lifecycle
The extension participates in three lifecycle phases, and each one does a specific job.
INIT
When a cold start fires, the extension registers with the Lambda Extensions Runtime API for the INVOKE and SHUTDOWN events and binds the evaluator on CHIO_EXTENSION_ADDR (default 127.0.0.1:9090). It fails closed: a missing CHIO_RECEIPT_TABLE, an unreachable Runtime API, or an unreachable DynamoDB makes the binary exit non-zero instead of running without a receipt sink. Every subsequent invocation reuses the warm process.
INVOKE
During an invocation, the handler calls POST /v1/evaluate on the local evaluator, directly or through the chio-lambda-python client or its @chio_tool decorator, and acts on the verdict. The extension records the receipt and buffers it for batch persistence.
SHUTDOWN
When Lambda reclaims the execution environment, the extension gets one last window to flush buffered receipts to DynamoDB; it also flushes when the buffer fills. Batched flush avoids per-invocation durability latency while still guaranteeing no receipt is lost when the environment is torn down. Throttled or unprocessed writes are retried with exponential backoff up to five attempts, capped under the ~2 second SHUTDOWN budget.
In-memory buffering is acceptable because of SHUTDOWN
Cold-Start Optimization
Cold start is the dominant latency concern with any Lambda Extension. The extension is a small Rust binary, cross-compiled for arm64 and x86_64. Pre-publishing it as a Lambda layer avoids a per-cold-start download, and because the evaluator listens on loopback, warm-path evaluate calls stay inside the execution environment with no network hop.
Configuration and Evaluator API
The extension reads a small set of environment variables and serves a minimal JSON-over-HTTP evaluator so any language can call it with its standard library alone.
| Variable | Required | Default | Meaning |
|---|---|---|---|
CHIO_RECEIPT_TABLE | yes | none | DynamoDB table receipts are flushed into. Absent, the extension fails closed and exits non-zero. |
CHIO_EXTENSION_ADDR | no | 127.0.0.1:9090 | Local socket address for the evaluator. |
RUST_LOG | no | chio_lambda_extension=info | Tracing filter. |
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, tool_server, tool_name, scope, arguments}
response: {receipt_id, decision, reason, capability_id,
tool_server, tool_name, timestamp}The evaluator is an early-phase stub
capability_id or tool_name is missing; the extension's own README states that more sophisticated policy evaluation is wired in a subsequent phase. The chio-kernel dependency is already pulled in so the expansion is mechanical. Client code should treat any non-authoritative verdict as a deny (the Python client already does).Using the Extension from Python
The companion client is chio-lambda-python. It is a thin, synchronous httpx-based client (Lambda handlers are typically synchronous) and is fail-closed: an unreachable extension, a malformed response, or any non-authoritative verdict all surface as a denial. There is no record() call, the extension buffers and flushes the receipt itself.
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},
}The @chio_tool decorator wraps a handler so evaluation runs before the body. It resolves capability_id from an explicit kwarg, then event["chio_capability_id"] (key configurable via capability_event_key), then $CHIO_CAPABILITY_ID (configurable via capability_env). A deny or an unreachable extension raises ChioLambdaError and the wrapped body never runs.
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):
# Body runs only on an authoritative allow. The decorator injects
# capability_id and verdict when the signature declares them.
return run_query(event["body"])Receipt Persistence
Lambda execution environments are ephemeral, so receipts need a durable home before the environment is recycled. The shipped extension flushes to exactly one sink: DynamoDB. Each item is keyed on receipt_id (partition) and timestamp (sort), and contains capability_id, tool_server, tool_name, decision, reason, and a canonical-JSON payload. The extension does not create the table; provision it in your IaC.
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 keyIAM Integration
The extension shares the function's execution role, so its DynamoDB writes run with the same IAM identity the function has. There is no separate credential pathway and no extra secret to manage. The only permission the extension needs is write access to the receipt table:
# Permission the chio extension needs (attached to the function role)
- Effect: Allow
Action:
- dynamodb:BatchWriteItem # receipt flush on SHUTDOWN / buffer-full
Resource: !GetAtt ReceiptTable.ArnSAM Template
Publish the layer with aws lambda publish-layer-version --layer-name chio-kernel-extension, then reference its ARN, for example arn:aws:lambda:us-east-1:000000000000:layer:chio-kernel-extension:42, and attach it to any function that should be governed.
Resources:
ChioExtensionLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: chio-kernel-extension
ContentUri: dist/chio-extension-arm64/
CompatibleRuntimes:
- python3.11
- python3.12
- python3.13
- nodejs20.x
- nodejs22.x
CompatibleArchitectures:
- arm64
- x86_64
ToolFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
Runtime: python3.13
Architectures: [arm64]
Layers:
- !Ref ChioExtensionLayer
Environment:
Variables:
CHIO_RECEIPT_TABLE: !Ref ReceiptTable
Policies:
- DynamoDBCrudPolicy: { TableName: !Ref ReceiptTable }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
scripts/package-layer.sh
chio-lambda-python/ # deps: httpx
src/chio_lambda/client.py # ChioLambdaClient, EvaluateVerdict, ChioLambdaError
src/chio_lambda/decorators.py # @chio_toolOpen Questions
- Policy evaluation. Today the evaluator is a stub that denies only on missing
capability_id/tool_name. Wiring thechio-kerneldependency for scope, budget, and guard evaluation is the next phase. - Provisioned concurrency. With provisioned concurrency, the extension is always warm. How it should refresh policy once evaluation is wired, on a background timer or a miss-driven basis, is open.
- SnapStart (Java). Lambda SnapStart checkpoints the JVM after INIT. The extension state must be checkpoint-safe: no open sockets, no time-dependent state at checkpoint time.
- Multi-function workflows. For Step Functions orchestrating multiple Lambdas, should each function carry a grant token the orchestrator acquired, similar to the Temporal
WorkflowGrantmodel?
Next Steps
- Envoy ext_authz · front any HTTP service with Chio via the Envoy filter
- Kafka · governance for event-driven Lambda fan-ins via the streaming adapter
- Receipt Dashboard · visualize receipts flushed from the DynamoDB receipt table