Skip to content

Runtime authorization API: /v1/authorize deep-dive

POST /v1/authorize is the core of AegisAgent: every tool call an SDK intercepts is sent here before it executes. This page documents the request and response schema, how a decision is reached, how the action hash and trust-provenance gating work, what happens on error, and what latency to expect.

For the operational counterpart — what happens when a dependency is unavailable — see Fail-closed behavior guide.

Request

POST /v1/authorize
Authorization: Bearer <agent_token>
X-Aegis-Tenant-ID: <tenant_uuid>
Content-Type: application/json
{
  "request_id": "optional-idempotency-key",
  "agent": { "id": "agent-uuid-or-key", "environment": "production" },
  "user": { "id": "user-123", "role": "operator" },
  "tool_call": {
    "tool": "github",
    "action": "merge_pr",
    "resource": "repo:acme/widgets#pr-42",
    "mutates_state": true,
    "parameters": { "branch": "main", "pr_number": 42 }
  },
  "context": {
    "source_trust": "semi_trusted_customer",
    "contains_sensitive_data": false
  },
  "trace": { "run_id": "run_abc123", "trace_id": "0123456789abcdef0123456789abcdef" },
  "callback": { "url": "https://example.com/slack/callback", "secret": "whsec_..." },
  "nonce": "optional-replay-protection-nonce",
  "timestamp": "2026-06-15T12:00:00Z"
}

Field reference

