fix(security): enforce explicit exception handling

ISSUES CLOSED: #320
This commit is contained in:
CoreRasurae
2026-02-22 12:11:12 +00:00
parent d70a5d84ae
commit 8604bc66b6
12 changed files with 1619 additions and 19 deletions
+87
View File
@@ -0,0 +1,87 @@
"""ASV benchmarks for exception handling overhead.
Measures the cost of error classification, redaction,
and wrapping operations.
"""
from __future__ import annotations
from cleveragents.core.error_handling import (
classify_error,
format_error_for_cli,
redact_error_details,
redact_value,
wrap_unexpected,
)
from cleveragents.core.exceptions import (
DatabaseError,
ValidationError,
)
class ClassifyBench:
"""Benchmark error classification throughput."""
timeout = 60
def setup(self) -> None:
self.validation_err = ValidationError("field invalid")
self.db_err = DatabaseError("connection lost")
self.bare_err = Exception("unknown crash")
def time_classify_validation(self) -> None:
"""Benchmark classifying a ValidationError."""
classify_error(self.validation_err)
def time_classify_database(self) -> None:
"""Benchmark classifying a DatabaseError."""
classify_error(self.db_err)
def time_classify_bare(self) -> None:
"""Benchmark classifying a bare Exception."""
classify_error(self.bare_err)
def time_format_cli(self) -> None:
"""Benchmark formatting an error for CLI."""
info = classify_error(self.validation_err)
format_error_for_cli(info)
class RedactBench:
"""Benchmark secret redaction throughput."""
timeout = 60
def setup(self) -> None:
self.details_secret = {"api_key": "sk-abc123", "plan_id": "01PLAN"}
self.details_clean = {"plan_id": "01PLAN", "status": "ok"}
self.value_with_secret = "token sk-abcdefghijklmnopqrstuvwxyz end"
def time_redact_with_secret(self) -> None:
"""Benchmark redacting details with a secret key."""
redact_error_details(self.details_secret)
def time_redact_clean(self) -> None:
"""Benchmark redacting details without secrets."""
redact_error_details(self.details_clean)
def time_redact_value(self) -> None:
"""Benchmark redacting a string value."""
redact_value(self.value_with_secret)
class WrapBench:
"""Benchmark exception wrapping throughput."""
timeout = 60
def setup(self) -> None:
self.bare_err = Exception("crash")
def time_wrap_bare(self) -> None:
"""Benchmark wrapping a bare Exception."""
wrap_unexpected(self.bare_err)
def time_wrap_with_context(self) -> None:
"""Benchmark wrapping with context."""
wrap_unexpected(self.bare_err, context={"plan_id": "01PLAN"})
+196
View File
@@ -0,0 +1,196 @@
# Error Handling
## Overview
The `cleveragents.core.error_handling` module provides centralized
error classification, secret redaction, and exception wrapping for
consistent CLI output and safe logging.
Secret redaction **delegates** to `cleveragents.shared.redaction` so
that all redaction paths (structlog processors, CLI output, error
details) share the same patterns and configuration. On import, the
module registers additional patterns (JWT, GitHub PAT, GitLab PAT)
with the shared redaction registry.
## CLI Integration
The top-level CLI error handlers in `cli/main.py` use `classify_error`
and `wrap_unexpected` to ensure every exception surfaced to the user
is classified with an HTTP-like error code and has secrets redacted
from both the message and any attached details.
```python
except CleverAgentsError as e:
from cleveragents.core.error_handling import classify_error
info = classify_error(e)
print(f"Error [{info.code.value}] {info.code.name}: {info.message}")
except Exception as e:
from cleveragents.core.error_handling import classify_error, wrap_unexpected
safe = wrap_unexpected(e)
info = classify_error(safe)
print(f"Error [{info.code.value}] {info.code.name}: {info.message}")
```
## Error Codes
All errors are mapped to HTTP-like numeric codes:
### Client Errors (400-range)
| Code | Name | Mapped From |
|------|---------------------|-------------------------------|
| 400 | BAD_REQUEST | BusinessRuleViolation, PlanError, DomainError |
| 401 | UNAUTHORIZED | AuthenticationError |
| 403 | FORBIDDEN | AuthorizationError |
| 404 | NOT_FOUND | ResourceNotFoundError |
| 409 | CONFLICT | ResourceConflictError |
| 422 | VALIDATION_FAILED | ValidationError |
| 429 | RATE_LIMITED | RateLimitError |
### Server Errors (500-range)
| Code | Name | Mapped From |
|------|---------------------|-------------------------------|
| 500 | INTERNAL | InfrastructureError, CleverAgentsError (catch-all) |
| 503 | SERVICE_UNAVAILABLE | ExternalServiceError |
| 520 | PROVIDER_ERROR | ProviderError |
| 521 | EXECUTION_ERROR | ExecutionError |
| 522 | DATABASE_ERROR | DatabaseError |
| 523 | FILESYSTEM_ERROR | FileSystemError |
| 524 | NETWORK_ERROR | NetworkError |
| 525 | CONFIGURATION_ERROR | ConfigurationError |
| 526 | TOKEN_LIMIT | TokenLimitExceededError |
| 527 | MODEL_UNAVAILABLE | ModelNotAvailableError |
| 528 | STREAM_ERROR | StreamRoutingError |
## Error Categories
Each `ErrorInfo` includes a `category` field derived from the error code:
| Value | Name | Code Range | Description |
|-------|--------|------------|--------------------------|
| 4 | CLIENT | 400-499 | Client / input errors |
| 5 | SERVER | 500-599 | Server / infrastructure |
```python
from cleveragents.core.error_handling import classify_error, ErrorCategory
info = classify_error(exc)
if info.category == ErrorCategory.CLIENT:
print("User-correctable error")
```
## Secret Redaction
Redaction is handled by `cleveragents.shared.redaction` (the single
source of truth for all secret masking). The `redact_error_details`
function in this module is a thin wrapper around `redact_dict` that
forces `show_secrets=False` so error details are **always** masked.
### Redacted Keys (via `shared.redaction`)
Key names are matched by **substring** against the shared sensitive
key list: `api_key`, `apikey`, `password`, `passwd`, `secret`,
`token`, `credential`, `private_key`, `access_key`, `auth`.
False positives like `token_count` and `max_tokens` are excluded.
### Pattern-Based Redaction
String values are scanned for secret patterns from the shared
registry plus the additional patterns registered by this module:
- OpenAI API keys (`sk-...`, `sk-proj-...`)
- Anthropic keys (`sk-ant-...`)
- JWT tokens (`eyJ...`)
- GitHub PATs (`ghp_...`, `ghs_...`)
- GitLab PATs (`glpat-...`)
- Bearer tokens
- Token IDs (`tok_...`)
### Single-Value Redaction
The `redact_value` function (re-exported from `cleveragents.shared.redaction`)
scans a single string for secret patterns and replaces matches:
```python
from cleveragents.core.error_handling import redact_value
safe = redact_value("token is sk-abcdefghijklmnopqrstuvwxyz")
# => "token is ***REDACTED***"
```
This is the same function used internally by `classify_error` to redact
exception messages before they reach CLI output.
### Dict Redaction
```python
from cleveragents.core.error_handling import redact_error_details
safe = redact_error_details({
"api_key": "sk-real-secret",
"plan_id": "01PLAN001",
})
# => {"api_key": "***REDACTED***", "plan_id": "01PLAN001"}
```
## Wrapping Unexpected Exceptions
Use `wrap_unexpected` to convert non-`CleverAgentsError` exceptions
into safe, user-facing errors:
```python
from cleveragents.core.error_handling import wrap_unexpected
try:
risky_operation()
except Exception as exc:
safe = wrap_unexpected(exc, context={"plan_id": plan_id})
raise safe from exc
```
If the exception is already a `CleverAgentsError`, it is returned
with optional context merged into a **copy** of its details (the
original details dict is not mutated).
## CLI Formatting
```python
from cleveragents.core.error_handling import classify_error, format_error_for_cli
info = classify_error(exc)
print(format_error_for_cli(info))
# Error [422] VALIDATION_FAILED: name is required
```
Note: `classify_error` redacts secrets from the exception **message**
as well as from its details, so the output is always safe to display.
## Exception Hierarchy
```
CleverAgentsError
├── DomainError
│ ├── ValidationError
│ ├── BusinessRuleViolation
│ ├── ResourceNotFoundError
│ ├── ResourceConflictError
│ └── PlanError
├── InfrastructureError
│ ├── DatabaseError
│ ├── NetworkError
│ └── ExternalServiceError
├── ProviderError
│ ├── RateLimitError
│ ├── TokenLimitExceededError
│ └── ModelNotAvailableError
├── AuthenticationError
├── AuthorizationError
├── ConfigurationError
│ └── MissingConfigurationError
├── FileSystemError
├── ExecutionError
└── StreamRoutingError
```
+31
View File
@@ -42828,6 +42828,37 @@ Secrets (API keys, database credentials, authentication tokens) are managed thro
5. **Server mode credential isolation**: In server mode, each user's API keys are stored encrypted in the server database using AES-256-GCM with a per-user key derived from the server's master secret. Keys are decrypted only at the moment of LLM invocation and are never transmitted to the client CLI.
#### Exception Handling and Error Classification
All exceptions surfacing through the CLI are processed by the `cleveragents.core.error_handling` module, which provides three guarantees: **classification**, **redaction**, and **safe wrapping**.
**Error classification**: Every exception is mapped to an HTTP-like numeric error code via the exception class hierarchy. The mapping uses the exception's MRO (Method Resolution Order) to find the most specific match:
| Range | Category | Examples |
|---|---|---|
| 400-range | Client / input errors | `ValidationError` → 422, `ResourceNotFoundError` → 404, `AuthenticationError` → 401, `RateLimitError` → 429 |
| 500-range | Server / infrastructure errors | `DatabaseError` → 522, `NetworkError` → 524, `ProviderError` → 520, `ConfigurationError` → 525 |
Unknown exceptions (not inheriting from `CleverAgentsError`) default to code 500 (`INTERNAL`).
**Secret redaction in error output**: Both the exception **message** and its **details** dict are redacted before display. Redaction delegates to `cleveragents.shared.redaction` (the single redaction implementation shared with structlog processors and CLI output). This ensures:
- Key names matching sensitive substrings (`api_key`, `password`, `secret`, `token`, `credential`, `auth`, etc.) have values fully replaced with `***REDACTED***`.
- String values are scanned for known secret patterns (OpenAI keys, Anthropic keys, JWT tokens, GitHub PATs, GitLab PATs, Bearer tokens).
- Nested dicts and lists are recursively redacted.
- Error details always enforce redaction (`show_secrets=False`) regardless of the global `show_secrets` flag.
**Safe wrapping of unexpected exceptions**: The `wrap_unexpected` function converts bare `Exception` instances into `CleverAgentsError` with a generic, user-safe message (`"An unexpected error occurred"`). Internal stack details are captured in the error's `details` dict for diagnostics but are not shown to the user. If the exception is already a `CleverAgentsError`, it is returned unchanged with optional context merged into a copy of its details.
**CLI integration**: The top-level error handlers in `cli/main.py` use `classify_error` for `CleverAgentsError` exceptions and `wrap_unexpected` + `classify_error` for bare exceptions. CLI output includes the error code and category name for consistent, machine-parseable error reporting:
```
Error [422] VALIDATION_FAILED: name is required
Error [500] INTERNAL: An unexpected error occurred
```
See `docs/reference/error_handling.md` for the complete API reference and exception hierarchy.
#### Audit Logging
All security-relevant operations are recorded in the `audit_log` table with the following events:
+252
View File
@@ -0,0 +1,252 @@
Feature: Explicit exception handling and error classification
As a security-conscious developer
I want structured error types with redaction and classification
So that errors are safe for users and diagnostics are preserved
Background:
Given a security exceptions test environment
# -- Error code mapping ---------------------------------------------------
Scenario: Classify a ValidationError as 422
Given a ValidationError with message "field is invalid"
When I classify the security exception
Then the error code should be 422
And the error code name should be "VALIDATION_FAILED"
And the error handling category should be "CLIENT"
Scenario: Classify a ResourceNotFoundError as 404
Given a ResourceNotFoundError for resource "plan" with id "01PLAN001"
When I classify the security exception
Then the error code should be 404
And the error code name should be "NOT_FOUND"
Scenario: Classify an AuthenticationError as 401
Given an AuthenticationError with message "invalid token"
When I classify the security exception
Then the error code should be 401
And the error code name should be "UNAUTHORIZED"
Scenario: Classify an AuthorizationError as 403
Given an AuthorizationError with message "access denied"
When I classify the security exception
Then the error code should be 403
And the error code name should be "FORBIDDEN"
Scenario: Classify a RateLimitError as 429
Given a RateLimitError with message "slow down"
When I classify the security exception
Then the error code should be 429
And the error code name should be "RATE_LIMITED"
Scenario: Classify a DatabaseError as 522
Given a DatabaseError with message "connection lost"
When I classify the security exception
Then the error code should be 522
And the error handling category should be "SERVER"
Scenario: Classify a ConfigurationError as 525
Given a ConfigurationError with message "missing key"
When I classify the security exception
Then the error code should be 525
Scenario: Classify a NetworkError as 524
Given a NetworkError with message "timeout"
When I classify the security exception
Then the error code should be 524
Scenario: Classify a FileSystemError as 523
Given a FileSystemError with message "permission denied"
When I classify the security exception
Then the error code should be 523
Scenario: Classify a ProviderError as 520
Given a ProviderError with message "model failed"
When I classify the security exception
Then the error code should be 520
Scenario: Classify a TokenLimitExceededError as 526
Given a TokenLimitExceededError with message "context too long"
When I classify the security exception
Then the error code should be 526
Scenario: Classify a BusinessRuleViolation as 400
Given a BusinessRuleViolation with message "invalid transition"
When I classify the security exception
Then the error code should be 400
Scenario: Classify a PlanError as 400
Given a PlanError with message "plan stuck"
When I classify the security exception
Then the error code should be 400
Scenario: Classify an unknown Exception as 500
Given a bare Exception with message "something broke"
When I classify the security exception
Then the error code should be 500
And the error handling category should be "SERVER"
Scenario: Classify an ExecutionError as 521
Given an ExecutionError with message "agent failed"
When I classify the security exception
Then the error code should be 521
Scenario: Classify a StreamRoutingError as 528
Given a StreamRoutingError with message "routing failed"
When I classify the security exception
Then the error code should be 528
Scenario: Classify a ModelNotAvailableError as 527
Given a ModelNotAvailableError with message "deprecated"
When I classify the security exception
Then the error code should be 527
Scenario: Classify a ResourceConflictError as 409
Given a ResourceConflictError with message "already exists"
When I classify the security exception
Then the error code should be 409
Scenario: Classify a MissingConfigurationError as 525
Given a MissingConfigurationError with message "key not set"
When I classify the security exception
Then the error code should be 525
Scenario: Classify an ExternalServiceError as 503
Given an ExternalServiceError with message "service down"
When I classify the security exception
Then the error code should be 503
# -- Secret redaction -----------------------------------------------------
Scenario: Redact API key from error details
Given redaction test details with key "api_key" and value "sk-abc123secret"
When I redact the error details
Then the redacted detail "api_key" should be "***REDACTED***"
Scenario: Redact password from error details
Given redaction test details with key "password" and value "my-secret-pass"
When I redact the error details
Then the redacted detail "password" should be "***REDACTED***"
Scenario: Redact OpenAI key pattern from string value
Given redaction test details with key "message" and value "Failed with key sk-abcdefghijklmnopqrstuvwxyz"
When I redact the error details
Then the redacted detail "message" should contain "***REDACTED***"
And the redacted detail "message" should not contain "sk-abcdefghijklmnopqrstuvwxyz"
Scenario: Redact JWT token pattern from string value
Given redaction test details with key "log" and value "auth eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 failed"
When I redact the error details
Then the redacted detail "log" should contain "***REDACTED***"
Scenario: Redact nested dict values
Given redaction test details with nested key "config" containing secret_key "abc123"
When I redact the error details
Then the redacted nested detail "config" key "secret_key" should be "***REDACTED***"
Scenario: Non-sensitive values pass through unchanged
Given redaction test details with key "plan_id" and value "01PLAN001"
When I redact the error details
Then the redacted detail "plan_id" should be "01PLAN001"
Scenario: Redact GitHub PAT pattern from string value
Given redaction test details with key "msg" and value "token ghp_abcdefghijklmnopqrstuvwxyz0123456 expired"
When I redact the error details
Then the redacted detail "msg" should contain "***REDACTED***"
Scenario: Redact value with redact_value function
Given a redaction test string value "key=sk-abcdefghijklmnopqrstuvwxyz"
When I redact the string value
Then the redacted string should contain "***REDACTED***"
# -- Wrapping unexpected exceptions ----------------------------------------
Scenario: Wrap a bare Exception into CleverAgentsError
Given a bare Exception with message "internal crash"
When I wrap the unexpected exception
Then the wrapped error should be a CleverAgentsError
And the wrapped error message should be "An unexpected error occurred"
And the wrapped error details should have key "exception_type"
Scenario: Wrap preserves CleverAgentsError as-is
Given a CleverAgentsError with message "known issue"
When I wrap the unexpected exception
Then the wrapped error message should be "known issue"
Scenario: Wrap merges context into CleverAgentsError details
Given a CleverAgentsError with message "known issue"
And a wrapping context with key "plan_id" and value "01PLAN001"
When I wrap the unexpected exception
Then the wrapped error details should have key "plan_id"
Scenario: Wrap with custom safe message
Given a bare Exception with message "segfault"
When I wrap the unexpected exception with message "Operation failed"
Then the wrapped error message should be "Operation failed"
Scenario: Wrap redacts secrets in context
Given a bare Exception with message "oops"
And a wrapping context with key "api_key" and value "sk-secret123456789012345"
When I wrap the unexpected exception
Then the wrapped error details key "api_key" should be "***REDACTED***"
# -- CLI formatting -------------------------------------------------------
Scenario: Format error for CLI output
Given a ValidationError with message "name is required"
When I classify the security exception
And I format the error for CLI
Then the error CLI output should contain "422"
And the error CLI output should contain "VALIDATION_FAILED"
And the error CLI output should contain "name is required"
Scenario: Format error with details for CLI output
Given a detailed CleverAgentsError "db fail" with detail "table" as "plans"
When I classify the security exception
And I format the error for CLI
Then the error CLI output should contain "table: plans"
# -- Secret in message (S1) ------------------------------------------------
Scenario: classify_error redacts secrets embedded in exception message
Given a CleverAgentsError with message "key=sk-abcdefghijklmnopqrstuvwxyz leaked"
When I classify the security exception
Then the error info message should contain "***REDACTED***"
And the error info message should not contain "sk-abcdefghijklmnopqrstuvwxyz"
Scenario: Format CLI output redacts secrets from message
Given a CleverAgentsError with message "tok sk-abcdefghijklmnopqrstuvwxyz end"
When I classify the security exception
And I format the error for CLI
Then the error CLI output should contain "***REDACTED***"
And the error CLI output should not contain "sk-abcdefghijklmnopqrstuvwxyz"
# -- List value redaction (B2/T1) ------------------------------------------
Scenario: Redact secrets inside list values
Given redaction test details with key "keys" and list values "sk-abcdefghijklmnopqrstuvwxyz" and "normal"
When I redact the error details
Then the redacted detail "keys" should be a list
And the redacted list "keys" should contain "***REDACTED***"
And the redacted list "keys" should not contain "sk-abcdefghijklmnopqrstuvwxyz"
# -- Non-string non-dict passthrough (T4) ----------------------------------
Scenario: Integer values pass through redaction unchanged
Given redaction test details with key "count" and integer value 42
When I redact the error details
Then the redacted detail "count" should be integer 42
Scenario: Boolean values pass through redaction unchanged
Given redaction test details with key "enabled" and boolean value true
When I redact the error details
Then the redacted detail "enabled" should be boolean true
# -- Non-mutation of original details (B1) ---------------------------------
Scenario: Wrap does not mutate the original exception details dict
Given a tracked CleverAgentsError "original" with details key "a" value "1"
And a wrapping context with key "b" and value "2"
When I wrap the unexpected exception
Then the original exception details reference should not have key "b"
And the wrapped error details should have key "b"
+2 -1
View File
@@ -187,7 +187,8 @@ def step_verify_core_self_contained(context):
imports = context.imports.get(package, set())
# Filter out self-imports and allowed imports
# Core can import from config for dependency injection container setup
allowed_imports = {"core", "config"}
# and from shared for cross-cutting utilities (redaction, etc.)
allowed_imports = {"core", "config", "shared"}
other_imports = imports - allowed_imports
assert not other_imports, f"{package} imports from: {other_imports}"
+469
View File
@@ -0,0 +1,469 @@
"""Step definitions for security exception handling tests."""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.core.error_handling import (
ErrorCategory,
classify_error,
format_error_for_cli,
redact_error_details,
redact_value,
wrap_unexpected,
)
from cleveragents.core.exceptions import (
AuthenticationError,
AuthorizationError,
BusinessRuleViolation,
CleverAgentsError,
ConfigurationError,
DatabaseError,
ExecutionError,
ExternalServiceError,
FileSystemError,
MissingConfigurationError,
ModelNotAvailableError,
NetworkError,
PlanError,
ProviderError,
RateLimitError,
ResourceConflictError,
ResourceNotFoundError,
StreamRoutingError,
TokenLimitExceededError,
ValidationError,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a security exceptions test environment")
def step_given_sec_exc_env(context: Context) -> None:
context.sec_exc = None # Exception
context.sec_error_info = None # ErrorInfo | None
context.sec_details = {} # dict
context.sec_redacted = {} # dict
context.sec_wrapped = None # CleverAgentsError | None
context.sec_cli_output = ""
context.sec_wrap_context = {} # dict
context.sec_string_value = ""
context.sec_redacted_string = ""
# ---------------------------------------------------------------------------
# Given — exception types
# ---------------------------------------------------------------------------
@given('a ValidationError with message "{msg}"')
def step_given_validation_error(context: Context, msg: str) -> None:
context.sec_exc = ValidationError(msg)
@given('a ResourceNotFoundError for resource "{rtype}" with id "{rid}"')
def step_given_not_found(context: Context, rtype: str, rid: str) -> None:
context.sec_exc = ResourceNotFoundError(resource_type=rtype, resource_id=rid)
@given('an AuthenticationError with message "{msg}"')
def step_given_auth_error(context: Context, msg: str) -> None:
context.sec_exc = AuthenticationError(msg)
@given('an AuthorizationError with message "{msg}"')
def step_given_authz_error(context: Context, msg: str) -> None:
context.sec_exc = AuthorizationError(msg)
@given('a RateLimitError with message "{msg}"')
def step_given_rate_limit(context: Context, msg: str) -> None:
context.sec_exc = RateLimitError(msg)
@given('a DatabaseError with message "{msg}"')
def step_given_db_error(context: Context, msg: str) -> None:
context.sec_exc = DatabaseError(msg)
@given('a ConfigurationError with message "{msg}"')
def step_given_config_error(context: Context, msg: str) -> None:
context.sec_exc = ConfigurationError(msg)
@given('a NetworkError with message "{msg}"')
def step_given_network_error(context: Context, msg: str) -> None:
context.sec_exc = NetworkError(msg)
@given('a FileSystemError with message "{msg}"')
def step_given_fs_error(context: Context, msg: str) -> None:
context.sec_exc = FileSystemError(msg)
@given('a ProviderError with message "{msg}"')
def step_given_provider_error(context: Context, msg: str) -> None:
context.sec_exc = ProviderError(msg)
@given('a TokenLimitExceededError with message "{msg}"')
def step_given_token_limit(context: Context, msg: str) -> None:
context.sec_exc = TokenLimitExceededError(msg)
@given('a BusinessRuleViolation with message "{msg}"')
def step_given_biz_rule(context: Context, msg: str) -> None:
context.sec_exc = BusinessRuleViolation(msg)
@given('a PlanError with message "{msg}"')
def step_given_plan_error(context: Context, msg: str) -> None:
context.sec_exc = PlanError(msg)
@given('a bare Exception with message "{msg}"')
def step_given_bare_exception(context: Context, msg: str) -> None:
context.sec_exc = Exception(msg)
@given('an ExecutionError with message "{msg}"')
def step_given_execution_error(context: Context, msg: str) -> None:
context.sec_exc = ExecutionError(msg)
@given('a StreamRoutingError with message "{msg}"')
def step_given_stream_error(context: Context, msg: str) -> None:
context.sec_exc = StreamRoutingError(msg)
@given('a ModelNotAvailableError with message "{msg}"')
def step_given_model_unavail(context: Context, msg: str) -> None:
context.sec_exc = ModelNotAvailableError(msg)
@given('a ResourceConflictError with message "{msg}"')
def step_given_conflict(context: Context, msg: str) -> None:
context.sec_exc = ResourceConflictError(msg)
@given('a MissingConfigurationError with message "{msg}"')
def step_given_missing_config(context: Context, msg: str) -> None:
context.sec_exc = MissingConfigurationError(msg)
@given('an ExternalServiceError with message "{msg}"')
def step_given_ext_svc_error(context: Context, msg: str) -> None:
context.sec_exc = ExternalServiceError(msg)
@given('a CleverAgentsError with message "{msg}"')
def step_given_ca_error(context: Context, msg: str) -> None:
context.sec_exc = CleverAgentsError(msg)
@given('a detailed CleverAgentsError "{msg}" with detail "{k}" as "{v}"')
def step_given_ca_error_details(context: Context, msg: str, k: str, v: str) -> None:
context.sec_exc = CleverAgentsError(msg, details={k: v})
# ---------------------------------------------------------------------------
# Given — error details for redaction
# ---------------------------------------------------------------------------
@given('redaction test details with key "{key}" and value "{value}"')
def step_given_error_details(context: Context, key: str, value: str) -> None:
context.sec_details[key] = value
@given(
'redaction test details with nested key "{outer}" containing secret_key "{value}"'
)
def step_given_nested_details(context: Context, outer: str, value: str) -> None:
context.sec_details[outer] = {"secret_key": value}
@given('a redaction test string value "{value}"')
def step_given_string_value(context: Context, value: str) -> None:
context.sec_string_value = value
# ---------------------------------------------------------------------------
# Given — wrapping context
# ---------------------------------------------------------------------------
@given('a wrapping context with key "{key}" and value "{value}"')
def step_given_wrap_context(context: Context, key: str, value: str) -> None:
context.sec_wrap_context[key] = value
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("I classify the security exception")
def step_when_classify(context: Context) -> None:
context.sec_error_info = classify_error(context.sec_exc)
@when("I redact the error details")
def step_when_redact_details(context: Context) -> None:
context.sec_redacted = redact_error_details(context.sec_details)
@when("I redact the string value")
def step_when_redact_string(context: Context) -> None:
context.sec_redacted_string = redact_value(context.sec_string_value)
@when("I wrap the unexpected exception")
def step_when_wrap(context: Context) -> None:
ctx = context.sec_wrap_context or None
context.sec_wrapped = wrap_unexpected(context.sec_exc, context=ctx)
@when('I wrap the unexpected exception with message "{msg}"')
def step_when_wrap_msg(context: Context, msg: str) -> None:
context.sec_wrapped = wrap_unexpected(context.sec_exc, safe_message=msg)
@when("I format the error for CLI")
def step_when_format_cli(context: Context) -> None:
assert context.sec_error_info is not None
context.sec_cli_output = format_error_for_cli(context.sec_error_info)
# ---------------------------------------------------------------------------
# Then steps — classification
# ---------------------------------------------------------------------------
@then("the error code should be {code}")
def step_then_error_code(context: Context, code: str) -> None:
assert context.sec_error_info is not None
assert context.sec_error_info.code.value == int(code), (
f"Expected {code}, got {context.sec_error_info.code.value}"
)
@then('the error code name should be "{name}"')
def step_then_error_code_name(context: Context, name: str) -> None:
assert context.sec_error_info is not None
assert context.sec_error_info.code.name == name, (
f"Expected {name}, got {context.sec_error_info.code.name}"
)
@then('the error handling category should be "{cat}"')
def step_then_error_category(context: Context, cat: str) -> None:
assert context.sec_error_info is not None
expected = ErrorCategory.CLIENT if cat == "CLIENT" else ErrorCategory.SERVER
assert context.sec_error_info.category == expected
# ---------------------------------------------------------------------------
# Then steps — redaction
# ---------------------------------------------------------------------------
@then('the redacted detail "{key}" should be "{expected}"')
def step_then_redacted_detail(context: Context, key: str, expected: str) -> None:
assert context.sec_redacted[key] == expected, (
f"Expected {expected!r}, got {context.sec_redacted[key]!r}"
)
@then('the redacted detail "{key}" should contain "{text}"')
def step_then_redacted_contains(context: Context, key: str, text: str) -> None:
val = str(context.sec_redacted[key])
assert text in val, f"Expected '{text}' in {val!r}"
@then('the redacted detail "{key}" should not contain "{text}"')
def step_then_redacted_not_contains(context: Context, key: str, text: str) -> None:
val = str(context.sec_redacted[key])
assert text not in val, f"Did not expect '{text}' in {val!r}"
@then('the redacted nested detail "{outer}" key "{inner}" should be "{expected}"')
def step_then_nested_redacted(
context: Context, outer: str, inner: str, expected: str
) -> None:
nested = context.sec_redacted[outer]
assert nested[inner] == expected
@then('the redacted string should contain "{text}"')
def step_then_redacted_string_contains(context: Context, text: str) -> None:
assert text in context.sec_redacted_string
# ---------------------------------------------------------------------------
# Then steps — wrapping
# ---------------------------------------------------------------------------
@then("the wrapped error should be a CleverAgentsError")
def step_then_wrapped_is_ca(context: Context) -> None:
assert isinstance(context.sec_wrapped, CleverAgentsError)
@then('the wrapped error message should be "{msg}"')
def step_then_wrapped_msg(context: Context, msg: str) -> None:
assert context.sec_wrapped is not None
assert context.sec_wrapped.message == msg, (
f"Expected {msg!r}, got {context.sec_wrapped.message!r}"
)
@then('the wrapped error details should have key "{key}"')
def step_then_wrapped_details_key(context: Context, key: str) -> None:
assert context.sec_wrapped is not None
assert key in context.sec_wrapped.details, (
f"Key '{key}' not in {list(context.sec_wrapped.details.keys())}"
)
@then('the wrapped error details key "{key}" should be "{expected}"')
def step_then_wrapped_detail_value(context: Context, key: str, expected: str) -> None:
assert context.sec_wrapped is not None
assert context.sec_wrapped.details[key] == expected
# ---------------------------------------------------------------------------
# Then steps — CLI formatting
# ---------------------------------------------------------------------------
@then('the error CLI output should contain "{text}"')
def step_then_cli_contains(context: Context, text: str) -> None:
assert text in context.sec_cli_output, (
f"Expected '{text}' in:\n{context.sec_cli_output}"
)
@then('the error CLI output should not contain "{text}"')
def step_then_cli_not_contains(context: Context, text: str) -> None:
assert text not in context.sec_cli_output, (
f"Did not expect '{text}' in:\n{context.sec_cli_output}"
)
# ---------------------------------------------------------------------------
# Given steps — list / integer / boolean details
# ---------------------------------------------------------------------------
@given('redaction test details with key "{key}" and list values "{v1}" and "{v2}"')
def step_given_list_details(context: Context, key: str, v1: str, v2: str) -> None:
context.sec_details[key] = [v1, v2]
@given('redaction test details with key "{key}" and integer value {val:d}')
def step_given_int_details(context: Context, key: str, val: int) -> None:
context.sec_details[key] = val
@given('redaction test details with key "{key}" and boolean value {val}')
def step_given_bool_details(context: Context, key: str, val: str) -> None:
context.sec_details[key] = val.lower() == "true"
# ---------------------------------------------------------------------------
# Given steps — tracked details for non-mutation test (B1)
# ---------------------------------------------------------------------------
@given('a tracked CleverAgentsError "{msg}" with details key "{k}" value "{v}"')
def step_given_ca_tracked(context: Context, msg: str, k: str, v: str) -> None:
context.sec_exc = CleverAgentsError(msg, details={k: v})
# Capture a snapshot of the original details *before* wrapping.
context.sec_original_details_ref = dict(context.sec_exc.details)
# ---------------------------------------------------------------------------
# Then steps — error info message assertions (S1)
# ---------------------------------------------------------------------------
@then('the error info message should contain "{text}"')
def step_then_info_msg_contains(context: Context, text: str) -> None:
assert context.sec_error_info is not None
assert text in context.sec_error_info.message, (
f"Expected '{text}' in message: {context.sec_error_info.message!r}"
)
@then('the error info message should not contain "{text}"')
def step_then_info_msg_not_contains(context: Context, text: str) -> None:
assert context.sec_error_info is not None
assert text not in context.sec_error_info.message, (
f"Did not expect '{text}' in message: {context.sec_error_info.message!r}"
)
# ---------------------------------------------------------------------------
# Then steps — list redaction (B2/T1)
# ---------------------------------------------------------------------------
@then('the redacted detail "{key}" should be a list')
def step_then_is_list(context: Context, key: str) -> None:
assert isinstance(context.sec_redacted[key], list), (
f"Expected list, got {type(context.sec_redacted[key]).__name__}"
)
@then('the redacted list "{key}" should contain "{text}"')
def step_then_list_contains(context: Context, key: str, text: str) -> None:
values = [str(v) for v in context.sec_redacted[key]]
assert any(text in v for v in values), f"Expected '{text}' in one of: {values}"
@then('the redacted list "{key}" should not contain "{text}"')
def step_then_list_not_contains(context: Context, key: str, text: str) -> None:
values = [str(v) for v in context.sec_redacted[key]]
assert all(text not in v for v in values), (
f"Did not expect '{text}' in any of: {values}"
)
# ---------------------------------------------------------------------------
# Then steps — non-string passthrough (T4)
# ---------------------------------------------------------------------------
@then('the redacted detail "{key}" should be integer {val:d}')
def step_then_int_detail(context: Context, key: str, val: int) -> None:
assert context.sec_redacted[key] == val, (
f"Expected {val}, got {context.sec_redacted[key]!r}"
)
@then('the redacted detail "{key}" should be boolean {val}')
def step_then_bool_detail(context: Context, key: str, val: str) -> None:
expected = val.lower() == "true"
assert context.sec_redacted[key] is expected, (
f"Expected {expected}, got {context.sec_redacted[key]!r}"
)
# ---------------------------------------------------------------------------
# Then steps — non-mutation (B1)
# ---------------------------------------------------------------------------
@then('the original exception details reference should not have key "{key}"')
def step_then_orig_ref_no_key(context: Context, key: str) -> None:
assert key not in context.sec_original_details_ref, (
f"Key '{key}' unexpectedly found in original snapshot: "
f"{list(context.sec_original_details_ref.keys())}"
)
+109
View File
@@ -0,0 +1,109 @@
"""Robot Framework helper for exception handling tests.
Provides a CLI-style interface for Robot to invoke error
classification, redaction, and wrapping.
Usage::
python robot/helper_security_exceptions.py classify <error_type>
python robot/helper_security_exceptions.py redact <key> <value>
python robot/helper_security_exceptions.py wrap <message>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Ensure the src directory is on the import path.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.core.error_handling import ( # noqa: E402
classify_error,
format_error_for_cli,
redact_error_details,
wrap_unexpected,
)
from cleveragents.core.exceptions import ( # noqa: E402
AuthenticationError,
CleverAgentsError,
ConfigurationError,
DatabaseError,
ValidationError,
)
_ERROR_TYPES: dict[str, type[Exception]] = {
"validation": ValidationError,
"auth": AuthenticationError,
"config": ConfigurationError,
"database": DatabaseError,
"bare": Exception,
}
def _cmd_classify(args: list[str]) -> int:
"""Classify an error by type name."""
if len(args) < 1:
print("Usage: classify <error_type>")
return 1
etype = args[0]
exc_cls = _ERROR_TYPES.get(etype, Exception)
exc = exc_cls("test error")
info = classify_error(exc)
output = format_error_for_cli(info)
print(f"exc-classify-ok: code={info.code.value} name={info.code.name}")
print(output)
return 0
def _cmd_redact(args: list[str]) -> int:
"""Redact a key-value pair."""
if len(args) < 2:
print("Usage: redact <key> <value>")
return 1
details = {args[0]: args[1]}
redacted = redact_error_details(details)
print(f"exc-redact-ok: {json.dumps(redacted)}")
return 0
def _cmd_wrap(args: list[str]) -> int:
"""Wrap a bare Exception."""
if len(args) < 1:
print("Usage: wrap <message>")
return 1
exc = Exception(args[0])
wrapped = wrap_unexpected(exc)
is_ca = isinstance(wrapped, CleverAgentsError)
print(f"exc-wrap-ok: is_ca={is_ca} msg={wrapped.message}")
return 0
def main() -> int:
"""Entry point called by Robot Framework ``Run Process``."""
if len(sys.argv) < 2:
print("Usage: helper_security_exceptions.py <command> ...")
return 1
command = sys.argv[1]
args = sys.argv[2:]
dispatch = {
"classify": _cmd_classify,
"redact": _cmd_redact,
"wrap": _cmd_wrap,
}
handler = dispatch.get(command)
if handler is None:
print(f"Unknown command: {command}")
return 1
return handler(args)
if __name__ == "__main__":
sys.exit(main())
+76
View File
@@ -0,0 +1,76 @@
*** Settings ***
Documentation Integration tests for exception handling module
Resource common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Test Cases ***
Classify Validation Error
[Documentation] Verify ValidationError classifies as 422
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... classify validation
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} code=422
Classify Database Error
[Documentation] Verify DatabaseError classifies as 522
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... classify database
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} code=522
Redact Secret Key
[Documentation] Verify api_key is redacted
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... redact api_key sk-secret12345
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} ***REDACTED***
Wrap Bare Exception
[Documentation] Verify bare Exception is wrapped in CleverAgentsError
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... wrap unexpected-crash
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} is_ca=True
Redact Non-Secret Key
[Documentation] Verify non-secret values pass through
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... redact plan_id 01PLAN001
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} 01PLAN001
Should Not Contain ${result.stdout} REDACTED
Helper No Args
[Documentation] Verify helper exits with error when no args given
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
Helper Unknown Command
[Documentation] Verify helper exits with error for unknown command
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... bogus
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Unknown command
Helper Classify Missing Type
[Documentation] Verify classify with no type arg prints usage
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... classify
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
Helper Redact Missing Value
[Documentation] Verify redact with only one arg prints usage
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... redact api_key
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
Helper Wrap Missing Message
[Documentation] Verify wrap with no message arg prints usage
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_exceptions.py
... wrap
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
@@ -38,12 +38,11 @@ VALID_EVENT_TYPES: frozenset[str] = frozenset(
}
)
# Timestamp format matching the spec: ``strftime('%Y-%m-%dT%H:%M:%f')``
# which produces ``YYYY-MM-DDTHH:MM:ffffff`` (no seconds field, microseconds
# immediately after minutes). All timestamps must use this format so that
# lexicographic ``>=`` / ``<`` comparisons on the ``created_at`` TEXT column
# remain correct.
_TIMESTAMP_FMT = "%Y-%m-%dT%H:%M:%f"
# Timestamp format: ``strftime('%Y-%m-%dT%H:%M:%S.%f')``
# which produces ``YYYY-MM-DDTHH:MM:SS.ffffff`` (ISO-8601 with microseconds).
# All timestamps must use this format so that lexicographic ``>=`` / ``<``
# comparisons on the ``created_at`` TEXT column remain correct.
_TIMESTAMP_FMT = "%Y-%m-%dT%H:%M:%S.%f"
# ── Domain data class ────────────────────────────────────────────
@@ -187,7 +186,7 @@ class AuditService:
plan_id: Filter by exact plan_id.
project_name: Filter by exact project name.
event_type: Filter by event type.
since: ISO-8601 timestamp (``YYYY-MM-DDTHH:MM:ffffff``
since: ISO-8601 timestamp (``YYYY-MM-DDTHH:MM:SS.ffffff``
format recommended to match the stored format); only
entries created at or after this time are returned.
The comparison is lexicographic on the TEXT column.
+29 -10
View File
@@ -386,15 +386,26 @@ def init(
default_filters=default_filters,
)
except CleverAgentsError as e:
from cleveragents.core.error_handling import classify_error
err_console = get_err_console()
err_console.print(f"[red]Error:[/red] {e.message}")
if hasattr(e, "details") and e.details:
for key, value in e.details.items():
info = classify_error(e)
err_console.print(
f"[red]Error [{info.code.value}] {info.code.name}:[/red] {info.message}"
)
if info.details:
for key, value in info.details.items():
err_console.print(f" {key}: {value}")
raise typer.Exit(1) from e
except Exception as e:
from cleveragents.core.error_handling import classify_error, wrap_unexpected
err_console = get_err_console()
err_console.print(f"[red]Unexpected error:[/red] {e}")
safe = wrap_unexpected(e)
info = classify_error(safe)
err_console.print(
f"[red]Error [{info.code.value}] {info.code.name}:[/red] {info.message}"
)
raise typer.Exit(1) from e
@@ -625,14 +636,16 @@ def main(args: list[str] | None = None) -> int:
except typer.Abort:
return 1
except CleverAgentsError as e:
from cleveragents.shared.redaction import redact_dict, redact_value
from cleveragents.core.error_handling import classify_error
err_console = get_err_console()
err_console.print(f"[red]Error:[/red] {redact_value(e.message)}")
if e.details:
safe_details = redact_dict(e.details)
info = classify_error(e)
err_console.print(
f"[red]Error [{info.code.value}] {info.code.name}:[/red] {info.message}"
)
if info.details:
err_console.print("[dim]Details:[/dim]")
for key, value in safe_details.items():
for key, value in info.details.items():
err_console.print(f" {key}: {value}")
return 1
except KeyboardInterrupt:
@@ -640,8 +653,14 @@ def main(args: list[str] | None = None) -> int:
err_console.print("\n[yellow]Interrupted by user[/yellow]")
return 130
except Exception as e:
from cleveragents.core.error_handling import classify_error, wrap_unexpected
err_console = get_err_console()
err_console.print(f"[red]Unexpected error:[/red] {e}")
safe = wrap_unexpected(e)
info = classify_error(safe)
err_console.print(
f"[red]Error [{info.code.value}] {info.code.name}:[/red] {info.message}"
)
return 1
+22 -1
View File
@@ -5,9 +5,30 @@ across multiple layers but don't belong to any specific layer.
Includes:
- Exception hierarchy
- Error handling (classification, redaction, wrapping)
- Base classes and interfaces
- Common types and enums
- Logging configuration
"""
__all__ = []
from cleveragents.core.error_handling import (
ErrorCategory,
ErrorCode,
ErrorInfo,
classify_error,
format_error_for_cli,
redact_error_details,
redact_value,
wrap_unexpected,
)
__all__: list[str] = [
"ErrorCategory",
"ErrorCode",
"ErrorInfo",
"classify_error",
"format_error_for_cli",
"redact_error_details",
"redact_value",
"wrap_unexpected",
]
+340
View File
@@ -0,0 +1,340 @@
"""Explicit exception handling, error codes, and secret redaction.
Provides a unified error handling layer that:
- Maps exception types to HTTP-like error categories for consistent
CLI and API output.
- Redacts sensitive values from error details before logging
(delegates to :mod:`cleveragents.shared.redaction`).
- Wraps unexpected exceptions in ``CleverAgentsError`` with a safe,
user-facing message that hides internal stack details.
Usage::
from cleveragents.core.error_handling import (
ErrorCode,
classify_error,
redact_error_details,
wrap_unexpected,
)
try:
risky_operation()
except Exception as exc:
safe = wrap_unexpected(exc, context={"plan_id": plan_id})
raise safe from exc
"""
from __future__ import annotations
import logging
import traceback
from enum import IntEnum
from typing import Any
from cleveragents.core.exceptions import (
AuthenticationError,
AuthorizationError,
BusinessRuleViolation,
CleverAgentsError,
ConfigurationError,
DatabaseError,
DomainError,
ExecutionError,
ExternalServiceError,
FileSystemError,
InfrastructureError,
MissingConfigurationError,
ModelNotAvailableError,
NetworkError,
PlanError,
ProviderError,
RateLimitError,
ResourceConflictError,
ResourceNotFoundError,
StreamRoutingError,
TokenLimitExceededError,
ValidationError,
)
from cleveragents.shared.redaction import (
redact_dict as _shared_redact_dict,
)
from cleveragents.shared.redaction import (
redact_value, # re-exported
)
from cleveragents.shared.redaction import (
register_pattern as _register_pattern,
)
__all__: list[str] = [
"ErrorCategory",
"ErrorCode",
"ErrorInfo",
"classify_error",
"format_error_for_cli",
"redact_error_details",
"redact_value",
"wrap_unexpected",
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Register additional secret patterns discovered during security audit.
# These extend the shared redaction module so *all* redaction paths
# (structlog, CLI, error details) benefit from the same patterns.
# ---------------------------------------------------------------------------
_register_pattern(r"eyJ[a-zA-Z0-9_-]{10,}") # JWT tokens
_register_pattern(r"ghp_[a-zA-Z0-9]{30,}") # GitHub PATs
_register_pattern(r"ghs_[a-zA-Z0-9]{30,}") # GitHub App tokens
_register_pattern(r"glpat-[a-zA-Z0-9_-]{20,}") # GitLab PATs
# ---------------------------------------------------------------------------
# Error code registry
# ---------------------------------------------------------------------------
class ErrorCode(IntEnum):
"""HTTP-like error codes for CLI output consistency.
Maps to conventional HTTP status code ranges:
- 400-range: client/input errors
- 500-range: server/internal errors
"""
# 400-range: Input / domain errors
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
CONFLICT = 409
VALIDATION_FAILED = 422
RATE_LIMITED = 429
# 500-range: Internal / infrastructure errors
INTERNAL = 500
NOT_IMPLEMENTED = 501
SERVICE_UNAVAILABLE = 503
PROVIDER_ERROR = 520
EXECUTION_ERROR = 521
DATABASE_ERROR = 522
FILESYSTEM_ERROR = 523
NETWORK_ERROR = 524
CONFIGURATION_ERROR = 525
TOKEN_LIMIT = 526
MODEL_UNAVAILABLE = 527
STREAM_ERROR = 528
class ErrorCategory(IntEnum):
"""Broad error category for grouping."""
CLIENT = 4
SERVER = 5
# ---------------------------------------------------------------------------
# Exception -> ErrorCode mapping (dict-based for O(1) MRO lookup)
# ---------------------------------------------------------------------------
_EXCEPTION_CODE_MAP: dict[type[Exception], ErrorCode] = {
# Domain / client errors
ValidationError: ErrorCode.VALIDATION_FAILED,
ResourceNotFoundError: ErrorCode.NOT_FOUND,
ResourceConflictError: ErrorCode.CONFLICT,
BusinessRuleViolation: ErrorCode.BAD_REQUEST,
PlanError: ErrorCode.BAD_REQUEST,
DomainError: ErrorCode.BAD_REQUEST,
# Auth
AuthenticationError: ErrorCode.UNAUTHORIZED,
AuthorizationError: ErrorCode.FORBIDDEN,
# Config
MissingConfigurationError: ErrorCode.CONFIGURATION_ERROR,
ConfigurationError: ErrorCode.CONFIGURATION_ERROR,
# Provider
RateLimitError: ErrorCode.RATE_LIMITED,
TokenLimitExceededError: ErrorCode.TOKEN_LIMIT,
ModelNotAvailableError: ErrorCode.MODEL_UNAVAILABLE,
ProviderError: ErrorCode.PROVIDER_ERROR,
# Infrastructure
DatabaseError: ErrorCode.DATABASE_ERROR,
NetworkError: ErrorCode.NETWORK_ERROR,
ExternalServiceError: ErrorCode.SERVICE_UNAVAILABLE,
FileSystemError: ErrorCode.FILESYSTEM_ERROR,
InfrastructureError: ErrorCode.INTERNAL,
# Execution
StreamRoutingError: ErrorCode.STREAM_ERROR,
ExecutionError: ErrorCode.EXECUTION_ERROR,
# Catch-all for CleverAgentsError
CleverAgentsError: ErrorCode.INTERNAL,
}
# ---------------------------------------------------------------------------
# Secret redaction — delegates to cleveragents.shared.redaction
# ---------------------------------------------------------------------------
def redact_error_details(details: dict[str, Any]) -> dict[str, Any]:
"""Redact sensitive values from error details.
Delegates to :func:`cleveragents.shared.redaction.redact_dict` with
``show_secrets=False`` so error details are **always** redacted
regardless of the global ``show_secrets`` flag.
Args:
details: The error details dict.
Returns:
A new dict with sensitive values redacted.
"""
return _shared_redact_dict(details, show_secrets=False)
# ---------------------------------------------------------------------------
# Error classification
# ---------------------------------------------------------------------------
class ErrorInfo:
"""Structured error information with code and redacted details.
Attributes:
code: HTTP-like error code.
category: Broad error category (client or server).
message: User-safe error message (secrets redacted).
exception_type: The original exception class name.
details: Redacted error details dict.
"""
__slots__ = ("category", "code", "details", "exception_type", "message")
def __init__(
self,
*,
code: ErrorCode,
message: str,
exception_type: str,
details: dict[str, Any] | None = None,
) -> None:
self.code = code
self.category = ErrorCategory.CLIENT if code < 500 else ErrorCategory.SERVER
self.message = message
self.exception_type = exception_type
self.details = details or {}
def classify_error(exc: Exception) -> ErrorInfo:
"""Classify an exception into a structured :class:`ErrorInfo`.
Walks the exception's MRO to find the most specific match in
``_EXCEPTION_CODE_MAP``, redacts details **and message**, and
returns a safe ``ErrorInfo``.
Args:
exc: The exception to classify.
Returns:
An ``ErrorInfo`` with the error code, redacted details, and
user-safe message.
"""
code = ErrorCode.INTERNAL
for cls in type(exc).__mro__:
if cls in _EXCEPTION_CODE_MAP:
code = _EXCEPTION_CODE_MAP[cls]
break
details: dict[str, Any] = {}
if isinstance(exc, CleverAgentsError) and exc.details:
details = redact_error_details(exc.details)
return ErrorInfo(
code=code,
message=redact_value(str(exc)),
exception_type=type(exc).__name__,
details=details,
)
# ---------------------------------------------------------------------------
# Wrapping unexpected exceptions
# ---------------------------------------------------------------------------
def wrap_unexpected(
exc: Exception,
*,
context: dict[str, Any] | None = None,
safe_message: str = "An unexpected error occurred",
) -> CleverAgentsError:
"""Wrap an unexpected exception in ``CleverAgentsError``.
If the exception is already a ``CleverAgentsError``, it is returned
with optional context merged into a **copy** of its details (the
original ``details`` dict object is not mutated).
For non-``CleverAgentsError`` exceptions, a new error is created
with the *safe_message* (hiding internal details from users).
The original exception type and a sanitised traceback snippet are
stored in the ``details`` dict for diagnostics.
Args:
exc: The exception to wrap.
context: Optional extra context (e.g. ``plan_id``,
``project_name``) to merge into the error details.
safe_message: User-facing message when wrapping unknown
exceptions.
Returns:
A ``CleverAgentsError`` instance.
"""
extra_context = redact_error_details(context) if context else {}
if isinstance(exc, CleverAgentsError):
if extra_context:
# Create a new dict so the original details object is not mutated.
exc.details = {**exc.details, **extra_context}
return exc
# Build sanitised details for diagnostics.
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
tb_text = "".join(tb_lines[-3:]) # last 3 lines only
tb_text = redact_value(tb_text)
details: dict[str, Any] = {
"exception_type": type(exc).__name__,
"traceback_snippet": tb_text,
}
details.update(extra_context)
logger.error(
"Wrapping unexpected exception: %s: %s",
type(exc).__name__,
redact_value(str(exc)),
)
return CleverAgentsError(safe_message, details=details)
# ---------------------------------------------------------------------------
# CLI formatting
# ---------------------------------------------------------------------------
def format_error_for_cli(info: ErrorInfo) -> str:
"""Format an ``ErrorInfo`` for human-readable CLI output.
Args:
info: The structured error info.
Returns:
Multi-line string suitable for terminal display.
"""
lines: list[str] = [
f"Error [{info.code.value}] {info.code.name}: {info.message}",
]
if info.details:
for key, value in info.details.items():
lines.append(f" {key}: {value}")
return "\n".join(lines)