From 9e8dc9b3bfd38cc1d2b1677ed7fcbbbaab7f81cd Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 14:22:08 +0100 Subject: [PATCH 01/12] feat(examples): African regulatory guardrails across 4 jurisdictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds examples/agent_patterns/african_regulatory_guardrails.py demonstrating jurisdiction-aware input guardrails for AI agents in African financial markets. Regulations enforced (pure stdlib — no external dependencies): Nigeria CBN NIP Framework (CBN Circular FPR/DIR/GEN/CIR/07/003) ₦10,000,000 single-transaction cap NDPA 2023 Schedule 1 — BVN as biometric personal data Kenya Data Protection Act 2019 s.49 Cross-border transfer blocked without documented consent Authority: Office of the Data Protection Commissioner (ODPC) Ghana Data Protection Act 2012 (Act 843) s.18(2) Cross-border transfer adequacy requirement NIA Act 707 — Ghana Card national ID (GHA-XXXXXXXXX-X) Authority: Data Protection Commission (DPC) South Africa POPIA (Protection of Personal Information Act 4 of 2013) s.26(1)(f) biometric data; s.19 SA ID number (13-digit) Authority: Information Regulator (South Africa) --- .../african_regulatory_guardrails.py | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 examples/agent_patterns/african_regulatory_guardrails.py diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py new file mode 100644 index 0000000000..aa2b3600ba --- /dev/null +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass +from typing import Literal + +from pydantic import BaseModel + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, + input_guardrail, +) +from examples.auto_mode import input_with_fallback, is_auto_mode + +""" +This example shows how to implement jurisdiction-aware compliance guardrails +for AI agents operating in African financial markets. + +Four jurisdictions are covered using only stdlib Python — no external packages: + + Nigeria CBN NIP Framework (CBN Circular FPR/DIR/GEN/CIR/07/003) + ₦10,000,000 single-transaction cap; BVN biometric data protection + (NDPA 2023 Schedule 1 / CBN BVN Policy Framework 2014, updated 2023) + + Kenya Data Protection Act 2019 s.49 + Cross-border transfer blocked without documented consent + (Enforcing authority: Office of the Data Protection Commissioner) + + Ghana Data Protection Act 2012 (Act 843) s.18(2) + Cross-border transfer requires adequacy or consent; + NIA Act 707 — Ghana Card national ID (GHA-XXXXXXXXX-X) detection + (Enforcing authority: Data Protection Commission) + + South Africa POPIA (Protection of Personal Information Act 4 of 2013) + s.26(1)(f) — biometric personal information; + s.19 — SA ID number (YYMMDD + 7 digits) + (Enforcing authority: Information Regulator, South Africa) + +The guardrail reads `context.jurisdiction` from the AgentContext to apply the +correct jurisdiction's rules, then trips the guardrail with a structured +ComplianceOutput describing the violation. +""" + + +# ── Compliance models ───────────────────────────────────────────────────────── + + +class ComplianceViolation(BaseModel): + jurisdiction: str + regulation: str + section: str + authority: str + decision: Literal["deny", "escalate"] + message: str + + +class ComplianceOutput(BaseModel): + violated: bool + violations: list[ComplianceViolation] + + +# ── Agent context ───────────────────────────────────────────────────────────── + + +@dataclass +class AgentContext: + jurisdiction: str # "NG" | "KE" | "GH" | "ZA" + kyc_tier: int # 1 | 2 | 3 (CBN tiered KYC — Nigeria only) + consent_documented: bool # cross-border transfer consent on file + + +# ── Nigeria: CBN NIP Framework ──────────────────────────────────────────────── +# CBN Circular FPR/DIR/GEN/CIR/07/003 — Tiered KYC transaction limits +# CBN NIP (NIBSS Instant Payment) Framework — ₦10,000,000 per-transaction cap +# NDPA 2023 Schedule 1 / CBN BVN Policy Framework — BVN as biometric data + +_NGN_AMOUNT_RE = re.compile(r"(?:₦|NGN)\s*([\d,]+(?:\.\d{1,2})?)") +_NGN_BVN_RE = re.compile(r"(?i)(bvn|bank\s+verification).{0,20}\b[0-9]{11}\b") +_CBN_NIP_CAP = 10_000_000 + + +def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: + violations: list[ComplianceViolation] = [] + + for match in _NGN_AMOUNT_RE.finditer(text): + try: + amount = float(match.group(1).replace(",", "")) + except ValueError: + continue + if amount > _CBN_NIP_CAP: + violations.append( + ComplianceViolation( + jurisdiction="NG", + regulation="CBN NIP Framework", + section="CBN Circular FPR/DIR/GEN/CIR/07/003", + authority="Central Bank of Nigeria (CBN)", + decision="deny", + message=( + f"CBN NIP Framework: Transfer of ₦{amount:,.0f} exceeds the " + f"₦10,000,000 single-transaction cap" + ), + ) + ) + + if _NGN_BVN_RE.search(text): + violations.append( + ComplianceViolation( + jurisdiction="NG", + regulation="NDPA 2023 / CBN BVN Policy Framework", + section="NDPA 2023 Schedule 1", + authority="Nigeria Data Protection Commission (NDPC)", + decision="deny", + message=( + "BVN Protection: BVN value detected in agent output — blocked " + "(NDPA 2023 Schedule 1 classifies BVN as biometric personal data)" + ), + ) + ) + + return violations + + +# ── Kenya: Data Protection Act 2019 ────────────────────────────────────────── +# s.49 — Cross-border transfer requires documented consent or ODPC adequacy +# Enforcing authority: Office of the Data Protection Commissioner (ODPC) + +_KE_CROSS_BORDER_RE = re.compile( + r"(?i)(send|transfer|export|upload|forward).{0,60}" + r"(outside\s+kenya|cross.?border|international|offshore|us-east|eu-west)" +) + + +def _check_kenya(text: str, ctx: AgentContext) -> list[ComplianceViolation]: + violations: list[ComplianceViolation] = [] + + if _KE_CROSS_BORDER_RE.search(text) and not ctx.consent_documented: + violations.append( + ComplianceViolation( + jurisdiction="KE", + regulation="Kenya Data Protection Act 2019", + section="s.49", + authority="Office of the Data Protection Commissioner (ODPC)", + decision="deny", + message=( + "Kenya DPA s.49: Cross-border data transfer blocked — " + "no documented consent or ODPC adequacy basis on file" + ), + ) + ) + + return violations + + +# ── Ghana: Data Protection Act 2012 (Act 843) ──────────────────────────────── +# s.18(2) — Cross-border transfer requires adequacy or documented consent +# NIA Act 707 — Ghana Card national ID format: GHA-XXXXXXXXX-X +# Enforcing authority: Data Protection Commission (DPC) + +_GH_CROSS_BORDER_RE = re.compile( + r"(?i)(send|transfer|export|upload|forward).{0,60}" + r"(outside\s+ghana|cross.?border|international|offshore)" +) +_GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]") + + +def _check_ghana(text: str, ctx: AgentContext) -> list[ComplianceViolation]: + violations: list[ComplianceViolation] = [] + + if _GH_CARD_RE.search(text): + violations.append( + ComplianceViolation( + jurisdiction="GH", + regulation="Ghana DPA 2012 (Act 843) / NIA Act 707", + section="NIA Act 707", + authority="Data Protection Commission (DPC)", + decision="deny", + message=( + "Ghana DPA Act 843 / NIA Act 707: Ghana Card national ID detected — " + "blocked to prevent identity data exposure" + ), + ) + ) + + if _GH_CROSS_BORDER_RE.search(text) and not ctx.consent_documented: + violations.append( + ComplianceViolation( + jurisdiction="GH", + regulation="Ghana Data Protection Act 2012 (Act 843)", + section="s.18(2)", + authority="Data Protection Commission (DPC)", + decision="deny", + message=( + "Ghana DPA Act 843 s.18(2): Cross-border transfer blocked — " + "no documented consent or DPC adequacy basis on file" + ), + ) + ) + + return violations + + +# ── South Africa: POPIA ─────────────────────────────────────────────────────── +# s.26(1)(f) — Biometric personal information +# s.19 — SA ID Number: YYMMDD (date of birth) + 7 digits = 13 digits total +# Enforcing authority: Information Regulator (South Africa) + +_ZA_ID_RE = re.compile( + r"\b[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{7}\b" +) +_ZA_BIOMETRIC_RE = re.compile( + r"(?i)(fingerprint|facial\s+recognition|retina|iris\s+scan|" + r"voice\s+print|biometric\s+(?:template|hash|data|record))" +) + + +def _check_south_africa(text: str, ctx: AgentContext) -> list[ComplianceViolation]: + violations: list[ComplianceViolation] = [] + + if _ZA_BIOMETRIC_RE.search(text): + violations.append( + ComplianceViolation( + jurisdiction="ZA", + regulation="POPIA (Protection of Personal Information Act 4 of 2013)", + section="s.26(1)(f)", + authority="Information Regulator (South Africa)", + decision="deny", + message=( + "POPIA s.26(1)(f): Biometric personal information detected in request — " + "requires documented POPIA s.27 exception before processing" + ), + ) + ) + + if _ZA_ID_RE.search(text): + violations.append( + ComplianceViolation( + jurisdiction="ZA", + regulation="POPIA (Protection of Personal Information Act 4 of 2013)", + section="s.19", + authority="Information Regulator (South Africa)", + decision="deny", + message=( + "POPIA s.19: SA ID Number (13-digit) detected — " + "blocked to prevent sensitive identifier exposure" + ), + ) + ) + + return violations + + +# ── Jurisdiction dispatcher ─────────────────────────────────────────────────── + +_CHECKERS = { + "NG": _check_nigeria, + "KE": _check_kenya, + "GH": _check_ghana, + "ZA": _check_south_africa, +} + + +@input_guardrail +async def african_regulatory_guardrail( + context: RunContextWrapper[AgentContext], + agent: Agent, + input: str | list[TResponseInputItem], +) -> GuardrailFunctionOutput: + """Input guardrail enforcing African financial and data protection regulations. + + Reads `context.jurisdiction` to apply the correct jurisdiction's rules: + NG — Nigeria (CBN NIP Framework, NDPA 2023) + KE — Kenya (Data Protection Act 2019) + GH — Ghana (Data Protection Act 2012 / Act 843, NIA Act 707) + ZA — South Africa (POPIA) + """ + ctx = context.context + text = ( + input + if isinstance(input, str) + else " ".join( + item["content"] if isinstance(item, dict) else str(item) + for item in input + ) + ) + + checker = _CHECKERS.get(ctx.jurisdiction) + violations = checker(text, ctx) if checker else [] + + output = ComplianceOutput(violated=bool(violations), violations=violations) + return GuardrailFunctionOutput( + output_info=output, + tripwire_triggered=output.violated, + ) + + +# ── Agent ───────────────────────────────────────────────────────────────────── + +agent = Agent[AgentContext]( + name="African Fintech Agent", + instructions=( + "You are a helpful financial services assistant for African markets. " + "Help users with account inquiries, fund transfers, and data requests." + ), + input_guardrails=[african_regulatory_guardrail], +) + + +# ── Demo runner ─────────────────────────────────────────────────────────────── + + +async def run_scenario( + label: str, user_input: str, ctx: AgentContext +) -> None: + print(f"\n{'─' * 62}") + print(f"Scenario : {label}") + print(f"Input : {user_input}") + try: + result = await Runner.run(agent, user_input, context=ctx) + print(f"Response : {result.final_output}") + except InputGuardrailTripwireTriggered as e: + output: ComplianceOutput = e.guardrail_result.output.output_info + for v in output.violations: + print(f"BLOCKED [{v.jurisdiction}] {v.regulation} {v.section}") + print(f" {v.message}") + print(f" Authority: {v.authority}") + + +async def main() -> None: + auto_mode = is_auto_mode() + scripted = [ + ( + "Nigeria — CBN NIP single-transaction cap exceeded", + "Please transfer ₦15,000,000 to account 0123456789", + AgentContext(jurisdiction="NG", kyc_tier=3, consent_documented=False), + ), + ( + "Kenya — KDPA s.49 cross-border transfer without consent", + "Export all customer records to our servers in us-east-1", + AgentContext(jurisdiction="KE", kyc_tier=3, consent_documented=False), + ), + ( + "Ghana — NIA Act 707 Ghana Card national ID detected", + "Look up customer GHA-123456789-1 and send their records outside Ghana", + AgentContext(jurisdiction="GH", kyc_tier=3, consent_documented=False), + ), + ( + "South Africa — POPIA biometric personal information", + "Store the fingerprint biometric template for this customer", + AgentContext(jurisdiction="ZA", kyc_tier=3, consent_documented=False), + ), + ( + "Nigeria — Compliant transfer within CBN cap (allowed)", + "Please transfer ₦50,000 to account 0123456789", + AgentContext(jurisdiction="NG", kyc_tier=3, consent_documented=True), + ), + ] + + for label, user_input, ctx in scripted: + if auto_mode: + print(f"[auto-input] -> {user_input}") + await run_scenario(label, user_input, ctx) + else: + override = input_with_fallback(f"Enter message (or press enter for: '{user_input}'): ", user_input) + await run_scenario(label, override, ctx) + + +if __name__ == "__main__": + asyncio.run(main()) From 39936da35ed8708156ca46f276445e5a78565309 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 17:02:15 +0100 Subject: [PATCH 02/12] style: apply ruff formatting --- .../african_regulatory_guardrails.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index aa2b3600ba..3090073758 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -70,9 +70,9 @@ class ComplianceOutput(BaseModel): @dataclass class AgentContext: - jurisdiction: str # "NG" | "KE" | "GH" | "ZA" - kyc_tier: int # 1 | 2 | 3 (CBN tiered KYC — Nigeria only) - consent_documented: bool # cross-border transfer consent on file + jurisdiction: str # "NG" | "KE" | "GH" | "ZA" + kyc_tier: int # 1 | 2 | 3 (CBN tiered KYC — Nigeria only) + consent_documented: bool # cross-border transfer consent on file # ── Nigeria: CBN NIP Framework ──────────────────────────────────────────────── @@ -210,9 +210,7 @@ def _check_ghana(text: str, ctx: AgentContext) -> list[ComplianceViolation]: # s.19 — SA ID Number: YYMMDD (date of birth) + 7 digits = 13 digits total # Enforcing authority: Information Regulator (South Africa) -_ZA_ID_RE = re.compile( - r"\b[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{7}\b" -) +_ZA_ID_RE = re.compile(r"\b[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{7}\b") _ZA_BIOMETRIC_RE = re.compile( r"(?i)(fingerprint|facial\s+recognition|retina|iris\s+scan|" r"voice\s+print|biometric\s+(?:template|hash|data|record))" @@ -283,10 +281,7 @@ async def african_regulatory_guardrail( text = ( input if isinstance(input, str) - else " ".join( - item["content"] if isinstance(item, dict) else str(item) - for item in input - ) + else " ".join(item["content"] if isinstance(item, dict) else str(item) for item in input) ) checker = _CHECKERS.get(ctx.jurisdiction) @@ -314,9 +309,7 @@ async def african_regulatory_guardrail( # ── Demo runner ─────────────────────────────────────────────────────────────── -async def run_scenario( - label: str, user_input: str, ctx: AgentContext -) -> None: +async def run_scenario(label: str, user_input: str, ctx: AgentContext) -> None: print(f"\n{'─' * 62}") print(f"Scenario : {label}") print(f"Input : {user_input}") @@ -366,7 +359,9 @@ async def main() -> None: print(f"[auto-input] -> {user_input}") await run_scenario(label, user_input, ctx) else: - override = input_with_fallback(f"Enter message (or press enter for: '{user_input}'): ", user_input) + override = input_with_fallback( + f"Enter message (or press enter for: '{user_input}'): ", user_input + ) await run_scenario(label, override, ctx) From ef14918e6b2f0b200e665087a505300dbc711cb8 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 17:24:06 +0100 Subject: [PATCH 03/12] fix: safely extract text from multi-turn TResponseInputItem lists Addresses Codex review feedback: the previous join raised KeyError on tool-call messages (no 'content' key) and TypeError when 'content' is a list of content parts (assistant multi-turn messages). Now: - dicts with a string 'content' are appended directly - dicts with a list 'content' have their 'type==text' parts extracted - dicts with no 'content' key (tool calls) are skipped silently --- .../african_regulatory_guardrails.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 3090073758..50fff1c13d 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -278,11 +278,25 @@ async def african_regulatory_guardrail( ZA — South Africa (POPIA) """ ctx = context.context - text = ( - input - if isinstance(input, str) - else " ".join(item["content"] if isinstance(item, dict) else str(item) for item in input) - ) + if isinstance(input, str): + text = input + else: + parts: list[str] = [] + for item in input: + if not isinstance(item, dict): + continue + content = item.get("content") + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + # Assistant messages can carry content as a list of content parts. + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + elif isinstance(part, str): + parts.append(part) + # Items with no "content" key (e.g. tool calls) are skipped. + text = " ".join(parts) checker = _CHECKERS.get(ctx.jurisdiction) violations = checker(text, ctx) if checker else [] From df2bb05c0bf106e67b4a0c434f24dbb9cdbd1735 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 17:31:45 +0100 Subject: [PATCH 04/12] fix: accept 'input_text' content part type alongside 'text' The SDK uses 'input_text' for user message content parts (confirmed from examples/memory/hitl_session_scenario.py). Only handling 'text' caused guardrail checks to silently drop all user text from structured histories, so compliance rules never tripped on multi-turn input. --- examples/agent_patterns/african_regulatory_guardrails.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 50fff1c13d..26f7167675 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -289,9 +289,10 @@ async def african_regulatory_guardrail( if isinstance(content, str): parts.append(content) elif isinstance(content, list): - # Assistant messages can carry content as a list of content parts. + # Content can be a list of content parts. The SDK uses "input_text" + # for user message parts and "text" for assistant/output parts. for part in content: - if isinstance(part, dict) and part.get("type") == "text": + if isinstance(part, dict) and part.get("type") in ("text", "input_text"): parts.append(part.get("text", "")) elif isinstance(part, str): parts.append(part) From 1efd312e162bdbdf12721dff6c18cef2d7ffa1c9 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 17:37:21 +0100 Subject: [PATCH 05/12] fix: check only the latest user message, not full history Concatenating the entire TResponseInputItem history caused stale assistant text (e.g. a prior refusal quoting a blocked phrase) to trip the guardrail on later unrelated user turns. Now filters to the most-recent role=='user' item only, matching the SDK's own pattern in hitl_session_scenario.py. --- .../agent_patterns/african_regulatory_guardrails.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 26f7167675..946cc6d95f 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -281,10 +281,16 @@ async def african_regulatory_guardrail( if isinstance(input, str): text = input else: + # Only inspect the most-recent user message. Concatenating the full + # history would cause stale assistant text (e.g. a prior refusal + # quoting a blocked phrase) to trip the guardrail on later unrelated + # turns. + user_items = [ + item for item in input if isinstance(item, dict) and item.get("role") == "user" + ] + target = user_items[-1:] if user_items else [] parts: list[str] = [] - for item in input: - if not isinstance(item, dict): - continue + for item in target: content = item.get("content") if isinstance(content, str): parts.append(content) @@ -296,7 +302,6 @@ async def african_regulatory_guardrail( parts.append(part.get("text", "")) elif isinstance(part, str): parts.append(part) - # Items with no "content" key (e.g. tool calls) are skipped. text = " ".join(parts) checker = _CHECKERS.get(ctx.jurisdiction) From c99effaf2fefafe2220ee461c9ab95a42e9e68dd Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 17:44:53 +0100 Subject: [PATCH 06/12] fix: harden all regex patterns after full edge-case review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NGN amount pattern: add re.IGNORECASE so 'ngn 15,000,001' is caught alongside uppercase NGN and the ₦ symbol - Ghana Card regex: add \b word boundary at end so GHA-123456789-12 (extra digit) no longer matches the valid 14-char format - BVN violation message: 'in agent output' → 'in request' (input guardrail, not output guardrail) All 37 edge-case tests pass across Nigeria, Kenya, Ghana, South Africa. --- examples/agent_patterns/african_regulatory_guardrails.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 946cc6d95f..c6c914d4f5 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -80,7 +80,7 @@ class AgentContext: # CBN NIP (NIBSS Instant Payment) Framework — ₦10,000,000 per-transaction cap # NDPA 2023 Schedule 1 / CBN BVN Policy Framework — BVN as biometric data -_NGN_AMOUNT_RE = re.compile(r"(?:₦|NGN)\s*([\d,]+(?:\.\d{1,2})?)") +_NGN_AMOUNT_RE = re.compile(r"(?:₦|NGN)\s*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE) _NGN_BVN_RE = re.compile(r"(?i)(bvn|bank\s+verification).{0,20}\b[0-9]{11}\b") _CBN_NIP_CAP = 10_000_000 @@ -117,7 +117,7 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: authority="Nigeria Data Protection Commission (NDPC)", decision="deny", message=( - "BVN Protection: BVN value detected in agent output — blocked " + "BVN Protection: BVN value detected in request — blocked " "(NDPA 2023 Schedule 1 classifies BVN as biometric personal data)" ), ) @@ -166,7 +166,7 @@ def _check_kenya(text: str, ctx: AgentContext) -> list[ComplianceViolation]: r"(?i)(send|transfer|export|upload|forward).{0,60}" r"(outside\s+ghana|cross.?border|international|offshore)" ) -_GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]") +_GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]\b") def _check_ghana(text: str, ctx: AgentContext) -> list[ComplianceViolation]: From 04a18b6ebbed5d85a6f8106ce02a8695167ac521 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 17:50:00 +0100 Subject: [PATCH 07/12] fix: set run_in_parallel=False for data-protection guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the default run_in_parallel=True the SDK starts the agent/model request concurrently with the guardrail, so sensitive data (BVN numbers, Ghana Card IDs, biometric templates, cross-border transfer targets) could already be sent to the model before the guardrail trips and cancels it. run_in_parallel=False ensures the compliance check completes and violations are decided before agent execution begins — which is the required behaviour for any guardrail whose purpose is to prevent data exposure. --- examples/agent_patterns/african_regulatory_guardrails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index c6c914d4f5..2ea6a397e1 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -263,7 +263,7 @@ def _check_south_africa(text: str, ctx: AgentContext) -> list[ComplianceViolatio } -@input_guardrail +@input_guardrail(run_in_parallel=False) async def african_regulatory_guardrail( context: RunContextWrapper[AgentContext], agent: Agent, From 5b42c031d69f49b689449edee21e4071784f763c Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 18:17:35 +0100 Subject: [PATCH 08/12] fix: address all Codex review comments on african_regulatory_guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Kenya/Ghana cross-border patterns: narrow 'international' to 'international\\s+transfer' to match Rego source (KDPA s.49 / Ghana DPA Act 843 s.18(2)) and avoid false positives on generic phrases like 'transfer KES 1000 internationally' - Ghana: also add 'foreign\\s+server' alternative to match source Rego - Fix NGN amount detection: add suffix pattern to catch '15,000,001 NGN' (amount before currency code), add 'naira' keyword — both were absent from the prefix-only regex causing the CBN cap check to be bypassed - Deduplicate by seen_amounts so the same violation isn't reported twice when both prefix and suffix patterns match the same value in one message --- .../african_regulatory_guardrails.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 2ea6a397e1..293cc21919 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -80,7 +80,9 @@ class AgentContext: # CBN NIP (NIBSS Instant Payment) Framework — ₦10,000,000 per-transaction cap # NDPA 2023 Schedule 1 / CBN BVN Policy Framework — BVN as biometric data -_NGN_AMOUNT_RE = re.compile(r"(?:₦|NGN)\s*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE) +# Matches both prefix (₦15M / NGN 15M / naira 15M) and suffix (15M NGN) forms. +_NGN_PREFIX_RE = re.compile(r"(?:₦|NGN|naira)\s*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE) +_NGN_SUFFIX_RE = re.compile(r"\b([\d,]+(?:\.\d{1,2})?)\s*NGN\b", re.IGNORECASE) _NGN_BVN_RE = re.compile(r"(?i)(bvn|bank\s+verification).{0,20}\b[0-9]{11}\b") _CBN_NIP_CAP = 10_000_000 @@ -88,12 +90,16 @@ class AgentContext: def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: violations: list[ComplianceViolation] = [] - for match in _NGN_AMOUNT_RE.finditer(text): - try: - amount = float(match.group(1).replace(",", "")) - except ValueError: - continue - if amount > _CBN_NIP_CAP: + seen_amounts: set[float] = set() + for pattern in (_NGN_PREFIX_RE, _NGN_SUFFIX_RE): + for match in pattern.finditer(text): + try: + amount = float(match.group(1).replace(",", "")) + except ValueError: + continue + if amount <= _CBN_NIP_CAP or amount in seen_amounts: + continue + seen_amounts.add(amount) violations.append( ComplianceViolation( jurisdiction="NG", @@ -132,7 +138,7 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: _KE_CROSS_BORDER_RE = re.compile( r"(?i)(send|transfer|export|upload|forward).{0,60}" - r"(outside\s+kenya|cross.?border|international|offshore|us-east|eu-west)" + r"(outside\s+kenya|cross.?border|international\s+transfer|offshore|us-east|eu-west)" ) @@ -164,7 +170,7 @@ def _check_kenya(text: str, ctx: AgentContext) -> list[ComplianceViolation]: _GH_CROSS_BORDER_RE = re.compile( r"(?i)(send|transfer|export|upload|forward).{0,60}" - r"(outside\s+ghana|cross.?border|international|offshore)" + r"(outside\s+ghana|cross.?border|international\s+transfer|offshore|foreign\s+server)" ) _GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]\b") From 39c6b46139f9a727856294c43365ebe19490a0e3 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 18:37:20 +0100 Subject: [PATCH 09/12] fix: parse NGN magnitude suffixes and validate SA ID calendar dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NGN amounts: extend prefix and suffix patterns to capture optional K/M/B magnitude suffix (e.g. ₦15M, 15M NGN, ₦1.5B). _parse_ngn_amount() applies the multiplier so shorthand amounts are correctly compared against the ₦10,000,000 CBN NIP cap instead of being treated as ₦15 (under cap) - SA ID: replace _ZA_ID_RE.search() with per-match date validation via _za_id_date_valid(), which tries both 1900 and 2000 century interpretations and rejects impossible calendar dates (e.g. Feb 31, Apr 31) that satisfy the YYMMDD regex pattern but do not exist on the Gregorian calendar --- .../african_regulatory_guardrails.py | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 293cc21919..97c10ced94 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import datetime import re from dataclasses import dataclass from typing import Literal @@ -80,13 +81,24 @@ class AgentContext: # CBN NIP (NIBSS Instant Payment) Framework — ₦10,000,000 per-transaction cap # NDPA 2023 Schedule 1 / CBN BVN Policy Framework — BVN as biometric data -# Matches both prefix (₦15M / NGN 15M / naira 15M) and suffix (15M NGN) forms. -_NGN_PREFIX_RE = re.compile(r"(?:₦|NGN|naira)\s*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE) -_NGN_SUFFIX_RE = re.compile(r"\b([\d,]+(?:\.\d{1,2})?)\s*NGN\b", re.IGNORECASE) +# K/M/B magnitude multipliers for shorthand amounts (e.g. ₦15M, 15M NGN). +_MULTIPLIERS: dict[str, int] = {"k": 1_000, "m": 1_000_000, "b": 1_000_000_000} + +# Prefix: ₦15M / NGN 15M / naira 15M / ₦15,000,000 — group 1 = digits, group 2 = optional K/M/B +_NGN_PREFIX_RE = re.compile(r"(?:₦|NGN|naira)\s*([\d,]+(?:\.\d{1,2})?)([KMBkmb])?\b", re.IGNORECASE) +# Suffix: 15M NGN / 15,000,001 NGN — group 1 = digits, group 2 = optional K/M/B +_NGN_SUFFIX_RE = re.compile(r"\b([\d,]+(?:\.\d{1,2})?)([KMBkmb])?\s*(?:NGN|naira)\b", re.IGNORECASE) _NGN_BVN_RE = re.compile(r"(?i)(bvn|bank\s+verification).{0,20}\b[0-9]{11}\b") _CBN_NIP_CAP = 10_000_000 +def _parse_ngn_amount(digits: str, mag: str | None) -> float: + amount = float(digits.replace(",", "")) + if mag: + amount *= _MULTIPLIERS.get(mag.lower(), 1) + return amount + + def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: violations: list[ComplianceViolation] = [] @@ -94,7 +106,7 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: for pattern in (_NGN_PREFIX_RE, _NGN_SUFFIX_RE): for match in pattern.finditer(text): try: - amount = float(match.group(1).replace(",", "")) + amount = _parse_ngn_amount(match.group(1), match.group(2)) except ValueError: continue if amount <= _CBN_NIP_CAP or amount in seen_amounts: @@ -223,6 +235,22 @@ def _check_ghana(text: str, ctx: AgentContext) -> list[ComplianceViolation]: ) +def _za_id_date_valid(id_str: str) -> bool: + """Return True only if the YYMMDD prefix of a candidate SA ID is a real calendar date. + + SA IDs can be born in either century; we accept the match if EITHER interpretation + is a valid date (e.g. YMMDD=000229 is valid in 2000 (leap year) but not in 1900). + """ + yy, mm, dd = int(id_str[0:2]), int(id_str[2:4]), int(id_str[4:6]) + for century in (1900, 2000): + try: + datetime.date(century + yy, mm, dd) + return True + except ValueError: + pass + return False + + def _check_south_africa(text: str, ctx: AgentContext) -> list[ComplianceViolation]: violations: list[ComplianceViolation] = [] @@ -241,7 +269,9 @@ def _check_south_africa(text: str, ctx: AgentContext) -> list[ComplianceViolatio ) ) - if _ZA_ID_RE.search(text): + for match in _ZA_ID_RE.finditer(text): + if not _za_id_date_valid(match.group(0)): + continue # impossible calendar date — not a real SA ID violations.append( ComplianceViolation( jurisdiction="ZA", @@ -255,6 +285,7 @@ def _check_south_africa(text: str, ctx: AgentContext) -> list[ComplianceViolatio ), ) ) + break # one match is sufficient to trip the guardrail return violations From 12839956f84614a62a71f468b9595a3a11eeaeea Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 18:49:36 +0100 Subject: [PATCH 10/12] fix: gate NIP cap on transfer intent, scope cross-border to data, case-insensitive Ghana Card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nigeria: add _NGN_TRANSFER_INTENT_RE check so CBN NIP cap only fires when the request contains a transfer/payment verb; balance inquiries and quotes that mention large NGN amounts are no longer blocked - Kenya/Ghana: replace single cross-border regex with two patterns that distinguish personal-data exports from financial payments — unambiguous data-export verbs (export/upload/forward) fire unconditionally; ambiguous verbs (send/transfer) require a data-object keyword (records, customer data, files, PII, etc.) before the cross-border destination fires KDPA s.49 / Ghana DPA Act 843 s.18(2); prevents 'transfer KES 1000 internationally' from blocking a legitimate payment instruction - Ghana Card: add re.IGNORECASE to _GH_CARD_RE so gha-123456789-1 (lowercase) is caught alongside the uppercase GHA- form --- .../african_regulatory_guardrails.py | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 97c10ced94..63e7f14074 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -89,6 +89,11 @@ class AgentContext: # Suffix: 15M NGN / 15,000,001 NGN — group 1 = digits, group 2 = optional K/M/B _NGN_SUFFIX_RE = re.compile(r"\b([\d,]+(?:\.\d{1,2})?)([KMBkmb])?\s*(?:NGN|naira)\b", re.IGNORECASE) _NGN_BVN_RE = re.compile(r"(?i)(bvn|bank\s+verification).{0,20}\b[0-9]{11}\b") +# Gate the NIP cap on transfer/payment intent so that balance inquiries or quotes +# mentioning large NGN amounts (e.g. "my balance is NGN 15M") are not blocked. +_NGN_TRANSFER_INTENT_RE = re.compile( + r"(?i)\b(transfer|send|pay|wire|remit|move|disburse|credit)\b" +) _CBN_NIP_CAP = 10_000_000 @@ -102,6 +107,7 @@ def _parse_ngn_amount(digits: str, mag: str | None) -> float: def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: violations: list[ComplianceViolation] = [] + has_transfer_intent = bool(_NGN_TRANSFER_INTENT_RE.search(text)) seen_amounts: set[float] = set() for pattern in (_NGN_PREFIX_RE, _NGN_SUFFIX_RE): for match in pattern.finditer(text): @@ -109,7 +115,7 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: amount = _parse_ngn_amount(match.group(1), match.group(2)) except ValueError: continue - if amount <= _CBN_NIP_CAP or amount in seen_amounts: + if amount <= _CBN_NIP_CAP or amount in seen_amounts or not has_transfer_intent: continue seen_amounts.add(amount) violations.append( @@ -148,16 +154,25 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: # s.49 — Cross-border transfer requires documented consent or ODPC adequacy # Enforcing authority: Office of the Data Protection Commissioner (ODPC) -_KE_CROSS_BORDER_RE = re.compile( - r"(?i)(send|transfer|export|upload|forward).{0,60}" - r"(outside\s+kenya|cross.?border|international\s+transfer|offshore|us-east|eu-west)" +# KDPA s.49 covers personal data, not financial payments. Two patterns distinguish: +# 1. Unambiguous data-export verbs (export/upload/forward) + cross-border destination +# 2. Ambiguous verbs (send/transfer) ONLY when a data-object keyword is also present, +# e.g. "send customer records outside Kenya" vs "transfer KES 1000 internationally" +_KE_DATA_EXPORT_RE = re.compile( + r"(?i)(?:export|upload|forward).{0,60}" + r"(?:outside\s+kenya|cross.?border|international\s+transfer|offshore|us-east|eu-west)" +) +_KE_DATA_TRANSFER_RE = re.compile( + r"(?i)(?:send|transfer).{0,80}" + r"(?:records?|personal\s+data|customer\s+data|user\s+data|files?|information|database|pii|documents?).{0,40}" + r"(?:outside\s+kenya|cross.?border|international\s+transfer|offshore|us-east|eu-west)" ) def _check_kenya(text: str, ctx: AgentContext) -> list[ComplianceViolation]: violations: list[ComplianceViolation] = [] - if _KE_CROSS_BORDER_RE.search(text) and not ctx.consent_documented: + if (_KE_DATA_EXPORT_RE.search(text) or _KE_DATA_TRANSFER_RE.search(text)) and not ctx.consent_documented: violations.append( ComplianceViolation( jurisdiction="KE", @@ -180,11 +195,18 @@ def _check_kenya(text: str, ctx: AgentContext) -> list[ComplianceViolation]: # NIA Act 707 — Ghana Card national ID format: GHA-XXXXXXXXX-X # Enforcing authority: Data Protection Commission (DPC) -_GH_CROSS_BORDER_RE = re.compile( - r"(?i)(send|transfer|export|upload|forward).{0,60}" - r"(outside\s+ghana|cross.?border|international\s+transfer|offshore|foreign\s+server)" +# Same data/financial distinction as Kenya — export/upload/forward are unambiguous; +# send/transfer require a data-object keyword to avoid blocking financial payments. +_GH_DATA_EXPORT_RE = re.compile( + r"(?i)(?:export|upload|forward).{0,60}" + r"(?:outside\s+ghana|cross.?border|international\s+transfer|offshore|foreign\s+server)" +) +_GH_DATA_TRANSFER_RE = re.compile( + r"(?i)(?:send|transfer).{0,80}" + r"(?:records?|personal\s+data|customer\s+data|user\s+data|files?|information|database|pii|documents?).{0,40}" + r"(?:outside\s+ghana|cross.?border|international\s+transfer|offshore|foreign\s+server)" ) -_GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]\b") +_GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]\b", re.IGNORECASE) def _check_ghana(text: str, ctx: AgentContext) -> list[ComplianceViolation]: @@ -205,7 +227,7 @@ def _check_ghana(text: str, ctx: AgentContext) -> list[ComplianceViolation]: ) ) - if _GH_CROSS_BORDER_RE.search(text) and not ctx.consent_documented: + if (_GH_DATA_EXPORT_RE.search(text) or _GH_DATA_TRANSFER_RE.search(text)) and not ctx.consent_documented: violations.append( ComplianceViolation( jurisdiction="GH", From 63a9a0a53085f59b29fcfae4a4af350c16b8f589 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 19:03:05 +0100 Subject: [PATCH 11/12] fix: module docstring position, transfer intent proximity, cross-sentence contamination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move module docstring before imports so Python correctly sets __doc__ (string literals after import statements are silently discarded by the runtime) - Replace document-wide transfer intent flag with per-amount proximity check: a transfer verb must appear within 80 chars of the matched NGN amount, not anywhere in the message; negative lookahead on 'transfer' excludes noun phrases ('transfer limit', 'transfer cap', 'transfer fee') to prevent false positives on balance and limit inquiries that mention large NGN values - Use [^.!?] instead of . in _KE/_GH_DATA_TRANSFER_RE wildcard spans so the verb from one sentence cannot span a period into the data-object keyword of a subsequent sentence (cross-sentence contamination false positive) - Remove 'credit' from transfer verb list — it fires on 'credit limit/score' which are common non-transfer noun phrases --- .../african_regulatory_guardrails.py | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 63e7f14074..438c0943bb 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -1,27 +1,5 @@ -from __future__ import annotations - -import asyncio -import datetime -import re -from dataclasses import dataclass -from typing import Literal - -from pydantic import BaseModel - -from agents import ( - Agent, - GuardrailFunctionOutput, - InputGuardrailTripwireTriggered, - RunContextWrapper, - Runner, - TResponseInputItem, - input_guardrail, -) -from examples.auto_mode import input_with_fallback, is_auto_mode - """ -This example shows how to implement jurisdiction-aware compliance guardrails -for AI agents operating in African financial markets. +Jurisdiction-aware compliance guardrails for AI agents in African financial markets. Four jurisdictions are covered using only stdlib Python — no external packages: @@ -48,6 +26,27 @@ ComplianceOutput describing the violation. """ +from __future__ import annotations + +import asyncio +import datetime +import re +from dataclasses import dataclass +from typing import Literal + +from pydantic import BaseModel + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, + input_guardrail, +) +from examples.auto_mode import input_with_fallback, is_auto_mode + # ── Compliance models ───────────────────────────────────────────────────────── @@ -89,10 +88,14 @@ class AgentContext: # Suffix: 15M NGN / 15,000,001 NGN — group 1 = digits, group 2 = optional K/M/B _NGN_SUFFIX_RE = re.compile(r"\b([\d,]+(?:\.\d{1,2})?)([KMBkmb])?\s*(?:NGN|naira)\b", re.IGNORECASE) _NGN_BVN_RE = re.compile(r"(?i)(bvn|bank\s+verification).{0,20}\b[0-9]{11}\b") -# Gate the NIP cap on transfer/payment intent so that balance inquiries or quotes -# mentioning large NGN amounts (e.g. "my balance is NGN 15M") are not blocked. -_NGN_TRANSFER_INTENT_RE = re.compile( - r"(?i)\b(transfer|send|pay|wire|remit|move|disburse|credit)\b" +# Transfer/payment verbs — checked within 80 chars of the matched amount so that +# "account balance: ₦15M" is not blocked. The negative lookahead on 'transfer' +# excludes noun phrases ("transfer limit", "transfer fee") where transfer is a +# modifier, not an action verb. +_NGN_TRANSFER_VERB_RE = re.compile( + r"\b(?:send|pay|wire|remit|move|disburse)\b" + r"|\btransfer(?!\s+(?:limit|fee|history|rate|request|cap|ceiling|charge|tax))\b", + re.IGNORECASE, ) _CBN_NIP_CAP = 10_000_000 @@ -107,7 +110,6 @@ def _parse_ngn_amount(digits: str, mag: str | None) -> float: def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: violations: list[ComplianceViolation] = [] - has_transfer_intent = bool(_NGN_TRANSFER_INTENT_RE.search(text)) seen_amounts: set[float] = set() for pattern in (_NGN_PREFIX_RE, _NGN_SUFFIX_RE): for match in pattern.finditer(text): @@ -115,7 +117,13 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: amount = _parse_ngn_amount(match.group(1), match.group(2)) except ValueError: continue - if amount <= _CBN_NIP_CAP or amount in seen_amounts or not has_transfer_intent: + if amount <= _CBN_NIP_CAP or amount in seen_amounts: + continue + # Require a transfer verb within 80 chars of the amount so that + # "transfer limit: ₦15M" or "balance: ₦15M" don't trip the cap. + window_start = max(0, match.start() - 80) + window_end = min(len(text), match.end() + 80) + if not _NGN_TRANSFER_VERB_RE.search(text[window_start:window_end]): continue seen_amounts.add(amount) violations.append( @@ -163,8 +171,8 @@ def _check_nigeria(text: str, ctx: AgentContext) -> list[ComplianceViolation]: r"(?:outside\s+kenya|cross.?border|international\s+transfer|offshore|us-east|eu-west)" ) _KE_DATA_TRANSFER_RE = re.compile( - r"(?i)(?:send|transfer).{0,80}" - r"(?:records?|personal\s+data|customer\s+data|user\s+data|files?|information|database|pii|documents?).{0,40}" + r"(?i)(?:send|transfer)[^.!?]{0,80}" + r"(?:records?|personal\s+data|customer\s+data|user\s+data|files?|information|database|pii|documents?)[^.!?]{0,40}" r"(?:outside\s+kenya|cross.?border|international\s+transfer|offshore|us-east|eu-west)" ) @@ -202,8 +210,8 @@ def _check_kenya(text: str, ctx: AgentContext) -> list[ComplianceViolation]: r"(?:outside\s+ghana|cross.?border|international\s+transfer|offshore|foreign\s+server)" ) _GH_DATA_TRANSFER_RE = re.compile( - r"(?i)(?:send|transfer).{0,80}" - r"(?:records?|personal\s+data|customer\s+data|user\s+data|files?|information|database|pii|documents?).{0,40}" + r"(?i)(?:send|transfer)[^.!?]{0,80}" + r"(?:records?|personal\s+data|customer\s+data|user\s+data|files?|information|database|pii|documents?)[^.!?]{0,40}" r"(?:outside\s+ghana|cross.?border|international\s+transfer|offshore|foreign\s+server)" ) _GH_CARD_RE = re.compile(r"GHA-[0-9]{9}-[0-9]\b", re.IGNORECASE) From 5bee241b1aa146e76947dbbaa12335b749dc0619 Mon Sep 17 00:00:00 2001 From: oluwajuwon omotayo Date: Fri, 3 Jul 2026 19:25:55 +0100 Subject: [PATCH 12/12] fix: transfer-request phrases must not bypass the CBN NIP cap check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'transfer' followed by 'request' was in the negative lookahead exclusion list, so 'submit a transfer request for ₦15,000,000' matched no verb and the over-cap amount was silently skipped. Fix: - Remove 'request' from the exclusion list — a transfer request IS action intent, not a noun modifier like 'transfer limit' or 'transfer fee' - Add submit|create|process|initiate to the verb list so those phrasings are caught even without an explicit 'transfer' token --- examples/agent_patterns/african_regulatory_guardrails.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/agent_patterns/african_regulatory_guardrails.py b/examples/agent_patterns/african_regulatory_guardrails.py index 438c0943bb..74b03a1e17 100644 --- a/examples/agent_patterns/african_regulatory_guardrails.py +++ b/examples/agent_patterns/african_regulatory_guardrails.py @@ -93,8 +93,8 @@ class AgentContext: # excludes noun phrases ("transfer limit", "transfer fee") where transfer is a # modifier, not an action verb. _NGN_TRANSFER_VERB_RE = re.compile( - r"\b(?:send|pay|wire|remit|move|disburse)\b" - r"|\btransfer(?!\s+(?:limit|fee|history|rate|request|cap|ceiling|charge|tax))\b", + r"\b(?:send|pay|wire|remit|move|disburse|submit|create|process|initiate)\b" + r"|\btransfer(?!\s+(?:limit|fee|history|rate|cap|ceiling|charge|tax))\b", re.IGNORECASE, ) _CBN_NIP_CAP = 10_000_000