@@ -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"})
|
||||
@@ -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
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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())}"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user