All demos
FintechLangGraph

Credit Underwriting

Borrower application + uploaded bank statement → underwriting recommendation

Ready to replay
0.0s / 9.9s

Security Pipeline

Input
Sandbox
Network
PII Scan
Injection
Vault
LLM Call
Result
Real run · LangGraph · python run.py · input: borrower c-001 — Aarav Sharma (CIBIL 762) · captured 2026-06-30 · SDK 1.3.0
Run it yourself
View the agent code· with Declawfintech-workflows/sandboxed/01-credit-underwriting-langgraph/run.py
"""Credit Underwriting workflow (LangGraph) — sandboxed with Declaw, real GPT-4.1.

Governance posture (see ../../GOVERNANCE.md): the binding credit decision is made
by a **deterministic rule engine** (`_score_to_decision`), the **LLM only writes
the explanation**, and **every outcome — approve included — routes through an
officer-confirmation gate** before it is binding. That is the RBI/SR-11-7/EU-AI-Act
"don't delegate a material decision to an opaque model" shape, and it holds in
every target jurisdiction. The jurisdiction itself is a thin overlay
(DECLAW_JURISDICTION): it sets the adverse-action format (US ECOA reason codes vs
reasoned explanation), the GDPR Art. 22 human-review affordance, and the declaw
OPA governance pack attached to the LLM sandbox (one `policy_ref` swap).

Two sandboxed steps:
  1. statement_parse  — wrapped in kyc_document_policy (PII redact+rehydrate,
     injection scanned with the data-egress-sensitive posture + Tier-2 Gemma
     judge at threshold 0.5) so an injected memo in the bank statement is
     detected and recorded in the audit trail (action=log_only here; the
     enforcing action=block variant is proven in verify_security_primitives.py).
  2. explain          — wrapped in lending_llm_policy (PII redact+rehydrate +
     the jurisdiction's governance pack) so raw PAN/Aadhaar/SSN/CIBIL never reach
     OpenAI; the proxy replaces them with [REDACTED_*] tokens, then rehydrates
     them in the response.

Demo recipe:
  c-003 -> rule engine DECLINEs; LLM writes a jurisdiction-formatted adverse-
           action notice; pending officer confirmation
  c-001 -> rule engine APPROVEs; pending officer confirmation (not auto-sanctioned)
  c-002 -> adversarial memo in statement is DETECTED + audited by kyc_document_policy
"""
from __future__ import annotations

import json
import sys
import textwrap
from pathlib import Path
from typing import Annotated, Literal, TypedDict

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph

REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
sys.path.insert(0, str(REPO_ROOT / "sandboxed"))

from shared.mock_customers import CUSTOMERS, LENDING_POLICIES  # noqa: E402
from shared.mock_bureau import cibil_report, fico_report  # noqa: E402
from shared.mock_statements import STATEMENTS  # noqa: E402
from shared.declaw_helpers import (  # noqa: E402
    LLM_DOMAINS, LLM_PIP, lending_llm_policy, kyc_document_policy,
    llm_envs, run_python_in_sandbox,
)
from shared import governance as gov  # noqa: E402


# ---------- State ----------

class UnderwritingState(TypedDict, total=False):
    customer_id: str
    loan_amount: int
    policy_key: str
    customer_profile: dict
    bureau_report: dict
    statement: dict
    alt_data: dict
    risk_score: dict
    decision: Literal["approve", "decline", "borderline"]
    explanation: str
    adverse_action: dict          # jurisdiction-formatted reason(s) for the customer
    status: str                   # gate state: PENDING_HUMAN_CONFIRMATION until an officer signs off
    human_review_notes: str
    audit_log: Annotated[list[dict], "append-only audit trail"]


# ---------- Sandbox scripts ----------

