Skip to content

Commit 1476fcd

Browse files
VladUZHclaude
andcommitted
harden: fail closed on any decision that is not an explicit allow
evaluate_governance_sync/_async enumerated the bad decisions — raise on deny, raise on approval_required, return otherwise. That is a denylist: any value outside the known set reaches the `return` and the guarded action executes. This is the same shape @sidclaw/sdk carried until 13c6ab3, where it was a live governance bypass. It is NOT currently exploitable here: EvaluateResponse declares `decision: PolicyEffect` as a Literal, and the client returns `EvaluateResponse.model_validate(...)`, so pydantic rejects an unrecognised decision before middleware sees it. Verified end to end against a mocked server returning {"decision": "quarantine"} — it raises ValidationError. So this is defence in depth, not a fix, and it is deliberately NOT being published as a hotfix. The reason to add it anyway: the only thing making the denylist safe is a Literal two modules away. Widen PolicyEffect to add a decision value — exactly what a new server-side decision would require — and pydantic stops rejecting it while the denylist silently starts allowing it. The TypeScript SDK reached its bypass by that route. tests/test_middleware/test_fail_closed.py covers both layers: that pydantic rejects unknown decisions, and that the decision-site guard fires when validation is bypassed (model_construct simulates a widened Literal). Mutation-checked — removing the guard fails 8 of them. Version bumped to 0.2.1 with no PyPI release. Leaving it at 0.2.0 would put different source under an already-published version string, which is the invisible drift that let three security fixes sit unshipped on the npm side for three months. Local ahead of PyPI says "unreleased work exists"; local equal to PyPI while differing says nothing at all. 186 tests pass (167 + 19 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 31ea42e commit 1476fcd

4 files changed

Lines changed: 172 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sidclaw"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Python SDK for SidClaw — governance for AI agents"
99
readme = "README.md"
1010
license = "Apache-2.0"

src/sidclaw/_constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SDK_VERSION = "0.2.0"
1+
SDK_VERSION = "0.2.1"
22
DEFAULT_BASE_URL = "https://api.sidclaw.com"
33
DEFAULT_MAX_RETRIES = 3
44
DEFAULT_TIMEOUT = 30.0

src/sidclaw/middleware/_base.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ def evaluate_governance_sync(
1616
data_classification: DataClassification = "internal",
1717
context: dict[str, Any] | None = None,
1818
) -> EvaluateResponse:
19-
"""Evaluate an action and raise on deny/approval_required."""
19+
"""Evaluate an action, allowing it only on an explicit ``allow`` decision.
20+
21+
Raises ``ActionDeniedError`` on ``deny``, on ``approval_required``, and on
22+
any other value — the guarded action proceeds only when the policy engine
23+
explicitly permits it.
24+
"""
2025
decision = client.evaluate(
2126
EvaluateParams(
2227
operation=operation,
@@ -41,6 +46,20 @@ def evaluate_governance_sync(
4146
policy_rule_id=decision.policy_rule_id,
4247
)
4348

49+
# Fail closed on anything that is not an explicit allow.
50+
#
51+
# Enumerating only the bad decisions is a denylist: an unknown, misspelled,
52+
# absent, null, or newly-added decision value fell through to ALLOW and the
53+
# tool executed ungoverned. @sidclaw/sdk fixed this in 13c6ab3; this is the
54+
# Python port of that fix. The authoritative set is PolicyEffectValues =
55+
# ('allow', 'approval_required', 'deny').
56+
if decision.decision != "allow":
57+
raise ActionDeniedError(
58+
f"Unexpected policy decision: {decision.decision!r}",
59+
trace_id=decision.trace_id,
60+
policy_rule_id=decision.policy_rule_id,
61+
)
62+
4463
return decision
4564

4665

@@ -53,7 +72,12 @@ async def evaluate_governance_async(
5372
data_classification: DataClassification = "internal",
5473
context: dict[str, Any] | None = None,
5574
) -> EvaluateResponse:
56-
"""Async version: evaluate an action and raise on deny/approval_required."""
75+
"""Async version: allow only on an explicit ``allow`` decision.
76+
77+
Raises ``ActionDeniedError`` on ``deny``, on ``approval_required``, and on
78+
any other value — the guarded action proceeds only when the policy engine
79+
explicitly permits it.
80+
"""
5781
decision = await client.evaluate(
5882
EvaluateParams(
5983
operation=operation,
@@ -78,6 +102,20 @@ async def evaluate_governance_async(
78102
policy_rule_id=decision.policy_rule_id,
79103
)
80104

105+
# Fail closed on anything that is not an explicit allow.
106+
#
107+
# Enumerating only the bad decisions is a denylist: an unknown, misspelled,
108+
# absent, null, or newly-added decision value fell through to ALLOW and the
109+
# tool executed ungoverned. @sidclaw/sdk fixed this in 13c6ab3; this is the
110+
# Python port of that fix. The authoritative set is PolicyEffectValues =
111+
# ('allow', 'approval_required', 'deny').
112+
if decision.decision != "allow":
113+
raise ActionDeniedError(
114+
f"Unexpected policy decision: {decision.decision!r}",
115+
trace_id=decision.trace_id,
116+
policy_rule_id=decision.policy_rule_id,
117+
)
118+
81119
return decision
82120

83121

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""The guarded action runs only on an explicit ``allow``.
2+
3+
Two layers enforce this, and these tests cover both:
4+
5+
1. ``EvaluateResponse.decision`` is a ``Literal``, so pydantic rejects any
6+
value the SDK does not know about before middleware ever sees it.
7+
2. ``evaluate_governance_*`` re-checks for an explicit ``allow`` at the
8+
decision site.
9+
10+
Layer 2 looks redundant against layer 1 today — that is the point. The
11+
TypeScript SDK had only the equivalent of layer 2, wrote it as a denylist
12+
("raise on deny, raise on approval_required, otherwise proceed"), and shipped
13+
a live governance bypass: any unrecognised decision became ALLOW. Python is
14+
safe from that today only because of layer 1. The moment someone widens the
15+
``PolicyEffect`` literal to add a decision value, layer 1 stops rejecting it
16+
and layer 2 is the only thing standing between a new server decision and an
17+
ungoverned tool call. These tests fail if that guard is ever removed.
18+
"""
19+
20+
import httpx
21+
import pytest
22+
import respx
23+
24+
from sidclaw import AsyncSidClaw, SidClaw
25+
from sidclaw._errors import ActionDeniedError
26+
from sidclaw._types import EvaluateResponse
27+
from sidclaw.middleware._base import evaluate_governance_async, evaluate_governance_sync
28+
29+
30+
def _body(decision: str) -> dict:
31+
return {
32+
"decision": decision,
33+
"trace_id": "t-1",
34+
"approval_request_id": None,
35+
"reason": "because",
36+
"policy_rule_id": None,
37+
}
38+
39+
40+
@pytest.fixture
41+
def fc_client():
42+
return SidClaw(api_key="k", base_url="https://fc.api", agent_id="a", max_retries=0)
43+
44+
45+
@pytest.fixture
46+
def fc_async_client():
47+
return AsyncSidClaw(api_key="k", base_url="https://fc.api", agent_id="a", max_retries=0)
48+
49+
50+
@pytest.fixture
51+
def fc_mock():
52+
with respx.mock(base_url="https://fc.api") as m:
53+
yield m
54+
55+
56+
class TestLayerOneRejectsUnknownDecisions:
57+
"""An unrecognised decision never reaches middleware."""
58+
59+
@pytest.mark.parametrize("decision", ["quarantine", "log", "ALLOW", "Allow", "", "allow_once"])
60+
def test_unknown_decision_does_not_allow(self, fc_client, fc_mock, decision):
61+
fc_mock.post("/api/v1/evaluate").mock(return_value=httpx.Response(200, json=_body(decision)))
62+
# Must raise something. What matters is that it does NOT return and
63+
# let the caller proceed — that is the fail-open failure mode.
64+
with pytest.raises(Exception):
65+
evaluate_governance_sync(fc_client, "op")
66+
67+
def test_missing_decision_field_does_not_allow(self, fc_client, fc_mock):
68+
fc_mock.post("/api/v1/evaluate").mock(
69+
return_value=httpx.Response(200, json={"trace_id": "t-1", "reason": "no decision key"})
70+
)
71+
with pytest.raises(Exception):
72+
evaluate_governance_sync(fc_client, "op")
73+
74+
75+
class TestLayerTwoGuardsTheDecisionSite:
76+
"""The explicit-allow check, exercised directly.
77+
78+
``model_construct`` skips pydantic validation, which simulates the world
79+
where ``PolicyEffect`` has been widened and layer 1 no longer rejects the
80+
value. Without the guard in ``_base.py`` these calls return normally and
81+
the guarded action executes ungoverned.
82+
"""
83+
84+
@pytest.mark.parametrize("decision", ["quarantine", "log", "", "ALLOW"])
85+
def test_sync_raises_on_non_allow(self, fc_client, monkeypatch, decision):
86+
monkeypatch.setattr(
87+
fc_client,
88+
"evaluate",
89+
lambda _params: EvaluateResponse.model_construct(
90+
decision=decision, trace_id="t-1", approval_request_id=None, reason="r", policy_rule_id=None
91+
),
92+
)
93+
with pytest.raises(ActionDeniedError, match="Unexpected policy decision"):
94+
evaluate_governance_sync(fc_client, "op")
95+
96+
@pytest.mark.parametrize("decision", ["quarantine", "log", "", "ALLOW"])
97+
async def test_async_raises_on_non_allow(self, fc_async_client, monkeypatch, decision):
98+
async def _fake(_params):
99+
return EvaluateResponse.model_construct(
100+
decision=decision, trace_id="t-1", approval_request_id=None, reason="r", policy_rule_id=None
101+
)
102+
103+
monkeypatch.setattr(fc_async_client, "evaluate", _fake)
104+
with pytest.raises(ActionDeniedError, match="Unexpected policy decision"):
105+
await evaluate_governance_async(fc_async_client, "op")
106+
107+
108+
class TestKnownDecisionsStillBehave:
109+
"""The guard must not change the three documented outcomes."""
110+
111+
def test_allow_returns(self, fc_client, fc_mock):
112+
fc_mock.post("/api/v1/evaluate").mock(return_value=httpx.Response(200, json=_body("allow")))
113+
assert evaluate_governance_sync(fc_client, "op").decision == "allow"
114+
115+
def test_deny_raises(self, fc_client, fc_mock):
116+
fc_mock.post("/api/v1/evaluate").mock(return_value=httpx.Response(200, json=_body("deny")))
117+
with pytest.raises(ActionDeniedError):
118+
evaluate_governance_sync(fc_client, "op")
119+
120+
def test_approval_required_raises(self, fc_client, fc_mock):
121+
fc_mock.post("/api/v1/evaluate").mock(
122+
return_value=httpx.Response(200, json=_body("approval_required"))
123+
)
124+
with pytest.raises(ActionDeniedError, match="Approval required"):
125+
evaluate_governance_sync(fc_client, "op")
126+
127+
async def test_async_allow_returns(self, fc_async_client, fc_mock):
128+
fc_mock.post("/api/v1/evaluate").mock(return_value=httpx.Response(200, json=_body("allow")))
129+
result = await evaluate_governance_async(fc_async_client, "op")
130+
assert result.decision == "allow"

0 commit comments

Comments
 (0)