From 1453ed5e7cb7ef718a0f850eb6398d6f073ac6c0 Mon Sep 17 00:00:00 2001 From: bouillipx Date: Fri, 27 Feb 2026 23:21:49 +0800 Subject: [PATCH 1/3] feat(security): enforce tool preflight boundary and policy gate Implement the p0-enforce-security-boundary change end-to-end across runtime wiring, policy mapping, auditability, and release gates. Key changes: - Add default security boundary implementations (no-op + policy) and structured security error codes. - Add DareAgentBuilder security boundary injection via with_security_boundary() and config-driven resolution (default policy, optional noop). - Enforce security preflight in DareAgent tool loop: verify_trust -> check_policy before any gateway invoke. - Map policy decisions deterministically: ALLOW continues, APPROVE_REQUIRED enters approval memory flow, DENY blocks with stable status/code/message payload. - Extend GovernedToolGateway with force_approval, approval_reason, and approval_observer so policy-triggered approval reuses existing approval memory control plane. - Emit structured security audit events (security.trust_verified, security.policy_checked, security.policy_denied, security.policy_approval) with capability/tool correlation fields. - Tighten RegistryPlanValidator to fail when trusted risk metadata is missing/invalid rather than silently defaulting. - Add security config surface to Config (from_dict/to_dict) and builder tests for config/override behavior. - Expand tests: unit coverage for allow/deny/approve_required paths, boundary defaults, gateway observer integration, trusted metadata validation; add integration flow proving high-risk tools are gated before invocation. - Add the new security gate test group to scripts/ci/run_risk_matrix.sh as a release gate. Additional fix included: - Harden event_trace_bridge OpenTelemetry compatibility by supporting both property- and method-style is_valid APIs to avoid runtime crashes during event append. Validation executed locally: - .venv/bin/python -m pytest -q tests/unit/test_security_boundary.py tests/unit/test_dare_agent_security_policy_gate.py tests/unit/test_builder_security_boundary.py tests/unit/test_governed_tool_gateway.py tests/unit/test_registry_plan_validator.py tests/unit/test_config_model.py tests/integration/test_security_policy_gate_flow.py - .venv/bin/python -m pytest -q tests/unit/test_five_layer_agent.py --- dare_framework/agent/builder.py | 22 ++ dare_framework/agent/dare_agent.py | 335 +++++++++++++++++- dare_framework/config/types.py | 4 + .../_internal/event_trace_bridge.py | 4 +- .../plan/_internal/registry_validator.py | 12 +- dare_framework/security/__init__.py | 28 +- dare_framework/security/errors.py | 33 ++ dare_framework/security/impl/__init__.py | 13 + .../impl/default_security_boundary.py | 270 ++++++++++++++ .../tool/_internal/governed_tool_gateway.py | 59 ++- .../p0-enforce-security-boundary/tasks.md | 26 ++ scripts/ci/run_risk_matrix.sh | 9 +- .../test_security_policy_gate_flow.py | 103 ++++++ tests/unit/test_builder_security_boundary.py | 74 ++++ tests/unit/test_config_model.py | 2 + .../test_dare_agent_security_policy_gate.py | 237 +++++++++++++ tests/unit/test_governed_tool_gateway.py | 72 ++++ tests/unit/test_registry_plan_validator.py | 28 ++ tests/unit/test_security_boundary.py | 76 ++++ 19 files changed, 1397 insertions(+), 10 deletions(-) create mode 100644 dare_framework/security/errors.py create mode 100644 dare_framework/security/impl/__init__.py create mode 100644 dare_framework/security/impl/default_security_boundary.py create mode 100644 openspec/changes/p0-enforce-security-boundary/tasks.md create mode 100644 tests/integration/test_security_policy_gate_flow.py create mode 100644 tests/unit/test_builder_security_boundary.py create mode 100644 tests/unit/test_dare_agent_security_policy_gate.py create mode 100644 tests/unit/test_security_boundary.py diff --git a/dare_framework/agent/builder.py b/dare_framework/agent/builder.py index cba08b6c..a9f1a8ba 100644 --- a/dare_framework/agent/builder.py +++ b/dare_framework/agent/builder.py @@ -63,6 +63,8 @@ IValidator, IValidatorManager, ) +from dare_framework.security.kernel import ISecurityBoundary +from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary from dare_framework.skill import Skill, ISkillLoader, ISkillStore, SkillStoreBuilder from dare_framework.skill._internal.action_handler import SkillsActionHandler from dare_framework.skill._internal.filesystem_skill_loader import FileSystemSkillLoader @@ -602,6 +604,7 @@ def __init__(self, name: str) -> None: self._execution_control: IExecutionControl | None = None self._hooks: list[IHook] = [] self._telemetry: ITelemetryProvider | None = None + self._security_boundary: ISecurityBoundary | None = None self._verbose: bool = False def with_planner(self, planner: IPlanner) -> DareAgentBuilder: @@ -632,6 +635,11 @@ def with_telemetry(self, telemetry: ITelemetryProvider) -> DareAgentBuilder: self._telemetry = telemetry return self + def with_security_boundary(self, security_boundary: ISecurityBoundary) -> DareAgentBuilder: + """Inject an explicit security boundary for tool preflight.""" + self._security_boundary = security_boundary + return self + def with_verbose(self, verbose: bool = True) -> DareAgentBuilder: self._verbose = verbose return self @@ -720,6 +728,7 @@ def _build_impl( hooks = None telemetry = self._telemetry + security_boundary = self._resolve_security_boundary(config) return DareAgent( name=self._name, model=model, @@ -733,11 +742,24 @@ def _build_impl( event_log=self._event_log, hooks=hooks, telemetry=telemetry, + security_boundary=security_boundary, agent_channel=agent_channel, verbose=self._verbose, approval_manager=approval_manager, ) + def _resolve_security_boundary(self, config: Config) -> ISecurityBoundary: + if self._security_boundary is not None: + return self._security_boundary + raw_mode = config.security.get("boundary", config.security.get("mode", "policy")) + mode = str(raw_mode).strip().lower() + if mode in {"off", "none", "noop", "disabled"}: + boundary: ISecurityBoundary = NoOpSecurityBoundary() + else: + boundary = PolicySecurityBoundary.from_config(config.security) + self._security_boundary = boundary + return boundary + __all__ = ["DareAgentBuilder", "ReactAgentBuilder", "SimpleChatAgentBuilder"] diff --git a/dare_framework/agent/dare_agent.py b/dare_framework/agent/dare_agent.py index 6a3cf9e3..020b4df8 100644 --- a/dare_framework/agent/dare_agent.py +++ b/dare_framework/agent/dare_agent.py @@ -47,6 +47,17 @@ ValidatedPlan, VerifyResult, ) +from dare_framework.security import ( + ISecurityBoundary, + NoOpSecurityBoundary, + PolicyDecision, + SECURITY_APPROVAL_MANAGER_MISSING, + SECURITY_POLICY_CHECK_FAILED, + SECURITY_POLICY_DENIED, + SECURITY_TRUST_DERIVATION_FAILED, + SecurityBoundaryError, +) +from dare_framework.security.types import TrustedInput from dare_framework.tool._internal.governed_tool_gateway import ( ApprovalInvokeContext, GovernedToolGateway, @@ -63,6 +74,15 @@ class MilestoneResult: verify_result: VerifyResult | None = None +@dataclass(frozen=True) +class SecurityPreflightResult: + """Outcome of tool security preflight checks.""" + + trusted_input: TrustedInput + decision: PolicyDecision + reason: str | None = None + + if TYPE_CHECKING: from dare_framework.config.types import Config from dare_framework.context.kernel import IContext @@ -121,6 +141,7 @@ def __init__( event_log: IEventLog | None = None, hooks: list[IHook] | None = None, telemetry: ITelemetryProvider | None = None, + security_boundary: ISecurityBoundary | None = None, # Milestone orchestration components (optional) sandbox: IPlanAttemptSandbox | None = None, step_executor: IStepExecutor | None = None, @@ -148,6 +169,7 @@ def __init__( event_log: Event log for audit (optional). hooks: Hook implementations invoked at lifecycle phases (optional). telemetry: Telemetry provider for traces/metrics/logs (optional). + security_boundary: Security boundary used for trust/policy preflight. max_milestone_attempts: Max retries per milestone. max_plan_attempts: Max plan generation attempts. max_tool_iterations: Max tool call iterations per execute loop. @@ -193,6 +215,7 @@ def __init__( self._telemetry = telemetry if telemetry is not None else NoOpTelemetryProvider() self._event_log = make_trace_aware(event_log) self._hooks = list(hooks) if hooks is not None else [] + self._security_boundary = security_boundary if security_boundary is not None else NoOpSecurityBoundary() if isinstance(self._telemetry, OTelTelemetryProvider): if not any(isinstance(hook, ObservabilityHook) for hook in self._hooks): self._hooks.append(ObservabilityHook(self._telemetry)) @@ -976,14 +999,166 @@ async def _run_tool_loop( "error": policy_error, "output": {}, } + + try: + preflight = await self._evaluate_tool_security( + request=request, + descriptor=descriptor, + tool_name=tool_name, + tool_call_id=tool_call_id, + attempt=attempts, + ) + except SecurityBoundaryError as exc: + denied_error = str(exc).strip() or "security policy denied tool invocation" + await self._log_event( + "security.policy_denied", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "code": exc.code, + "reason": exc.reason, + "error": denied_error, + }, + ) + denied_status = "not_allow" + await self._emit_hook( + HookPhase.AFTER_TOOL, + { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "capability_id": request.capability_id, + "attempt": attempts, + "success": False, + "error": denied_error, + "approved": False, + "policy_decision": PolicyDecision.DENY.value, + "security_code": exc.code, + "evidence_collected": False, + "duration_ms": (time.perf_counter() - tool_start) * 1000.0, + "budget_stats": self._budget_stats(), + }, + ) + return { + "success": False, + "status": denied_status, + "error": denied_error, + "output": { + "status": denied_status, + "code": exc.code, + "message": denied_error, + }, + } + + trusted_input = preflight.trusted_input + risk_level = self._risk_level_from_trusted_input(trusted_input) + effective_params = dict(trusted_input.params) + policy_decision = preflight.decision.value + + if preflight.decision is PolicyDecision.DENY: + denied_error = preflight.reason or "tool invocation denied by security policy" + await self._log_event( + "security.policy_denied", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "code": SECURITY_POLICY_DENIED, + "reason": preflight.reason, + }, + ) + denied_status = "not_allow" + await self._emit_hook( + HookPhase.AFTER_TOOL, + { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "capability_id": request.capability_id, + "attempt": attempts, + "success": False, + "error": denied_error, + "approved": False, + "policy_decision": policy_decision, + "security_code": SECURITY_POLICY_DENIED, + "evidence_collected": False, + "duration_ms": (time.perf_counter() - tool_start) * 1000.0, + "budget_stats": self._budget_stats(), + }, + ) + return { + "success": False, + "status": denied_status, + "error": denied_error, + "output": { + "status": denied_status, + "code": SECURITY_POLICY_DENIED, + "message": denied_error, + }, + } + + if ( + preflight.decision is PolicyDecision.APPROVE_REQUIRED + and self._approval_manager is None + ): + denied_error = "tool invocation requires approval but no approval manager is configured" + await self._log_event( + "security.policy_denied", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "code": SECURITY_APPROVAL_MANAGER_MISSING, + "reason": "approval manager missing", + }, + ) + await self._emit_hook( + HookPhase.AFTER_TOOL, + { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "capability_id": request.capability_id, + "attempt": attempts, + "success": False, + "error": denied_error, + "approved": False, + "policy_decision": policy_decision, + "security_code": SECURITY_APPROVAL_MANAGER_MISSING, + "evidence_collected": False, + "duration_ms": (time.perf_counter() - tool_start) * 1000.0, + "budget_stats": self._budget_stats(), + }, + ) + return { + "success": False, + "status": "fail", + "error": denied_error, + "output": { + "status": "fail", + "code": SECURITY_APPROVAL_MANAGER_MISSING, + "message": denied_error, + }, + } + await self._log_event("tool.invoke", { "tool_name": tool_name, "tool_call_id": tool_call_id, "capability_id": request.capability_id, "attempt": attempts, + "policy_decision": policy_decision, }) try: + async def approval_observer(payload: dict[str, Any]) -> None: + await self._log_event( + "security.policy_approval", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + **payload, + }, + ) + approval_ctx = ApprovalInvokeContext( session_id=session_id, transport=transport, @@ -991,12 +1166,15 @@ async def _run_tool_loop( tool_call_id=tool_call_id, event_logger=self._log_event, runtime_context=self._context, + force_approval=preflight.decision is PolicyDecision.APPROVE_REQUIRED, + approval_reason=preflight.reason, + approval_observer=approval_observer, ) result = await self._governed_tool_gateway.invoke( request.capability_id, approval_ctx, envelope=request.envelope, - **request.params, + **effective_params, ) await self._log_event("tool.result", { @@ -1005,6 +1183,7 @@ async def _run_tool_loop( "capability_id": request.capability_id, "success": getattr(result, "success", True), "attempt": attempts, + "policy_decision": policy_decision, }) tool_success = True @@ -1030,6 +1209,7 @@ async def _run_tool_loop( "success": tool_success, "error": result.error if hasattr(result, "error") else None, "approved": approved, + "policy_decision": policy_decision, "evidence_collected": evidence_collected, "duration_ms": (time.perf_counter() - tool_start) * 1000.0, "budget_stats": self._budget_stats(), @@ -1076,6 +1256,7 @@ async def _run_tool_loop( "capability_id": request.capability_id, "error": str(e), "attempt": attempts, + "policy_decision": policy_decision, }) approved = True try: @@ -1095,6 +1276,7 @@ async def _run_tool_loop( "success": False, "error": str(e), "approved": approved, + "policy_decision": policy_decision, "evidence_collected": False, "duration_ms": (time.perf_counter() - tool_start) * 1000.0, "budget_stats": self._budget_stats(), @@ -1252,6 +1434,157 @@ def _mount_skill_from_result(self, output: Any) -> None: ) ) + async def _evaluate_tool_security( + self, + *, + request: ToolLoopRequest, + descriptor: Any | None, + tool_name: str, + tool_call_id: str, + attempt: int, + ) -> SecurityPreflightResult: + trust_context: dict[str, Any] = { + "capability_id": request.capability_id, + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "attempt": attempt, + "descriptor": descriptor, + "risk_level": getattr(request.envelope.risk_level, "value", request.envelope.risk_level), + "envelope_risk_level": getattr(request.envelope.risk_level, "value", request.envelope.risk_level), + "requires_approval": self._requires_approval(descriptor), + } + try: + trusted_input = await self._security_boundary.verify_trust( + input=dict(request.params), + context=trust_context, + ) + except SecurityBoundaryError as exc: + await self._log_event( + "security.trust_verified", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "status": "failed", + "code": exc.code, + "reason": exc.reason, + }, + ) + raise + except Exception as exc: + message = str(exc).strip() or "security trust verification failed" + await self._log_event( + "security.trust_verified", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "status": "failed", + "code": SECURITY_TRUST_DERIVATION_FAILED, + "reason": message, + }, + ) + raise SecurityBoundaryError( + code=SECURITY_TRUST_DERIVATION_FAILED, + message=message, + reason=message, + ) from exc + + await self._log_event( + "security.trust_verified", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "status": "verified", + "risk_level": trusted_input.risk_level.value, + "requires_approval": bool(trusted_input.metadata.get("requires_approval", False)), + }, + ) + + policy_context = { + **trust_context, + "trusted_input": trusted_input, + "risk_level": trusted_input.risk_level.value, + "requires_approval": bool(trusted_input.metadata.get("requires_approval", False)), + "metadata": dict(trusted_input.metadata), + } + try: + decision = await self._security_boundary.check_policy( + action="invoke_tool", + resource=request.capability_id, + context=policy_context, + ) + if not isinstance(decision, PolicyDecision): + raw_value = getattr(decision, "value", decision) + try: + decision = PolicyDecision(str(raw_value)) + except ValueError as exc: + raise SecurityBoundaryError( + code=SECURITY_POLICY_CHECK_FAILED, + message=f"unsupported policy decision: {raw_value!r}", + reason="security boundary returned unknown policy decision", + ) from exc + except SecurityBoundaryError: + raise + except Exception as exc: + message = str(exc).strip() or "security policy check failed" + raise SecurityBoundaryError( + code=SECURITY_POLICY_CHECK_FAILED, + message=message, + reason=message, + ) from exc + + reason = self._derive_policy_reason( + decision=decision, + capability_id=request.capability_id, + trusted_input=trusted_input, + ) + await self._log_event( + "security.policy_checked", + { + "tool_name": tool_name, + "tool_call_id": tool_call_id, + "capability_id": request.capability_id, + "decision": decision.value, + "reason": reason, + }, + ) + return SecurityPreflightResult( + trusted_input=trusted_input, + decision=decision, + reason=reason, + ) + + def _derive_policy_reason( + self, + *, + decision: PolicyDecision, + capability_id: str, + trusted_input: TrustedInput, + ) -> str | None: + metadata = dict(trusted_input.metadata) + if decision is PolicyDecision.ALLOW: + return None + if decision is PolicyDecision.APPROVE_REQUIRED: + raw_reason = metadata.get("approval_reason") + if isinstance(raw_reason, str) and raw_reason.strip(): + return raw_reason.strip() + return f"policy requires approval for capability '{capability_id}'" + raw_reason = metadata.get("deny_reason") + if isinstance(raw_reason, str) and raw_reason.strip(): + return raw_reason.strip() + return f"policy denied capability '{capability_id}'" + + def _risk_level_from_trusted_input(self, trusted_input: TrustedInput) -> int: + mapping = { + "read_only": 1, + "idempotent_write": 2, + "non_idempotent_effect": 3, + "compensatable": 4, + } + return mapping.get(trusted_input.risk_level.value, 1) + def _risk_level_value(self, descriptor: Any | None) -> int: if descriptor is None or descriptor.metadata is None: return 1 diff --git a/dare_framework/config/types.py b/dare_framework/config/types.py index cbbb5dd3..f6068259 100644 --- a/dare_framework/config/types.py +++ b/dare_framework/config/types.py @@ -356,6 +356,7 @@ class Config: allow_mcps: list[str] = field(default_factory=list) components: dict[str, ComponentConfig] = field(default_factory=dict) hooks: HooksConfig = field(default_factory=HooksConfig) + security: dict[str, Any] = field(default_factory=dict) knowledge: dict[str, Any] = field(default_factory=dict) """Knowledge backend config: type (vector|rawdata), storage (in_memory|sqlite|chromadb), options.""" long_term_memory: dict[str, Any] = field(default_factory=dict) @@ -397,6 +398,7 @@ def from_dict(cls, data: dict[str, Any]) -> Config: } hooks_raw = data.get("hooks") hooks = HooksConfig.from_dict(hooks_raw) if isinstance(hooks_raw, dict) else HooksConfig() + security = data.get("security") if isinstance(data.get("security"), dict) else {} knowledge = data.get("knowledge") if isinstance(data.get("knowledge"), dict) else {} long_term_memory = data.get("long_term_memory") if isinstance(data.get("long_term_memory"), dict) else {} prompt_store_path_pattern = data.get("prompt_store_path_pattern") @@ -433,6 +435,7 @@ def from_dict(cls, data: dict[str, Any]) -> Config: allow_mcps=allow_mcps, components=components, hooks=hooks, + security=security, knowledge=knowledge, long_term_memory=long_term_memory, workspace_dir=workspace_dir, @@ -484,6 +487,7 @@ def to_dict(self) -> dict[str, Any]: "allow_mcps": list(self.allow_mcps), "components": {key: value.to_dict() for key, value in self.components.items()}, "hooks": self.hooks.to_dict(), + "security": dict(self.security), "knowledge": dict(self.knowledge), "long_term_memory": dict(self.long_term_memory), "workspace_dir": self.workspace_dir, diff --git a/dare_framework/observability/_internal/event_trace_bridge.py b/dare_framework/observability/_internal/event_trace_bridge.py index 4b5d623d..4daac101 100644 --- a/dare_framework/observability/_internal/event_trace_bridge.py +++ b/dare_framework/observability/_internal/event_trace_bridge.py @@ -32,7 +32,9 @@ def extract_trace_context() -> TraceContext | None: return None ctx = trace.get_current_span().get_span_context() - if not ctx.is_valid(): + is_valid_attr = getattr(ctx, "is_valid", None) + is_valid = is_valid_attr() if callable(is_valid_attr) else bool(is_valid_attr) + if not is_valid: return None return TraceContext( diff --git a/dare_framework/plan/_internal/registry_validator.py b/dare_framework/plan/_internal/registry_validator.py index a6789e32..caa45eee 100644 --- a/dare_framework/plan/_internal/registry_validator.py +++ b/dare_framework/plan/_internal/registry_validator.py @@ -136,7 +136,13 @@ def _validate_step( return None metadata = _normalize_metadata(capability.metadata) + if "risk_level" not in metadata: + errors.append(f"missing trusted risk metadata for capability: {resolved_id}") + return None risk_level = _parse_risk_level(metadata.get("risk_level")) + if risk_level is None: + errors.append(f"invalid trusted risk metadata for capability: {resolved_id}") + return None return ValidatedStep( step_id=step.step_id, @@ -219,15 +225,15 @@ def _normalize_value(value: Any) -> Any: return value -def _parse_risk_level(value: Any) -> RiskLevel: +def _parse_risk_level(value: Any) -> RiskLevel | None: if isinstance(value, RiskLevel): return value if isinstance(value, str): try: return RiskLevel(value) except ValueError: - return RiskLevel.READ_ONLY - return RiskLevel.READ_ONLY + return None + return None __all__ = ["RegistryPlanValidator"] diff --git a/dare_framework/security/__init__.py b/dare_framework/security/__init__.py index ce4629ce..72e4f064 100644 --- a/dare_framework/security/__init__.py +++ b/dare_framework/security/__init__.py @@ -1,6 +1,32 @@ """security domain facade.""" +from dare_framework.security.errors import ( + SECURITY_APPROVAL_MANAGER_MISSING, + SECURITY_POLICY_CHECK_FAILED, + SECURITY_POLICY_DENIED, + SECURITY_TRUST_DERIVATION_FAILED, + SecurityBoundaryError, +) +from dare_framework.security.impl import ( + DefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) from dare_framework.security.kernel import ISecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput -__all__ = ["ISecurityBoundary", "PolicyDecision", "RiskLevel", "SandboxSpec", "TrustedInput"] +__all__ = [ + "DefaultSecurityBoundary", + "ISecurityBoundary", + "NoOpSecurityBoundary", + "PolicyDecision", + "PolicySecurityBoundary", + "RiskLevel", + "SECURITY_APPROVAL_MANAGER_MISSING", + "SECURITY_POLICY_CHECK_FAILED", + "SECURITY_POLICY_DENIED", + "SECURITY_TRUST_DERIVATION_FAILED", + "SandboxSpec", + "SecurityBoundaryError", + "TrustedInput", +] diff --git a/dare_framework/security/errors.py b/dare_framework/security/errors.py new file mode 100644 index 00000000..400167b3 --- /dev/null +++ b/dare_framework/security/errors.py @@ -0,0 +1,33 @@ +"""Security-domain error contracts.""" + +from __future__ import annotations + + +SECURITY_TRUST_DERIVATION_FAILED = "SECURITY_TRUST_DERIVATION_FAILED" +SECURITY_POLICY_CHECK_FAILED = "SECURITY_POLICY_CHECK_FAILED" +SECURITY_POLICY_DENIED = "SECURITY_POLICY_DENIED" +SECURITY_APPROVAL_MANAGER_MISSING = "SECURITY_APPROVAL_MANAGER_MISSING" + + +class SecurityBoundaryError(RuntimeError): + """Structured error raised by security boundaries.""" + + def __init__( + self, + *, + code: str, + message: str, + reason: str | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.reason = reason + + +__all__ = [ + "SECURITY_APPROVAL_MANAGER_MISSING", + "SECURITY_POLICY_CHECK_FAILED", + "SECURITY_POLICY_DENIED", + "SECURITY_TRUST_DERIVATION_FAILED", + "SecurityBoundaryError", +] diff --git a/dare_framework/security/impl/__init__.py b/dare_framework/security/impl/__init__.py new file mode 100644 index 00000000..62328cb6 --- /dev/null +++ b/dare_framework/security/impl/__init__.py @@ -0,0 +1,13 @@ +"""Default security boundary implementations.""" + +from dare_framework.security.impl.default_security_boundary import ( + DefaultSecurityBoundary, + NoOpSecurityBoundary, + PolicySecurityBoundary, +) + +__all__ = [ + "DefaultSecurityBoundary", + "NoOpSecurityBoundary", + "PolicySecurityBoundary", +] diff --git a/dare_framework/security/impl/default_security_boundary.py b/dare_framework/security/impl/default_security_boundary.py new file mode 100644 index 00000000..46b17f4b --- /dev/null +++ b/dare_framework/security/impl/default_security_boundary.py @@ -0,0 +1,270 @@ +"""Default security boundary implementations.""" + +from __future__ import annotations + +import inspect +from typing import Any, Callable, Iterable + +from dare_framework.security.errors import ( + SECURITY_TRUST_DERIVATION_FAILED, + SecurityBoundaryError, +) +from dare_framework.security.kernel import ISecurityBoundary +from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput + + +def _coerce_risk_level(value: Any) -> RiskLevel | None: + if isinstance(value, RiskLevel): + return value + if hasattr(value, "value"): + value = value.value + if isinstance(value, str): + try: + return RiskLevel(value) + except ValueError: + return None + return None + + +def _coerce_policy_decision(value: Any, *, default: PolicyDecision) -> PolicyDecision: + if isinstance(value, PolicyDecision): + return value + if hasattr(value, "value"): + value = value.value + if isinstance(value, str): + try: + return PolicyDecision(value) + except ValueError: + return default + return default + + +def _coerce_risk_levels(values: Any, *, default: set[RiskLevel]) -> set[RiskLevel]: + if not isinstance(values, (list, tuple, set)): + return set(default) + normalized: set[RiskLevel] = set() + for value in values: + parsed = _coerce_risk_level(value) + if parsed is not None: + normalized.add(parsed) + return normalized if normalized else set(default) + + +def _coerce_str_set(values: Any) -> set[str]: + if not isinstance(values, (list, tuple, set)): + return set() + normalized: set[str] = set() + for value in values: + if not isinstance(value, str): + continue + item = value.strip() + if item: + normalized.add(item) + return normalized + + +def _descriptor_metadata(context: dict[str, Any]) -> dict[str, Any]: + descriptor = context.get("descriptor") + metadata = getattr(descriptor, "metadata", None) + if isinstance(metadata, dict): + return dict(metadata) + return {} + + +def _derive_capability_id(context: dict[str, Any], resource: str | None = None) -> str | None: + for key in ("capability_id", "resource", "tool_name"): + value = context.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(resource, str) and resource.strip(): + return resource.strip() + descriptor = context.get("descriptor") + value = getattr(descriptor, "id", None) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _derive_risk_level(context: dict[str, Any], metadata: dict[str, Any]) -> RiskLevel | None: + candidates: Iterable[Any] = ( + metadata.get("risk_level"), + context.get("risk_level"), + context.get("envelope_risk_level"), + ) + for candidate in candidates: + parsed = _coerce_risk_level(candidate) + if parsed is not None: + return parsed + return None + + +def _derive_requires_approval(context: dict[str, Any], metadata: dict[str, Any]) -> bool: + value = metadata.get("requires_approval", context.get("requires_approval", False)) + return bool(value) + + +async def _execute_callable(fn: Callable[[], Any]) -> Any: + result = fn() + if inspect.isawaitable(result): + return await result + return result + + +class NoOpSecurityBoundary(ISecurityBoundary): + """Permissive boundary used for migration and local development.""" + + async def verify_trust(self, *, input: dict[str, Any], context: dict[str, Any]) -> TrustedInput: + metadata = _descriptor_metadata(context) + capability_id = _derive_capability_id(context) + if capability_id is not None: + metadata.setdefault("capability_id", capability_id) + metadata["requires_approval"] = _derive_requires_approval(context, metadata) + risk_level = _derive_risk_level(context, metadata) or RiskLevel.READ_ONLY + return TrustedInput(params=dict(input), risk_level=risk_level, metadata=metadata) + + async def check_policy( + self, + *, + action: str, + resource: str, + context: dict[str, Any], + ) -> PolicyDecision: + _ = (action, resource, context) + return PolicyDecision.ALLOW + + async def execute_safe( + self, + *, + action: str, + fn: Callable[[], Any], + sandbox: SandboxSpec, + ) -> Any: + _ = (action, sandbox) + return await _execute_callable(fn) + + +class PolicySecurityBoundary(ISecurityBoundary): + """Default policy boundary with deterministic decision mapping.""" + + def __init__( + self, + *, + deny_capability_ids: set[str] | None = None, + approval_required_risk_levels: set[RiskLevel] | None = None, + deny_risk_levels: set[RiskLevel] | None = None, + default_decision: PolicyDecision = PolicyDecision.ALLOW, + require_trusted_metadata: bool = False, + ) -> None: + self._deny_capability_ids = set(deny_capability_ids or set()) + self._approval_required_risk_levels = ( + set(approval_required_risk_levels) + if approval_required_risk_levels is not None + else {RiskLevel.NON_IDEMPOTENT_EFFECT} + ) + self._deny_risk_levels = set(deny_risk_levels or set()) + self._default_decision = default_decision + self._require_trusted_metadata = require_trusted_metadata + + @classmethod + def from_config(cls, config: dict[str, Any] | None) -> PolicySecurityBoundary: + settings = dict(config or {}) + deny_capability_ids = _coerce_str_set( + settings.get("deny_capability_ids", settings.get("blocked_capability_ids", [])) + ) + approval_required_risk_levels = _coerce_risk_levels( + settings.get("approval_required_risk_levels"), + default={RiskLevel.NON_IDEMPOTENT_EFFECT}, + ) + deny_risk_levels = _coerce_risk_levels( + settings.get("deny_risk_levels"), + default=set(), + ) + default_decision = _coerce_policy_decision( + settings.get("default_decision"), + default=PolicyDecision.ALLOW, + ) + require_trusted_metadata = bool(settings.get("require_trusted_metadata", False)) + return cls( + deny_capability_ids=deny_capability_ids, + approval_required_risk_levels=approval_required_risk_levels, + deny_risk_levels=deny_risk_levels, + default_decision=default_decision, + require_trusted_metadata=require_trusted_metadata, + ) + + async def verify_trust(self, *, input: dict[str, Any], context: dict[str, Any]) -> TrustedInput: + metadata = _descriptor_metadata(context) + capability_id = _derive_capability_id(context) + if capability_id is None: + raise SecurityBoundaryError( + code=SECURITY_TRUST_DERIVATION_FAILED, + message="missing trusted capability identifier", + reason="capability_id not found in trusted context", + ) + metadata["capability_id"] = capability_id + + risk_level = _derive_risk_level(context, metadata) + if risk_level is None: + if self._require_trusted_metadata: + raise SecurityBoundaryError( + code=SECURITY_TRUST_DERIVATION_FAILED, + message=f"missing trusted risk metadata for capability '{capability_id}'", + reason="risk_level not derivable from trusted metadata", + ) + risk_level = RiskLevel.READ_ONLY + metadata.setdefault("risk_level", risk_level.value) + else: + metadata["risk_level"] = risk_level.value + + metadata["requires_approval"] = _derive_requires_approval(context, metadata) + return TrustedInput(params=dict(input), risk_level=risk_level, metadata=metadata) + + async def check_policy( + self, + *, + action: str, + resource: str, + context: dict[str, Any], + ) -> PolicyDecision: + _ = action + trusted = context.get("trusted_input") + metadata = dict(getattr(trusted, "metadata", {})) if trusted is not None else _descriptor_metadata(context) + capability_id = _derive_capability_id(context, resource) or resource + risk_level = ( + getattr(trusted, "risk_level", None) + if trusted is not None + else _derive_risk_level(context, metadata) + ) + if risk_level is None: + risk_level = RiskLevel.READ_ONLY + + if capability_id in self._deny_capability_ids: + return PolicyDecision.DENY + if risk_level in self._deny_risk_levels: + return PolicyDecision.DENY + if _derive_requires_approval(context, metadata): + return PolicyDecision.APPROVE_REQUIRED + if risk_level in self._approval_required_risk_levels: + return PolicyDecision.APPROVE_REQUIRED + return self._default_decision + + async def execute_safe( + self, + *, + action: str, + fn: Callable[[], Any], + sandbox: SandboxSpec, + ) -> Any: + _ = (action, sandbox) + return await _execute_callable(fn) + + +# Backward-compatible alias for legacy references. +DefaultSecurityBoundary = PolicySecurityBoundary + + +__all__ = [ + "DefaultSecurityBoundary", + "NoOpSecurityBoundary", + "PolicySecurityBoundary", +] diff --git a/dare_framework/tool/_internal/governed_tool_gateway.py b/dare_framework/tool/_internal/governed_tool_gateway.py index a465100c..0dc43817 100644 --- a/dare_framework/tool/_internal/governed_tool_gateway.py +++ b/dare_framework/tool/_internal/governed_tool_gateway.py @@ -35,6 +35,7 @@ from dare_framework.transport.kernel import AgentChannel ApprovalEventLogger = Callable[[str, dict[str, Any]], Awaitable[None]] +ApprovalObserver = Callable[[dict[str, Any]], Awaitable[None]] @dataclass(frozen=True) @@ -47,6 +48,9 @@ class ApprovalInvokeContext: tool_call_id: str | None = None event_logger: ApprovalEventLogger | None = None runtime_context: Context | None = None + force_approval: bool = False + approval_reason: str | None = None + approval_observer: ApprovalObserver | None = None @dataclass(frozen=True) @@ -90,6 +94,9 @@ async def invoke( tool_call_id = approval.tool_call_id if approval is not None else None approval_event_logger = approval.event_logger if approval is not None else None runtime_context = approval.runtime_context if approval is not None else None + force_approval = approval.force_approval if approval is not None else False + approval_reason = approval.approval_reason if approval is not None else None + approval_observer = approval.approval_observer if approval is not None else None if runtime_context is None: runtime_context = context @@ -101,7 +108,7 @@ async def invoke( delegate_params = dict(params) delegate_params.setdefault("context", context) - requires_approval = self._requires_approval(capability_id) + requires_approval = force_approval or self._requires_approval(capability_id) if requires_approval: approval_resolution = await self._resolve_approval( capability_id=capability_id, @@ -110,6 +117,8 @@ async def invoke( transport=transport, tool_name=tool_name or capability_id, tool_call_id=tool_call_id or "unknown", + approval_reason=approval_reason, + approval_observer=approval_observer, event_logger=approval_event_logger, ) if approval_resolution.verdict != "allow": @@ -153,6 +162,8 @@ async def _resolve_approval( transport: AgentChannel | None, tool_name: str, tool_call_id: str, + approval_reason: str | None, + approval_observer: ApprovalObserver | None, event_logger: ApprovalEventLogger | None, ) -> ApprovalResolution: if self._approval_manager is None: @@ -165,7 +176,7 @@ async def _resolve_approval( capability_id=capability_id, params=params, session_id=session_id, - reason=f"Tool {capability_id} requires approval", + reason=approval_reason or f"Tool {capability_id} requires approval", ) if evaluation.status == ApprovalEvaluationStatus.ALLOW: await self._emit_approval_event( @@ -180,6 +191,14 @@ async def _resolve_approval( "rule_id": evaluation.rule.rule_id if evaluation.rule is not None else None, }, ) + await self._notify_approval_observer( + approval_observer, + { + "status": "allow", + "source": "rule", + "rule_id": evaluation.rule.rule_id if evaluation.rule is not None else None, + }, + ) return ApprovalResolution(verdict="allow") if evaluation.status == ApprovalEvaluationStatus.DENY: await self._emit_approval_event( @@ -194,6 +213,14 @@ async def _resolve_approval( "rule_id": evaluation.rule.rule_id if evaluation.rule is not None else None, }, ) + await self._notify_approval_observer( + approval_observer, + { + "status": "deny", + "source": "rule", + "rule_id": evaluation.rule.rule_id if evaluation.rule is not None else None, + }, + ) return ApprovalResolution( verdict="deny", error="tool invocation denied by approval rule", @@ -205,6 +232,14 @@ async def _resolve_approval( ) request_id = evaluation.request.request_id + await self._notify_approval_observer( + approval_observer, + { + "status": "pending", + "source": "pending_request", + "request_id": request_id, + }, + ) await self._emit_approval_pending_message( request=evaluation.request.to_dict(), transport=transport, @@ -242,6 +277,14 @@ async def _resolve_approval( "request_id": request_id, }, ) + await self._notify_approval_observer( + approval_observer, + { + "status": decision.value, + "source": "pending_request", + "request_id": request_id, + }, + ) if decision == ApprovalDecision.ALLOW: return ApprovalResolution(verdict="allow") return ApprovalResolution( @@ -301,6 +344,18 @@ async def _emit_approval_event( except Exception: self._logger.exception("approval event emission failed: %s", event_type) + async def _notify_approval_observer( + self, + observer: ApprovalObserver | None, + payload: dict[str, Any], + ) -> None: + if observer is None: + return + try: + await observer(payload) + except Exception: + self._logger.exception("approval observer callback failed") + def _build_delegate_invoke_kwargs( self, *, diff --git a/openspec/changes/p0-enforce-security-boundary/tasks.md b/openspec/changes/p0-enforce-security-boundary/tasks.md new file mode 100644 index 00000000..9743b5e6 --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/tasks.md @@ -0,0 +1,26 @@ +## 1. Security Boundary Wiring + +- [x] 1.1 为 security domain 增加默认 boundary 实现(no-op + policy)。 +- [x] 1.2 在 builder 增加 `with_security_boundary` 注入与 config 解析。 +- [x] 1.3 在 `DareAgent` 工具执行前接入 `verify_trust` 预处理。 +- [x] 1.4 在 `DareAgent` 工具执行前接入 `check_policy` 判定分支。 + +## 2. Policy Decision Integration + +- [x] 2.1 统一 `ALLOW / APPROVE_REQUIRED / DENY` 到运行时行为映射。 +- [x] 2.2 将 `APPROVE_REQUIRED` 分支接入 approval memory 流程。 +- [x] 2.3 为 `DENY` 分支定义稳定错误码和用户可读错误信息。 +- [x] 2.4 对接 Hook/Telemetry,确保策略决策可观测。 + +## 3. Audit Event Contract + +- [x] 3.1 定义安全门控相关事件类型与 payload 字段约定。 +- [x] 3.2 在 trust 校验与 policy 判定处落审计事件。 +- [x] 3.3 增加事件字段契约测试,确保兼容 replay/query。 + +## 4. Tests and Release Gate + +- [x] 4.1 新增单元测试覆盖 allow/deny/approve_required 三路径。 +- [x] 4.2 新增集成测试验证高风险工具必须经过 policy gate。 +- [x] 4.3 增加回归测试验证无 boundary 时不会 silent bypass。 +- [x] 4.4 在 CI 增加本 change 的必过测试分组。 diff --git a/scripts/ci/run_risk_matrix.sh b/scripts/ci/run_risk_matrix.sh index 4d902398..ef85a4fc 100755 --- a/scripts/ci/run_risk_matrix.sh +++ b/scripts/ci/run_risk_matrix.sh @@ -2,8 +2,13 @@ set -euo pipefail # Keep this suite small and stable: auth, channel concurrency/backpressure, -# and execution-control behavior are the highest-leverage regression sentinels. +# execution-control, and security gate behavior are the highest-leverage +# regression sentinels. pytest -q \ tests/unit/test_a2a.py \ tests/unit/test_transport_channel.py \ - tests/unit/test_execution_control.py + tests/unit/test_execution_control.py \ + tests/unit/test_security_boundary.py \ + tests/unit/test_governed_tool_gateway.py \ + tests/unit/test_dare_agent_security_policy_gate.py \ + tests/integration/test_security_policy_gate_flow.py diff --git a/tests/integration/test_security_policy_gate_flow.py b/tests/integration/test_security_policy_gate_flow.py new file mode 100644 index 00000000..5e492e97 --- /dev/null +++ b/tests/integration/test_security_policy_gate_flow.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest + +from dare_framework.agent.dare_agent import DareAgent +from dare_framework.config import Config +from dare_framework.context import Context +from dare_framework.model.types import ModelInput, ModelResponse +from dare_framework.security.impl import PolicySecurityBoundary +from dare_framework.tool._internal.control.approval_manager import ( + ApprovalMatcherKind, + ApprovalScope, + JsonApprovalRuleStore, + ToolApprovalManager, +) +from dare_framework.tool.types import CapabilityDescriptor, CapabilityType, ToolResult + + +class _TwoStepModel: + name = "two-step-model" + + def __init__(self) -> None: + self._responses = [ + ModelResponse( + content="run high-risk command", + tool_calls=[{"name": "run_command", "arguments": {"command": "echo hi"}}], + ), + ModelResponse(content="done", tool_calls=[]), + ] + + async def generate(self, model_input: ModelInput, *, options: Any = None) -> ModelResponse: + _ = (model_input, options) + if self._responses: + return self._responses.pop(0) + return ModelResponse(content="done", tool_calls=[]) + + +class _RecordingGateway: + def __init__(self) -> None: + self.invoke_calls: list[dict[str, Any]] = [] + self._descriptor = CapabilityDescriptor( + id="run_command", + type=CapabilityType.TOOL, + name="run_command", + description="Run shell command", + input_schema={"type": "object", "properties": {"command": {"type": "string"}}}, + metadata={ + "risk_level": "non_idempotent_effect", + "requires_approval": False, + }, + ) + + def list_capabilities(self) -> list[CapabilityDescriptor]: + return [self._descriptor] + + async def invoke(self, capability_id: str, *, envelope: Any, **params: Any) -> ToolResult[dict[str, Any]]: + self.invoke_calls.append( + {"capability_id": capability_id, "envelope": envelope, "params": dict(params)} + ) + return ToolResult(success=True, output={"ok": True}) + + +@pytest.mark.asyncio +async def test_high_risk_tool_invocation_must_pass_policy_gate(tmp_path: Path) -> None: + approval_manager = ToolApprovalManager( + workspace_store=JsonApprovalRuleStore(tmp_path / "workspace" / "approvals.json"), + user_store=JsonApprovalRuleStore(tmp_path / "user" / "approvals.json"), + ) + gateway = _RecordingGateway() + agent = DareAgent( + name="security-policy-gate-flow", + model=_TwoStepModel(), + context=Context(config=Config()), + tool_gateway=gateway, + approval_manager=approval_manager, + security_boundary=PolicySecurityBoundary(), + ) + + run_task = asyncio.create_task(agent("run high risk tool")) + request_id: str | None = None + for _ in range(100): + pending = approval_manager.list_pending() + if pending: + request_id = pending[0].request_id + break + await asyncio.sleep(0.01) + + assert request_id is not None + assert gateway.invoke_calls == [] + + await approval_manager.grant( + request_id, + scope=ApprovalScope.ONCE, + matcher=ApprovalMatcherKind.EXACT_PARAMS, + ) + result = await run_task + + assert result.success is True + assert len(gateway.invoke_calls) == 1 diff --git a/tests/unit/test_builder_security_boundary.py b/tests/unit/test_builder_security_boundary.py new file mode 100644 index 00000000..37d88f54 --- /dev/null +++ b/tests/unit/test_builder_security_boundary.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Any + +from dare_framework.agent import BaseAgent +from dare_framework.config import Config +from dare_framework.context import Context +from dare_framework.model.types import ModelInput, ModelResponse +from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.tool.types import ToolResult + + +class _Model: + name = "mock-model" + + async def generate(self, model_input: ModelInput, *, options: Any = None) -> ModelResponse: + _ = (model_input, options) + return ModelResponse(content="ok", tool_calls=[]) + + +class _ToolGateway: + def list_capabilities(self) -> list[Any]: + return [] + + async def invoke(self, capability_id: str, *, envelope: Any, **params: Any) -> ToolResult[dict[str, Any]]: + _ = (capability_id, envelope, params) + return ToolResult(success=True, output={}) + + +def test_dare_builder_defaults_to_policy_security_boundary() -> None: + builder = BaseAgent.dare_agent_builder("security-default").with_model(_Model()) + config = Config() + agent = builder._build_impl( # noqa: SLF001 - builder contract test + config=config, + model=_Model(), + context=Context(config=config), + tool_gateway=_ToolGateway(), + approval_manager=None, + agent_channel=None, + ) + + assert isinstance(agent._security_boundary, PolicySecurityBoundary) + + +def test_dare_builder_supports_noop_boundary_from_config() -> None: + builder = BaseAgent.dare_agent_builder("security-noop").with_model(_Model()) + config = Config.from_dict({"security": {"boundary": "noop"}}) + agent = builder._build_impl( # noqa: SLF001 - builder contract test + config=config, + model=_Model(), + context=Context(config=config), + tool_gateway=_ToolGateway(), + approval_manager=None, + agent_channel=None, + ) + + assert isinstance(agent._security_boundary, NoOpSecurityBoundary) + + +def test_dare_builder_explicit_security_boundary_overrides_config() -> None: + explicit = NoOpSecurityBoundary() + builder = BaseAgent.dare_agent_builder("security-explicit").with_model(_Model()) + builder = builder.with_security_boundary(explicit) + config = Config.from_dict({"security": {"boundary": "policy"}}) + agent = builder._build_impl( # noqa: SLF001 - builder contract test + config=config, + model=_Model(), + context=Context(config=config), + tool_gateway=_ToolGateway(), + approval_manager=None, + agent_channel=None, + ) + + assert agent._security_boundary is explicit diff --git a/tests/unit/test_config_model.py b/tests/unit/test_config_model.py index fbd6d654..8f152087 100644 --- a/tests/unit/test_config_model.py +++ b/tests/unit/test_config_model.py @@ -89,6 +89,7 @@ def test_config_to_dict_round_trip() -> None: "cli": {"log_path": "/tmp/dare.log"}, "allow_tools": ["tool_a"], "components": {"hook": {"stdout": {"level": "info"}}}, + "security": {"boundary": "noop"}, } ) @@ -100,6 +101,7 @@ def test_config_to_dict_round_trip() -> None: assert payload["cli"]["log_path"] == "/tmp/dare.log" assert payload["allow_tools"] == ["tool_a"] assert payload["components"]["hook"]["stdout"] == {"level": "info"} + assert payload["security"]["boundary"] == "noop" def test_config_is_immutable() -> None: diff --git a/tests/unit/test_dare_agent_security_policy_gate.py b/tests/unit/test_dare_agent_security_policy_gate.py new file mode 100644 index 00000000..e4eb5bb4 --- /dev/null +++ b/tests/unit/test_dare_agent_security_policy_gate.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest + +from dare_framework.agent.dare_agent import DareAgent +from dare_framework.config import Config +from dare_framework.context import Context +from dare_framework.plan.types import ToolLoopRequest +from dare_framework.security.errors import SECURITY_POLICY_DENIED +from dare_framework.security.kernel import ISecurityBoundary +from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput +from dare_framework.tool._internal.control.approval_manager import ( + ApprovalMatcherKind, + ApprovalScope, + JsonApprovalRuleStore, + ToolApprovalManager, +) +from dare_framework.tool.types import CapabilityDescriptor, CapabilityType, ToolResult + + +class _Model: + name = "mock-model" + + async def generate(self, model_input: Any, *, options: Any = None) -> Any: + _ = (model_input, options) + raise RuntimeError("generate should not be called in tool-loop tests") + + +class _RecordingEventLog: + def __init__(self) -> None: + self.events: list[tuple[str, dict[str, Any]]] = [] + + async def append(self, event_type: str, payload: dict[str, Any]) -> str: + self.events.append((event_type, dict(payload))) + return f"evt-{len(self.events)}" + + +class _RecordingGateway: + def __init__(self, capabilities: list[CapabilityDescriptor] | None = None) -> None: + self._capabilities = list(capabilities or []) + self.invoke_calls: list[dict[str, Any]] = [] + + def list_capabilities(self) -> list[CapabilityDescriptor]: + return list(self._capabilities) + + async def invoke(self, capability_id: str, *, envelope: Any, **params: Any) -> ToolResult[dict[str, Any]]: + self.invoke_calls.append({"capability_id": capability_id, "envelope": envelope, "params": dict(params)}) + return ToolResult(success=True, output={"ok": True}) + + +class _FixedBoundary(ISecurityBoundary): + def __init__(self, decision: PolicyDecision) -> None: + self._decision = decision + self.calls: list[str] = [] + + async def verify_trust(self, *, input: dict[str, Any], context: dict[str, Any]) -> TrustedInput: + self.calls.append("verify_trust") + return TrustedInput( + params=dict(input), + risk_level=RiskLevel.READ_ONLY, + metadata={"capability_id": context.get("capability_id"), "requires_approval": False}, + ) + + async def check_policy( + self, + *, + action: str, + resource: str, + context: dict[str, Any], + ) -> PolicyDecision: + _ = (action, resource, context) + self.calls.append("check_policy") + return self._decision + + async def execute_safe( + self, + *, + action: str, + fn: Any, + sandbox: SandboxSpec, + ) -> Any: + _ = (action, sandbox) + return await fn() + + +def _agent( + *, + gateway: _RecordingGateway, + event_log: _RecordingEventLog | None = None, + security_boundary: ISecurityBoundary | None = None, + approval_manager: ToolApprovalManager | None = None, +) -> DareAgent: + agent = DareAgent( + name="security-agent", + model=_Model(), + context=Context(config=Config()), + tool_gateway=gateway, + event_log=event_log, + security_boundary=security_boundary, + approval_manager=approval_manager, + ) + # _run_tool_loop is normally called inside execute(), where session state + # is always initialized. Unit tests call it directly. + agent._session_state = type( # noqa: SLF001 - targeted runtime unit test setup + "_SessionState", + (), + {"run_id": "run-security", "task_id": "task-security", "current_milestone_state": None}, + )() + return agent + + +def _descriptor(*, requires_approval: bool = False) -> CapabilityDescriptor: + return CapabilityDescriptor( + id="run_command", + type=CapabilityType.TOOL, + name="run_command", + description="Run shell command", + input_schema={"type": "object", "properties": {"command": {"type": "string"}}}, + metadata={"requires_approval": requires_approval, "risk_level": "read_only"}, + ) + + +@pytest.mark.asyncio +async def test_security_preflight_allow_invokes_gateway() -> None: + event_log = _RecordingEventLog() + boundary = _FixedBoundary(PolicyDecision.ALLOW) + gateway = _RecordingGateway([_descriptor()]) + agent = _agent(gateway=gateway, event_log=event_log, security_boundary=boundary) + + result = await agent._run_tool_loop( # noqa: SLF001 - direct runtime boundary coverage + ToolLoopRequest(capability_id="run_command", params={"command": "echo ok"}), + tool_name="run_command", + tool_call_id="tc-allow", + descriptor=_descriptor(), + ) + + assert result["success"] is True + assert boundary.calls == ["verify_trust", "check_policy"] + assert len(gateway.invoke_calls) == 1 + event_types = [event_type for event_type, _ in event_log.events] + assert "security.trust_verified" in event_types + assert "security.policy_checked" in event_types + trust_payload = next(payload for event_type, payload in event_log.events if event_type == "security.trust_verified") + policy_payload = next(payload for event_type, payload in event_log.events if event_type == "security.policy_checked") + assert trust_payload["capability_id"] == "run_command" + assert policy_payload["capability_id"] == "run_command" + assert policy_payload["decision"] == PolicyDecision.ALLOW.value + + +@pytest.mark.asyncio +async def test_security_preflight_deny_blocks_gateway() -> None: + event_log = _RecordingEventLog() + boundary = _FixedBoundary(PolicyDecision.DENY) + gateway = _RecordingGateway([_descriptor()]) + agent = _agent(gateway=gateway, event_log=event_log, security_boundary=boundary) + + result = await agent._run_tool_loop( # noqa: SLF001 - direct runtime boundary coverage + ToolLoopRequest(capability_id="run_command", params={"command": "echo no"}), + tool_name="run_command", + tool_call_id="tc-deny", + descriptor=_descriptor(), + ) + + assert result["success"] is False + assert result["status"] == "not_allow" + assert result["output"]["code"] == SECURITY_POLICY_DENIED + assert gateway.invoke_calls == [] + + +@pytest.mark.asyncio +async def test_security_preflight_approve_required_routes_to_approval_memory(tmp_path: Path) -> None: + event_log = _RecordingEventLog() + boundary = _FixedBoundary(PolicyDecision.APPROVE_REQUIRED) + gateway = _RecordingGateway([_descriptor(requires_approval=False)]) + approval_manager = ToolApprovalManager( + workspace_store=JsonApprovalRuleStore(tmp_path / "workspace" / "approvals.json"), + user_store=JsonApprovalRuleStore(tmp_path / "user" / "approvals.json"), + ) + agent = _agent( + gateway=gateway, + event_log=event_log, + security_boundary=boundary, + approval_manager=approval_manager, + ) + + run_task = asyncio.create_task( + agent._run_tool_loop( # noqa: SLF001 - direct runtime boundary coverage + ToolLoopRequest(capability_id="run_command", params={"command": "echo gated"}), + tool_name="run_command", + tool_call_id="tc-approve", + descriptor=_descriptor(requires_approval=False), + ) + ) + request_id: str | None = None + for _ in range(100): + pending = approval_manager.list_pending() + if pending: + request_id = pending[0].request_id + break + await asyncio.sleep(0.01) + assert request_id is not None + + await approval_manager.grant( + request_id, + scope=ApprovalScope.ONCE, + matcher=ApprovalMatcherKind.EXACT_PARAMS, + ) + result = await run_task + + assert result["success"] is True + assert len(gateway.invoke_calls) == 1 + approval_events = [payload for event_type, payload in event_log.events if event_type == "security.policy_approval"] + assert approval_events + assert any(isinstance(payload.get("request_id"), str) for payload in approval_events) + + +@pytest.mark.asyncio +async def test_missing_explicit_boundary_uses_default_preflight_instead_of_bypass() -> None: + event_log = _RecordingEventLog() + gateway = _RecordingGateway([_descriptor()]) + agent = _agent(gateway=gateway, event_log=event_log, security_boundary=None) + + result = await agent._run_tool_loop( # noqa: SLF001 - direct runtime boundary coverage + ToolLoopRequest(capability_id="run_command", params={"command": "echo default"}), + tool_name="run_command", + tool_call_id="tc-default", + descriptor=_descriptor(), + ) + + assert result["success"] is True + event_types = [event_type for event_type, _ in event_log.events] + assert "security.trust_verified" in event_types + assert "security.policy_checked" in event_types diff --git a/tests/unit/test_governed_tool_gateway.py b/tests/unit/test_governed_tool_gateway.py index 3fdf6770..07c9e6ef 100644 --- a/tests/unit/test_governed_tool_gateway.py +++ b/tests/unit/test_governed_tool_gateway.py @@ -6,8 +6,10 @@ from dare_framework.plan.types import Envelope from dare_framework.tool._internal.control.approval_manager import ( + ApprovalDecision, ApprovalEvaluation, ApprovalEvaluationStatus, + PendingApprovalRequest, ) from dare_framework.tool._internal.governed_tool_gateway import ( ApprovalInvokeContext, @@ -58,6 +60,40 @@ async def evaluate( return ApprovalEvaluation(status=ApprovalEvaluationStatus.ALLOW) +class _PendingApprovalManager(_RecordingApprovalManager): + async def evaluate( + self, + *, + capability_id: str, + params: dict[str, Any], + session_id: str | None, + reason: str, + ) -> ApprovalEvaluation: + self.evaluate_calls.append( + { + "capability_id": capability_id, + "params": dict(params), + "session_id": session_id, + "reason": reason, + } + ) + request = PendingApprovalRequest( + request_id="req-1", + capability_id=capability_id, + params=dict(params), + params_hash="hash", + command=None, + session_id=session_id, + reason=reason, + created_at=1.0, + ) + return ApprovalEvaluation(status=ApprovalEvaluationStatus.PENDING, request=request) + + async def wait_for_resolution(self, request_id: str) -> ApprovalDecision: + assert request_id == "req-1" + return ApprovalDecision.ALLOW + + @pytest.mark.asyncio async def test_governed_gateway_approval_uses_effective_params_with_context_collision() -> None: capability = CapabilityDescriptor( @@ -93,3 +129,39 @@ async def test_governed_gateway_approval_uses_effective_params_with_context_coll delegate_params = delegate.invoke_calls[0]["params"] assert delegate_params["command"] == "echo hello" assert delegate_params["context"] == "tool-arg-context" + + +@pytest.mark.asyncio +async def test_governed_gateway_force_approval_uses_custom_reason_and_observer() -> None: + capability = CapabilityDescriptor( + id="run_command", + type=CapabilityType.TOOL, + name="run_command", + description="run command", + input_schema={"type": "object", "properties": {}}, + metadata={"requires_approval": False}, + ) + delegate = _RecordingDelegateGateway(capability) + approval_manager = _PendingApprovalManager() + gateway = GovernedToolGateway(delegate, approval_manager=approval_manager) + observed: list[dict[str, Any]] = [] + + async def observer(payload: dict[str, Any]) -> None: + observed.append(dict(payload)) + + result = await gateway.invoke( + capability.id, + approval=ApprovalInvokeContext( + force_approval=True, + approval_reason="policy requires approval", + approval_observer=observer, + ), + envelope=Envelope(), + command="echo hello", + ) + + assert result.success is True + assert approval_manager.evaluate_calls + assert approval_manager.evaluate_calls[0]["reason"] == "policy requires approval" + assert any(item.get("status") == "pending" and item.get("request_id") == "req-1" for item in observed) + assert any(item.get("status") == "allow" and item.get("request_id") == "req-1" for item in observed) diff --git a/tests/unit/test_registry_plan_validator.py b/tests/unit/test_registry_plan_validator.py index 85719cf1..62b1a262 100644 --- a/tests/unit/test_registry_plan_validator.py +++ b/tests/unit/test_registry_plan_validator.py @@ -10,6 +10,7 @@ from dare_framework.tool.tool_manager import ToolManager from dare_framework.tool.kernel import ITool from dare_framework.tool.types import CapabilityKind, ToolResult, ToolType +from dare_framework.tool.types import CapabilityDescriptor, CapabilityType from dare_framework.infra.component import ComponentType @@ -136,3 +137,30 @@ async def test_registry_validator_handles_plan_tool_prefix() -> None: assert validated.success is True assert validated.steps[0].risk_level == RiskLevel.READ_ONLY assert validated.steps[0].metadata["capability_kind"] == CapabilityKind.PLAN_TOOL.value + + +@pytest.mark.asyncio +async def test_registry_validator_fails_when_trusted_risk_metadata_missing() -> None: + class _Gateway: + def list_capabilities(self) -> list[CapabilityDescriptor]: + return [ + CapabilityDescriptor( + id="tool:missing-risk", + type=CapabilityType.TOOL, + name="missing_risk", + description="missing risk metadata", + input_schema={"type": "object", "properties": {}}, + metadata={"capability_kind": CapabilityKind.TOOL.value}, + ) + ] + + validator = RegistryPlanValidator(tool_gateway=_Gateway()) + plan = ProposedPlan( + plan_description="plan", + steps=[ProposedStep(step_id="s1", capability_id="tool:missing-risk", params={})], + ) + + validated = await validator.validate_plan(plan, {}) + + assert validated.success is False + assert "missing trusted risk metadata" in validated.errors[0] diff --git a/tests/unit/test_security_boundary.py b/tests/unit/test_security_boundary.py new file mode 100644 index 00000000..029c7a19 --- /dev/null +++ b/tests/unit/test_security_boundary.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from dare_framework.security.errors import SECURITY_TRUST_DERIVATION_FAILED, SecurityBoundaryError +from dare_framework.security.impl import NoOpSecurityBoundary, PolicySecurityBoundary +from dare_framework.security.types import PolicyDecision, RiskLevel + + +@pytest.mark.asyncio +async def test_noop_security_boundary_allows_policy() -> None: + boundary = NoOpSecurityBoundary() + trusted = await boundary.verify_trust( + input={"path": "README.md"}, + context={"capability_id": "read_file"}, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="read_file", + context={"trusted_input": trusted}, + ) + + assert trusted.params == {"path": "README.md"} + assert decision is PolicyDecision.ALLOW + + +@pytest.mark.asyncio +async def test_policy_security_boundary_requires_approval_for_high_risk() -> None: + boundary = PolicySecurityBoundary() + trusted = await boundary.verify_trust( + input={"command": "rm -rf /tmp/foo"}, + context={ + "capability_id": "run_command", + "descriptor": {"metadata": {"risk_level": RiskLevel.NON_IDEMPOTENT_EFFECT.value}}, + "risk_level": RiskLevel.NON_IDEMPOTENT_EFFECT.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="run_command", + context={"trusted_input": trusted, "capability_id": "run_command"}, + ) + + 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"}) + trusted = await boundary.verify_trust( + input={"command": "ls"}, + context={ + "capability_id": "run_command", + "risk_level": RiskLevel.READ_ONLY.value, + }, + ) + decision = await boundary.check_policy( + action="invoke_tool", + resource="run_command", + context={"trusted_input": trusted, "capability_id": "run_command"}, + ) + + assert decision is PolicyDecision.DENY + + +@pytest.mark.asyncio +async def test_policy_security_boundary_strict_trust_requires_metadata() -> None: + boundary = PolicySecurityBoundary(require_trusted_metadata=True) + + with pytest.raises(SecurityBoundaryError) as exc_info: + await boundary.verify_trust( + input={"foo": "bar"}, + context={"capability_id": "tool.echo"}, + ) + + assert exc_info.value.code == SECURITY_TRUST_DERIVATION_FAILED From db499e3db4f5adde158a91d2bd0b87047d3ed926 Mon Sep 17 00:00:00 2001 From: bouillipx Date: Fri, 27 Feb 2026 23:28:39 +0800 Subject: [PATCH 2/3] docs(openspec): add P0 change specs for conformance, default eventlog, and step-driven execution Add OpenSpec change artifacts to drive the remaining P0 workstreams in a structured, reviewable workflow. Scope: - Add full change artifacts (.openspec.yaml, proposal, design, specs, tasks) for p0-conformance-gate. - Add full change artifacts for p0-default-eventlog. - Add missing OpenSpec metadata/design/spec docs for p0-enforce-security-boundary so implementation and tasks are fully traceable. - Add full change artifacts for p0-step-driven-execution. Rationale: - Capture explicit requirements and contracts before implementation to reduce ambiguity and regression risk. - Keep P0 tracks independently auditable with per-change design, requirement deltas, and executable task checklists. - Enable deterministic follow-up implementation via openspec apply/verify/archive workflow. Notes: - This commit is documentation/spec only; no runtime source code behavior changes are introduced. --- .../p0-conformance-gate/.openspec.yaml | 2 + .../changes/p0-conformance-gate/design.md | 65 ++++++++++++++++++ .../changes/p0-conformance-gate/proposal.md | 31 +++++++++ .../specs/core-runtime/spec.md | 15 ++++ .../specs/p0-conformance-gate/spec.md | 23 +++++++ .../specs/validation/spec.md | 15 ++++ openspec/changes/p0-conformance-gate/tasks.md | 24 +++++++ .../p0-default-eventlog/.openspec.yaml | 2 + .../changes/p0-default-eventlog/design.md | 63 +++++++++++++++++ .../changes/p0-default-eventlog/proposal.md | 36 ++++++++++ .../specs/core-runtime/spec.md | 16 +++++ .../specs/default-event-log/spec.md | 39 +++++++++++ .../specs/observability/spec.md | 17 +++++ .../specs/session-loop/spec.md | 17 +++++ openspec/changes/p0-default-eventlog/tasks.md | 25 +++++++ .../.openspec.yaml | 2 + .../p0-enforce-security-boundary/design.md | 68 +++++++++++++++++++ .../p0-enforce-security-boundary/proposal.md | 38 +++++++++++ .../specs/core-runtime/spec.md | 31 +++++++++ .../specs/define-trust-boundary/spec.md | 31 +++++++++ .../specs/security-policy-gate/spec.md | 42 ++++++++++++ .../specs/validation/spec.md | 16 +++++ .../p0-step-driven-execution/.openspec.yaml | 2 + .../p0-step-driven-execution/design.md | 64 +++++++++++++++++ .../p0-step-driven-execution/proposal.md | 35 ++++++++++ .../specs/core-runtime/spec.md | 43 ++++++++++++ .../specs/plan-module/spec.md | 36 ++++++++++ .../specs/session-loop/spec.md | 18 +++++ .../specs/step-driven-execution/spec.md | 35 ++++++++++ .../changes/p0-step-driven-execution/tasks.md | 25 +++++++ 30 files changed, 876 insertions(+) create mode 100644 openspec/changes/p0-conformance-gate/.openspec.yaml create mode 100644 openspec/changes/p0-conformance-gate/design.md create mode 100644 openspec/changes/p0-conformance-gate/proposal.md create mode 100644 openspec/changes/p0-conformance-gate/specs/core-runtime/spec.md create mode 100644 openspec/changes/p0-conformance-gate/specs/p0-conformance-gate/spec.md create mode 100644 openspec/changes/p0-conformance-gate/specs/validation/spec.md create mode 100644 openspec/changes/p0-conformance-gate/tasks.md create mode 100644 openspec/changes/p0-default-eventlog/.openspec.yaml create mode 100644 openspec/changes/p0-default-eventlog/design.md create mode 100644 openspec/changes/p0-default-eventlog/proposal.md create mode 100644 openspec/changes/p0-default-eventlog/specs/core-runtime/spec.md create mode 100644 openspec/changes/p0-default-eventlog/specs/default-event-log/spec.md create mode 100644 openspec/changes/p0-default-eventlog/specs/observability/spec.md create mode 100644 openspec/changes/p0-default-eventlog/specs/session-loop/spec.md create mode 100644 openspec/changes/p0-default-eventlog/tasks.md create mode 100644 openspec/changes/p0-enforce-security-boundary/.openspec.yaml create mode 100644 openspec/changes/p0-enforce-security-boundary/design.md create mode 100644 openspec/changes/p0-enforce-security-boundary/proposal.md create mode 100644 openspec/changes/p0-enforce-security-boundary/specs/core-runtime/spec.md create mode 100644 openspec/changes/p0-enforce-security-boundary/specs/define-trust-boundary/spec.md create mode 100644 openspec/changes/p0-enforce-security-boundary/specs/security-policy-gate/spec.md create mode 100644 openspec/changes/p0-enforce-security-boundary/specs/validation/spec.md create mode 100644 openspec/changes/p0-step-driven-execution/.openspec.yaml create mode 100644 openspec/changes/p0-step-driven-execution/design.md create mode 100644 openspec/changes/p0-step-driven-execution/proposal.md create mode 100644 openspec/changes/p0-step-driven-execution/specs/core-runtime/spec.md create mode 100644 openspec/changes/p0-step-driven-execution/specs/plan-module/spec.md create mode 100644 openspec/changes/p0-step-driven-execution/specs/session-loop/spec.md create mode 100644 openspec/changes/p0-step-driven-execution/specs/step-driven-execution/spec.md create mode 100644 openspec/changes/p0-step-driven-execution/tasks.md diff --git a/openspec/changes/p0-conformance-gate/.openspec.yaml b/openspec/changes/p0-conformance-gate/.openspec.yaml new file mode 100644 index 00000000..d1c6cc6f --- /dev/null +++ b/openspec/changes/p0-conformance-gate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-27 diff --git a/openspec/changes/p0-conformance-gate/design.md b/openspec/changes/p0-conformance-gate/design.md new file mode 100644 index 00000000..42ebdeec --- /dev/null +++ b/openspec/changes/p0-conformance-gate/design.md @@ -0,0 +1,65 @@ +## Context + +当前测试覆盖面较广,但缺少围绕 P0 不变量的统一门禁视图。多个能力分散在 unit/integration 中,发布时难以快速回答“P0 是否仍成立”。为避免后续变更破坏核心承诺,需要建立集中、可追踪、可阻断的 conformance gate。 + +## Goals / Non-Goals + +**Goals:** +- 建立聚焦 P0 的测试与 CI 门禁机制。 +- 让关键不变量失败时可以一键阻断合并。 +- 输出可读的失败分类,缩短回归定位时间。 +- 将门禁与 OpenSpec 任务闭环对齐,支持持续验收。 + +**Non-Goals:** +- 本次不构建通用性能基准平台。 +- 本次不覆盖所有业务场景,只聚焦 P0 主链路。 +- 本次不替代现有全量测试,仅新增一层发布级硬门槛。 + +## Decisions + +### Decision 1: 定义 P0 门禁维度 +- Security Gate: 工具调用前 trust/policy 决策必须生效。 +- Execution Gate: `step_driven` 执行路径必须可用且可验证。 +- Audit Gate: 默认 event log 的 hash-chain/replay 必须通过。 +- 理由:与 P0 范围一一对应,避免门禁目标漂移。 + +### Decision 2: 门禁优先使用集成用例 + 关键单测 +- 集成用例覆盖跨域链路,关键单测覆盖稳定契约。 +- CI `p0-gate` 仅跑必要集合,避免过重导致反馈过慢。 +- 理由:平衡信号质量与执行效率。 + +### Decision 3: 门禁失败标准化输出 +- 统一失败标签(`SECURITY_REGRESSION`、`STEP_EXEC_REGRESSION`、`AUDIT_CHAIN_REGRESSION`)。 +- 在 CI summary 输出失败类型、触发用例、建议排查模块。 +- 理由:减少“测试红了但不知先看哪里”的协作摩擦。 + +### Decision 4: 与发布流程绑定 +- `p0-gate` 设为 required check,main 分支合并必须通过。 +- 版本发布前重复执行并归档结果。 +- 理由:把质量要求前置到提交阶段。 + +## Risks / Trade-offs + +- [Risk] 新门禁增加 CI 时长,影响迭代速度。 + → Mitigation: 只纳入高价值用例,并按变更范围优化触发策略。 +- [Risk] 门禁过严导致早期开发体验下降。 + → Mitigation: 允许 feature branch 软失败,主分支强制。 +- [Risk] 用例维护成本上升。 + → Mitigation: 每个 P0 change 同步维护对应门禁用例,避免债务累积。 + +## Migration Plan + +1. 先定义 `p0-gate` 用例清单与失败标签规范。 +2. 补齐缺失的集成用例与关键单测。 +3. 在 CI 增加 `p0-gate` job(先观察模式,后切 required)。 +4. 观察两周稳定性后升级为主分支强制门禁。 +5. 发布流程增加门禁结果归档。 + +Rollback: +- 将 `p0-gate` 从 required 降级为非阻断检查,同时保留报告输出。 + +## Open Questions + +- 是否需要把 P0 指标写入 machine-readable 报告(如 JSON)供 dashboard 消费? +- `p0-gate` 是否按路径变更做选择性触发,还是始终全量执行? +- 对于 flaky 用例,是否设立临时 quarantine 机制与期限? diff --git a/openspec/changes/p0-conformance-gate/proposal.md b/openspec/changes/p0-conformance-gate/proposal.md new file mode 100644 index 00000000..7545409c --- /dev/null +++ b/openspec/changes/p0-conformance-gate/proposal.md @@ -0,0 +1,31 @@ +## Why + +P0 三项核心能力(安全门控、step 驱动执行、默认事件链)即使实现完成,如果没有统一的门禁规则,后续迭代仍可能回归。需要把“架构不变量”固化为 CI 可执行约束,避免只靠人工评审维护质量。 + +## What Changes + +- 新增 `p0-gate` 测试分组,覆盖安全决策、执行闭环、审计链完整性三大不变量。 +- 引入跨模块集成用例,验证 `plan -> execute -> verify` 与 `policy -> approval -> invoke` 的链路一致性。 +- 在 CI 中将 `p0-gate` 设为必过检查,未通过禁止合并。 +- 定义 P0 指标基线(通过率、关键路径失败率、事件链完整率),作为发布阈值。 +- 输出失败诊断模板,帮助快速定位是策略、执行还是审计链回归。 + +## Capabilities + +### New Capabilities +- `p0-conformance-gate`: 将 P0 架构不变量转为自动化测试门禁与发布准入规则。 + +### Modified Capabilities +- `validation`: 扩展到运行时跨域一致性验证,而不仅是局部单元行为。 +- `core-runtime`: 增加面向发布的合规回归门槛定义与校验入口。 + +## Impact + +- Affected code: + - `tests/unit/*`(新增/重组 P0 关键断言) + - `tests/integration/*`(新增端到端闭环用例) + - CI workflow 配置(新增 `p0-gate` job) + - `docs/` 中开发与发布流程说明 +- Process impact: + - 合并流程新增硬门禁,短期可能降低合并速度但提升稳定性。 + - 需要团队维护 P0 用例与指标基线。 diff --git a/openspec/changes/p0-conformance-gate/specs/core-runtime/spec.md b/openspec/changes/p0-conformance-gate/specs/core-runtime/spec.md new file mode 100644 index 00000000..6ab8fef8 --- /dev/null +++ b/openspec/changes/p0-conformance-gate/specs/core-runtime/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: Auditable Event Logging +The Kernel SHALL append structured events to `IEventLog` for state transitions, plan attempts, tool invocations, policy decisions, and verification outcomes, including correlation identifiers (task/session/milestone/run). + +The emitted event schema MUST remain stable enough to support automated P0 conformance checks for security gating, step execution, and audit integrity. + +#### Scenario: Tool invocation is logged +- **WHEN** `IToolGateway.invoke()` is called +- **THEN** an event is appended to `IEventLog` including capability id, derived risk, decision, and outcome + +#### Scenario: Event schema supports conformance verification +- **WHEN** CI conformance tests query runtime events +- **THEN** required correlation and decision fields are present to validate P0 invariants + diff --git a/openspec/changes/p0-conformance-gate/specs/p0-conformance-gate/spec.md b/openspec/changes/p0-conformance-gate/specs/p0-conformance-gate/spec.md new file mode 100644 index 00000000..a7fa0d28 --- /dev/null +++ b/openspec/changes/p0-conformance-gate/specs/p0-conformance-gate/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: P0 conformance gate SHALL be a required quality checkpoint +The project SHALL define a `p0-gate` conformance checkpoint that validates P0 runtime invariants before merge to protected branches. + +- The gate MUST cover security policy gating, step-driven execution correctness, and event-log chain integrity. +- The gate MUST be configured as a required CI check for protected branch merges. + +#### Scenario: Merge is blocked when p0-gate fails +- **GIVEN** a pull request targeting a protected branch +- **WHEN** `p0-gate` job fails +- **THEN** merge is blocked until the gate passes + +### Requirement: P0 gate failures MUST be classified deterministically +The conformance gate SHALL classify failures into deterministic categories to accelerate triage. + +- Failure categories MUST include: `SECURITY_REGRESSION`, `STEP_EXEC_REGRESSION`, and `AUDIT_CHAIN_REGRESSION`. +- CI output MUST include failing test identifiers and category labels. + +#### Scenario: Security regression is labeled in CI output +- **WHEN** a security gating invariant test fails +- **THEN** CI summary marks the failure as `SECURITY_REGRESSION` + diff --git a/openspec/changes/p0-conformance-gate/specs/validation/spec.md b/openspec/changes/p0-conformance-gate/specs/validation/spec.md new file mode 100644 index 00000000..bd299eb6 --- /dev/null +++ b/openspec/changes/p0-conformance-gate/specs/validation/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: Composite validator +The system SHALL provide a CompositeValidator that chains multiple validators in ascending order and aggregates validation errors. + +For P0 conformance, composite validation MUST support invariant-focused validators whose failures are propagated with stable failure categories. + +#### Scenario: Aggregated validation errors +- **WHEN** any validator in the chain fails +- **THEN** the CompositeValidator MUST return failure and include all collected errors + +#### Scenario: Conformance validator failure is categorized +- **WHEN** a P0 invariant validator fails +- **THEN** returned errors include a stable category label suitable for CI conformance reporting + diff --git a/openspec/changes/p0-conformance-gate/tasks.md b/openspec/changes/p0-conformance-gate/tasks.md new file mode 100644 index 00000000..c587480d --- /dev/null +++ b/openspec/changes/p0-conformance-gate/tasks.md @@ -0,0 +1,24 @@ +## 1. Define Gate Scope + +- [ ] 1.1 定义 `p0-gate` 覆盖的三类不变量与验收阈值。 +- [ ] 1.2 明确每类不变量对应的测试文件与责任模块。 +- [ ] 1.3 定义标准失败标签与 CI summary 输出格式。 + +## 2. Build P0 Test Suite + +- [ ] 2.1 新增集成测试覆盖安全门控主链路(allow/deny/approve_required)。 +- [ ] 2.2 新增集成测试覆盖 `step_driven` 执行闭环。 +- [ ] 2.3 新增集成测试覆盖默认 event log hash-chain/replay。 +- [ ] 2.4 增加关键单测确保契约字段与错误码稳定。 + +## 3. CI Integration + +- [ ] 3.1 在 CI workflow 增加 `p0-gate` job 与命令入口。 +- [ ] 3.2 将 `p0-gate` 配置为主分支 required check。 +- [ ] 3.3 输出标准化门禁报告(通过率、失败类型、建议排查点)。 + +## 4. Operationalization + +- [ ] 4.1 更新开发文档,说明本地运行与故障排查流程。 +- [ ] 4.2 在发布流程增加 `p0-gate` 结果归档步骤。 +- [ ] 4.3 制定 flaky 用例处理规则与时限。 diff --git a/openspec/changes/p0-default-eventlog/.openspec.yaml b/openspec/changes/p0-default-eventlog/.openspec.yaml new file mode 100644 index 00000000..d1c6cc6f --- /dev/null +++ b/openspec/changes/p0-default-eventlog/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-27 diff --git a/openspec/changes/p0-default-eventlog/design.md b/openspec/changes/p0-default-eventlog/design.md new file mode 100644 index 00000000..410850b4 --- /dev/null +++ b/openspec/changes/p0-default-eventlog/design.md @@ -0,0 +1,63 @@ +## Context + +当前 `dare_framework/event/kernel.py` 定义了完整接口,但没有默认实现,导致运行时常依赖外部注入或 mock。仓库中存在历史 event log 路径与跳过测试,增加了维护成本,也削弱了“默认可审计”的产品承诺。 + +## Goals / Non-Goals + +**Goals:** +- 提供 canonical `SQLiteEventLog` 并满足 `IEventLog` 全部方法。 +- 建立稳定 hash-chain 计算口径与篡改检测能力。 +- 支持最小 replay 快照输出,满足 P0 可复验需求。 +- 在 builder 中提供默认装配,降低使用门槛。 + +**Non-Goals:** +- 本次不做分布式日志复制与多节点一致性。 +- 本次不实现复杂索引优化或冷热分层存储。 +- 本次不实现全文检索型 query 能力。 + +## Decisions + +### Decision 1: sqlite 作为默认本地持久化后端 +- 采用单文件 sqlite 存储,兼顾可移植性与开发门槛。 +- 事件表包含 `event_id`、`event_type`、`payload_json`、`timestamp`、`prev_hash`、`hash`。 +- 理由:足够支撑 P0 的审计链与 replay,同时易于测试和部署。 + +### Decision 2: hash-chain 计算口径固定 +- `hash = H(event_type + payload_json + timestamp + prev_hash)`。 +- `payload_json` 使用稳定排序序列化,避免同语义不同字节导致链不一致。 +- 理由:保证跨进程复验可重复。 + +### Decision 3: builder 默认按配置自动装配 +- 若用户未显式 `with_event_log()`,builder 根据 config 决定是否创建默认 sqlite event log。 +- 默认路径放在 workspace 下(例如 `.dare/events.db`),并支持覆盖。 +- 理由:把“可审计”从可选增强变为默认基础能力。 + +### Decision 4: legacy 路径迁移策略 +- 在 canonical tests 中替换 legacy import;保留短期兼容层但不再新增依赖。 +- 理由:减少双实现漂移与测试噪声。 + +## Risks / Trade-offs + +- [Risk] sqlite 文件增长可能影响长期运行性能。 + → Mitigation: 先提供基础归档/轮转参数,后续再做分层存储。 +- [Risk] hash 口径一旦变更会影响旧链复验。 + → Mitigation: 在 schema 中记录 hash_version,并通过迁移脚本兼容旧数据。 +- [Risk] 默认启用 event log 可能引入 I/O 开销。 + → Mitigation: 提供开关,并保证 append 路径轻量化。 + +## Migration Plan + +1. 新增 `SQLiteEventLog` 与默认工厂,完成 `IEventLog` 四方法实现。 +2. 在 builder 接入默认 event log 装配与配置解析。 +3. 打通 observability trace bridge 与默认实现兼容性测试。 +4. 将 `tests/unit/test_event_log.py` 从 skip 状态迁移到 canonical 验证。 +5. 增加 replay/hash-chain 集成测试并纳入 CI。 + +Rollback: +- 通过配置关闭默认 event log 装配,或回退到显式注入模式。 + +## Open Questions + +- 是否需要在 P0 就支持事件数据加密(at-rest)? +- `replay` 的最小快照字段是否要覆盖 budget/context 摘要? +- 默认事件保留周期和清理策略由框架还是宿主应用负责? diff --git a/openspec/changes/p0-default-eventlog/proposal.md b/openspec/changes/p0-default-eventlog/proposal.md new file mode 100644 index 00000000..33a040c3 --- /dev/null +++ b/openspec/changes/p0-default-eventlog/proposal.md @@ -0,0 +1,36 @@ +## Why + +项目已将 EventLog 定义为状态外化与审计复验的核心,但 canonical `event` domain 目前仅有接口,缺少默认实现。没有开箱可用的事件链,很多“可审计/可回放”能力只能停留在设计层,无法成为默认运行保障。 + +## What Changes + +- 在 `dare_framework/event` 域提供默认 `IEventLog` 实现(sqlite 持久化 + hash-chain)。 +- 提供 `append/query/replay/verify_chain` 的完整实现,并冻结最小事件表结构。 +- 在 `DareAgentBuilder` 增加默认 event log 装配能力(可配置路径/开关)。 +- 统一事件 payload 序列化策略,保证 query/replay 的稳定行为。 +- 将现有遗留/跳过的 event 测试迁移到 canonical 实现并加入回归门禁。 + +## Capabilities + +### New Capabilities +- `default-event-log`: 默认可用的审计日志实现,支持 hash-chain 校验与最小 replay。 + +### Modified Capabilities +- `core-runtime`: 从“可选 event log 注入”升级为“可默认装配事件链能力”。 +- `session-loop`: 会话与里程碑关键事件写入路径收敛到 canonical event domain。 +- `observability`: 与 trace bridge 对齐,确保事件链与观测链可共同追踪。 + +## Impact + +- Affected code: + - `dare_framework/event/_internal/sqlite_event_log.py` (new) + - `dare_framework/event/defaults.py` (new) + - `dare_framework/event/__init__.py` + - `dare_framework/agent/builder.py` + - `dare_framework/config/types.py` + - `tests/unit/test_event_log.py` + - `tests/unit/test_five_layer_agent.py` + - `tests/integration/test_example_agent_flow.py` +- Data/ops impact: + - 默认产生本地 sqlite 事件文件,需明确路径与清理策略。 + - 事件结构变更后需要保持向后兼容读取(若存在旧数据)。 diff --git a/openspec/changes/p0-default-eventlog/specs/core-runtime/spec.md b/openspec/changes/p0-default-eventlog/specs/core-runtime/spec.md new file mode 100644 index 00000000..0bd00071 --- /dev/null +++ b/openspec/changes/p0-default-eventlog/specs/core-runtime/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Auditable Event Logging +The Kernel SHALL append structured events to `IEventLog` for state transitions, plan attempts, tool invocations, policy decisions, and verification outcomes, including correlation identifiers (task/session/milestone/run). + +Runtime builders SHOULD provide a default `IEventLog` implementation so auditable event logging is available by default rather than only via explicit injection. + +#### Scenario: Tool invocation is logged +- **WHEN** `IToolGateway.invoke()` is called +- **THEN** an event is appended to `IEventLog` including capability id, derived risk, decision, and outcome + +#### Scenario: Default runtime emits events without explicit event log injection +- **GIVEN** an agent is built without calling `with_event_log(...)` +- **WHEN** a session runs with default event logging enabled +- **THEN** core runtime events are persisted through the default event log implementation + diff --git a/openspec/changes/p0-default-eventlog/specs/default-event-log/spec.md b/openspec/changes/p0-default-eventlog/specs/default-event-log/spec.md new file mode 100644 index 00000000..2d753b8e --- /dev/null +++ b/openspec/changes/p0-default-eventlog/specs/default-event-log/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Default EventLog implementation SHALL be available +The framework SHALL provide a default `IEventLog` implementation in the canonical `event` domain. + +- The default implementation MUST support `append`, `query`, `replay`, and `verify_chain`. +- The default implementation MUST be usable without external services in local runtime environments. + +#### Scenario: Builder can run with default event log +- **GIVEN** no explicit event log is injected by the caller +- **WHEN** runtime is built with default event logging enabled +- **THEN** a default `IEventLog` implementation is attached and receives runtime events + +### Requirement: Event integrity MUST use hash-chain verification +The default event log SHALL maintain append-only integrity with a deterministic hash-chain. + +- Each record MUST include `prev_hash` and `hash`. +- `verify_chain` MUST return `false` when any persisted event content is tampered with. + +#### Scenario: Hash-chain verification succeeds for untampered log +- **WHEN** events are appended through normal runtime flow +- **THEN** `verify_chain` returns `true` + +#### Scenario: Hash-chain verification detects tampering +- **GIVEN** an event record is modified after persistence +- **WHEN** `verify_chain` is executed +- **THEN** it returns `false` + +### Requirement: Replay MUST return deterministic snapshot data +The default event log SHALL provide deterministic replay output from a given event boundary. + +- `replay(from_event_id)` MUST return events in append order. +- Replay output MUST include enough state to reconstruct minimal runtime context for audit purposes. + +#### Scenario: Replay returns ordered event window +- **GIVEN** a persisted event sequence with a known `from_event_id` +- **WHEN** replay is requested from that id +- **THEN** returned events are ordered by append sequence and include correlation metadata + diff --git a/openspec/changes/p0-default-eventlog/specs/observability/spec.md b/openspec/changes/p0-default-eventlog/specs/observability/spec.md new file mode 100644 index 00000000..1bb518ab --- /dev/null +++ b/openspec/changes/p0-default-eventlog/specs/observability/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: TelemetryProvider extensibility via hooks and EventLog +The system SHALL expose observability extension points via `IExtensionPoint` hook emissions and `IEventLog` entries so TelemetryProvider components can emit traces, metrics, and logs. + +When default event logging is enabled, EventLog-based telemetry correlation MUST continue to work without additional host wiring. + +#### Scenario: Provider subscribes to runtime emissions +- **GIVEN** a TelemetryProvider is registered +- **WHEN** the runtime emits hook payloads or appends EventLog entries +- **THEN** the provider can generate corresponding telemetry with trace/span correlation + +#### Scenario: Default event log entries carry telemetry correlation context +- **GIVEN** runtime uses default event log +- **WHEN** events are appended during a traced execution +- **THEN** the emitted event payload retains trace/span correlation fields consumable by telemetry bridges + diff --git a/openspec/changes/p0-default-eventlog/specs/session-loop/spec.md b/openspec/changes/p0-default-eventlog/specs/session-loop/spec.md new file mode 100644 index 00000000..1f426566 --- /dev/null +++ b/openspec/changes/p0-default-eventlog/specs/session-loop/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: Session summary emission +The runtime SHALL generate a deterministic `SessionSummary` at session end and append it to the EventLog. The `RunResult` SHALL expose `session_id` and `session_summary`. + +When the default event log is enabled, session summary events MUST be persisted without requiring explicit event log injection by the caller. + +#### Scenario: RunResult exposes session summary +- **WHEN** the Session Loop completes +- **THEN** the returned `RunResult` includes `session_id` and `session_summary` +- **AND** `session.summary` is appended to EventLog + +#### Scenario: Session summary is persisted by default event log +- **GIVEN** runtime uses default event log wiring +- **WHEN** Session Loop completes +- **THEN** a `session.summary` event is persisted and queryable from the default store + diff --git a/openspec/changes/p0-default-eventlog/tasks.md b/openspec/changes/p0-default-eventlog/tasks.md new file mode 100644 index 00000000..334d2986 --- /dev/null +++ b/openspec/changes/p0-default-eventlog/tasks.md @@ -0,0 +1,25 @@ +## 1. Canonical EventLog Implementation + +- [ ] 1.1 新增 `SQLiteEventLog` 并实现 `append/query/replay/verify_chain`。 +- [ ] 1.2 定义并落地事件表结构与 hash-chain 字段。 +- [ ] 1.3 固定 payload 序列化口径(稳定排序 JSON)。 +- [ ] 1.4 增加 hash 版本字段以支持后续兼容迁移。 + +## 2. Builder and Config Wiring + +- [ ] 2.1 在 builder 中增加默认 event log 自动装配逻辑。 +- [ ] 2.2 在 config 中增加 event log 开关与路径配置项。 +- [ ] 2.3 保持显式 `with_event_log` 注入优先级高于默认装配。 + +## 3. Observability and Runtime Compatibility + +- [ ] 3.1 验证 trace-aware event bridge 与默认实现协同工作。 +- [ ] 3.2 统一关键会话事件写入字段,确保 query/replay 可消费。 +- [ ] 3.3 补充运行时错误处理,确保 event 写入失败不导致静默丢失。 + +## 4. Tests and Migration + +- [ ] 4.1 迁移并启用 canonical `test_event_log`,移除 skip 依赖。 +- [ ] 4.2 新增单测验证 hash-chain 篡改检测。 +- [ ] 4.3 新增集成测试验证 replay 最小快照行为。 +- [ ] 4.4 增加兼容测试覆盖 builder 默认装配与显式注入两路径。 diff --git a/openspec/changes/p0-enforce-security-boundary/.openspec.yaml b/openspec/changes/p0-enforce-security-boundary/.openspec.yaml new file mode 100644 index 00000000..d1c6cc6f --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-27 diff --git a/openspec/changes/p0-enforce-security-boundary/design.md b/openspec/changes/p0-enforce-security-boundary/design.md new file mode 100644 index 00000000..fdf2d20d --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/design.md @@ -0,0 +1,68 @@ +## Context + +`ISecurityBoundary` 已在 domain 层定义,但当前 `DareAgent` 主循环主要通过 hook 与审批记忆完成局部门控,缺少统一的 trust/policy 入口。现状下高风险调用在部分路径依赖约定而非强制机制,不利于合规场景中的一致执法与审计追踪。 + +## Goals / Non-Goals + +**Goals:** +- 在工具调用前统一执行 trust 推导与 policy 判定。 +- 让 `ALLOW / APPROVE_REQUIRED / DENY` 成为运行时一等决策结果。 +- 将审批记忆与 policy gate 对齐为单一控制平面语义。 +- 为每次门控决策生成稳定审计事件,支持 query/replay。 + +**Non-Goals:** +- 本次不引入复杂 RBAC/ABAC DSL 引擎。 +- 本次不改造所有工具实现内部安全策略,仅收敛调用边界。 +- 本次不处理分布式多节点策略一致性。 + +## Decisions + +### Decision 1: 安全边界在 Agent Tool Loop 强制执行 +- 在 `DareAgent` 工具调用路径中加入显式 preflight:`verify_trust -> check_policy`。 +- preflight 失败或拒绝时,不进入 `ToolGateway.invoke(...)`。 +- 理由:把安全检查放在副作用边界之前,避免遗漏与旁路。 +- Alternatives: + - 在 `ToolGateway` 内部做隐式校验:可行,但会弱化 agent 侧可观测上下文。 + - 仅依赖 hooks 拦截:灵活但不具备强制约束。 + +### Decision 2: policy 决策与审批记忆采用单向衔接 +- `APPROVE_REQUIRED` 统一转入现有 approval memory 流程。 +- `DENY` 直接失败并记录结构化错误码。 +- `ALLOW` 才允许继续调用工具。 +- 理由:避免“双重审批系统”导致语义冲突。 + +### Decision 3: Builder 提供默认 boundary 注入 +- 新增 builder 注入点(例如 `with_security_boundary()`),并支持 config 驱动默认实现。 +- 开发环境可选 no-op,生产默认 policy boundary。 +- 理由:保持向后兼容的同时,推动默认安全基线。 + +### Decision 4: 审计事件模型统一 +- 新增/统一事件类型(示例:`security.trust_verified`、`security.policy_checked`、`security.policy_denied`)。 +- 事件 payload 要包含 capability、decision、reason、request_id(若进入审批)。 +- 理由:支持运行后追责与复验。 + +## Risks / Trade-offs + +- [Risk] 旧流程依赖“隐式放行”,接入后可能出现行为收紧导致任务失败。 + → Mitigation: 增加过渡配置与清晰错误提示,先灰度启用严格策略。 +- [Risk] 双重门控(policy + approval)可能增加时延。 + → Mitigation: policy 快速判定并缓存只读策略;仅高风险路径进入审批等待。 +- [Risk] 事件字段不统一导致后续审计困难。 + → Mitigation: 在实现前先冻结事件字段契约并加契约测试。 + +## Migration Plan + +1. 先引入默认 boundary 实现与 builder 注入,不改变现有工具语义。 +2. 在 `DareAgent` 工具 preflight 接入 boundary,并加 feature flag 控制。 +3. 打通 `APPROVE_REQUIRED` 到 approval memory,统一返回与事件格式。 +4. 开启集成回归(allow/deny/approve_required)并在 CI 中设为必过。 +5. 切换默认到 strict policy 模式,保留短期回滚开关。 + +Rollback: +- 将配置切回 no-op boundary 或禁用 strict preflight 分支,恢复原有行为。 + +## Open Questions + +- policy 判定是否需要按 tool namespace 区分不同默认策略? +- `verify_trust` 产物是否需要写入 context 供后续 validator 使用? +- 对于 `APPROVE_REQUIRED` 超时,是否统一落为 `DENY` 还是可重试状态? diff --git a/openspec/changes/p0-enforce-security-boundary/proposal.md b/openspec/changes/p0-enforce-security-boundary/proposal.md new file mode 100644 index 00000000..a91c83de --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/proposal.md @@ -0,0 +1,38 @@ +## Why + +当前框架把 `LLM 不可信` 作为核心不变量,但安全边界仍主要停留在接口层,关键门控尚未强制接入主执行路径。结果是策略决策、审批链路与工具调用之间存在绕过风险,难以满足“默认安全、可审计、可复验”的 P0 目标。 + +## What Changes + +- 在运行时引入默认安全边界实现(no-op 仅用于开发,policy 作为生产默认候选),并由 builder 统一注入。 +- 在工具调用前强制执行 `verify_trust` 与 `check_policy`,禁止未校验调用直接进入 `ToolGateway.invoke(...)`。 +- 打通 policy 决策与审批记忆:`ALLOW` 直接执行,`APPROVE_REQUIRED` 进入审批流,`DENY` 结构化拒绝并记录审计事件。 +- 统一策略事件与错误码,确保 Hook/Telemetry/EventLog 能还原一次完整门控决策链。 +- 增加面向 P0 的单元/集成回归,覆盖允许、拒绝、待审批三类路径。 + +## Capabilities + +### New Capabilities +- `security-policy-gate`: 在 agent 执行路径中提供统一 trust + policy 门控能力,并输出可审计决策记录。 + +### Modified Capabilities +- `define-trust-boundary`: 将 trust 推导从“设计约束”升级为“执行时强制步骤”。 +- `core-runtime`: 将工具调用前的 policy gate 纳入主循环,补齐拒绝与审批的标准行为。 +- `validation`: 增加针对安全门控决策的可验证断言与回归门禁。 + +## Impact + +- Affected code: + - `dare_framework/agent/dare_agent.py` + - `dare_framework/agent/builder.py` + - `dare_framework/security/kernel.py` + - `dare_framework/security/types.py` + - `dare_framework/config/types.py` + - `tests/unit/test_five_layer_agent.py` + - `tests/unit/test_tool_gateway.py` + - `tests/integration/test_example_agent_flow.py` +- Affected runtime behavior: + - 高风险工具调用默认进入策略门控。 + - 审批记忆与策略决策语义统一。 +- Dependency/API impact: + - 新增可选安全边界注入配置,默认行为更严格(可能暴露既有“隐式放行”路径)。 diff --git a/openspec/changes/p0-enforce-security-boundary/specs/core-runtime/spec.md b/openspec/changes/p0-enforce-security-boundary/specs/core-runtime/spec.md new file mode 100644 index 00000000..eafac2ea --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/specs/core-runtime/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: ExecutionControl waiting interface +The system SHALL provide an explicit HITL waiting interface via `IExecutionControl.wait_for_human(checkpoint_id, reason)`. + +This waiting interface MUST be used when security policy returns `APPROVE_REQUIRED` for plan execution or tool invocation. + +#### Scenario: Approval-required plan execution +- **GIVEN** a validated plan that requires approval +- **WHEN** the orchestrator gates execution +- **THEN** it records `exec.pause`, calls `wait_for_human(...)`, and records `exec.resume` before continuing + +#### Scenario: Approval-required tool invocation +- **GIVEN** a tool capability that requires approval +- **WHEN** policy evaluation returns `APPROVE_REQUIRED` +- **THEN** the orchestrator records `exec.pause`, calls `wait_for_human(...)`, and records `exec.resume` before continuing + +### Requirement: Auditable Event Logging +The Kernel SHALL append structured events to `IEventLog` for state transitions, plan attempts, tool invocations, policy decisions, and verification outcomes, including correlation identifiers (task/session/milestone/run). + +Security preflight events MUST include trust/policy outcomes before any tool side effects occur. + +#### Scenario: Tool invocation is logged +- **WHEN** `IToolGateway.invoke()` is called +- **THEN** an event is appended to `IEventLog` including capability id, derived risk, decision, and outcome + +#### Scenario: Policy denial is logged before invocation +- **WHEN** security policy denies a tool invocation +- **THEN** an event is appended with denial reason and correlation identifiers +- **AND** no invocation outcome event is emitted as success + diff --git a/openspec/changes/p0-enforce-security-boundary/specs/define-trust-boundary/spec.md b/openspec/changes/p0-enforce-security-boundary/specs/define-trust-boundary/spec.md new file mode 100644 index 00000000..c0a1dd1a --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/specs/define-trust-boundary/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Security Boundary Minimal Contract +The `ISecurityBoundary` interface SHALL expose a minimal contract that includes: +- verifying trusted input (`verify_trust`) +- checking policy decisions (`check_policy`) +- executing actions safely (`execute_safe`) + +The runtime MUST treat these methods as mandatory preflight boundaries for side-effecting invocations, not optional helper hooks. + +#### Scenario: Checking policy +- **WHEN** a tool action is evaluated +- **THEN** `check_policy` returns a decision such as `ALLOW`, `APPROVE_REQUIRED`, or `DENY` +- **AND** the runtime maps that decision to deterministic control flow + +### Requirement: Security Boundary Positioning +The agent flow SHALL apply `ISecurityBoundary` checks before invoking tool execution or protocol adapters. + +- `verify_trust` MUST run before policy evaluation. +- `check_policy` MUST run before `IToolGateway.invoke(...)`. +- A denied or unresolved decision MUST prevent downstream invocation. + +#### Scenario: Enforcing ordering +- **WHEN** an agent prepares to invoke a tool +- **THEN** it verifies trust and policy before calling the tool gateway + +#### Scenario: Denied policy prevents side effects +- **WHEN** `check_policy` returns `DENY` +- **THEN** the runtime terminates the invocation path +- **AND** no tool or protocol adapter is invoked + diff --git a/openspec/changes/p0-enforce-security-boundary/specs/security-policy-gate/spec.md b/openspec/changes/p0-enforce-security-boundary/specs/security-policy-gate/spec.md new file mode 100644 index 00000000..05fadf74 --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/specs/security-policy-gate/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Tool invocation MUST pass security preflight +The runtime SHALL execute security preflight before any side-effecting capability invocation. + +- Preflight MUST execute in strict order: `verify_trust` then `check_policy`. +- The runtime MUST NOT call `IToolGateway.invoke(...)` when preflight has not completed. +- Preflight failures MUST produce deterministic denial responses. + +#### Scenario: Allowed invocation passes preflight +- **GIVEN** a tool invocation with trusted metadata derivable from registry +- **WHEN** `verify_trust` succeeds and `check_policy` returns `ALLOW` +- **THEN** the runtime invokes the tool through `IToolGateway.invoke(...)` + +#### Scenario: Denied invocation is blocked before tool gateway +- **GIVEN** a tool invocation where `check_policy` returns `DENY` +- **WHEN** the runtime evaluates preflight +- **THEN** the runtime rejects the invocation +- **AND** `IToolGateway.invoke(...)` is not called + +### Requirement: Policy decisions MUST map to deterministic runtime actions +The runtime SHALL map policy decisions to deterministic control flow. + +- `ALLOW` MUST continue invocation immediately. +- `APPROVE_REQUIRED` MUST enter approval flow and wait for resolution. +- `DENY` MUST terminate invocation with a policy denial result. + +#### Scenario: APPROVE_REQUIRED routes into approval flow +- **GIVEN** `check_policy` returns `APPROVE_REQUIRED` +- **WHEN** the runtime handles the decision +- **THEN** it creates or reuses an approval request and waits for explicit allow/deny + +### Requirement: Security preflight MUST be auditable +The runtime SHALL append structured security events for trust derivation and policy decisions. + +- Events MUST include `capability_id`, decision status, and correlation identifiers. +- If approval is required, events MUST include a stable `request_id`. + +#### Scenario: Security event payload includes correlation fields +- **WHEN** a policy decision is made for a tool invocation +- **THEN** an event record includes `task_id`, `run_id`, `capability_id`, and decision outcome + diff --git a/openspec/changes/p0-enforce-security-boundary/specs/validation/spec.md b/openspec/changes/p0-enforce-security-boundary/specs/validation/spec.md new file mode 100644 index 00000000..c3691b4b --- /dev/null +++ b/openspec/changes/p0-enforce-security-boundary/specs/validation/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Composite validator +The system SHALL provide a CompositeValidator that chains multiple validators in ascending order and aggregates validation errors. + +Composite validation for security-sensitive plans MUST include trusted metadata checks and MUST fail when required trusted fields cannot be derived from registry-backed sources. + +#### Scenario: Aggregated validation errors +- **WHEN** any validator in the chain fails +- **THEN** the CompositeValidator MUST return failure and include all collected errors + +#### Scenario: Missing trusted security metadata fails validation +- **WHEN** a proposed step references a capability but trusted risk metadata cannot be derived +- **THEN** validation returns failure +- **AND** the error set includes a security metadata derivation error + diff --git a/openspec/changes/p0-step-driven-execution/.openspec.yaml b/openspec/changes/p0-step-driven-execution/.openspec.yaml new file mode 100644 index 00000000..d1c6cc6f --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-27 diff --git a/openspec/changes/p0-step-driven-execution/design.md b/openspec/changes/p0-step-driven-execution/design.md new file mode 100644 index 00000000..c2ebebdb --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/design.md @@ -0,0 +1,64 @@ +## Context + +`DareAgent` 已具备 `execution_mode`、`IStepExecutor`、`ValidatedStep` 等基础结构,但当前主执行路径未消费这些能力。计划环与执行环之间缺乏硬连接,使 `ValidatedPlan` 不能稳定约束实际工具调用顺序,也削弱了后续验证与审计信号。 + +## Goals / Non-Goals + +**Goals:** +- 让 `execution_mode` 成为真实运行时开关。 +- 在 `step_driven` 模式下按 step 顺序执行并收集 evidence。 +- 定义 step 失败行为(中断/继续策略)并输出可复验结果。 +- 保持 `model_driven` 路径兼容,避免现有行为大面积回归。 + +**Non-Goals:** +- 本次不实现 DAG 并发 step 调度,仅支持顺序执行。 +- 本次不重写 planner 产出格式,只做最小转换与执行接线。 +- 本次不扩展复杂依赖表达式(如跨 step 条件分支语言)。 + +## Decisions + +### Decision 1: 执行模式双轨并存 +- `model_driven` 继续沿用模型 tool-calls 驱动。 +- `step_driven` 仅消费 `ValidatedPlan.steps`,不再每轮向模型请求工具选择。 +- 理由:先保证可控执行路径,再逐步扩展高级编排。 + +### Decision 2: 复用现有 `IStepExecutor` +- 直接复用 `DefaultStepExecutor`,由 agent 注入执行。 +- 每步产出 `StepResult`,并将 evidence 聚合到 execute result。 +- 理由:避免重复实现,快速让现有抽象落地。 + +### Decision 3: 失败策略默认 fail-fast +- 任一步 `success=False` 默认终止后续 steps。 +- 输出包含已完成步骤、失败步骤、错误列表,供 remediator/validator 使用。 +- 理由:P0 先保证行为可预测,后续再考虑容错重试策略。 + +### Decision 4: 统一 verify 输入结构 +- 无论执行模式,verify 阶段都接收标准化 `RunResult` 与可选 plan。 +- `step_driven` 模式额外传递 step 汇总(成功数、失败数、evidence)。 +- 理由:保证 validator 兼容并增强可解释性。 + +## Risks / Trade-offs + +- [Risk] 部分任务依赖模型在线推理决策,切到 step 模式可能能力下降。 + → Mitigation: 默认保持 `model_driven`,按场景显式启用 `step_driven`。 +- [Risk] planner step 质量不足会直接影响执行成功率。 + → Mitigation: 先在 validator 中加强 step 完整性校验并提供清晰报错。 +- [Risk] 双模式并存增加维护复杂度。 + → Mitigation: 共享 execute result 与 verify 接口,避免分叉过深。 + +## Migration Plan + +1. 在 agent 中实现 execution mode 分支,先接通 `step_driven` 基础路径。 +2. 注入默认 step executor,并完成 step 结果聚合格式。 +3. 增加无 validator 场景下 `ProposedStep -> ValidatedStep` 最小转换。 +4. 更新 verify 入口以接收 step 汇总并验证兼容性。 +5. 通过单测与集成测试验证双模式不互相回归。 + +Rollback: +- 配置回退至 `model_driven` 默认路径并禁用 `step_driven` 分支。 + +## Open Questions + +- `step_driven` 是否需要按 step 级别支持重试次数配置? +- `_previous_output` 上下文注入是否需要标准命名与 schema 限制? +- 长任务下是否要把 step 进度实时透传到 transport(用于 UI 展示)? diff --git a/openspec/changes/p0-step-driven-execution/proposal.md b/openspec/changes/p0-step-driven-execution/proposal.md new file mode 100644 index 00000000..cef1bc6b --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/proposal.md @@ -0,0 +1,35 @@ +## Why + +当前五层循环中 `ValidatedPlan.steps` 已有数据结构,但执行环路仍主要由模型即时 `tool_calls` 驱动,导致“计划可验证”与“执行可追踪”之间存在断层。P0 需要把 step 计划变成可落地执行路径,减少模型自由漂移并提升可审计性。 + +## What Changes + +- 启用 `execution_mode` 的真实分支行为,支持 `model_driven` 与 `step_driven` 两种执行路径。 +- 在 `step_driven` 模式下按 `ValidatedPlan.steps` 顺序执行,并输出结构化 `StepResult` 与 `Evidence`。 +- 将现有 `IStepExecutor` / `DefaultStepExecutor` 接入主循环,统一失败中断与错误聚合策略。 +- 补齐无 validator 场景下的最小 step 转换逻辑,避免当前 `steps=[]` 的信息丢失。 +- 将 step 执行产物纳入 milestone verify 输入,形成“plan -> execute -> verify”闭环。 + +## Capabilities + +### New Capabilities +- `step-driven-execution`: 基于 `ValidatedPlan.steps` 的确定性执行模式,支持顺序执行、证据采集与失败处理。 + +### Modified Capabilities +- `plan-module`: 将 plan step 从“描述性数据”升级为“可执行输入”。 +- `session-loop`: 调整 execute loop 行为,使其支持按配置切换执行模式。 +- `core-runtime`: 增强运行时输出结构,支持 step 级结果与 evidence 聚合。 + +## Impact + +- Affected code: + - `dare_framework/agent/dare_agent.py` + - `dare_framework/agent/_internal/step_executor.py` + - `dare_framework/agent/builder.py` + - `dare_framework/plan/interfaces.py` + - `dare_framework/plan/types.py` + - `tests/unit/test_five_layer_agent.py` + - `tests/integration/test_example_agent_flow.py` +- Runtime/API impact: + - `execution_mode` 从注释字段变为生效行为,错误策略将更确定。 + - step 执行结果将进入验证阶段,提高外部可验证性。 diff --git a/openspec/changes/p0-step-driven-execution/specs/core-runtime/spec.md b/openspec/changes/p0-step-driven-execution/specs/core-runtime/spec.md new file mode 100644 index 00000000..3620d563 --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/specs/core-runtime/spec.md @@ -0,0 +1,43 @@ +## MODIFIED Requirements + +### Requirement: Step-Driven Execution Mode +The runtime SHALL support a step-driven execution mode where the Execute Loop follows `ValidatedPlan.steps` sequentially. + +- When `execution_mode="step_driven"`, the Execute Loop SHALL execute each `ValidatedStep` in order via `IStepExecutor`. +- Each step result SHALL be passed to the next step as context. +- If a step fails, execution SHALL halt and control returns to Milestone Loop for remediation. +- When `execution_mode="model_driven"` (default), the existing model-free execution behavior is preserved. +- In `step_driven` mode, runtime MUST NOT bypass validated step ordering by using ad-hoc model tool calls as the primary execution source. + +#### Scenario: Step-by-step execution +- **GIVEN** a ValidatedPlan with 3 steps and `execution_mode="step_driven"` +- **WHEN** the Execute Loop runs +- **THEN** each step is executed in order, with previous results available to subsequent steps + +#### Scenario: Step failure halts execution +- **GIVEN** a ValidatedPlan with 3 steps in step-driven mode +- **WHEN** step 2 fails +- **THEN** step 3 is NOT executed and control returns to Milestone Loop + +#### Scenario: Model-driven mode preserves existing behavior +- **GIVEN** `execution_mode="model_driven"` (or not specified) +- **WHEN** the Execute Loop runs +- **THEN** the model drives execution freely without step constraints + +### Requirement: IStepExecutor 接口 +The runtime SHALL provide an `IStepExecutor` interface for executing individual plan steps. + +- `execute_step(step, ctx, previous_results)` SHALL execute a single `ValidatedStep` and return a `StepResult`. +- `StepResult` MUST contain: `step_id`, `success`, `output`, `evidence`, `errors`. +- Implementations MAY invoke tools via `IToolGateway` based on step's `capability_id`. +- `StepResult.evidence` MUST be preserved into execute output so verify/remediation can consume it. + +#### Scenario: Step executor invokes tool +- **GIVEN** a ValidatedStep with capability_id="write_file" +- **WHEN** `execute_step` is called +- **THEN** the executor invokes the tool via IToolGateway and returns a StepResult + +#### Scenario: Step evidence is available for verification +- **WHEN** step execution produces evidence entries +- **THEN** execute loop output includes those entries for milestone verification + diff --git a/openspec/changes/p0-step-driven-execution/specs/plan-module/spec.md b/openspec/changes/p0-step-driven-execution/specs/plan-module/spec.md new file mode 100644 index 00000000..9f1e0c8a --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/specs/plan-module/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: 计划模型分层(Proposed vs Validated) +计划模块 SHALL 提供明确的 Proposed/Validated 类型分层: +- Proposed 计划与步骤来自 planner,视为不可信输入。 +- Validated 计划与步骤由 validator 基于可信 registry 派生,至少包含可信 `risk_level`;其余可信字段统一承载在可信 `metadata` 中,且不与 Proposed 类型混用或别名化。 + +在支持 step-driven 执行时,`ValidatedStep` MUST 包含可直接执行所需的完整字段(`step_id`, `capability_id`, `params`, `envelope`)。 + +#### Scenario: 读取计划类型分层 +- **WHEN** 贡献者查看 plan 模型 +- **THEN** ProposedPlan/ProposedStep 与 ValidatedPlan/ValidatedStep 为独立类型,且 ValidatedStep 明确携带可信字段 + +#### Scenario: ValidatedStep 可直接执行 +- **WHEN** 运行时进入 `step_driven` 执行模式 +- **THEN** 每个 ValidatedStep 都可在无需二次推断的前提下交由 step executor 执行 + +### Requirement: 计划步骤与 Tool Loop 执行边界 +计划模块 SHALL 提供 `Envelope`、`DonePredicate`、`ToolLoopRequest` 模型,以支撑 Tool Loop 的执行边界: +- `Envelope` 至少包含 `allowed_capability_ids`、`budget`、`done_predicate`、`risk_level`。 +- `ToolLoopRequest` 由 `capability_id + params + envelope` 组成。 +- `ValidatedStep` 可在可信 `metadata` 中携带由 registry 派生的 `capability_kind` 等字段,以支持 Plan Tool 识别。 + +在 `step_driven` 模式中,运行时 MUST 从 `ValidatedStep` 直接构建执行请求并执行,不依赖模型临时生成工具调用。 + +#### Scenario: ToolLoopRequest 受 Envelope 约束 +- **GIVEN** 一个包含 `allowed_capability_ids` 的 Envelope +- **WHEN** 从 ValidatedStep 生成 ToolLoopRequest +- **THEN** 调用仅允许发生在该 allowlist 范围内 + +#### Scenario: Step-driven 不依赖模型二次选工具 +- **GIVEN** 已通过验证的 `ValidatedPlan.steps` +- **WHEN** 运行时执行 `step_driven` 模式 +- **THEN** 工具调用来源于 steps +- **AND** 不是由模型在执行期重新决定 capability + diff --git a/openspec/changes/p0-step-driven-execution/specs/session-loop/spec.md b/openspec/changes/p0-step-driven-execution/specs/session-loop/spec.md new file mode 100644 index 00000000..b8f3982c --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/specs/session-loop/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements + +### Requirement: SessionContext initialization and config snapshot +The runtime SHALL create a SessionContext at the start of a session and bind the effective Config snapshot (if a ConfigProvider is supplied). The session start event SHALL include a `config_hash` derived from the snapshot. + +The SessionContext MUST also record the effective `execution_mode` used for the run so downstream loops and audit events can determine whether execution is model-driven or step-driven. + +#### Scenario: Session context includes config snapshot +- **GIVEN** a runtime configured with an `IConfigProvider` +- **WHEN** a session starts +- **THEN** SessionContext stores the effective Config snapshot +- **AND** `session.start` includes `config_hash` + +#### Scenario: Session context includes execution mode +- **WHEN** a session starts with `execution_mode="step_driven"` +- **THEN** SessionContext records `execution_mode=step_driven` +- **AND** subsequent loop events can read the same mode value + diff --git a/openspec/changes/p0-step-driven-execution/specs/step-driven-execution/spec.md b/openspec/changes/p0-step-driven-execution/specs/step-driven-execution/spec.md new file mode 100644 index 00000000..41186edc --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/specs/step-driven-execution/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Runtime MUST support explicit execution mode selection +The runtime SHALL support explicit execution modes: `model_driven` and `step_driven`. + +- `model_driven` MUST preserve existing model-selected tool invocation behavior. +- `step_driven` MUST execute only from validated plan steps. + +#### Scenario: Default mode remains model-driven +- **WHEN** execution mode is not explicitly configured +- **THEN** the runtime uses `model_driven` +- **AND** existing tool-call behavior remains compatible + +#### Scenario: Configured step-driven mode activates step executor path +- **WHEN** execution mode is set to `step_driven` +- **THEN** execute loop runs via the step executor path + +### Requirement: Step-driven mode MUST execute steps deterministically +In `step_driven` mode the runtime SHALL execute `ValidatedPlan.steps` sequentially and deterministically. + +- Each step MUST run in declared order. +- Previous successful step output MUST be available as step context for subsequent steps. +- Step failure MUST halt remaining steps unless a future policy explicitly enables continuation. + +#### Scenario: Sequential step execution +- **GIVEN** three validated steps in a plan +- **WHEN** execute loop runs in `step_driven` mode +- **THEN** step 1, step 2, and step 3 are executed in order + +#### Scenario: Step failure halts downstream steps +- **GIVEN** three validated steps in `step_driven` mode +- **WHEN** step 2 fails +- **THEN** step 3 is not executed +- **AND** execution returns failure with step-level errors + diff --git a/openspec/changes/p0-step-driven-execution/tasks.md b/openspec/changes/p0-step-driven-execution/tasks.md new file mode 100644 index 00000000..1c917c4c --- /dev/null +++ b/openspec/changes/p0-step-driven-execution/tasks.md @@ -0,0 +1,25 @@ +## 1. Execution Mode Activation + +- [ ] 1.1 在 `DareAgent` 中启用 `execution_mode` 分支选择逻辑。 +- [ ] 1.2 保持 `model_driven` 现有行为并补充兼容性断言。 +- [ ] 1.3 新增 `step_driven` 路径入口并接入 execute loop。 + +## 2. Step Executor Integration + +- [ ] 2.1 将 `IStepExecutor` 注入到 `DareAgent` 默认构建路径。 +- [ ] 2.2 在 `step_driven` 模式按 `ValidatedPlan.steps` 顺序执行。 +- [ ] 2.3 聚合 `StepResult` 与 `Evidence` 到统一 execute result 结构。 +- [ ] 2.4 实现 step 失败 fail-fast 与错误传播。 + +## 3. Plan-to-Step Bridge + +- [ ] 3.1 补齐无 validator 场景下的最小 step 转换逻辑。 +- [ ] 3.2 增加 step 合法性校验(capability、params、envelope 基础检查)。 +- [ ] 3.3 在 verify 阶段透传 plan 与 step 汇总信息。 + +## 4. Tests and Regression + +- [ ] 4.1 新增单测验证 step 顺序执行与失败中断语义。 +- [ ] 4.2 新增单测验证 evidence 聚合格式与字段完整性。 +- [ ] 4.3 新增集成测试覆盖 `step_driven` 完整链路(plan->execute->verify)。 +- [ ] 4.4 回归测试确保 `model_driven` 路径无行为退化。 From 9f8c2439fc10ee167fdb223e4e717a2420fde31d Mon Sep 17 00:00:00 2001 From: bouillipx Date: Sat, 28 Feb 2026 10:23:59 +0800 Subject: [PATCH 3/3] fix(security): require trusted risk metadata in tool preflight Address the P1 review finding on PR #120. The tool preflight path was passing envelope risk defaults into verify_trust. Because ToolLoopRequest creates a default Envelope with read_only risk, strict PolicySecurityBoundary mode could incorrectly derive a trusted risk level even when the capability descriptor had no trusted risk metadata. Changes: - stop forwarding envelope risk fields into the trust-derivation context in DareAgent._evaluate_tool_security - add a regression test proving strict trust mode rejects a capability that lacks trusted risk metadata even when the request uses the default envelope Why this shape: - policy evaluation still receives the trusted risk derived from the boundary result - strict mode now behaves consistently with RegistryPlanValidator and PolicySecurityBoundary.require_trusted_metadata expectations Verification: - .venv/bin/python -m pytest tests/unit/test_dare_agent_security_policy_gate.py::test_strict_policy_boundary_rejects_missing_trusted_risk_metadata_even_with_default_envelope -q - .venv/bin/python -m pytest tests/unit/test_dare_agent_security_policy_gate.py tests/unit/test_security_boundary.py tests/unit/test_builder_security_boundary.py tests/unit/test_config_model.py tests/unit/test_governed_tool_gateway.py tests/unit/test_registry_plan_validator.py -q - git diff --check --- dare_framework/agent/dare_agent.py | 2 -- .../test_dare_agent_security_policy_gate.py | 34 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/dare_framework/agent/dare_agent.py b/dare_framework/agent/dare_agent.py index 020b4df8..2644b23e 100644 --- a/dare_framework/agent/dare_agent.py +++ b/dare_framework/agent/dare_agent.py @@ -1449,8 +1449,6 @@ async def _evaluate_tool_security( "tool_call_id": tool_call_id, "attempt": attempt, "descriptor": descriptor, - "risk_level": getattr(request.envelope.risk_level, "value", request.envelope.risk_level), - "envelope_risk_level": getattr(request.envelope.risk_level, "value", request.envelope.risk_level), "requires_approval": self._requires_approval(descriptor), } try: diff --git a/tests/unit/test_dare_agent_security_policy_gate.py b/tests/unit/test_dare_agent_security_policy_gate.py index e4eb5bb4..ab701954 100644 --- a/tests/unit/test_dare_agent_security_policy_gate.py +++ b/tests/unit/test_dare_agent_security_policy_gate.py @@ -10,7 +10,8 @@ from dare_framework.config import Config from dare_framework.context import Context from dare_framework.plan.types import ToolLoopRequest -from dare_framework.security.errors import SECURITY_POLICY_DENIED +from dare_framework.security.errors import SECURITY_POLICY_DENIED, SECURITY_TRUST_DERIVATION_FAILED +from dare_framework.security.impl import PolicySecurityBoundary from dare_framework.security.kernel import ISecurityBoundary from dare_framework.security.types import PolicyDecision, RiskLevel, SandboxSpec, TrustedInput from dare_framework.tool._internal.control.approval_manager import ( @@ -235,3 +236,34 @@ async def test_missing_explicit_boundary_uses_default_preflight_instead_of_bypas event_types = [event_type for event_type, _ in event_log.events] assert "security.trust_verified" in event_types assert "security.policy_checked" in event_types + + +@pytest.mark.asyncio +async def test_strict_policy_boundary_rejects_missing_trusted_risk_metadata_even_with_default_envelope() -> None: + event_log = _RecordingEventLog() + descriptor = CapabilityDescriptor( + id="run_command", + type=CapabilityType.TOOL, + name="run_command", + description="Run shell command", + input_schema={"type": "object", "properties": {"command": {"type": "string"}}}, + metadata={"requires_approval": False}, + ) + gateway = _RecordingGateway([descriptor]) + agent = _agent( + gateway=gateway, + event_log=event_log, + security_boundary=PolicySecurityBoundary(require_trusted_metadata=True), + ) + + result = await agent._run_tool_loop( # noqa: SLF001 - direct runtime boundary coverage + ToolLoopRequest(capability_id="run_command", params={"command": "echo strict"}), + tool_name="run_command", + tool_call_id="tc-strict-missing-risk", + descriptor=descriptor, + ) + + assert result["success"] is False + assert result["status"] == "not_allow" + assert result["output"]["code"] == SECURITY_TRUST_DERIVATION_FAILED + assert gateway.invoke_calls == []