Skip to content

Commit 6e538d4

Browse files
committed
fix: address imran-siddique review - empty sig, bounded lists, links, version
- Empty signature/publicKey now returns valid:False (security fix) - All internal lists bounded to MAX_LOG=10000 (prevents memory leak) - README links use correct case: ScopeBlind/scopeblind-gateway - pyproject.toml adds Tom Farley as individual author - Version reference updated from 0.4.6 to 0.5.2 - 56 tests passing
1 parent 9a3be8a commit 6e538d4

File tree

4 files changed

+58
-28
lines changed

4 files changed

+58
-28
lines changed

packages/agentmesh-integrations/scopeblind-protect-mcp/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ protect-mcp is a security gateway that wraps any MCP server with:
1111
- **Issuer-blind verification** (verifier can confirm receipt validity without learning who issued it)
1212
- **Spending authority** (prove an agent's purchase is authorized without revealing org details)
1313

14-
Published on npm: `npx protect-mcp@latest` | [GitHub](https://github.com/scopeblind/scopeblind-gateway) | [Docs](https://scopeblind.com/docs/protect-mcp)
14+
Published on npm: `npx protect-mcp@latest` | [GitHub](https://github.com/ScopeBlind/scopeblind-gateway) | [Docs](https://scopeblind.com/docs/protect-mcp)
1515

1616
## How it complements AGT
1717

packages/agentmesh-integrations/scopeblind-protect-mcp/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ readme = "README.md"
1010
license = "MIT"
1111
requires-python = ">=3.9"
1212
authors = [
13+
{ name = "Tom Farley" },
1314
{ name = "ScopeBlind Pty Ltd" }
1415
]
1516
keywords = ["mcp", "cedar", "policy", "receipts", "agentmesh", "scopeblind", "trust"]

packages/agentmesh-integrations/scopeblind-protect-mcp/scopeblind_protect_mcp/adapter.py

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,21 @@ class CedarPolicyBridge:
9898
behavioral trust on top.
9999
"""
100100

101+
MAX_HISTORY = 10000 # Prevent unbounded memory growth
102+
101103
def __init__(
102104
self,
103105
trust_floor: int = 0,
104106
trust_bonus_per_allow: int = 50,
105107
deny_penalty: int = 200,
106108
require_receipt: bool = False,
109+
max_history: int = MAX_HISTORY,
107110
):
108111
self.trust_floor = trust_floor
109112
self.trust_bonus = trust_bonus_per_allow
110113
self.deny_penalty = deny_penalty
111114
self.require_receipt = require_receipt
115+
self._max_history = max_history
112116
self._history: List[Dict[str, Any]] = []
113117
self._history_lock = threading.Lock()
114118

@@ -141,8 +145,7 @@ def evaluate(
141145
result["allowed"] = False
142146
result["reason"] = "Decision receipt required but not provided"
143147
result["adjusted_trust"] = max(0, agent_trust_score - self.deny_penalty)
144-
with self._history_lock:
145-
self._history.append(result)
148+
self._record(result)
146149
return result
147150

148151
# Cedar deny is authoritative — not overridable by trust score
@@ -153,8 +156,7 @@ def evaluate(
153156
f"(policies: {cedar_decision.policy_ids})"
154157
)
155158
result["adjusted_trust"] = max(0, agent_trust_score - self.deny_penalty)
156-
with self._history_lock:
157-
self._history.append(result)
159+
self._record(result)
158160
return result
159161

160162
# Cedar allow — layer AGT trust check
@@ -165,8 +167,7 @@ def evaluate(
165167
f"Cedar allowed but trust score {adjusted} below floor {self.trust_floor}"
166168
)
167169
result["adjusted_trust"] = adjusted
168-
with self._history_lock:
169-
self._history.append(result)
170+
self._record(result)
170171
return result
171172

172173
result["allowed"] = True
@@ -182,6 +183,13 @@ def evaluate(
182183
self._history.append(result)
183184
return result
184185

186+
def _record(self, entry: Dict[str, Any]) -> None:
187+
"""Append to history with bounded size to prevent memory leaks."""
188+
with self._history_lock:
189+
self._history.append(entry)
190+
if len(self._history) > self._max_history:
191+
self._history = self._history[-self._max_history:]
192+
185193
def get_history(self) -> List[Dict[str, Any]]:
186194
with self._history_lock:
187195
return list(self._history)
@@ -236,8 +244,11 @@ class ReceiptVerifier:
236244
"acta:artifact",
237245
}
238246

239-
def __init__(self, strict: bool = True):
247+
MAX_LOG = 10000 # Prevent unbounded memory growth
248+
249+
def __init__(self, strict: bool = True, max_log: int = MAX_LOG):
240250
self.strict = strict
251+
self._max_log = max_log
241252
self._verified: List[Dict[str, Any]] = []
242253
self._verified_lock = threading.Lock()
243254

@@ -277,14 +288,26 @@ def validate_structure_only(self, receipt: Dict[str, Any]) -> Dict[str, Any]:
277288
if not isinstance(payload, dict):
278289
return {"valid": False, "reason": "Payload must be an object"}
279290

291+
# Empty signature/publicKey should not pass as valid
292+
sig = receipt.get("signature", "")
293+
pk = receipt.get("publicKey", "")
294+
if not sig or not pk:
295+
return {
296+
"valid": False,
297+
"reason": "Empty signature or publicKey (cryptographic fields must be non-empty)",
298+
"receipt_type": receipt_type,
299+
"has_signature": bool(sig),
300+
"has_public_key": bool(pk),
301+
}
302+
280303
result = {
281304
"valid": True,
282305
"receipt_type": receipt_type,
283306
"tool": payload.get("tool", payload.get("resource", "")),
284307
"decision": payload.get("effect", payload.get("decision", "")),
285308
"timestamp": payload.get("timestamp"),
286-
"has_signature": bool(receipt.get("signature")),
287-
"has_public_key": bool(receipt.get("publicKey")),
309+
"has_signature": True,
310+
"has_public_key": True,
288311
}
289312

290313
# Spending authority specific fields
@@ -296,6 +319,8 @@ def validate_structure_only(self, receipt: Dict[str, Any]) -> Dict[str, Any]:
296319

297320
with self._verified_lock:
298321
self._verified.append(result)
322+
if len(self._verified) > self._max_log:
323+
self._verified = self._verified[-self._max_log:]
299324
return result
300325

301326
def to_agt_context(self, receipt: Dict[str, Any]) -> Dict[str, Any]:
@@ -350,15 +375,19 @@ class SpendingGate:
350375

351376
UTILIZATION_BANDS = {"low", "medium", "high", "exceeded"}
352377

378+
MAX_LOG = 10000 # Prevent unbounded memory growth
379+
353380
def __init__(
354381
self,
355382
max_single_amount: float = 10000.0,
356383
high_util_trust_floor: int = 500,
357384
blocked_categories: Optional[List[str]] = None,
385+
max_log: int = MAX_LOG,
358386
):
359387
self.max_single_amount = max_single_amount
360388
self.high_util_trust_floor = high_util_trust_floor
361389
self.blocked_categories = set(blocked_categories or [])
390+
self._max_log = max_log
362391
self._decisions: List[Dict[str, Any]] = []
363392
self._decisions_lock = threading.Lock()
364393

@@ -398,38 +427,33 @@ def evaluate_spend(
398427
f"Amount {amount} {currency} exceeds single-transaction "
399428
f"limit of {self.max_single_amount} {currency}"
400429
)
401-
with self._decisions_lock:
402-
self._decisions.append(result)
430+
self._record(result)
403431
return result
404432

405433
if amount <= 0:
406434
result["allowed"] = False
407435
result["reason"] = "Amount must be positive"
408-
with self._decisions_lock:
409-
self._decisions.append(result)
436+
self._record(result)
410437
return result
411438

412439
# 2. Category check
413440
if category in self.blocked_categories:
414441
result["allowed"] = False
415442
result["reason"] = f"Category '{category}' is blocked"
416-
with self._decisions_lock:
417-
self._decisions.append(result)
443+
self._record(result)
418444
return result
419445

420446
# 3. Utilization + trust
421447
if utilization_band not in self.UTILIZATION_BANDS:
422448
result["allowed"] = False
423449
result["reason"] = f"Invalid utilization band: {utilization_band}"
424-
with self._decisions_lock:
425-
self._decisions.append(result)
450+
self._record(result)
426451
return result
427452

428453
if utilization_band == "exceeded":
429454
result["allowed"] = False
430455
result["reason"] = "Budget utilization exceeded"
431-
with self._decisions_lock:
432-
self._decisions.append(result)
456+
self._record(result)
433457
return result
434458

435459
if utilization_band == "high" and agent_trust_score < self.high_util_trust_floor:
@@ -438,8 +462,7 @@ def evaluate_spend(
438462
f"High utilization requires trust score >= {self.high_util_trust_floor} "
439463
f"(current: {agent_trust_score})"
440464
)
441-
with self._decisions_lock:
442-
self._decisions.append(result)
465+
self._record(result)
443466
return result
444467

445468
# 4. Receipt check for high-value transactions
@@ -448,8 +471,7 @@ def evaluate_spend(
448471
result["reason"] = (
449472
f"Transactions above 1000 {currency} require a spending authority receipt"
450473
)
451-
with self._decisions_lock:
452-
self._decisions.append(result)
474+
self._record(result)
453475
return result
454476

455477
result["allowed"] = True
@@ -463,6 +485,13 @@ def evaluate_spend(
463485
self._decisions.append(result)
464486
return result
465487

488+
def _record(self, entry: Dict[str, Any]) -> None:
489+
"""Append to decisions with bounded size to prevent memory leaks."""
490+
with self._decisions_lock:
491+
self._decisions.append(entry)
492+
if len(self._decisions) > self._max_log:
493+
self._decisions = self._decisions[-self._max_log:]
494+
466495
def get_decisions(self) -> List[Dict[str, Any]]:
467496
with self._decisions_lock:
468497
return list(self._decisions)
@@ -509,7 +538,7 @@ def scopeblind_context(
509538
"""
510539
ctx: Dict[str, Any] = {
511540
"source": "scopeblind:protect-mcp",
512-
"version": "0.4.6",
541+
"version": "0.5.2",
513542
}
514543

515544
if cedar_decision is not None:

packages/agentmesh-integrations/scopeblind-protect-mcp/tests/test_scopeblind_adapter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,8 @@ def test_receipt_payload_is_list(self):
383383
result = verifier.validate_structure_only(receipt)
384384
assert result["valid"] is False
385385

386-
def test_receipt_null_signature(self):
387-
"""Signature present but empty string should still pass structure check."""
386+
def test_receipt_empty_signature_rejected(self):
387+
"""Empty signature string should be rejected as invalid."""
388388
verifier = ReceiptVerifier()
389389
receipt = {
390390
"type": "scopeblind:decision",
@@ -393,7 +393,7 @@ def test_receipt_null_signature(self):
393393
"publicKey": "pk",
394394
}
395395
result = verifier.validate_structure_only(receipt)
396-
assert result["valid"] is True
396+
assert result["valid"] is False
397397
assert result["has_signature"] is False
398398

399399
def test_to_agt_context_malformed_receipt(self):

0 commit comments

Comments
 (0)