Field Type Required Notes
request_id string no Idempotency key. A repeat (agent, request_id) returns the original decision/approval verbatim instead of re-evaluating policy or writing duplicate audit rows.
agent.id string yes Agent identifier as known to the SDK; the gateway resolves the actual agent via the Authorization Bearer token, not this field.
agent.environment string yes Free-form environment label (e.g. production, staging).
user object no The human/principal on whose behalf the agent is acting, if known.
tool_call.tool string yes Tool key (e.g. github, slack, or an MCP server key).
tool_call.action string yes Action key within the tool (e.g. merge_pr).
tool_call.resource string | null no Target resource identifier. null/absent is part of the canonical action and the hash.
tool_call.mutates_state bool yes Whether this action changes state. Drives risk defaults, trust-provenance gating, and the audit-writer fail-closed check.
tool_call.parameters object yes Arbitrary JSON parameters for the call. Strip secrets/large payloads client-side before sending — only what policies need.
context.source_trust string yes One of the 6 trust-provenance levels (see below).
context.contains_sensitive_data bool no Hint for policy evaluation; defaults false.
trace.run_id / trace.trace_id string no Propagated for cross-system correlation (OpenTelemetry trace ID).
callback object no Registers a webhook for a require_approval decision. secret is hashed (sha256) before storage as callback_secret_hash; the plaintext is never persisted.
nonce / timestamp string no Opt-in replay protection (#1306): a repeated (tenant, agent, nonce), or a timestamp more than 5 minutes old/in the future, is rejected with 409. This is distinct from request_idnonce rejects a repeat; request_id replays the original result.

Response

{
  "decision_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "decision": "require_approval",
  "risk_score": 75,
  "risk_level": "high",
  "reason": "Mutating action on 'github' requires human approval per policy.",
  "matched_policies": ["require_approval_mutating_github"],
  "approval": {
    "approval_id": "893c5d64-1234-4321-9988-aabbccddeeff",
    "status": "pending",
    "approver_group": "platform-leads",
    "expires_at": "2026-06-15T12:15:00Z",
    "action_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d"
  }
}
Field Notes
decision_id UUID for this evaluation; appears in /v1/decisions/:id and /v1/audit/events.
decision "allow", "deny", "require_approval", "redact", "quarantine", … — see Decision types.
risk_score / risk_level Derived from the registered tool/action's configured risk (low→10, medium→40, high→75, critical→95), or MCP tool risk for MCP calls.
reason Human-readable explanation, safe to display to an operator.
matched_policies Cedar policy IDs (or synthetic markers like agent_frozen, mcp_unknown_tool, critical_risk_requires_approval) that produced the decision — useful for debugging policy precedence.
redacted_fields Present when decision == "redact": parameter keys the SDK must mask before execution. Empty for other decisions.
receipt Optional receipt identity for durable evidence (protected/mutating paths).
approval Present only when decision == "require_approval". action_hash is the value the SDK must match before executing (see below).

Decision types

/v1/authorize returns a string decision. The common SDK-handled values are:

  • allow — execute immediately. No approval is created.
  • deny — never execute. The SDK raises AegisAuthorizationDenied (PermissionError in the decorator). matched_policies explains why — common markers include registered_action_default_deny (unregistered action), mcp_unknown_tool / mcp_server_quarantined, agent_frozen / agent_revoked, and Cedar forbid policy IDs for untrusted/malicious provenance.
  • require_approval — block until a human approves (or the approval expires). The response includes an approval object; the SDK polls GET /v1/approvals/:id until status is approved, rejected, or EXPIRED.
  • redact — execute with selected parameter fields replaced by [REDACTED] (Python SDK). Produced by Cedar @decision("redact") plus @redact_fields(...). redacted_fields is populated only for this decision; other decisions clear it.
  • quarantine — produced by Cedar @decision("quarantine") on a permit. The evaluate stage sets the agent status to quarantined (host port) and returns decision: "quarantine". SDKs that do not special-case this string should treat unknown decisions as fail-closed (do not execute).

Agent/MCP lifecycle quarantine is separate from the quarantine decision string. Freezing/revoking an agent (POST /v1/agents/:id/freeze|revoke) or quarantining an MCP server (POST /v1/mcp/servers/:server_key/quarantine) is a state change. Subsequent authorize calls for that principal/server return deny with markers such as agent_frozen, agent_revoked, or mcp_server_quarantined — not necessarily decision: "quarantine".

Policy evaluation flow

Thin adapters build AuthorizeContext and call aegis-decision::run_authorize_pipeline (host DecisionRuntime ports). Stages:

SDK                                Gateway (/v1/authorize)
───                                ───────────────────────
compute expected_action_hash
  (aegis-jcs-1 over tool_call)
POST /v1/authorize ───────────────▶ REST: authorize_context_from_headers
                                       + run_authorize_pipeline
                                    gRPC: same typed context + evaluate

                                    admit
                                      1. Resolve agent via Bearer / mTLS
                                         (tenant-scoped) → 401 if invalid
                                      2. Optional HMAC request signature
                                      3. Environment allow-list

                                    preflight
                                      4. Tool permission
                                      5. Replay/nonce + idempotency
                                         (request_id)
                                      6. Rate limit / quota + heartbeat

                                    guard
                                      7. agent.status frozen/revoked?
                                         → deny, matched_policies=[agent_<status>]
                                      8. Optional admission webhook
                                      9. Action hash (post-mutation)
                                     10. Ban / quarantine-record checks

                                    metadata
                                     11. Registered action risk / defaults
                                     12. MCP path: server quarantined?
                                         tool unknown/unapproved?
                                         → deny, mcp_* markers

                                    evaluate
                                     13. Cedar policy_engine.authorize(...)
                                         → allow | deny | require_approval
                                           (+ annotations: redact, quarantine)
                                     14. Decision overrides (critical risk,
                                         force_approval, action defaults)
                                     15. Audit-writer preflight (high-risk
                                         + SOC stream full → deny)
                                     16. write_decision_and_audit + receipts
                                     17. require_approval → create Approval
                                         bound to action_hash
                                     18. quarantine decision → quarantine_agent

        ◀────────────────── AuthorizeResponse {decision, risk_*, reason,
                              matched_policies, approval?, receipt?,
                              redacted_fields?}
  decision == allow
        │──▶ execute tool
  decision == redact
        │──▶ execute with redacted_fields masked (Python SDK)
  decision == deny | quarantine | unknown
        │──▶ raise / fail closed — never executes
  decision == require_approval
        │──▶ poll GET /v1/approvals/:id until approved
        │──▶ recompute action_hash for the about-to-run action
        │──▶ compare to approval.action_hash
        │       mismatch → FAIL CLOSED (PermissionError), never executes
        │       match → POST /v1/approvals/:id/consume (single-use) → execute

Implementation: lib/decision/, thin src/src/routes/authorize.rs, authorize_service.rs, decision_runtime.rs.

Trust-provenance integration

context.source_trust is one of six deterministic levels (most → least trusted):

  1. trusted_internal_signed
  2. trusted_internal_unsigned
  3. semi_trusted_customer
  4. untrusted_external
  5. malicious_suspected
  6. unknown

The gateway passes this through verbatim as context.trust_level to Cedar. Classifiers upstream of the SDK may only tighten this label (move it down the list), never loosen it. The base policy pack (policies.cedar / src/policies.cedar) encodes:

  • mutates_state == true and trust_level is trusted_internal_* → evaluated normally (no special gating).
  • mutates_state == true and trust_level is semi_trusted_customer or unknownrequire_approval, regardless of what the action itself would otherwise resolve to.
  • mutates_state == true and trust_level is untrusted_external or malicious_suspectedforbid (hard deny).

This is the confused-deputy defense: a prompt-injected instruction arriving via an untrusted channel cannot cause a mutating tool call to execute, no matter how the request is worded — only the source of the triggering content matters. See policies.cedar for the exact rules.

Action hash computation (aegis-jcs-1)

Both the SDK (before calling /v1/authorize and again before executing after approval) and the gateway (when binding an approval) compute:

action_hash = sha256_hex(canonicalize({
  "tool": tool_call.tool,
  "action": tool_call.action,
  "resource": tool_call.resource,   // or null if absent
  "mutates_state": tool_call.mutates_state,
  "parameters": tool_call.parameters,
}))

canonicalize is the aegis-jcs-1 scheme:

  • object keys sorted by Unicode code point
  • compact separators (","/":", no spaces)
  • raw UTF-8 — non-ASCII is not escaped to \uXXXX
  • non-finite floats (NaN/Infinity) are rejected

This must be byte-identical across the Python, Go, and TypeScript SDKs and the Rust gateway — locked by tests/canonical_action_vectors.json and a cross-language CI gate. Any divergence would silently break the approval-integrity guarantee, so never modify canonicalization without bumping the scheme version and updating all four implementations together.

This hash is the basis of the approve-then-swap defense (T-A1/T-A2): if the action about to execute hashes to anything other than the action_hash bound to its approval, the SDK fails closed and the tool never runs.

Error cases and fail-closed behavior

Every error case for /v1/authorize — unreachable gateway, database error, unregistered agent/tool, frozen agent, expired/mismatched approval, full audit pipeline, untrusted provenance, etc. — is enumerated with its exact behavior in the Fail-closed behavior guide. The short version: mutating or high-risk actions deny on any ambiguity; only non-mutating, low-risk reads may ever proceed without a fully verified chain.

Code examples

Python

from aegisagent import protect_tool, AegisAuthorizationDenied

@protect_tool(tool_key="github", action_key="merge_pr")
def merge_pr(branch: str, pr_number: int) -> str:
    ...  # actual GitHub API call

try:
    merge_pr(branch="main", pr_number=42)
except AegisAuthorizationDenied as e:
    print(f"Denied: {e}")

@protect_tool computes expected_action_hash, calls client.authorize(...), and on require_approval blocks polling GET /v1/approvals/:id before re-checking the hash and calling POST /v1/approvals/:id/consume prior to executing merge_pr.

Go

client := aegis.NewClient(aegis.ClientOptions{
    BaseURL:    "http://127.0.0.1:8080",
    AgentToken: agentToken,
    TenantID:   tenantID,
})

err := aegis.Protect(client, aegis.AuthorizeRequest{
    Tool:          "github",
    Action:        "merge_pr",
    MutatesState:  true,
    Parameters:    map[string]any{"branch": "main", "pr_number": 42},
    SourceTrust:   "trusted_internal_unsigned",
}, func() error {
    return mergePR("main", 42) // executes only on allow / approved
})

TypeScript

import { AegisClient, protect } from "@aegisagent/sdk";

const client = new AegisClient({
  baseUrl: "http://127.0.0.1:8080",
  agentToken,
  tenantId,
});

await protect(
  client,
  {
    tool: "github",
    action: "merge_pr",
    mutatesState: true,
    parameters: { branch: "main", prNumber: 42 },
  },
  async () => mergePR("main", 42) // executes only on allow / approved
);

Latency expectations

The in-process /v1/authorize hot path meets:

  • p50 < 10ms
  • p95 < 50ms
  • p99 < 100ms

Cedar policy evaluation itself targets <75ms even for tenants with custom policy sets. See performance-baseline.md for the full methodology (criterion benchmark against a real SQLite-backed AppState, 100 agents / 1000 prior decisions seeded) and HTTP-level overhead notes.