diff --git a/CHANGELOG.md b/CHANGELOG.md index a53f51b..04ebd8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Governed trusted-composition MCP compatibility and real STDIO checks prove the same protocol-error boundary for action-level executor failure: safe `ActionExecutionFailureEvidence` remains in error `data`, the controlled failing resource shows zero observed fixture mutation without rewriting `external_side_effect_state=unknown`, and a subsequent valid mutation succeeds. - The MCP protocol guard removes this failure class from the normal model-correctable Tool-result channel; it does not prove that every host will avoid programmatic retry and does not provide idempotency, rollback, compensation, or distributed transaction semantics. - Agno failure regression coverage proves a mutable executor is attempted exactly once and callers receive the original governed failure evidence with exact context/action/authorization, `execution_attempted=true`, `failure_reason=executor_error`, and `external_side_effect_state=unknown`; raw executor text remains only in the local exception cause. +- Cross-framework governed-failure conformance now proves direct runtime, LangGraph, CrewAI, LlamaIndex, and Agno preserve baseline-equivalent `ActionExecutionFailureEvidence` for an authorized executor failure, with exactly one attempt and raw executor text confined to the local exception cause. ## 1.3.0 - 2026-09-05 diff --git a/docs/security/GOVERNED_AGENT_ACTIONS.md b/docs/security/GOVERNED_AGENT_ACTIONS.md index 5ee233f..f984926 100644 --- a/docs/security/GOVERNED_AGENT_ACTIONS.md +++ b/docs/security/GOVERNED_AGENT_ACTIONS.md @@ -483,15 +483,19 @@ The controlled matrix covers: | caller mismatch | fail-closed `deny` | none | | identity-source mismatch | fail-closed `deny` | none | | resource escalation | fail-closed `deny` | none | +| authorized executor failure | `GovernedActionExecutionError` with baseline-equivalent `ActionExecutionFailureEvidence` | external side-effect state remains `unknown`; exactly one attempt | For every framework, the suite compares: -- the complete `ActionExecutionEvidence` object; +- the complete `ActionExecutionEvidence` object for successful and pre-executor paths; +- the complete `ActionExecutionFailureEvidence` object for the authorized executor-failure path; - observable in-memory state; -- successful execution count. +- successful execution or executor-attempt count, as applicable. The identity-source mismatch scenario uses the same allowed caller and action scope but changes only the trusted context from `trusted_composition` to `api_key`. Because no API-key-specific rule exists in that fixture, the direct runtime and all four framework adapters must return `deny / no_matching_rule` with zero side effects. +The executor-failure scenario uses the exact allowed action and a deterministic executor that raises only after invocation. The direct application runtime establishes the failure baseline, and every framework must surface `GovernedActionExecutionError` with equal structured evidence, exactly one executor attempt, and the raw executor message absent from both structured evidence and the governed error text. The raw exception remains only as the local Python cause, while `external_side_effect_state` remains `unknown`. + This is **provider-free cross-framework conformance evidence**. It does not prove production security, provider behavior, remote identity propagation, or statistical performance. Relevant test: diff --git a/tests/integration/test_governed_action_framework_conformance.py b/tests/integration/test_governed_action_framework_conformance.py index cefd46a..15bee5a 100644 --- a/tests/integration/test_governed_action_framework_conformance.py +++ b/tests/integration/test_governed_action_framework_conformance.py @@ -31,6 +31,8 @@ ) from agentic_lab.application.action_runtime import ( ActionExecutionEvidence, + ActionExecutor, + GovernedActionExecutionError, GovernedActionRuntime, ) @@ -39,6 +41,7 @@ _APPROVED_AT = datetime(2026, 9, 5, 20, 0, tzinfo=UTC) _NOW = _APPROVED_AT + timedelta(minutes=5) _EXPIRES_AT = _APPROVED_AT + timedelta(minutes=15) +_RAW_EXECUTOR_ERROR = "synthetic cross-framework executor failure" _RULES: dict[ActionAuthorizationRuleKey, AuthorizationOutcome] = { ( @@ -82,6 +85,18 @@ class _Scenario: revoke_approval: bool = False +class _FailingExecutor: + """Count one mutable invocation before raising a deterministic failure.""" + + def __init__(self) -> None: + self.calls = 0 + + def execute(self, proposed_action: ProposedAction) -> None: + """Fail after the application has crossed the executor boundary.""" + self.calls += 1 + raise RuntimeError(_RAW_EXECUTOR_ERROR) + + def _action( *, resource: str = _FINDING_RESOURCE, @@ -210,7 +225,7 @@ def now(self) -> datetime: def _runtime( - executor: InMemoryFindingAcknowledgementExecutor, + executor: ActionExecutor, scenario: _Scenario, ) -> GovernedActionRuntime: approvals: tuple[HumanApprovalEvidence, ...] = () @@ -312,6 +327,30 @@ def _assert_expected_behavior( assert executor.is_acknowledged(_FINDING_RESOURCE) is scenario.expected_execution +def _assert_failure_behavior( + error: GovernedActionExecutionError, + executor: _FailingExecutor, + scenario: _Scenario, +) -> None: + evidence = error.evidence + + assert executor.calls == 1 + assert evidence.proposed_action == scenario.proposed_action + assert evidence.context == scenario.context + assert evidence.authorization.outcome == "allow" + assert evidence.authorization.reason == "explicit_allow" + assert evidence.approval_status == "not_applicable" + assert evidence.human_approval is None + assert evidence.approver_authorization is None + assert evidence.execution_attempted is True + assert evidence.failure_reason == "executor_error" + assert evidence.external_side_effect_state == "unknown" + assert _RAW_EXECUTOR_ERROR not in evidence.model_dump_json() + assert _RAW_EXECUTOR_ERROR not in str(error) + assert isinstance(error.__cause__, RuntimeError) + assert str(error.__cause__) == _RAW_EXECUTOR_ERROR + + @pytest.mark.parametrize( ("framework_name", "runner"), _FRAMEWORK_RUNNERS, @@ -341,3 +380,37 @@ def test_framework_governed_actions_match_application_baseline( assert framework_evidence == baseline_evidence, ( f"{framework_name} diverged from the application baseline for {scenario.name}" ) + + +@pytest.mark.parametrize( + ("framework_name", "runner"), + _FRAMEWORK_RUNNERS, + ids=[name for name, _ in _FRAMEWORK_RUNNERS], +) +def test_framework_executor_failures_match_application_baseline( + framework_name: str, + runner: FrameworkRunner, +) -> None: + """Require every framework to preserve application-owned executor failure provenance.""" + scenario = _SCENARIOS[0] + + baseline_executor = _FailingExecutor() + with pytest.raises(GovernedActionExecutionError) as baseline_caught: + _runtime(baseline_executor, scenario).execute( + scenario.proposed_action, + scenario.context, + ) + _assert_failure_behavior(baseline_caught.value, baseline_executor, scenario) + + framework_executor = _FailingExecutor() + with pytest.raises(GovernedActionExecutionError) as framework_caught: + runner( + _runtime(framework_executor, scenario), + scenario.context, + scenario.proposed_action, + ) + _assert_failure_behavior(framework_caught.value, framework_executor, scenario) + + assert framework_caught.value.evidence == baseline_caught.value.evidence, ( + f"{framework_name} diverged from the application failure baseline" + )