Skip to content

Commit 42c0e59

Browse files
madhusudanpatnaikclaude
andcommitted
Stop negative amounts bypassing spending ceilings and approvals
Found while auditing the risk engine. Verified against the exact policy the shipped pitch demo uses (max_amount=5000, require_approval_over=500): amount= 9500 -> DENY (correct) amount=-9500 -> ALLOW (no ceiling, no human, no risk score) Both bounds in _check_constraints are `amount > limit`, so any negative value cleared the hard ceiling AND the human-review threshold simultaneously. risk.py independently gated its score on `amount > 0`, so the same request also scored zero risk — the two systems that should have caught it failed the same way for the same reason. Nothing validates the sign anywhere, including the request schema. Some payment APIs treat a negative refund as a charge, which turns this from a nonsense value into a transfer in the opposite direction, unbounded. The fix is what the engine's own comment already claimed it did. That comment reads "a policy that constrains amount must not be bypassable by simply omitting (or malforming) the amount — route such actions to a human", but only the `amount is None` case was implemented; a negative parses fine through _coerce_number and sailed past. Negative amounts now take that same already-prescribed route rather than inventing sign semantics — notably NOT by silently comparing abs(), which would reinterpret the caller's intent and execute a transfer they did not ask for. risk.py now scores by magnitude (abs) rather than signed value, so -9500 and +9500 carry identical exposure weight and the risk signal agrees with the policy decision instead of calling the negative case harmless. 11 regression tests covering the bypass values, that positive amounts are completely unchanged (250 allow / 501 approval / 5001 deny), that the pre-existing missing-amount rule still holds, that 0 is NOT swept up as negative, and that a policy with no amount condition doesn't suddenly start demanding approval. 450 passed, 2 skipped; ruff- and mypy-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18f1a3e commit 42c0e59

4 files changed

Lines changed: 118 additions & 2 deletions

File tree

CHANGELOG.md

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

4343
### Fixed
4444

45+
- **Negative amounts bypassed spending ceilings and approval thresholds**
46+
(high) — every amount bound was `amount > limit`, so against a policy with
47+
`max_amount: 5000` and `require_approval_over: 500`, an `amount` of `-9500`
48+
was ALLOWED outright while `+9500` was correctly denied: no ceiling, no
49+
human, and no risk score (which separately gated on `amount > 0`). Payment
50+
APIs that read a negative refund as a charge turn this into a transfer the
51+
ceiling exists to prevent. Negative amounts now take the same route the
52+
engine already prescribed for a malformed amount — human review — and the
53+
risk score measures exposure by magnitude.
4554
- **Cross-tenant IDOR in SCIM provisioning** (critical) — every SCIM endpoint
4655
operated on the user table by raw primary key with no `org_id` scoping, behind
4756
a single deployment-wide bearer token. One tenant's IdP token could list,

agentguard/policy/engine.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,24 @@ def _check_constraints(
321321
"amount — routed to human review.",
322322
)
323323

324+
# A NEGATIVE amount is malformed for this purpose too, and used to sail
325+
# straight through: both bounds below are `amount > limit`, so -9500
326+
# cleared a max_amount of 5000 AND a require_approval_over of 500 with
327+
# no ceiling and no human, while +9500 was correctly denied. Some
328+
# payment APIs also treat a negative refund as a charge, which turns
329+
# the bypass into a transfer in the opposite direction. There is no
330+
# legitimate negative value for a spend ceiling to bound, so this takes
331+
# the same route the comment above already prescribes for a malformed
332+
# amount rather than inventing sign semantics (e.g. silently comparing
333+
# abs(), which would reinterpret the caller's intent).
334+
if amount_constrained and amount is not None and amount < 0:
335+
return (
336+
Decision.REQUIRE_APPROVAL,
337+
f"Amount-constrained action submitted with a negative amount "
338+
f"({amount:g}) — cannot be bounded by a spending limit, routed "
339+
"to human review.",
340+
)
341+
324342
# All operator-supplied numeric conditions are parsed with the fail-safe
325343
# coercer and fail CLOSED on a malformed value — a bad condition must
326344
# never raise on the authorize hot path (that would 500 the decision and