STATEMENT_PARSE_SCRIPT = textwrap.dedent("""
    import json, re

    with open("/tmp/in.json") as f:
        inp = json.load(f)

    narration = inp.get("narration", "")
    bank = inp.get("bank", "")
    period = inp.get("period", "")
    opening = inp.get("opening_balance", 0)
    closing = inp.get("closing_balance", 0)

    # Extract credit/debit lines by simple pattern
    credits = sum(float(m) for m in re.findall(r"CREDIT\\s+([\\d,.]+)", narration))
    debits  = sum(float(m) for m in re.findall(r"DEBIT\\s+([\\d,.]+)",  narration))

    out = {
        "bank": bank,
        "period": period,
        "opening_balance": opening,
        "closing_balance": closing,
        "total_credits": credits,
        "total_debits": debits,
        "cash_flow_delta": closing - opening,
        # Narration passed through; injection defense has already scanned it
        # (data-egress-sensitive + Tier-2 judge, log_only) and any injected
        # directives are detected and recorded in the audit trail.
        "narration": narration,
    }
    with open("/tmp/out.json", "w") as f:
        json.dump(out, f)
""")


EXPLAIN_SCRIPT = textwrap.dedent("""
    import json
    from openai import OpenAI

    with open("/tmp/in.json") as f:
        inp = json.load(f)

    client = OpenAI()
    resp = client.chat.completions.create(
        model="gpt-4.1",
        messages=[
            {"role": "system", "content": (
                "You are a fair-lending credit analyst. Produce a concise, "
                "plain-text explanation of the lending decision. Do NOT reference "
                "caste, religion, gender, or any protected characteristic. Cite "
                "only financial criteria. If declined, provide at least one "
                "actionable improvement suggestion. If you see REDACTED_* tokens "
                "treat them as opaque identifiers for the applicant."
            )},
            {"role": "user", "content": json.dumps(inp)},
        ],
        max_completion_tokens=400,
    )
    with open("/tmp/out.json", "w") as f:
        json.dump({"explanation": resp.choices[0].message.content}, f)
""")


# ---------- Sandboxed helpers ----------

def _parse_statement_sandboxed(stmt: dict) -> dict:
    pol = kyc_document_policy(allow_domains=["fs.internal.example"])
    payload = {
        "bank": stmt.get("bank"),
        "period": stmt.get("period"),
        "opening_balance": stmt.get("opening_balance_inr") or stmt.get("opening_balance_usd", 0),
        "closing_balance": stmt.get("closing_balance_inr") or stmt.get("closing_balance_usd", 0),
        "narration": stmt.get("narration", ""),
    }
    return run_python_in_sandbox("kyc-statement-parse", STATEMENT_PARSE_SCRIPT, pol, payload=payload)


def _explain_sandboxed(payload: dict) -> str:
    # Jurisdiction overlay: attach the region's OPA governance pack to the LLM
    # sandbox — the same agent, made region-appropriate by one policy_ref swap.
    pol = lending_llm_policy(
        allow_domains=LLM_DOMAINS,
        governance_pack=gov.governance_pack(),
    )
    out = run_python_in_sandbox(
        "lending-explain-llm", EXPLAIN_SCRIPT, pol,
        payload=payload, pip_packages=LLM_PIP, envs=llm_envs(),
    )
    return out.get("explanation", "")


# ---------- Helpers ----------

def _score_to_decision(cibil: int, income: int, policy: dict) -> tuple[str, float]:
    if cibil >= 700 and income >= 25000:
        return "approve", 0.85
    if cibil >= 620 and income >= 25000:
        return "borderline", 0.52
    return "decline", 0.18


# ---------- Graph nodes ----------

def gather(state: UnderwritingState) -> UnderwritingState:
    c = CUSTOMERS[state["customer_id"]]
    profile = {
        "id": c.id,
        "name": c.name,
        "pan": c.pan,
        "aadhaar": c.aadhaar,
        "ssn": c.ssn,
        "email": c.email,
        "phone": c.phone,
        "address": c.address,
        "monthly_income": c.monthly_income,
        "employment": c.employment,
        "gstin": c.gstin,
        "ein": c.ein,
    }
    print(f"[node gather] customer={c.id} name={c.name} "
          "(sandboxed, PII redacted+rehydrated at proxy in downstream LLM steps)")
    return {
        "customer_profile": profile,
        "audit_log": [{"node": "gather", "customer_id": state["customer_id"]}],
    }


