diff --git a/dare_framework/checkpoint/defaults.py b/dare_framework/checkpoint/defaults.py new file mode 100644 index 00000000..6e75e694 --- /dev/null +++ b/dare_framework/checkpoint/defaults.py @@ -0,0 +1,230 @@ +"""Supported default checkpoint implementations and contributors. + +This module keeps legacy checkpoint symbols importable after checkpoint internals +were consolidated into ``dare_framework.checkpoint.kernel``. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict +from typing import Any +from uuid import uuid4 + +from dare_framework.context.types import Message + +STM = "stm" +SESSION_STATE = "session_state" +SESSION_CONTEXT = "session_context" +WORKSPACE_FILES = "workspace_files" + + +class MemoryCheckpointStore: + """In-memory checkpoint payload store kept for facade compatibility.""" + + def __init__(self) -> None: + self._store: dict[str, dict[str, Any]] = {} + + def put(self, checkpoint_id: str, payload: dict[str, Any]) -> None: + self._store[checkpoint_id] = _clone_payload(payload) + + def get(self, checkpoint_id: str) -> dict[str, Any] | None: + if checkpoint_id not in self._store: + return None + return _clone_payload(self._store[checkpoint_id]) + + def delete(self, checkpoint_id: str) -> bool: + if checkpoint_id in self._store: + del self._store[checkpoint_id] + return True + return False + + +def _scope_keys(scope: Any, method_name: str) -> list[str]: + method = getattr(scope, method_name, None) + if callable(method): + values = method() + if isinstance(values, (list, tuple, set)): + return [str(v) for v in values] + if isinstance(scope, (list, tuple, set)): + return [str(v) for v in scope] + return [] + + +def _clone_payload(value: Any) -> Any: + """Clone nested checkpoint payloads so save/restore stays side-effect free.""" + return deepcopy(value) + + +class DefaultCheckpointSaveRestore: + """Legacy save/restore coordinator over contributor payload components.""" + + def __init__(self, store: MemoryCheckpointStore, contributors: list[Any]) -> None: + self._store = store + self._contributors = { + str(c.component_key): c + for c in contributors + if getattr(c, "component_key", None) is not None + } + + def save(self, scope: Any, ctx: Any) -> str: + payload: dict[str, Any] = {} + for key in _scope_keys(scope, "keys_for_save"): + contributor = self._contributors.get(key) + if contributor is None: + continue + payload[key] = contributor.serialize(ctx) + checkpoint_id = uuid4().hex[:16] + self._store.put(checkpoint_id, payload) + return checkpoint_id + + def restore(self, checkpoint_id: str, scope: Any, ctx: Any) -> None: + payload = self._store.get(checkpoint_id) + if payload is None: + raise LookupError(f"Checkpoint not found: {checkpoint_id!r}") + for key in _scope_keys(scope, "keys_for_restore"): + if key not in payload: + continue + contributor = self._contributors.get(key) + if contributor is None: + continue + contributor.deserialize_and_apply(payload[key], ctx) + + +class StmContributor: + """Serialize/restore short-term memory messages.""" + + component_key = STM + + def serialize(self, ctx: Any) -> list[dict[str, Any]]: + context = getattr(ctx, "context", None) + if context is None: + return [] + messages = context.stm_get() + return [ + { + "role": m.role, + "kind": m.kind, + "text": m.text, + "attachments": [ + { + "kind": attachment.kind, + "uri": attachment.uri, + "mime_type": attachment.mime_type, + "filename": attachment.filename, + "metadata": _clone_payload(getattr(attachment, "metadata", {}) or {}), + } + for attachment in m.attachments + ], + "data": _clone_payload(m.data), + "name": m.name, + "metadata": _clone_payload(getattr(m, "metadata", {}) or {}), + "mark": getattr(m, "mark", None), + "id": getattr(m, "id", None), + } + for m in messages + ] + + def deserialize_and_apply(self, payload: list[Any], ctx: Any) -> None: + context = getattr(ctx, "context", None) + if context is None: + return + context.stm_clear() + for item in payload or []: + if not isinstance(item, dict): + continue + restored_text = item["text"] if "text" in item else item.get("content", "") + context.stm_add( + Message( + role=item.get("role", "user"), + kind=item.get("kind", "chat"), + text=restored_text, + attachments=_clone_payload(item.get("attachments")) or [], + data=_clone_payload(item.get("data")), + name=item.get("name"), + metadata=dict(item.get("metadata") or {}), + mark=item.get("mark", "temporary"), + id=item.get("id"), + ) + ) + + +class SessionStateContributor: + """Serialize/restore minimal session-state fields.""" + + component_key = SESSION_STATE + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + state = getattr(ctx, "session_state", None) + if state is None: + return None + try: + return asdict(state) + except Exception: + return { + "current_milestone_idx": getattr(state, "current_milestone_idx", None), + "task_id": getattr(state, "task_id", None), + "run_id": getattr(state, "run_id", None), + } + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + state = getattr(ctx, "session_state", None) + if state is None or not isinstance(payload, dict): + return + if "current_milestone_idx" in payload and hasattr(state, "current_milestone_idx"): + state.current_milestone_idx = payload["current_milestone_idx"] + if "task_id" in payload and hasattr(state, "task_id"): + state.task_id = payload["task_id"] + if "run_id" in payload and hasattr(state, "run_id"): + state.run_id = payload["run_id"] + + +class SessionContextContributor: + """Serialize session context for audit trails (restore intentionally no-op).""" + + component_key = SESSION_CONTEXT + + def serialize(self, ctx: Any) -> dict[str, Any] | None: + session_context = getattr(ctx, "session_context", None) + if session_context is None: + return None + try: + serialized = asdict(session_context) + except Exception: + return { + "session_id": getattr(session_context, "session_id", None), + "task_id": getattr(session_context, "task_id", None), + } + config_value = serialized.get("config") + if config_value is not None and not isinstance(config_value, dict): + try: + serialized["config"] = asdict(config_value) + except Exception: + serialized["config"] = None + return serialized + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + # SessionContext is construction-time state; legacy restore path keeps this as no-op. + + +class WorkspaceGitContributor: + """Compatibility no-op contributor after workspace-git checkpoint removal.""" + + component_key = WORKSPACE_FILES + + def serialize(self, ctx: Any) -> dict[str, Any]: + _ = ctx + return {} + + def deserialize_and_apply(self, payload: Any, ctx: Any) -> None: + _ = (payload, ctx) + +__all__ = [ + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", +] diff --git a/dare_framework/embedding/__init__.py b/dare_framework/embedding/__init__.py index 6b8f4d84..d21d9fc5 100644 --- a/dare_framework/embedding/__init__.py +++ b/dare_framework/embedding/__init__.py @@ -1,11 +1,11 @@ """embedding domain facade.""" -from dare_framework.embedding.interfaces import IEmbeddingAdapter -from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult -from dare_framework.embedding._internal import OpenAIEmbeddingAdapter - -__all__ = [ - "IEmbeddingAdapter", +from dare_framework.embedding.interfaces import IEmbeddingAdapter +from dare_framework.embedding.types import EmbeddingOptions, EmbeddingResult +from dare_framework.embedding.defaults import OpenAIEmbeddingAdapter + +__all__ = [ + "IEmbeddingAdapter", "EmbeddingOptions", "EmbeddingResult", "OpenAIEmbeddingAdapter", diff --git a/dare_framework/embedding/defaults.py b/dare_framework/embedding/defaults.py new file mode 100644 index 00000000..88e38524 --- /dev/null +++ b/dare_framework/embedding/defaults.py @@ -0,0 +1,5 @@ +"""Supported default embedding implementations.""" + +from dare_framework.embedding._internal.openai_embedding import OpenAIEmbeddingAdapter + +__all__ = ["OpenAIEmbeddingAdapter"] diff --git a/dare_framework/event/__init__.py b/dare_framework/event/__init__.py index d0fede30..703a2740 100644 --- a/dare_framework/event/__init__.py +++ b/dare_framework/event/__init__.py @@ -1,6 +1,6 @@ """event domain facade.""" -from dare_framework.event._internal import DefaultEventLog, SQLiteEventLog +from dare_framework.event.defaults import DefaultEventLog, SQLiteEventLog from dare_framework.event.kernel import IEventLog from dare_framework.event.types import Event, RuntimeSnapshot diff --git a/dare_framework/event/defaults.py b/dare_framework/event/defaults.py new file mode 100644 index 00000000..d5eee182 --- /dev/null +++ b/dare_framework/event/defaults.py @@ -0,0 +1,5 @@ +"""Supported default event-log implementations.""" + +from dare_framework.event._internal.sqlite_event_log import DefaultEventLog, SQLiteEventLog + +__all__ = ["SQLiteEventLog", "DefaultEventLog"] diff --git a/dare_framework/hook/__init__.py b/dare_framework/hook/__init__.py index 3ffb7e15..1d1d3c25 100644 --- a/dare_framework/hook/__init__.py +++ b/dare_framework/hook/__init__.py @@ -2,7 +2,7 @@ from dare_framework.hook.interfaces import IHookManager from dare_framework.hook.kernel import IExtensionPoint, IHook, HookFn -from dare_framework.hook._internal import HookExtensionPoint +from dare_framework.hook.defaults import HookExtensionPoint from dare_framework.hook.types import HookDecision, HookEnvelope, HookPhase, HookResult __all__ = [ diff --git a/dare_framework/hook/defaults.py b/dare_framework/hook/defaults.py new file mode 100644 index 00000000..2037a192 --- /dev/null +++ b/dare_framework/hook/defaults.py @@ -0,0 +1,5 @@ +"""Supported default extension-point implementation.""" + +from dare_framework.hook._internal.hook_extension_point import HookExtensionPoint + +__all__ = ["HookExtensionPoint"] diff --git a/dare_framework/plan/__init__.py b/dare_framework/plan/__init__.py index 34cf3afe..59716d99 100644 --- a/dare_framework/plan/__init__.py +++ b/dare_framework/plan/__init__.py @@ -23,7 +23,7 @@ ValidatedStep, VerifyResult, ) -from dare_framework.plan._internal import DefaultPlanner, DefaultRemediator +from dare_framework.plan.defaults import DefaultPlanner, DefaultRemediator __all__ = [ # Interfaces diff --git a/dare_framework/plan/defaults.py b/dare_framework/plan/defaults.py new file mode 100644 index 00000000..6f0edc33 --- /dev/null +++ b/dare_framework/plan/defaults.py @@ -0,0 +1,6 @@ +"""Supported default planner/remediator implementations.""" + +from dare_framework.plan._internal.default_planner import DefaultPlanner +from dare_framework.plan._internal.default_remediator import DefaultRemediator + +__all__ = ["DefaultPlanner", "DefaultRemediator"] diff --git a/dare_framework/security/__init__.py b/dare_framework/security/__init__.py index 5a4e3ab1..4a2e9b3c 100644 --- a/dare_framework/security/__init__.py +++ b/dare_framework/security/__init__.py @@ -1,6 +1,6 @@ """Security domain facade.""" -from dare_framework.security._internal import DefaultSecurityBoundary +from dare_framework.security.defaults import DefaultSecurityBoundary from dare_framework.security.errors import ( SECURITY_APPROVAL_MANAGER_MISSING, SECURITY_POLICY_CHECK_FAILED, @@ -8,7 +8,10 @@ SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError, ) -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.kernel import ISecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput diff --git a/dare_framework/security/defaults.py b/dare_framework/security/defaults.py new file mode 100644 index 00000000..bd6a0611 --- /dev/null +++ b/dare_framework/security/defaults.py @@ -0,0 +1,5 @@ +"""Public default security boundary exports.""" + +from dare_framework.security._internal.default_security_boundary import DefaultSecurityBoundary + +__all__ = ["DefaultSecurityBoundary"] diff --git a/dare_framework/security/impl/default_security_boundary.py b/dare_framework/security/impl/default_security_boundary.py index 46b17f4b..6cea686f 100644 --- a/dare_framework/security/impl/default_security_boundary.py +++ b/dare_framework/security/impl/default_security_boundary.py @@ -259,7 +259,7 @@ async def execute_safe( return await _execute_callable(fn) -# Backward-compatible alias for legacy references. +# Backward-compatible alias for legacy impl references. DefaultSecurityBoundary = PolicySecurityBoundary diff --git a/dare_framework/transport/__init__.py b/dare_framework/transport/__init__.py index 8be5b978..9057aa9e 100644 --- a/dare_framework/transport/__init__.py +++ b/dare_framework/transport/__init__.py @@ -4,27 +4,27 @@ from dare_framework.transport.serialization import jsonify_transport_value from dare_framework.transport.types import ( ActionPayload, + AttachmentRef, ControlPayload, - EnvelopePayload, EnvelopeKind, - MessageRole, + EnvelopePayload, MessageKind, MessagePayload, - AttachmentRef, + MessageRole, + Receiver, SelectDomain, SelectKind, SelectPayload, + Sender, TransportEnvelope, new_envelope_id, - Receiver, - Sender, ) from dare_framework.transport.interaction import ( - AgentControl, ActionHandlerDispatcher, + AgentControl, ResourceAction, ) -from dare_framework.transport._internal import ( +from dare_framework.transport.adapters import ( DefaultAgentChannel, DirectClientChannel, StdioClientChannel, diff --git a/dare_framework/transport/adapters.py b/dare_framework/transport/adapters.py new file mode 100644 index 00000000..9dadb2c9 --- /dev/null +++ b/dare_framework/transport/adapters.py @@ -0,0 +1,15 @@ +"""Supported default transport channel adapters.""" + +from dare_framework.transport._internal import ( + DefaultAgentChannel, + DirectClientChannel, + StdioClientChannel, + WebSocketClientChannel, +) + +__all__ = [ + "DefaultAgentChannel", + "DirectClientChannel", + "StdioClientChannel", + "WebSocketClientChannel", +] diff --git a/docs/design/Framework_MinSurface_Review.md b/docs/design/Framework_MinSurface_Review.md index 4d80b193..bc8ec18a 100644 --- a/docs/design/Framework_MinSurface_Review.md +++ b/docs/design/Framework_MinSurface_Review.md @@ -268,3 +268,8 @@ MCP adapters, etc. - Tight coupling to concrete classes prevents safe refactors. - External users rely on internal DTOs that must then be supported forever. - Increased likelihood of breaking changes with each internal improvement. + +## 5. Implementation Note (2026-03-04) +- T0-4 facade compliance batch moved public-domain `__init__.py` imports away from direct `._internal` references into explicit public export layers (`defaults.py`, `impl`, `adapters`). +- The following facades now import only public modules directly: `checkpoint`, `embedding`, `event`, `hook`, `plan`, `security`, `transport`. +- A regression rule was added in `tests/unit/test_package_initializers_facade_pattern.py` to block future direct `._internal` imports from public facades. diff --git a/tests/unit/test_checkpoint_defaults.py b/tests/unit/test_checkpoint_defaults.py new file mode 100644 index 00000000..7a759174 --- /dev/null +++ b/tests/unit/test_checkpoint_defaults.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import importlib +from dataclasses import dataclass + +from dare_framework.config import Config +from dare_framework.context import AttachmentKind, AttachmentRef, Context, Message, MessageKind, MessageMark + + +def test_checkpoint_defaults_module_exports_are_importable() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + + for symbol in ( + "MemoryCheckpointStore", + "DefaultCheckpointSaveRestore", + "StmContributor", + "WorkspaceGitContributor", + "SessionStateContributor", + "SessionContextContributor", + ): + assert hasattr(defaults, symbol), f"missing checkpoint default symbol: {symbol}" + + +@dataclass +class _DummyConfig: + model: str + + +@dataclass +class _DummySessionContext: + session_id: str + task_id: str + config: _DummyConfig | None + + +@dataclass +class _DummyCtx: + session_context: _DummySessionContext | None + + +@dataclass +class _DummySessionState: + current_milestone_idx: int | None + task_id: str | None + run_id: str | None + + +def test_session_context_contributor_preserves_serialized_config_dict() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.SessionContextContributor() + ctx = _DummyCtx( + session_context=_DummySessionContext( + session_id="s-1", + task_id="t-1", + config=_DummyConfig(model="gpt-4.1"), + ) + ) + + payload = contributor.serialize(ctx) + + assert payload is not None + assert payload["config"] == {"model": "gpt-4.1"} + + +def test_session_state_contributor_restores_task_and_run_ids() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.SessionStateContributor() + state = _DummySessionState(current_milestone_idx=0, task_id="task-old", run_id="run-old") + ctx = type("Ctx", (), {"session_state": state})() + + contributor.deserialize_and_apply( + {"current_milestone_idx": 3, "task_id": "task-new", "run_id": "run-new"}, + ctx, + ) + + assert state.current_milestone_idx == 3 + assert state.task_id == "task-new" + assert state.run_id == "run-new" + + +def test_stm_contributor_roundtrip_uses_current_message_text_shape() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message(role="user", text="hello", name="alice", metadata={"trace_id": "t-1"}) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 1 + assert restored_messages[0].text == "hello" + assert restored_messages[0].name == "alice" + assert restored_messages[0].metadata == {"trace_id": "t-1"} + + +def test_stm_contributor_roundtrip_preserves_typed_message_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="see attachment", + attachments=[ + AttachmentRef( + kind=AttachmentKind.IMAGE, + uri="https://example.com/a.png", + metadata={"source": "camera"}, + ) + ], + mark=MessageMark.PERSISTENT, + id="user-1", + ) + ) + source_context.stm_add( + Message( + role="assistant", + kind=MessageKind.TOOL_CALL, + text="", + data={"tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}]}, + mark=MessageMark.IMMUTABLE, + id="assistant-1", + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 2 + + assert restored_messages[0].attachments[0].uri == "https://example.com/a.png" + assert restored_messages[0].attachments[0].metadata == {"source": "camera"} + assert restored_messages[0].mark == MessageMark.PERSISTENT + assert restored_messages[0].id == "user-1" + + assert restored_messages[1].kind == MessageKind.TOOL_CALL + assert restored_messages[1].data == { + "tool_calls": [{"id": "tc_1", "name": "demo_tool", "arguments": {"x": 1}}] + } + assert restored_messages[1].mark == MessageMark.IMMUTABLE + assert restored_messages[1].id == "assistant-1" + + +def test_stm_contributor_roundtrip_preserves_null_text_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + contributor = defaults.StmContributor() + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="tool", + kind=MessageKind.TOOL_RESULT, + text=None, + data={"tool_call_id": "tc_1", "output": {"ok": True}}, + id="tool-1", + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + + payload = contributor.serialize(source_ctx) + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + contributor.deserialize_and_apply(payload, restored_ctx) + + restored_messages = restored_context.stm_get() + assert len(restored_messages) == 1 + assert restored_messages[0].text is None + assert restored_messages[0].data == {"tool_call_id": "tc_1", "output": {"ok": True}} + + +def test_checkpoint_save_snapshot_deep_copies_nested_message_metadata() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + store = defaults.MemoryCheckpointStore() + saver = defaults.DefaultCheckpointSaveRestore(store, [defaults.StmContributor()]) + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="hello", + metadata={"trace": {"step": 1}}, + ) + ) + source_ctx = type("Ctx", (), {"context": source_context})() + scope = type( + "Scope", + (), + {"keys_for_save": lambda self: ["stm"], "keys_for_restore": lambda self: ["stm"]}, + )() + + checkpoint_id = saver.save(scope, source_ctx) + + source_context.stm_get()[0].metadata["trace"]["step"] = 99 + + restored_context = Context(config=Config()) + restored_ctx = type("Ctx", (), {"context": restored_context})() + saver.restore(checkpoint_id, scope, restored_ctx) + + restored_messages = restored_context.stm_get() + assert restored_messages[0].metadata == {"trace": {"step": 1}} + + +def test_checkpoint_store_get_returns_deep_copied_payloads() -> None: + defaults = importlib.import_module("dare_framework.checkpoint.defaults") + store = defaults.MemoryCheckpointStore() + saver = defaults.DefaultCheckpointSaveRestore(store, [defaults.StmContributor()]) + + source_context = Context(config=Config()) + source_context.stm_add( + Message( + role="user", + text="hello", + metadata={"trace": {"step": 1}}, + ) + ) + scope = type( + "Scope", + (), + {"keys_for_save": lambda self: ["stm"], "keys_for_restore": lambda self: ["stm"]}, + )() + source_ctx = type("Ctx", (), {"context": source_context})() + + checkpoint_id = saver.save(scope, source_ctx) + + restored_context_a = Context(config=Config()) + restored_ctx_a = type("Ctx", (), {"context": restored_context_a})() + saver.restore(checkpoint_id, scope, restored_ctx_a) + restored_context_a.stm_get()[0].metadata["trace"]["step"] = 77 + + restored_context_b = Context(config=Config()) + restored_ctx_b = type("Ctx", (), {"context": restored_context_b})() + saver.restore(checkpoint_id, scope, restored_ctx_b) + + assert restored_context_b.stm_get()[0].metadata == {"trace": {"step": 1}} diff --git a/tests/unit/test_package_initializers_facade_pattern.py b/tests/unit/test_package_initializers_facade_pattern.py index 60101c1a..c99a76ac 100644 --- a/tests/unit/test_package_initializers_facade_pattern.py +++ b/tests/unit/test_package_initializers_facade_pattern.py @@ -5,6 +5,42 @@ from pathlib import Path +def _is_internal_module_path(module: str) -> bool: + return module == "_internal" or module.startswith("_internal.") or "._internal" in module + + +def _find_direct_internal_import(node: ast.stmt) -> str | None: + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module and _is_internal_module_path(module): + return f"{'.' * node.level}{module}" + + # `from . import _internal` uses `module is None` and stores names in aliases. + for alias in node.names: + if _is_internal_module_path(alias.name): + return f"{'.' * node.level}{alias.name}" + return None + + if isinstance(node, ast.Import): + for alias in node.names: + if _is_internal_module_path(alias.name): + return alias.name + return None + + +def test_internal_import_detection_covers_relative_syntax() -> None: + cases = { + "from ._internal.foo import Bar": "._internal.foo", + "from .._internal import Foo": ".._internal", + "from . import _internal": "._internal", + "from dare_framework.tool._internal.tools import EchoTool": "dare_framework.tool._internal.tools", + "from dare_framework.tool.defaults import EchoTool": None, + } + for source, expected in cases.items(): + node = ast.parse(source).body[0] + assert _find_direct_internal_import(node) == expected + + def test_package_initializers_follow_facade_pattern() -> None: """Asserts that all dare_framework initializers use the facade pattern. @@ -13,6 +49,8 @@ def test_package_initializers_follow_facade_pattern() -> None: 2. No class or function definitions. 3. Assignments allowed only for metadata (e.g., __all__, __version__). 4. Imports allowed for re-exporting. + 5. Star imports are prohibited. + 6. Public facades must not import from `._internal` directly. """ repo_root = Path(__file__).resolve().parents[2] package_root = repo_root / "dare_framework" @@ -22,6 +60,7 @@ def test_package_initializers_follow_facade_pattern() -> None: violations: list[str] = [] for path in init_files: source = path.read_text(encoding="utf-8") + is_public_facade = "_internal" not in path.parts if not source.strip(): # Empty files are okay if they are just placeholders, # but the user wanted docstrings. @@ -75,6 +114,19 @@ def test_package_initializers_follow_facade_pattern() -> None: ) # Rule 4: Imports are allowed (for re-exporting) + if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names): + violations.append(f"{path.relative_to(repo_root)}: Prohibited star import in package initializer") + continue + + # Rule 6: Public facades must not directly import internal modules. + if is_public_facade: + direct_internal_import = _find_direct_internal_import(node) + if direct_internal_import is not None: + violations.append( + f"{path.relative_to(repo_root)}: Prohibited direct _internal import ({direct_internal_import})" + ) + continue + if isinstance(node, (ast.Import, ast.ImportFrom)): continue diff --git a/tests/unit/test_security_boundary.py b/tests/unit/test_security_boundary.py index 029c7a19..59e08603 100644 --- a/tests/unit/test_security_boundary.py +++ b/tests/unit/test_security_boundary.py @@ -2,8 +2,13 @@ import pytest +from dare_framework.security import DefaultSecurityBoundary from dare_framework.security.errors import SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError -from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.impl import ( + DefaultSecurityBoundary as ImplDefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.types import PolicyDecision, RiskLevel @@ -44,6 +49,74 @@ async def test_policy_security_boundary_requires_approval_for_high_risk() -> Non assert decision is PolicyDecision.APPROVE_REQUIRED +@pytest.mark.asyncio +async def test_default_security_boundary_remains_permissive_for_high_risk() -> None: + boundary = DefaultSecurityBoundary() + trusted = await boundary.verify_trust( + input={"command": "rm -rf /tmp/foo"}, + context={ + "capability_id": "run_command", + "risk_level": RiskLevel.NON_IDEMPOTENT_EFFECT.value, + "requires_approval": True, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="run_command", + context={"trusted_input": trusted, "capability_id": "run_command", "requires_approval": True}, + ) + + assert decision is PolicyDecision.ALLOW + + +@pytest.mark.asyncio +async def test_impl_default_security_boundary_from_config_preserves_policy_constructor() -> None: + boundary = ImplDefaultSecurityBoundary.from_config( + { + "approval_required_risk_levels": [RiskLevel.READ_ONLY.value], + "default_decision": PolicyDecision.DENY.value, + } + ) + trusted = await boundary.verify_trust( + input={"path": "README.md"}, + context={ + "capability_id": "read_file", + "risk_level": RiskLevel.READ_ONLY.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="read_file", + context={"trusted_input": trusted, "capability_id": "read_file"}, + ) + + assert isinstance(boundary, PolicySecurityBoundary) + assert decision is PolicyDecision.APPROVE_REQUIRED + + +@pytest.mark.asyncio +async def test_impl_default_security_boundary_constructor_preserves_policy_semantics() -> None: + boundary = ImplDefaultSecurityBoundary( + approval_required_risk_levels={RiskLevel.READ_ONLY}, + default_decision=PolicyDecision.DENY, + ) + trusted = await boundary.verify_trust( + input={"path": "README.md"}, + context={ + "capability_id": "read_file", + "risk_level": RiskLevel.READ_ONLY.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="read_file", + context={"trusted_input": trusted, "capability_id": "read_file"}, + ) + + assert isinstance(boundary, PolicySecurityBoundary) + assert decision is PolicyDecision.APPROVE_REQUIRED + + @pytest.mark.asyncio async def test_policy_security_boundary_denies_blocked_capability() -> None: boundary = PolicySecurityBoundary(deny_capability_ids={"run_command"})