3131
3232import json
3333import os
34+ import re
3435from dataclasses import dataclass , field
3536from pathlib import Path
3637from typing import Any
4445
4546# ordered by increasing autonomy
4647ACTION_ORDER = (ACTION_ESCALATE , ACTION_AUTO_NOTIFY , ACTION_AUTO_PR , ACTION_AUTO_MERGE )
48+ ACTION_RANK = {action : index for index , action in enumerate (ACTION_ORDER )}
4749
4850# ── risk tiers ──────────────────────────────────────────────────────────
4951
8890 "LICENSE" ,
8991 ".gitignore" ,
9092})
93+ CRITICAL_EXACT = frozenset ({
94+ ".github/codex_auto_merge_policy.json" ,
95+ })
96+ REPO_ROOT = Path (__file__ ).resolve ().parents [1 ]
97+ AUTONOMY_POLICY_PATH_ENV = "CODEX_AUDIT_SERVICE_AUTONOMY_POLICY_PATH"
98+
99+
100+ def load_autonomy_policy (path : Path | None = None ) -> dict [str , Any ]:
101+ """Load the shared autonomy policy from a trusted service-owned path.
102+
103+ The service must not read policy rules from the untrusted PR checkout being
104+ reviewed. Set CODEX_AUDIT_SERVICE_AUTONOMY_POLICY_PATH to a deployment-owned
105+ file or pass an explicit path in tests/tools. Missing or malformed files
106+ fall back to the built-in conservative classifier.
107+ """
108+ if path is None :
109+ env_path = os .environ .get (AUTONOMY_POLICY_PATH_ENV , "" ).strip ()
110+ if not env_path :
111+ return {}
112+ path = Path (env_path )
113+ if not path .exists ():
114+ return {}
115+ try :
116+ payload = json .loads (path .read_text (encoding = "utf-8" ))
117+ except (OSError , json .JSONDecodeError ):
118+ return {}
119+ return payload if isinstance (payload , dict ) else {}
91120
92121
93122@dataclass (frozen = True )
@@ -150,17 +179,49 @@ def get_matrix(self, repo: str | None = None) -> list[tuple[str, float, str]]:
150179 return self .decision_matrix
151180
152181
153- def classify_file_risk (path : str ) -> str :
182+ def _policy_matches (path : str , rule : dict [str , Any ]) -> bool :
183+ exact = rule .get ("exact" )
184+ if isinstance (exact , list ) and path in {str (item ) for item in exact }:
185+ return True
186+ prefixes = rule .get ("prefixes" )
187+ if isinstance (prefixes , list ) and any (path .startswith (str (prefix )) for prefix in prefixes ):
188+ return True
189+ return False
190+
191+
192+ def _blocked_by_policy (path : str , policy : dict [str , Any ] | None ) -> bool :
193+ if path in CRITICAL_EXACT :
194+ return True
195+ patterns = (policy or {}).get ("blocked_path_patterns" ) if isinstance (policy , dict ) else None
196+ raw_patterns = list (CRITICAL_PATTERNS )
197+ if isinstance (patterns , list ):
198+ raw_patterns .extend (pattern for pattern in patterns if isinstance (pattern , str ))
199+ for pattern in raw_patterns :
200+ if not isinstance (pattern , str ) or not pattern .strip ():
201+ continue
202+ try :
203+ if re .search (pattern , path , flags = re .IGNORECASE ):
204+ return True
205+ except re .error :
206+ continue
207+ return False
208+
209+
210+ def classify_file_risk (path : str , * , policy : dict [str , Any ] | None = None ) -> str :
154211 """Classify a changed file path into a risk tier.
155212
156- Mirrors the logic in codex_auto_merge_policy.json risk_policy.
213+ Mirrors ``codex_auto_merge_policy.json`` when present, then falls back to
214+ the built-in conservative rules.
157215 """
158- import re as _re
216+ if _blocked_by_policy (path , policy ):
217+ return RISK_CRITICAL
159218
160- # critical: secrets, credentials, keys
161- for pattern in CRITICAL_PATTERNS :
162- if _re .search (pattern , path ):
163- return RISK_CRITICAL
219+ risk_policy = (policy or {}).get ("risk_policy" ) if isinstance (policy , dict ) else None
220+ if isinstance (risk_policy , dict ):
221+ for tier in (RISK_CRITICAL , RISK_HIGH , RISK_MEDIUM , RISK_LOW ):
222+ rule = risk_policy .get (tier )
223+ if isinstance (rule , dict ) and _policy_matches (path , rule ):
224+ return tier
164225
165226 # low: exact match
166227 if path in LOW_RISK_EXACT :
@@ -185,14 +246,14 @@ def classify_file_risk(path: str) -> str:
185246 return RISK_MEDIUM
186247
187248
188- def classify_changes_risk (changed_paths : list [str ]) -> str :
249+ def classify_changes_risk (changed_paths : list [str ], * , policy : dict [ str , Any ] | None = None ) -> str :
189250 """Classify the overall risk of a set of changed file paths.
190251
191252 Returns the highest risk tier among all changed files.
192253 """
193254 if not changed_paths :
194255 return RISK_LOW
195- tiers = {classify_file_risk (p ) for p in changed_paths }
256+ tiers = {classify_file_risk (p , policy = policy ) for p in changed_paths }
196257 for tier in (RISK_CRITICAL , RISK_HIGH , RISK_MEDIUM , RISK_LOW ):
197258 if tier in tiers :
198259 return tier
@@ -231,6 +292,45 @@ def decide_action(
231292 return ACTION_ESCALATE
232293
233294
295+ def _cap_action (action : str , maximum : str ) -> str :
296+ if ACTION_RANK .get (action , 0 ) > ACTION_RANK .get (maximum , 0 ):
297+ return maximum
298+ return action
299+
300+
301+ def apply_runtime_guards (
302+ action : str ,
303+ * ,
304+ health_status : str | None = None ,
305+ quota_status : str | None = None ,
306+ ) -> tuple [str , list [str ]]:
307+ """Downgrade autonomy based on runtime health/quota state."""
308+ guarded_action = action
309+ guards : list [str ] = []
310+ health = (health_status or "healthy" ).strip ().lower ()
311+ quota = (quota_status or "ok" ).strip ().lower ()
312+
313+ if health == "unhealthy" :
314+ guarded_action = ACTION_ESCALATE
315+ guards .append ("service health is unhealthy; forcing human review" )
316+ elif health == "degraded" :
317+ capped = _cap_action (guarded_action , ACTION_AUTO_PR )
318+ if capped != guarded_action :
319+ guards .append ("service health is degraded; auto-merge capped at auto-pr" )
320+ guarded_action = capped
321+
322+ if quota in {"exhausted" , "blocked" }:
323+ guarded_action = ACTION_ESCALATE
324+ guards .append (f"quota status is { quota } ; forcing human review" )
325+ elif quota in {"low" , "constrained" }:
326+ capped = _cap_action (guarded_action , ACTION_AUTO_PR )
327+ if capped != guarded_action :
328+ guards .append (f"quota status is { quota } ; auto-merge capped at auto-pr" )
329+ guarded_action = capped
330+
331+ return guarded_action , guards
332+
333+
234334def extract_confidence (verdicts : list [dict [str , Any ]]) -> float :
235335 """Extract an aggregated confidence score from a list of reviewer verdicts.
236336
@@ -255,6 +355,9 @@ def recommended_action(
255355 * ,
256356 config : AutonomyConfig | None = None ,
257357 repo : str | None = None ,
358+ policy : dict [str , Any ] | None = None ,
359+ health_status : str | None = None ,
360+ quota_status : str | None = None ,
258361) -> dict [str , Any ]:
259362 """Compute the recommended autonomous action from AI verdicts and file risks.
260363
@@ -265,8 +368,10 @@ def recommended_action(
265368 reason: Human-readable explanation.
266369 """
267370 confidence = extract_confidence (verdicts )
268- risk = classify_changes_risk (changed_paths or [])
269- action = decide_action (confidence , risk , config = config , repo = repo )
371+ active_policy = policy if policy is not None else load_autonomy_policy ()
372+ risk = classify_changes_risk (changed_paths or [], policy = active_policy )
373+ initial_action = decide_action (confidence , risk , config = config , repo = repo )
374+ action , runtime_guards = apply_runtime_guards (initial_action , health_status = health_status , quota_status = quota_status )
270375
271376 reasons = {
272377 (ACTION_ESCALATE , RISK_CRITICAL ): "Critical files changed — always escalates to human review" ,
@@ -283,7 +388,12 @@ def recommended_action(
283388
284389 return {
285390 "action" : action ,
391+ "initial_action" : initial_action ,
286392 "confidence" : confidence ,
287393 "risk" : risk ,
288394 "reason" : reason ,
395+ "human_review_required" : action == ACTION_ESCALATE ,
396+ "auto_merge_allowed" : action == ACTION_AUTO_MERGE ,
397+ "runtime_guards" : runtime_guards ,
398+ "policy_version" : active_policy .get ("version" ) if isinstance (active_policy , dict ) else None ,
289399 }
0 commit comments