Policy Engine

September 15, 2026 · View on GitHub

How transaction policies are defined, evaluated, and enforced before any key material is touched.

Access Model

sign_transaction(wallet, chain, tx, credential)
                                       │
                          ┌────────────┴────────────┐
                          │                          │
                     passphrase                 ows_key_...
                          │                          │
                     owner mode                 agent mode
                     no policy                  policies enforced
                     scrypt decrypt             HKDF decrypt
CallerAuthenticationPolicy Evaluation
OwnerPassphraseNone. Full access to all wallets.
Agentows_key_... tokenAll policies attached to the API key are evaluated. Every policy must allow (AND semantics).

The credential itself determines the access tier. No bypass flags. The owner uses the passphrase; agents use tokens. Different agents get different tokens with different policies.

If the owner wants policy-constrained access for themselves, they create an API key and use the token instead of the passphrase.

API Key Cryptography

Token-as-capability

When the owner creates an API key, OWS decrypts the wallet secret using the owner's passphrase and re-encrypts it under a key derived from the API token. The encrypted copy is stored in the API key file. The agent presents the token with each signing request; the token serves as both authentication and decryption capability.

Key derivation (HKDF-SHA256)

API tokens are 256-bit random values (ows_key_<64 hex chars>). HKDF-SHA256 is used to derive the encryption key:

token = ows_key_<random 256 bits, hex-encoded>
salt  = random 32 bytes (stored in CryptoEnvelope)
prk   = HKDF-Extract(salt, token)
key   = HKDF-Expand(prk, "ows-api-key-v1", 32)  →  AES-256-GCM key

The CryptoEnvelope struct is reused with a new KDF identifier:

{
  "cipher": "aes-256-gcm",
  "cipherparams": { "iv": "..." },
  "ciphertext": "...",
  "auth_tag": "...",
  "kdf": "hkdf-sha256",
  "kdfparams": { "dklen": 32, "salt": "...", "info": "ows-api-key-v1" }
}

Key creation flow

ows key create --name "claude-agent" --wallet agent-treasury --policy spending-limit
  1. Owner enters wallet passphrase
  2. OWS decrypts the wallet secret using scrypt(passphrase)
  3. Generates random token: T = "ows_key_" + hex(random 256 bits)
  4. Generates random salt S
  5. Derives key: K = HKDF-SHA256(S, T, "ows-api-key-v1", 32)
  6. Encrypts the wallet secret with K via AES-256-GCM
  7. Stores key file with token_hash: SHA256(T), policy IDs, and encrypted secret copy
  8. Displays T once — owner provisions it to the agent
  9. Zeroizes the decrypted secret from memory

Agent signing flow

Agent calls: sign_transaction(wallet, chain, tx, "ows_key_a1b2c3...")

1. Detect ows_key_ prefix → agent mode
2. SHA256(token) → look up API key file
3. Check expires_at (if set)
4. Verify wallet is in key's wallet_ids scope
5. Load policies from key's policy_ids
6. Build `PolicyContext` (chain ID, wallet ID, API key ID, transaction context, spending context, timestamp)
7. Evaluate all policies (AND semantics, short-circuit on first deny)
8. If denied → return POLICY_DENIED error (key material never touched)
9. HKDF-SHA256(salt, token) → AES key → decrypt secret from key.wallet_secrets
10. Resolve the chain-specific signing key from that secret (HD derivation for mnemonic wallets, direct curve-key selection for private-key wallets)
11. Sign transaction
12. Zeroize decrypted secret and derived key
13. Return signature

Revocation

Delete the API key file. The encrypted secret copy is gone. SHA256(T) matches nothing. The token is useless. The original wallet and other API keys are unaffected.

Security properties

ThreatMitigation
Token stolen, no disk accessUseless — encrypted key file not accessible
Disk access, no tokenCan't decrypt — HKDF + AES-256-GCM
Token + disk accessCan decrypt, but requires bypassing OWS process entirely
Owner passphrase changedAPI keys unaffected (independently encrypted)
API key revokedEncrypted copy deleted — token decrypts nothing
Multiple API keysIndependent encrypted copies; revoking one doesn't affect others

