Skip to content

Commit 7ff21fb

Browse files
madhusudanpatnaikclaude
andcommitted
Reject unknown policy condition keys instead of silently ignoring them
The worst failure mode found in this codebase so far, because it is one keystroke away and completely invisible. Proved against a policy an operator would believe caps spending at $5,000: {"max_amount": 5000} $999,999 -> DENY (correct) {"max_ammount": 5000} $999,999 -> ALLOW {"maxAmount": 5000} $999,999 -> ALLOW {"require_approval_ovr": 5} $999,999 -> ALLOW Unrecognised keys are simply never read by _check_constraints, so for a security control they fail OPEN. The console authors `conditions` as a raw JSON textarea with no schema, validation or autocomplete, the policy saves with a 201, and the console renders it as an active constraint. Nothing anywhere reveals the ceiling is inert. It also removes a real inconsistency in the engine's own philosophy: a malformed VALUE already fails closed (`{"max_amount": "abc"}` routes to human review), while an unknown KEY failed open. Both are the same operator error. Validation lives on PolicyIn rather than in the route handlers, so every write path — create, update, preview and rollback — is covered by construction rather than by remembering to call it. Errors name the offending key, suggest the intended one via difflib, and list all six valid keys, because the person reading this is mid-typo and needs the fix, not a schema reference. Chose write-time rejection over a console form (which would leave Terraform/script/API authors unprotected — how production policies actually get written) and over warn-only (weaker than the fail-closed precedent the engine already sets for malformed values). 19 tests: every valid key accepted, a pinned assertion that the allowlist matches what the engine reads so the two can't drift, eight realistic typos rejected, the error message asserted to be actionable, a valid+invalid mix still rejected, and an end-to-end API check that the write is refused 422 while the correctly-spelled policy still creates. Existing seeds and demos needed no changes — they only ever used valid keys. 473 passed, 2 skipped; ruff- and mypy-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c2f4fed commit 7ff21fb

3 files changed

Lines changed: 155 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ have a chain worth retaining.
4242

4343
### Fixed
4444

