codereview-loop 0.13.0__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.
codereview/crash.py ADDED
@@ -0,0 +1,247 @@
1
+ """Crash handling — release-gate row L17.
2
+
3
+ An unexpected error must never reach the user as a raw traceback,
4
+ and must never be mistaken for a verdict. `run_guarded` wraps the
5
+ command-line entry point:
6
+
7
+ * any uncaught exception prints ONE line to stderr, writes a
8
+ redacted crash note next to the records, and exits 2 — the
9
+ same code as "inconclusive", so CI can never read a crash as
10
+ a PASS (0) or a plain block (1);
11
+ * `--debug` or CODEREVIEW_DEBUG=1 additionally prints the
12
+ redacted traceback;
13
+ * KeyboardInterrupt exits 130 with one line; a closed pipe
14
+ exits quietly (141);
15
+ * everything about reporting is best-effort: nothing in this
16
+ module can raise out of `run_guarded`, change a gate
17
+ outcome, or delay the exit beyond the short ping timeout.
18
+
19
+ The crash note is a local file. It is redacted with the same
20
+ `redact_secrets` the records use, and it never contains the
21
+ environment. Sending anything anywhere is feedback.py's job, is
22
+ opt-in, and is attempted only after the note is on disk.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import datetime
28
+ import json
29
+ import os
30
+ import platform
31
+ import sys
32
+ import traceback
33
+
34
+ from .core import VERSION, redact_secrets
35
+
36
+ SCHEMA = "codereview.crash/1"
37
+ EXIT_CRASH = 2
38
+ EXIT_INTERRUPT = 130
39
+ EXIT_BROKEN_PIPE = 141
40
+
41
+ DOCS_POINTER = ("This is a bug; run 'codereview support-bundle' "
42
+ "and see SECURITY.md / CONTRIBUTING.md.")
43
+
44
+ _SECRET_FLAG_WORDS = ("key", "token", "secret", "password", "passwd",
45
+ "credential", "auth")
46
+ _MAX_MESSAGE = 500
47
+ _MAX_TRACEBACK = 20000
48
+
49
+ # Callables the entry points register to run after a crash (for
50
+ # example: mark a GitHub commit status pending). Best-effort.
51
+ _crash_hooks = []
52
+
53
+
54
+ def register_crash_hook(fn):
55
+ _crash_hooks.append(fn)
56
+
57
+
58
+ def clear_crash_hooks():
59
+ del _crash_hooks[:]
60
+
61
+
62
+ def build_identity():
63
+ """Version, and the git hash when the build stamped one
64
+ (src/codereview/_build_info.py with GIT_HASH, or the
65
+ CODEREVIEW_GIT_HASH variable)."""
66
+ git_hash = os.environ.get("CODEREVIEW_GIT_HASH", "").strip() \
67
+ or None
68
+ if git_hash is None:
69
+ try:
70
+ from . import _build_info # type: ignore
71
+ git_hash = getattr(_build_info, "GIT_HASH", None)
72
+ except Exception: # noqa: BLE001 — optional module
73
+ git_hash = None
74
+ return {"version": VERSION, "git_hash": git_hash}
75
+
76
+
77
+ def redact_argv(argv):
78
+ """Redact a command line: secret-shaped text in any argument,
79
+ and the value after (or inside) a flag whose name looks like
80
+ it carries a credential."""
81
+ out = []
82
+ hide_next = False
83
+ for arg in argv or []:
84
+ arg = str(arg)
85
+ if hide_next:
86
+ out.append("[REDACTED]")
87
+ hide_next = False
88
+ continue
89
+ if arg.startswith("-"):
90
+ name, eq, _value = arg.partition("=")
91
+ if any(w in name.lower() for w in _SECRET_FLAG_WORDS):
92
+ if eq:
93
+ out.append(name + "=[REDACTED]")
94
+ else:
95
+ out.append(arg)
96
+ hide_next = True
97
+ continue
98
+ out.append(redact_secrets(arg))
99
+ return out
100
+
101
+
102
+ def _command_name(argv):
103
+ for arg in argv or []:
104
+ if not str(arg).startswith("-"):
105
+ return str(arg)[:40]
106
+ break
107
+ return "review"
108
+
109
+
110
+ def _safe_text(text, limit):
111
+ try:
112
+ return redact_secrets(str(text))[:limit]
113
+ except Exception: # noqa: BLE001
114
+ return "[unavailable]"
115
+
116
+
117
+ def build_crash_note(exc, argv):
118
+ tb_text = "".join(traceback.format_exception(
119
+ type(exc), exc, exc.__traceback__))
120
+ note = {
121
+ "schema": SCHEMA,
122
+ "created_at": datetime.datetime.now(
123
+ datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
124
+ **build_identity(),
125
+ "python": platform.python_version(),
126
+ "platform": platform.system(),
127
+ "machine": platform.machine(),
128
+ "command": _command_name(argv),
129
+ "argv": redact_argv(argv),
130
+ "exception_type": type(exc).__name__,
131
+ "message": _safe_text(exc, _MAX_MESSAGE),
132
+ "traceback": _safe_text(tb_text, _MAX_TRACEBACK),
133
+ }
134
+ return note
135
+
136
+
137
+ def _crash_dir(argv):
138
+ override = os.environ.get("CODEREVIEW_CRASH_DIR", "").strip()
139
+ if override:
140
+ return override
141
+ args = [str(a) for a in argv or []]
142
+ for i, arg in enumerate(args):
143
+ if arg == "--out" and i + 1 < len(args):
144
+ return args[i + 1]
145
+ if arg.startswith("--out="):
146
+ return arg[len("--out="):]
147
+ return "."
148
+
149
+
150
+ def write_crash_note(note, argv):
151
+ """Write the note; return its path, or None when no directory
152
+ could be written (never raises)."""
153
+ stamp = note["created_at"].replace("-", "").replace(":", "")
154
+ name = f"codereview-crash-{stamp}-{os.getpid()}.json"
155
+ for directory in (_crash_dir(argv), "."):
156
+ try:
157
+ os.makedirs(directory, exist_ok=True)
158
+ path = os.path.join(directory, name)
159
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
160
+ 0o600)
161
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
162
+ json.dump(note, fh, indent=2, sort_keys=True)
163
+ fh.write("\n")
164
+ return path
165
+ except OSError:
166
+ continue
167
+ return None
168
+
169
+
170
+ def _say(text):
171
+ try:
172
+ print(text, file=sys.stderr)
173
+ except Exception: # noqa: BLE001 — stderr itself may be gone
174
+ pass
175
+
176
+
177
+ def _split_debug(argv):
178
+ """-> (argv without --debug, debug_requested)."""
179
+ debug = os.environ.get("CODEREVIEW_DEBUG", "").strip() == "1"
180
+ clean = []
181
+ for arg in argv:
182
+ if arg == "--debug":
183
+ debug = True
184
+ else:
185
+ clean.append(arg)
186
+ return clean, debug
187
+
188
+
189
+ def _handle_crash(exc, argv, debug):
190
+ try:
191
+ note = build_crash_note(exc, argv)
192
+ path = write_crash_note(note, argv)
193
+ except Exception: # noqa: BLE001 — the handler must not crash
194
+ note, path = None, None
195
+ kind = type(exc).__name__
196
+ message = _safe_text(exc, 200).replace("\n", " ")
197
+ line = f"codereview: internal error ({kind}): {message}. " \
198
+ f"{DOCS_POINTER}"
199
+ if path:
200
+ line += f" Crash note: {path}"
201
+ _say(line)
202
+ if debug and note is not None:
203
+ _say(note["traceback"].rstrip())
204
+ for hook in list(_crash_hooks):
205
+ try:
206
+ hook()
207
+ except Exception: # noqa: BLE001 — best effort only
208
+ pass
209
+ if note is not None:
210
+ try:
211
+ from . import feedback
212
+ feedback.maybe_send_crash_ping(note, exc)
213
+ except Exception: # noqa: BLE001 — never affects the exit
214
+ pass
215
+
216
+
217
+ def run_guarded(fn, argv=None):
218
+ """Run `fn(argv)` (the CLI main) under the crash policy and
219
+ return the process exit code."""
220
+ raw = list(sys.argv[1:] if argv is None else argv)
221
+ clean, debug = _split_debug(raw)
222
+ try:
223
+ rc = fn(clean)
224
+ return 0 if rc is None else rc
225
+ except SystemExit as exc:
226
+ code = exc.code
227
+ if code is None:
228
+ return 0
229
+ if isinstance(code, int):
230
+ return code
231
+ _say(str(code))
232
+ return 1
233
+ except KeyboardInterrupt:
234
+ _say("codereview: interrupted.")
235
+ return EXIT_INTERRUPT
236
+ except BrokenPipeError:
237
+ try:
238
+ devnull = os.open(os.devnull, os.O_WRONLY)
239
+ os.dup2(devnull, sys.stdout.fileno())
240
+ except Exception: # noqa: BLE001
241
+ pass
242
+ return EXIT_BROKEN_PIPE
243
+ except Exception as exc: # noqa: BLE001 — the point of this module
244
+ _handle_crash(exc, clean, debug)
245
+ return EXIT_CRASH
246
+ finally:
247
+ clear_crash_hooks()
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env python3
2
+ """codereview agent-CLI discovery — find the agent CLIs a user
3
+ already has installed, so first-run setup can offer them as
4
+ review seats (Epic K1).
5
+
6
+ The audience: users who run subscription-authenticated agent
7
+ CLIs (Claude Code, Codex, ...) and have no API keys. Agent seats
8
+ have existed since v0.7.0, but the setup wizard never looked at
9
+ the machine — a user had to know the agent:<name>:<command>
10
+ syntax and type a correct non-interactive invocation from
11
+ memory. Discovery closes that surface gap; it adds NO new
12
+ execution capability: a discovered seat is an ordinary agent
13
+ spec, executed, recorded, and probed exactly like a hand-typed
14
+ one.
15
+
16
+ Honesty rules (the reason this module is small):
17
+
18
+ * A CLI is reported only when its binary is actually found on
19
+ PATH (shutil.which semantics: present AND executable;
20
+ directories and non-executable files do not count).
21
+ * Each found binary gets a cheap `--version` probe. A failed
22
+ probe is reported as failed, in those words — never smoothed
23
+ over, never silently treated as working.
24
+ * A seat command is pre-filled ONLY where this repository
25
+ carries a verified non-interactive invocation (the spec's
26
+ U6 rule: templates enter the table as build-phase probes
27
+ verify them). Since Epic K2's U6 probe matrix, that is all
28
+ three entries, each with its provenance class recorded at
29
+ the entry: Claude Code (the shipped example wrapper,
30
+ examples/agent-seat-claude.sh), Codex (invocation form
31
+ verified live against codex-cli 0.164.0-alpha.9's own
32
+ --help plus one logged-out exec probe, stdout contract
33
+ from the official non-interactive docs), and Gemini CLI
34
+ (official docs only — not installed on the build VM, so
35
+ no live verification; the entry says so). A CLI whose
36
+ invocation were still UNVERIFIED would be reported as
37
+ found, with the reason it is not offerable; the user can
38
+ still seat one by typing an agent: spec with their own
39
+ command in the wizard's provider step, exactly as before
40
+ this module existed.
41
+ * Discovery reads the machine; it writes nothing. The wizard
42
+ saves only what the user confirms, through the existing
43
+ save path.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import shutil
49
+ import subprocess
50
+ import threading
51
+
52
+ from . import hardening
53
+
54
+ PROBE_TIMEOUT = 5
55
+
56
+ # Retained-output ceiling for one --version probe (P3-07):
57
+ # a version string is one line; anything past this is a
58
+ # spewing binary, and the parent must not buffer it.
59
+ _PROBE_OUTPUT_CAP = 64 * 1024
60
+
61
+ # The instruction the shipped example wrapper passes to Claude
62
+ # Code (examples/agent-seat-claude.sh, verbatim): the seat's
63
+ # brief arrives on stdin — command_agent pipes the prompt there
64
+ # AND names the same content's file in $CODEREVIEW_PROMPT, so
65
+ # the wrapper's `< "$CODEREVIEW_PROMPT"` redirect and this
66
+ # direct command receive identical input.
67
+ _CLAUDE_INSTRUCTION = (
68
+ "You are a reviewer seat in a multi-model code review. "
69
+ "The document on stdin is your complete brief: a diff plus "
70
+ "instructions. Follow the instructions exactly and output "
71
+ "ONLY the JSON verdict object they ask for — no prose, "
72
+ "no markdown fences.")
73
+
74
+ # name seat name (the agent:<name>:... the spec carries)
75
+ # display human name for the offer lines
76
+ # binaries candidate executable names, tried in order
77
+ # family the CLI vendor's family tag — used ONLY by the
78
+ # wizard's family-diversity warning. The gate tags
79
+ # every agent seat family 'agent' (cli._default_
80
+ # seat_meta) unless the operator overrides with
81
+ # --seat-meta; discovery does not change that.
82
+ # command the verified non-interactive invocation, or None
83
+ # when this repository carries none (see module
84
+ # docstring). Seat contract: the prompt arrives on
85
+ # stdin and in $CODEREVIEW_PROMPT; stdout is the
86
+ # response (providers.command_agent).
87
+ KNOWN_CLIS = (
88
+ {
89
+ "name": "claude",
90
+ "display": "Claude Code",
91
+ "binaries": ("claude",),
92
+ "family": "anthropic",
93
+ "command": 'claude -p --tools "" "' + _CLAUDE_INSTRUCTION
94
+ + '"',
95
+ },
96
+ {
97
+ # U6 provenance: VERIFIED-LIVE for the invocation form
98
+ # (`codex exec --help` on codex-cli 0.164.0-alpha.9,
99
+ # plus one logged-out exec probe on the build VM that
100
+ # consumed the piped prompt and attempted the call,
101
+ # exiting 1 on 401s) and VERIFIED-DOCS for the contract
102
+ # (developers.openai.com/codex/noninteractive: `-` /
103
+ # piped stdin is the full prompt; only the final agent
104
+ # message goes to stdout, progress to stderr; sandbox
105
+ # defaults to read-only). `-s read-only` is stated
106
+ # explicitly because a reviewer seat must never write;
107
+ # `--skip-git-repo-check` because the seat's cwd is a
108
+ # scratch dir, not a repo. An end-to-end SUCCESSFUL run
109
+ # was not live-verified (no Codex login on the build
110
+ # VM) — the form and failure behaviour are what U6
111
+ # verified.
112
+ "name": "codex",
113
+ "display": "Codex",
114
+ "binaries": ("codex",),
115
+ "family": "openai",
116
+ "command": "codex exec -s read-only "
117
+ "--skip-git-repo-check -",
118
+ },
119
+ {
120
+ # U6 provenance: VERIFIED-DOCS only (Gemini CLI is not
121
+ # installed on the build VM; nothing here is live-
122
+ # verified). Official CLI reference (geminicli.com/
123
+ # docs/cli/cli-reference): `-p/--prompt` "Appended to
124
+ # stdin input if provided. Forces non-interactive
125
+ # mode" — so the brief rides stdin exactly as with
126
+ # Claude, and the -p text is the same seat instruction.
127
+ # Headless reference documents exit codes 0 (success),
128
+ # 1 (general/API failure), 42 (input error), 53 (turn
129
+ # limit exceeded).
130
+ "name": "gemini",
131
+ "display": "Gemini CLI",
132
+ "binaries": ("gemini",),
133
+ "family": "google",
134
+ "command": 'gemini -p "' + _CLAUDE_INSTRUCTION + '"',
135
+ },
136
+ )
137
+
138
+ _BY_NAME = {entry["name"]: entry for entry in KNOWN_CLIS}
139
+
140
+
141
+ def seat_spec(entry):
142
+ """The ordinary agent spec a confirmed discovery pick saves
143
+ (agent:<name>:<command>). Only callable for entries with a
144
+ verified command — the wizard never offers the others."""
145
+ if not entry.get("command"):
146
+ raise ValueError(
147
+ f"no verified seat command for {entry['name']!r}")
148
+ return f"agent:{entry['name']}:{entry['command']}"
149
+
150
+
151
+ def family_for_seat_name(name):
152
+ """Vendor family for an agent seat name, from the discovery
153
+ table (agent:claude:* -> 'anthropic'); None for names the
154
+ table does not know (a wrapper script, an echo stub, ...).
155
+ Used by the wizard's family warning only — never by the
156
+ gate: providers' agent-seat family derivation (P6-06)
157
+ reads the seat command's PROGRAM against the table, not
158
+ the seat name, because a name must not confer a family
159
+ the seat does not run (review-v11)."""
160
+ entry = _BY_NAME.get(str(name).strip().lower())
161
+ return entry["family"] if entry else None
162
+
163
+
164
+ def default_probe(path):
165
+ """Cheap liveness probe: `<binary> --version`, bounded by
166
+ PROBE_TIMEOUT, output capped. Returns (ok, detail) and never
167
+ raises — a binary that cannot even answer --version is a
168
+ probe failure with the reason attached, not an exception in
169
+ the wizard."""
170
+ # P3-04 backstop: the probed binary is a direct child of
171
+ # this process (same /proc/$PPID/environ exposure as the
172
+ # evidence sandbox's env-only mode).
173
+ hardening.make_non_dumpable()
174
+ # P3-07: the probe NEVER raises — not OSError, not a
175
+ # timeout, not MemoryError from a spewing binary's
176
+ # unbounded output (the old subprocess.run capture had
177
+ # no cap, so the "never raises" contract was false).
178
+ try:
179
+ return _probe_version(path)
180
+ except Exception as exc: # noqa: BLE001 — never raises
181
+ return False, f"could not run: {exc}"
182
+
183
+
184
+ def _drain_capped(stream, sink, proc):
185
+ """Read `stream` into `sink` (a bytearray), retaining at
186
+ most _PROBE_OUTPUT_CAP bytes; once the cap is exceeded,
187
+ kill the child and keep draining (discarding) so the
188
+ pipe never blocks it on the way down."""
189
+ try:
190
+ while True:
191
+ chunk = stream.read(8192)
192
+ if not chunk:
193
+ return
194
+ room = _PROBE_OUTPUT_CAP - len(sink)
195
+ if room >= len(chunk):
196
+ sink.extend(chunk)
197
+ continue
198
+ if room > 0:
199
+ sink.extend(chunk[:room])
200
+ try:
201
+ proc.kill()
202
+ except OSError:
203
+ pass
204
+ except (OSError, ValueError):
205
+ return
206
+
207
+
208
+ def _probe_version(path):
209
+ proc = subprocess.Popen(
210
+ [path, "--version"], stdout=subprocess.PIPE,
211
+ stderr=subprocess.PIPE)
212
+ out_sink, err_sink = bytearray(), bytearray()
213
+ readers = [
214
+ threading.Thread(target=_drain_capped,
215
+ args=(proc.stdout, out_sink, proc),
216
+ daemon=True),
217
+ threading.Thread(target=_drain_capped,
218
+ args=(proc.stderr, err_sink, proc),
219
+ daemon=True),
220
+ ]
221
+ for reader in readers:
222
+ reader.start()
223
+ return _supervise_probe_run(proc, readers, out_sink,
224
+ err_sink)
225
+
226
+
227
+ def _supervise_probe_run(proc, readers, out_sink, err_sink):
228
+ """The probe's supervised wait — the pairing the
229
+ subprocess-timeout guard accepts by name, as it does the
230
+ evidence sandbox's: every wait is deadline-bounded
231
+ (wait(timeout=...), bounded joins) and expiry kills the
232
+ child. It is a stronger bound than communicate(timeout=
233
+ ...), not a weaker one: communicate buffers the child's
234
+ output without bound, this wait drains it under the
235
+ P3-07 cap."""
236
+ try:
237
+ proc.wait(timeout=PROBE_TIMEOUT)
238
+ except subprocess.TimeoutExpired:
239
+ try:
240
+ proc.kill()
241
+ except OSError:
242
+ pass
243
+ proc.wait()
244
+ return False, (f"could not run: timed out after "
245
+ f"{PROBE_TIMEOUT} seconds")
246
+ for reader in readers:
247
+ reader.join(timeout=2)
248
+ out = out_sink.decode("utf-8", "replace").strip() \
249
+ or err_sink.decode("utf-8", "replace").strip()
250
+ detail = out.splitlines()[0][:120] if out else ""
251
+ if proc.returncode != 0:
252
+ return False, (f"exited {proc.returncode}"
253
+ + (f": {detail}" if detail else ""))
254
+ return True, detail
255
+
256
+
257
+ def discover_clis(path_value=None, which_fn=None, probe_fn=None):
258
+ """Scan PATH for the known agent CLIs.
259
+
260
+ path_value: the PATH string to search (the wizard passes its
261
+ environ's PATH); None means the process's own PATH. which_fn
262
+ and probe_fn are injectable so tests fake the machine — no
263
+ test may depend on the real PATH contents. Returns one dict
264
+ per FOUND cli, in table order: the table entry plus 'path'
265
+ (the resolved executable), 'probe_ok', and 'probe_detail'."""
266
+ if which_fn is None:
267
+ def which_fn(binary):
268
+ return shutil.which(binary, path=path_value)
269
+ if probe_fn is None:
270
+ probe_fn = default_probe
271
+ found = []
272
+ for entry in KNOWN_CLIS:
273
+ resolved = None
274
+ for binary in entry["binaries"]:
275
+ resolved = which_fn(binary)
276
+ if resolved:
277
+ break
278
+ if not resolved:
279
+ continue
280
+ try:
281
+ ok, detail = probe_fn(resolved)
282
+ except Exception as exc: # noqa: BLE001 — discovery
283
+ # never raises (P3-07): a probe that blows up is
284
+ # a failed probe with the reason attached.
285
+ ok, detail = False, f"could not run: {exc}"
286
+ found.append({**entry, "path": resolved,
287
+ "probe_ok": bool(ok),
288
+ "probe_detail": detail})
289
+ return found