Skip to content
Open
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
18 changes: 13 additions & 5 deletions agentshield/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def evaluate(self, transaction: dict, rules: list, prior_transactions: list) ->
"severity": "high"
}

# Ensure timestamp exists, default to now if missing
if 'timestamp' not in transaction or not transaction['timestamp']:
transaction['timestamp'] = datetime.now(timezone.utc).isoformat()

# Validate amount is parseable as a number
try:
txn_amount = self._to_decimal(transaction['amount'])
Expand Down Expand Up @@ -421,15 +425,15 @@ def _check_velocity(self, rule_id: str, transaction: dict, prior_transactions: l
def _check_merchant_allowlist(self, rule_id: str, transaction: dict, params: dict, action: str):
allowed = params.get('allowed', [])
merchant = transaction.get('merchant')
if merchant and merchant not in allowed:
if merchant is not None and merchant not in allowed:
return self._make_result(action, rule_id,
f"Merchant '{merchant}' is not in the allowlist")
return None

def _check_category_block(self, rule_id: str, transaction: dict, params: dict, action: str):
blocked = params.get('blocked', [])
category = transaction.get('category')
if category and category in blocked:
if category is not None and category in blocked:
return self._make_result(action, rule_id,
f"Category '{category}' is blocked")
return None
Expand Down Expand Up @@ -459,12 +463,13 @@ def _check_session_budget(self, rule_id: str, transaction: dict, txn_amount: Dec
return None

session_field = params.get('session_id', 'session_id')
session_id = transaction.get(session_field)
raw_session_id = transaction.get(session_field)
session_id = raw_session_id if raw_session_id is not None else 'default_session'
agent_id = transaction.get('agent_id')
decay_factor = params.get('decay_factor')

# Strict guardrail: session identity is mandatory for budget tracking.
if params.get('require_session_id') and session_id is None:
if params.get('require_session_id') and raw_session_id is None:
return self._make_result(
action, rule_id,
f"session_id is required for session_budget tracking "
Expand All @@ -478,7 +483,10 @@ def _check_session_budget(self, rule_id: str, transaction: dict, txn_amount: Dec
for prior in prior_transactions:
if prior.get('agent_id') != agent_id:
continue
if prior.get(session_field) == session_id:
prior_session = prior.get(session_field, 'default_session')
if prior_session is None:
prior_session = 'default_session'
if session_id == prior_session:
prior_amount = self._to_decimal_safe(prior.get('amount'))
if prior_amount is not None and prior_amount > 0:
session_total += prior_amount
Expand Down
103 changes: 103 additions & 0 deletions tests/bounty_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import unittest
from agentshield.engine import SpendControlEngine
from decimal import Decimal
from datetime import datetime, timezone

class TestBountyBypasses(unittest.TestCase):
def setUp(self):
self.engine = SpendControlEngine()

def test_merchant_allowlist_empty_string_bypass(self):
"""Bypass 1: Empty merchant string bypasses allowlist."""
rules = [{
"id": "rule_1",
"type": "merchant_allowlist",
"priority": 1,
"params": {"allowed": ["TrustedCorp"]},
"action": "BLOCK"
}]
transaction = {
"amount": "100.00",
"merchant": "", # Should be blocked, but is approved
"category": "software"
}
result = self.engine.evaluate(transaction, rules, [])
self.assertEqual(result['decision'], 'BLOCKED', "Empty merchant should be blocked by allowlist")

def test_category_block_empty_string_bypass(self):
"""Bypass 2: Empty category string bypasses blocklist if explicitly blocked."""
rules = [{
"id": "rule_2",
"type": "category_block",
"priority": 1,
"params": {"blocked": [""]},
"action": "BLOCK"
}]
transaction = {
"amount": "100.00",
"merchant": "Casino",
"category": ""
}
result = self.engine.evaluate(transaction, rules, [])
self.assertEqual(result['decision'], 'BLOCKED', "Empty category should be blocked if explicitly in blocklist")

def test_daily_total_missing_timestamp_bypass(self):
"""Bypass 3: Missing timestamp bypasses daily total limits."""
rules = [{
"id": "rule_3",
"type": "daily_total",
"priority": 1,
"params": {"max_daily": "100.00"},
"action": "BLOCK"
}]
prior = [{"amount": "80.00", "merchant": "A", "category": "B", "timestamp": datetime.now(timezone.utc).isoformat()}]
transaction = {
"amount": "50.00",
"merchant": "A",
"category": "B"
# Missing timestamp
}
result = self.engine.evaluate(transaction, rules, prior)
self.assertEqual(result['decision'], 'BLOCKED', "Missing timestamp should not bypass daily total")

def test_cascade_cost_negative_probability_bypass(self):
"""Bypass 4: Negative failure probability bypasses cascade cost limits."""
rules = [{
"id": "rule_4",
"type": "cascade_cost",
"priority": 1,
"params": {"max_cascade_cost": "50.00", "reversal_cost": "100.00"},
"action": "BLOCK"
}]
# Without fix, 60 + (-1.0 * 100) = -40 (Approved)
# With fix, 60 + (0.0 * 100) = 60 (Blocked)
transaction = {
"amount": "60.00",
"merchant": "A",
"category": "B",
"fail_probability": "-1.0"
}
result = self.engine.evaluate(transaction, rules, [])
self.assertEqual(result['decision'], 'BLOCKED', "Negative fail_probability should be clamped to 0")

def test_session_reset_bypass(self):
"""Bypass 5: Missing session_id allows resetting session budget per call."""
rules = [{
"id": "rule_5",
"type": "session_budget",
"priority": 1,
"params": {"max_session": "100.00"},
"action": "BLOCK"
}]
prior = [{"amount": "80.00", "merchant": "A", "category": "B"}] # Missing session_id
transaction = {
"amount": "50.00",
"merchant": "A",
"category": "B"
# Missing session_id
}
result = self.engine.evaluate(transaction, rules, prior)
self.assertEqual(result['decision'], 'BLOCKED', "Missing session_id should not reset budget")

if __name__ == '__main__':
unittest.main()