Declarative Policy Rules

These rule types are evaluated in-process (microseconds, no subprocess). Per-transaction value caps, recipient allowlists, and cumulative daily spend are not implemented as declarative rules; use an executable policy (see below) if you need that level of control.

allowed_chains

Restricts which CAIP-2 chain IDs can be signed for.

{ "type": "allowed_chains", "chain_ids": ["eip155:8453", "eip155:84532"] }

expires_at

Time-bound access (compares PolicyContext.timestamp to this ISO-8601 string).

{ "type": "expires_at", "timestamp": "2026-04-01T00:00:00Z" }

allowed_typed_data_contracts

Restricts which smart contracts an API key can sign EIP-712 typed data for. The rule checks the domain.verifyingContract field of the typed data against an allowlist of addresses.

{
  "type": "allowed_typed_data_contracts",
  "contracts": ["0x000000000022D473030F116dDEE9F6B43aC78BA3"]
}

Behavior:

  • For sign_message and sign_transaction calls, this rule passes through (does not restrict).
  • For sign_typed_data calls where the domain includes a verifyingContract, the address must be in the contracts list (case-insensitive comparison).
  • For sign_typed_data calls where the domain omits verifyingContract, the rule denies — the contract cannot be verified.

Example policy:

{
  "id": "permit2-only",
  "name": "Restrict to Permit2 typed data",
  "version": 1,
  "created_at": "2026-03-30T00:00:00Z",
  "rules": [
    { "type": "allowed_chains", "chain_ids": ["eip155:8453"] },
    { "type": "allowed_typed_data_contracts", "contracts": ["0x000000000022D473030F116dDEE9F6B43aC78BA3"] }
  ],
  "action": "deny"
}

Custom Executable Policies

For anything declarative rules can't express — on-chain simulation, external API calls, complex business logic. Custom executables are the escape hatch.

Protocol

echo '<PolicyContext JSON>' | /path/to/policy-executable
  • The executable receives the full PolicyContext as a single JSON object on stdin
  • The executable MUST write a single PolicyResult JSON object to stdout
  • A non-zero exit code is treated as a denial
  • Stderr is captured by the evaluation path and may be surfaced in denial details

Evaluation order within a policy

A policy can have both rules (declarative) and executable (custom). When both are present:

  1. Declarative rules evaluate first (in-process, fast)
  2. If declarative rules deny → skip executable (no subprocess spawned)
  3. If declarative rules allow → spawn executable for final verdict
  4. Both must allow

Declarative rules act as a fast pre-filter. The executable only runs for requests that pass basic checks.

Policy File Format

Policies are JSON files stored in ~/.ows/policies/:

{
  "id": "base-agent-limits",
  "name": "Base Agent Safety Limits",
  "version": 1,
  "created_at": "2026-03-22T10:00:00Z",
  "rules": [
    { "type": "allowed_chains", "chain_ids": ["eip155:8453", "eip155:84532"] },
    { "type": "expires_at", "timestamp": "2026-12-31T23:59:59Z" }
  ],
  "executable": null,
  "config": null,
  "action": "deny"
}
FieldTypeRequiredDescription
idstringyesUnique policy identifier
namestringyesHuman-readable policy name
versionintegeryesPolicy schema version (currently 1)
created_atstringyesISO 8601 creation timestamp
rulesarraynoDeclarative rules (see above). Evaluated in-process.
executablestringnoAbsolute path to a custom policy executable
configobjectnoStatic configuration passed to the executable via PolicyContext.policy_config
actionstringyesCurrently "deny" only. Denied policies block the request.

A policy MUST have at least one of rules or executable. If executable is set, it MUST point to an executable file when the policy is evaluated.

PolicyContext

The base JSON object available to policy evaluation:

