Chio/Docs
LOGIN · JOIN

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

SHUTDOWN is the only flush. The INVOKE arm of the lifecycle loop logs the request id and does nothing else (src/lifecycle.rs:190-196); when the SHUTDOWN event arrives the hook drains the buffer and issues BatchWriteItem in chunks of 25. Unprocessed or throttled items are retried up to five times with backoff at 100, 200, 400, 800 and 1600 milliseconds, and the whole hook is wrapped in a 1.5 second timeout so the extension returns to the Runtime API before Lambda force-kills it (src/lifecycle.rs:206-210, src/dynamodb_flush.rs:43-46,121-153,210-215).

The buffer is bounded, and overflow is dropped

Receipts go into a bounded channel of RECEIPT_BUFFER_CAPACITY = 1024 and are written with try_send. There is no buffer-full flush. When the channel is full the evaluator still answers the caller, logs receipt buffer full or closed; receipt dropped, and the receipt is gone (src/main.rs:419-425). A warm environment that serves more than 1024 evaluations between one cold start and its SHUTDOWN loses the excess. Size the expected invocations-per-environment against that ceiling before treating the DynamoDB table as a complete log.

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_TRUSTED_ISSUERSyesnoneJSON array of hex-encoded issuer public keys. This is the whole issuer set: the evaluator reads it from the deployment environment and from nowhere else. Absent, every evaluation denies with missing CHIO_TRUSTED_ISSUERS deployment configuration.
CHIO_CAPABILITY_TOKENS_JSONconditionalnoneJSON object mapping capability id to capability token. The evaluator reads it only when the request carries no inline capability. Absent in that case, the evaluation denies with missing CHIO_CAPABILITY_TOKENS_JSON and no inline capability token.
CHIO_EXTENSION_ADDRno127.0.0.1:9090Local socket address for the evaluator.
RUST_LOGnochio_lambda_extension=info,warnTracing filter.

The HTTP API has a health probe and one evaluation endpoint. It is plain JSON over HTTP/1.1 on loopback, so a handler in any runtime can call it with its standard library and no Chio dependency:

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

Every request field defaults to empty, so a caller sends only what it has. The capability may travel 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 off it. trusted_issuers is accepted for wire compatibility and ignored: the issuer set comes from CHIO_TRUSTED_ISSUERS in the deployment environment and from nowhere else.

json
{
  "capability_id": "cap-01HW...",
  "tool_server":   "tools.example",
  "tool_name":     "database-query",
  "scope":         "db:read",
  "arguments":     { "sql": "SELECT 1" }
}

The response is the same shape on allow and on deny. Only decision and reason move, which is what makes the caller's branch trivial to write in a runtime with no client library:

json
{
  "receipt_id":     "01908f4a-...",
  "decision":       "deny",
  "authorized":     false,
  "authoritative":  false,
  "receipt_kind":   "trace_observation",
  "boundary_class": "detect_only",
  "reason":         "missing tool_name",
  "metadata":       null,
  "capability_id":  "cap-01HW...",
  "tool_server":    "tools.example",
  "tool_name":      "",
  "timestamp":      1713225600
}

The deny reasons come out of evaluate_request in this order:

  1. missing tool_server
  2. missing tool_name
  3. A capability-resolution failure. This branch sits before the capability_id checks and its reason is formatted, not fixed: 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 will not parse, and the bare missing trusted_issuers when it parses to an empty array
  7. Whatever reason capability evaluation itself returns, falling back to capability evaluation denied the request

The extension is detect-only, so no verdict it returns is an allow

Every response carries authoritative: false, authorized: false, receipt_kind: "trace_observation", and boundary_class: "detect_only" as literals (src/main.rs:392-405). The extension resolves the capability token, checks it against the issuer set in CHIO_TRUSTED_ISSUERS, and writes a trace receipt; it runs no guards and it authorizes nothing.

That has a consequence worth stating before the client examples below. EvaluateVerdict.allowed (sdks/lambda/chio-lambda-python/src/chio_lambda/client.py:57-66) is true only when decision == "allow" and authorized and authoritative and receipt_kind == "mediated_decision" and boundary_class == "prevent", and denied is its negation. Against the shipped extension the last four are never satisfied, so denied is always true. Read the examples below as the shape of the fail-closed branch, not as a gate that can open: the extension observes, and an authoritative decision point in front of the function is what allows. The Python client is written that way on purpose, so a caller that later points it at a real mediating kernel keeps the same code.


Using the Extension from Python

The companion client is chio-lambda-python, which imports as chio_lambda. 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.

sdks/lambda/chio-lambda-python/README.md:13bash
pip install chio-lambda-python
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, which the detect-only
    # extension never returns. 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.zip
      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, chio-sdk-python
    src/chio_lambda/client.py       # ChioLambdaClient, EvaluateVerdict, ChioLambdaError
    src/chio_lambda/decorators.py   # @chio_tool

Open Questions

  • Guards and budgets. The evaluator resolves the capability token, checks it against the deployment issuer set, and runs evaluate_capability with an empty guard set and no metering. Scope narrowing, budget reservation, and the guard pipeline belong to the authoritative decision point in front of the function, which is why every verdict here is detect_only. Whether a co-process that cannot hold budget state across a recycled environment should ever carry them is the open question.
  • 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