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.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- 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())
|