Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 492323a2a9 | |||
| 1712b0008a | |||
| 2d1676f655 | |||
| 23d73e7fb2 | |||
| 86f96f299e | |||
| 5c5309f35d | |||
| f4cea72248 | |||
| 761622f746 | |||
| bf52a9c648 | |||
|
97c1007bb5
|
@@ -0,0 +1,63 @@
|
||||
Feature: Auth middleware emits security events
|
||||
As a platform operator
|
||||
I want authentication outcomes emitted as domain events
|
||||
So the audit pipeline captures auth success and failure details
|
||||
|
||||
Scenario: Successful authentication emits AUTH_SUCCESS
|
||||
Given an auth middleware with expected token "tok_secret_123"
|
||||
And an auth recording event bus
|
||||
When I authenticate with token "tok_secret_123" identity "alice@example.com" and ip "10.0.0.5"
|
||||
Then the auth result should be true
|
||||
And the auth event bus should have exactly 1 event
|
||||
And the latest auth event type should be AUTH_SUCCESS
|
||||
And the latest auth event should contain detail "user_identity" with value "alice@example.com"
|
||||
And the latest auth event should contain detail "ip_address" with value "10.0.0.5"
|
||||
And the latest auth event should contain detail "token_prefix" with value "tok_se..."
|
||||
|
||||
Scenario: Short token success masks token_prefix
|
||||
Given an auth middleware with expected token "short"
|
||||
And an auth recording event bus
|
||||
When I authenticate with token "short" identity "alice@example.com" and ip "10.0.0.5"
|
||||
Then the auth result should be true
|
||||
And the latest auth event type should be AUTH_SUCCESS
|
||||
And the latest auth event should contain detail "token_prefix" with value "***..."
|
||||
|
||||
Scenario: Failed authentication emits AUTH_FAILURE
|
||||
Given an auth middleware with expected token "tok_secret_123"
|
||||
And an auth recording event bus
|
||||
When I authenticate with token "tok_wrong_999" identity "alice@example.com" and ip "10.0.0.5"
|
||||
Then the auth result should be false
|
||||
And the auth event bus should have exactly 1 event
|
||||
And the latest auth event type should be AUTH_FAILURE
|
||||
And the latest auth event should contain detail "attempted_identity" with value "alice@example.com"
|
||||
And the latest auth event should contain detail "failure_reason" with value "invalid_token"
|
||||
And the latest auth event should contain detail "token_prefix" with value "tok_wr..."
|
||||
|
||||
Scenario: Missing configured token emits AUTH_FAILURE
|
||||
Given an auth middleware with no configured token
|
||||
And an auth recording event bus
|
||||
When I authenticate with token "tok_any_111" identity "bob@example.com" and ip "10.0.0.9"
|
||||
Then the auth result should be false
|
||||
And the latest auth event type should be AUTH_FAILURE
|
||||
And the latest auth event should contain detail "failure_reason" with value "token_not_configured"
|
||||
|
||||
Scenario: Empty authentication token is rejected before emission
|
||||
Given an auth middleware with expected token "tok_secret_123"
|
||||
And an auth recording event bus
|
||||
When I try to authenticate with an empty token
|
||||
Then an auth ValueError should be raised
|
||||
And the auth event bus should have exactly 0 event
|
||||
|
||||
Scenario: Auth middleware events persist through AuditEventSubscriber
|
||||
Given auth middleware is wired to AuditEventSubscriber with expected token "tok_secret_123"
|
||||
When I authenticate with token "tok_secret_123" identity "carol@example.com" and ip "10.1.2.3"
|
||||
And I authenticate with token "tok_bad_123" identity "dave@example.com" and ip "10.1.2.4"
|
||||
Then the auth result history should be "true,false"
|
||||
And the audit log should contain 1 auth_success entry from middleware
|
||||
And the latest auth_success audit entry should contain detail "ip_address" with value "10.1.2.3"
|
||||
And the latest auth_success audit entry should contain detail "token_prefix" with value "tok_se..."
|
||||
And the audit log should contain 1 auth_failure entry from middleware
|
||||
And the latest auth_failure audit entry should contain detail "ip_address" with value "10.1.2.4"
|
||||
And the latest auth_failure audit entry should contain detail "attempted_identity" with value "dave@example.com"
|
||||
And the latest auth_failure audit entry should contain detail "failure_reason" with value "invalid_token"
|
||||
And the latest auth_failure audit entry should contain detail "token_prefix" with value "tok_ba..."
|
||||
@@ -0,0 +1,53 @@
|
||||
Feature: Database URL sanitisation — credentials must never be exposed
|
||||
|
||||
As a CleverAgents operator
|
||||
I want database URLs in CLI output to have their credentials masked
|
||||
So that running ``agents info`` never leaks passwords or API tokens
|
||||
|
||||
@tdd_bug_8395
|
||||
Scenario Outline: PostgreSQL URL with user and password — all are masked
|
||||
Given the database url is "<url>"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| url | expected |
|
||||
| postgresql://user:secret@localhost/mydb | postgresql://***:***@localhost/mydb |
|
||||
| postgresql://admin:p%40ssw0rd@db.example.com:5432/agents | postgresql://***:***@db.example.com:5432/agents |
|
||||
| postgresql://readonly:r0ad_only@pg.cluster.internal:5433/production | postgresql://***:***@pg.cluster.internal:5433/production |
|
||||
|
||||
Scenario Outline: MySQL URL with credentials — all are masked
|
||||
Given the database url is "<url>"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| url | expected |
|
||||
| mysql://app:s3cret@mysql.internal:3306/agents | mysql://***:***@mysql.internal:3306/agents |
|
||||
| mysql+pymysql://root:toor@localhost/testdb | mysql+pymysql://***:***@localhost/testdb |
|
||||
|
||||
Scenario Outline: SQLite URLs — remain unchanged (no credentials)
|
||||
Given the database url is "<url>"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| url | expected |
|
||||
| sqlite:///data/cleveragents.db | sqlite:///data/cleveragents.db |
|
||||
| sqlite:////absolute/path/to/db.sqlite | sqlite:////absolute/path/to/db.sqlite |
|
||||
| memory | memory |
|
||||
|
||||
Scenario: Username-only URL — password is still masked
|
||||
Given the database url is "postgres://deploy@db.example.com/prod"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "postgres://***:***@db.example.com/prod"
|
||||
|
||||
Scenario: SQLite URL without credentials remains unchanged
|
||||
Given the database url is "sqlite:///test.db"
|
||||
When I run sanitise_db_url
|
||||
Then the sanitised database url should be "sqlite:///test.db"
|
||||
|
||||
Scenario: build_info_data returns sanitised database URL
|
||||
Given a mock settings object with database url "postgresql://admin:supersecret@host.example.com:5432/mydb"
|
||||
When I call build_info_data
|
||||
Then the database field in info data should be "postgresql://***:***@host.example.com:5432/mydb"
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Minimal in-memory event bus used for Behave assertions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
|
||||
class RecordingEventBus:
|
||||
"""Minimal in-memory event bus used for Behave assertions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[DomainEvent] = []
|
||||
|
||||
def emit(self, event: DomainEvent) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: Callable[[DomainEvent], None],
|
||||
) -> None:
|
||||
_ = (event_type, handler)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Step definitions for auth_middleware_events.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.application.services.audit_event_subscriber import (
|
||||
AuditEventSubscriber,
|
||||
)
|
||||
from cleveragents.application.services.audit_service import AuditService
|
||||
from cleveragents.application.services.auth_middleware import TokenAuthMiddleware
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
from features.mocks.recording_event_bus import RecordingEventBus
|
||||
|
||||
|
||||
def _fresh_audit_service() -> AuditService:
|
||||
Settings._instance = None
|
||||
settings = Settings(database_url="sqlite:///:memory:")
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return AuditService(settings=settings, session=session)
|
||||
|
||||
|
||||
@given('an auth middleware with expected token "{token}"')
|
||||
def step_auth_middleware_with_token(context: Context, token: str) -> None:
|
||||
context.expected_token = token
|
||||
|
||||
|
||||
@given("an auth middleware with no configured token")
|
||||
def step_auth_middleware_no_token(context: Context) -> None:
|
||||
context.expected_token = None
|
||||
|
||||
|
||||
@given("an auth recording event bus")
|
||||
def step_auth_recording_event_bus(context: Context) -> None:
|
||||
context.event_bus = RecordingEventBus()
|
||||
context.auth_middleware = TokenAuthMiddleware(
|
||||
expected_token=context.expected_token,
|
||||
event_bus=context.event_bus,
|
||||
)
|
||||
context.auth_result_history = []
|
||||
|
||||
|
||||
@given('auth middleware is wired to AuditEventSubscriber with expected token "{token}"')
|
||||
def step_auth_middleware_audit_pipeline(context: Context, token: str) -> None:
|
||||
context.audit_service = _fresh_audit_service()
|
||||
context.event_bus = ReactiveEventBus()
|
||||
context.audit_subscriber = AuditEventSubscriber(
|
||||
audit_service=context.audit_service,
|
||||
event_bus=context.event_bus,
|
||||
)
|
||||
context.auth_middleware = TokenAuthMiddleware(
|
||||
expected_token=token,
|
||||
event_bus=context.event_bus,
|
||||
)
|
||||
context.auth_result_history = []
|
||||
|
||||
|
||||
@when('I authenticate with token "{token}" identity "{identity}" and ip "{ip_address}"')
|
||||
def step_authenticate(
|
||||
context: Context, token: str, identity: str, ip_address: str
|
||||
) -> None:
|
||||
context.auth_result = context.auth_middleware.authenticate(
|
||||
token,
|
||||
identity=identity,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
context.auth_result_history.append(context.auth_result)
|
||||
|
||||
|
||||
@when("I try to authenticate with an empty token")
|
||||
def step_try_authenticate_empty_token(context: Context) -> None:
|
||||
context.auth_error = None
|
||||
try:
|
||||
context.auth_middleware.authenticate("", identity="empty@example.com")
|
||||
except ValueError as exc:
|
||||
context.auth_error = exc
|
||||
|
||||
|
||||
@then("the auth result should be true")
|
||||
def step_auth_result_true(context: Context) -> None:
|
||||
assert context.auth_result is True
|
||||
|
||||
|
||||
@then("the auth result should be false")
|
||||
def step_auth_result_false(context: Context) -> None:
|
||||
assert context.auth_result is False
|
||||
|
||||
|
||||
@then("the auth event bus should have exactly {count:d} event")
|
||||
def step_auth_event_count(context: Context, count: int) -> None:
|
||||
assert len(context.event_bus.events) == count, (
|
||||
f"Expected {count} events, got {len(context.event_bus.events)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the latest auth event type should be {event_type_name}")
|
||||
def step_latest_auth_event_type(context: Context, event_type_name: str) -> None:
|
||||
expected = EventType[event_type_name]
|
||||
actual = context.event_bus.events[-1].event_type
|
||||
assert actual == expected, f"Expected event type {expected}, got {actual}"
|
||||
|
||||
|
||||
@then(
|
||||
'the latest auth event should contain detail "{key}" with value "{expected_value}"'
|
||||
)
|
||||
def step_latest_auth_event_detail(
|
||||
context: Context, key: str, expected_value: str
|
||||
) -> None:
|
||||
details = context.event_bus.events[-1].details
|
||||
assert details.get(key) == expected_value, (
|
||||
f"Expected {key}={expected_value!r}, got {details.get(key)!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("an auth ValueError should be raised")
|
||||
def step_auth_value_error(context: Context) -> None:
|
||||
assert context.auth_error is not None, (
|
||||
"Expected ValueError but no exception was raised"
|
||||
)
|
||||
assert isinstance(context.auth_error, ValueError), (
|
||||
f"Expected ValueError, got {type(context.auth_error).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then('the auth result history should be "{history_csv}"')
|
||||
def step_auth_result_history(context: Context, history_csv: str) -> None:
|
||||
expected = [part.strip().lower() == "true" for part in history_csv.split(",")]
|
||||
assert context.auth_result_history == expected, (
|
||||
f"Expected history {expected}, got {context.auth_result_history}"
|
||||
)
|
||||
|
||||
|
||||
@then("the audit log should contain 1 auth_success entry from middleware")
|
||||
def step_audit_has_auth_success(context: Context) -> None:
|
||||
entries = context.audit_service.list_entries(event_type="auth_success")
|
||||
assert len(entries) == 1, f"Expected 1 auth_success entry, got {len(entries)}"
|
||||
|
||||
|
||||
@then("the audit log should contain 1 auth_failure entry from middleware")
|
||||
def step_audit_has_auth_failure(context: Context) -> None:
|
||||
entries = context.audit_service.list_entries(event_type="auth_failure")
|
||||
assert len(entries) == 1, f"Expected 1 auth_failure entry, got {len(entries)}"
|
||||
|
||||
|
||||
@then(
|
||||
'the latest {audit_event_type} audit entry should contain detail "{key}" with value "{expected_value}"'
|
||||
)
|
||||
def step_latest_audit_entry_detail(
|
||||
context: Context, audit_event_type: str, key: str, expected_value: str
|
||||
) -> None:
|
||||
entries = context.audit_service.list_entries(event_type=audit_event_type)
|
||||
assert entries, f"Expected at least one {audit_event_type} audit entry"
|
||||
details = entries[0].details
|
||||
assert details.get(key) == expected_value, (
|
||||
f"Expected {audit_event_type}.{key}={expected_value!r}, "
|
||||
f"got {details.get(key)!r}"
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Step definitions for db_url_sanitisation.feature.
|
||||
|
||||
Tests the ``_sanitise_db_url`` helper and its use in ``build_info_data``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from tempfile import mkdtemp
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('the database url is "{url}"')
|
||||
def step_db_url_given(context: Context, url: str) -> None:
|
||||
"""Store the raw database URL for processing."""
|
||||
context.raw_db_url = url
|
||||
|
||||
|
||||
@given('a mock settings object with database url "{db_url}"')
|
||||
def step_mock_settings_with_db_url(context: Context, db_url: str) -> None:
|
||||
"""Build a full mock Settings that ``build_info_data`` expects."""
|
||||
tmpdir = mkdtemp()
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.database_url = db_url
|
||||
mock_settings.data_dir = Path(tmpdir)
|
||||
mock_settings.storage_path = Path(tmpdir)
|
||||
mock_settings.config_path = Path(tmpdir) / "config.toml"
|
||||
mock_settings.log_dir = Path(tmpdir) / "logs"
|
||||
mock_settings.default_automation_profile = "auto"
|
||||
mock_settings.has_provider_configured = MagicMock(return_value=False)
|
||||
mock_settings.configured_provider_names = MagicMock(return_value=[])
|
||||
mock_settings.debug_enabled = False
|
||||
context.mock_settings = mock_settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run sanitise_db_url")
|
||||
def step_run_sanitise(context: Context) -> None:
|
||||
"""Call _sanitise_db_url with the stored raw URL."""
|
||||
from cleveragents.cli.commands.system import _sanitise_db_url
|
||||
|
||||
context.sanitised_url = _sanitise_db_url(context.raw_db_url)
|
||||
|
||||
|
||||
@when("I call build_info_data")
|
||||
def step_call_build_info(context: Context) -> None:
|
||||
"""Call ``build_info_data`` with the mock settings."""
|
||||
from cleveragents.cli.commands.system import build_info_data
|
||||
|
||||
with patch(
|
||||
"cleveragents.config.settings.get_settings",
|
||||
return_value=context.mock_settings,
|
||||
):
|
||||
context.info_data = build_info_data()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the sanitised database url should be "{expected}"')
|
||||
def step_assert_sanitised_url(context: Context, expected: str) -> None:
|
||||
"""Verify the sanitised URL matches expectation."""
|
||||
actual = context.sanitised_url
|
||||
assert actual == expected, f"Expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the database field in info data should be "{expected}"')
|
||||
def step_assert_info_db_field(context: Context, expected: str) -> None:
|
||||
"""Verify the sanitised URL appears correctly in build_info_data output."""
|
||||
actual = context.info_data["database"]
|
||||
assert actual == expected, f"Expected database field '{expected}', got '{actual}'"
|
||||
@@ -107,6 +107,10 @@ def _make_assembler(
|
||||
repo.get_context_policy.return_value = policy
|
||||
else:
|
||||
repo.get_context_policy.return_value = ProjectContextPolicy()
|
||||
# Default: project context config with hot_max_tokens=None (falls back to constructor).
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.hot_max_tokens = None
|
||||
repo.get_project_context_config.return_value = mock_cfg
|
||||
|
||||
mock_pipeline = MagicMock()
|
||||
if pipeline_result is not None:
|
||||
|
||||
@@ -89,7 +89,7 @@ class AutoDebugAgent:
|
||||
|
||||
return workflow
|
||||
|
||||
def _analyze_error(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _analyze_error(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
logger.info("Analyzing error message")
|
||||
|
||||
error_msg = state.get("error_message", "")
|
||||
@@ -129,17 +129,18 @@ Analyze this error and provide insights."""
|
||||
logger.warning("LLM analysis failed, using fallback: %s", exc)
|
||||
analysis = "Error analysis completed"
|
||||
|
||||
state.setdefault("messages", []).append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": analysis,
|
||||
"type": "error_analysis",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
*state.get("messages", []),
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": analysis,
|
||||
"type": "error_analysis",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
def _generate_fix(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _generate_fix(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
logger.info("Generating fix suggestion")
|
||||
|
||||
error_analysis = next(
|
||||
@@ -220,10 +221,9 @@ Generate fix attempt #{attempt_num}."""
|
||||
"files_to_modify": [],
|
||||
}
|
||||
|
||||
state["current_fix"] = fix_data
|
||||
return state
|
||||
return {"current_fix": fix_data}
|
||||
|
||||
def _validate_fix(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _validate_fix(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
logger.info("Validating fix")
|
||||
|
||||
current_fix = state.get("current_fix", {})
|
||||
@@ -278,14 +278,14 @@ Validate this fix."""
|
||||
logger.warning("LLM validation failed, using fallback: %s", exc)
|
||||
is_valid = True
|
||||
|
||||
state["fix_validated"] = is_valid
|
||||
updates: dict[str, Any] = {"fix_validated": is_valid}
|
||||
|
||||
if not is_valid:
|
||||
attempted_fixes = state.get("attempted_fixes", [])
|
||||
attempted_fixes = list(state.get("attempted_fixes", []))
|
||||
attempted_fixes.append(current_fix)
|
||||
state["attempted_fixes"] = attempted_fixes
|
||||
updates["attempted_fixes"] = attempted_fixes
|
||||
|
||||
return state
|
||||
return updates
|
||||
|
||||
def _should_retry_fix(self, state: AutoDebugState) -> str:
|
||||
if not state.get("fix_validated", False):
|
||||
@@ -294,15 +294,16 @@ Validate this fix."""
|
||||
return "retry"
|
||||
return "done"
|
||||
|
||||
def _finalize(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _finalize(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
logger.info("Finalizing auto-debug results")
|
||||
|
||||
state["result"] = {
|
||||
"success": state.get("fix_validated", False),
|
||||
"fix": state.get("current_fix"),
|
||||
"attempts": len(state.get("attempted_fixes", [])),
|
||||
return {
|
||||
"result": {
|
||||
"success": state.get("fix_validated", False),
|
||||
"fix": state.get("current_fix"),
|
||||
"attempts": len(state.get("attempted_fixes", [])),
|
||||
}
|
||||
}
|
||||
return state
|
||||
|
||||
def invoke(
|
||||
self, input_state: AutoDebugState, config: dict[str, Any] | None = None
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Authentication middleware that emits audit-friendly auth events.
|
||||
|
||||
This service encapsulates bearer-token authentication checks and emits
|
||||
``AUTH_SUCCESS`` / ``AUTH_FAILURE`` domain events through an injected
|
||||
``EventBus`` so the audit pipeline can persist login outcomes.
|
||||
|
||||
Spec alignment:
|
||||
- docs/specification.md § Event System
|
||||
- docs/specification.md § Audit Logging (auth_success / auth_failure)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
from cleveragents.shared.redaction import redact_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.infrastructure.events.protocol import EventBus
|
||||
|
||||
_logger = structlog.get_logger(__name__)
|
||||
_TOKEN_PREFIX_LEN = 6
|
||||
_SHORT_TOKEN_MASK = "***..."
|
||||
|
||||
|
||||
def _token_prefix(token: str) -> str:
|
||||
"""Return a safe token prefix for audit details.
|
||||
|
||||
Never returns the full token value. Tokens shorter than or equal to the
|
||||
prefix length are collapsed to a constant mask to avoid full disclosure.
|
||||
"""
|
||||
if len(token) <= _TOKEN_PREFIX_LEN:
|
||||
return _SHORT_TOKEN_MASK
|
||||
return f"{token[:_TOKEN_PREFIX_LEN]}..."
|
||||
|
||||
|
||||
def _normalize_optional_text(field_name: str, value: str | None) -> str | None:
|
||||
"""Normalize optional text fields with guard-first validation."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{field_name} must be a string or None")
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{field_name} must not be empty when provided")
|
||||
return normalized
|
||||
|
||||
|
||||
class TokenAuthMiddleware:
|
||||
"""Authenticate bearer tokens and emit AUTH_* events.
|
||||
|
||||
This class is intentionally transport-agnostic. A future server-mode
|
||||
middleware can delegate authentication decisions to this component and
|
||||
inherit consistent event emission semantics.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
expected_token: str | None,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> None:
|
||||
"""Initialize middleware with an expected token and optional event bus.
|
||||
|
||||
Args:
|
||||
expected_token: Configured bearer token for successful auth.
|
||||
event_bus: Event bus used for AUTH_SUCCESS/AUTH_FAILURE emission.
|
||||
"""
|
||||
self._expected_token = _normalize_optional_text(
|
||||
"expected_token", expected_token
|
||||
)
|
||||
self._event_bus = event_bus
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
identity: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> bool:
|
||||
"""Authenticate *token* and emit a corresponding auth event.
|
||||
|
||||
Args:
|
||||
token: Bearer token supplied by the caller.
|
||||
identity: Optional user identity / principal for audit context.
|
||||
ip_address: Optional client IP address for audit context.
|
||||
|
||||
Returns:
|
||||
``True`` if authentication succeeds, otherwise ``False``.
|
||||
"""
|
||||
if not isinstance(token, str):
|
||||
raise TypeError("token must be a string")
|
||||
normalized_token = token.strip()
|
||||
if not normalized_token:
|
||||
raise ValueError("token must not be empty")
|
||||
|
||||
normalized_identity = _normalize_optional_text("identity", identity)
|
||||
normalized_ip = _normalize_optional_text("ip_address", ip_address)
|
||||
|
||||
if self._expected_token is None:
|
||||
self._emit_auth_failure(
|
||||
attempted_identity=normalized_identity,
|
||||
ip_address=normalized_ip,
|
||||
token_prefix=_token_prefix(normalized_token),
|
||||
reason="token_not_configured",
|
||||
)
|
||||
return False
|
||||
|
||||
if hmac.compare_digest(normalized_token, self._expected_token):
|
||||
self._emit_auth_success(
|
||||
user_identity=normalized_identity,
|
||||
ip_address=normalized_ip,
|
||||
token_prefix=_token_prefix(normalized_token),
|
||||
)
|
||||
return True
|
||||
|
||||
self._emit_auth_failure(
|
||||
attempted_identity=normalized_identity,
|
||||
ip_address=normalized_ip,
|
||||
token_prefix=_token_prefix(normalized_token),
|
||||
reason="invalid_token",
|
||||
)
|
||||
return False
|
||||
|
||||
def _emit_auth_success(
|
||||
self,
|
||||
*,
|
||||
user_identity: str | None,
|
||||
ip_address: str | None,
|
||||
token_prefix: str,
|
||||
) -> None:
|
||||
"""Emit AUTH_SUCCESS if an event bus is configured."""
|
||||
if self._event_bus is None:
|
||||
return
|
||||
details: dict[str, str] = {
|
||||
"user_identity": user_identity or "unknown",
|
||||
"token_prefix": token_prefix,
|
||||
}
|
||||
if ip_address is not None:
|
||||
details["ip_address"] = ip_address
|
||||
self._emit_event(EventType.AUTH_SUCCESS, details)
|
||||
|
||||
def _emit_auth_failure(
|
||||
self,
|
||||
*,
|
||||
attempted_identity: str | None,
|
||||
ip_address: str | None,
|
||||
token_prefix: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Emit AUTH_FAILURE if an event bus is configured."""
|
||||
if self._event_bus is None:
|
||||
return
|
||||
details: dict[str, str] = {
|
||||
"attempted_identity": attempted_identity or "unknown",
|
||||
"user_identity": "unauthenticated",
|
||||
"failure_reason": reason,
|
||||
"token_prefix": token_prefix,
|
||||
}
|
||||
if ip_address is not None:
|
||||
details["ip_address"] = ip_address
|
||||
self._emit_event(EventType.AUTH_FAILURE, details)
|
||||
|
||||
def _emit_event(self, event_type: EventType, details: dict[str, str]) -> None:
|
||||
"""Emit one auth event and swallow publish-side failures."""
|
||||
bus = self._event_bus
|
||||
if bus is None:
|
||||
return
|
||||
try:
|
||||
bus.emit(
|
||||
DomainEvent(
|
||||
event_type=event_type,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
_logger.warning(
|
||||
"auth_event_emit_failed",
|
||||
event_type=event_type.value,
|
||||
error_type=type(exc).__name__,
|
||||
error_message=redact_value(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TokenAuthMiddleware"]
|
||||
@@ -22,6 +22,7 @@ from cleveragents.domain.models.core.context_fragment import (
|
||||
FragmentProvenance as CoreFragmentProvenance,
|
||||
)
|
||||
from cleveragents.domain.models.core.context_policy import ProjectContextPolicy
|
||||
from cleveragents.domain.models.core.project import ContextConfig
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
NamespacedProjectRepository,
|
||||
)
|
||||
@@ -45,12 +46,13 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
context_tier_service: Any,
|
||||
project_repository: NamespacedProjectRepository,
|
||||
acms_pipeline: ACMSPipeline | None = None,
|
||||
hot_max_tokens: int = 4096,
|
||||
hot_max_tokens: int | None = None,
|
||||
) -> None:
|
||||
self._tier = context_tier_service
|
||||
self._project_repository = project_repository
|
||||
self._pipeline = acms_pipeline or ACMSPipeline()
|
||||
self._hot_max_tokens = hot_max_tokens
|
||||
# Global/container-level fallback when no project-level value is set.
|
||||
self._fallback_hot_max_tokens = hot_max_tokens
|
||||
self._logger = logger.bind(component="execute_phase_context_assembler")
|
||||
|
||||
@property
|
||||
@@ -166,6 +168,47 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
return False
|
||||
return not (exclude and _matches_any(exclude))
|
||||
|
||||
def _resolve_effective_hot_max_tokens(
|
||||
self, project_names: list[str],
|
||||
) -> int:
|
||||
"""Return the effective hot-tier max-tokens for *project_names*.
|
||||
|
||||
Resolves per-project ``ContextConfig.hot_max_tokens`` values. The
|
||||
maximum non-None value found across all linked projects is used
|
||||
so that a multi-project plan can benefit from the most generous
|
||||
per-project budget setting. When no project provides a value,
|
||||
falls back to the container/global default stored in
|
||||
``self._fallback_hot_max_tokens``; if that is also unset, returns
|
||||
the historical safety-net of **4096**.
|
||||
"""
|
||||
candidates: list[int] = []
|
||||
|
||||
for name in project_names:
|
||||
try:
|
||||
config: ContextConfig = (
|
||||
self._project_repository.get_project_context_config(name)
|
||||
)
|
||||
raw = config.hot_max_tokens
|
||||
if raw is not None and isinstance(raw, int):
|
||||
candidates.append(raw)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"context_config_lookup_failed",
|
||||
project_name=name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Use the maximum project-level value (most generous wins).
|
||||
if candidates:
|
||||
return max(candidates)
|
||||
|
||||
# Fall back to container/global default.
|
||||
if self._fallback_hot_max_tokens is not None:
|
||||
return self._fallback_hot_max_tokens
|
||||
|
||||
# Absolute safety-net.
|
||||
return 4096
|
||||
|
||||
@staticmethod
|
||||
def _resource_matches(
|
||||
resource_id: str, include: list[str], exclude: list[str]
|
||||
|
||||
@@ -17,6 +17,7 @@ from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
@@ -82,6 +83,41 @@ def _dep_version(package: str) -> str:
|
||||
return "not installed"
|
||||
|
||||
|
||||
def _sanitise_db_url(url: str) -> str:
|
||||
"""Sanitise a database URL by masking credentials.
|
||||
|
||||
Parses the URL and replaces username/password components with masked
|
||||
placeholders so that CLI output never exposes real credentials.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> _sanitise_db_url("postgresql://user:secret@localhost/mydb")
|
||||
'postgresql://***:***@localhost/mydb'
|
||||
>>> _sanitise_db_url("sqlite:///path/to/db.sqlite")
|
||||
'sqlite:///path/to/db.sqlite'
|
||||
|
||||
Args:
|
||||
url: The raw database URL.
|
||||
|
||||
Returns:
|
||||
The sanitised URL with credentials masked (or unchanged if no
|
||||
username/password is present).
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
|
||||
# If there is no userinfo segment, return as-is (e.g. sqlite URLs)
|
||||
if not parsed.username and not parsed.password:
|
||||
return url
|
||||
|
||||
# Rebuild the netloc with masked credentials
|
||||
userinfo = "***:***"
|
||||
port_part = f":{parsed.port}" if parsed.port else ""
|
||||
new_netloc = f"{userinfo}@{parsed.hostname}{port_part}"
|
||||
|
||||
sanitized = parsed._replace(netloc=new_netloc)
|
||||
return urlunparse(sanitized)
|
||||
|
||||
|
||||
def build_version_data() -> dict[str, Any]:
|
||||
"""Assemble structured data for the ``version`` command."""
|
||||
return {
|
||||
@@ -145,7 +181,7 @@ def build_info_data() -> dict[str, Any]:
|
||||
"version": __version__,
|
||||
"data_dir": str(data_dir),
|
||||
"config_path": str(config_path),
|
||||
"database": db_url,
|
||||
"database": _sanitise_db_url(db_url),
|
||||
"server_mode": server_mode,
|
||||
"platform": f"{platform.system()} {platform.release()} ({platform.machine()})",
|
||||
"automation": settings.default_automation_profile,
|
||||
|
||||
@@ -3146,6 +3146,56 @@ class NamespacedProjectRepository(ProjectRepositoryProtocol):
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def get_project_context_config(self, namespaced_name: str) -> Any:
|
||||
"""Return a project's stored ``ContextConfig`` or the default.
|
||||
|
||||
Unlike :meth:`get_context_policy` which returns a
|
||||
``ProjectContextPolicy``, this method returns the full
|
||||
``ContextConfig`` containing memory-tier settings such as
|
||||
hot_max_tokens, warm/cold tier limits, and more.
|
||||
|
||||
Args:
|
||||
namespaced_name: The project's ``namespace/name`` string.
|
||||
|
||||
Returns:
|
||||
A ``ContextConfig`` instance (default if the project or its
|
||||
config is not found).
|
||||
"""
|
||||
from cleveragents.domain.models.core.project import ContextConfig
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
row = (
|
||||
session.query(NamespacedProjectModel)
|
||||
.filter_by(namespaced_name=namespaced_name)
|
||||
.first()
|
||||
)
|
||||
if row is None or row.context_policy_json is None:
|
||||
return ContextConfig()
|
||||
|
||||
payload = json.loads(cast(str, row.context_policy_json))
|
||||
if not isinstance(payload, dict):
|
||||
_log.warning(
|
||||
"project_context_config_payload_invalid",
|
||||
project_name=namespaced_name,
|
||||
)
|
||||
return ContextConfig()
|
||||
return ContextConfig.model_validate(payload)
|
||||
except (ValueError, TypeError):
|
||||
_log.warning(
|
||||
"project_context_config_invalid",
|
||||
project_name=namespaced_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return ContextConfig()
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(
|
||||
f"Failed to load project context config for '{namespaced_name}': {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@database_retry
|
||||
def update(self, project: Any) -> Any:
|
||||
"""Update mutable fields of an existing project.
|
||||
|
||||
@@ -143,11 +143,33 @@ class StdioTransport:
|
||||
self._process = None
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"lsp.transport.started",
|
||||
pid=self._process.pid,
|
||||
command=self._command,
|
||||
)
|
||||
try:
|
||||
logger.info(
|
||||
"lsp.transport.started",
|
||||
pid=self._process.pid,
|
||||
command=self._command,
|
||||
)
|
||||
except Exception:
|
||||
# Post-spawn initialization failed — clean up the orphaned
|
||||
# subprocess to prevent resource leaks. We re-raise the
|
||||
# original exception so callers still get proper error
|
||||
# semantics; this block only ensures resources are freed.
|
||||
logger.warning(
|
||||
"lsp.transport.start_post_init_failed",
|
||||
pid=self._process.pid,
|
||||
)
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=_GRACEFUL_SHUTDOWN_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"lsp.transport.force_killing_cleanup",
|
||||
pid=self._process.pid,
|
||||
)
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=2.0)
|
||||
self._process = None
|
||||
raise
|
||||
|
||||
def stop(self, timeout: float = _GRACEFUL_SHUTDOWN_TIMEOUT) -> int | None:
|
||||
"""Terminate the subprocess gracefully, then force-kill if needed.
|
||||
|
||||
Reference in New Issue
Block a user