Chio/Docs

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 aloneLambda + Chio
IAM role-based authorization on the functionCapability-scoped, time-bounded, per-tool authorization per invocation
CloudWatch logs with structured fieldsMerkle-committed, signed receipt log independent of CloudWatch
No tool-level policy APIGuard pipeline evaluates each invocation with evidence
Binary allow or deny semantics at the gatewayBudget-aware, scope-narrowing, conditional access
No cross-invocation audit trailReceipt 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.

rendering…
Function handler and Chio extension run as co-processes in the Lambda execution environment, communicating over internal HTTP.

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

The extension buffers receipts in memory during invocations and flushes on SHUTDOWN because Lambda guarantees the extension a draining window before the environment goes away. The buffer also flushes when it fills, so exposure between flushes stays bounded even for long-lived warm environments.

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.

VariableRequiredDefaultMeaning
CHIO_RECEIPT_TABLEyesnoneDynamoDB table receipts are flushed into. Absent, the extension fails closed and exits non-zero.
CHIO_EXTENSION_ADDRno127.0.0.1:9090Local socket address for the evaluator.
RUST_LOGnochio_lambda_extension=infoTracing filter.

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

text
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

Today the evaluator deterministically denies only when 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.

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

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

yaml
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

IAM 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:

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

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

yaml
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

text
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_tool

Open Questions

  • Policy evaluation. Today the evaluator is a stub that denies only on missing capability_id / tool_name. Wiring the chio-kernel dependency 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 WorkflowGrant model?

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