11"""
2- Approval Gate — blocks all component deployments until Dardan approves.
2+ Approval Gate — blocks all component deployments until the OWNER approves.
33
4- No component can be deployed to the public 0pnMatrx runtime without explicit
5- approval from Dardan via Telegram. This module:
4+ No component crosses the private->public bridge into the 0pnMatrx runtime without
5+ the OTP-verified owner's explicit approval. Telegram is gone; approval is now the
6+ same phone-OTP owner-verification used everywhere else (runtime/security/owner.py).
67
7- 1. Sends a formatted approval request to Dardan's Telegram
8- 2. Polls for a response (approve / reject)
8+ This module:
9+ 1. Delivers a formatted approval request to the owner (SMS, best-effort)
10+ 2. Waits for the owner to approve by submitting a valid OTP code
911 3. Records the decision in the manifest
1012
11- The approval gate is NON-OPTIONAL. There is no bypass, no auto-approve,
12- and no timeout-to-approve. If approval is not received, the component
13- stays in staging indefinitely.
13+ The approval gate is NON-OPTIONAL. There is no bypass, no auto-approve, and no
14+ timeout-to-approve: if approval is not received, the component stays in staging.
15+ Approving is an owner-gated action ("approve_component") — it requires the bound
16+ owner identity (Apple ID + wallet) AND a fresh OTP. Rejecting is always allowed.
1417"""
1518
1619from __future__ import annotations
2225from enum import Enum
2326from typing import Any
2427
25- from bridge import DARDAN_TELEGRAM_ID
2628from bridge .exporter import ExportBundle
2729from bridge .sanitizer import SanitizationResult
2830
@@ -38,7 +40,7 @@ class ApprovalStatus(str, Enum):
3840
3941@dataclass
4042class ApprovalDecision :
41- """Records Dardan 's approval or rejection."""
43+ """Records the owner 's approval or rejection."""
4244 component_name : str
4345 version : str
4446 status : ApprovalStatus
@@ -58,46 +60,41 @@ def to_dict(self) -> dict[str, Any]:
5860
5961
6062class ApprovalGate :
61- """Blocks deployment until Dardan explicitly approves via Telegram .
63+ """Blocks deployment until the OTP-verified owner explicitly approves .
6264
6365 Usage::
6466
65- gate = ApprovalGate(telegram_notifier= notifier)
66- decision = await gate.request_approval(bundle, sanitizer_result)
67- if decision.status == ApprovalStatus.APPROVED:
68- # proceed to deploy
69- ...
67+ gate = ApprovalGate(owner_verification=owner, notifier=dispatcher )
68+ decision = await gate.request_approval(bundle, result,
69+ owner_apple_id=aid, owner_wallet=w)
70+ # owner submits an OTP code out-of-band:
71+ await gate.approve(decision.request_id, aid, w, otp_code)
7072 """
7173
72- POLL_INTERVAL = 10 # seconds between Telegram polls
74+ POLL_INTERVAL = 5 # seconds between decision checks
7375 MAX_WAIT = 3600 # 1 hour max wait (then mark as expired)
7476
75- def __init__ (self , telegram_notifier = None ):
76- """Initialize with an optional TelegramNotifier instance.
77-
78- If no notifier is provided, approval requests are logged but
79- cannot be delivered. Manual approval via set_decision() is
80- still supported .
77+ def __init__ (self , owner_verification = None , notifier = None ):
78+ """Args:
79+ owner_verification: an ``OwnerVerification`` (OTP gating). Without it,
80+ ``approve`` cannot authorize — the gate is effectively sealed.
81+ notifier: optional notification dispatcher to deliver the summary to
82+ the owner (SMS). If absent, the request is logged only .
8183 """
82- self .notifier = telegram_notifier
84+ self .owner = owner_verification
85+ self .notifier = notifier
8386 self ._pending : dict [str , ApprovalDecision ] = {}
8487
8588 async def request_approval (
8689 self ,
8790 bundle : ExportBundle ,
8891 sanitizer_result : SanitizationResult ,
92+ * ,
93+ owner_apple_id : str | None = None ,
94+ owner_wallet : str | None = None ,
8995 ) -> ApprovalDecision :
90- """Send approval request and wait for a decision.
91-
92- Args:
93- bundle: The component bundle awaiting approval.
94- sanitizer_result: Result from the sanitizer stage.
95-
96- Returns:
97- ApprovalDecision with the final status.
98- """
96+ """Deliver an approval request to the owner and wait for a decision."""
9997 request_id = f"approval_{ bundle .component_name } _{ bundle .version } _{ int (time .time ())} "
100-
10198 decision = ApprovalDecision (
10299 component_name = bundle .component_name ,
103100 version = bundle .version ,
@@ -106,28 +103,29 @@ async def request_approval(
106103 )
107104 self ._pending [request_id ] = decision
108105
109- # Build human-readable summary
110106 summary = self ._build_summary (bundle , sanitizer_result )
111107
112- # Send to Dardan via Telegram
113- if self .notifier :
114- await self .notifier .send_approval_request (
115- chat_id = DARDAN_TELEGRAM_ID ,
116- message = summary ,
117- request_id = request_id ,
118- )
119- logger .info ("Approval request sent to Dardan: %s" , request_id )
108+ # Deliver the summary to the owner (SMS), best-effort.
109+ if self .notifier is not None :
110+ try :
111+ await self .notifier .broadcast (summary , level = "critical" , channels = ["sms" ])
112+ except Exception :
113+ logger .exception ("Failed to deliver approval summary to owner" )
120114 else :
121- logger .warning (
122- "No Telegram notifier configured. Approval request logged "
123- "but not delivered: %s\n %s" , request_id , summary ,
124- )
115+ logger .warning ("No notifier configured. Approval request logged only:\n %s" , summary )
116+
117+ # Start an owner OTP so the owner can approve with a code.
118+ if self .owner is not None and owner_apple_id and owner_wallet :
119+ try :
120+ await self .owner .start_owner_otp (owner_apple_id , owner_wallet )
121+ except Exception :
122+ logger .exception ("Failed to start owner OTP for approval" )
123+
124+ logger .info ("Approval request pending owner OTP: %s" , request_id )
125125
126- # Poll for response
127126 start = time .time ()
128127 while decision .status == ApprovalStatus .PENDING :
129- elapsed = time .time () - start
130- if elapsed >= self .MAX_WAIT :
128+ if time .time () - start >= self .MAX_WAIT :
131129 decision .status = ApprovalStatus .EXPIRED
132130 decision .reason = f"No response after { self .MAX_WAIT } s"
133131 logger .warning ("Approval request expired: %s" , request_id )
@@ -136,47 +134,60 @@ async def request_approval(
136134
137135 return decision
138136
139- def set_decision (
137+ async def approve (
140138 self ,
141139 request_id : str ,
142- approved : bool ,
143- reason : str = "" ,
140+ apple_id : str | None ,
141+ wallet : str | None ,
142+ otp_code : str | None ,
144143 ) -> ApprovalDecision | None :
145- """Manually set a decision (called from Telegram callback or API).
146-
147- Args:
148- request_id: The approval request ID.
149- approved: True to approve, False to reject.
150- reason: Optional reason for the decision.
144+ """Approve a pending request — OTP-verified owner only.
151145
152- Returns:
153- The updated ApprovalDecision, or None if request_id not found.
146+ Requires the bound owner identity AND a fresh OTP. Returns the updated
147+ decision on success, or the still-PENDING decision (unchanged) if
148+ authorization fails.
154149 """
155150 decision = self ._pending .get (request_id )
156151 if not decision :
157152 logger .warning ("Unknown approval request: %s" , request_id )
158153 return None
154+ if self .owner is None :
155+ logger .error ("No OwnerVerification configured — cannot approve." )
156+ return decision
159157
160- decision .status = ApprovalStatus .APPROVED if approved else ApprovalStatus .REJECTED
158+ auth = await self .owner .authorize_owner_action (
159+ "approve_component" , apple_id , wallet , otp_code
160+ )
161+ if not auth .get ("authorized" ):
162+ logger .warning ("Approval denied for %s: %s" , request_id , auth .get ("reason" ))
163+ decision .reason = auth .get ("reason" , "Owner authorization failed." )
164+ return decision # stays PENDING
165+
166+ decision .status = ApprovalStatus .APPROVED
161167 decision .decided_at = time .time ()
162- decision .reason = reason
168+ decision .reason = "Approved by OTP-verified owner."
169+ logger .info ("Approved by owner: %s" , decision .component_name )
170+ return decision
163171
164- logger .info (
165- "Approval decision for %s: %s (reason: %s)" ,
166- decision .component_name , decision .status .value , reason or "none" ,
167- )
172+ def reject (self , request_id : str , reason : str = "" ) -> ApprovalDecision | None :
173+ """Reject a pending request. Always allowed (rejecting is the safe path)."""
174+ decision = self ._pending .get (request_id )
175+ if not decision :
176+ return None
177+ decision .status = ApprovalStatus .REJECTED
178+ decision .decided_at = time .time ()
179+ decision .reason = reason or "Rejected."
180+ logger .info ("Rejected: %s (%s)" , decision .component_name , decision .reason )
168181 return decision
169182
170183 def get_pending (self ) -> list [ApprovalDecision ]:
171- """Return all pending approval requests."""
172184 return [d for d in self ._pending .values () if d .status == ApprovalStatus .PENDING ]
173185
174186 def _build_summary (
175187 self ,
176188 bundle : ExportBundle ,
177189 sanitizer_result : SanitizationResult ,
178190 ) -> str :
179- """Build a human-readable summary for the Telegram message."""
180191 sanitizer_status = "CLEAN" if sanitizer_result .is_clean else "VIOLATIONS FOUND"
181192 violation_text = ""
182193 if not sanitizer_result .is_clean :
@@ -195,5 +206,5 @@ def _build_summary(
195206 f"Hash: { bundle .content_hash [:16 ]} ...\n "
196207 f"Sanitizer: { sanitizer_status } \n "
197208 f"{ violation_text } \n "
198- f"Reply /approve or /reject to this message ."
209+ f"To APPROVE, submit your owner OTP code. To reject, decline ."
199210 )
0 commit comments