Arcezia

Arcezia / AI agent security / Action authorization

AI agent action authorization: one tool call, three answers

AI agent action authorization means deciding, before a tool runs, whether this exact call may run. A check sits between the agent and the tool. It answers ALLOW (the call runs), REVIEW (the call is held, and the answer names what would release it, such as a person’s approval) or BLOCK (the call is refused, and the answer says why). The same job is also called tool-call security or pre-execution control. Below, one fixed call, SELECT COUNT(*) FROM orders, is checked three times on the live service. The call never changes. Only what the operator set for the session changes, and each answer says why.

One call, three answers

The call is the same in all three: tool execute_sql, statement SELECT COUNT(*) FROM orders, task look up orders, under a contract that says this SQL tool never sends data out, never crosses to an outside system and never touches sensitive data.

What the operator set for the sessionAnswerWhat the answer says
Scope signed with your key: the session may run execute_sql on the orders tableALLOWAllowed.
The same scope, sent without a signatureREVIEWrelease ['approval:user'], release_without_person ['scope:resource_scope']: a person approves it, or you sign the scope
Signed scope that also lists execute_sql under denied_action_typesBLOCKreason ['scope:denied_action_types']: your own session refuses this tool

Measured 7 October 2026, 10:23:58 UTC to 10:24:13 UTC, against https://api.arcezia.com, by running the two scripts below exactly as shown: once with the arcezia Python SDK 1.0.7 from PyPI, once over raw HTTP. Both gave the answers above. A second run a few minutes earlier gave the same answers, and its three signed records are the sample export on the audit evidence page.

With the Python SDK

pip install -U arcezia cryptography, then set ARCEZIA_API_KEY to a key with the owner role from the Keys page at app.arcezia.com, and run:

import os
import arcezia
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