45+
- **A typo in a policy condition key silently disabled that control** (high) —
46+
keys the engine doesn't recognise are never read, so for a security control
47+
they fail *open*: a policy written `{"max_ammount": 5000}` (one transposed
48+
letter, authored in the console's raw-JSON textarea) saved successfully,
49+
showed as active, and allowed a $999,999 action through what the operator
50+
believed was a $5,000 ceiling. Unknown condition keys are now rejected at
51+
write time with a "did you mean" suggestion and the list of valid keys. This
52+
also removes an inconsistency — a malformed *value* already failed closed
53+
while an unknown *key* failed open.
54+
- **An unusable vault key surfaced only on the first governed call** — seeding
55+
before setting `AGENTGUARD_SECRET_KEY` (the order the README implies) left
56+
connector credentials encrypted under the dev default; the server booted
57+
clean and failed later with "key rotated?" despite no rotation. Now checked
58+
at startup with an actionable message.
4559
- **Negative amounts bypassed spending ceilings and approval thresholds**
4660
(high) — every amount bound was `amount > limit`, so against a policy with
4761
`max_amount: 5000` and `require_approval_over: 500`, an `amount` of `-9500`

agentguard/schemas.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime
66
from typing import Any
77

8-
from pydantic import BaseModel, ConfigDict, Field
8+
from pydantic import BaseModel, ConfigDict, Field, field_validator
99

1010

1111
# --- Auth -------------------------------------------------------------------
@@ -67,6 +67,36 @@ class OrgUserIn(BaseModel):
6767

6868
# --- Roles & policies -------------------------------------------------------
6969

70+
# Every key the policy engine actually reads out of `conditions`. A key outside
71+
# this set is silently ignored at evaluation time, which for a security control
72+
# means it FAILS OPEN: `{"max_ammount": 5000}` (one typo) reads as "no ceiling",
73+
# and a $999,999 action sails through a policy the operator believes caps it at
74+
# $5,000. The console authors this field as a raw JSON textarea, so a typo is a
75+
# keystroke away and nothing downstream would ever surface it.
76+
#
77+
# Note the inconsistency this removes: a malformed VALUE (`{"max_amount":
78+
# "abc"}`) already fails closed to human review in engine._check_constraints,
79+
# while an unknown KEY failed open. Both are operator error; both should now be
80+
# caught, and catching this one at write time is strictly better than at
81+
# evaluation time because the operator is still looking at the policy.
82+
_VALID_POLICY_CONDITIONS = frozenset({
83+
"max_amount", # hard spending ceiling -> deny above
84+
"require_approval_over", # spending threshold -> human review above
85+
"rate_limit", # {"count": N, "per_seconds": S}
86+
"time_window", # {"start": "09:00", "end": "17:00"} UTC
87+
"attributes", # ABAC predicates on subject.* / resource.* / env.*
88+
"risk_step_up", # per-policy override of the global step-up threshold
89+
})
90+
91+
92+
def _suggest_condition_key(unknown: str) -> str | None:
93+
"""Best-effort 'did you mean' for a mistyped condition key."""
94+
import difflib
95+
96+
matches = difflib.get_close_matches(unknown, _VALID_POLICY_CONDITIONS, n=1, cutoff=0.6)
97+
return matches[0] if matches else None
98+
99+
70100
class PolicyIn(BaseModel):
71101
name: str = ""
72102
effect: str = Field(default="allow", pattern="^(allow|deny)$")
@@ -77,6 +107,23 @@ class PolicyIn(BaseModel):
77107
priority: int = 0
78108
enabled: bool = True
79109

110+
@field_validator("conditions")
111+
@classmethod
112+
def _reject_unknown_conditions(cls, v: dict[str, Any]) -> dict[str, Any]:
113+
unknown = sorted(set(v) - _VALID_POLICY_CONDITIONS)
114+
if not unknown:
115+
return v
116+
details = []
117+
for key in unknown:
118+
suggestion = _suggest_condition_key(key)
119+
details.append(f"{key!r}" + (f" (did you mean {suggestion!r}?)" if suggestion else ""))
120+
raise ValueError(
121+
"unknown policy condition key(s): " + ", ".join(details)
122+
+ ". An unrecognised key is ignored when the policy is evaluated, so the "
123+
"constraint you intended would not be enforced. Valid keys: "
124+
+ ", ".join(sorted(_VALID_POLICY_CONDITIONS))
125+
)
126+
80127

81128
class PolicyOut(PolicyIn):
82129
model_config = ConfigDict(from_attributes=True)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Unknown policy condition keys must be rejected at write time.
2+
3+
An unrecognised key in `conditions` is simply never read by the policy engine,
4+
so for a security control it FAILS OPEN. Proved before this validation existed,
5+
against a policy the operator believed capped spending at $5,000:
6+
7+
{"max_amount": 5000} -> $999,999 DENY (correct)
8+
{"max_ammount": 5000} -> $999,999 ALLOW (one typo, ceiling gone)
9+
{"maxAmount": 5000} -> $999,999 ALLOW
10+
{"require_approval_ovr": 5} -> $999,999 ALLOW
11+
12+
The console authors this field as a raw JSON textarea, so the typo is one
13+
keystroke away, the policy saves successfully, and nothing downstream ever
14+
reveals that the constraint is inert.
15+
16+
This also removes an inconsistency: a malformed VALUE already failed closed
17+
(`{"max_amount": "abc"}` routes to human review in _check_constraints), while
18+
an unknown KEY failed open.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import pytest
24+
from pydantic import ValidationError
25+
26+
from agentguard.schemas import _VALID_POLICY_CONDITIONS, PolicyIn
27+
28+
29+
@pytest.mark.parametrize("key", sorted(_VALID_POLICY_CONDITIONS))
30+
def test_every_documented_key_is_accepted(key):
31+
"""The allowlist must not reject a key the engine actually honours."""
32+
assert PolicyIn(conditions={key: {}}).conditions == {key: {}}
33+
34+
35+
def test_allowlist_matches_what_the_engine_reads():
36+
"""Guard against the allowlist and the engine drifting apart.
37+
38+
If someone teaches the engine a new condition key without adding it here,
39+
that key becomes un-writable through the API; if they remove one, a dead
40+
key stays writable. Both are caught by pinning the set explicitly.
41+
"""
42+
assert _VALID_POLICY_CONDITIONS == {
43+
"max_amount", "require_approval_over", "rate_limit",
44+
"time_window", "attributes", "risk_step_up",
45+
}
46+
47+
48+
@pytest.mark.parametrize("bad", [
49+
"max_ammount", "maxAmount", "max_amount_usd", "require_approval_ovr",
50+
"MAX_AMOUNT", "rate_limits", "timewindow", "wibble",
51+
])
52+
def test_unknown_keys_are_rejected(bad):
53+
with pytest.raises(ValidationError):
54+
PolicyIn(conditions={bad: 5000})
55+
56+
57+
def test_error_names_the_key_the_valid_set_and_a_suggestion():
58+
"""The message has to be actionable — this is an operator typo, at 2am."""
59+
with pytest.raises(ValidationError) as exc:
60+
PolicyIn(conditions={"max_ammount": 5000})
61+
msg = str(exc.value)
62+
assert "max_ammount" in msg, "must name the offending key"
63+
assert "did you mean 'max_amount'" in msg, "must suggest the intended key"
64+
assert "max_amount" in msg and "rate_limit" in msg, "must list valid keys"
65+
66+
67+
def test_a_valid_key_alongside_a_typo_still_rejects():
68+
"""Partial correctness must not mask the broken half."""
69+
with pytest.raises(ValidationError):
70+
PolicyIn(conditions={"max_amount": 5000, "require_approval_ovr": 500})
71+
72+
73+
def test_empty_conditions_are_fine():
74+
assert PolicyIn(conditions={}).conditions == {}
75+
76+
77+
def test_rejected_over_the_api(client, admin_headers):
78+
"""End-to-end: the API refuses the write rather than storing a dead policy."""
79+
role = client.post("/api/roles", json={"name": "typo-role"},
80+
headers=admin_headers).json()
81+
r = client.post(f"/api/roles/{role['id']}/policies", headers=admin_headers,
82+
json={"effect": "allow", "resource": "payment:**",
83+
"actions": ["payment.refund"],
84+
"conditions": {"max_ammount": 5000}})
85+
assert r.status_code == 422, r.text
86+
assert "max_ammount" in r.text
87+
88+
# And the correctly-spelled policy is still accepted.
89+
ok = client.post(f"/api/roles/{role['id']}/policies", headers=admin_headers,
90+
json={"effect": "allow", "resource": "payment:**",
91+
"actions": ["payment.refund"],
92+
"conditions": {"max_amount": 5000}})
93+
assert ok.status_code == 201, ok.text

0 commit comments

Comments
 (0)