agentguard/risk.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,14 @@ def assess(db: Session, agent: Agent, req: ActionRequest, decision: PolicyDecisi
8787
factors.append("egress")
8888

8989
amount = _coerce_amount((req.metadata or {}).get("amount"))
90-
if amount is not None and amount > 0:
91-
bump = min(int(amount / 1000 * 5), 20)
90+
# Magnitude, not signed value: `amount > 0` meant a -9500 refund scored the
91+
# same zero risk as no amount at all, even though its exposure is identical
92+
# to +9500 (and some payment APIs read a negative refund as a charge). The
93+
# policy engine routes negative amounts to human review; the risk score
94+
# should agree with that rather than call them harmless.
95+
if amount is not None and amount != 0:
96+
exposure = abs(amount)
97+
bump = min(int(exposure / 1000 * 5), 20)
9298
if bump:
9399
score += bump
94100
factors.append(f"amount:{amount:g}")

tests/test_negative_amount.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Negative amounts must not bypass spending ceilings or approval thresholds.
2+
3+
Verified bypass before this fix, against the exact policy the shipped pitch
4+
demo uses (max_amount=5000, require_approval_over=500):
5+
6+
amount= 9500 -> DENY (correct)
7+
amount=-9500 -> ALLOW (no ceiling, no human, no risk score)
8+
9+
Both bounds in _check_constraints are `amount > limit`, so any negative value
10+
cleared both. risk.py additionally gated its score on `amount > 0`, so the same
11+
request also scored zero risk. Some payment APIs treat a negative refund as a
12+
charge, which turns this from a nonsense value into a transfer the ceiling was
13+
meant to prevent.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import pytest
19+
20+
from agentguard.dlp.scanner import DLPResult
21+
from agentguard.models import Decision, Effect, Policy
22+
from agentguard.policy.engine import ActionRequest, PolicyEngine
23+
24+
CEILING, REVIEW_OVER = 5000, 500
25+
26+
27+
def _policy() -> Policy:
28+
return Policy(
29+
id=1, name="refunds", effect=Effect.ALLOW, resource="payment:**",
30+
actions=["payment.refund"], enabled=True, priority=0,
31+
conditions={"max_amount": CEILING, "require_approval_over": REVIEW_OVER},
32+
)
33+
34+
35+
def _decide(amount):
36+
req = ActionRequest(action_type="payment.refund",
37+
resource="payment:stripe:refund",
38+
metadata={} if amount is None else {"amount": amount})
39+
return PolicyEngine().evaluate(req, [_policy()],
40+
dlp=DLPResult(findings=[], redacted=None))
41+
42+
43+
@pytest.mark.parametrize("amount", [-1, -0.01, -500, -9500, -1_000_000, "-9500"])
44+
def test_negative_amounts_never_pass_silently(amount):
45+
"""The bypass: any of these previously returned ALLOW."""
46+
assert _decide(amount).decision == Decision.REQUIRE_APPROVAL
47+
48+
49+
def test_positive_amounts_are_unchanged():
50+
"""The fix must not alter correct existing behaviour."""
51+
assert _decide(250).decision == Decision.ALLOW
52+
assert _decide(REVIEW_OVER + 1).decision == Decision.REQUIRE_APPROVAL
53+
assert _decide(CEILING + 1).decision == Decision.DENY
54+
55+
56+
def test_missing_amount_still_routes_to_human():
57+
"""Pre-existing fail-closed rule this fix is modelled on."""
58+
assert _decide(None).decision == Decision.REQUIRE_APPROVAL
59+
60+
61+
def test_zero_is_allowed_not_treated_as_negative():
62+
"""0 is a bounded, verifiable amount — it must not be swept up by the fix."""
63+
assert _decide(0).decision == Decision.ALLOW
64+
65+
66+
def test_unconstrained_policy_ignores_amount_sign():
67+
"""A policy with no amount condition must not suddenly demand approval."""
68+
p = Policy(id=2, name="open", effect=Effect.ALLOW, resource="payment:**",
69+
actions=["payment.refund"], enabled=True, priority=0, conditions={})
70+
req = ActionRequest(action_type="payment.refund", resource="payment:x",
71+
metadata={"amount": -9500})
72+
assert PolicyEngine().evaluate(
73+
req, [p], dlp=DLPResult(findings=[], redacted=None)).decision == Decision.ALLOW
74+
75+
76+
def test_risk_scores_negative_amount_by_magnitude():
77+
"""risk.py gated on `amount > 0`, so -9500 scored as if there were no amount."""
78+
from agentguard.risk import _coerce_amount
79+
80+
assert _coerce_amount(-9500) == -9500.0
81+
# The scoring path is exercised via assess(); here we assert the property
82+
# that motivated the change: exposure is magnitude, not signed value.
83+
assert abs(_coerce_amount(-9500)) == abs(_coerce_amount(9500))

0 commit comments

Comments
 (0)