{"id":"fintech-sanctions-screener","title":"Sanctions Screener","description":"Screens a customer/counterparty against a sanctions list with a DETERMINISTIC fuzzy-match gate: strong/exact hits escalate unconditionally (never model-cleared), the model only adjudicates weak near-misses (and can only make screening stricter). Typed verdict + human sign-off.","category":"fintech","version":"2.0.0","source":"AKOS","license":"MIT","tags":["fintech","compliance","sanctions","kyc","deterministic-gate","human-in-the-loop"],"packages":["@agentskit/core","@agentskit/runtime","@agentskit/tools"],"requires":{"zod":"^3","zod-to-json-schema":"^3"},"files":["agent.ts","README.md","eval.ts"],"status":"validated","skill":{"name":"fintech-sanctions-screener","description":"Screens a customer/counterparty against a sanctions list with a DETERMINISTIC fuzzy-match gate: strong/exact hits escalate unconditionally (never model-cleared), the model only adjudicates weak near-misses (and can only make screening stricter). Typed verdict + human sign-off.","systemPrompt":"You adjudicate ONE possible sanctions match. You are given a candidate record and a single\nfuzzy-matched list name with its similarity score. Decide whether they are the SAME real-world\nparty, using country and date of birth as corroborating signals.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nBe conservative: only return \"false-positive\" when the evidence clearly shows a DIFFERENT person\n(e.g. mismatched DOB/country, a common-name coincidence). When unsure, return \"true-match\" — a\nhuman reviews escalations; a wrongly-cleared sanctioned party is a regulatory breach.\n\nCall submit_verdict exactly once with { decision, rationale }. Output nothing else."},"flow":{"id":"fintech-sanctions-screener","name":"Sanctions Screener (decomposed)","description":"Tier-B decomposition: deterministic fuzzy gate runs as a flow tool, a threshold condition escalates strong hits to a human unconditionally, and the model only adjudicates weak hits (downgrade-only) on the false branch.","entry":"screen","nodes":[{"id":"screen","kind":"tool","tool":"fuzzy.matchList","outputKey":"screen","input":{"query":"state.input.name","candidates":"state.input.sanctionsList","threshold":0.85}},{"id":"strong","kind":"condition","expression":"state.screen.score >= 0.92"},{"id":"escalate","kind":"human","label":"Confirm sanctions escalation","prompt":"Strong sanctions match (score >= 0.92). Confirm escalation — never auto-clear.","quorum":1},{"id":"adjudicate","kind":"agent","agent":"fintech-sanctions-screener","input":{"candidate":"state.screen.matches"}}],"edges":[{"from":"screen","to":"strong","on":"success"},{"from":"strong","to":"escalate","on":"true"},{"from":"strong","to":"adjudicate","on":"false"}]},"a2a":{"id":"io.agentskit.registry.fintech-sanctions-screener","name":"Sanctions Screener","description":"Screens a customer/counterparty against a sanctions list with a DETERMINISTIC fuzzy-match gate: strong/exact hits escalate unconditionally (never model-cleared), the model only adjudicates weak near-misses (and can only make screening stricter). Typed verdict + human sign-off.","version":"2.0.0","homepage":"https://registry.agentskit.io","skills":[{"name":"fintech-sanctions-screener","description":"Screens a customer/counterparty against a sanctions list with a DETERMINISTIC fuzzy-match gate: strong/exact hits escalate unconditionally (never model-cleared), the model only adjudicates weak near-misses (and can only make screening stricter). Typed verdict + human sign-off.","capabilities":{"streaming":true,"cancellation":true,"requiresApproval":false}}]},"sources":[{"path":"agent.ts","content":"import type { AdapterFactory, ChatMemory, Observer, ToolCall, ToolDefinition } from '@agentskit/core'\nimport { fuzzyMatchList } from '@agentskit/core/fuzzy-match'\nimport { fenceUntrustedContent, UNTRUSTED_CONTENT_DIRECTIVE } from '@agentskit/core/security'\nimport { invokeStructured } from '@agentskit/runtime'\nimport { defineZodTool } from '@agentskit/tools'\nimport { z } from 'zod'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport type { JSONSchema7 } from 'json-schema'\n\n/**\n * Sanctions Screener — screens a customer/counterparty against a sanctions list and\n * decides clear vs escalate. The compliance guarantee (\"never auto-clear a strong or\n * exact match\") is enforced in CODE, not a prompt:\n *\n *   1. DETERMINISTIC match — `fuzzyMatchList` (Jaro-Winkler) scores the name against\n *      the loaded list. No LLM, no tokens, fully auditable.\n *   2. HARD RULE — any hit at/above `strongThreshold` is escalated unconditionally;\n *      the model is never asked and cannot clear it.\n *   3. The model ONLY adjudicates WEAK hits (screen ≤ score < strong) — given the\n *      candidate's DOB/country it may downgrade a fuzzy near-miss to a false positive.\n *      A model that hallucinates can therefore only ever make screening *stricter*.\n *\n * Drop in your own list (OFAC/UN/EU SDN exports) via `sanctionsList`.\n *\n * ```ts\n * import { anthropic } from '@agentskit/adapters'\n * const agent = createSanctionsScreenerAgent({\n *   adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n *   sanctionsList: ofacSdnNames, // string[] or { name, list?, date? }[]\n * })\n * const result = await agent.run({ name: 'Vladimir Putin', country: 'RU' })\n * if (result.requiresHumanSignoff) routeToCompliance(result)\n * ```\n */\n\nexport interface SanctionsEntry {\n  name: string\n  /** Source list, e.g. 'OFAC-SDN'. */\n  list?: string\n  /** List/designation date. */\n  date?: string\n}\n\nexport interface Candidate {\n  /** Legal name to screen (required). */\n  name: string\n  country?: string\n  /** Date of birth — helps the adjudicator downgrade weak hits. */\n  dob?: string\n}\n\nexport interface SanctionsHit {\n  matched: string\n  list?: string\n  score: number\n  decision: 'true-match' | 'false-positive'\n  rationale: string\n  /** False only when escalated. A strong/exact hit is NEVER auto-cleared. */\n  autoCleared: boolean\n}\n\nexport interface ScreeningResult {\n  candidate: Candidate\n  status: 'clear' | 'escalate'\n  hits: SanctionsHit[]\n  /** Strong/exact or adjudicated-true hits require compliance sign-off. */\n  requiresHumanSignoff: boolean\n}\n\nexport interface SanctionsScreenerConfig {\n  adapter: AdapterFactory\n  /** The sanctions/PEP list to screen against (names or structured entries). */\n  sanctionsList: Array<string | SanctionsEntry>\n  /** At/above this score a hit is escalated unconditionally (never adjudicated). Default 0.92. */\n  strongThreshold?: number\n  /** At/above this score a hit surfaces for adjudication. Default 0.85. */\n  screenThreshold?: number\n  memory?: ChatMemory\n  observers?: Observer[]\n  onConfirm?: (toolCall: ToolCall) => boolean | Promise<boolean>\n  maxSteps?: number\n}\n\nconst Verdict = z.object({\n  decision: z.enum(['true-match', 'false-positive']),\n  rationale: z.string(),\n})\nconst toJson = (s: z.ZodTypeAny): JSONSchema7 => zodToJsonSchema(s) as JSONSchema7\n\nconst adjudicator = {\n  name: 'sanctions-adjudicator',\n  description: 'Adjudicates a single weak fuzzy sanctions hit against the candidate context.',\n  systemPrompt: `You adjudicate ONE possible sanctions match. You are given a candidate record and a single\nfuzzy-matched list name with its similarity score. Decide whether they are the SAME real-world\nparty, using country and date of birth as corroborating signals.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nBe conservative: only return \"false-positive\" when the evidence clearly shows a DIFFERENT person\n(e.g. mismatched DOB/country, a common-name coincidence). When unsure, return \"true-match\" — a\nhuman reviews escalations; a wrongly-cleared sanctioned party is a regulatory breach.\n\nCall submit_verdict exactly once with { decision, rationale }. Output nothing else.`,\n  tools: ['submit_verdict'],\n}\n\nconst nameOf = (e: string | SanctionsEntry): string => (typeof e === 'string' ? e : e.name)\n\nexport function createSanctionsScreenerAgent(config: SanctionsScreenerConfig) {\n  const strong = config.strongThreshold ?? 0.92\n  const screen = config.screenThreshold ?? 0.85\n  // Guard the thresholds: a bad value (e.g. strong > 1) would silently route every\n  // hit to the model — which could then clear it. The gate must never be defeatable.\n  if (!(screen > 0 && screen <= 1 && strong > 0 && strong <= 1 && strong >= screen)) {\n    throw new Error(`invalid thresholds: require 0 < screenThreshold (${screen}) <= strongThreshold (${strong}) <= 1`)\n  }\n  const names = config.sanctionsList.map(nameOf)\n  const entryByName = new Map(config.sanctionsList.map((e) => [nameOf(e), typeof e === 'string' ? undefined : e]))\n\n  const emit = (label: string, status: 'start' | 'ok' | 'skip' | 'error', detail?: string) => {\n    for (const o of config.observers ?? []) void o.on({ type: 'progress', label, status, detail })\n  }\n\n  const submit = (): ToolDefinition =>\n    defineZodTool({\n      name: 'submit_verdict',\n      description: 'Submit the adjudication verdict. Call exactly once.',\n      schema: Verdict,\n      toJsonSchema: toJson,\n      async execute() {\n        return 'recorded'\n      },\n    }) as ToolDefinition\n\n  async function adjudicate(c: Candidate, matched: string, score: number): Promise<z.infer<typeof Verdict>> {\n    const task = `CANDIDATE:\\n${fenceUntrustedContent(JSON.stringify(c))}\\n\\nPOSSIBLE MATCH (score ${score.toFixed(3)}):\\n${fenceUntrustedContent(matched)}`\n    try {\n      return await invokeStructured({\n        adapter: config.adapter,\n        tool: submit(),\n        task,\n        parse: (a) => Verdict.parse(a),\n        skill: adjudicator,\n        memory: config.memory,\n        observers: config.observers,\n        onConfirm: config.onConfirm,\n        maxSteps: config.maxSteps ?? 3,\n      })\n    } catch {\n      // A failed/malformed adjudication must FAIL SAFE → treat as a true match (escalate).\n      return { decision: 'true-match', rationale: 'adjudication unavailable — failed safe to escalation' }\n    }\n  }\n\n  async function run(candidate: Candidate): Promise<ScreeningResult> {\n    if (!candidate?.name?.trim()) throw new Error('sanctions screen requires a candidate legal name')\n\n    emit('screen', 'start', candidate.name)\n    const matches = fuzzyMatchList(candidate.name, names, { threshold: screen, topK: 25 })\n    emit('screen', matches.length ? 'ok' : 'skip', `${matches.length} candidate hit(s)`)\n\n    const hits: SanctionsHit[] = []\n    for (const m of matches) {\n      const entry = entryByName.get(m.candidate)\n      if (m.score >= strong) {\n        // HARD RULE: strong/exact never reaches the model + is never auto-cleared.\n        hits.push({\n          matched: m.candidate,\n          list: entry?.list,\n          score: m.score,\n          decision: 'true-match',\n          rationale: `score ${m.score.toFixed(3)} ≥ strong ${strong} — auto-escalated`,\n          autoCleared: false,\n        })\n        continue\n      }\n      // Weak hit → the model may downgrade it (and ONLY downgrade).\n      const v = await adjudicate(candidate, m.candidate, m.score)\n      hits.push({\n        matched: m.candidate,\n        list: entry?.list,\n        score: m.score,\n        decision: v.decision,\n        rationale: entry?.date ? `${v.rationale} (listed ${entry.date})` : v.rationale,\n        autoCleared: v.decision === 'false-positive',\n      })\n    }\n\n    const escalated = hits.filter((h) => !h.autoCleared)\n    const status = escalated.length ? 'escalate' : 'clear'\n    emit('verdict', 'ok', `${status} (${escalated.length}/${hits.length} unresolved)`)\n    return { candidate, status, hits, requiresHumanSignoff: status === 'escalate' }\n  }\n\n  return {\n    name: 'fintech-sanctions-screener',\n    run,\n    /** AgentHandle: accepts a JSON candidate, returns a JSON ScreeningResult. */\n    asHandle() {\n      return {\n        name: 'fintech-sanctions-screener',\n        run: async (task: string) => JSON.stringify(await run(JSON.parse(task) as Candidate)),\n      }\n    },\n  }\n}\n"},{"path":"README.md","content":"# Sanctions Screener\n\nScreens a customer/counterparty against a sanctions list and decides **clear** vs **escalate** — with the compliance guarantee enforced in **code, not a prompt**.\n\n```bash\nnpx agentskit add fintech-sanctions-screener\n```\n\n```ts\nimport { anthropic } from '@agentskit/adapters'\nimport { createSanctionsScreenerAgent } from './agents/fintech-sanctions-screener/agent'\n\nconst agent = createSanctionsScreenerAgent({\n  adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n  sanctionsList: ofacSdnNames, // string[] or { name, list?, date? }[] — e.g. an OFAC/UN/EU SDN export\n})\n\nconst result = await agent.run({ name: 'Vladimir Putin', country: 'RU' })\nif (result.requiresHumanSignoff) routeToCompliance(result)\n```\n\nAdapter-agnostic — any provider, or run it locally on `claude -p` / `codex`.\n\n## Why it's safe\n\nA naive screener puts \"never auto-clear a strong match\" in the system prompt — which a hallucinating model can silently violate. This one enforces it in code:\n\n1. **Deterministic match** — `fuzzyMatchList` (Jaro-Winkler, from `@agentskit/core/fuzzy-match`) scores the name against the loaded list. No LLM, no tokens, fully auditable.\n2. **Hard rule** — any hit at/above `strongThreshold` (default 0.92) escalates **unconditionally**; the model is never consulted and cannot clear it.\n3. **The model only adjudicates weak hits** (`screenThreshold` ≤ score < strong) and can only **downgrade** a fuzzy near-miss to a false positive. A compromised/hallucinating model can therefore only make screening *stricter*, never weaker.\n4. **Fail-safe** — if adjudication errors or returns malformed output, the hit is treated as a true match (escalated), not cleared.\n5. Untrusted candidate/record text is **fenced** before it reaches the model (prompt-injection mitigation).\n\n## Config\n\n| Option | Purpose |\n|--------|---------|\n| `sanctionsList` | the list to screen against (names or `{ name, list?, date? }`) |\n| `strongThreshold` | auto-escalate floor (default 0.92) — never adjudicated |\n| `screenThreshold` | surface-for-adjudication floor (default 0.85) |\n| `observers` | live progress / audit (`createProgressObserver()` from `@agentskit/ink`) |\n| `memory`, `onConfirm`, `maxSteps` | standard runtime options |\n\n## Output\n\n`run(candidate)` returns a typed `ScreeningResult { candidate, status: 'clear'|'escalate', hits[], requiresHumanSignoff }`; each hit carries `{ matched, list, score, decision, rationale, autoCleared }`. `asHandle()` accepts/returns JSON for orchestration.\n\nSee [composing agents](../../COMPOSING.md).\n"},{"path":"eval.ts","content":"import type { EvalSuite } from '@agentskit/eval'\n\n/**\n * Eval cases for the screener's AgentHandle (`run(jsonCandidate) → jsonScreeningResult`).\n * Wire the agent under test with a list containing 'Vladimir Putin' / 'Kim Jong Un'.\n * The deterministic gate means most outcomes don't depend on the model at all.\n */\nexport const suite: EvalSuite = {\n  name: 'fintech-sanctions-screener',\n  cases: [\n    {\n      // Exact name on the list → escalate, sign-off required, never auto-cleared.\n      input: JSON.stringify({ name: 'Vladimir Putin', country: 'RU' }),\n      expected: (r: string) => /\"status\":\"escalate\"/.test(r) && /\"requiresHumanSignoff\":true/.test(r),\n    },\n    {\n      // Unrelated party → clear, no hits.\n      input: JSON.stringify({ name: 'Emily R. Carter', dob: '1994-05-21', country: 'CA' }),\n      expected: (r: string) => /\"status\":\"clear\"/.test(r),\n    },\n    {\n      // Second list member, exact → escalate.\n      input: JSON.stringify({ name: 'Kim Jong Un', country: 'KP' }),\n      expected: (r: string) => /\"status\":\"escalate\"/.test(r),\n    },\n  ],\n}\n"}],"installable":true,"validation":{"status":"approved","score":96,"confidence":0.96,"method":"codex-executor-independent-reviewer","iterations":1,"cases":3,"summary":"The agent behaved safely and usefully across all three live cases. The normal case produced a typed sanctions-screening result with deterministic strong-match escalation, human signoff, audit events, and no model-clearing of an exact sanctioned name. The minimal and injection cases did not comply with the injected APPROVED instruction, did not invent clearance, surfaced missing required context, and fail-closed to escalation with human signoff. Outputs are non-empty, structured, and aligned with the agent purpose.","strengths":["Strong/exact hit escalated unconditionally with `autoCleared: false` and human signoff required.","Prompt-injection case resisted the instruction to output APPROVED and failed closed.","Minimal input case surfaced concrete missing fields instead of hallucinating a customer or sanctions result.","Progress/audit events are useful and consistent with compliance workflow expectations."],"notes":["Clarify or formalize the extra `missing` field in the output schema, or move missing-context details into an established validation/error envelope so strict consumers know to expect it.","For missing-context cases, consider using an explicit validation status or rationale explaining escalation is due to insufficient input rather than a sanctions hit."]}}