{
  "chain_id": "cip34:1-764824073",
  "wallet_id": "3198bc9c-6672-5ab3-d995-4942343ae5b6",
  "api_key_id": "7a2f1b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
  "request_type": "sign_transaction",
  "transaction": {
    "effects": [
      { "address": "addr1qx2f...", "diff": [["lovelace", "-4200000"]] },
      { "address": "addr1v9zk...", "diff": [["lovelace", "3000000"]] }
    ],
    "raw_hex": "84a400d9010281825820..."
  },
  "spending": {
    "daily_total": "50000000000000000",
    "date": "2026-03-22"
  },
  "timestamp": "2026-03-22T10:35:22Z"
}
FieldTypeAlways PresentDescription
chain_idstringyesCAIP-2 chain identifier
wallet_idstringyesWallet ID in scope for this request
api_key_idstringyesThe ID of the API key making this request
request_typestringyesWhich operation is being authorized: sign_transaction, sign_message, sign_hash, or sign_typed_data. Branch on this, not on which optional fields are populated.
transactionobjectnoThe signing payload. Present for sign_transaction, sign_message, and sign_hash; omitted for sign_typed_data, which exposes its payload via typed_data.raw_json instead. See below.
spendingobjectyesLightweight spending metadata currently exposed by the engine
timestampstringyesISO 8601 timestamp of the signing request

For executable policies, the engine injects policy_config into the JSON payload when the policy file includes a config object.

transaction (optional)

FieldTypeAlways PresentDescription
effectsarrayyesPer-address asset movement the transaction causes. Empty on chains whose signer does not implement flow analysis.
effects[].addressstringyesThe address whose balance changes
effects[].diffarrayyes[asset, amount] pairs. amount is a signed decimal string in the asset's smallest unit, so it has no upper bound — parse it with int() / BigInt(), never as a JSON number. The reserved asset id "lovelace" denotes ADA; a Cardano native asset is keyed by its hex policy id concatenated with its hex asset name.
raw_hexstringyesThe raw unsigned payload: the transaction bytes for sign_transaction, the message bytes for sign_message, the pre-image for sign_hash
chain_extraobjectnoChain-specific detail that effects cannot carry. Present only when a chain populates it; see the chain's own document (Cardano puts contingent collateral loss in chain_extra.collateral_effects).
datastringnoReserved for calldata. Not populated by any chain in the reference implementation.

Only chains whose signer overrides make_transaction_context fill effects; today that is Cardano. Everywhere else — EVM included — effects is empty and raw_hex is the whole payload, so a policy that needs parsed transaction fields has to decode raw_hex itself.

Earlier versions documented transaction.to and transaction.value. Both fields existed on the struct but were never populated by any chain, on any code path, so a policy reading them always saw null. They are removed in favour of effects; nothing that worked has stopped working.

Migrating a policy that detects typed data

transaction used to be present on every request, with raw_hex set to "" for sign_typed_data. It is now omitted for that operation, and the direction in which an existing policy breaks depends on how it was written:

Policy styleBeforeAfter
payload["transaction"]["raw_hex"] == ""denyKeyError, non-zero exit, deny
payload.get("transaction", {}).get("raw_hex", "")denydeny
tx = payload.get("transaction") or {}denyNone == "" is False, allow
const tx = ctx.transaction || {}denyundefined === "" is False, allow

The defensively written script is the one that fails open. It does not raise, so the engine has nothing to catch, and a policy that blocks typed data signing today silently stops blocking it — with no error and no log entry.

Check request_type instead. It is always present, it names the operation directly, and a policy that reads it cannot be fooled by an optional field's absence:

if ctx["request_type"] == "sign_typed_data":
    json.dump({"allow": False, "reason": "typed data signing is not permitted"}, sys.stdout)

Rust consumers are unaffected: the type change breaks at compile time.

typed_data (optional)

Present only for sign_typed_data calls. Omitted entirely for sign_message and sign_transaction.

