MHamdan commited on
Commit
cd88779
·
verified ·
1 Parent(s): 3a64e54

CI deploy 0c6476e

Browse files
amanpay/agentic_orchestration/__init__.py ADDED
File without changes
amanpay/agentic_orchestration/agents.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded PR C2 agents — Context, Risk, Auth-Rec, Rail, Explanation.
2
+
3
+ Each agent is a reasoning-only actor (PR B registry `can_execute_payment=False`). It produces a
4
+ STRUCTURED, validated output and never carries authority. Tool-using agents (Context, Risk, Rail)
5
+ reach data/models only through the PR B `ToolGateway` with a PoP-checked `CapabilityGrant`.
6
+
7
+ Authority reminders (enforced by the orchestrator + PDP):
8
+ * Risk Agent output is ADVISORY (recommend_step_up may only add caution).
9
+ * Auth-Rec Agent may recommend only a method meeting/exceeding the PDP minimum.
10
+ * Rail Agent ranks only eligible/available rails.
11
+ * Explanation Agent renders reason codes; it cannot change decisions.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Dict, List, Optional
18
+
19
+ from amanpay.agentic_orchestration import schemas
20
+ from amanpay.agentic_orchestration.tools import (
21
+ TOOL_FEATURES, TOOL_RAIL_OPTIONS, TOOL_RISK_MODEL, ToolClient,
22
+ )
23
+ from amanpay.agentic_risk.reason_codes import render, validate as validate_codes
24
+
25
+ # Assurance ordering is CONTEXT-SENSITIVE (see AGENT_ARCHITECTURE); this rank is used only to
26
+ # reject a recommendation that is *below* the PDP minimum, never to assert a universal hierarchy.
27
+ _ASSURANCE_RANK = {"trusted_session": 0, "webauthn": 2, "oob": 2, "webauthn+oob": 3,
28
+ "manual_review": 4}
29
+
30
+
31
+ class ContextAgent:
32
+ spiffe = "spiffe://amanpay/agent/context"
33
+
34
+ def run(self, client: ToolClient, cap, txn_id: str, *, now: float, intent_hash: str) -> Dict:
35
+ out = client.call(cap, TOOL_FEATURES, {"txn_id": txn_id}, now=now, intent_hash=intent_hash)
36
+ return schemas.validate_context(out)
37
+
38
+
39
+ class RiskAgent:
40
+ spiffe = "spiffe://amanpay/agent/risk"
41
+
42
+ def run(self, client: ToolClient, cap, txn_id: str, *, now: float, intent_hash: str) -> Dict:
43
+ out = client.call(cap, TOOL_RISK_MODEL, {"txn_id": txn_id}, now=now, intent_hash=intent_hash)
44
+ return schemas.validate_risk(out) # advisory only
45
+
46
+
47
+ class AuthRecommendationAgent:
48
+ spiffe = "spiffe://amanpay/agent/auth"
49
+
50
+ def run(self, *, pdp_required: List[str], risk_band: str,
51
+ supported: List[str]) -> Dict:
52
+ """Recommend a method that MEETS/EXCEEDS the PDP minimum. Never weakens it."""
53
+ min_rank = max((_ASSURANCE_RANK.get(m, 0) for m in pdp_required), default=0)
54
+ # prefer the least-intrusive supported method that still meets the minimum
55
+ eligible = [m for m in supported
56
+ if _ASSURANCE_RANK.get(m, 0) >= min_rank]
57
+ # elevated/high risk -> lean to a stronger eligible method
58
+ if risk_band in ("elevated", "high") and "webauthn+oob" in supported:
59
+ rec = "webauthn+oob"
60
+ elif eligible:
61
+ rec = sorted(eligible, key=lambda m: _ASSURANCE_RANK.get(m, 0))[0]
62
+ else:
63
+ rec = "manual_review"
64
+ meets = _ASSURANCE_RANK.get(rec, 0) >= min_rank
65
+ return schemas.validate_auth({"recommended_method": rec, "meets_pdp_minimum": bool(meets)})
66
+
67
+
68
+ class RailRecommendationAgent:
69
+ spiffe = "spiffe://amanpay/agent/rail"
70
+
71
+ def run(self, client: ToolClient, cap, *, country: str, currency: str, now: float,
72
+ intent_hash: str, user_pref: str = "") -> Dict:
73
+ opts = client.call(cap, TOOL_RAIL_OPTIONS, {"country": country, "currency": currency},
74
+ now=now, intent_hash=intent_hash)
75
+ rails = list(opts.get("rails", []))
76
+ # order signals: user preference first, otherwise stable configured order (eligibility
77
+ # already applied by the tool). Never adds a rail not returned by the tool.
78
+ ranked = sorted(rails, key=lambda r: (0 if r == user_pref else 1))
79
+ return schemas.validate_rail({"ranked": ranked, "eligible_only": True})
80
+
81
+
82
+ class ExplanationAgent:
83
+ spiffe = "spiffe://amanpay/agent/explain"
84
+
85
+ def run(self, *, reason_codes: List[str], locale: str = "en") -> Dict:
86
+ codes = validate_codes(reason_codes) # allowlist only; no invented codes
87
+ messages = [render(c, locale) for c in codes]
88
+ return schemas.validate_explanation({"locale": locale, "messages": messages,
89
+ "reason_codes": codes})
amanpay/agentic_orchestration/api.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Internal /ai/v1 API surface for PR C2 (labelled demo).
2
+
3
+ Per the checkpoint API-exposure classification, the per-agent endpoints (context/risk/auth/rails/
4
+ explain) are internal-only and are NOT exposed as public HTTP routes. This module exposes only:
5
+ * GET /ai/v1/models/status — safe aggregate status (no scores/thresholds).
6
+ * POST /ai/v1/demo/payee — register a demo payee (returns ref, never the raw IBAN).
7
+ * POST /ai/v1/demo/orchestrate — run the secured agent workflow (mock provider, shadow model).
8
+
9
+ The demo endpoint is gated: it runs only when AMANPAY_ENABLE_AI_DEMO is truthy (default on for the
10
+ trusted HF demo; disable in real production). It exposes no keys/capabilities/approval secrets/raw
11
+ IBANs and cannot mint execution capabilities or move real money.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import time
18
+
19
+ from fastapi import APIRouter, Body, HTTPException
20
+
21
+ from amanpay.agentic_orchestration.orchestrator import DEMO_LABEL, SecureAgentOrchestrator
22
+ from amanpay.agentic_risk.features import UserHistory
23
+ from amanpay.agentic_risk.reason_codes import REASON_CODES_VERSION
24
+ from amanpay.agentic_risk.features import FEATURE_SCHEMA_VERSION
25
+
26
+ router = APIRouter(prefix="/ai/v1", tags=["agentic-risk"])
27
+
28
+ _orch = SecureAgentOrchestrator()
29
+
30
+
31
+ def _demo_enabled() -> bool:
32
+ v = os.getenv("AMANPAY_ENABLE_AI_DEMO", "1").strip().lower()
33
+ return v not in ("", "0", "false", "no")
34
+
35
+
36
+ @router.get("/models/status")
37
+ def models_status() -> dict:
38
+ """Safe status — versions + shadow flag, NO scores or thresholds."""
39
+ return {"behavioural_model": _orch.shadow.model.name if _orch.shadow.model else "none",
40
+ "model_version": _orch.shadow.model.version if _orch.shadow.model else "none",
41
+ "feature_version": FEATURE_SCHEMA_VERSION,
42
+ "reason_codes_version": REASON_CODES_VERSION,
43
+ "shadow_only": True, "affects_payment": False, "label": DEMO_LABEL}
44
+
45
+
46
+ @router.post("/demo/payee")
47
+ def demo_payee(body: dict = Body(...)) -> dict:
48
+ try:
49
+ p = _orch.base.payees.register(body["iban"], country=body.get("country", "SA"))
50
+ except (KeyError, ValueError) as exc:
51
+ raise HTTPException(status_code=422, detail=str(exc))
52
+ return {"payee_ref": p.payee_ref, "display": p.display, "label": DEMO_LABEL}
53
+
54
+
55
+ @router.post("/demo/orchestrate")
56
+ def demo_orchestrate(body: dict = Body(...)) -> dict:
57
+ if not _demo_enabled():
58
+ raise HTTPException(status_code=403, detail="AI demo disabled")
59
+ try:
60
+ hist = UserHistory(
61
+ payments=int(body.get("history_payments", 20)),
62
+ payees=set(body.get("known_payees", [])),
63
+ merchants=set(body.get("known_merchants", ["m1"])),
64
+ amount_mean=float(body.get("amount_mean", 5000)),
65
+ amount_std=float(body.get("amount_std", 1500)),
66
+ hour_hist={int(body.get("usual_hour", 12)): 10},
67
+ recent_24h=int(body.get("recent_24h", 1)),
68
+ device_trust_days=float(body.get("device_trust_days", 200)),
69
+ rail_pref=body.get("rail_pref", "sarie"))
70
+ r = _orch.run(user_id=body.get("user_id", "demo"), payee_ref=body["payee_ref"],
71
+ amount_minor=int(body["amount_minor"]), hist=hist,
72
+ consent=bool(body.get("consent", True)),
73
+ approve=bool(body.get("approve", False)),
74
+ country=body.get("country", "SA"), currency=body.get("currency", "SAR"),
75
+ merchant=body.get("merchant", "m1"),
76
+ now=float(body.get("now", time.time())), locale=body.get("locale", "en"))
77
+ except (KeyError, ValueError) as exc:
78
+ raise HTTPException(status_code=422, detail=str(exc))
79
+ # Return only safe fields (no keys/capabilities/approval secrets/raw IBAN).
80
+ return {"decision": r.decision, "reason_codes": r.reason_codes,
81
+ "required_auth": r.required_auth, "agent_state": r.agent_state,
82
+ "risk": r.risk, "auth_recommendation": r.auth_recommendation,
83
+ "rails": r.rails, "explanation": r.explanation,
84
+ "payment": r.payment, "audit": r.audit, "fallback": r.fallback,
85
+ "labels": r.labels}
amanpay/agentic_orchestration/orchestrator.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PR C2 — Secure Agent Orchestration.
2
+
3
+ Runs the bounded agents through PR B security (identities, signed AgentMessages, PoP-checked
4
+ capabilities, ToolGateway, ReplayStore, AuditChain, budgets) and feeds their outputs to the
5
+ deterministic PR B PDP, which stays authoritative. The C1 behavioural-risk model runs in SHADOW —
6
+ its output is an advisory input (`recommend_step_up`) that can only ADD caution.
7
+
8
+ Guarantees (tested): Risk Agent cannot call Payment Core; agent "allow" cannot override a PDP deny;
9
+ agent cannot lower a PDP step-up; malformed/absent agent output falls back to deterministic policy;
10
+ only Payment Core initiates.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import secrets
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Optional
18
+
19
+ from amanpay.agent_security import envelopes as E
20
+ from amanpay.agent_security.budgets import AgentBudget, BudgetExceeded
21
+ from amanpay.agent_security.gateway import MessageRejected, ToolError, validate_message
22
+ from amanpay.agent_security.orchestrator import AgentPaymentOrchestrator, ENV
23
+ from amanpay.agentic_orchestration import schemas
24
+ from amanpay.agentic_orchestration.agents import (
25
+ AuthRecommendationAgent, ContextAgent, ExplanationAgent, RailRecommendationAgent, RiskAgent,
26
+ )
27
+ from amanpay.agentic_orchestration.tools import (
28
+ TOOL_FEATURES, TOOL_RAIL_OPTIONS, TOOL_RISK_MODEL, ToolClient, register_tools,
29
+ )
30
+ from amanpay.agentic_risk.calibration import PlattCalibrator
31
+ from amanpay.agentic_risk.evaluation import temporal_user_split
32
+ from amanpay.agentic_risk.features import FeatureProvider, Transaction, UserHistory
33
+ from amanpay.agentic_risk.models import LogisticRegression
34
+ from amanpay.agentic_risk.shadow import ShadowRiskService
35
+ from amanpay.agentic_risk.synthetic import SyntheticConfig, generate
36
+
37
+ DEMO_LABEL = ("DEMO ONLY — local identity issuer, mock agents, mock provider, shadow-only "
38
+ "behavioural model (no payment authority), no real-money authority.")
39
+
40
+
41
+ def _default_shadow() -> ShadowRiskService:
42
+ """Fit the C1 logistic model on deterministic synthetic data for the demo (shadow-only)."""
43
+ import numpy as np
44
+ ex = generate(SyntheticConfig(n_users=40, seed=1234))
45
+ fp = FeatureProvider()
46
+ X = np.array([fp.derive(e.hist, e.txn)["vector"] for e in ex])
47
+ y = np.array([e.label for e in ex])
48
+ uid = np.array([e.user_id for e in ex]); t = np.array([e.t for e in ex])
49
+ tr, _ = temporal_user_split(uid, t, test_frac=0.3)
50
+ m = LogisticRegression().fit(X[tr], y[tr])
51
+ cal = PlattCalibrator().fit(m.predict_proba(X[tr]), y[tr])
52
+ return ShadowRiskService(m, cal)
53
+
54
+
55
+ @dataclass
56
+ class OrchestrationResult:
57
+ decision: str
58
+ reason_codes: List[str]
59
+ required_auth: List[str]
60
+ agent_state: str
61
+ context: Optional[Dict] = None
62
+ risk: Optional[Dict] = None
63
+ auth_recommendation: Optional[Dict] = None
64
+ rails: Optional[Dict] = None
65
+ explanation: Optional[Dict] = None
66
+ payment: Optional[Dict] = None
67
+ audit: Optional[Dict] = None
68
+ fallback: Optional[str] = None
69
+ labels: Dict[str, str] = field(default_factory=lambda: {"note": DEMO_LABEL})
70
+
71
+
72
+ class SecureAgentOrchestrator:
73
+ """Composes the PR B payment orchestrator + C1 shadow model + the C2 agent layer."""
74
+
75
+ def __init__(self, shadow: Optional[ShadowRiskService] = None) -> None:
76
+ self.base = AgentPaymentOrchestrator() # PR B: keyring/issuer/pdp/replay/audit/core
77
+ self.shadow = shadow or _default_shadow()
78
+ self.gw = __import__("amanpay.agent_security.gateway", fromlist=["ToolGateway"]).ToolGateway(
79
+ self.base.kr, self.base.replay, env=ENV)
80
+ self._txns: Dict[str, Dict] = {} # txn_id -> {hist, txn, consent}
81
+ register_tools(self.gw, features_handler=self._features, risk_handler=self._risk,
82
+ rail_handler=self._rails)
83
+ # short-lived agent identities (PR B LocalDemoIssuer)
84
+ self.ids = {a: self.base.issuer.issue(a, now=0.0, ttl=1_000_000.0)
85
+ for a in ("spiffe://amanpay/agent/context", "spiffe://amanpay/agent/risk",
86
+ "spiffe://amanpay/agent/rail")}
87
+ self.context_agent = ContextAgent(); self.risk_agent = RiskAgent()
88
+ self.auth_agent = AuthRecommendationAgent(); self.rail_agent = RailRecommendationAgent()
89
+ self.explain_agent = ExplanationAgent()
90
+
91
+ # -- tool handlers (resolve behavioural data by txn_id from the trusted store) ------ #
92
+ def _features(self, args: Dict, ctx: Dict) -> Dict:
93
+ rec = self._txns[args["txn_id"]]
94
+ f = FeatureProvider().derive(rec["hist"], rec["txn"])
95
+ return {"channel": rec.get("channel", "app"), "recent_count": int(rec["hist"].recent_24h),
96
+ "cold_start": bool(f["cold_start"]), "missing": list(f["missing"]),
97
+ "feature_version": f["schema_version"]}
98
+
99
+ def _risk(self, args: Dict, ctx: Dict) -> Dict:
100
+ rec = self._txns[args["txn_id"]]
101
+ a = self.shadow.assess(rec["hist"], rec["txn"], consent=rec["consent"])
102
+ return {"band": a.band,
103
+ "recommend_step_up": a.band in ("elevated", "high", "uncertain")
104
+ or a.confidence == "uncertain",
105
+ "reason_codes": a.reason_codes, "confidence": a.confidence,
106
+ "model_version": a.model_version, "feature_version": a.feature_version,
107
+ "shadow": bool(a.shadow)}
108
+
109
+ def _rails(self, args: Dict, ctx: Dict) -> Dict:
110
+ rails = self.base.provider.rails_by_country.get(args["country"].upper(), [])
111
+ return {"rails": list(rails), "eligible_only": True}
112
+
113
+ def _cap(self, subject_spiffe: str, cnf_kid: str, audience: str, intent, *, now: float):
114
+ return E.make_capability(
115
+ self.base.kr, kid=self.base.pdp_k.kid, issuer="spiffe://amanpay/pdp", key_version=1,
116
+ env=ENV, subject_agent=subject_spiffe, cnf=cnf_kid, audience=audience,
117
+ operation=audience, resource=intent.intent_id, intent_id=intent.intent_id,
118
+ intent_hash=intent.intent_hash, now=now)
119
+
120
+ def _msg(self, sender: str, recipient: str, mtype: str, payload: Dict, intent, flow: str,
121
+ *, now: float) -> None:
122
+ """Send + validate one signed AgentMessage (proves the PR B envelope path)."""
123
+ kid = self.ids[sender].kid if sender in self.ids else self.base.pdp_k.kid
124
+ m = E.make_message(self.base.kr, kid=kid, sender=sender, recipient=recipient,
125
+ audience=recipient, intent_id=intent.intent_id,
126
+ intent_hash=intent.intent_hash, message_type=mtype, payload=payload,
127
+ flow_id=flow, env=ENV, now=now)
128
+ validate_message(self.base.kr, self.base.replay, m, now=now, env=ENV,
129
+ expected_intent_hash=intent.intent_hash, expected_flow_id=flow)
130
+
131
+ # -- the secured workflow ----------------------------------------------------------- #
132
+ def run(self, *, user_id: str, payee_ref: str, amount_minor: int, hist: UserHistory,
133
+ consent: bool = True, approve: bool = False, country: str = "SA",
134
+ currency: str = "SAR", merchant: str = "m1", now: float = 1000.0,
135
+ locale: str = "en") -> OrchestrationResult:
136
+ payee = self.base.payees.get(payee_ref)
137
+ if payee is None:
138
+ raise ValueError("unknown payee_ref")
139
+ flow = "flow_" + secrets.token_hex(6)
140
+ txn_id = "txn_" + secrets.token_hex(6)
141
+ txn = Transaction(amount_minor=amount_minor, hour=int(now // 3600) % 24,
142
+ payee_ref=payee_ref, merchant=merchant, country=country,
143
+ currency=currency, rail="sarie", country_currency_ok=True)
144
+ self._txns[txn_id] = {"hist": hist, "txn": txn, "consent": consent, "channel": "app"}
145
+
146
+ intent = E.make_intent(
147
+ self.base.kr, kid=self.base.fe_k.kid, issuer="spiffe://amanpay/frontend", key_version=1,
148
+ env=ENV, user_id=user_id, operation="payment.create", amount_minor=amount_minor,
149
+ currency=currency, payee_ref=payee.payee_ref, payee_display=payee.display,
150
+ payee_binding_hash=payee.binding_hash, merchant=merchant, country=country,
151
+ reference="invoice", now=now)
152
+ self.base.audit.append(actor_identity="spiffe://amanpay/frontend", action="intent.created",
153
+ result="ok", intent_id=intent.intent_id,
154
+ intent_hash=intent.intent_hash, now=now)
155
+
156
+ budget = AgentBudget(started_at=now)
157
+ fallback = None
158
+ context = risk = None
159
+ # ----- Context + Risk agents (advisory), with fail-closed fallback -----
160
+ try:
161
+ cctx = ToolClient(self.gw, self.base.kr, self.context_agent.spiffe,
162
+ self.ids[self.context_agent.spiffe].kid, budget)
163
+ ccap = self._cap(self.context_agent.spiffe, cctx.kid, TOOL_FEATURES, intent, now=now)
164
+ context = self.context_agent.run(cctx, ccap, txn_id, now=now,
165
+ intent_hash=intent.intent_hash)
166
+ self._msg(self.context_agent.spiffe, self.risk_agent.spiffe, schemas.CONTEXT_RESULT,
167
+ context, intent, flow, now=now)
168
+
169
+ rctx = ToolClient(self.gw, self.base.kr, self.risk_agent.spiffe,
170
+ self.ids[self.risk_agent.spiffe].kid, budget)
171
+ rcap = self._cap(self.risk_agent.spiffe, rctx.kid, TOOL_RISK_MODEL, intent, now=now)
172
+ risk = self.risk_agent.run(rctx, rcap, txn_id, now=now, intent_hash=intent.intent_hash)
173
+ self._msg(self.risk_agent.spiffe, "spiffe://amanpay/pdp", schemas.RISK_RESULT,
174
+ risk, intent, flow, now=now)
175
+ except (BudgetExceeded, ToolError, MessageRejected, schemas.SchemaError) as exc:
176
+ fallback = f"agent-failure->deterministic ({type(exc).__name__})"
177
+ self.base.audit.append(actor_identity="spiffe://amanpay/agent/risk",
178
+ action="conflicting.action", result="fallback",
179
+ intent_id=intent.intent_id, reason_codes=[], now=now)
180
+ risk = None
181
+
182
+ # ----- Deterministic PDP (authoritative); risk is ADVISORY (add-caution only) -----
183
+ agent_rec = {"recommend_step_up": bool(risk["recommend_step_up"])} if risk else None
184
+ ctx_for_pdp = {"biometric_ok": True, "confidence": 0.95,
185
+ "modalities": ["face", "fp", "voice"],
186
+ "recent_count": int(hist.recent_24h)}
187
+ d1 = self.base.pdp.evaluate(intent, None, context=ctx_for_pdp, now=now + 1,
188
+ idempotency_key=txn_id, agent_recommendation=agent_rec)
189
+
190
+ # ----- Auth recommendation (advisory; PDP already chose the minimum) -----
191
+ auth_rec = self.auth_agent.run(pdp_required=d1.required_auth,
192
+ risk_band=(risk["band"] if risk else "model_unavailable"),
193
+ supported=["trusted_session", "webauthn", "oob",
194
+ "webauthn+oob"])
195
+ # ----- Rail ranking (only configured/available) -----
196
+ try:
197
+ rcl = ToolClient(self.gw, self.base.kr, self.rail_agent.spiffe,
198
+ self.ids[self.rail_agent.spiffe].kid, budget)
199
+ rlcap = self._cap(self.rail_agent.spiffe, rcl.kid, TOOL_RAIL_OPTIONS, intent, now=now)
200
+ rails = self.rail_agent.run(rcl, rlcap, country=country, currency=currency, now=now,
201
+ intent_hash=intent.intent_hash, user_pref=hist.rail_pref)
202
+ except (ToolError, schemas.SchemaError):
203
+ rails = {"ranked": [], "eligible_only": True}
204
+
205
+ reason_codes = list(risk["reason_codes"]) if risk else ["MODEL_UNAVAILABLE"]
206
+ explanation = self.explain_agent.run(reason_codes=reason_codes, locale=locale)
207
+
208
+ result = OrchestrationResult(
209
+ decision=d1.decision, reason_codes=d1.reason_codes, required_auth=d1.required_auth,
210
+ agent_state="policy_evaluated", context=context, risk=risk,
211
+ auth_recommendation=auth_rec, rails=rails, explanation=explanation,
212
+ audit=self.base.audit.summary(), fallback=fallback)
213
+
214
+ if d1.decision != "step_up" or not approve:
215
+ return result
216
+
217
+ # ----- User approval -> PDP validates -> single-use ExecutionCapability -> Payment Core -----
218
+ ap = self.base.approve(intent, now=now + 2)
219
+ d2 = self.base.pdp.evaluate(intent, ap, context=ctx_for_pdp, now=now + 3,
220
+ idempotency_key=txn_id, agent_recommendation=agent_rec)
221
+ if d2.decision != "allow":
222
+ result.decision = d2.decision; result.reason_codes = d2.reason_codes
223
+ return result
224
+ ec = d2.execution_capability
225
+ E.verify_execution_capability(self.base.kr, ec, now=now + 3, env=ENV)
226
+ if not self.base.replay.consume("exec-cap", ec.execution_id, now=now + 3):
227
+ result.decision = "deny"; return result
228
+ payment = self.base.core.create_payment(
229
+ user_id=user_id, amount_minor=amount_minor, country=country, currency=currency,
230
+ merchant_id=merchant, payee_iban=payee.iban,
231
+ consent={"granted": True, "purpose": "agent-orchestrated payment (demo)"},
232
+ idempotency_key=txn_id, now=now + 3)
233
+ self.base.audit.append(actor_identity="spiffe://amanpay/payment-core",
234
+ action="payment.initiated", result="ok",
235
+ intent_id=intent.intent_id, now=now + 3)
236
+ result.decision = "allow"; result.reason_codes = d2.reason_codes
237
+ result.agent_state = "initiated"; result.payment = payment.view()
238
+ result.audit = self.base.audit.summary()
239
+ return result
amanpay/agentic_orchestration/schemas.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured, validated agent-output schemas (PR C2).
2
+
3
+ Every model/agent result is treated as UNTRUSTED input until validated: it must match a strict
4
+ schema, carry versions + allowlisted reason codes, and must NOT carry executable instructions or
5
+ free-form text that could reach authorization logic. Malformed output is rejected (fail-closed).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List
11
+
12
+ from amanpay.agentic_risk.reason_codes import validate as validate_codes
13
+
14
+ # Message types (must be a subset of PR B gateway MESSAGE_TYPES).
15
+ CONTEXT_RESULT = "context.result"
16
+ RISK_RESULT = "risk.result"
17
+ AUTH_RESULT = "auth.result"
18
+ RAIL_RESULT = "rail.result"
19
+ EXPLAIN_RESULT = "explain.result"
20
+
21
+ # Keys that must never appear in an agent payload (no executable/authorization instructions).
22
+ _FORBIDDEN_KEYS = {"command", "run", "exec", "sql", "url", "capability", "execution_capability",
23
+ "approve", "authorize", "decision", "override", "signature", "private_key",
24
+ "secret", "token"}
25
+
26
+
27
+ class SchemaError(ValueError):
28
+ pass
29
+
30
+
31
+ def _check_forbidden(payload: Dict[str, Any]) -> None:
32
+ bad = [k for k in payload if k.lower() in _FORBIDDEN_KEYS]
33
+ if bad:
34
+ raise SchemaError(f"agent payload contains forbidden key(s): {bad}")
35
+
36
+
37
+ def _require(payload: Dict[str, Any], fields: Dict[str, type]) -> None:
38
+ for k, t in fields.items():
39
+ if k not in payload:
40
+ raise SchemaError(f"missing field {k!r}")
41
+ if not isinstance(payload[k], t):
42
+ raise SchemaError(f"field {k!r} wrong type (want {t.__name__})")
43
+
44
+
45
+ def validate_context(payload: Dict[str, Any]) -> Dict[str, Any]:
46
+ _check_forbidden(payload)
47
+ _require(payload, {"channel": str, "recent_count": int, "cold_start": bool,
48
+ "missing": list, "feature_version": str})
49
+ return payload
50
+
51
+
52
+ def validate_risk(payload: Dict[str, Any]) -> Dict[str, Any]:
53
+ """Risk Agent output — advisory ONLY. It may set recommend_step_up (add caution) but carries
54
+ no decision/authority. Reason codes must be allowlisted."""
55
+ _check_forbidden(payload)
56
+ _require(payload, {"band": str, "recommend_step_up": bool, "reason_codes": list,
57
+ "confidence": str, "model_version": str, "feature_version": str,
58
+ "shadow": bool})
59
+ if payload["band"] not in ("low", "uncertain", "elevated", "high", "model_unavailable"):
60
+ raise SchemaError(f"invalid band: {payload['band']}")
61
+ validate_codes(payload["reason_codes"]) # allowlist enforcement
62
+ if payload.get("shadow") is not True:
63
+ raise SchemaError("risk output must be shadow=True in this phase")
64
+ return payload
65
+
66
+
67
+ def validate_auth(payload: Dict[str, Any]) -> Dict[str, Any]:
68
+ _check_forbidden(payload)
69
+ _require(payload, {"recommended_method": str, "meets_pdp_minimum": bool})
70
+ return payload
71
+
72
+
73
+ def validate_rail(payload: Dict[str, Any]) -> Dict[str, Any]:
74
+ _check_forbidden(payload)
75
+ _require(payload, {"ranked": list, "eligible_only": bool})
76
+ return payload
77
+
78
+
79
+ def validate_explanation(payload: Dict[str, Any]) -> Dict[str, Any]:
80
+ _check_forbidden(payload)
81
+ _require(payload, {"locale": str, "messages": list, "reason_codes": list})
82
+ validate_codes(payload["reason_codes"])
83
+ return payload
amanpay/agentic_orchestration/tools.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool handlers + ToolClient for PR C2 agents.
2
+
3
+ Tools are registered in the PR B `ToolGateway` (static allowlist, typed schemas, PoP-checked
4
+ capability, budgets, secret scrubbing). Agents reach data/models ONLY through these tools — never
5
+ the Payment Core, shell, DB, or arbitrary URLs.
6
+
7
+ Only a `txn_id` crosses the (signed) tool args; the handler resolves the actual behavioural data
8
+ from the orchestrator's trusted in-process store, so raw features never travel through signed
9
+ request bodies or agent messages.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Callable, Dict
15
+
16
+ from amanpay.agent_security import envelopes as E
17
+ from amanpay.agent_security.budgets import AgentBudget
18
+ from amanpay.agent_security.gateway import ToolGateway, ToolSpec
19
+
20
+ # Tool names (also the capability audiences).
21
+ TOOL_FEATURES = "tool:read_derived_features"
22
+ TOOL_RISK_MODEL = "tool:invoke_risk_model"
23
+ TOOL_RAIL_OPTIONS = "tool:rail_options"
24
+
25
+ Handler = Callable[[Dict, Dict], Dict]
26
+
27
+
28
+ def register_tools(gw: ToolGateway, *, features_handler: Handler, risk_handler: Handler,
29
+ rail_handler: Handler) -> None:
30
+ """Register the C2 tools with orchestrator-provided handlers (closures over its txn store)."""
31
+ gw.register_tool(ToolSpec(
32
+ name=TOOL_FEATURES,
33
+ allowed_agents=frozenset({"spiffe://amanpay/agent/context"}),
34
+ input_schema={"txn_id": str},
35
+ output_schema={"channel": str, "recent_count": int, "cold_start": bool,
36
+ "missing": list, "feature_version": str},
37
+ handler=features_handler))
38
+
39
+ gw.register_tool(ToolSpec(
40
+ name=TOOL_RISK_MODEL,
41
+ allowed_agents=frozenset({"spiffe://amanpay/agent/risk"}),
42
+ input_schema={"txn_id": str},
43
+ output_schema={"band": str, "recommend_step_up": bool, "reason_codes": list,
44
+ "confidence": str, "model_version": str, "feature_version": str,
45
+ "shadow": bool},
46
+ handler=risk_handler))
47
+
48
+ gw.register_tool(ToolSpec(
49
+ name=TOOL_RAIL_OPTIONS,
50
+ allowed_agents=frozenset({"spiffe://amanpay/agent/rail"}),
51
+ input_schema={"country": str, "currency": str},
52
+ output_schema={"rails": list, "eligible_only": bool},
53
+ handler=rail_handler))
54
+
55
+
56
+ class ToolClient:
57
+ """Wraps a single agent's capability + key so it can call the gateway with a fresh PoP."""
58
+
59
+ def __init__(self, gw: ToolGateway, keyring, agent_spiffe: str, subject_kid: str,
60
+ budget: AgentBudget, env: str = "demo") -> None:
61
+ self.gw = gw
62
+ self.keyring = keyring
63
+ self.agent = agent_spiffe
64
+ self.kid = subject_kid
65
+ self.budget = budget
66
+ self.env = env
67
+
68
+ def call(self, capability: E.CapabilityGrant, tool_name: str, args: Dict, *,
69
+ now: float, intent_hash: str | None = None) -> Dict:
70
+ proof = E.make_use_proof(self.keyring, self.kid, capability, operation=tool_name,
71
+ audience=tool_name, request=args, now=now)
72
+ return self.gw.call(agent_spiffe=self.agent, tool_name=tool_name, args=args,
73
+ capability=capability, use_proof=proof, subject_kid=self.kid,
74
+ budget=self.budget, now=now, intent_hash=intent_hash)
api/main.py CHANGED
@@ -117,6 +117,10 @@ app.include_router(notifications_router) # torch-free /notify/* endpoints
117
  from api.agent_security_routes import router as agent_security_router # noqa: E402
118
  app.include_router(agent_security_router)
119
 
 
 
 
 
120
 
121
  _ROOT = os.path.dirname(os.path.dirname(__file__))
122
  _FRONTEND = os.path.join(_ROOT, "frontend", "index.html") # legacy (rollback)
 
117
  from api.agent_security_routes import router as agent_security_router # noqa: E402
118
  app.include_router(agent_security_router)
119
 
120
+ # Agentic risk orchestration (/ai/v1 — shadow-only behavioural model, labelled demo).
121
+ from amanpay.agentic_orchestration.api import router as ai_router # noqa: E402
122
+ app.include_router(ai_router)
123
+
124
 
125
  _ROOT = os.path.dirname(os.path.dirname(__file__))
126
  _FRONTEND = os.path.join(_ROOT, "frontend", "index.html") # legacy (rollback)
build_info.json CHANGED
@@ -1 +1 @@
1
- {"commit":"8832af5","build_time":"2026-07-13T04:36:02Z","frontend":"1.0.0"}
 
1
+ {"commit":"0c6476e","build_time":"2026-07-13T04:57:19Z","frontend":"1.0.0"}