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/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
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()
|
codereview/discovery.py
ADDED
|
@@ -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
|