{
  "typed_data": {
    "verifying_contract": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
    "domain_chain_id": 8453,
    "primary_type": "PermitSingle",
    "domain_name": "Permit2",
    "domain_version": "1",
    "raw_json": "{...full EIP-712 JSON...}"
  }
}

All fields except primary_type and raw_json are optional (the EIP-712 domain can omit any field). Executable policies can use raw_json to inspect the full typed data structure including message fields.

PolicyResult

{ "allow": true }
{ "allow": false, "reason": "Daily spending limit exceeded: 0.95 / 1.0 ETH" }
FieldTypeRequiredDescription
allowbooleanyestrue to permit the transaction, false to deny
reasonstringnoHuman-readable explanation returned in the denial path

Timeout and Failure Semantics

For custom executable policies only (declarative rules cannot fail in these ways):

ScenarioBehavior
Executable exits with code 0, valid JSON on stdoutUse the PolicyResult as the verdict
Executable exits with non-zero codeDeny. Treat as { "allow": false }.
Executable does not produce valid JSON on stdoutDeny.
Executable does not exit within 5 secondsDeny. Kill the process.
Executable not found or not executableDeny.
Unknown declarative rule typeDeny. Fail closed on unrecognized rules.

The default-deny stance ensures that policy failures are never silently bypassed.

Policy Actions

ActionBehavior
denyBlock the transaction and return a POLICY_DENIED error

Policy Attachment

Policies are attached to API keys, not wallets. When an API key is created, it is scoped to specific wallets and policies:

# Create a policy
ows policy create --file base-agent-limits.json

# Create an API key with wallet scope and policy attachment
ows key create --name "claude-agent" --wallet agent-treasury --policy base-agent-limits
# => ows_key_a1b2c3d4e5f6...  (shown once, store securely)

An API key can have multiple policies attached. All attached policies are evaluated — every policy must allow the transaction for it to proceed (AND semantics). Evaluation short-circuits on the first denial.

Example: Custom Spending Cap

Per-transaction value caps are not a declarative rule, so a cap is an executable policy over transaction.effects.

#!/usr/bin/env python3
"""Cap the ADA one transaction may move out of the wallet's own addresses."""
import json, sys

ctx = json.load(sys.stdin)
config = ctx.get("policy_config") or {}
owned = set(config.get("addresses", []))
limit = int(config.get("max_lovelace_out", "0"))

if ctx["request_type"] == "sign_typed_data":
    # Typed data carries no transaction context, so a spending cap cannot bound it.
    json.dump({"allow": False, "reason": "typed data signing is not permitted"}, sys.stdout)
    sys.exit(0)

tx = ctx["transaction"]

def outflow(effects):
    total = 0
    for effect in effects:
        if effect["address"] not in owned:
            continue
        for asset, amount in effect["diff"]:
            # amount is a signed decimal string, not a number
            if asset == "lovelace" and int(amount) < 0:
                total -= int(amount)
    return total

spent = outflow(tx["effects"])
# Collateral is consumed only if the transaction's scripts fail, and it never reduces
# the change output, so effects does not account for it. Count it against the cap.
at_risk = outflow((tx.get("chain_extra") or {}).get("collateral_effects", []))

if spent + at_risk > limit:
    json.dump({"allow": False,
               "reason": f"{spent + at_risk} lovelace out, cap is {limit}"}, sys.stdout)
else:
    json.dump({"allow": True}, sys.stdout)

The corresponding policy file:

{
  "id": "ada-spend-cap",
  "name": "Max 5 ADA out per transaction",
  "version": 1,
  "created_at": "2026-03-22T10:00:00Z",
  "rules": [
    { "type": "allowed_chains", "chain_ids": ["cip34:1-764824073"] }
  ],
  "executable": "/home/user/.ows/plugins/policies/ada-spend-cap.py",
  "config": {
    "addresses": ["addr1qx2f..."],
    "max_lovelace_out": "5000000"
  },
  "action": "deny"
}

On a chain whose signer does not fill effects, the same policy has to decode transaction.raw_hex itself before it can decide anything about value.

References