PATH = "principal_key.pem"   # your signing key: keep it where the agent cannot read it
if not os.path.exists(PATH):
    with open(PATH, "wb") as f:
        f.write(Ed25519PrivateKey.generate().private_bytes(
            serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
key = serialization.load_pem_private_key(open(PATH, "rb").read(), password=None)

admin = arcezia.Arcezia(task="look up orders", signing_key=key)
admin.register_token_key(key.public_key())      # once per account; safe to repeat with the same key
for c in admin.contracts():
    if c["name"] == "quickstart":
        admin.delete_contract("quickstart")
SQL = admin.register_contract({"tools": {"execute_sql": {
    "pack": "database_ops",
    "not_present": ["outbound", "trust_boundary_crossing", "sensitive_data"],
}}}, name="quickstart")["domains"]["execute_sql"]

scope = {"allowed_domains": [SQL], "allowed_action_types": ["execute_sql"], "resource_scope": ["orders"]}
sessions = [("signed scope", scope, True),
            ("same scope, unsigned", scope, False),
            ("signed, tool denied", {**scope, "denied_action_types": ["execute_sql"]}, True)]
for label, envelope, signed in sessions:
    az = arcezia.Arcezia(task="look up orders", signing_key=key if signed else None)
    az.start_session(capability_envelope=envelope)
    cert = az.verify(action_type="execute_sql", action_description="SELECT COUNT(*) FROM orders",
                     domain=SQL)
    print(label, "|", cert.verdict, "| release", cert.release,
          "| release_without_person", cert.release_without_person, "| reason", cert.reason)

Output of that run:

signed scope | ALLOW | release [] | release_without_person [] | reason []
same scope, unsigned | REVIEW | release ['approval:user'] | release_without_person ['scope:resource_scope'] | reason []
signed, tool denied | BLOCK | release [] | release_without_person [] | reason ['scope:denied_action_types']

Over HTTP

Any language can send the same requests. Only the signing needs code: arcezia_sign.py is the small signer from Recipe 3 in the developer docs, saved next to this script.

API=https://api.arcezia.com
AUTH="Authorization: Bearer $ARCEZIA_API_KEY"
J="Content-Type: application/json"

# Your signing key (arcezia_sign.py from the developer docs, Recipe 3), registered once.
curl -s -X POST "$API/v1/account/token_key" -H "$AUTH" -H "$J" \
  -d "{\"public_key\": \"$(python3 arcezia_sign.py pubkey)\"}" > /dev/null
ACCOUNT=$(curl -s "$API/v1/account/token_key" -H "$AUTH" | python3 -c 'import json,sys; print(json.load(sys.stdin)["api_key_id"])')

# The contract for the SQL tool.
curl -s -X DELETE "$API/v1/contracts/quickstart" -H "$AUTH" > /dev/null
SQL=$(curl -s -X POST "$API/v1/contracts" -H "$AUTH" -H "$J" \
  -d '{"name": "quickstart", "contract": {"tools": {"execute_sql": {"pack": "database_ops", "not_present": ["outbound", "trust_boundary_crossing", "sensitive_data"]}}}}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["domains"]["execute_sql"])')

SCOPE="{\"allowed_domains\": [\"$SQL\"], \"allowed_action_types\": [\"execute_sql\"], \"resource_scope\": [\"orders\"]}"
DENIED="{\"allowed_domains\": [\"$SQL\"], \"allowed_action_types\": [\"execute_sql\"], \"resource_scope\": [\"orders\"], \"denied_action_types\": [\"execute_sql\"]}"

# Open a session with $1 as its body, then check the same call in it.
check() {
  SID=$(curl -s -X POST "$API/v1/session" -H "$AUTH" -H "$J" -d "$1" \
    | python3 -c 'import json,sys; print(json.load(sys.stdin)["session_id"])')
  curl -s -X POST "$API/v1/verify" -H "$AUTH" -H "$J" \
    -d "{\"task\": \"look up orders\", \"session_id\": \"$SID\", \"action_type\": \"execute_sql\", \"domain\": \"$SQL\", \"action_description\": \"SELECT COUNT(*) FROM orders\"}" \
    | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["verdict"], d.get("release"), d.get("release_without_person"), d.get("reason"))'
}
check "{\"task\": \"look up orders\", \"capability_envelope_token\": \"$(python3 arcezia_sign.py envelope "$ACCOUNT" "$SCOPE")\"}"
check "{\"task\": \"look up orders\", \"capability_envelope\": $SCOPE}"
check "{\"task\": \"look up orders\", \"capability_envelope_token\": \"$(python3 arcezia_sign.py envelope "$ACCOUNT" "$DENIED")\"}"

Output of that run (verdict, release, release without a person, reason):

ALLOW None None None
REVIEW ['approval:user'] ['scope:resource_scope'] None
BLOCK None None ['scope:denied_action_types']

Tool-call security: what the check looks at

The check looks at the call, not at the model’s text: which tool, with which arguments, on which records, inside which session. Here the deciding difference is the session’s scope and who signed it. A prompt cannot widen a signed scope, and an agent cannot sign one: the key that signs it stays with you, where the agent cannot read it.

The same call goes the other way when its statement changes. In the developer docs Quickstart, SELECT * FROM orders in the same signed session is held for a person, because it reads every row.

Pre-execution control: the answer comes first

Every answer above came back before anything touched the database. Nothing in this page ran the SQL. Your code runs the tool only on ALLOW, never on “not BLOCK”: a REVIEW goes to a person, a BLOCK stops. Each answer is also kept as a signed record that an auditor can check offline: audit evidence.

What this does not show

Questions

What is the difference between authenticating an AI agent and authorizing its actions?

Authentication says who is calling: the agent’s key or token. Action authorization decides whether this one call, with these arguments, may run now. A valid credential answers the first question and not the second. In the PocketOS incident, the token that deleted the production volume was valid: it was scoped to any operation.

Why was the unsigned session held for review?

A scope counts only when the person in charge of the agent signs it, because an agent could write any scope for itself. The answer says so: release names a person’s approval, and release_without_person names the way out without one, scope:resource_scope: sign the scope.

Can I run this on my own account?

Yes. Issue a free key at app.arcezia.com, set ARCEZIA_API_KEY, and run either script above. The scripts create a signing key file and a contract named quickstart on your account. Session and record ids will differ. If an answer differs from the one shown here, its release or reason says why.