code-mower 0.5.0b5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1738 @@
1
+ #!/usr/bin/env python3
2
+ """Codex audit CLI — review a single PR via the OpenAI Codex CLI.
3
+
4
+ Standalone command + Python module. UNLIKE the Devin bridge (cloud webhook)
5
+ and the local LLM bridge (local OpenAI-compatible endpoint), the Codex bridge
6
+ invokes the local Codex CLI as a subprocess. The first call uses the
7
+ built-in review subcommand against a checked-out worktree of the PR
8
+ branch; the second call converts that prose review into a schema-shaped
9
+ verdict JSON artifact. The built-in reviewer has empirically caught real
10
+ bugs the other reviewers miss on the same PR — see the multi-reviewer
11
+ calibration in PR #233 (Devin PASS + local LLM 7 false positives + Codex 6
12
+ real findings).
13
+
14
+ Pipeline per audit:
15
+
16
+ 1. Resolve the LOCAL repo path for `owner/repo` from
17
+ `CODEX_AUDIT_REPO_PATHS` (the CLI requires a real git checkout for
18
+ the built-in review).
19
+ 2. Fetch the PR head SHA via the GitHub API.
20
+ 3. `git fetch origin pull/<N>/head` + create a temporary worktree at
21
+ the head SHA (detached, so we don't pollute branch namespace).
22
+ 4. Run `codex exec --ignore-user-config --sandbox read-only review
23
+ --base origin/main` from that worktree, capturing the final review
24
+ prose via `--output-last-message`.
25
+ 5. Run generic `codex exec --ignore-user-config --output-schema`
26
+ outside the PR worktree to convert that prose into the wrapper-owned
27
+ `codeMower.codexAudit.v1` verdict JSON, then validate it with a
28
+ stdlib shape checker. Policy: P0/P1/P2 = blocker; P3 = concern
29
+ (non-blocking). This matches how Codex's #233 findings broke down
30
+ empirically.
31
+ 6. Stale-head check: refetch PR head; if it changed mid-review, emit
32
+ the `needs-codex-audit` trailer instead of PASS/BLOCKED.
33
+ 7. Post the parsed review as a PR comment ending with the authoritative
34
+ trailer line.
35
+ 8. Clean up the worktree.
36
+
37
+ CLI usage:
38
+
39
+ CODEX_AUDIT_REPO_PATHS=\\
40
+ owner/repo:/path/to/reference-app,\\
41
+ owner/other-repo:/path/to/reference-service \\
42
+ GITHUB_TOKEN=... \\
43
+ tools/run_codex_audit_pr.sh --repo owner/other-repo --pr 233
44
+
45
+ Prefer the wrapper over direct `python3 tools/codex_audit_pr.py`: it
46
+ selects a controlled Python interpreter for this script and refuses to
47
+ fall back silently to ambient system Python.
48
+
49
+ Module usage (called by `codex_audit_bridge.py` if/when we add a polling
50
+ daemon analogous to local_llm_audit_bridge.py):
51
+
52
+ from tools.codex_audit_pr import AuditConfig, audit_pr
53
+ result = audit_pr(config, "owner/other-repo", 233)
54
+
55
+ Exit codes (CLI mode):
56
+ 0 comment posted (or dry-run printed)
57
+ 1 generic error (config, network, Codex CLI failure)
58
+ 2 stale head SHA detected mid-review (caller may requeue)
59
+
60
+ Trailer protocol (mirrors Devin/local LLM):
61
+ <!-- CODEX_AUDIT_STATE: codex-audit-done --> (verdict was PASS)
62
+ <!-- CODEX_AUDIT_STATE: codex-audit-blocked --> (verdict was BLOCKED)
63
+ <!-- CODEX_AUDIT_STATE: needs-codex-audit --> (stale head; requeue)
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import argparse
69
+ import json
70
+ import os
71
+ import selectors
72
+ import shutil
73
+ import subprocess
74
+ import sys
75
+ import tempfile
76
+ import time
77
+ import urllib.error
78
+ import warnings
79
+ from dataclasses import dataclass, field, replace
80
+ from datetime import datetime, timezone
81
+ from pathlib import Path
82
+ from typing import Any, Dict, List, Optional, Tuple
83
+
84
+ if __package__ in {None, "", "tools"}:
85
+ try:
86
+ from tools.audit_progress import AuditProgress, run_subprocess_with_progress
87
+ from tools.provider_runners import (
88
+ clip_text as _clip_text,
89
+ fetch_pull_request,
90
+ limit_comment_body,
91
+ one_line as _one_line,
92
+ parse_repo_paths as _parse_repo_paths,
93
+ pop_github_token_env,
94
+ post_pr_comment,
95
+ repost_audit_verdict_artifact,
96
+ require_exact_keys as _require_exact_keys,
97
+ resolve_github_token_from_stdin_or_env,
98
+ write_audit_verdict_artifact,
99
+ )
100
+ except ImportError: # pragma: no cover - direct script execution fallback
101
+ from audit_progress import AuditProgress, run_subprocess_with_progress # type: ignore
102
+ from provider_runners import ( # type: ignore
103
+ clip_text as _clip_text,
104
+ fetch_pull_request,
105
+ limit_comment_body,
106
+ one_line as _one_line,
107
+ parse_repo_paths as _parse_repo_paths,
108
+ pop_github_token_env,
109
+ post_pr_comment,
110
+ repost_audit_verdict_artifact,
111
+ require_exact_keys as _require_exact_keys,
112
+ resolve_github_token_from_stdin_or_env,
113
+ write_audit_verdict_artifact,
114
+ )
115
+ else: # pragma: no cover - exercised after package extraction.
116
+ from .audit_progress import AuditProgress, run_subprocess_with_progress
117
+ from .provider_runners import (
118
+ clip_text as _clip_text,
119
+ fetch_pull_request,
120
+ limit_comment_body,
121
+ one_line as _one_line,
122
+ parse_repo_paths as _parse_repo_paths,
123
+ pop_github_token_env,
124
+ post_pr_comment,
125
+ repost_audit_verdict_artifact,
126
+ require_exact_keys as _require_exact_keys,
127
+ resolve_github_token_from_stdin_or_env,
128
+ write_audit_verdict_artifact,
129
+ )
130
+
131
+
132
+ # ----- Configuration / defaults -----
133
+
134
+ DEFAULT_CODEX_CLI_PATH = "/Applications/Codex.app/Contents/Resources/codex"
135
+ DEFAULT_CODEX_TIMEOUT = 900 # 15 min; Codex review can take 5-10 min on a large diff
136
+ DEFAULT_BASE_REF = "origin/main"
137
+ DEFAULT_REPOS = "owner/repo,owner/other-repo"
138
+ DEFAULT_IGNORE_USER_CONFIG = True
139
+ DEFAULT_DIFF_DIAGNOSTIC_BUDGET_BYTES = 600_000
140
+ CODEX_AUDIT_SCHEMA_ID = "codeMower.codexAudit.v1"
141
+ CODEX_AUDIT_VERDICT_SCHEMA_PATH = (
142
+ Path(__file__).resolve().with_name("codex_audit_verdict.schema.json")
143
+ )
144
+ MAX_VERDICT_FILE_BYTES = 1_000_000
145
+ MAX_RENDERED_FINDINGS = 50
146
+ MAX_SUMMARY_CHARS = 4_000
147
+ MAX_FINDING_TITLE_CHARS = 300
148
+ MAX_FINDING_FILE_CHARS = 500
149
+ MAX_FINDING_DETAIL_CHARS = 4_000
150
+
151
+
152
+ # ----- Data classes -----
153
+
154
+
155
+ @dataclass
156
+ class AuditConfig:
157
+ github_token: str
158
+ repo_paths: Dict[str, Path] # "owner/repo" → local checkout path
159
+ codex_cli_path: str = DEFAULT_CODEX_CLI_PATH
160
+ base_ref: str = DEFAULT_BASE_REF
161
+ timeout: int = DEFAULT_CODEX_TIMEOUT
162
+ dry_run: bool = False
163
+ # Structured audits are automation, not an interactive Codex session.
164
+ # Default to ignoring user config so personal model/reasoning/plugin
165
+ # settings cannot make audit runtime or behavior depend on the operator's
166
+ # desktop profile. `--use-user-config` is the explicit escape hatch for
167
+ # CLI debugging.
168
+ ignore_user_config: bool = DEFAULT_IGNORE_USER_CONFIG
169
+ # Optional override for the Python venv whose `bin/` is prepended to
170
+ # PATH (and exported as VIRTUAL_ENV) when invoking `codex review`.
171
+ # If None AND `disable_venv` is False, `audit_pr()` auto-discovers
172
+ # `<local_repo>/.venv/`.
173
+ #
174
+ # Without this, `codex review`'s subprocesses (pytest, build_oracle_*,
175
+ # etc.) inherit the audit machine's system PATH — on a typical
176
+ # macOS dev box that resolves to anaconda Python 3.7.6 with stale
177
+ # numpy/Pillow, instead of the canonical .venv (Python 3.12.13,
178
+ # numpy 2.3.5, Pillow 12.2.0 per corpus_manifest.json). The drift
179
+ # produced bogus per-pixel diffs on PR #262 round 3.
180
+ venv_path: Optional[Path] = None
181
+ # Explicit kill switch for venv injection: when True, audit_pr()
182
+ # does NOT auto-discover even if `<local_repo>/.venv/` exists.
183
+ # The CLI sets this via `--venv-path ""` / empty
184
+ # CODEX_AUDIT_VENV_PATH. Separate field rather than overloading
185
+ # venv_path with a `Path("")` sentinel — round-4 P2 on PR #264:
186
+ # `str(Path(""))` returns `"."`, not `""`, so the sentinel could
187
+ # accidentally inject `./bin` if cwd had one.
188
+ disable_venv: bool = False
189
+ # Shared progress emitter for long-running audit phases. When unset,
190
+ # audit_pr() installs the default Code Mower stderr emitter.
191
+ progress: Optional[AuditProgress] = None
192
+
193
+
194
+ @dataclass
195
+ class CodexVerdict:
196
+ """Parsed verdict from Codex audit output."""
197
+ verdict: str # "PASS" | "BLOCKED" | "UNKNOWN"
198
+ prose: str # the final review block (clean, ready to render as a comment)
199
+ p0_count: int = 0
200
+ p1_count: int = 0
201
+ p2_count: int = 0
202
+ p3_count: int = 0
203
+ findings: List[str] = field(default_factory=list) # raw [P*] finding lines
204
+ # Local-only diagnostic for structured verdict self-inconsistency
205
+ # (e.g. verdict=pass but P2 findings are present). This is not
206
+ # rendered into the public PR comment; audit_pr() logs it to stderr.
207
+ mismatch_note: str = ""
208
+
209
+ @property
210
+ def blocker_count(self) -> int:
211
+ # Policy: P0/P1/P2 = blocker; P3 = concern (non-blocking).
212
+ # Matches the empirical severity breakdown on #233 where every P2
213
+ # finding was a real bug worth fixing.
214
+ return self.p0_count + self.p1_count + self.p2_count
215
+
216
+ def to_dict(self) -> Dict[str, Any]:
217
+ return {
218
+ "verdict": self.verdict,
219
+ "p0_count": self.p0_count,
220
+ "p1_count": self.p1_count,
221
+ "p2_count": self.p2_count,
222
+ "p3_count": self.p3_count,
223
+ "blocker_count": self.blocker_count,
224
+ "findings": self.findings,
225
+ "mismatch_note": self.mismatch_note,
226
+ }
227
+
228
+
229
+ @dataclass
230
+ class AuditResult:
231
+ repo: str
232
+ pr_number: int
233
+ head_sha_start: str
234
+ head_sha_end: str
235
+ verdict: str # "PASS" | "BLOCKED" | "STALE"
236
+ trailer: str
237
+ comment_body: str
238
+ codex_stdout: str # stdout from the codex CLI (the review proper)
239
+ codex_stderr: str = "" # stderr captured separately for debugging
240
+ parsed: Optional[CodexVerdict] = None
241
+ posted_comment_url: Optional[str] = None
242
+ verdict_artifact_path: Optional[Path] = None
243
+
244
+ def head_changed_during_review(self) -> bool:
245
+ return self.head_sha_start != self.head_sha_end
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class ReviewContextDiagnostics:
250
+ base_ref: str
251
+ head_sha: str
252
+ changed_file_count: int
253
+ diff_bytes: int
254
+ diagnostic_budget_bytes: int = DEFAULT_DIFF_DIAGNOSTIC_BUDGET_BYTES
255
+
256
+ @property
257
+ def over_diagnostic_budget(self) -> bool:
258
+ return self.diff_bytes > self.diagnostic_budget_bytes
259
+
260
+ def summary(self) -> str:
261
+ return (
262
+ f"base_ref={self.base_ref}; head={self.head_sha[:12]}; "
263
+ f"changed_files={self.changed_file_count}; "
264
+ f"full_diff={self.diff_bytes} bytes; "
265
+ f"diagnostic_budget={self.diagnostic_budget_bytes} bytes; "
266
+ f"wrapper_truncated=no; "
267
+ f"over_diagnostic_budget={'yes' if self.over_diagnostic_budget else 'no'}; "
268
+ "codex_cli_owns_review_context=yes"
269
+ )
270
+
271
+
272
+ # ----- Trailers -----
273
+
274
+
275
+ STALE_TRAILER = "<!-- CODEX_AUDIT_STATE: needs-codex-audit -->"
276
+ DONE_TRAILER = "<!-- CODEX_AUDIT_STATE: codex-audit-done -->"
277
+ BLOCKED_TRAILER = "<!-- CODEX_AUDIT_STATE: codex-audit-blocked -->"
278
+
279
+
280
+ # ----- Structured verdict validation -----
281
+
282
+
283
+ def _unknown_structured_verdict(reason: str) -> CodexVerdict:
284
+ return CodexVerdict(
285
+ verdict="UNKNOWN",
286
+ prose=(
287
+ f"(structured Codex verdict is unusable: {reason}; "
288
+ "requeue and retry)"
289
+ ),
290
+ )
291
+
292
+
293
+ def _render_structured_prose(
294
+ summary: str,
295
+ findings: List[Dict[str, Any]],
296
+ total_findings: int,
297
+ ) -> str:
298
+ lines = [
299
+ "Summary:",
300
+ "",
301
+ _clip_text(summary, MAX_SUMMARY_CHARS),
302
+ "",
303
+ ]
304
+ if not findings:
305
+ lines.append("Findings: none.")
306
+ return "\n".join(lines)
307
+
308
+ lines.extend(["Findings:", ""])
309
+ for finding in findings:
310
+ severity = finding["severity"]
311
+ title = _one_line(finding["title"], MAX_FINDING_TITLE_CHARS)
312
+ file_path = _one_line(finding["file"], MAX_FINDING_FILE_CHARS)
313
+ line = finding["line"]
314
+ detail = _clip_text(finding["detail"], MAX_FINDING_DETAIL_CHARS)
315
+ lines.append(f"- [{severity}] {title} -- `{file_path}:{line}`")
316
+ for detail_line in detail.splitlines():
317
+ lines.append(f" {detail_line}")
318
+
319
+ omitted = total_findings - len(findings)
320
+ if omitted > 0:
321
+ lines.extend([
322
+ "",
323
+ f"... {omitted} additional finding(s) omitted from the comment "
324
+ "to stay within GitHub's comment limits.",
325
+ ])
326
+ return "\n".join(lines)
327
+
328
+
329
+ def parse_structured_codex_verdict(data: Any) -> CodexVerdict:
330
+ """Validate and classify a structured Codex audit verdict.
331
+
332
+ This is deliberately small and stdlib-only. The Codex CLI's
333
+ `--output-schema` shapes the final message, but the wrapper remains
334
+ authoritative for the label policy and re-checks every field before
335
+ posting a trailer-bearing PR comment.
336
+ """
337
+ top_keys = {"schema", "verdict", "summary", "findings"}
338
+ finding_keys = {"severity", "title", "file", "line", "detail"}
339
+
340
+ if not isinstance(data, dict):
341
+ return _unknown_structured_verdict("top-level value is not an object")
342
+
343
+ key_error = _require_exact_keys(data, top_keys, "top-level object")
344
+ if key_error:
345
+ return _unknown_structured_verdict(key_error)
346
+
347
+ if data["schema"] != CODEX_AUDIT_SCHEMA_ID:
348
+ return _unknown_structured_verdict(
349
+ f"schema is {data['schema']!r}, expected {CODEX_AUDIT_SCHEMA_ID!r}"
350
+ )
351
+
352
+ declared_verdict = data["verdict"]
353
+ if declared_verdict not in ("pass", "blocked"):
354
+ return _unknown_structured_verdict(
355
+ f"verdict is {declared_verdict!r}, expected 'pass' or 'blocked'"
356
+ )
357
+
358
+ summary = data["summary"]
359
+ if not isinstance(summary, str) or not summary.strip():
360
+ return _unknown_structured_verdict("summary must be a non-empty string")
361
+
362
+ raw_findings = data["findings"]
363
+ if not isinstance(raw_findings, list):
364
+ return _unknown_structured_verdict("findings must be an array")
365
+
366
+ p_counts = {0: 0, 1: 0, 2: 0, 3: 0}
367
+ rendered_findings: List[Dict[str, Any]] = []
368
+ finding_lines: List[str] = []
369
+
370
+ for idx, raw_finding in enumerate(raw_findings):
371
+ where = f"findings[{idx}]"
372
+ if not isinstance(raw_finding, dict):
373
+ return _unknown_structured_verdict(f"{where} is not an object")
374
+
375
+ key_error = _require_exact_keys(raw_finding, finding_keys, where)
376
+ if key_error:
377
+ return _unknown_structured_verdict(key_error)
378
+
379
+ severity = raw_finding["severity"]
380
+ if severity not in ("P0", "P1", "P2", "P3"):
381
+ return _unknown_structured_verdict(
382
+ f"{where}.severity is {severity!r}, expected P0/P1/P2/P3"
383
+ )
384
+
385
+ for field_name in ("title", "file", "detail"):
386
+ field_value = raw_finding[field_name]
387
+ if not isinstance(field_value, str) or not field_value.strip():
388
+ return _unknown_structured_verdict(
389
+ f"{where}.{field_name} must be a non-empty string"
390
+ )
391
+
392
+ file_path = raw_finding["file"]
393
+ if "\n" in file_path or "\r" in file_path:
394
+ return _unknown_structured_verdict(
395
+ f"{where}.file must be a single-line path"
396
+ )
397
+
398
+ line = raw_finding["line"]
399
+ if isinstance(line, bool) or not isinstance(line, int) or line < 1:
400
+ return _unknown_structured_verdict(
401
+ f"{where}.line must be an integer >= 1"
402
+ )
403
+
404
+ p_counts[int(severity[1])] += 1
405
+ finding_lines.append(
406
+ f"- [{severity}] {_one_line(raw_finding['title'], MAX_FINDING_TITLE_CHARS)} "
407
+ f"-- {_one_line(file_path, MAX_FINDING_FILE_CHARS)}:{line}"
408
+ )
409
+ if len(rendered_findings) < MAX_RENDERED_FINDINGS:
410
+ rendered_findings.append(raw_finding)
411
+
412
+ blocker_count = p_counts[0] + p_counts[1] + p_counts[2]
413
+ if blocker_count > 0:
414
+ verdict = "BLOCKED"
415
+ mismatch_note = (
416
+ "structured verdict declared pass but blocker findings are present"
417
+ if declared_verdict == "pass" else ""
418
+ )
419
+ elif declared_verdict == "pass":
420
+ verdict = "PASS"
421
+ mismatch_note = ""
422
+ else:
423
+ return _unknown_structured_verdict(
424
+ "verdict declared blocked but no P0/P1/P2 findings were present"
425
+ )
426
+
427
+ return CodexVerdict(
428
+ verdict=verdict,
429
+ prose=_render_structured_prose(summary, rendered_findings, len(raw_findings)),
430
+ p0_count=p_counts[0],
431
+ p1_count=p_counts[1],
432
+ p2_count=p_counts[2],
433
+ p3_count=p_counts[3],
434
+ findings=finding_lines,
435
+ mismatch_note=mismatch_note,
436
+ )
437
+
438
+
439
+ def parse_structured_codex_verdict_text(text: str) -> CodexVerdict:
440
+ try:
441
+ data = json.loads(text)
442
+ except json.JSONDecodeError as exc:
443
+ return _unknown_structured_verdict(f"invalid JSON: {exc}")
444
+ return parse_structured_codex_verdict(data)
445
+
446
+
447
+ def load_structured_codex_verdict(path: Path) -> CodexVerdict:
448
+ if not path.exists():
449
+ return _unknown_structured_verdict(f"verdict file was not created at {path}")
450
+ try:
451
+ size = path.stat().st_size
452
+ except OSError as exc:
453
+ return _unknown_structured_verdict(f"could not stat verdict file: {exc}")
454
+ if size <= 0:
455
+ return _unknown_structured_verdict("verdict file is empty")
456
+ if size > MAX_VERDICT_FILE_BYTES:
457
+ return _unknown_structured_verdict(
458
+ f"verdict file is too large ({size} bytes)"
459
+ )
460
+ try:
461
+ os.chmod(path, 0o600)
462
+ except OSError:
463
+ pass
464
+ try:
465
+ text = path.read_text(encoding="utf-8")
466
+ except OSError as exc:
467
+ return _unknown_structured_verdict(f"could not read verdict file: {exc}")
468
+ return parse_structured_codex_verdict_text(text)
469
+
470
+
471
+ # ----- CLI diagnostic capture -----
472
+
473
+
474
+ DEFAULT_CLI_FAILURE_DIR = (
475
+ Path.home() / ".cache" / "code-mower-audits" / "cli-failures"
476
+ )
477
+
478
+
479
+ def dump_cli_failure(
480
+ repo: str,
481
+ pr_number: int,
482
+ head_sha: str,
483
+ stdout: str,
484
+ stderr: str,
485
+ reason: str,
486
+ base_dir: Optional[Path] = None,
487
+ ) -> Optional[Path]:
488
+ """Persist raw Codex CLI output to disk when the comment body lacks
489
+ the diagnostic content needed to investigate the verdict.
490
+
491
+ Called from the UNKNOWN audit_pr branch: the structured verdict file
492
+ was missing, malformed, failed schema validation, or was
493
+ self-inconsistent. The orchestrator auto-requeues, but without this
494
+ dump there's no way to diagnose "real structured-output drift or a
495
+ one-shot Codex CLI flake?" once the worktree is torn down and the next
496
+ audit overwrites the in-memory capture.
497
+
498
+ Output: `<base_dir>/<owner>_<name>_pr<N>_<sha[:12]>_<UTC>.log`.
499
+ The file is self-describing — header includes repo/pr/sha/reason
500
+ plus stdout/stderr byte counts so it stays useful when attached
501
+ to an issue without the surrounding context.
502
+
503
+ File permissions: 0o600 (owner read/write only). Directory: 0o700.
504
+ The raw output can contain source snippets and credentials
505
+ accidentally printed by PR-controlled subprocesses, so on a
506
+ shared audit host the default umask (often 0o644 file / 0o755
507
+ dir) would leak audit data to other users on the box. Created
508
+ via `os.open(..., mode=0o600)` rather than `write_text()` +
509
+ `chmod()` so there's no TOCTOU window where the file briefly
510
+ exists with the umask perms before being restricted. (Codex P2
511
+ audit on reference-app#196 5e97011.)
512
+
513
+ Symlink / pre-existing-path safety: open uses `O_EXCL | O_NOFOLLOW`
514
+ in addition to `O_CREAT`, so a PR-controlled subprocess that
515
+ pre-creates the dump path (predictable filename) as either a
516
+ symlink-to-sensitive-target or a world-readable regular file
517
+ cannot trick this helper into truncating an arbitrary file or
518
+ leaving our 0o600 mode unenforced (`mode` is ignored for
519
+ existing targets). A collision fails the open; the OSError
520
+ handler returns None and the dump is just skipped — preferable
521
+ to writing potentially-credential-laden content into an attacker-
522
+ controlled path. The timestamp includes microseconds to make
523
+ natural collisions vanishingly rare. (Codex P2 audit on
524
+ reference-app#196 17b4156.)
525
+
526
+ Returns the dump path on success, or None if the dump failed
527
+ (best-effort; we never want this helper to interrupt an audit).
528
+
529
+ `base_dir` is parameterized for tests; defaults to
530
+ `DEFAULT_CLI_FAILURE_DIR` (`~/.cache/code-mower-audits/cli-failures/`).
531
+ """
532
+ try:
533
+ if base_dir is None:
534
+ base_dir = DEFAULT_CLI_FAILURE_DIR
535
+ # `mkdir(mode=0o700)` only takes effect on dirs we create; if the
536
+ # dir already exists with looser perms, mkdir(exist_ok=True) is
537
+ # a no-op. Chmod afterward to make it idempotent. Wrap in its
538
+ # own try so a chmod failure (we don't own a pre-existing dir)
539
+ # doesn't drop the dump — the file's own 0o600 still protects
540
+ # the content.
541
+ base_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
542
+ try:
543
+ os.chmod(base_dir, 0o700)
544
+ except OSError:
545
+ pass
546
+
547
+ owner_name = repo.replace("/", "_")
548
+ short_sha = (head_sha or "unknown")[:12]
549
+ # Microsecond resolution — same-second collisions across audits
550
+ # would have caused O_EXCL (below) to refuse the open.
551
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ")
552
+ path = base_dir / f"{owner_name}_pr{pr_number}_{short_sha}_{ts}.log"
553
+
554
+ # Codex P3 audit on reference-app#196 / reference-service#358 0eff691/9cd9213:
555
+ # `len(str)` counts Python characters, not UTF-8 bytes. The
556
+ # CLI output frequently contains non-ASCII (em-dashes, smart
557
+ # quotes, etc.), so a char-count header on a multi-byte body
558
+ # would under-report and mislead anyone investigating
559
+ # truncation. Encode then measure to get the real byte count
560
+ # that landed on disk via `fdopen(..., encoding="utf-8")`.
561
+ stdout_bytes = len(stdout.encode("utf-8"))
562
+ stderr_bytes = len(stderr.encode("utf-8"))
563
+ header = (
564
+ "# Codex CLI raw output — structured verdict was unusable\n"
565
+ f"# repo: {repo}\n"
566
+ f"# pr: {pr_number}\n"
567
+ f"# head_sha: {head_sha}\n"
568
+ f"# captured_at: {ts}\n"
569
+ f"# diagnostic_reason: {reason}\n"
570
+ f"# stdout_bytes: {stdout_bytes}\n"
571
+ f"# stderr_bytes: {stderr_bytes}\n"
572
+ )
573
+ body = (
574
+ header
575
+ + "\n===== STDOUT =====\n"
576
+ + (stdout if stdout else "<empty>\n")
577
+ + "\n===== STDERR =====\n"
578
+ + (stderr if stderr else "<empty>\n")
579
+ )
580
+ # Atomic create-with-mode + symlink/pre-existing refusal:
581
+ # - O_CREAT|O_EXCL: refuses if path already exists; together
582
+ # they guarantee our 0o600 mode is enforced (the mode arg
583
+ # is ignored by the kernel for existing targets).
584
+ # - O_NOFOLLOW: refuses if the final path component is a
585
+ # symlink, so a pre-planted symlink-to-sensitive-file
586
+ # cannot redirect our write.
587
+ # On collision/symlink: open raises OSError → caught by the
588
+ # outer try → dump skipped (None returned). Better to lose a
589
+ # diagnostic dump than write credential-laden bytes to an
590
+ # attacker-controlled path.
591
+ fd = os.open(
592
+ str(path),
593
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
594
+ 0o600,
595
+ )
596
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
597
+ f.write(body)
598
+ return path
599
+ except OSError as exc:
600
+ # Never propagate: a logging failure must not break the audit.
601
+ print(
602
+ f" warning: failed to dump CLI output: {exc}",
603
+ file=sys.stderr,
604
+ flush=True,
605
+ )
606
+ return None
607
+
608
+
609
+ # ----- Worktree management -----
610
+
611
+
612
+ def _create_temp_worktree(local_repo: Path, head_sha: str) -> Path:
613
+ """Create a detached worktree at `head_sha` under a tempdir. Returns
614
+ the worktree path."""
615
+ # First make sure the SHA is present locally. `git fetch origin
616
+ # pull/<N>/head` would also work but we don't know the PR number here;
617
+ # use a direct fetch of the commit.
618
+ # Actually for PR commits not on main, we need to ensure the SHA is
619
+ # locally available. The caller (audit_pr) handles this by passing
620
+ # the head SHA after fetching pull/<n>/head.
621
+ tmp_root = Path(tempfile.mkdtemp(prefix="codex-audit-"))
622
+ worktree_path = tmp_root / "wt"
623
+ subprocess.run(
624
+ ["git", "-C", str(local_repo), "worktree", "add", "--detach",
625
+ str(worktree_path), head_sha],
626
+ check=True, capture_output=True, text=True,
627
+ )
628
+ return worktree_path
629
+
630
+
631
+ def _remove_worktree(local_repo: Path, worktree_path: Path) -> None:
632
+ """Remove a worktree, ignoring errors (best-effort cleanup)."""
633
+ try:
634
+ subprocess.run(
635
+ ["git", "-C", str(local_repo), "worktree", "remove", "--force",
636
+ str(worktree_path)],
637
+ check=False, capture_output=True, text=True,
638
+ )
639
+ except Exception: # noqa: BLE001
640
+ pass
641
+ # Also rmdir the tempdir parent
642
+ try:
643
+ worktree_path.parent.rmdir()
644
+ except Exception: # noqa: BLE001
645
+ pass
646
+
647
+
648
+ def _fetch_pr_head(local_repo: Path, pr_number: int) -> None:
649
+ """Ensure the PR's head commit is locally available."""
650
+ subprocess.run(
651
+ ["git", "-C", str(local_repo), "fetch", "origin",
652
+ f"pull/{pr_number}/head"],
653
+ check=True, capture_output=True, text=True,
654
+ )
655
+
656
+
657
+ def _fetch_base_ref(local_repo: Path, base_ref: str) -> None:
658
+ """Refresh the base ref (typically `origin/main`) so the codex review
659
+ diff is against current upstream, not a stale local snapshot. Codex
660
+ round 1 of #234 — P2: without this, a long-lived local checkout
661
+ whose `origin/main` is stale would have Codex diff the PR against
662
+ an older base, including already-merged changes and/or missing
663
+ current-base context.
664
+
665
+ `base_ref` is in the form passed to `codex review --base` — typically
666
+ `origin/main`. We `git fetch origin main` (the remote branch part)
667
+ to update the corresponding tracking ref.
668
+ """
669
+ # Parse `origin/<branch>` or fall back to the full ref string.
670
+ # Codex meta-review round 2 — P2: avoid str.removeprefix() (Python 3.9+
671
+ # only). The repo's local `python3` is 3.7, and we don't want this
672
+ # tool to require .venv just to extract a branch name.
673
+ if "/" in base_ref and base_ref.startswith("origin/"):
674
+ remote_branch = base_ref[len("origin/"):]
675
+ subprocess.run(
676
+ ["git", "-C", str(local_repo), "fetch", "origin", remote_branch],
677
+ check=True, capture_output=True, text=True,
678
+ )
679
+ else:
680
+ # Generic fetch — let git figure out the remote.
681
+ subprocess.run(
682
+ ["git", "-C", str(local_repo), "fetch", "origin", base_ref],
683
+ check=True, capture_output=True, text=True,
684
+ )
685
+
686
+
687
+ def _run_git_text(local_repo: Path, args: List[str], *, timeout: int = 60) -> str:
688
+ result = subprocess.run(
689
+ ["git", "-C", str(local_repo), *args],
690
+ check=True,
691
+ capture_output=True,
692
+ text=True,
693
+ timeout=timeout,
694
+ )
695
+ return result.stdout
696
+
697
+
698
+ def _count_git_stdout_bytes(
699
+ local_repo: Path,
700
+ args: List[str],
701
+ *,
702
+ limit_bytes: int,
703
+ timeout: int = 60,
704
+ ) -> int:
705
+ if limit_bytes <= 0:
706
+ raise ValueError("limit_bytes must be greater than zero")
707
+ command = ["git", "-C", str(local_repo), *args]
708
+ process = subprocess.Popen(
709
+ command,
710
+ stdout=subprocess.PIPE,
711
+ stderr=subprocess.PIPE,
712
+ )
713
+ total = 0
714
+ selector = selectors.DefaultSelector()
715
+ try:
716
+ if process.stdout is None:
717
+ raise RuntimeError("git stdout pipe was not created")
718
+ selector.register(process.stdout, selectors.EVENT_READ)
719
+ deadline = time.monotonic() + timeout
720
+ while True:
721
+ remaining = deadline - time.monotonic()
722
+ if remaining <= 0:
723
+ process.kill()
724
+ process.communicate()
725
+ raise subprocess.TimeoutExpired(command, timeout)
726
+ if not selector.select(timeout=remaining):
727
+ process.kill()
728
+ process.communicate()
729
+ raise subprocess.TimeoutExpired(command, timeout)
730
+ chunk = os.read(
731
+ process.stdout.fileno(),
732
+ max(1, min(65536, limit_bytes + 1 - total)),
733
+ )
734
+ if not chunk:
735
+ break
736
+ total += len(chunk)
737
+ if total > limit_bytes:
738
+ process.kill()
739
+ process.communicate(timeout=timeout)
740
+ return total
741
+ _, stderr = process.communicate(timeout=max(0.1, deadline - time.monotonic()))
742
+ except subprocess.TimeoutExpired:
743
+ process.kill()
744
+ process.communicate()
745
+ raise
746
+ finally:
747
+ selector.close()
748
+ if process.returncode != 0:
749
+ raise subprocess.CalledProcessError(
750
+ process.returncode,
751
+ command,
752
+ output=b"",
753
+ stderr=stderr,
754
+ )
755
+ return total
756
+
757
+
758
+ def _build_review_context_diagnostics(
759
+ local_repo: Path,
760
+ *,
761
+ base_ref: str,
762
+ head_sha: str,
763
+ diagnostic_budget_bytes: int = DEFAULT_DIFF_DIAGNOSTIC_BUDGET_BYTES,
764
+ ) -> ReviewContextDiagnostics:
765
+ diff_range = f"{base_ref}...{head_sha}"
766
+ names = _run_git_text(
767
+ local_repo,
768
+ ["diff", "--name-only", "--find-renames", diff_range],
769
+ )
770
+ diff_bytes = _count_git_stdout_bytes(
771
+ local_repo,
772
+ ["diff", "--find-renames", "--unified=80", diff_range],
773
+ limit_bytes=diagnostic_budget_bytes,
774
+ timeout=120,
775
+ )
776
+ changed_file_count = len([line for line in names.splitlines() if line.strip()])
777
+ return ReviewContextDiagnostics(
778
+ base_ref=base_ref,
779
+ head_sha=head_sha,
780
+ changed_file_count=changed_file_count,
781
+ diff_bytes=diff_bytes,
782
+ diagnostic_budget_bytes=diagnostic_budget_bytes,
783
+ )
784
+
785
+
786
+ # ----- Codex CLI invocation -----
787
+
788
+
789
+ def _discover_venv(local_repo: Path) -> Optional[Path]:
790
+ """Look for a venv at `<local_repo>/.venv/` and return its
791
+ ABSOLUTE path if it contains a working `bin/python`. Returns None
792
+ otherwise.
793
+
794
+ Used by `audit_pr()` when `config.venv_path` is unset and
795
+ `config.disable_venv` is False, so most repos get the right
796
+ Python without any explicit configuration. Repos without a venv
797
+ (e.g. reference-app, which uses npm/vitest for tests) get a no-op.
798
+
799
+ The returned path is `resolve()`d so the subprocess (which runs
800
+ with `cwd=worktree_path`) can still find `bin/python` when the
801
+ caller's `local_repo` was a relative path. Round-4 P2 on PR #264.
802
+ """
803
+ candidate = (local_repo / ".venv").resolve()
804
+ if (candidate / "bin" / "python").exists():
805
+ return candidate
806
+ return None
807
+
808
+
809
+ def _build_subprocess_env(venv_path: Optional[Path]) -> Dict[str, str]:
810
+ """Return an env dict for the `codex review` subprocess.
811
+
812
+ When `venv_path` is a real path with `bin/python`, prepend
813
+ `<venv>/bin` to PATH and export VIRTUAL_ENV so any Python
814
+ invocations from `codex review` (pytest, diagnostic tools, etc.)
815
+ pick up the right interpreter. Also clear PYTHONHOME if set —
816
+ leaving it pointing at a different Python install breaks the
817
+ venv's site-packages resolution.
818
+
819
+ `venv_path` is `resolve()`d to an absolute path before injection.
820
+ The subprocess runs with `cwd=worktree_path`, so any RELATIVE
821
+ PATH entry would resolve from the temp worktree, not the
822
+ caller's cwd. Round-4 P2 on PR #264: previously a relative
823
+ `--venv-path .venv` got injected literally and the subprocess
824
+ then searched `<worktree>/.venv/bin` (which doesn't exist) and
825
+ silently fell back to the ambient interpreter.
826
+
827
+ No-op (returns os.environ unchanged) when `venv_path` is None
828
+ or its resolved form doesn't point at a working `bin/python`.
829
+ Codex P3 fix for PR #262 (issue tracked as Task #97): without
830
+ this, audits inherit the system PATH which on a typical macOS
831
+ dev box resolves to anaconda Python 3.7.6 with numpy 1.18 /
832
+ Pillow 9.5 — producing per-pixel drift relative to the
833
+ canonical .venv (3.12.13 / 2.3.5 / 12.2.0).
834
+ """
835
+ env = dict(os.environ)
836
+ # The `codex review` subprocess executes scripts/tooling from the
837
+ # PR worktree under audit — that is untrusted code by definition.
838
+ # GITHUB_TOKEN / GH_TOKEN in this script's parent env are used for
839
+ # GitHub API calls from THIS process (PR fetch, comment post). They
840
+ # must not leak into the codex subprocess where the PR code runs.
841
+ # Caught by Codex P1 audit on reference-app#194 / reference-service#354:
842
+ # `gh auth token` fallback in the wrapper made every audit caller a
843
+ # potential exposure, but the same issue applied to manually-exported
844
+ # tokens before that. This sanitization closes both paths.
845
+ for sensitive in ("GITHUB_TOKEN", "GH_TOKEN"):
846
+ env.pop(sensitive, None)
847
+ if venv_path is None:
848
+ return env
849
+ # Path("") and Path(".") both stringify as "." and resolve to cwd.
850
+ # If cwd happens to have ./bin/python (e.g. the audit machine
851
+ # was started from a directory shaped like a venv) the resolution
852
+ # below would naively inject cwd as VIRTUAL_ENV — exactly the
853
+ # silent-cwd-injection bug round-4 was meant to fix. Round-4
854
+ # round-2 P2 on PR #264: refuse to inject for empty-ish paths
855
+ # regardless of cwd contents. CLI callers never reach here with
856
+ # Path("") because main() routes `--venv-path ""` through
857
+ # `disable_venv=True`, but library callers can.
858
+ if str(venv_path) in ("", "."):
859
+ return env
860
+ abs_venv = venv_path.resolve()
861
+ if not (abs_venv / "bin" / "python").exists():
862
+ warnings.warn(
863
+ f"venv path {abs_venv} has no bin/python; "
864
+ "leaving subprocess environment unchanged",
865
+ RuntimeWarning,
866
+ stacklevel=2,
867
+ )
868
+ return env
869
+ venv_bin = str(abs_venv / "bin")
870
+ env["VIRTUAL_ENV"] = str(abs_venv)
871
+ existing_path = env.get("PATH", "")
872
+ env["PATH"] = (
873
+ f"{venv_bin}{os.pathsep}{existing_path}"
874
+ if existing_path else venv_bin
875
+ )
876
+ env.pop("PYTHONHOME", None)
877
+ return env
878
+
879
+
880
+ def _trusted_repo_root() -> Path:
881
+ return Path(__file__).resolve().parents[1]
882
+
883
+
884
+ def _make_secure_temp_dir(prefix: str) -> Path:
885
+ path = Path(tempfile.mkdtemp(prefix=prefix))
886
+ os.chmod(path, 0o700)
887
+ return path
888
+
889
+
890
+ def _read_last_message_file(path: Path, fallback: str = "") -> str:
891
+ if not path.exists():
892
+ return fallback
893
+ try:
894
+ os.chmod(path, 0o600)
895
+ except OSError:
896
+ pass
897
+ try:
898
+ return path.read_text(encoding="utf-8")
899
+ except OSError:
900
+ return fallback
901
+
902
+
903
+ def _resolve_executable_path(path_text: str, *, label: str, env_name: str) -> str:
904
+ """Resolve either a filesystem path or a command name from PATH."""
905
+ expanded = Path(path_text).expanduser()
906
+ if os.sep in path_text or (os.altsep and os.altsep in path_text):
907
+ if expanded.exists():
908
+ return str(expanded.resolve())
909
+ raise FileNotFoundError(
910
+ f"{label} not found at {path_text}. Install it or override {env_name}."
911
+ )
912
+
913
+ resolved = shutil.which(path_text)
914
+ if resolved:
915
+ return resolved
916
+ raise FileNotFoundError(
917
+ f"{label} command not found on PATH: {path_text}. Install it or override {env_name}."
918
+ )
919
+
920
+
921
+ def preflight_codex_cli(config: AuditConfig) -> str:
922
+ """Check the Codex CLI capabilities this wrapper relies on.
923
+
924
+ We track and log the installed CLI version rather than hard-pinning,
925
+ but fail fast if the relevant `exec`/`exec review` flags disappear.
926
+ """
927
+ config.codex_cli_path = _resolve_executable_path(
928
+ config.codex_cli_path,
929
+ label="Codex CLI",
930
+ env_name="CODEX_CLI_PATH",
931
+ )
932
+
933
+ env = _build_subprocess_env(None)
934
+ version = subprocess.run(
935
+ [config.codex_cli_path, "--version"],
936
+ capture_output=True,
937
+ text=True,
938
+ timeout=30,
939
+ env=env,
940
+ )
941
+ if version.returncode != 0:
942
+ raise subprocess.CalledProcessError(
943
+ version.returncode,
944
+ [config.codex_cli_path, "--version"],
945
+ output=version.stdout,
946
+ stderr=version.stderr,
947
+ )
948
+
949
+ # Probe help without `--ignore-user-config` even when audits will use it.
950
+ # Otherwise an old CLI that rejects the flag would fail here with a raw
951
+ # CalledProcessError before reaching the explicit missing-capability check.
952
+ exec_help = subprocess.run(
953
+ [config.codex_cli_path, "exec", "--help"],
954
+ capture_output=True,
955
+ text=True,
956
+ timeout=30,
957
+ env=env,
958
+ )
959
+ review_help = subprocess.run(
960
+ [config.codex_cli_path, "exec", "review", "--help"],
961
+ capture_output=True,
962
+ text=True,
963
+ timeout=30,
964
+ env=env,
965
+ )
966
+ for result, command_name in (
967
+ (exec_help, "codex exec --help"),
968
+ (review_help, "codex exec review --help"),
969
+ ):
970
+ if result.returncode != 0:
971
+ raise subprocess.CalledProcessError(
972
+ result.returncode,
973
+ command_name.split(),
974
+ output=result.stdout,
975
+ stderr=result.stderr,
976
+ )
977
+
978
+ exec_text = exec_help.stdout + exec_help.stderr
979
+ review_text = review_help.stdout + review_help.stderr
980
+ missing: List[str] = []
981
+ if config.ignore_user_config and "--ignore-user-config" not in exec_text:
982
+ missing.append("codex exec --ignore-user-config")
983
+ for flag in ("--output-schema", "--output-last-message"):
984
+ if flag not in exec_text:
985
+ missing.append(f"codex exec {flag}")
986
+ if "--skip-git-repo-check" not in exec_text:
987
+ missing.append("codex exec --skip-git-repo-check")
988
+ for flag in ("--base", "--output-last-message"):
989
+ if flag not in review_text:
990
+ missing.append(f"codex exec review {flag}")
991
+ if missing:
992
+ raise RuntimeError(
993
+ "Codex CLI is missing required structured-audit capability: "
994
+ + ", ".join(missing)
995
+ )
996
+
997
+ return (version.stdout or version.stderr).strip()
998
+
999
+
1000
+ def _codex_exec_command(
1001
+ config: AuditConfig,
1002
+ *args: str,
1003
+ skip_git_repo_check: bool = False,
1004
+ ) -> List[str]:
1005
+ command = [config.codex_cli_path, "exec"]
1006
+ if config.ignore_user_config:
1007
+ command.append("--ignore-user-config")
1008
+ if skip_git_repo_check:
1009
+ command.append("--skip-git-repo-check")
1010
+ command.extend(args)
1011
+ return command
1012
+
1013
+
1014
+ def _structured_verdict_prompt(review_text: str) -> str:
1015
+ return (
1016
+ "Convert the Codex review prose below into the exact structured "
1017
+ "audit verdict schema. This is a transport step, not a second "
1018
+ "review. Use only findings that are explicitly present in the "
1019
+ "review prose; do not invent new findings. Preserve P0/P1/P2/P3 "
1020
+ "severity labels. Verdict policy: if any P0, P1, or P2 finding "
1021
+ "is present, verdict must be \"blocked\". If only P3 findings "
1022
+ "or no findings are present, verdict must be \"pass\". Use schema "
1023
+ f"{CODEX_AUDIT_SCHEMA_ID!r}. For line ranges, use the first line. "
1024
+ "If the review says no blocking issues were found, return an "
1025
+ "empty findings array.\n\n"
1026
+ "Review prose:\n"
1027
+ "----- BEGIN REVIEW -----\n"
1028
+ f"{review_text.rstrip()}\n"
1029
+ "----- END REVIEW -----\n"
1030
+ )
1031
+
1032
+
1033
+ def run_codex_review(
1034
+ config: AuditConfig,
1035
+ worktree_path: Path,
1036
+ ) -> Tuple[str, str]:
1037
+ """Run the built-in Codex review from the PR worktree.
1038
+
1039
+ The final review prose is captured via `--output-last-message`, which
1040
+ avoids scraping the streaming CLI log. Returns `(review_prose,
1041
+ diagnostics)`, where diagnostics is stderr from the review process.
1042
+
1043
+ Raises `subprocess.CalledProcessError` on non-zero exit; a failed
1044
+ built-in review is a hard audit failure rather than a structured
1045
+ verdict failure.
1046
+ """
1047
+ config.codex_cli_path = _resolve_executable_path(
1048
+ config.codex_cli_path,
1049
+ label="Codex CLI",
1050
+ env_name="CODEX_CLI_PATH",
1051
+ )
1052
+ env = _build_subprocess_env(config.venv_path)
1053
+ tmp_dir = _make_secure_temp_dir("codex-audit-review-")
1054
+ review_path = tmp_dir / "review.txt"
1055
+ command = _codex_exec_command(
1056
+ config,
1057
+ "--sandbox",
1058
+ "read-only",
1059
+ "review",
1060
+ "--base",
1061
+ config.base_ref,
1062
+ "--output-last-message",
1063
+ str(review_path),
1064
+ )
1065
+ try:
1066
+ result = run_subprocess_with_progress(
1067
+ command,
1068
+ progress=config.progress or AuditProgress("codex-audit"),
1069
+ phase="codex-review",
1070
+ run=subprocess.run,
1071
+ cwd=str(worktree_path),
1072
+ capture_output=True,
1073
+ text=True,
1074
+ timeout=config.timeout,
1075
+ env=env,
1076
+ )
1077
+ if result.returncode != 0:
1078
+ raise subprocess.CalledProcessError(
1079
+ result.returncode,
1080
+ command,
1081
+ output=result.stdout,
1082
+ stderr=result.stderr,
1083
+ )
1084
+ return _read_last_message_file(review_path, result.stdout), result.stderr
1085
+ finally:
1086
+ shutil.rmtree(str(tmp_dir), ignore_errors=True)
1087
+
1088
+
1089
+ def run_codex_verdict_structuring(
1090
+ config: AuditConfig,
1091
+ review_text: str,
1092
+ ) -> Tuple[CodexVerdict, str, str]:
1093
+ """Convert review prose to a schema-validated verdict JSON artifact."""
1094
+ config.codex_cli_path = _resolve_executable_path(
1095
+ config.codex_cli_path,
1096
+ label="Codex CLI",
1097
+ env_name="CODEX_CLI_PATH",
1098
+ )
1099
+ if not CODEX_AUDIT_VERDICT_SCHEMA_PATH.exists():
1100
+ raise FileNotFoundError(
1101
+ f"Codex audit verdict schema not found at "
1102
+ f"{CODEX_AUDIT_VERDICT_SCHEMA_PATH}"
1103
+ )
1104
+
1105
+ tmp_dir = _make_secure_temp_dir("codex-audit-verdict-")
1106
+ verdict_path = tmp_dir / "verdict.json"
1107
+ command = _codex_exec_command(
1108
+ config,
1109
+ "--sandbox",
1110
+ "read-only",
1111
+ "--output-schema",
1112
+ str(CODEX_AUDIT_VERDICT_SCHEMA_PATH),
1113
+ "--output-last-message",
1114
+ str(verdict_path),
1115
+ "-",
1116
+ skip_git_repo_check=True,
1117
+ )
1118
+ try:
1119
+ result = run_subprocess_with_progress(
1120
+ command,
1121
+ progress=config.progress or AuditProgress("codex-audit"),
1122
+ phase="codex-structure",
1123
+ run=subprocess.run,
1124
+ cwd=str(_trusted_repo_root()),
1125
+ input=_structured_verdict_prompt(review_text),
1126
+ capture_output=True,
1127
+ text=True,
1128
+ timeout=config.timeout,
1129
+ env=_build_subprocess_env(None),
1130
+ )
1131
+ if result.returncode != 0:
1132
+ return (
1133
+ _unknown_structured_verdict(
1134
+ "Codex structured-output pass exited "
1135
+ f"{result.returncode}"
1136
+ ),
1137
+ result.stdout,
1138
+ result.stderr,
1139
+ )
1140
+ return load_structured_codex_verdict(verdict_path), result.stdout, result.stderr
1141
+ finally:
1142
+ shutil.rmtree(str(tmp_dir), ignore_errors=True)
1143
+
1144
+
1145
+ # ----- Comment formatting -----
1146
+
1147
+
1148
+ def format_comment(
1149
+ parsed: CodexVerdict,
1150
+ head_sha: str,
1151
+ is_stale: bool = False,
1152
+ stale_end_sha: Optional[str] = None,
1153
+ is_unknown: bool = False,
1154
+ ) -> str:
1155
+ """Build the GitHub comment body with header, prose, and trailer."""
1156
+ header = "## Codex audit (calibration phase — informational only)\n\n"
1157
+ header += f"Head SHA: `{head_sha}`\n"
1158
+ if is_stale:
1159
+ body = (
1160
+ header
1161
+ + f"\nHead SHA changed during review (`{head_sha[:8]}` → "
1162
+ + f"`{(stale_end_sha or '?')[:8]}`). Skipping this verdict and "
1163
+ + "requeuing for re-review of the new head.\n\n"
1164
+ + STALE_TRAILER
1165
+ + "\n"
1166
+ )
1167
+ return limit_comment_body(body, STALE_TRAILER, provider_name="Codex")
1168
+ if is_unknown:
1169
+ body = (
1170
+ header
1171
+ + "\nCould not validate a Codex structured verdict artifact. "
1172
+ + "The CLI may have produced no review, "
1173
+ + "the structured-output pass may have failed, or the verdict "
1174
+ + "format may have drifted. "
1175
+ + "Requeuing for re-review.\n\n"
1176
+ + STALE_TRAILER
1177
+ + "\n"
1178
+ )
1179
+ return limit_comment_body(body, STALE_TRAILER, provider_name="Codex")
1180
+
1181
+ header += (
1182
+ f"Findings: P0={parsed.p0_count}, P1={parsed.p1_count}, "
1183
+ f"P2={parsed.p2_count}, P3={parsed.p3_count} "
1184
+ f"(blocker policy: any P0/P1/P2 → BLOCKED)\n\n"
1185
+ )
1186
+
1187
+ verdict_line = (
1188
+ "Codex Audit: BLOCKED" if parsed.verdict == "BLOCKED"
1189
+ else "Codex Audit: PASS"
1190
+ )
1191
+ trailer = BLOCKED_TRAILER if parsed.verdict == "BLOCKED" else DONE_TRAILER
1192
+
1193
+ body_lines = [
1194
+ header.rstrip(),
1195
+ "",
1196
+ verdict_line,
1197
+ "",
1198
+ parsed.prose.rstrip(),
1199
+ "",
1200
+ trailer,
1201
+ ]
1202
+ return limit_comment_body(
1203
+ "\n".join(body_lines) + "\n",
1204
+ trailer,
1205
+ provider_name="Codex",
1206
+ )
1207
+
1208
+
1209
+ # ----- Orchestration -----
1210
+
1211
+
1212
+ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult:
1213
+ """End-to-end audit of one PR. Creates a temporary worktree at the PR
1214
+ head, runs Codex review, structures its verdict, formats + posts a
1215
+ comment."""
1216
+ local_repo = config.repo_paths.get(repo)
1217
+ if local_repo is None:
1218
+ raise ValueError(
1219
+ f"no local repo path configured for {repo}. Set "
1220
+ f"CODEX_AUDIT_REPO_PATHS=owner/repo:/path[,owner/repo:/path,...]"
1221
+ )
1222
+ if not local_repo.exists():
1223
+ raise FileNotFoundError(
1224
+ f"configured local repo path does not exist: {local_repo}"
1225
+ )
1226
+ if config.progress is None:
1227
+ config = replace(config, progress=AuditProgress("codex-audit"))
1228
+
1229
+ pr_meta = fetch_pull_request(repo, pr_number, token=config.github_token)
1230
+ head_sha_start = pr_meta["head"]["sha"]
1231
+
1232
+ config.progress.emit(
1233
+ "audit",
1234
+ status="start",
1235
+ detail=f"{repo}#{pr_number} head={head_sha_start[:8]}",
1236
+ )
1237
+ print(f"audit {repo}#{pr_number} head={head_sha_start[:8]} "
1238
+ f"(local: {local_repo})", file=sys.stderr, flush=True)
1239
+ codex_version = preflight_codex_cli(config)
1240
+ print(
1241
+ f" codex CLI: {codex_version or '(version unavailable)'}",
1242
+ file=sys.stderr,
1243
+ flush=True,
1244
+ )
1245
+
1246
+ # Auto-discover a venv under the local repo and use it for the
1247
+ # `codex review` subprocess unless the caller explicitly set one
1248
+ # or explicitly disabled discovery via `--venv-path ""`. Without
1249
+ # this, audits inherit the system PATH which on a typical macOS
1250
+ # dev box resolves to anaconda Python 3.7.6 instead of the
1251
+ # canonical .venv (Task #97 / Codex P3 on PR #262). Mutating the
1252
+ # config here is local to `audit_pr` (the caller's config dict
1253
+ # isn't shared across audits via mutable state).
1254
+ #
1255
+ # Round-4 P2 on PR #264: the previous version used `Path("")` as
1256
+ # the disable sentinel and checked `str(venv_path) == ""` in
1257
+ # `_build_subprocess_env`. But `str(Path(""))` returns `"."`,
1258
+ # not `""`, so the sentinel never tripped — and if cwd happened
1259
+ # to have `./bin/python` the "disable" path silently injected
1260
+ # that bogus Python. Replaced with an explicit `disable_venv`
1261
+ # bool field; sentinel is gone.
1262
+ if config.venv_path is None and not config.disable_venv:
1263
+ discovered_venv = _discover_venv(local_repo)
1264
+ if discovered_venv is not None:
1265
+ print(
1266
+ f" using venv {discovered_venv} for subprocess Python "
1267
+ "(set --venv-path to override or empty string to disable)",
1268
+ file=sys.stderr, flush=True,
1269
+ )
1270
+ config = replace(config, venv_path=discovered_venv)
1271
+
1272
+ # Ensure both the PR head AND the base ref are locally fetched
1273
+ # before running the review. Stale base = wrong diff = wrong review.
1274
+ _fetch_pr_head(local_repo, pr_number)
1275
+ _fetch_base_ref(local_repo, config.base_ref)
1276
+ review_context_summary = "review context diagnostics unavailable"
1277
+ try:
1278
+ review_context = _build_review_context_diagnostics(
1279
+ local_repo,
1280
+ base_ref=config.base_ref,
1281
+ head_sha=head_sha_start,
1282
+ )
1283
+ review_context_summary = review_context.summary()
1284
+ print(
1285
+ f" review context: {review_context_summary}",
1286
+ file=sys.stderr,
1287
+ flush=True,
1288
+ )
1289
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1290
+ print(
1291
+ f" warning: failed to build review context diagnostics: {exc}",
1292
+ file=sys.stderr,
1293
+ flush=True,
1294
+ )
1295
+
1296
+ # Codex round 6 of #234 — P3: handle the force-push race where
1297
+ # `head_sha_start` was read before the user force-pushed and our
1298
+ # `pull/<N>/head` fetch now points at the new SHA. If the old SHA
1299
+ # isn't locally available, treat it as a stale-head condition
1300
+ # rather than crashing on the worktree create.
1301
+ try:
1302
+ worktree_path = _create_temp_worktree(local_repo, head_sha_start)
1303
+ except subprocess.CalledProcessError:
1304
+ # Worktree create failed — likely because the SHA isn't in the
1305
+ # local repo anymore (force-push race). Refetch and compare;
1306
+ # if the head has moved, emit the stale-requeue comment.
1307
+ pr_meta_after = fetch_pull_request(repo, pr_number, token=config.github_token)
1308
+ head_sha_after = pr_meta_after["head"]["sha"]
1309
+ if head_sha_after != head_sha_start:
1310
+ print(f" force-push race: head moved from {head_sha_start[:8]} "
1311
+ f"to {head_sha_after[:8]} during fetch; emitting STALE",
1312
+ file=sys.stderr, flush=True)
1313
+ comment_body = format_comment(
1314
+ CodexVerdict(verdict="UNKNOWN",
1315
+ prose="(force-push detected before worktree created)"),
1316
+ head_sha_start, is_stale=True, stale_end_sha=head_sha_after,
1317
+ )
1318
+ result = AuditResult(
1319
+ repo=repo, pr_number=pr_number,
1320
+ head_sha_start=head_sha_start, head_sha_end=head_sha_after,
1321
+ verdict="STALE", trailer=STALE_TRAILER,
1322
+ comment_body=comment_body, codex_stdout="", codex_stderr="",
1323
+ )
1324
+ if not config.dry_run:
1325
+ artifact_path = write_audit_verdict_artifact(
1326
+ lane_id="codex-audit",
1327
+ repo=repo,
1328
+ pr_number=pr_number,
1329
+ head_sha_start=head_sha_start,
1330
+ head_sha_end=head_sha_after,
1331
+ verdict=result.verdict,
1332
+ trailer=result.trailer,
1333
+ comment_body=comment_body,
1334
+ )
1335
+ result.verdict_artifact_path = artifact_path
1336
+ if artifact_path is not None:
1337
+ print(
1338
+ f" saved verdict artifact before posting: {artifact_path}",
1339
+ file=sys.stderr,
1340
+ flush=True,
1341
+ )
1342
+ # Loud-failure contract: post_pr_comment() raises on any
1343
+ # HTTP error or network failure. DO NOT catch here. The
1344
+ # wrapper (tools/run_codex_audit_pr.sh) relies on this
1345
+ # script exiting non-zero when posting fails so its
1346
+ # finish_lock trap can log status="failed" instead of
1347
+ # "completed". A silent catch would re-create the
1348
+ # reference-app#184 silent-failure mode where three audit
1349
+ # runs reported exitCode=0 but never posted a GitHub
1350
+ # comment. See run_codex_audit_pr.sh's finish_lock
1351
+ # comment for the wrapper-side history and the memory
1352
+ # file feedback_silent_success_silent_failure.md for the
1353
+ # general lesson.
1354
+ posted = post_pr_comment(repo, pr_number, comment_body,
1355
+ token=config.github_token)
1356
+ result.posted_comment_url = posted.get("html_url")
1357
+ config.progress.emit(
1358
+ "audit",
1359
+ status="finish",
1360
+ detail=f"{repo}#{pr_number} verdict=STALE",
1361
+ )
1362
+ return result
1363
+ # Same head; the worktree failure is for some other reason.
1364
+ # Re-raise so the outer error path runs.
1365
+ raise
1366
+
1367
+ try:
1368
+ t0 = time.time()
1369
+ review_text, review_stderr = run_codex_review(config, worktree_path)
1370
+ dt = time.time() - t0
1371
+ print(f" codex review completed in {dt:.0f}s", file=sys.stderr, flush=True)
1372
+ finally:
1373
+ _remove_worktree(local_repo, worktree_path)
1374
+
1375
+ # Convert Codex's prose review into a structured, schema-shaped
1376
+ # verdict artifact. This second call runs outside the PR worktree and
1377
+ # receives only the captured review prose.
1378
+ t0 = time.time()
1379
+ parsed, structure_stdout, structure_stderr = run_codex_verdict_structuring(
1380
+ config,
1381
+ review_text,
1382
+ )
1383
+ dt = time.time() - t0
1384
+ print(
1385
+ f" codex verdict structuring completed in {dt:.0f}s",
1386
+ file=sys.stderr,
1387
+ flush=True,
1388
+ )
1389
+ if parsed.mismatch_note:
1390
+ print(
1391
+ f" structured-verdict mismatch: {parsed.mismatch_note}",
1392
+ file=sys.stderr,
1393
+ flush=True,
1394
+ )
1395
+ codex_stdout = review_text
1396
+ codex_stderr = (
1397
+ "===== CODEX REVIEW CONTEXT =====\n"
1398
+ + review_context_summary
1399
+ + "\n\n"
1400
+ "===== CODEX REVIEW STDERR =====\n"
1401
+ + (review_stderr if review_stderr else "<empty>\n")
1402
+ + "\n===== STRUCTURE STDOUT =====\n"
1403
+ + (structure_stdout if structure_stdout else "<empty>\n")
1404
+ + "\n===== STRUCTURE STDERR =====\n"
1405
+ + (structure_stderr if structure_stderr else "<empty>\n")
1406
+ )
1407
+
1408
+ # Stale-head check: refetch PR head and compare. If it changed mid-review,
1409
+ # the verdict applies to a no-longer-current SHA — requeue.
1410
+ pr_meta_after = fetch_pull_request(repo, pr_number, token=config.github_token)
1411
+ head_sha_end = pr_meta_after["head"]["sha"]
1412
+ is_stale = head_sha_start != head_sha_end
1413
+
1414
+ if is_stale:
1415
+ comment_body = format_comment(parsed, head_sha_start, is_stale=True,
1416
+ stale_end_sha=head_sha_end)
1417
+ result_verdict = "STALE"
1418
+ trailer = STALE_TRAILER
1419
+ elif parsed.verdict == "UNKNOWN":
1420
+ # When the structured verdict is missing, malformed, or
1421
+ # unactionable, requeue rather than auto-PASS. The trailer is
1422
+ # the same as STALE because the downstream outcome (re-run,
1423
+ # don't trust this comment) is identical.
1424
+ #
1425
+ # Persist the review prose and structuring diagnostics. The
1426
+ # post-comment trailer auto-requeues, but the next attempt
1427
+ # overwrites the in-memory capture and the worktree is gone, so
1428
+ # without this dump there's no way to diagnose "real structured
1429
+ # output drift or one-shot Codex CLI flake?" after the fact.
1430
+ # Best-effort: a logging failure must not interrupt the audit.
1431
+ dump_path = dump_cli_failure(
1432
+ repo=repo,
1433
+ pr_number=pr_number,
1434
+ head_sha=head_sha_start,
1435
+ stdout=codex_stdout,
1436
+ stderr=codex_stderr,
1437
+ reason=parsed.prose,
1438
+ )
1439
+ if dump_path is not None:
1440
+ print(
1441
+ f" captured CLI output for diagnosis: {dump_path}",
1442
+ file=sys.stderr,
1443
+ flush=True,
1444
+ )
1445
+ comment_body = format_comment(parsed, head_sha_start, is_unknown=True)
1446
+ result_verdict = "UNKNOWN"
1447
+ trailer = STALE_TRAILER
1448
+ else:
1449
+ comment_body = format_comment(parsed, head_sha_start)
1450
+ result_verdict = parsed.verdict
1451
+ trailer = BLOCKED_TRAILER if parsed.verdict == "BLOCKED" else DONE_TRAILER
1452
+
1453
+ result = AuditResult(
1454
+ repo=repo,
1455
+ pr_number=pr_number,
1456
+ head_sha_start=head_sha_start,
1457
+ head_sha_end=head_sha_end,
1458
+ verdict=result_verdict,
1459
+ trailer=trailer,
1460
+ comment_body=comment_body,
1461
+ codex_stdout=codex_stdout,
1462
+ codex_stderr=codex_stderr,
1463
+ parsed=parsed,
1464
+ )
1465
+
1466
+ if not config.dry_run:
1467
+ artifact_path = write_audit_verdict_artifact(
1468
+ lane_id="codex-audit",
1469
+ repo=repo,
1470
+ pr_number=pr_number,
1471
+ head_sha_start=head_sha_start,
1472
+ head_sha_end=head_sha_end,
1473
+ verdict=result_verdict,
1474
+ trailer=trailer,
1475
+ comment_body=comment_body,
1476
+ )
1477
+ result.verdict_artifact_path = artifact_path
1478
+ if artifact_path is not None:
1479
+ print(
1480
+ f" saved verdict artifact before posting: {artifact_path}",
1481
+ file=sys.stderr,
1482
+ flush=True,
1483
+ )
1484
+ # Loud-failure contract: see the matching comment ~70 lines above
1485
+ # at the STALE-handler call site. tl;dr: post_pr_comment() raising
1486
+ # is the only signal the wrapper's finish_lock has that posting
1487
+ # failed. Do not catch the exception here.
1488
+ posted = post_pr_comment(repo, pr_number, comment_body,
1489
+ token=config.github_token)
1490
+ result.posted_comment_url = posted.get("html_url")
1491
+ print(f"posted {repo}#{pr_number} verdict={result_verdict} "
1492
+ f"url={result.posted_comment_url}", file=sys.stderr)
1493
+
1494
+ config.progress.emit(
1495
+ "audit",
1496
+ status="finish",
1497
+ detail=f"{repo}#{pr_number} verdict={result_verdict}",
1498
+ )
1499
+ return result
1500
+
1501
+
1502
+ # ----- CLI entry point -----
1503
+
1504
+
1505
+ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
1506
+ ap = argparse.ArgumentParser(
1507
+ description="Codex audit CLI — review a single pull request.",
1508
+ )
1509
+ ap.add_argument("--repo", help="owner/repo, e.g. owner/repo")
1510
+ ap.add_argument("--pr", type=int, help="PR number")
1511
+ ap.add_argument(
1512
+ "--repost-verdict-artifact",
1513
+ type=Path,
1514
+ default=None,
1515
+ help=(
1516
+ "Post a previously saved verdict artifact comment body and exit "
1517
+ "without rerunning Codex."
1518
+ ),
1519
+ )
1520
+ ap.add_argument("--codex-cli-path",
1521
+ default=os.environ.get("CODEX_CLI_PATH", DEFAULT_CODEX_CLI_PATH))
1522
+ ap.add_argument("--base-ref", default=os.environ.get("CODEX_BASE_REF", DEFAULT_BASE_REF),
1523
+ help="base ref to diff against (default: origin/main)")
1524
+ ap.add_argument("--repo-paths",
1525
+ default=os.environ.get("CODEX_AUDIT_REPO_PATHS", ""),
1526
+ help="comma-separated owner/repo:/path entries. Required.")
1527
+ ap.add_argument("--timeout", type=int,
1528
+ default=int(os.environ.get("CODEX_AUDIT_TIMEOUT", DEFAULT_CODEX_TIMEOUT)))
1529
+ ap.add_argument(
1530
+ "--dry-run",
1531
+ action="store_true",
1532
+ default=_env_flag("CODEX_AUDIT_DRY_RUN"),
1533
+ help="Print the audit comment to stdout instead of posting it.",
1534
+ )
1535
+ ap.add_argument(
1536
+ "--use-user-config",
1537
+ action="store_true",
1538
+ default=_env_flag("CODEX_AUDIT_USE_USER_CONFIG"),
1539
+ help=(
1540
+ "Allow codex exec subprocesses to load the operator's user "
1541
+ "Codex config. Default is to pass --ignore-user-config so "
1542
+ "structured audit runtime is independent of personal "
1543
+ "model/reasoning/plugin settings."
1544
+ ),
1545
+ )
1546
+ ap.add_argument(
1547
+ "--venv-path",
1548
+ default=os.environ.get("CODEX_AUDIT_VENV_PATH"),
1549
+ help=(
1550
+ "Python venv whose bin/ is prepended to PATH (and exported "
1551
+ "as VIRTUAL_ENV) when running `codex review`. Default: "
1552
+ "auto-discover `<local_repo>/.venv/`. Pass empty string to "
1553
+ "disable. Without this, audits inherit the system PATH "
1554
+ "which typically resolves to a stale system Python instead "
1555
+ "of the canonical .venv (see corpus_manifest.json for the "
1556
+ "pinned environment)."
1557
+ ),
1558
+ )
1559
+ ap.add_argument(
1560
+ "--read-token-from-stdin",
1561
+ action="store_true",
1562
+ help=(
1563
+ "Read GITHUB_TOKEN from the first line of stdin instead of "
1564
+ "os.environ. The wrapper passes this so the token never "
1565
+ "appears in this Python process's initial environment, "
1566
+ "which on Linux remains visible via /proc/<pid>/environ "
1567
+ "to PR-controlled subprocesses even after "
1568
+ "os.environ.pop(). When set, GITHUB_TOKEN / GH_TOKEN env "
1569
+ "vars are also cleared as defense-in-depth."
1570
+ ),
1571
+ )
1572
+ return ap.parse_args(argv)
1573
+
1574
+
1575
+ def _env_flag(name: str) -> bool:
1576
+ return os.environ.get(name, "").strip().lower() in {
1577
+ "1",
1578
+ "true",
1579
+ "yes",
1580
+ "on",
1581
+ }
1582
+
1583
+
1584
+ def _extract_and_clear_github_token() -> Optional[str]:
1585
+ """Legacy: read GITHUB_TOKEN from os.environ, then remove it (and
1586
+ GH_TOKEN) from os.environ.
1587
+
1588
+ `os.environ.pop` clears the var from Python's view, but on Linux
1589
+ the kernel-exposed /proc/<pid>/environ shows the INITIAL exec
1590
+ environment block, not Python's live runtime env. So if the
1591
+ parent process exec'd Python with GITHUB_TOKEN in env, that
1592
+ initial-env exposure persists for the lifetime of the Python
1593
+ process regardless of pop(). The wrapper avoids this by piping
1594
+ the token via stdin and passing --read-token-from-stdin instead;
1595
+ this function is kept for callers that invoke `codex_audit_pr.py`
1596
+ directly without the wrapper, where the /proc exposure is
1597
+ accepted as part of the caller's threat model.
1598
+
1599
+ Returns the token value (kept only in a local variable, NOT
1600
+ re-exported via os.environ), or None if no token was present.
1601
+ """
1602
+ return pop_github_token_env()
1603
+
1604
+
1605
+ def _resolve_github_token(read_from_stdin: bool) -> Optional[str]:
1606
+ """Return the GITHUB_TOKEN, either piped via stdin (wrapper path —
1607
+ avoids /proc/<pid>/environ exposure of the initial Python env on
1608
+ Linux) or read from os.environ (legacy path for direct callers).
1609
+
1610
+ The wrapper passes --read-token-from-stdin and pipes the token as
1611
+ the first line of stdin. When that flag is set, this function
1612
+ reads stdin and ALSO clears GITHUB_TOKEN / GH_TOKEN from
1613
+ os.environ as defense-in-depth (in case the caller accidentally
1614
+ also exported the env vars — the wrapper unsets them before exec
1615
+ but a misuse path could leave them).
1616
+
1617
+ Returns None if no token can be resolved by either mechanism.
1618
+ """
1619
+ return resolve_github_token_from_stdin_or_env(read_from_stdin)
1620
+
1621
+
1622
+ def main(argv: Optional[List[str]] = None) -> int:
1623
+ args = _parse_args(argv)
1624
+ token = _resolve_github_token(args.read_token_from_stdin)
1625
+ if not token:
1626
+ if args.read_token_from_stdin:
1627
+ print(
1628
+ "error: --read-token-from-stdin was passed but stdin "
1629
+ "did not contain a token",
1630
+ file=sys.stderr,
1631
+ )
1632
+ else:
1633
+ print("error: GITHUB_TOKEN env var is required (or pass "
1634
+ "--read-token-from-stdin and pipe the token in)",
1635
+ file=sys.stderr)
1636
+ return 1
1637
+ if args.repost_verdict_artifact is not None:
1638
+ try:
1639
+ posted = repost_audit_verdict_artifact(
1640
+ args.repost_verdict_artifact,
1641
+ token=token,
1642
+ )
1643
+ except (
1644
+ OSError,
1645
+ TypeError,
1646
+ ValueError,
1647
+ json.JSONDecodeError,
1648
+ urllib.error.HTTPError,
1649
+ urllib.error.URLError,
1650
+ ) as exc:
1651
+ print(f"error: failed to repost verdict artifact: {exc}", file=sys.stderr)
1652
+ return 1
1653
+ print(posted.get("html_url") or "posted")
1654
+ return 0
1655
+ if not args.repo or args.pr is None:
1656
+ print("error: --repo and --pr are required unless --repost-verdict-artifact is used", file=sys.stderr)
1657
+ return 1
1658
+ if not args.repo_paths:
1659
+ print("error: --repo-paths or CODEX_AUDIT_REPO_PATHS is required",
1660
+ file=sys.stderr)
1661
+ return 1
1662
+
1663
+ try:
1664
+ repo_paths = _parse_repo_paths(args.repo_paths)
1665
+ except ValueError as exc:
1666
+ print(f"error: {exc}", file=sys.stderr)
1667
+ return 1
1668
+
1669
+ # --venv-path semantics:
1670
+ # not set (None) -> auto-discover <local_repo>/.venv/
1671
+ # non-empty str -> use that path (resolved to absolute)
1672
+ # empty str ("") -> explicit disable (disable_venv=True)
1673
+ #
1674
+ # Round-4 P2 on PR #264:
1675
+ # - Empty string is handled via the explicit `disable_venv` field,
1676
+ # not a `Path("")` sentinel. `str(Path(""))` returns `"."` and
1677
+ # could accidentally inject `./bin` if cwd had one.
1678
+ # - Non-empty paths are resolved to absolute BEFORE storing on the
1679
+ # config, so a relative `--venv-path .venv` still finds its
1680
+ # bin/python when the subprocess runs with `cwd=worktree_path`.
1681
+ explicit_venv: Optional[Path]
1682
+ disable_venv = False
1683
+ if args.venv_path is None:
1684
+ explicit_venv = None
1685
+ elif args.venv_path == "":
1686
+ explicit_venv = None
1687
+ disable_venv = True
1688
+ else:
1689
+ explicit_venv = Path(args.venv_path).resolve()
1690
+ config = AuditConfig(
1691
+ github_token=token,
1692
+ repo_paths=repo_paths,
1693
+ codex_cli_path=args.codex_cli_path,
1694
+ base_ref=args.base_ref,
1695
+ timeout=args.timeout,
1696
+ dry_run=args.dry_run,
1697
+ ignore_user_config=not args.use_user_config,
1698
+ venv_path=explicit_venv,
1699
+ disable_venv=disable_venv,
1700
+ )
1701
+
1702
+ try:
1703
+ result = audit_pr(config, args.repo, args.pr)
1704
+ except urllib.error.HTTPError as exc:
1705
+ print(f"error: GitHub API HTTP {exc.code} — {exc.reason}", file=sys.stderr)
1706
+ return 1
1707
+ except urllib.error.URLError as exc:
1708
+ print(f"error: network — {exc}", file=sys.stderr)
1709
+ return 1
1710
+ except subprocess.TimeoutExpired:
1711
+ print(f"error: codex audit timed out after {args.timeout}s",
1712
+ file=sys.stderr)
1713
+ return 1
1714
+ except subprocess.CalledProcessError as exc:
1715
+ print(f"error: subprocess failed — {exc}", file=sys.stderr)
1716
+ return 1
1717
+ except FileNotFoundError as exc:
1718
+ print(f"error: {exc}", file=sys.stderr)
1719
+ return 1
1720
+ except RuntimeError as exc:
1721
+ print(f"error: {exc}", file=sys.stderr)
1722
+ return 1
1723
+
1724
+ if args.dry_run:
1725
+ print(result.comment_body)
1726
+
1727
+ # Codex round 7 of #234 — P2: both STALE and UNKNOWN result in a
1728
+ # `needs-codex-audit` requeue comment. Automation using the exit
1729
+ # code to decide whether to retry must see exit 2 for BOTH cases;
1730
+ # otherwise UNKNOWN looks like a successful audit even though the
1731
+ # comment requests re-review.
1732
+ if result.verdict in ("STALE", "UNKNOWN"):
1733
+ return 2
1734
+ return 0
1735
+
1736
+
1737
+ if __name__ == "__main__":
1738
+ raise SystemExit(main())