> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwedai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# AI agent verification and security

> QWED provides AI agent security with pre-execution checks, policy enforcement, budget controls, and activity logging before agents execute actions.

Pre-execution verification for AI agents.

Use this guide when you need AI agent security, zero-trust approval flows, tool call verification, and runtime policy enforcement before an agent touches external systems.

## Overview

QWED Agent Verification provides:

* **Pre-execution checks** before agents act
* **Budget enforcement** to limit costs
* **Risk assessment** for each action
* **Activity logging** for audit trails

## Registering an agent

> [!NOTE]
>
> These examples use `AgentService` from the `qwed` package. It runs in your own process and keeps agents, budgets, and conversation state in memory. The Python SDK's `QWEDClient` has no agent methods. A QWED server registers agents with [`POST /agents/register`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2Fregister) and verifies their requests with [`POST /agents/{agent_id}/verify`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2F%2Fverify).

```python theme={null}
from qwed_new.core.agent_service import ActionContext, AgentAction, AgentService

client = AgentService()

agent = client.register_agent(
    name="DataAnalyst",
    agent_type="supervised",  # a label; trust comes from trust_level
    principal_id="user_123",
    trust_level=1,  # 0 untrusted, 1 supervised (default), 2 autonomous, 3 trusted
    permissions={
        "allowed_engines": ["math", "logic", "sql"],
        "blocked_tools": ["execute_code"],
    },
    budget={
        "max_daily_cost_usd": 100,
        "max_requests_per_hour": 500,
    }
)

agent_id = agent["agent_id"]
print(agent_id)               # agent_1a2b3c4d5e6f
print(agent["agent_token"])  # qwed_agent_xyz...
```

The examples below use this `agent_id`.

> [!WARNING]
>
> With a QWED server, the `agent_token` that `POST /agents/register` returns is shown only once. QWED stores an HMAC-SHA256 digest of it, keyed with `QWED_API_KEY_LOOKUP_SECRET`, so it can't be retrieved later. To replace a lost or exposed token, call [`POST /agents/{agent_id}/token/reissue`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2F%2Ftoken%2Freissue). The old token stops working.

## Verifying actions

Before an agent executes an action, you must provide an `ActionContext` with a `conversation_id` and a monotonically increasing `step_number`. These fields are **required** — requests without them are rejected.

```python theme={null}
query = "SELECT * FROM users"

decision = client.verify_action(
    agent_id=agent_id,
    action=AgentAction(
        action_type="execute_sql",
        query=query,
    ),
    context=ActionContext(
        conversation_id="conv_users",
        step_number=1,
        user_intent="Get user list",
    )
)

if decision["decision"] == "APPROVED":
    execute_query(query=query)
elif decision["decision"] == "DENIED":
    # Context, registration and unknown-action denials carry "error";
    # policy denials list the failed checks instead.
    print("Action blocked:", decision.get("error") or decision["verification"]["checks_failed"])
elif decision["decision"] == "PENDING":
    request_human_approval()
elif decision["decision"] == "BUDGET_EXCEEDED":
    print("Budget limit reached:", decision["error"])
```

