Skip to content

Commit b092fc5

Browse files
authored
Merge pull request #14 from QuantStrategyLab/codex/autonomy-policy-control
feat: guard autonomy decisions with shared policy
2 parents 9d5719b + d150e22 commit b092fc5

3 files changed

Lines changed: 209 additions & 12 deletions

File tree

service/ai_gateway_service.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from service.adapters.llm_adapter import LlmAdapter
4545
from service.adapters.codex_adapter import CodexAdapter
4646
from service.autonomy import (
47+
load_autonomy_policy,
4748
recommended_action as compute_recommended_action,
4849
)
4950
from service.feedback import (
@@ -905,7 +906,11 @@ def _handle_review(self, payload: dict[str, Any]) -> None:
905906
# Autonomy decision: confidence + file risk → recommended action
906907
repo = str(payload.get("source_repository") or "")
907908
action = compute_recommended_action(
908-
results, changed_paths, repo=repo if repo else None,
909+
results,
910+
changed_paths,
911+
repo=repo if repo else None,
912+
policy=load_autonomy_policy(),
913+
health_status=get_health_monitor().status,
909914
)
910915
_audit_log("review_completed", consensus=consensus, all_success=all_ok,
911916
action=action["action"], confidence=action["confidence"], risk=action["risk"])

service/autonomy.py

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import json
3333
import os
34+
import re
3435
from dataclasses import dataclass, field
3536
from pathlib import Path
3637
from typing import Any
@@ -44,6 +45,7 @@
4445

4546
# ordered by increasing autonomy
4647
ACTION_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

@@ -88,6 +90,33 @@
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+
234334
def 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
}

tests/test_autonomy.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@
22

33
from __future__ import annotations
44

5+
import os
6+
from pathlib import Path
7+
import tempfile
58
import unittest
69

710
from service.autonomy import (
811
ACTION_AUTO_MERGE,
912
ACTION_AUTO_PR,
1013
ACTION_ESCALATE,
14+
classify_file_risk,
1115
RISK_HIGH,
1216
RISK_LOW,
1317
RISK_MEDIUM,
18+
RISK_CRITICAL,
19+
AUTONOMY_POLICY_PATH_ENV,
1420
DEFAULT_DECISION_MATRIX,
21+
load_autonomy_policy,
1522
recommended_action,
1623
)
1724

@@ -38,3 +45,78 @@ def test_default_matrix_reflects_safer_thresholds(self) -> None:
3845
self.assertIn((RISK_MEDIUM, 0.70, ACTION_AUTO_PR), DEFAULT_DECISION_MATRIX)
3946
self.assertIn((RISK_HIGH, 0.85, ACTION_AUTO_PR), DEFAULT_DECISION_MATRIX)
4047
self.assertNotIn((RISK_HIGH, 0.95, ACTION_AUTO_MERGE), DEFAULT_DECISION_MATRIX)
48+
49+
def test_shared_policy_classifies_blocked_and_low_risk_paths(self) -> None:
50+
policy = {
51+
"version": 7,
52+
"blocked_path_patterns": [r"(^|/).*token.*$"],
53+
"risk_policy": {
54+
"low": {"prefixes": ["docs/"], "exact": ["CHANGELOG.md"]},
55+
"high": {"prefixes": ["src/quant_"]},
56+
},
57+
}
58+
59+
self.assertEqual(classify_file_risk("docs/runbook.md", policy=policy), RISK_LOW)
60+
self.assertEqual(classify_file_risk("CHANGELOG.md", policy=policy), RISK_LOW)
61+
self.assertEqual(classify_file_risk("src/quant_alpha.py", policy=policy), RISK_HIGH)
62+
self.assertEqual(classify_file_risk("config/token.txt", policy=policy), RISK_CRITICAL)
63+
self.assertEqual(classify_file_risk("config/secret.pem", policy=policy), RISK_CRITICAL)
64+
65+
def test_policy_load_does_not_depend_on_cwd(self) -> None:
66+
with tempfile.TemporaryDirectory() as tmp:
67+
policy_path = Path(__file__).resolve().parents[1] / ".github" / "codex_auto_merge_policy.json"
68+
old_cwd = os.getcwd()
69+
old_env = os.environ.get(AUTONOMY_POLICY_PATH_ENV)
70+
os.environ[AUTONOMY_POLICY_PATH_ENV] = str(policy_path)
71+
try:
72+
os.chdir(tmp)
73+
policy = load_autonomy_policy()
74+
finally:
75+
os.chdir(old_cwd)
76+
if old_env is None:
77+
os.environ.pop(AUTONOMY_POLICY_PATH_ENV, None)
78+
else:
79+
os.environ[AUTONOMY_POLICY_PATH_ENV] = old_env
80+
81+
self.assertEqual(policy.get("version"), 1)
82+
83+
def test_policy_is_not_loaded_from_repo_by_default(self) -> None:
84+
old_env = os.environ.pop(AUTONOMY_POLICY_PATH_ENV, None)
85+
try:
86+
self.assertEqual(load_autonomy_policy(), {})
87+
finally:
88+
if old_env is not None:
89+
os.environ[AUTONOMY_POLICY_PATH_ENV] = old_env
90+
91+
def test_autonomy_policy_file_cannot_be_downgraded_by_policy(self) -> None:
92+
malicious_policy = {
93+
"risk_policy": {
94+
"low": {"exact": [".github/codex_auto_merge_policy.json"]},
95+
},
96+
}
97+
98+
self.assertEqual(
99+
classify_file_risk(".github/codex_auto_merge_policy.json", policy=malicious_policy),
100+
RISK_CRITICAL,
101+
)
102+
result = recommended_action(
103+
[{"confidence": 0.99}],
104+
[".github/codex_auto_merge_policy.json"],
105+
policy=malicious_policy,
106+
)
107+
self.assertEqual(result["action"], ACTION_ESCALATE)
108+
109+
def test_degraded_health_caps_auto_merge_to_auto_pr(self) -> None:
110+
result = recommended_action([{"confidence": 0.99}], ["docs/runbook.md"], health_status="degraded")
111+
112+
self.assertEqual(result["initial_action"], ACTION_AUTO_MERGE)
113+
self.assertEqual(result["action"], ACTION_AUTO_PR)
114+
self.assertFalse(result["auto_merge_allowed"])
115+
self.assertTrue(result["runtime_guards"])
116+
117+
def test_unhealthy_health_forces_human_review(self) -> None:
118+
result = recommended_action([{"confidence": 0.99}], ["docs/runbook.md"], health_status="unhealthy")
119+
120+
self.assertEqual(result["initial_action"], ACTION_AUTO_MERGE)
121+
self.assertEqual(result["action"], ACTION_ESCALATE)
122+
self.assertTrue(result["human_review_required"])

0 commit comments

Comments
 (0)