def bureau_pull(state: UnderwritingState) -> UnderwritingState:
    cid = state["customer_id"]
    cibil = cibil_report(cid)
    fico = fico_report(cid)
    print(f"[node bureau_pull] CIBIL={cibil['score']} band={cibil['score_band']} "
          "(sandboxed, PAN/score redacted before LLM egress)")
    return {
        "bureau_report": {"cibil": cibil, "fico": fico},
        "audit_log": [{"node": "bureau_pull", "cibil_score": cibil["score"]}],
    }


def statement_parse(state: UnderwritingState) -> UnderwritingState:
    """Sandboxed: kyc_document_policy scans injection + redacts PII in untrusted IO."""
    cid = state["customer_id"]
    stmt = STATEMENTS.get(cid, {})
    print(f"[node statement_parse] entering kyc_document_policy sandbox "
          "(injection scanned: data-egress-sensitive + Tier-2 judge, log_only, "
          "threshold=0.5 — adversarial memo is detected + audited)")
    parsed = _parse_statement_sandboxed(stmt)
    return {
        "statement": parsed,
        "audit_log": [{"node": "statement_parse", "sandboxed": True,
                       "bank": parsed.get("bank")}],
    }


def alt_data(state: UnderwritingState) -> UnderwritingState:
    cid = state["customer_id"]
    c = CUSTOMERS[cid]
    bureau = state.get("bureau_report", {})
    cibil_data = bureau.get("cibil", {})
    flags = cibil_data.get("flags", [])
    stmt = state.get("statement", {})
    opening = stmt.get("opening_balance") or 0
    closing = stmt.get("closing_balance") or 0
    alt = {
        "cash_flow_positive": closing > opening,
        "bureau_flags": flags,
        "thin_file": "no_bureau_history" in flags,
        "sub_prime_signals": "sub_prime" in flags or "written_off_last_36mo" in flags,
        "sanctions_check": "upi_sanctions" if cid == "c-003" else "clean",
        "income_inr": c.monthly_income,
    }
    return {
        "alt_data": alt,
        "audit_log": [{"node": "alt_data"}],
    }


def risk_score(state: UnderwritingState) -> UnderwritingState:
    policy = LENDING_POLICIES[state["policy_key"]]
    bureau = state["bureau_report"]
    cibil_score_val = bureau["cibil"]["score"]
    income = CUSTOMERS[state["customer_id"]].monthly_income
    decision, score = _score_to_decision(cibil_score_val, income, policy)
    risk = {
        "score": score,
        "decision": decision,
        "cibil": cibil_score_val,
        "income_monthly": income,
        "loan_amount": state.get("loan_amount", 0),
    }
    print(f"[node risk_score] score={score:.2f} decision={decision}")
    return {
        "risk_score": risk,
        "decision": decision,  # type: ignore[typeddict-item]
        "audit_log": [{"node": "risk_score", "decision": decision, "score": score}],
    }


def explain(state: UnderwritingState) -> UnderwritingState:
    """Sandboxed: lending_llm_policy redacts PII before LLM, rehydrates after."""
    profile = state["customer_profile"]
    bureau = state["bureau_report"]
    risk = state["risk_score"]
    policy = LENDING_POLICIES[state["policy_key"]]

    juris = gov.active_jurisdiction()
    print(f"[node explain] {gov.governance_banner(juris)}")
    print("[node explain] entering lending_llm_policy sandbox "
          "(PII redacted+rehydrated at proxy — PAN/Aadhaar/SSN/CIBIL never reach "
          f"OpenAI; governance pack {juris.governance_pack} attached)")

    payload = {
        "customer_name": profile["name"],
        "pan": profile["pan"],
        "aadhaar": profile["aadhaar"],
        "ssn": profile["ssn"],
        "cibil_score": risk["cibil"],
        "cibil_flags": bureau["cibil"]["flags"],
        "fico_score": bureau["fico"].get("score"),
        "decision": risk["decision"],
        "score": risk["score"],
        "loan_amount_inr": risk["loan_amount"],
        "policy_criteria": policy["criteria"],
    }
    explanation = _explain_sandboxed(payload)

    # The LLM only writes prose; the customer-facing adverse-action notice is
    # shaped deterministically per jurisdiction (US -> ECOA reason codes).
    out: UnderwritingState = {
        "explanation": explanation,
        "audit_log": [{"node": "explain", "sandboxed": True, "model": "gpt-4.1",
                       "jurisdiction": juris.code,
                       "governance_pack": juris.governance_pack}],
    }
    if risk["decision"] in ("decline", "borderline"):
        reasons = list(bureau["cibil"]["flags"]) or [
            f"CIBIL {risk['cibil']} below approval threshold"]
        out["adverse_action"] = gov.format_adverse_action(reasons, juris)
    return out