> [!WARNING]
>
> `APPROVED` is the agent decision: the action passed the agent's permissions, budget, risk and conversation checks. `AgentService` doesn't verify the SQL itself. Before you run the query, verify it with the [SQL engine](https://docs.qwedai.tech/engines/sql.md) and run it with least-privilege database credentials.

## Conversation controls

QWED enforces runtime guardrails that prevent agents from replaying actions, running in infinite loops, or exceeding conversation length limits. These checks run automatically on every `verify_action` call.

### How it works

Each call to `verify_action` must include a `conversation_id` (identifying the current session) and a `step_number` (a positive integer that increases with each action in that session). QWED uses these fields to enforce four controls:

| Control | Limit | Error code |
| - | - | - |
| Conversation length | 50 steps per conversation | `QWED-AGENT-LOOP-001` |
| Replay detection | Each step number can only be used once | `QWED-AGENT-LOOP-002` |
| Repetitive loop detection | Max 2 consecutive identical actions | `QWED-AGENT-LOOP-003` |
| No-progress doom loop | Same action on unchanged state ≥ 3 times | `QWED-AGENT-LOOP-004` |

### Incrementing steps correctly

The `step_number` must be strictly greater than any previously committed step within the same conversation. If an action is denied (for example, due to a loop), that step number is **not** consumed — you can retry the same step with a different action.

```python theme={null}
# Step 1: approved
client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="calculate", query="2+2"),
    context=ActionContext(conversation_id="conv_steps", step_number=1)
)

# Step 2: same action, still approved (first repeat)
client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="calculate", query="2+2"),
    context=ActionContext(conversation_id="conv_steps", step_number=2)
)

# Step 3: same action again — denied as repetitive loop
result = client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="calculate", query="2+2"),
    context=ActionContext(conversation_id="conv_steps", step_number=3)
)
# result["decision"] == "DENIED"
# result["error"]["code"] == "QWED-AGENT-LOOP-003"

# Step 3 retry: a different action succeeds on the same step number
client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="verify_logic", query="x > 1"),
    context=ActionContext(conversation_id="conv_steps", step_number=3)
)
```

> [!WARNING]
>
> If your agent framework retries failed actions automatically, make sure it does not reuse the same `step_number` for a previously approved step. Replayed step numbers are always rejected.

### Progress-aware doom loop detection (LOOP-004)

> [!NOTE]
>
> New in v5.1.0

LOOP-003 catches agents that repeat the same action consecutively, but it cannot detect an agent that keeps retrying an action when the underlying system state has not changed. LOOP-004 addresses this by binding each action to the world state at the time it was proposed.

To enable LOOP-004, include `pre_action_state_hash` and `state_source` in the action context:

```python theme={null}
import hashlib

# Compute a state hash from your environment
db_snapshot = get_database_checksum()
state_hash = hashlib.sha256(db_snapshot.encode()).hexdigest()

decision = client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="execute_sql", query="SELECT status FROM orders WHERE id = 42"),
    context=ActionContext(
        conversation_id="conv_orders",
        step_number=4,
        pre_action_state_hash=state_hash,
        state_source="db_snapshot",
    )
)
```

Only an approved action's fingerprint is recorded. A query with `UPDATE`, `DELETE`, `DROP` or `TRUNCATE` is critical risk, which a supervised agent is denied, so this example uses a `SELECT`.

The guard tracks a sliding window of the last 20 action+state fingerprints per conversation. If the same fingerprint appears 3 or more times, the action is halted with `QWED-AGENT-LOOP-004`.

**Accepted `state_source` values:**

| Value | Use case |
| - | - |
| `file_tree` | Git tree hash or directory listing hash |
| `db_snapshot` | Database state checksum |
| `conversation_digest` | Hash of the conversation history |
| `git_tree` | Git tree object hash |
| `custom` | Any caller-defined canonical hash |

**Validation rules:**

* `pre_action_state_hash` must be a 64-character lowercase hex SHA-256 digest
* Both `pre_action_state_hash` and `state_source` must be provided together — supplying only one is rejected
* During gradual rollout, both fields are optional. When the server enables `DOOM_LOOP_GUARD_REQUIRED`, they become mandatory

> [!NOTE]
>
> LOOP-004 fingerprints are only committed to the sliding window when the action decision is `APPROVED`. Denied and pending actions do not affect the history, preventing false positives from rejected retries.

## Trust levels

| Level | Value | Description |
| - | - | - |
| UNTRUSTED | 0 | No autonomous actions |
| SUPERVISED | 1 | Low-risk autonomous |
| AUTONOMOUS | 2 | Most actions autonomous |
| TRUSTED | 3 | Full autonomy |

## Tool approval policy

> [!NOTE]
>
> Changed in v5.0.2

The `ToolApprovalSystem` classifies every tool call into one of three categories before execution:

| Category | Behavior | Examples |
| - | - | - |
| **Safe (allowlisted)** | Auto-approved | `read_database`, `query_data`, `search_web`, `send_email`, `log_message`, `get_weather` |
| **Dangerous (blocklisted)** | Blocked — requires manual approval | `delete_database`, `drop_table`, `send_money`, `delete_files`, `shutdown_server`, `revoke_access` |
| **Unknown** | Blocked — requires explicit allowlisting | Any tool not in the safe or dangerous list |

Unknown tools are **denied by default**, regardless of their heuristic risk score. Previously, unknown tools with a low risk score (below 0.3) were auto-approved. This fail-closed behavior ensures that new or unexpected tools cannot execute without being explicitly added to the allowlist.

When an unknown tool is blocked, the response includes the tool name and the computed risk score for debugging:

```json theme={null}
{
  "approved": false,
  "blocked_reason": "Unknown tool 'my_custom_tool' requires explicit allowlisting (risk_score=0.2)"
}
```

To allow a custom tool, add it to the safe operations list in your `ToolApprovalSystem` configuration.

## Risk assessment

A tool action takes its risk from `TOOL_RISK_LEVELS`. An engine action is critical when its `query` (or `code`) contains `DROP`, `DELETE`, `TRUNCATE` or `UPDATE`, or `eval(`, `exec(`, `os.system` or `subprocess`; otherwise it is low.

| Risk | Tool actions |
| - | - |
| LOW | `database_read`, `file_read` |
| MEDIUM | `send_email`, `api_call` |
| HIGH | `file_write` |
| CRITICAL | `database_write`, `execute_code`, `file_delete` |

## Decision matrix

With the default `risk_threshold="medium"`, and when the permission checks pass:

| Trust Level | LOW Risk | MEDIUM Risk | HIGH Risk | CRITICAL Risk |
| - | - | - | - | - |
| 0 (Untrusted) | APPROVED | APPROVED | PENDING | DENIED |
| 1 (Supervised) | APPROVED | APPROVED | PENDING | DENIED |
| 2 (Autonomous) | APPROVED | APPROVED | APPROVED | DENIED |
| 3 (Trusted) | APPROVED | APPROVED | APPROVED | APPROVED |

Critical risk needs trust level 3. Risk above `risk_threshold` is `PENDING` below trust level 2. Pass `risk_threshold="low"` to `verify_action` to send medium-risk actions to `PENDING` too.

## Tool approval policy

> [!NOTE]
>
> Changed in v5.0.2

The tool approval system categorizes every tool call into one of three groups before execution:

| Category | Behavior | Examples |
| - | - | - |
| **Safe operations** | Auto-approved | `read_database`, `query_data`, `search_web` |
| **Dangerous operations** | Blocked, requires manual approval | `delete_database`, `drop_table`, `send_money` |
| **Unknown operations** | Blocked (default-deny) | Any tool not in the safe or dangerous list |

Unknown tools are always denied, regardless of their computed risk score. The blocked response includes the tool name and risk score for debugging:

```text theme={null}
Unknown tool 'my_custom_tool' requires explicit allowlisting (risk_score=0.2)
```

> [!WARNING]
>
> Before v5.0.2, unknown tools with a risk score below 0.3 were auto-approved. If your agents rely on custom tools that were previously approved through this heuristic, you must add them to the safe operations allowlist. See the [changelog](https://docs.qwedai.tech/changelog.md) for migration details.

### Adding tools to the allowlist

The safe and dangerous lists are set in `ToolApprovalSystem.__init__` (`safe_operations` and `dangerous_operations`). To allow a custom tool, add it to `safe_operations` on the instance that approves your tool calls:

```python theme={null}
from qwed_new.core.tool_approval import tool_approval

tool_approval.safe_operations.add("fetch_report")
```

Agent permissions don't change these lists. `AgentService` stores a `permissions["allowed_tools"]` list but doesn't check it: it checks only `blocked_tools` and `allowed_engines`. To let an agent use a custom action type, register it in `ACTION_ENGINES` or `TOOL_RISK_LEVELS`; see [Registered action enforcement](#registered-action-enforcement).

## Budget enforcement

```python theme={null}
# Check remaining budget
budget = client.get_agent_budget(agent_id)
print(budget)
# {
#   "cost": {"max_daily_usd": 100, "current_daily_usd": 45.50},
#   "requests": {"max_per_hour": 500, "current_hour": 123, "max_per_day": 10000, "current_day": 840}
# }
```

`verify_action` enforces two of these limits: the daily cost (`QWED-AGENT-BUDGET-001`) and the requests per hour (`QWED-AGENT-BUDGET-002`). The daily cost is an estimate, not your actual spending: each approved action adds a fixed \$0.01, whatever it costs to run. Enforce your real spending limits separately. `max_per_day` and `current_day` are reported but not enforced, so an agent that stays under its hourly limit isn't stopped at its daily request count.

## Activity logging

`AgentService` keeps an in-memory log of the decisions that reach its permission and risk checks: `APPROVED`, `PENDING`, and policy `DENIED`. It returns the other denials without logging them: an unregistered or suspended agent, a context or loop check (such as the replay above), an unknown action type, and a budget limit. Don't treat this log as a complete audit trail. Read an agent's entries with `get_agent_activity`:

```python theme={null}
for entry in client.get_agent_activity(agent_id, limit=10):
    print(entry["timestamp"], entry["action_type"], entry["decision"], entry["risk_level"])
```

A QWED server keeps its own activity log for agents registered with the server. `AgentService` doesn't register agents with the server, so register the agent with [`POST /agents/register`](https://docs.qwedai.tech/api/endpoints#post-%2Fagents%2Fregister) and set `QWED_AGENT_ID` and `QWED_AGENT_TOKEN` to the `agent_id` and `agent_token` it returned before you run this example.

```python theme={null}
import os

import requests

# An agent reads its own activity with the ID and token that
# POST /agents/register returned
server_agent_id = os.environ["QWED_AGENT_ID"]
server_agent_token = os.environ["QWED_AGENT_TOKEN"]
response = requests.get(
    f"http://localhost:8000/agents/{server_agent_id}/activity",
    params={"limit": 10},
    headers={"X-Agent-Token": server_agent_token},
)
response.raise_for_status()
for entry in response.json()["activities"]:
    print(f"{entry['timestamp']}: {entry['type']} -> {entry['status']}")
```

An agent reads its own activity with its token. Organization owners and admins can also list every agent in their organization with [`GET /agents`](https://docs.qwedai.tech/api/endpoints#get-%2Fagents) and read any of those agents' activity with an API key or a signed-in session, without the agent's token:

```bash theme={null}
# List your organization's agents (owner or admin)
curl --fail-with-body http://localhost:8000/agents \
  -H "X-API-Key: $QWED_API_KEY"

# Read an agent's 50 newest activity entries
curl --fail-with-body "http://localhost:8000/agents/42/activity?limit=50" \
  -H "X-API-Key: $QWED_API_KEY"
```

`limit` accepts `1` to `200`. See [`GET /agents/{agent_id}/activity`](https://docs.qwedai.tech/api/endpoints#get-%2Fagents%2F%2Factivity) for the access rules and status codes.

## Runtime hardening

> [!NOTE]
>
> New in v5.0.0

QWED enforces several runtime controls to prevent agent misuse, infinite loops, and replay attacks. These protections operate at the verification kernel level and cannot be bypassed by agents.

### Action context enforcement

Every `verify_action` call requires an `ActionContext` with:

| Field | Type | Required | Description |
| - | - | - | - |
| `conversation_id` | string | Yes | Unique identifier for the conversation/session |
| `step_number` | integer | Yes | Monotonically increasing step counter (must be >= 1) |
| `user_intent` | string | No | Human-readable description of the user's goal |
| `pre_action_state_hash` | string | Conditional | SHA-256 hex digest of the world state before the action. Required when `state_source` is provided |
| `state_source` | string | Conditional | How the hash was derived: `file_tree`, `db_snapshot`, `conversation_digest`, `git_tree`, or `custom`. Required when `pre_action_state_hash` is provided |

The step number must increase with each action in a conversation. Attempts to reuse or decrement step numbers are rejected.

### Registered action enforcement

> [!NOTE]
>
> New in v5.1.1

Every `verify_action` call must reference an `action_type` that QWED has registered semantics for. Action types must be either bound to a verification engine or registered as a governed tool with a known risk level. Engine examples: `calculate` → `math`, `execute_sql` → `sql`. Tool examples: `database_read`, `send_email`. Action types outside those two registries are denied with `QWED-AGENT-ACTION-001` before any risk assessment runs.

```python theme={null}
result = client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="do_arbitrary_thing", query="..."),
    context=ActionContext(conversation_id="conv_unknown", step_number=1),
)
# {
#   "decision": "DENIED",
#   "error": {
#     "code": "QWED-AGENT-ACTION-001",
#     "message": "Unknown action_type 'do_arbitrary_thing' cannot be verified without explicit registered semantics"
#   }
# }
```

The denial fires before risk assessment, so unknown actions can never be auto-approved through a permissive default. The reserved conversation step is released, allowing the agent to retry the same step with a registered action type.

To allow a custom action, either bind it to an engine in `ACTION_ENGINES` or add it to your tool registry with an explicit risk level. Tools registered this way are reported with `engine: "tool_control"` in the verification response.

> [!WARNING]
>
> Before v5.1.1, unknown action types defaulted to `engine: "security"` and proceeded through risk assessment as `MEDIUM` risk. This could result in `APPROVED` or `PENDING` decisions depending on the agent's trust level. Audit any production agents that emit non-standard `action_type` values and register them explicitly before upgrading.

### Replay and loop detection

The agent service detects and blocks four types of problematic patterns:

| Pattern | Error code | Description |
| - | - | - |
| Step replay | `QWED-AGENT-LOOP-002` | Submitting an action with a `step_number` that was already used in the conversation |
| Repetitive loop | `QWED-AGENT-LOOP-003` | Submitting the same action (identical fingerprint) more than 2 consecutive times |
| No-progress doom loop | `QWED-AGENT-LOOP-004` | Repeating the same action on an unchanged world state 3 or more times (requires `pre_action_state_hash`) |
| Step limit exceeded | `QWED-AGENT-LOOP-001` | Exceeding the maximum of 50 steps per conversation |

Actions are fingerprinted deterministically using `action_type`, `query`, `code`, `target`, and `parameters`. When `pre_action_state_hash` is provided, the fingerprint also incorporates the world state hash. If a loop is detected, the conversation state is not advanced — the agent can recover by submitting a different action at the same step number.

```python theme={null}
# Step 1: approved
client.verify_action(agent_id, action=AgentAction(action_type="calculate", query="2+2"),
    context=ActionContext(conversation_id="conv_replay", step_number=1))

# Step 1 again: DENIED (replay)
client.verify_action(agent_id, action=AgentAction(action_type="calculate", query="2+2"),
    context=ActionContext(conversation_id="conv_replay", step_number=1))
# -> {"decision": "DENIED", "error": {"code": "QWED-AGENT-LOOP-002"}}
```

### Fail-closed for unknown action types

QWED denies any `verify_action` call whose `action_type` has no registered engine binding or tool risk level. Action verification is deterministic — actions without explicit semantics cannot be risk-assessed or routed to a verification engine, so the kernel returns a denial instead of falling back to a permissive default.

```python theme={null}
result = client.verify_action(
    agent_id,
    action=AgentAction(action_type="transfer_funds_internal_v2", query="Move funds between ledgers"),
    context=ActionContext(conversation_id="conv_42", step_number=1),
)
# -> {"decision": "DENIED", "error": {"code": "QWED-AGENT-ACTION-001", "message": "..."}}
```

A denial under `QWED-AGENT-ACTION-001` releases the in-flight step reservation, so the agent may retry the same step number with a registered action type. Registered action types include the entries in `ACTION_ENGINES` (`execute_sql`, `execute_code`, `calculate`, `verify_logic`, and `verify_fact`) and tools listed in `TOOL_RISK_LEVELS`.

### In-flight reservation system

While QWED processes a `verify_action` call, it reserves the step number so concurrent requests cannot claim the same step. QWED releases the reservation if the action is denied, allowing the agent to retry with a different action at the same step.

### Budget denial behavior

When a budget check fails, the conversation step is not consumed. This means the agent can retry the same step number after the budget resets without triggering a replay detection error.

### Fail-closed rate limiting

The Redis-backed sliding window rate limiter fails closed when Redis is unavailable. If the Redis backend encounters an error, all requests are denied rather than allowed, preventing uncontrolled access during infrastructure failures. When Redis is entirely absent at startup, a local in-memory fallback limiter is used instead.

### Environment integrity verification

On API server startup, QWED runs an environment integrity check (via `StartupHookGuard`) before initializing the database. If the environment is compromised, the server refuses to start. This prevents operation in tampered runtime environments.

### Timing-safe token verification

Agent token verification uses `hmac.compare_digest` for constant-time comparison, preventing timing side-channel attacks against agent authentication.

### Fail-closed on unknown actions

QWED only verifies actions whose `action_type` has explicit, registered semantics. If you submit an `action_type` that is not bound to a verification engine or a known tool, the request is denied with `QWED-AGENT-ACTION-001` before risk assessment runs.

Registered actions fall into two categories:

| Category | Examples | Engine |
| - | - | - |
| Verification engines | `execute_sql`, `execute_code`, `calculate`, `verify_logic`, `verify_fact` | `sql`, `code`, `math`, `logic`, `fact` |
| Tool calls | `database_read`, `database_write`, `send_email`, `file_read`, `file_write`, `file_delete`, `api_call` | `tool_control` |

Anything else — including custom action names, typos, and forward-compatible names that the runtime does not yet recognize — is denied:

```python theme={null}
result = client.verify_action(
    agent_id=agent_id,
    action=AgentAction(action_type="transfer_funds_internal_v2", query="Move funds between ledgers"),
    context=ActionContext(conversation_id="conv_transfer", step_number=1),
)

# {
#   "decision": "DENIED",
#   "error": {
#     "code": "QWED-AGENT-ACTION-001",
#     "message": "Unknown action_type 'transfer_funds_internal_v2' cannot be verified without explicit registered semantics"
#   }
# }
```

Key behaviors to plan for:

* **No `verification` block is returned.** Unknown actions never receive an engine label or a `VERIFIED` status — there is no generic `"security"` fallback.
* **The step reservation is released.** Because the action was denied, the same `step_number` can be retried with a registered action (no `QWED-AGENT-LOOP-002` replay error).
* **Map custom intents to registered actions.** If your agent needs to perform a domain-specific operation, route it through one of the registered verification engines or tool calls rather than inventing a new `action_type` string.

## Framework integration

These integrations verify through the QWED API with `QWEDClient`. They don't take an agent ID, so the `AgentService` checks on this page don't apply to them.

### LangChain

```python theme={null}
from qwed_sdk.langchain import QWEDVerificationCallback

agent = initialize_agent(
    tools=[...],
    callbacks=[QWEDVerificationCallback(api_key="qwed_...")]
)
```

### CrewAI

```python theme={null}
from qwed_sdk.crewai import QWEDVerifiedAgent

analyst = QWEDVerifiedAgent(
    role="Analyst",
    goal="Analyze data",
    api_key="qwed_...",
)
```


## Related topics

- [LLM verification with formal methods](https://docs.qwedai.tech/advanced/llm-verification.md)
- [QWED vs Guardrails, RAG, and RLHF for LLM verification](https://docs.qwedai.tech/advanced/comparison.md)
- [QWED: deterministic verification for LLMs and AI agents](https://docs.qwedai.tech/intro.md)
- [QWED Tax: tax verification for AI agents](https://docs.qwedai.tech/tax/overview.md)
- [AgentStateGuard: verify agent state before commit](https://docs.qwedai.tech/advanced/agent-state-guard.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.