Chio/Docs
LOGIN · JOIN

AWS Lambda

Evaluate each governed operation in a Lambda handler before its body runs, and keep every decision in a DynamoDB receipt table.

Guide chio@5f63e6e

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.

Start
Lambda through the kernelChoose an outcome.

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.

Invocations served by one warm execution environment, each evaluated before its tool body runs. The sequence of outcomes is illustrative. INIT, cold starts, and the function's IAM role are left out.

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.

  1. Create a Python environment
    Terminal · your project
    python3 -m venv .venv
    source .venv/bin/activate
  2. Install the SDK and the adapter
    Terminal · your project, with .venv active
    python -m pip install chio-sdk chio-lambda-python
  3. Check the import
    Terminal · the same Python environment
    python -c 'from chio_lambda import ChioLambdaClient; print("Lambda adapter ready")'
    Expected checkpoint

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

Terminalbash
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 arm64
Output
archive    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.

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

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

handler.py · decoratorpython
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.

Terminalbash
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
Output · illustrative IDs
{"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.

Terminalbash
aws dynamodb query --table-name "$CHIO_RECEIPT_TABLE" \
  --key-condition-expression "receipt_id = :r" \
  --expression-attribute-values '{":r": {"S": "rcpt-01b4…2e71"}}'
Output · one item, attributes flattened; values illustrative
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.

CallerWhere the check sitsOn refusal
ChioLambdaClient.evaluateInside the handler, before the operationverdict.denied; your code returns 403
@chio_toolAround the handler, before the bodyRaises ChioLambdaError
POST /v1/evaluateAny runtime, standard library onlydecision: "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.

Deployment ordering · AWS LambdaChoose a phase.

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.

One Lambda execution environment on a time axis: INIT and invocations drawn in order, the SHUTDOWN phase to scale in seconds. Layers, IAM, and the table schema are left out.

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

Environment variables

VariableRequiredDefaultMeaning
CHIO_RECEIPT_TABLE
yes
none
DynamoDB table receipts flush into. Absent, the extension fails closed and exits non-zero.
CHIO_TRUSTED_ISSUERS
yes
none
JSON array of hex-encoded issuer public keys: the whole issuer set. Absent, every evaluation denies with missing CHIO_TRUSTED_ISSUERS deployment configuration.
CHIO_CAPABILITY_TOKENS_JSON
conditional
none
JSON object mapping capability ID to capability token, read only when a request carries no inline capability.
CHIO_EXTENSION_ADDR
no
127.0.0.1:9090
Local socket address for the evaluator.
RUST_LOG
no
chio_lambda_extension=info,warn
Tracing filter.

Evaluator API

The HTTP API has a health probe and one evaluation endpoint.

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

Request · values illustrativejson
{
  "capability_id": "cap-01e4…6a19",
  "tool_server":   "tools.example",
  "tool_name":     "database-query",
  "scope":         "db:read",
  "arguments":     { "sql": "SELECT 1" }
}
Response · a refusal; values illustrativejson
{
  "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.

  1. missing tool_server
  2. missing tool_name
  3. 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 ..., or invalid capability token for ...
  4. missing capability_id
  5. capability_id does not match resolved capability token
  6. An issuer-set failure: missing CHIO_TRUSTED_ISSUERS deployment configuration when the variable is unset, invalid CHIO_TRUSTED_ISSUERS: ... or invalid trusted issuer ... when it does not parse, and missing trusted_issuers when it parses to an empty array
  7. 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.

template.yaml · function policyyaml
# Permission the chio extension needs (attached to the function role)
- Effect: Allow
  Action:
    - dynamodb:BatchWriteItem     # receipt flush on SHUTDOWN
  Resource: !GetAtt ReceiptTable.Arn

Lifecycle 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/lambdatext
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

Next: govern the events that trigger your functions