def officer_review(state: UnderwritingState) -> UnderwritingState:
    """Mandatory human gate — EVERY credit outcome (approve included) is held
    PENDING_HUMAN_CONFIRMATION here. The rule engine produced the recommendation
    and the LLM wrote the explanation; an officer owns the binding sanction. No
    funds are disbursed autonomously — that is the non-delegation requirement
    common to RBI SBR, US SR 11-7/ECOA, and the EU AI Act."""
    decision = state.get("decision")
    risk = state.get("risk_score", {})
    recommendation = {
        "approve": gov.RECOMMEND_APPROVE,
        "decline": gov.RECOMMEND_DECLINE,
        "borderline": gov.RECOMMEND_REVIEW,
    }.get(decision or "decline", gov.RECOMMEND_REVIEW)
    print(f"[node officer_review] {recommendation} — {gov.PENDING_HUMAN_CONFIRMATION} "
          f"(no autonomous sanction; officer signs off)")
    notes = (
        f"{recommendation}: rule-engine decision={decision}, "
        f"CIBIL={risk.get('cibil')}, score={risk.get('score', 0):.2f}. "
        f"Officer to confirm before the decision is binding "
        f"(verify income docs, re-check bureau flags)."
    )
    return {
        "status": gov.PENDING_HUMAN_CONFIRMATION,
        "human_review_notes": notes,
        "audit_log": [{"node": "officer_review", "recommendation": recommendation,
                       "status": gov.PENDING_HUMAN_CONFIRMATION}],
    }


# ---------- Graph ----------

def build_graph():
    g = StateGraph(UnderwritingState)
    g.add_node("gather", gather)
    g.add_node("bureau_pull", bureau_pull)
    g.add_node("statement_parse", statement_parse)
    g.add_node("alt_data", alt_data)
    g.add_node("risk_score", risk_score)
    g.add_node("explain", explain)
    g.add_node("officer_review", officer_review)

    g.add_edge(START, "gather")
    g.add_edge("gather", "bureau_pull")
    g.add_edge("bureau_pull", "statement_parse")
    g.add_edge("statement_parse", "alt_data")
    g.add_edge("alt_data", "risk_score")
    g.add_edge("risk_score", "explain")
    # Every outcome — approve included — passes through the officer gate.
    g.add_edge("explain", "officer_review")
    g.add_edge("officer_review", END)
    return g.compile(checkpointer=MemorySaver())


def _run_demo(graph, customer_id: str, loan_amount: int, policy_key: str, thread_id: str):
    initial: UnderwritingState = {
        "customer_id": customer_id,
        "loan_amount": loan_amount,
        "policy_key": policy_key,
    }
    config = {"configurable": {"thread_id": thread_id}}
    return graph.invoke(initial, config=config)


def _print_outcome(r: UnderwritingState) -> None:
    print(f"Rule-engine decision: {r.get('decision')}  ->  {r.get('status', '(no gate)')}")
    if r.get("human_review_notes"):
        print(f"Officer gate:         {r['human_review_notes']}")
    if r.get("adverse_action"):
        print(f"Adverse-action:       {json.dumps(r['adverse_action'])}")
    print(f"LLM explanation:      {r.get('explanation', '')[:280]}")


