Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- Authentication-first composition now re-wraps governed executor failures with the exact `credential_verified` evidence and rejects rejected-authentication or context-substitution combinations without copying credential or raw executor content.
- Authenticated MCP mutable execution now maps only post-executor `AuthenticatedGovernedActionExecutionError` states to host-visible `MCPError` protocol failures, preventing an uncertain side-effect outcome from becoming an ordinary model-visible Tool error that invites self-directed retry.
- The trusted-composition governed MCP mutable boundary now applies the same fail-closed transport classification to post-executor `GovernedActionExecutionError`, preserving safe action-level failure evidence while keeping raw executor text out of MCP protocol data.
- Agno governed mutable execution now preserves the original `GovernedActionExecutionError` across `RunStatus.error` instead of replacing application-owned failure provenance with a generic framework error; the existing `max_retries=0` boundary remains unchanged.

### Evidence

Expand All @@ -38,6 +39,7 @@
- Exact isolated `mcp[cli]==2.1.1` compatibility and real STDIO checks prove uncertain authenticated mutable execution raises a protocol `MCPError`, not `CallToolResult(is_error=true)`, while preserving safe authenticated failure evidence in error `data`, zero observed fixture mutation for the failing resource, and a subsequent valid mutation.
- 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.

## 1.3.0 - 2026-09-05

Expand Down
21 changes: 18 additions & 3 deletions docs/security/GOVERNED_AGENT_ACTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,13 +445,28 @@ The framework orchestrates. The application decides and enforces.

Existing framework adapters continue to use their explicitly injected local `trusted_composition` context. Phase 37 migrates their caller-policy fixtures to declare that source explicitly, so framework behavior cannot depend on an implicit source default inside authorization policy. Phase 47 keeps approver entitlement in the same shared application runtime; cross-framework conformance includes an unauthorized-approver scenario rather than duplicating HITL policy inside LangGraph, CrewAI, LlamaIndex, or Agno. API-key handling remains an application authentication concern rather than framework input.

### Agno retry hardening
### Agno retry and failure-provenance hardening

The Agno mutable step explicitly uses `max_retries=0` and fail-closed step behavior.

This is security-relevant because an implicit framework retry around a mutating executor could multiply side effects after a partial failure.
This is security-relevant because an implicit framework retry around a mutating executor could multiply side effects after a partial failure. The zero-retry setting therefore remains explicit even though authorization and executor-failure semantics belong to the application runtime rather than Agno.

A regression test uses a failing executor and proves it is invoked exactly once.
A second boundary matters after that one allowed attempt fails. `GovernedActionRuntime` raises `GovernedActionExecutionError` with `ActionExecutionFailureEvidence`, but Agno represents the workflow itself as `RunStatus.error`. The adapter must not replace the application-owned governed failure with a new generic framework error merely because the workflow status is error.

The Agno adapter therefore captures only `GovernedActionExecutionError` while the Step is executing and, when the Workflow reports `RunStatus.error`, re-raises that same governed exception. Non-governed Agno failures still use the adapter's generic framework error path.

This preserves two independent facts:

```text
Agno mutable executor failure
-> exactly one executor invocation
-> original GovernedActionExecutionError survives
-> ActionExecutionFailureEvidence remains inspectable
```

Regression coverage requires the exact caller context, proposed action and authorization evidence, `execution_attempted=true`, `failure_reason=executor_error`, and `external_side_effect_state=unknown`. Raw executor exception text remains excluded from structured evidence and the governed error message; it remains available only as the local Python exception cause.

This does not make Agno the owner of failure semantics and does not add retry, idempotency, rollback, compensation, or transaction guarantees.

## 8. Cross-framework conformance

Expand Down
19 changes: 14 additions & 5 deletions src/agentic_lab/adapters/agno/action_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from agentic_lab.application.action_authorization import ActionContext, ProposedAction
from agentic_lab.application.action_runtime import (
ActionExecutionEvidence,
GovernedActionExecutionError,
GovernedActionRuntime,
)

Expand All @@ -18,10 +19,11 @@

@dataclass(slots=True)
class _AgnoGovernedActionState:
"""Hold model-safe action state while Agno orchestrates execution."""
"""Bridge application-owned execution outcomes across Agno orchestration."""

proposed_action: ProposedAction
execution_evidence: ActionExecutionEvidence | None = None
execution_error: GovernedActionExecutionError | None = None


def _fail_closed_review() -> HumanReview:
Expand All @@ -37,10 +39,15 @@ def _action_step(
"""Build one native Agno Step around the application-owned runtime."""

def execute(_: StepInput) -> StepOutput:
evidence = action_runtime.execute(
state.proposed_action,
context,
)
try:
evidence = action_runtime.execute(
state.proposed_action,
context,
)
except GovernedActionExecutionError as exc:
state.execution_error = exc
raise

state.execution_evidence = evidence
return StepOutput(content=evidence)

Expand Down Expand Up @@ -91,6 +98,8 @@ def run(self, proposed_action: ProposedAction) -> ActionExecutionEvidence:
raw_output = workflow.run(input="governed action execution")

if raw_output.status == RunStatus.error:
if state.execution_error is not None:
raise state.execution_error
raise RuntimeError("Agno governed action Workflow execution failed")
if raw_output.status != RunStatus.completed:
raise RuntimeError(
Expand Down
31 changes: 25 additions & 6 deletions tests/unit/adapters/agno/test_agno_action_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
ProposedAction,
StaticActionAuthorizationPolicy,
)
from agentic_lab.application.action_runtime import GovernedActionRuntime
from agentic_lab.application.action_runtime import (
GovernedActionExecutionError,
GovernedActionRuntime,
)

_FINDING_RESOURCE = "finding:demo-001"
_ALLOWED_CALLER = "remediation-agent"
_RAW_EXECUTOR_ERROR = f"synthetic failure for {_FINDING_RESOURCE}"


class _FailingExecutor:
Expand Down Expand Up @@ -147,18 +151,33 @@ def test_agno_resource_escalation_fails_closed_before_mutation() -> None:
assert executor.execution_count == 0


def test_agno_mutable_step_does_not_retry_executor_failure() -> None:
"""Prevent framework retry policy from repeating a failed mutable execution."""
def test_agno_mutable_step_preserves_governed_failure_without_retry() -> None:
"""Preserve typed failure provenance while preventing repeated mutable execution."""
executor = _FailingExecutor()
context = ActionContext(caller_id=_ALLOWED_CALLER)
proposed_action = _action()
runtime = AgnoGovernedActionRuntime(
runtime=GovernedActionRuntime(
authorizer=_policy(),
executor=executor,
),
context=ActionContext(caller_id=_ALLOWED_CALLER),
context=context,
)

with pytest.raises(RuntimeError):
runtime.run(_action())
with pytest.raises(GovernedActionExecutionError) as caught:
runtime.run(proposed_action)

error = caught.value
assert executor.calls == 1
assert error.evidence.proposed_action == proposed_action
assert error.evidence.context == context
assert error.evidence.authorization.outcome == "allow"
assert error.evidence.authorization.reason == "explicit_allow"
assert error.evidence.approval_status == "not_applicable"
assert error.evidence.execution_attempted is True
assert error.evidence.failure_reason == "executor_error"
assert error.evidence.external_side_effect_state == "unknown"
assert _RAW_EXECUTOR_ERROR not in error.evidence.model_dump_json()
assert _RAW_EXECUTOR_ERROR not in str(error)
assert isinstance(error.__cause__, RuntimeError)
assert str(error.__cause__) == _RAW_EXECUTOR_ERROR