diff --git a/benchmarks/security_exception_bench.py b/benchmarks/security_exception_bench.py new file mode 100644 index 000000000..a96d35dca --- /dev/null +++ b/benchmarks/security_exception_bench.py @@ -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"}) diff --git a/docs/reference/error_handling.md b/docs/reference/error_handling.md new file mode 100644 index 000000000..562f76bcd --- /dev/null +++ b/docs/reference/error_handling.md @@ -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 +``` diff --git a/docs/specification.md b/docs/specification.md index 12fb0001e..6d04779e9 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -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: diff --git a/features/security_exceptions.feature b/features/security_exceptions.feature new file mode 100644 index 000000000..ec1852ce6 --- /dev/null +++ b/features/security_exceptions.feature @@ -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" diff --git a/features/steps/architecture_steps.py b/features/steps/architecture_steps.py index 9e941f4b4..1ac704f73 100644 --- a/features/steps/architecture_steps.py +++ b/features/steps/architecture_steps.py @@ -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}" diff --git a/features/steps/security_exception_steps.py b/features/steps/security_exception_steps.py new file mode 100644 index 000000000..bc44440b2 --- /dev/null +++ b/features/steps/security_exception_steps.py @@ -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())}" + ) diff --git a/robot/helper_security_exceptions.py b/robot/helper_security_exceptions.py new file mode 100644 index 000000000..9599d635f --- /dev/null +++ b/robot/helper_security_exceptions.py @@ -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 + python robot/helper_security_exceptions.py redact + python robot/helper_security_exceptions.py wrap +""" + +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 ") + 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 ") + 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 ") + 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 ...") + 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()) diff --git a/robot/security_exceptions.robot b/robot/security_exceptions.robot new file mode 100644 index 000000000..46eb20295 --- /dev/null +++ b/robot/security_exceptions.robot @@ -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 diff --git a/src/cleveragents/application/services/audit_service.py b/src/cleveragents/application/services/audit_service.py index 38e96df47..91e2d31f1 100644 --- a/src/cleveragents/application/services/audit_service.py +++ b/src/cleveragents/application/services/audit_service.py @@ -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. diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 7863c43d5..cb1e052f7 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -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 diff --git a/src/cleveragents/core/__init__.py b/src/cleveragents/core/__init__.py index 8dd8e2e60..f0b9bac58 100644 --- a/src/cleveragents/core/__init__.py +++ b/src/cleveragents/core/__init__.py @@ -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", +] diff --git a/src/cleveragents/core/error_handling.py b/src/cleveragents/core/error_handling.py new file mode 100644 index 000000000..2d71a36e0 --- /dev/null +++ b/src/cleveragents/core/error_handling.py @@ -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)