def main() -> None:
    print("=== Credit Underwriting (sandboxed, real LLM) ===")
    print(f"Governance: rule engine decides · LLM explains · officer confirms "
          f"every outcome · {gov.governance_banner()}\n")
    graph = build_graph()

    print("--- Demo 1: c-003 Rohan Desai (rule engine: DECLINE) ---")
    r1 = _run_demo(graph, "c-003", 200000, "personal_loan_india", "uw-sbx-c003")
    _print_outcome(r1)

    print("\n--- Demo 2: c-001 Aarav Sharma (rule engine: APPROVE — still officer-gated) ---")
    r2 = _run_demo(graph, "c-001", 500000, "personal_loan_india", "uw-sbx-c001")
    _print_outcome(r2)
    print("[NOTE] approve is NOT auto-sanctioned — it is held "
          f"{gov.PENDING_HUMAN_CONFIRMATION} for officer sign-off, same as a decline.")

    print("\n--- Demo 3: c-002 Priya Iyer (adversarial memo DETECTED by kyc_document_policy) ---")
    r3 = _run_demo(graph, "c-002", 1000000, "smb_working_capital_global", "uw-sbx-c002")
    _print_outcome(r3)
    print("[NOTE] Injection memo in c-002 statement was detected by kyc_document_policy "
          "(data-egress-sensitive + Tier-2 judge, log_only) and recorded in the audit "
          "trail; the decision is based on real financial signals only. The enforcing "
          "action=block variant is proven in verify_security_primitives.py (check 7).")


if __name__ == "__main__":
    main()
View raw audit JSON
[
  {
    "atMs": 450,
    "kind": "stage",
    "payload": {
      "stage": "sandbox",
      "status": "done",
      "detail": "Firecracker microVM · kyc_document_policy · own kernel · 11.3s real",
      "durationMs": 9300
    }
  },
  {
    "atMs": 1519,
    "kind": "stage",
    "payload": {
      "stage": "sandbox",
      "status": "done",
      "detail": "lending_llm_policy · guest 10.11.32.126 · baseline-hardening@v1"
    }
  },
  {
    "atMs": 2588,
    "kind": "network",
    "payload": {
      "event": "egress_allowed",
      "detail": {
        "host": "api.openai.com",
        "port": 443,
        "reason": "allowlist (only api.openai.com)"
      }
    }
  },
  {
    "atMs": 3656,
    "kind": "security",
    "payload": {
      "event": "pii_redaction",
      "detail": {
        "entities": [
          {
            "entity_type": "PERSON",
            "masked_value": "REDACTED_PERSON_2",
            "confidence": 0.85
          },
          {
            "entity_type": "PERSON",
            "masked_value": "REDACTED_PERSON_3",
            "confidence": 0.85
          },
          {
            "entity_type": "US_SSN",
            "masked_value": "REDACTED_US_SSN_38",
            "confidence": 0.5
          }
        ],
        "destination": "api.openai.com",
        "rehydrated_on_reply": true
      }
    }
  },
  {
    "atMs": 4725,
    "kind": "audit",
    "payload": {
      "event": "pii_redaction",
      "category": "security",
      "detail": {
        "entities": 3,
        "destination": "api.openai.com",
        "rehydrated": true
      },
      "timestamp": "2026-06-30T18:27:07.657406Z"
    }
  },
  {
    "atMs": 5794,
    "kind": "security",
    "payload": {
      "event": "injection_scan",
      "detail": {
        "source": "bank_statement (c-001)",
        "posture": "data-egress-sensitive + Tier-2 judge",
        "action": "log_only",
        "result": "no adversarial content in this statement"
      }
    }
  },
  {
    "atMs": 6862,
    "kind": "security",
    "payload": {
      "event": "vault_brokered",
      "detail": {
        "keys": "OPENAI_API_KEY",
        "host": "api.openai.com",
        "injected_at": "egress proxy",
        "exposure_to_vm": "none (declaw:vault-managed placeholder)"
      }
    }
  },
  {
    "atMs": 7931,
    "kind": "stage",
    "payload": {
      "stage": "llm",
      "status": "done",
      "detail": "gpt-4.1 called from inside the microVM (PII redacted on the wire) · 14.6s real",
      "durationMs": 1819
    }
  },
  {
    "atMs": 9000,
    "kind": "decision",
    "payload": {
      "text": "APPROVE → RECOMMEND_APPROVE · held PENDING_HUMAN_CONFIRMATION (even an approval is the officer's call, not the agent's)"
    }
  }
]