diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50fcc9e..3f936bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,22 @@ jobs: python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" "$f" && echo "OK: $f" || exit 1 done + financial-services: + runs-on: ubuntu-latest + defaults: + run: + working-directory: financial-services + steps: + - uses: actions/checkout@v7 + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Run credit engine tests + run: python -m unittest discover -s tests -v + industrial-embodied-ai: runs-on: ubuntu-latest defaults: diff --git a/README.md b/README.md index f4911f5..445f015 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working | Example | What it shows | Platform | Compliance | |---|---|---|---| | `embodied-action-receipts/` | Fixture-style offline verification for embodied action receipts: accepted chain, missing receipt, signature mismatch and valid controller rejection | Software-only fixtures | TRACE action-receipt evidence boundary | -| `financial-services/` | Credit risk agent: MiFID II escalation deny above EUR 500k with structured policy advice | SEV-SNP / TDX | EU AI Act Art. 9/12, MiFID II Art. 25, DORA Art. 9 | +| `financial-services/` | Corporate credit risk agent: six-step assessment with CDD, exposure and IFRS 9 guardrails on the write | SEV-SNP / TDX | EU AI Act Art. 9/12, CRR Art. 395, EBA/GL/2020/06, EU AML, DORA Art. 9 | | `healthcare/` | Clinical decision agent: EU AI Act Art. 14 HITL deny on high-risk treatment plans | SEV-SNP / TDX | EU AI Act Art. 14, HIPAA | | `industrial-embodied-ai/` | Material-movement agent with cMCP authorization, an independent safety-controller boundary and offline-verifiable closed-session evidence | TEE / software-only development mode | OT security and industrial robot safety references | | `multi-tenant-saas/` | Per-tenant Cedar policy bundles and enforcement modes (advisory vs enforcing) | TDX | GDPR Art. 6, customer contract SLA | diff --git a/financial-services/README.md b/financial-services/README.md index de68ca5..c9515e3 100644 --- a/financial-services/README.md +++ b/financial-services/README.md @@ -1,63 +1,67 @@ -# financial-services: EU Credit Risk Agent Demo +# financial-services: EU Corporate Credit Risk Agent Demo -End-to-end demo of a credit risk agent processing client financial documents through a cMCP Runtime with Cedar policy enforcement and signed TRACE Trust Records for EU regulatory compliance (EU AI Act, MiFID II, DORA, GDPR). +End-to-end demo of a corporate credit risk agent running a six-step assessment through a cMCP Runtime with Cedar policy enforcement and signed TRACE Trust Records for EU regulatory compliance (EU AI Act, CRR, EBA loan origination, EU AML, IFRS 9, DORA). + +The obligor is a fictional German Mittelstand manufacturer. Identifiers are valid in format (LEI check digits per ISO 17442 / ISO 7064 MOD 97-10, German IBANs likewise), the credit bureau is Creditreform on its Bonitätsindex scale, and the guardrails act on the real output of the assessment rather than on a single hard-coded number. --- ## What the demo shows **1. Cryptographic proof of which tools an AI agent called** -The cMCP Runtime intercepts every MCP tool call, records it in a hash-chained audit log persisted to SQLite, and seals the session into a signed `RuntimeClaim` (the TRACE Trust Record). An auditor can verify after the fact exactly which tools ran, in what order, and what was denied - without trusting the agent process. +The cMCP Runtime intercepts every MCP tool call, records it in a hash-chained audit log persisted to SQLite, and seals the session into a signed `RuntimeClaim` (the TRACE Trust Record). An auditor can verify after the fact exactly which tools ran, in what order, and what was denied, without trusting the agent process. **2. Cedar policy as machine-readable compliance** -The rules in `policy/allow.cedar` encode the bank's requirements directly. The MiFID II escalation rule blocks any risk report above EUR 500,000 and returns the policy's `@annotation` metadata as structured advice, so the calling system knows *why* and *who must review*. +The rules in `policy/allow.cedar` encode the bank's controls directly. Each forbid returns its `@annotation` metadata as structured advice on deny, so the calling system knows *why* the write was blocked and *who must review*. -**3. EU AI Act Article 12 transparency obligation** -Article 12 requires high-risk AI systems to log sufficient information for post-hoc monitoring. The TRACE record covers the policy version enforced, the audit chain root/tip, per-call decisions, and the runtime attestation - signed by the runtime's key. +**3. Controls that fire on the assessment result, not the request** +The agent screens the client, pulls a bureau report, aggregates group exposure and runs the PD/LGD model, then passes the outcome of those steps (CDD status, IFRS 9 stage, concentration breach, facility amount) into the write call. The Cedar guardrails act on those values, so the deny reflects the actual credit decision. **4. Attestation-gated data access (DORA Art. 9)** A Cedar rule forbids confidential (`mnpi`) tools when no attestation evidence is present: confidential financial data only flows through attested runtimes. --- -## Architecture +## The workflow ``` +------------------------------------------------------------------+ - | Credit Risk Agent (LLM) | - | agent/credit_risk_agent.py -- JSON-RPC 2.0 over HTTP | + | Credit Risk Agent (agent/credit_risk_agent.py) | +-------------------------------+----------------------------------+ - | tools/call (MCP) + | tools/call (MCP, JSON-RPC 2.0) v +------------------------------------------------------------------+ | cMCP Runtime :8443 | - | | - | +---------------+ +------------------+ +------------------+ | - | | Cedar engine | | Catalog checker | | Audit chain + | | - | | allow.cedar | | catalog.json | | TRACE signer | | - | +---------------+ +------------------+ +------------------+ | + | Cedar engine (allow.cedar) | catalog checker | audit + signer | +-------------------------------+----------------------------------+ | proxied tool call v +------------------------------------------------------------------+ - | Mock EU Credit Risk MCP Server :8080 | - | server/mock_mcp_server.py | - | finance.document_reader | - | finance.credit_score_lookup | - | finance.risk_report_writer | + | Mock EU Credit Risk MCP Server :8080 | + | 1. finance.document_reader annual financial statements | + | 2. finance.sanctions_screening entity + UBO CDD/AML screen | + | 3. finance.credit_bureau_lookup Creditreform Bonitätsindex | + | 4. finance.exposure_aggregation group exposure vs limit | + | 5. finance.risk_model PD/LGD/EAD, rating, IFRS 9 | + | 6. finance.risk_report_writer write to core banking | +------------------------------------------------------------------+ ``` +Tool responses are computed by `credit_engine.py` from a small set of client fixtures, so the server, the tests (`tests/test_credit_engine.py`) and the agent all agree on the same data. + --- ## The catalog -`catalog.json` registers three tools with approved definitions, classifications, and definition hashes (`sha256` of the canonical JSON of `approved_definition` - the runtime rejects drifted definitions). +`catalog.json` registers the six tools with approved definitions, classifications, and definition hashes (`sha256` of the canonical JSON of `approved_definition`; the runtime rejects drifted definitions). | Tool | compliance_domain | sensitivity_level | |---|---|---| | `finance.document_reader` | mnpi | confidential | -| `finance.credit_score_lookup` | pii | confidential | +| `finance.sanctions_screening` | pii | confidential | +| `finance.credit_bureau_lookup` | pii | confidential | +| `finance.exposure_aggregation` | internal | confidential | +| `finance.risk_model` | internal | confidential | | `finance.risk_report_writer` | internal | confidential | --- @@ -84,92 +88,93 @@ cd financial-services CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml ``` -**Terminal 3 - happy path (EUR 250,000, below the threshold):** +**Terminal 3 - the three scenarios:** ```bash -cd examples -python financial-services/agent/credit_risk_agent.py -``` +# A. clean SME, EUR 250k, within limits, performing -> all six steps allow +python agent/credit_risk_agent.py --scenario clean -``` -[1/3] Calling finance.document_reader ... - -> decision: allow -[2/3] Calling finance.credit_score_lookup ... - -> decision: allow -[3/3] Calling finance.risk_report_writer ... - -> decision: allow - -Closing session and fetching the signed TRACE Trust Record ... +# B. EUR 750k, exceeds delegated authority and breaches the concentration limit +python agent/credit_risk_agent.py --scenario large-exposure + +# C. a beneficial owner matches a sanctions list -> CDD does not clear +python agent/credit_risk_agent.py --scenario sanctions-hit ``` -**Escalation path (EUR 750,000):** +### Scenario A - clean (`clean`) -```bash -python financial-services/agent/credit_risk_agent.py --amount-eur 750000 -``` +Rheintal Präzisionstechnik GmbH, EUR 250,000. CDD clears, Creditreform index 178 (very good), aggregate exposure within the limit, IFRS 9 stage 1. All six steps allow and the report is written. + +### Scenario B - large exposure (`large-exposure`) + +Nordwind Logistik AG, EUR 750,000. The obligor is strong, but the facility exceeds the EUR 500,000 delegated lending authority *and* pushes aggregate group exposure past the single-obligor concentration limit. The write is denied: ``` -[3/3] Calling finance.risk_report_writer ... +[6/6] finance.risk_report_writer ... -> decision: deny (POLICY_DENY) advice from policy: - id: large-exposure-hitl + id: delegated-authority-hitl reason: human-review-required - regulation: mifid-ii-art-25 - escalation_threshold_eur: 500000 + regulation: eba-gl-2020-06 + delegated_authority_limit_eur: 500000 +``` + +### Scenario C - sanctions hit (`sanctions-hit`) - The risk report was NOT written to the core banking system. +Meridian Trading DMCC, EUR 200,000. Sanctions screening returns a beneficial-owner match, so `cdd_cleared` stays false. Even though the agent proceeds to the write, the runtime blocks it, so the CDD gate is enforced by the runtime rather than left to the agent's good behaviour: + +``` +[6/6] finance.risk_report_writer ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: cdd-clearance-required + reason: cdd-clearance-required + regulation: eu-aml-regulation-2024-1624 ``` --- ## The Cedar policy -`policy/allow.cedar` has no catch-all permit: each tool is explicitly permitted only for the `credit-risk-analyst` workflow (declared by the agent via `_cmcp.workflow_id`). Wrong workflow, missing workflow, or an unlisted action is denied by Cedar's default-deny: - -```cedar -permit ( - principal, - action == Action::"Finance.documentReader", - resource -) when { - context has workflow_id && - context.workflow_id == "credit-risk-analyst" -}; -``` +`policy/allow.cedar` has no catch-all permit: each tool is explicitly permitted only for the `credit-risk-analyst` workflow (declared by the agent via `_cmcp.workflow_id`). On top of the workflow-scoped permits, four guardrails forbid writing the assessment when a control fails: -On top of the workflow-scoped permits, the escalation rule: - -```cedar -@id("large-exposure-hitl") -@reason("human-review-required") -@regulation("mifid-ii-art-25") -@escalation_threshold_eur("500000") -forbid ( - principal, - action == Action::"Finance.riskReportWriter", - resource -) when { - context.arguments has amount_eur && - context.arguments.amount_eur > 500000 -}; -``` +| `@id` | Fires when | Regulation | +|---|---|---| +| `cdd-clearance-required` | `cdd_cleared` is not true (sanctions/PEP hit) | EU AML Regulation (EU) 2024/1624 | +| `delegated-authority-hitl` | `amount_eur > 500000` | EBA/GL/2020/06 (loan origination) | +| `large-exposure-concentration` | `breaches_concentration_limit` is true | CRR Art. 395 (large exposures) | +| `credit-impaired-manual-review` | `ifrs9_stage == 3` | IFRS 9 | +| `require-attested-runtime` | `mnpi` tool with no attestation evidence | DORA Art. 9 | + +Action names follow the cMCP convention: `finance.risk_report_writer` becomes `Action::"Finance.riskReportWriter"` (the segment before the dot PascalCase, each underscore segment after it camelCase). Tool arguments are available under `context.arguments`; the `@annotation` values are returned in the deny response's `error.data.advice`. -Action names follow the cMCP convention: `finance.risk_report_writer` becomes `Action::"Finance.riskReportWriter"` (PascalCase per underscore segment). Tool arguments are available under `context.arguments`; the `@annotation` values are returned in the deny response's `error.data.advice`. +> On the concentration limit: the CRR Art. 395 regulatory ceiling is 25% of Tier 1 capital, far larger than any single facility here. Banks implement it through a tighter internal single-obligor risk-appetite limit (EUR 2,000,000 in this demo); the runtime enforces the internal limit and cites CRR Art. 395 as the framework it implements. --- -## The TRACE Trust Record +## The TRACE Trust Records -See `trace-output/example-trust-record.json` - captured from a real run. Structure: `{"cmcp_version", "trace": {...}, "gateway": {...}, "signature"}`. +`trace-output/` holds one signed record per scenario, captured from real runs: + +| File | Result | +|---|---| +| `clean-trust-record.json` | 6 calls, 6 allowed, 0 denied | +| `large-exposure-trust-record.json` | 6 calls, 5 allowed, 1 denied | +| `sanctions-hit-trust-record.json` | 6 calls, 5 allowed, 1 denied | + +Verify one (schema, signature, audit chain, and hashes pass; hardware attestation fails in software-only dev mode): + +```bash +cmcp verify trace-output/large-exposure-trust-record.json +``` -| TRACE field | EU AI Act | MiFID II | DORA | GDPR | +| TRACE field | EU AI Act | CRR / EBA | DORA | GDPR | |---|---|---|---|---| -| `trace.policy.bundle_hash` | Art. 9 - risk mgmt system version | Art. 25 - controls documentation | Art. 9 - change management | - | -| `trace.policy.version` | Art. 12 - log versioning | Art. 25 - audit trail | Art. 11 - ICT change log | - | +| `trace.policy.bundle_hash` | Art. 9 - risk mgmt system version | CRR Art. 395 controls | Art. 9 - change management | - | +| `trace.policy.version` | Art. 12 - log versioning | EBA/GL/2020/06 governance | Art. 11 - ICT change log | - | | `trace.runtime` + `signature` | Art. 12 - tamper-evident logging | - | Art. 9 - security of ICT systems | Art. 32 - security of processing | -| `gateway.call_summary` | Art. 12 - post-hoc review data | Art. 25 - basis of recommendation | Art. 17 - incident management | Art. 5(1)(c) - data minimisation | +| `gateway.call_summary` | Art. 12 - post-hoc review data | EBA/GL/2020/06 basis of decision | Art. 17 - incident management | Art. 5(1)(c) - data minimisation | | `trace.data_class` | Art. 10 - data governance | - | - | Art. 5(1)(b) - purpose limitation | -| `trace.subject` | Art. 12 - traceability to run | Art. 25 - audit trail | Art. 17 - incident traceability | Art. 5(1)(f) - accountability | Export the full audit chain for a closed session: @@ -179,10 +184,20 @@ curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool --- +## The tests + +`tests/test_credit_engine.py` exercises the domain logic without a running server: LEI check digits, CDD clear vs hit, the Creditreform scale, exposure aggregation on both sides of the limit, and IFRS 9 staging. + +```bash +python -m unittest discover -s tests -v +``` + +--- + ## Extending this example - **Real MCP server:** point `server.url` in `catalog.json` at your endpoint; get the TLS fingerprint with `openssl s_client -connect host:443 | openssl x509 -fingerprint -sha256 -noout`. -- **Change the threshold:** edit the `when` clause and the `@escalation_threshold_eur` annotation together. +- **Change a threshold:** edit the `when` clause and the matching `@annotation` together (e.g. the delegated-authority limit). - **Hardware attestation:** drop `CMCP_DEV_MODE=1` on a VM with TPM 2.0 / SEV-SNP / TDX. - **Production hardening:** set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, `CMCP_CATALOG_HASH`. diff --git a/financial-services/agent/credit_risk_agent.py b/financial-services/agent/credit_risk_agent.py index 755aad5..64aa033 100644 --- a/financial-services/agent/credit_risk_agent.py +++ b/financial-services/agent/credit_risk_agent.py @@ -1,16 +1,27 @@ #!/usr/bin/env python3 """ -Credit risk agent demo for EU private bank. +Credit risk agent demo for an EU corporate lender. -Calls three MCP tools through the cMCP Runtime using JSON-RPC 2.0 over HTTP, -then closes the session to obtain the signed TRACE Trust Record. +Runs a six-step credit assessment through the cMCP Runtime using JSON-RPC 2.0 +over HTTP, then closes the session to obtain the signed TRACE Trust Record. The +arguments the agent passes to the final write call (CDD clearance, IFRS 9 stage, +concentration breach, rating) are derived from the earlier tool outputs, so the +Cedar guardrails act on the real result of the assessment. -Usage: - python credit_risk_agent.py [--gateway http://localhost:8443] [--amount-eur 250000] +Three scenarios: + + --scenario clean Rheintal Präzisionstechnik GmbH, EUR 250k. + Clean CDD, within limits, performing. All allow. + --scenario large-exposure Nordwind Logistik AG, EUR 750k. Exceeds + delegated authority and breaches the + concentration limit. Write denied. + --scenario sanctions-hit Meridian Trading DMCC, EUR 200k. A beneficial + owner matches a sanctions list, so CDD does not + clear and the runtime blocks the write. -The default amount (250,000 EUR) is below the 500k escalation threshold, so -all calls are allowed. Pass --amount-eur 750000 to trigger the MiFID II -human-review deny with structured advice from the Cedar policy. +Usage: + python credit_risk_agent.py [--gateway http://localhost:8443] + [--scenario clean|large-exposure|sanctions-hit] """ import argparse @@ -18,17 +29,23 @@ import sys import httpx +# Company names in this demo carry German umlauts; force UTF-8 so the console +# output is correct on Windows terminals too. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + DEFAULT_GATEWAY = "http://localhost:8443" WORKFLOW_ID = "credit-risk-analyst" +BUREAU = "creditreform" -CLIENT_ID = "EUR-2024-00847" -DOCUMENT_ID = "BS-2024-Q4" -CREDIT_BUREAU = "equifax" -RISK_SCORE = 72.3 -RECOMMENDATION = "approve" +SCENARIOS = { + "clean": {"client_id": "DE-CORP-2024-00847", "amount_eur": 250_000}, + "large-exposure": {"client_id": "DE-CORP-2024-01120", "amount_eur": 750_000}, + "sanctions-hit": {"client_id": "AE-CORP-2024-00311", "amount_eur": 200_000}, +} -def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dict, req_id: int) -> dict: +def call_tool(client, gateway, tool_name, arguments, req_id): payload = { "jsonrpc": "2.0", "id": req_id, @@ -42,20 +59,20 @@ def call_tool(client: httpx.Client, gateway: str, tool_name: str, arguments: dic resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) body = resp.json() if "error" in body: - return {"ok": False, "error": body["error"], "session_id": None} + return {"ok": False, "error": body["error"], "payload": None, "session_id": None} result = body["result"] + payload_text = result.get("content", [{}])[0].get("text", "{}") return { "ok": True, - "result": result, + "payload": json.loads(payload_text), "session_id": result.get("_cmcp", {}).get("session_id"), } -def print_outcome(outcome: dict) -> None: +def print_outcome(step, tool, outcome, note=""): + print(f"[{step}] {tool} ...") if outcome["ok"]: - meta = outcome["result"].get("_cmcp", {}) - decision = "advisory_deny" if meta.get("would_have_denied") else "allow" - print(f" -> decision: {decision}") + print(f" -> decision: allow{(' ' + note) if note else ''}") else: data = outcome["error"].get("data", {}) print(f" -> decision: deny ({data.get('error_code', 'unknown')})") @@ -66,42 +83,79 @@ def print_outcome(outcome: dict) -> None: print(f" {key}: {value}") -def close_session(client: httpx.Client, gateway: str, session_id: str) -> dict: +def close_session(client, gateway, session_id): resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) resp.raise_for_status() return resp.json() -def run(gateway: str, amount_eur: int) -> None: +def run(gateway, scenario): + sc = SCENARIOS[scenario] + client_id, amount = sc["client_id"], sc["amount_eur"] print(f"Connecting to cMCP Runtime at {gateway}") - print(f"Client: {CLIENT_ID} | Document: {DOCUMENT_ID} | Amount: EUR {amount_eur:,}") + print(f"Scenario: {scenario} | Client: {client_id} | Facility: EUR {amount:,}") print() session_id = None with httpx.Client(headers={"Content-Type": "application/json"}) as client: - print("[1/3] Calling finance.document_reader ...") - o1 = call_tool(client, gateway, "finance.document_reader", - {"document_id": DOCUMENT_ID, "client_id": CLIENT_ID}, 1) - print_outcome(o1) + o1 = call_tool(client, gateway, "finance.document_reader", {"client_id": client_id}, 1) + note = "" + if o1["ok"]: + p = o1["payload"] + note = f"{p.get('legal_name')} (LEI {p.get('lei')})" + print_outcome("1/6", "finance.document_reader", o1, note) session_id = o1.get("session_id") or session_id - print("[2/3] Calling finance.credit_score_lookup ...") - o2 = call_tool(client, gateway, "finance.credit_score_lookup", - {"client_id": CLIENT_ID, "bureau": CREDIT_BUREAU}, 2) - print_outcome(o2) + o2 = call_tool(client, gateway, "finance.sanctions_screening", {"client_id": client_id}, 2) + cdd_status = o2["payload"].get("cdd_status") if o2["ok"] else "unknown" + print_outcome("2/6", "finance.sanctions_screening", o2, f"cdd_status={cdd_status}") session_id = o2.get("session_id") or session_id - print("[3/3] Calling finance.risk_report_writer ...") - o3 = call_tool(client, gateway, "finance.risk_report_writer", - {"client_id": CLIENT_ID, "risk_score": RISK_SCORE, - "recommendation": RECOMMENDATION, "amount_eur": amount_eur}, 3) - print_outcome(o3) + o3 = call_tool(client, gateway, "finance.credit_bureau_lookup", + {"client_id": client_id, "bureau": BUREAU}, 3) + note = "" + if o3["ok"]: + p = o3["payload"] + note = f"Creditreform index {p.get('bonitaetsindex')} ({p.get('assessment')})" + print_outcome("3/6", "finance.credit_bureau_lookup", o3, note) session_id = o3.get("session_id") or session_id - if not o3["ok"]: + o4 = call_tool(client, gateway, "finance.exposure_aggregation", + {"client_id": client_id, "proposed_facility_eur": amount}, 4) + breaches = o4["payload"].get("breaches_concentration_limit") if o4["ok"] else None + aggregate = o4["payload"].get("aggregate_exposure_eur") if o4["ok"] else None + print_outcome("4/6", "finance.exposure_aggregation", o4, + f"aggregate EUR {aggregate:,} breach={breaches}" if aggregate is not None else "") + session_id = o4.get("session_id") or session_id + + o5 = call_tool(client, gateway, "finance.risk_model", + {"client_id": client_id, "proposed_facility_eur": amount}, 5) + rating = o5["payload"].get("internal_rating") if o5["ok"] else None + pd_1y = o5["payload"].get("pd_1y") if o5["ok"] else None + ifrs9_stage = o5["payload"].get("ifrs9_stage") if o5["ok"] else None + print_outcome("5/6", "finance.risk_model", o5, + f"rating {rating} IFRS9 stage {ifrs9_stage}") + session_id = o5.get("session_id") or session_id + + cdd_cleared = cdd_status == "clear" + recommendation = "approve" if (cdd_cleared and not breaches and ifrs9_stage == 1) else "refer" + o6 = call_tool(client, gateway, "finance.risk_report_writer", { + "client_id": client_id, + "internal_rating": rating, + "pd_1y": pd_1y, + "ifrs9_stage": ifrs9_stage, + "amount_eur": amount, + "aggregate_exposure_eur": aggregate, + "breaches_concentration_limit": breaches, + "cdd_cleared": cdd_cleared, + "recommendation": recommendation, + }, 6) + print_outcome("6/6", "finance.risk_report_writer", o6) + session_id = o6.get("session_id") or session_id + + if not o6["ok"]: print() print(" The risk report was NOT written to the core banking system.") - print(" Exposures above EUR 500,000 require a human reviewer (MiFID II Art. 25).") print() if session_id is None: @@ -120,10 +174,10 @@ def run(gateway: str, amount_eur: int) -> None: if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Credit risk agent demo") + parser = argparse.ArgumentParser(description="EU corporate credit risk agent demo") parser.add_argument("--gateway", default=DEFAULT_GATEWAY, help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") - parser.add_argument("--amount-eur", type=int, default=250_000, - help="Credit amount in EUR (default 250000; >500000 triggers HITL deny)") + parser.add_argument("--scenario", default="clean", choices=sorted(SCENARIOS), + help="which client scenario to run (default: clean)") args = parser.parse_args() - run(args.gateway, args.amount_eur) + run(args.gateway, args.scenario) diff --git a/financial-services/catalog.json b/financial-services/catalog.json index 8f03fc0..6aefe66 100644 --- a/financial-services/catalog.json +++ b/financial-services/catalog.json @@ -8,17 +8,23 @@ "transport": "http-sse" }, "approved_definition": { - "description": "Read client financial documents from secure vault", + "description": "Read filed annual financial statements for a corporate client from the secure document vault", "input_schema": { "type": "object", "properties": { - "document_id": {"type": "string"}, - "client_id": {"type": "string"} + "client_id": { + "type": "string" + }, + "document_id": { + "type": "string" + } }, - "required": ["document_id", "client_id"] + "required": [ + "client_id" + ] } }, - "definition_hash": "sha256:75312282f7ecce285ee6a091a261e99cb36d5ab5955bbb16536dca0baa45abb6", + "definition_hash": "sha256:24613a660da9855e9c5f0e1cae8a34c2e771b8a29affc3e17fae36f26c0e70f1", "compliance_domain": "mnpi", "requires_baa": false, "sensitivity_level": "confidential", @@ -26,7 +32,7 @@ "approved_by": "risk-committee@bank.eu" }, { - "tool_name": "finance.credit_score_lookup", + "tool_name": "finance.sanctions_screening", "server": { "display_name": "EU Credit Risk MCP Server", "url": "http://localhost:8080/mcp", @@ -34,23 +40,130 @@ "transport": "http-sse" }, "approved_definition": { - "description": "Look up credit bureau score for a client", + "description": "Screen a corporate client and its beneficial owners against EU/UN sanctions and PEP lists (CDD/AML)", "input_schema": { "type": "object", "properties": { - "client_id": {"type": "string"}, - "bureau": {"type": "string", "enum": ["equifax", "experian", "transunion"]} + "client_id": { + "type": "string" + } }, - "required": ["client_id"] + "required": [ + "client_id" + ] } }, - "definition_hash": "sha256:0db5f13780ca93d8dcfdfbf76b512d8c744cfd4c5d31bd16c67e55dda7e59af3", + "definition_hash": "sha256:182484c9fb9aacd66b97527b9d1c889ae0a9eac757ae4b50fd4665183aac9022", "compliance_domain": "pii", "requires_baa": false, "sensitivity_level": "confidential", "added_at": "2026-06-01T00:00:00Z", "approved_by": "risk-committee@bank.eu" }, + { + "tool_name": "finance.credit_bureau_lookup", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Retrieve a commercial credit-bureau report for a client", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "bureau": { + "type": "string", + "enum": [ + "creditreform", + "schufa", + "crif", + "banque-de-france-fiben" + ] + } + }, + "required": [ + "client_id" + ] + } + }, + "definition_hash": "sha256:a581f359451d7f8ce2082ae185d991a719458579e23008817781697a535f78f7", + "compliance_domain": "pii", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.exposure_aggregation", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Aggregate existing group exposure with the proposed facility and test it against the single-obligor concentration limit", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "proposed_facility_eur": { + "type": "number" + } + }, + "required": [ + "client_id", + "proposed_facility_eur" + ] + } + }, + "definition_hash": "sha256:aa25379a11f8b6e7b730efcf685f26370c52655b34b9f021ee0f79371c673021", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.risk_model", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Compute PD/LGD/EAD, the internal rating grade and the IFRS 9 stage for a proposed facility", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "proposed_facility_eur": { + "type": "number" + } + }, + "required": [ + "client_id", + "proposed_facility_eur" + ] + } + }, + "definition_hash": "sha256:5ec6f11017e587afa44af58fd0b70574c02127a0462f3b089cb5deba7566fc20", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, { "tool_name": "finance.risk_report_writer", "server": { @@ -60,19 +173,47 @@ "transport": "http-sse" }, "approved_definition": { - "description": "Write credit risk assessment to core banking system", + "description": "Write the credit risk assessment to the core banking system", "input_schema": { "type": "object", "properties": { - "client_id": {"type": "string"}, - "risk_score": {"type": "number"}, - "recommendation": {"type": "string"}, - "amount_eur": {"type": "number"} + "client_id": { + "type": "string" + }, + "internal_rating": { + "type": "string" + }, + "pd_1y": { + "type": "number" + }, + "ifrs9_stage": { + "type": "number" + }, + "amount_eur": { + "type": "number" + }, + "aggregate_exposure_eur": { + "type": "number" + }, + "breaches_concentration_limit": { + "type": "boolean" + }, + "cdd_cleared": { + "type": "boolean" + }, + "recommendation": { + "type": "string" + } }, - "required": ["client_id", "risk_score", "recommendation"] + "required": [ + "client_id", + "amount_eur", + "recommendation", + "cdd_cleared" + ] } }, - "definition_hash": "sha256:b98f4ffffd0d4699eeb0bc360ac8a966adf6b008359cd798e10c99546ce029f1", + "definition_hash": "sha256:e92a42ec46321042f852b516426e91d4e5c685130e4c35effcf3fd9c206b9ba6", "compliance_domain": "internal", "requires_baa": false, "sensitivity_level": "confidential", diff --git a/financial-services/credit_engine.py b/financial-services/credit_engine.py new file mode 100644 index 0000000..23ada41 --- /dev/null +++ b/financial-services/credit_engine.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Credit-decisioning domain logic for the financial-services example. + +Pure, dependency-free functions plus a small set of realistic EU corporate +client fixtures. The mock MCP server (``server/mock_mcp_server.py``) imports +this module so the tool responses are computed from one source of truth, and +``tests/test_credit_engine.py`` exercises the logic without a running server. + +Everything here is illustrative. The identifiers are valid in *format* (LEI +check digits per ISO 17442 / ISO 7064 MOD 97-10, German IBANs per the same +scheme) but the entities are fictional. +""" + +from __future__ import annotations + +from typing import Any + +# Internal single-obligor concentration limit. Banks set this well below the +# CRR Art. 395 regulatory large-exposures ceiling (25% of Tier 1 capital) as a +# matter of risk appetite; the runtime enforces the internal limit and cites +# CRR Art. 395 as the framework it implements. +CONCENTRATION_LIMIT_EUR = 2_000_000 + +# Facilities above this amount exceed the automated workflow's delegated +# lending authority and require a human credit decision-maker under the EBA +# Guidelines on loan origination and monitoring (EBA/GL/2020/06). +DELEGATED_AUTHORITY_LIMIT_EUR = 500_000 + + +# --------------------------------------------------------------------------- +# Client fixtures. Keyed by the bank's internal customer id. +# --------------------------------------------------------------------------- + +CLIENTS: dict[str, dict[str, Any]] = { + # Scenario A - clean SME, well within limits, performing. + "DE-CORP-2024-00847": { + "legal_name": "Rheintal Präzisionstechnik GmbH", + "lei": "529900RHEINTAL000140", + "creditreform_id": "20401234", + "nace_code": "C25.62", + "nace_label": "Machining", + "iban": "DE89370400440532013000", + "country": "DE", + "ubos": [ + {"name": "Klara Hoffmann", "role": "Managing shareholder (62%)"}, + {"name": "Rheintal Beteiligungs GmbH", "role": "Holding (38%)"}, + ], + "financials": { + "fiscal_year": 2024, + "total_assets_eur": 12_400_000, + "total_equity_eur": 4_960_000, + "total_liabilities_eur": 7_440_000, + "revenue_eur": 18_900_000, + "ebitda_eur": 2_460_000, + "net_debt_eur": 3_100_000, + "auditor_opinion": "unqualified", + }, + "bureau_index": 178, # Creditreform Bonitätsindex (100 best..600) + "existing_group_exposure_eur": 900_000, + "sanctions_hit": False, + }, + # Scenario B - strong obligor but the new facility breaches the internal + # concentration limit and exceeds delegated authority. + "DE-CORP-2024-01120": { + "legal_name": "Nordwind Logistik AG", + "lei": "391200NORDWIND000113", + "creditreform_id": "29004567", + "nace_code": "H52.29", + "nace_label": "Other transportation support activities", + "iban": "DE58500700100923456789", + "country": "DE", + "ubos": [ + {"name": "Nordwind Holding SE", "role": "Parent (100%)"}, + ], + "financials": { + "fiscal_year": 2024, + "total_assets_eur": 47_800_000, + "total_equity_eur": 15_300_000, + "total_liabilities_eur": 32_500_000, + "revenue_eur": 61_200_000, + "ebitda_eur": 7_950_000, + "net_debt_eur": 21_400_000, + "auditor_opinion": "unqualified", + }, + "bureau_index": 205, + "existing_group_exposure_eur": 1_450_000, + "sanctions_hit": False, + }, + # Scenario C - a beneficial owner matches a sanctions / PEP list. + "AE-CORP-2024-00311": { + "legal_name": "Meridian Trading DMCC", + "lei": "529900MERIDIAN000175", + "creditreform_id": None, + "nace_code": "G46.90", + "nace_label": "Non-specialised wholesale trade", + "iban": None, + "country": "AE", + "ubos": [ + {"name": "Dmitri V. Aslanov", "role": "Beneficial owner (55%)"}, + {"name": "Meridian Holdings Ltd", "role": "Holding (45%)"}, + ], + "financials": { + "fiscal_year": 2023, + "total_assets_eur": 3_100_000, + "total_equity_eur": 420_000, + "total_liabilities_eur": 2_680_000, + "revenue_eur": 9_400_000, + "ebitda_eur": 210_000, + "net_debt_eur": 2_260_000, + "auditor_opinion": "not_available", + }, + "bureau_index": None, + "existing_group_exposure_eur": 0, + "sanctions_hit": True, + }, +} + +DEFAULT_CLIENT = "DE-CORP-2024-00847" + +# Internal rating masterscale: (upper bound of Creditreform index, grade, PD). +_RATING_BANDS = [ + (150, "1b", 0.0018), + (200, "2b", 0.0061), + (250, "3b", 0.0140), + (300, "4b", 0.0325), + (350, "5b", 0.0710), + (600, "6b", 0.1600), +] + + +def _client(client_id: str) -> dict[str, Any]: + return CLIENTS.get(client_id, CLIENTS[DEFAULT_CLIENT]) + + +def read_financials(client_id: str) -> dict[str, Any]: + """Return the latest filed financials plus derived ratios.""" + c = _client(client_id) + f = c["financials"] + equity_ratio = round(f["total_equity_eur"] / f["total_assets_eur"], 3) + net_debt_to_ebitda = ( + round(f["net_debt_eur"] / f["ebitda_eur"], 2) if f["ebitda_eur"] else None + ) + ebitda_margin = round(f["ebitda_eur"] / f["revenue_eur"], 3) if f["revenue_eur"] else None + return { + "client_id": client_id, + "legal_name": c["legal_name"], + "lei": c["lei"], + "nace_code": c["nace_code"], + "nace_label": c["nace_label"], + "document_type": "annual_financial_statements", + "fiscal_year": f["fiscal_year"], + "total_assets_eur": f["total_assets_eur"], + "total_equity_eur": f["total_equity_eur"], + "total_liabilities_eur": f["total_liabilities_eur"], + "revenue_eur": f["revenue_eur"], + "ebitda_eur": f["ebitda_eur"], + "net_debt_eur": f["net_debt_eur"], + "equity_ratio": equity_ratio, + "ebitda_margin": ebitda_margin, + "net_debt_to_ebitda": net_debt_to_ebitda, + "auditor_opinion": f["auditor_opinion"], + "status": "retrieved", + } + + +def screen_sanctions(client_id: str) -> dict[str, Any]: + """Screen the entity and its beneficial owners (CDD / AML).""" + c = _client(client_id) + lists = [ + "EU Consolidated Financial Sanctions (CFSP)", + "UN Security Council Consolidated List", + "PEP", + ] + matches: list[dict[str, Any]] = [] + if c["sanctions_hit"]: + matches.append({ + "matched_entity": c["ubos"][0]["name"], + "match_type": "beneficial_owner", + "list": "EU Consolidated Financial Sanctions (CFSP)", + "score": 0.94, + }) + return { + "client_id": client_id, + "legal_name": c["legal_name"], + "lei": c["lei"], + "lists_checked": lists, + "entities_screened": [c["legal_name"]] + [u["name"] for u in c["ubos"]], + "matches": matches, + "cdd_status": "hit" if matches else "clear", + "status": "completed", + } + + +def bureau_report(client_id: str, bureau: str = "creditreform") -> dict[str, Any]: + """Return a commercial credit-bureau report. + + Uses the Creditreform Bonitätsindex scale (100 best, 600 = hard negative), + not a US FICO-style score. + """ + c = _client(client_id) + index = c["bureau_index"] + return { + "client_id": client_id, + "bureau": bureau, + "creditreform_id": c["creditreform_id"], + "bonitaetsindex": index, + "scale": "100-600 (100 = excellent, 600 = hard negative)", + "assessment": _bureau_band(index), + "status": "retrieved" if index is not None else "no_file", + } + + +def _bureau_band(index: int | None) -> str: + if index is None: + return "no_commercial_credit_file" + if index <= 149: + return "excellent" + if index <= 199: + return "very_good" + if index <= 249: + return "good" + if index <= 299: + return "medium" + if index <= 349: + return "weak" + return "very_weak" + + +def aggregate_exposure(client_id: str, proposed_facility_eur: int) -> dict[str, Any]: + """Aggregate existing group exposure with the proposed facility and test it + against the internal single-obligor concentration limit.""" + c = _client(client_id) + existing = c["existing_group_exposure_eur"] + aggregate = existing + proposed_facility_eur + utilisation = round(aggregate / CONCENTRATION_LIMIT_EUR, 3) + return { + "client_id": client_id, + "existing_group_exposure_eur": existing, + "proposed_facility_eur": proposed_facility_eur, + "aggregate_exposure_eur": aggregate, + "concentration_limit_eur": CONCENTRATION_LIMIT_EUR, + "limit_utilisation": utilisation, + "breaches_concentration_limit": aggregate > CONCENTRATION_LIMIT_EUR, + "framework": "CRR Art. 395 (large exposures); internal risk-appetite limit", + "status": "completed", + } + + +def run_risk_model(client_id: str, proposed_facility_eur: int) -> dict[str, Any]: + """Compute PD / LGD / EAD, the internal rating grade and the IFRS 9 stage.""" + c = _client(client_id) + index = c["bureau_index"] + grade, pd = _rating_from_index(index) + f = c["financials"] + # A simple, legible IFRS 9 staging rule for the demo: unaudited or very + # weak files are treated as under-performing / credit-impaired. + if f["auditor_opinion"] == "not_available" or (index is not None and index > 349): + ifrs9_stage = 3 + elif index is not None and index > 249: + ifrs9_stage = 2 + else: + ifrs9_stage = 1 + lgd = 0.35 # senior secured corporate, illustrative + ead = proposed_facility_eur + expected_loss = round(pd * lgd * ead) if pd is not None else None + return { + "client_id": client_id, + "internal_rating": grade, + "pd_1y": pd, + "lgd": lgd, + "ead_eur": ead, + "expected_loss_eur": expected_loss, + "ifrs9_stage": ifrs9_stage, + "model_id": "corp-pd-lgd-v2.3", + "status": "completed", + } + + +def _rating_from_index(index: int | None) -> tuple[str | None, float | None]: + if index is None: + return "unrated", None + for upper, grade, pd in _RATING_BANDS: + if index <= upper: + return grade, pd + return "6b", 0.16 diff --git a/financial-services/policy/allow.cedar b/financial-services/policy/allow.cedar index 517f47d..298f26a 100644 --- a/financial-services/policy/allow.cedar +++ b/financial-services/policy/allow.cedar @@ -1,65 +1,128 @@ -// Cedar policy bundle for EU credit risk agent -// version: credit-risk-v4.2 +// Cedar policy bundle for EU corporate credit risk agent +// version: credit-risk-v5.0 // author: compliance@bank.eu // -// Action names follow the cMCP convention: tool_name converted to -// PascalCase per underscore segment, e.g. finance.risk_report_writer -// becomes Action::"Finance.riskReportWriter". +// Action names follow the cMCP convention: the segment before the dot is +// PascalCase and each underscore segment after it is camelCase, e.g. +// finance.risk_report_writer becomes Action::"Finance.riskReportWriter". // // There is deliberately NO catch-all permit: anything not explicitly -// permitted below is denied (Cedar default-deny). The workflow_id is -// supplied by the agent via the _cmcp request metadata; calls without -// it are denied. +// permitted below is denied (Cedar default-deny). The workflow_id is supplied +// by the agent via the _cmcp request metadata; calls without it are denied. + +// --- Workflow-scoped permits ------------------------------------------------- +// The credit-risk-analyst workflow may run each step of the assessment. -// Rule 1: the credit-risk-analyst workflow may read client documents. permit ( principal, action == Action::"Finance.documentReader", resource ) when { - context has workflow_id && - context.workflow_id == "credit-risk-analyst" + context has workflow_id && context.workflow_id == "credit-risk-analyst" }; -// Rule 2: the credit-risk-analyst workflow may query credit bureaus. permit ( principal, - action == Action::"Finance.creditScoreLookup", + action == Action::"Finance.sanctionsScreening", resource ) when { - context has workflow_id && - context.workflow_id == "credit-risk-analyst" + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.creditBureauLookup", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.exposureAggregation", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.riskModel", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" }; -// Rule 3: the credit-risk-analyst workflow may write risk reports. -// Subject to the large-exposure forbid below. permit ( principal, action == Action::"Finance.riskReportWriter", resource ) when { - context has workflow_id && - context.workflow_id == "credit-risk-analyst" + context has workflow_id && context.workflow_id == "credit-risk-analyst" }; -// Rule 4: large-exposure escalation (MiFID II Art. 25). -// Any risk report above 500,000 EUR requires a human reviewer. The -// annotations are returned to the caller as structured advice on deny. -@id("large-exposure-hitl") +// --- Guardrails on writing the assessment ------------------------------------ +// Each forbid overrides the permits above and carries structured advice back +// to the caller in the deny response (error.data.advice). + +// Customer due diligence must be cleared before any assessment is written. +// A sanctions or PEP hit leaves cdd_cleared false and blocks the write, so the +// gate is enforced by the runtime rather than left to the agent's good +// behaviour. +@id("cdd-clearance-required") +@reason("cdd-clearance-required") +@regulation("eu-aml-regulation-2024-1624") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + !(context.arguments has cdd_cleared) || context.arguments.cdd_cleared != true +}; + +// Facilities above the delegated lending authority require a human credit +// decision-maker (EBA loan-origination governance). +@id("delegated-authority-hitl") @reason("human-review-required") -@regulation("mifid-ii-art-25") -@escalation_threshold_eur("500000") +@regulation("eba-gl-2020-06") +@delegated_authority_limit_eur("500000") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context.arguments has amount_eur && context.arguments.amount_eur > 500000 +}; + +// The proposed facility must not breach the single-obligor concentration +// limit (the internal implementation of the CRR large-exposures regime). +@id("large-exposure-concentration") +@reason("concentration-limit-breached") +@regulation("crr-art-395") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context.arguments has breaches_concentration_limit && + context.arguments.breaches_concentration_limit == true +}; + +// Credit-impaired obligors (IFRS 9 stage 3) cannot be auto-approved; they go +// to manual restructuring review. +@id("credit-impaired-manual-review") +@reason("ifrs9-stage-3-credit-impaired") +@regulation("ifrs-9") forbid ( principal, action == Action::"Finance.riskReportWriter", resource ) when { - context.arguments has amount_eur && - context.arguments.amount_eur > 500000 + context.arguments has ifrs9_stage && context.arguments.ifrs9_stage == 3 }; -// Rule 5: confidential financial data may only flow through an attested -// runtime. attestation_platform is supplied by the gateway from the TEE +// Confidential (mnpi) financial data may only flow through an attested +// runtime. attestation_platform is supplied by the runtime from the TEE // attestation report; "unknown" means no attestation evidence is present. @id("require-attested-runtime") @reason("attested-runtime-required") @@ -69,6 +132,5 @@ forbid ( action, resource ) when { - context.compliance_domain == "mnpi" && - context.attestation_platform == "unknown" + context.compliance_domain == "mnpi" && context.attestation_platform == "unknown" }; diff --git a/financial-services/policy/manifest.json b/financial-services/policy/manifest.json index 0246430..6dc321b 100644 --- a/financial-services/policy/manifest.json +++ b/financial-services/policy/manifest.json @@ -1,5 +1,5 @@ { - "version": "credit-risk-v4.2", + "version": "credit-risk-v5.0", "authored_at": "2026-06-01T00:00:00Z", "author_identity": "compliance@bank.eu", "commit_sha": "abc123def456" diff --git a/financial-services/server/mock_mcp_server.py b/financial-services/server/mock_mcp_server.py index b481bea..9c561c2 100644 --- a/financial-services/server/mock_mcp_server.py +++ b/financial-services/server/mock_mcp_server.py @@ -2,7 +2,10 @@ """ Mock EU Credit Risk MCP Server for the financial-services demo. -Serves the three catalog tools with canned responses on port 8080. +Serves the six catalog tools on port 8080. Tool responses are computed by +``credit_engine`` from a small set of realistic EU corporate client fixtures, +so the server, the tests and the agent all agree on the same data. + Stdlib only -- no dependencies. Usage: @@ -10,37 +13,51 @@ """ import json +import sys from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import credit_engine # noqa: E402 PORT = 8080 def _document_reader(args: dict) -> str: - return json.dumps({ - "document_id": args.get("document_id", ""), - "client_id": args.get("client_id", ""), - "document_type": "balance_sheet", - "period": "2024-Q4", - "total_assets_eur": 4_820_000, - "total_liabilities_eur": 3_140_000, - "status": "retrieved", - }) + return json.dumps(credit_engine.read_financials(args.get("client_id", credit_engine.DEFAULT_CLIENT))) -def _credit_score_lookup(args: dict) -> str: - return json.dumps({ - "client_id": args.get("client_id", ""), - "bureau": args.get("bureau", "equifax"), - "score": 742, - "scale": "280-850", - "retrieved_at": "2026-06-10T09:00:00Z", - }) +def _sanctions_screening(args: dict) -> str: + return json.dumps(credit_engine.screen_sanctions(args.get("client_id", credit_engine.DEFAULT_CLIENT))) + + +def _credit_bureau_lookup(args: dict) -> str: + return json.dumps(credit_engine.bureau_report( + args.get("client_id", credit_engine.DEFAULT_CLIENT), + args.get("bureau", "creditreform"), + )) + + +def _exposure_aggregation(args: dict) -> str: + return json.dumps(credit_engine.aggregate_exposure( + args.get("client_id", credit_engine.DEFAULT_CLIENT), + int(args.get("proposed_facility_eur", 0)), + )) + + +def _risk_model(args: dict) -> str: + return json.dumps(credit_engine.run_risk_model( + args.get("client_id", credit_engine.DEFAULT_CLIENT), + int(args.get("proposed_facility_eur", 0)), + )) def _risk_report_writer(args: dict) -> str: return json.dumps({ "client_id": args.get("client_id", ""), - "risk_score": args.get("risk_score"), + "internal_rating": args.get("internal_rating"), "recommendation": args.get("recommendation"), "amount_eur": args.get("amount_eur"), "report_id": "RR-2026-04471", @@ -50,7 +67,10 @@ def _risk_report_writer(args: dict) -> str: TOOLS = { "finance.document_reader": _document_reader, - "finance.credit_score_lookup": _credit_score_lookup, + "finance.sanctions_screening": _sanctions_screening, + "finance.credit_bureau_lookup": _credit_bureau_lookup, + "finance.exposure_aggregation": _exposure_aggregation, + "finance.risk_model": _risk_model, "finance.risk_report_writer": _risk_report_writer, } diff --git a/financial-services/tests/test_credit_engine.py b/financial-services/tests/test_credit_engine.py new file mode 100644 index 0000000..3136c5f --- /dev/null +++ b/financial-services/tests/test_credit_engine.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import credit_engine # noqa: E402 + + +class CreditEngineTests(unittest.TestCase): + CLEAN = "DE-CORP-2024-00847" + LARGE = "DE-CORP-2024-01120" + SANCTIONED = "AE-CORP-2024-00311" + + def test_lei_check_digits_are_valid(self) -> None: + """Every fixture LEI passes the ISO 7064 MOD 97-10 check.""" + for client in credit_engine.CLIENTS.values(): + lei = client["lei"] + self.assertEqual(len(lei), 20, lei) + digits = "".join(str(int(c, 36)) if c.isalpha() else c for c in lei) + self.assertEqual(int(digits) % 97, 1, f"invalid LEI check digits: {lei}") + + def test_clean_client_clears_cdd(self) -> None: + result = credit_engine.screen_sanctions(self.CLEAN) + self.assertEqual(result["cdd_status"], "clear") + self.assertEqual(result["matches"], []) + + def test_sanctioned_ubo_produces_a_hit(self) -> None: + result = credit_engine.screen_sanctions(self.SANCTIONED) + self.assertEqual(result["cdd_status"], "hit") + self.assertEqual(result["matches"][0]["match_type"], "beneficial_owner") + + def test_bureau_uses_creditreform_scale_not_fico(self) -> None: + report = credit_engine.bureau_report(self.CLEAN) + self.assertEqual(report["bonitaetsindex"], 178) + self.assertEqual(report["assessment"], "very_good") + self.assertIn("100-600", report["scale"]) + + def test_exposure_within_limit_does_not_breach(self) -> None: + agg = credit_engine.aggregate_exposure(self.CLEAN, 250_000) + self.assertEqual(agg["aggregate_exposure_eur"], 1_150_000) + self.assertFalse(agg["breaches_concentration_limit"]) + + def test_exposure_over_limit_breaches(self) -> None: + agg = credit_engine.aggregate_exposure(self.LARGE, 750_000) + self.assertEqual(agg["aggregate_exposure_eur"], 2_200_000) + self.assertTrue(agg["breaches_concentration_limit"]) + + def test_performing_client_is_stage_1_and_rated(self) -> None: + model = credit_engine.run_risk_model(self.CLEAN, 250_000) + self.assertEqual(model["ifrs9_stage"], 1) + self.assertEqual(model["internal_rating"], "2b") + self.assertEqual(model["ead_eur"], 250_000) + self.assertIsNotNone(model["expected_loss_eur"]) + + def test_unaudited_client_is_credit_impaired(self) -> None: + model = credit_engine.run_risk_model(self.SANCTIONED, 200_000) + self.assertEqual(model["ifrs9_stage"], 3) + + def test_financial_ratios_are_derived(self) -> None: + fin = credit_engine.read_financials(self.CLEAN) + self.assertEqual(fin["equity_ratio"], 0.4) + self.assertEqual(fin["net_debt_to_ebitda"], 1.26) + + +if __name__ == "__main__": + unittest.main() diff --git a/financial-services/trace-output/clean-trust-record.json b/financial-services/trace-output/clean-trust-record.json new file mode 100644 index 0000000..5226a99 --- /dev/null +++ b/financial-services/trace-output/clean-trust-record.json @@ -0,0 +1,128 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784610809, + "subject": "spiffe://cmcp.gateway/tee/e36c15f8abfcb5c4", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:6ed4ecc2813edf8e8e86fefc5781df3162b81bfe2ba18c44e6d6ae054d318a79", + "enforcement_mode": "enforce", + "version": "credit-risk-v5.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:5d8f7ced02b34bd905f8f061d1811537a165fd169eef200fc8777b5e5d2189e2", + "call_count": 6, + "entries": [ + { + "tool_name": "finance.document_reader", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.sanctions_screening", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.exposure_aggregation", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_model", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_report_writer", + "data_class": "confidential", + "decision": "allow" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "42wV-Kv8tcSo8JfX42tAsleWpFqY7yp_cf3IUSfynPs", + "kid": "cmcp-e36c15f8" + } + } + }, + "gateway": { + "session_id": "394e8095-55be-4100-b417-010c9b1da7e0", + "gateway_version": "0.3.0", + "sequence_number": 2, + "prev_claim_hash": "sha256:9c91f4c53a505b65f9ed7e8d76f45803e76640ce3ee32ece478dbe4147c92b4c", + "audit_chain": { + "root": "0d5885626021fa38c1be2a3954fb4ef6c3022ee21666643fa05fa13012c757d8", + "tip": "5d8f7ced02b34bd905f8f061d1811537a165fd169eef200fc8777b5e5d2189e2", + "length": 8 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 6, + "tool_calls_denied": 0, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_bureau_lookup", + "finance.document_reader", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer", + "finance.sanctions_screening" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "6dade2f6-89f7-426e-bd77-a59bea604615", + "tool_name": "finance.exposure_aggregation", + "sequence_number": 3 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4332bdb99e95034e582019c93a176674bc2b2280c872e96ec574c2da8b342e03", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T05:12:13.092919+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 6, + "tools_called": [ + "finance.document_reader", + "finance.sanctions_screening", + "finance.credit_bureau_lookup", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "0oHUJaLnHAxueJtoLvrd4gzCgn8g-zDIwAu4JJ3zAV-BeXeJIVp3669N26ADb5B7EJu51dj8m_Q0T6VUHnf2CA" +} diff --git a/financial-services/trace-output/example-trust-record.json b/financial-services/trace-output/example-trust-record.json deleted file mode 100644 index d6cc5cd..0000000 --- a/financial-services/trace-output/example-trust-record.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "cmcp_version": "1.0", - "trace": { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1781193684, - "subject": "spiffe://cmcp.gateway/session/7bab4703-706e-4f2e-80b3-c3611832cda8", - "runtime": { - "platform": "tpm2", - "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "firmware_version": "software-only-dev-mode" - }, - "policy": { - "bundle_hash": "sha256:9650dec5dec7afbc8608f0bdb29b9266c1f05990be603255e806479a76e9e23e", - "enforcement_mode": "enforce", - "version": "credit-risk-v4.2" - }, - "data_class": "confidential", - "tool_transcript": { - "hash": "sha256:bf92310550233e5363441d0cc3b4158480e86e1bee4b958123bb39e7c63be262", - "call_count": 4 - }, - "cnf": { - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "ZlmbQ6r1FqVml6LsKDifDR4grPSBhhRYZMqc8o0DdAc", - "kid": "cmcp-66599b43" - } - } - }, - "gateway": { - "session_id": "7bab4703-706e-4f2e-80b3-c3611832cda8", - "gateway_version": "unknown", - "sequence_number": 3, - "prev_claim_hash": "sha256:64a2493a624c547e12a5395a3bb1d23c8b42e077277bb88732ab5c7209da7e56", - "audit_chain": { - "root": "17e9e977e3a1307f2a738ef1dedc3a879be3065ef9111b06b3e0644517f59457", - "tip": "bf92310550233e5363441d0cc3b4158480e86e1bee4b958123bb39e7c63be262", - "length": 6 - }, - "call_summary": { - "tool_calls_total": 4, - "tool_calls_allowed": 3, - "tool_calls_denied": 1, - "tool_calls_faulted": 0, - "tools_invoked": [ - "finance.credit_score_lookup", - "finance.document_reader", - "finance.risk_report_writer" - ], - "session_max_sensitivity": "confidential", - "call_graph_summary": { - "compliance_domains_touched": [ - "internal", - "mnpi", - "pii" - ], - "cross_boundary_events": [ - { - "from_domain": "pii", - "to_domain": "internal", - "call_id": "1fb8e8c8-d709-4fda-914b-81bbd9a872db", - "tool_name": "finance.risk_report_writer", - "sequence_number": 3 - } - ], - "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." - } - }, - "catalog": { - "hash": "sha256:02640e5bd370cd9e2d383f07dcab8d2d3c5e9e0155ee14c56556d6e7645d86ca", - "drift_detected": false - }, - "attestation_generated_at": "2026-06-11T16:01:05.969369+00:00", - "attestation_validity_seconds": 86400, - "attestation_stale": false, - "catalog_exceptions": [], - "call_log_summary": { - "total_calls": 4, - "tools_called": [ - "finance.document_reader", - "finance.credit_score_lookup", - "finance.risk_report_writer" - ], - "suspicious_sequences_detected": 0 - } - }, - "signature": "Eb4r3WoFBeJ86Zz2DNQPnCCLG11BegMgod-Kjyf6J9V3uKElF5ZiY-SJ5MQwj24EboeXFsU1lKf2Z29ui6FbBA" -} diff --git a/financial-services/trace-output/large-exposure-trust-record.json b/financial-services/trace-output/large-exposure-trust-record.json new file mode 100644 index 0000000..5331dd1 --- /dev/null +++ b/financial-services/trace-output/large-exposure-trust-record.json @@ -0,0 +1,128 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784610814, + "subject": "spiffe://cmcp.gateway/tee/e36c15f8abfcb5c4", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:6ed4ecc2813edf8e8e86fefc5781df3162b81bfe2ba18c44e6d6ae054d318a79", + "enforcement_mode": "enforce", + "version": "credit-risk-v5.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:f2a45e0baa83eaaf3500d78e7d2207ea34692e3963fe611e8087447c21300f1c", + "call_count": 6, + "entries": [ + { + "tool_name": "finance.document_reader", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.sanctions_screening", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.exposure_aggregation", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_model", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_report_writer", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "42wV-Kv8tcSo8JfX42tAsleWpFqY7yp_cf3IUSfynPs", + "kid": "cmcp-e36c15f8" + } + } + }, + "gateway": { + "session_id": "f752c840-7c73-452d-a984-1887417d2d5d", + "gateway_version": "0.3.0", + "sequence_number": 3, + "prev_claim_hash": "sha256:2800f6ce64289cf0f92c3aabd5b4be6bec0815cd475bbbd699cc8a9cc87a4f25", + "audit_chain": { + "root": "3377da44e3074a7a1b956db8ccd0a1ed41178b85c12ae8810047fde243cb4260", + "tip": "f2a45e0baa83eaaf3500d78e7d2207ea34692e3963fe611e8087447c21300f1c", + "length": 8 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 5, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_bureau_lookup", + "finance.document_reader", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer", + "finance.sanctions_screening" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "60e3a027-8c58-4275-84cd-0ebbf2669399", + "tool_name": "finance.exposure_aggregation", + "sequence_number": 3 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4332bdb99e95034e582019c93a176674bc2b2280c872e96ec574c2da8b342e03", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T05:12:13.092919+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 6, + "tools_called": [ + "finance.document_reader", + "finance.sanctions_screening", + "finance.credit_bureau_lookup", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "KdjlwOfVLf_-nrdvpXOTcl2KSNKTpBWwdWHu91dKdxQHfxvgT35ivW-yhSi9mbC7v3uiM9DQ6UDaongMUN-OBQ" +} diff --git a/financial-services/trace-output/sanctions-hit-trust-record.json b/financial-services/trace-output/sanctions-hit-trust-record.json new file mode 100644 index 0000000..5220a75 --- /dev/null +++ b/financial-services/trace-output/sanctions-hit-trust-record.json @@ -0,0 +1,128 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784610818, + "subject": "spiffe://cmcp.gateway/tee/e36c15f8abfcb5c4", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:6ed4ecc2813edf8e8e86fefc5781df3162b81bfe2ba18c44e6d6ae054d318a79", + "enforcement_mode": "enforce", + "version": "credit-risk-v5.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:78c0e5c9f30f2053fc66ddc6476f434a5c24d9f4d5a0cd745929a04d4f4b2885", + "call_count": 6, + "entries": [ + { + "tool_name": "finance.document_reader", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.sanctions_screening", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.exposure_aggregation", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_model", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_report_writer", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "42wV-Kv8tcSo8JfX42tAsleWpFqY7yp_cf3IUSfynPs", + "kid": "cmcp-e36c15f8" + } + } + }, + "gateway": { + "session_id": "bf50e9f3-77d9-4f00-93d3-8fa84f656875", + "gateway_version": "0.3.0", + "sequence_number": 4, + "prev_claim_hash": "sha256:54f88db30aabb98564ad6b7a548a9078b33b8cdee1004fdc1818b48c3812c46d", + "audit_chain": { + "root": "dc5afb2b6d292c998d1772bbe6d3f9ed1912d558e3c336d8d2297f2d273d89d7", + "tip": "78c0e5c9f30f2053fc66ddc6476f434a5c24d9f4d5a0cd745929a04d4f4b2885", + "length": 8 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 5, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_bureau_lookup", + "finance.document_reader", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer", + "finance.sanctions_screening" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "3d7fa065-5648-418e-a73e-e83b9a7dd4ee", + "tool_name": "finance.exposure_aggregation", + "sequence_number": 3 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4332bdb99e95034e582019c93a176674bc2b2280c872e96ec574c2da8b342e03", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T05:12:13.092919+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 6, + "tools_called": [ + "finance.document_reader", + "finance.sanctions_screening", + "finance.credit_bureau_lookup", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "k4hytlJSgqejJapn89SHRf-49kxvLB9tXd-YFb7MRNpEjGuywJUWTkwxFxkP18cLzxeYRC8WNCVE0y6RVMMCDA" +}