ai-push-hooks 0.3.0 → 0.3.2
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.
- package/CHANGELOG.md +78 -2
- package/README.md +80 -993
- package/SECURITY.md +21 -16
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +6 -6
- package/docs/configuration.md +263 -0
- package/package.json +4 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -19
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +239 -54
- package/src/ai_push_hooks/engine.py +94 -43
- package/src/ai_push_hooks/executors/apply.py +124 -46
- package/src/ai_push_hooks/executors/ask.py +30 -536
- package/src/ai_push_hooks/executors/exec.py +73 -818
- package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
- package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
- package/src/ai_push_hooks/executors/runners/process.py +97 -17
- package/src/ai_push_hooks/executors/runners/registry.py +31 -9
- package/src/ai_push_hooks/executors/step_commands.py +65 -20
- package/src/ai_push_hooks/git_utils.py +899 -0
- package/src/ai_push_hooks/hook.py +49 -13
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +19 -8
- package/src/ai_push_hooks/modules/docs.py +146 -96
- package/src/ai_push_hooks/modules/pr.py +19 -8
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +182 -105
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +48 -27
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- package/run.sh +0 -29
|
@@ -54,15 +54,15 @@ def _safe_input_artifacts(
|
|
|
54
54
|
) -> tuple[RunnerArtifact, ...]:
|
|
55
55
|
"""Build the ordered logical artifact list shared by every adapter.
|
|
56
56
|
|
|
57
|
-
``
|
|
58
|
-
|
|
57
|
+
``validate_hook_owned_artifacts`` is the hook-owned artifact boundary used
|
|
58
|
+
before adapter dispatch. The logical names are the
|
|
59
59
|
configured input references, rather than filesystem basenames, so command,
|
|
60
60
|
Codex, Claude, and OpenCode receive the same ordered context.
|
|
61
61
|
"""
|
|
62
62
|
|
|
63
|
-
from .
|
|
63
|
+
from .runners.opencode_support import validate_hook_owned_artifacts
|
|
64
64
|
|
|
65
|
-
validated =
|
|
65
|
+
validated = validate_hook_owned_artifacts(context, input_paths)
|
|
66
66
|
if len(validated) != len(step.inputs):
|
|
67
67
|
raise HookError(
|
|
68
68
|
f"Runner input count does not match configured inputs for step `{step.id}`"
|
|
@@ -76,29 +76,36 @@ def _safe_input_artifacts(
|
|
|
76
76
|
# parents immediately before opening each file; this is a same-user
|
|
77
77
|
# race defense, not an OS sandbox. O_NOFOLLOW and descriptor checks
|
|
78
78
|
# below protect the final open even if the leaf changes concurrently.
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
or path_has_symlink(run_root, lexical_path)
|
|
79
|
+
if not is_path_within(lexical_path, run_root) or path_has_symlink(
|
|
80
|
+
run_root, lexical_path
|
|
82
81
|
):
|
|
83
82
|
raise HookError(f"Runner artifact must not traverse a symlink: {name}")
|
|
84
83
|
try:
|
|
85
84
|
resolved_path = lexical_path.resolve(strict=True)
|
|
86
85
|
except (OSError, RuntimeError) as exc:
|
|
87
|
-
raise HookError(
|
|
86
|
+
raise HookError(
|
|
87
|
+
f"Unable to safely resolve runner artifact: {name}"
|
|
88
|
+
) from exc
|
|
88
89
|
if (
|
|
89
90
|
resolved_path != lexical_path
|
|
90
91
|
or not is_path_within(resolved_path, run_root)
|
|
91
92
|
or not resolved_path.is_file()
|
|
92
93
|
):
|
|
93
|
-
raise HookError(
|
|
94
|
+
raise HookError(
|
|
95
|
+
f"Runner artifact must be a regular hook-owned file: {name}"
|
|
96
|
+
)
|
|
94
97
|
|
|
95
98
|
descriptor: int | None = None
|
|
96
99
|
try:
|
|
97
|
-
flags =
|
|
100
|
+
flags = (
|
|
101
|
+
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
102
|
+
)
|
|
98
103
|
descriptor = os.open(lexical_path, flags)
|
|
99
104
|
metadata = os.fstat(descriptor)
|
|
100
105
|
if not os.path.isfile(lexical_path) or not stat.S_ISREG(metadata.st_mode):
|
|
101
|
-
raise HookError(
|
|
106
|
+
raise HookError(
|
|
107
|
+
f"Runner artifact must be a regular hook-owned file: {name}"
|
|
108
|
+
)
|
|
102
109
|
remaining = RUNNER_INPUT_MAX_BYTES - total_bytes
|
|
103
110
|
if metadata.st_size > remaining:
|
|
104
111
|
raise HookError(
|
|
@@ -121,7 +128,9 @@ def _safe_input_artifacts(
|
|
|
121
128
|
total_bytes += len(content_bytes)
|
|
122
129
|
content = bytes(content_bytes).decode("utf-8")
|
|
123
130
|
except (OSError, UnicodeError) as exc:
|
|
124
|
-
raise HookError(
|
|
131
|
+
raise HookError(
|
|
132
|
+
f"Unable to read hook-owned runner artifact: {name}"
|
|
133
|
+
) from exc
|
|
125
134
|
finally:
|
|
126
135
|
if descriptor is not None:
|
|
127
136
|
os.close(descriptor)
|
|
@@ -143,7 +152,9 @@ def _build_request(
|
|
|
143
152
|
profile = resolve_runner_profile(context.config, step)
|
|
144
153
|
cwd = pathlib.Path(working_directory).resolve(strict=True)
|
|
145
154
|
if not cwd.is_dir():
|
|
146
|
-
raise HookError(
|
|
155
|
+
raise HookError(
|
|
156
|
+
f"Runner working directory is not a directory: {working_directory}"
|
|
157
|
+
)
|
|
147
158
|
request = RunnerRequest(
|
|
148
159
|
profile_id=profile.name,
|
|
149
160
|
runner_type=profile.type,
|
|
@@ -206,7 +217,9 @@ def _failure_result(
|
|
|
206
217
|
if not isinstance(session_id, str) or not session_id.strip():
|
|
207
218
|
session_id = fallback_session_id
|
|
208
219
|
session = _session_metadata(runner, session_id.strip()) if session_id else None
|
|
209
|
-
return RunnerResult(
|
|
220
|
+
return RunnerResult(
|
|
221
|
+
final_text="", returncode=1, stdout="", stderr="", session=session
|
|
222
|
+
)
|
|
210
223
|
|
|
211
224
|
|
|
212
225
|
def _preserve_failure_session(
|
|
@@ -214,7 +227,9 @@ def _preserve_failure_session(
|
|
|
214
227
|
result: RunnerResult,
|
|
215
228
|
fallback_session_id: str | None,
|
|
216
229
|
) -> RunnerResult:
|
|
217
|
-
if (
|
|
230
|
+
if (
|
|
231
|
+
result.session is not None and result.session.session_id
|
|
232
|
+
) or not fallback_session_id:
|
|
218
233
|
return result
|
|
219
234
|
return RunnerResult(
|
|
220
235
|
final_text=result.final_text,
|
|
@@ -250,7 +265,9 @@ def _completion(
|
|
|
250
265
|
)
|
|
251
266
|
|
|
252
267
|
|
|
253
|
-
def _print_normalized_output(
|
|
268
|
+
def _print_normalized_output(
|
|
269
|
+
context: RuntimeContext, invocation: _RunnerInvocation
|
|
270
|
+
) -> None:
|
|
254
271
|
if not context.config.logging.print_llm_output or not invocation.result.final_text:
|
|
255
272
|
return
|
|
256
273
|
# This is an explicit opt-in. Only normalized final text is printed and
|
|
@@ -270,7 +287,14 @@ def _request_sensitive_values(request: RunnerRequest) -> tuple[str, ...]:
|
|
|
270
287
|
if value
|
|
271
288
|
and any(
|
|
272
289
|
marker in name.upper()
|
|
273
|
-
for marker in (
|
|
290
|
+
for marker in (
|
|
291
|
+
"API_KEY",
|
|
292
|
+
"TOKEN",
|
|
293
|
+
"SECRET",
|
|
294
|
+
"PASSWORD",
|
|
295
|
+
"AUTH",
|
|
296
|
+
"CREDENTIAL",
|
|
297
|
+
)
|
|
274
298
|
)
|
|
275
299
|
)
|
|
276
300
|
return (
|
|
@@ -316,9 +340,7 @@ def _named_error(
|
|
|
316
340
|
"\n".join(part for part in (reason, streams) if part),
|
|
317
341
|
max_chars=1_200,
|
|
318
342
|
)
|
|
319
|
-
message = (
|
|
320
|
-
f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
|
|
321
|
-
)
|
|
343
|
+
message = f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
|
|
322
344
|
if details:
|
|
323
345
|
message += f": {details}"
|
|
324
346
|
return HookError(message)
|
|
@@ -65,7 +65,9 @@ def resolve_claude_executable() -> str:
|
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def _capability_error(
|
|
68
|
+
def _capability_error(
|
|
69
|
+
reason: str, *, details: str = ""
|
|
70
|
+
) -> RunnerAdapterUnavailableError:
|
|
69
71
|
return RunnerAdapterUnavailableError(
|
|
70
72
|
"Claude Code CLI does not satisfy the required non-interactive contract",
|
|
71
73
|
details=f"{reason}{(': ' + details) if details else ''}",
|
|
@@ -102,9 +104,13 @@ def check_claude_capabilities(
|
|
|
102
104
|
raise _capability_error("help output was truncated")
|
|
103
105
|
|
|
104
106
|
help_text = f"{help_result.stdout}\n{help_result.stderr}"
|
|
105
|
-
missing = tuple(
|
|
107
|
+
missing = tuple(
|
|
108
|
+
marker for marker in REQUIRED_HELP_MARKERS if marker not in help_text
|
|
109
|
+
)
|
|
106
110
|
if missing:
|
|
107
|
-
raise _capability_error(
|
|
111
|
+
raise _capability_error(
|
|
112
|
+
"missing required flags or modes", details=", ".join(missing)
|
|
113
|
+
)
|
|
108
114
|
|
|
109
115
|
|
|
110
116
|
def _protocol_failure(
|
|
@@ -158,13 +164,19 @@ def parse_claude_result(
|
|
|
158
164
|
try:
|
|
159
165
|
payload: Any = json.loads(raw)
|
|
160
166
|
except json.JSONDecodeError as exc:
|
|
161
|
-
raise _protocol_failure(
|
|
167
|
+
raise _protocol_failure(
|
|
168
|
+
request, process_result, "stdout was not valid JSON"
|
|
169
|
+
) from exc
|
|
162
170
|
if not isinstance(payload, dict):
|
|
163
|
-
raise _protocol_failure(
|
|
171
|
+
raise _protocol_failure(
|
|
172
|
+
request, process_result, "top-level JSON value was not an object"
|
|
173
|
+
)
|
|
164
174
|
|
|
165
175
|
message_type = payload.get("type")
|
|
166
176
|
if message_type != "result":
|
|
167
|
-
raise _protocol_failure(
|
|
177
|
+
raise _protocol_failure(
|
|
178
|
+
request, process_result, "top-level JSON type was not result"
|
|
179
|
+
)
|
|
168
180
|
|
|
169
181
|
is_error = payload.get("is_error")
|
|
170
182
|
if not isinstance(is_error, bool):
|
|
@@ -111,14 +111,18 @@ def _parse_codex_jsonl(request: RunnerRequest, process: ProcessResult) -> _Codex
|
|
|
111
111
|
thread_id = value
|
|
112
112
|
elif event_type == "turn.started":
|
|
113
113
|
if turn_open:
|
|
114
|
-
raise _protocol_error(
|
|
114
|
+
raise _protocol_error(
|
|
115
|
+
request, process, "Codex JSONL turn order was invalid"
|
|
116
|
+
)
|
|
115
117
|
turn_open = True
|
|
116
118
|
if not terminal_failure:
|
|
117
119
|
terminal_success = False
|
|
118
120
|
elif event_type == "item.completed":
|
|
119
121
|
item = event.get("item")
|
|
120
122
|
if not isinstance(item, dict):
|
|
121
|
-
raise _protocol_error(
|
|
123
|
+
raise _protocol_error(
|
|
124
|
+
request, process, "Codex item.completed event had no item"
|
|
125
|
+
)
|
|
122
126
|
if item.get("type") == "agent_message":
|
|
123
127
|
text = item.get("text")
|
|
124
128
|
if not isinstance(text, str):
|
|
@@ -213,7 +217,9 @@ class CodexRunner:
|
|
|
213
217
|
if process.stdout_truncated or process.stderr_truncated:
|
|
214
218
|
raise _protocol_error(request, process, "Codex output stream was truncated")
|
|
215
219
|
if not process.stdout.strip():
|
|
216
|
-
raise _protocol_error(
|
|
220
|
+
raise _protocol_error(
|
|
221
|
+
request, process, "Codex emitted an empty JSONL stream"
|
|
222
|
+
)
|
|
217
223
|
|
|
218
224
|
parsed = _parse_codex_jsonl(request, process)
|
|
219
225
|
if not parsed.terminal_success:
|
|
@@ -25,8 +25,10 @@ _PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _is_plain_text(value: object, *, allow_empty: bool = False) -> bool:
|
|
28
|
-
return
|
|
29
|
-
|
|
28
|
+
return (
|
|
29
|
+
isinstance(value, str)
|
|
30
|
+
and (allow_empty or bool(value.strip()))
|
|
31
|
+
and not any(ord(character) < 32 for character in value)
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
|
|
@@ -69,10 +71,14 @@ def _validate_request(request: object) -> RunnerRequest:
|
|
|
69
71
|
or not math.isfinite(request.timeout_seconds)
|
|
70
72
|
or request.timeout_seconds <= 0
|
|
71
73
|
):
|
|
72
|
-
raise RunnerContractError(
|
|
74
|
+
raise RunnerContractError(
|
|
75
|
+
"timeout_seconds must be finite and greater than zero"
|
|
76
|
+
)
|
|
73
77
|
|
|
74
78
|
if request.model is not None and not _is_plain_text(request.model):
|
|
75
|
-
raise RunnerContractError(
|
|
79
|
+
raise RunnerContractError(
|
|
80
|
+
"model must be a non-empty NUL-free string when provided"
|
|
81
|
+
)
|
|
76
82
|
if request.variant is not None:
|
|
77
83
|
raise RunnerContractError("variant is not valid for runner type command")
|
|
78
84
|
if request.prompt_transport not in {"stdin", "argv"}:
|
|
@@ -85,11 +91,17 @@ def _validate_request(request: object) -> RunnerRequest:
|
|
|
85
91
|
prompt_count = 0
|
|
86
92
|
for index, argument in enumerate(command, start=1):
|
|
87
93
|
if not isinstance(argument, str) or not argument.strip() or "\x00" in argument:
|
|
88
|
-
raise RunnerContractError(
|
|
94
|
+
raise RunnerContractError(
|
|
95
|
+
f"command[{index}] must be a non-empty NUL-free string"
|
|
96
|
+
)
|
|
89
97
|
for placeholder in _PLACEHOLDER_PATTERN.findall(argument):
|
|
90
98
|
if placeholder not in _ALLOWED_PLACEHOLDERS:
|
|
91
|
-
raise RunnerContractError(
|
|
92
|
-
|
|
99
|
+
raise RunnerContractError(
|
|
100
|
+
f"unknown placeholder {placeholder!r} in command[{index}]"
|
|
101
|
+
)
|
|
102
|
+
if (
|
|
103
|
+
"{" in argument or "}" in argument
|
|
104
|
+
) and argument not in _ALLOWED_PLACEHOLDERS:
|
|
93
105
|
raise RunnerContractError(
|
|
94
106
|
f"placeholders in command[{index}] must be whole argv elements"
|
|
95
107
|
)
|
|
@@ -97,7 +109,9 @@ def _validate_request(request: object) -> RunnerRequest:
|
|
|
97
109
|
prompt_count += 1
|
|
98
110
|
|
|
99
111
|
if request.prompt_transport == "stdin" and prompt_count:
|
|
100
|
-
raise RunnerContractError(
|
|
112
|
+
raise RunnerContractError(
|
|
113
|
+
"command must not contain {prompt} with stdin transport"
|
|
114
|
+
)
|
|
101
115
|
if request.prompt_transport == "argv" and prompt_count != 1:
|
|
102
116
|
raise RunnerContractError(
|
|
103
117
|
"command must contain exactly one {prompt} with argv transport"
|
|
@@ -142,7 +156,9 @@ class CommandRunner:
|
|
|
142
156
|
except RunnerExecutableNotFoundError as exc:
|
|
143
157
|
# The rendered argv can contain prompt/model values. Do not
|
|
144
158
|
# repeat the lower-level adapter's executable detail here.
|
|
145
|
-
raise RunnerExecutableNotFoundError(
|
|
159
|
+
raise RunnerExecutableNotFoundError(
|
|
160
|
+
"runner executable was not found"
|
|
161
|
+
) from exc
|
|
146
162
|
result = RunnerResult(
|
|
147
163
|
final_text=process_result.stdout,
|
|
148
164
|
returncode=process_result.returncode,
|
|
@@ -88,7 +88,9 @@ def _validate_text(
|
|
|
88
88
|
if allow_controls:
|
|
89
89
|
return value
|
|
90
90
|
allowed_controls = "\r\n\t" if allow_line_breaks else ""
|
|
91
|
-
if "\x00" in value or any(
|
|
91
|
+
if "\x00" in value or any(
|
|
92
|
+
ord(character) < 32 and character not in allowed_controls for character in value
|
|
93
|
+
):
|
|
92
94
|
raise RunnerContractError(f"{label} contains control characters")
|
|
93
95
|
return value
|
|
94
96
|
|
|
@@ -156,7 +158,9 @@ class PromptPacket:
|
|
|
156
158
|
if not isinstance(self.artifacts, (tuple, list)):
|
|
157
159
|
raise RunnerContractError("prompt packet artifacts must be ordered")
|
|
158
160
|
if not all(isinstance(item, RunnerArtifact) for item in self.artifacts):
|
|
159
|
-
raise RunnerContractError(
|
|
161
|
+
raise RunnerContractError(
|
|
162
|
+
"prompt packet artifacts must contain RunnerArtifact values"
|
|
163
|
+
)
|
|
160
164
|
object.__setattr__(self, "artifacts", tuple(self.artifacts))
|
|
161
165
|
|
|
162
166
|
def render(self) -> str:
|
|
@@ -208,13 +212,17 @@ class RunnerRequest:
|
|
|
208
212
|
_validate_text(self.purpose, "purpose")
|
|
209
213
|
if self.mode not in {"ask", "apply"}:
|
|
210
214
|
raise RunnerContractError("mode must be 'ask' or 'apply'")
|
|
211
|
-
_validate_text(
|
|
215
|
+
_validate_text(
|
|
216
|
+
self.instruction, "instruction", allow_empty=True, allow_line_breaks=True
|
|
217
|
+
)
|
|
212
218
|
if not isinstance(self.artifacts, (tuple, list)):
|
|
213
219
|
raise RunnerContractError("artifacts must be an ordered sequence")
|
|
214
220
|
normalized_artifacts: list[RunnerArtifact] = []
|
|
215
221
|
for artifact in self.artifacts:
|
|
216
222
|
if not isinstance(artifact, RunnerArtifact):
|
|
217
|
-
raise RunnerContractError(
|
|
223
|
+
raise RunnerContractError(
|
|
224
|
+
"artifacts must contain RunnerArtifact values"
|
|
225
|
+
)
|
|
218
226
|
normalized_artifacts.append(artifact)
|
|
219
227
|
object.__setattr__(self, "artifacts", tuple(normalized_artifacts))
|
|
220
228
|
if not isinstance(self.cwd, pathlib.Path):
|
|
@@ -310,7 +318,9 @@ class RunnerResult:
|
|
|
310
318
|
transcript: str | None = field(default=None, repr=False)
|
|
311
319
|
|
|
312
320
|
def __post_init__(self) -> None:
|
|
313
|
-
_validate_text(
|
|
321
|
+
_validate_text(
|
|
322
|
+
self.final_text, "final_text", allow_empty=True, allow_controls=True
|
|
323
|
+
)
|
|
314
324
|
if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
|
|
315
325
|
raise RunnerContractError("returncode must be an integer")
|
|
316
326
|
_validate_text(self.stdout, "stdout", allow_empty=True, allow_controls=True)
|
|
@@ -318,7 +328,9 @@ class RunnerResult:
|
|
|
318
328
|
if self.session is not None and not isinstance(self.session, SessionMetadata):
|
|
319
329
|
raise RunnerContractError("session must be SessionMetadata or None")
|
|
320
330
|
if self.transcript is not None:
|
|
321
|
-
_validate_text(
|
|
331
|
+
_validate_text(
|
|
332
|
+
self.transcript, "transcript", allow_empty=True, allow_line_breaks=True
|
|
333
|
+
)
|
|
322
334
|
|
|
323
335
|
def __repr__(self) -> str:
|
|
324
336
|
return (
|
|
@@ -342,18 +354,20 @@ class Runner(Protocol):
|
|
|
342
354
|
|
|
343
355
|
capabilities: RunnerCapabilities
|
|
344
356
|
|
|
345
|
-
def run(self, request: RunnerRequest) -> RunnerResult:
|
|
346
|
-
...
|
|
357
|
+
def run(self, request: RunnerRequest) -> RunnerResult: ...
|
|
347
358
|
|
|
348
359
|
|
|
349
360
|
class RunnerLifecycle(Protocol):
|
|
350
361
|
"""Optional lifecycle API, implemented only by adapters that need it."""
|
|
351
362
|
|
|
352
|
-
def finalize(
|
|
353
|
-
|
|
363
|
+
def finalize(
|
|
364
|
+
self, request: RunnerRequest, result: RunnerResult
|
|
365
|
+
) -> RunnerResult: ...
|
|
354
366
|
|
|
355
367
|
|
|
356
|
-
def finalize_runner(
|
|
368
|
+
def finalize_runner(
|
|
369
|
+
runner: Runner, request: RunnerRequest, result: RunnerResult
|
|
370
|
+
) -> RunnerResult:
|
|
357
371
|
"""Finalize a result when the adapter explicitly advertises that capability."""
|
|
358
372
|
|
|
359
373
|
capabilities = getattr(runner, "capabilities", RunnerCapabilities())
|
|
@@ -396,9 +410,7 @@ def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
|
396
410
|
for pattern in _SECRET_PATTERNS:
|
|
397
411
|
redacted = pattern.sub(
|
|
398
412
|
lambda match: (
|
|
399
|
-
f"{match.group(1)}[REDACTED]"
|
|
400
|
-
if match.lastindex
|
|
401
|
-
else "[REDACTED]"
|
|
413
|
+
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
|
|
402
414
|
),
|
|
403
415
|
redacted,
|
|
404
416
|
)
|
|
@@ -450,7 +462,10 @@ def bounded_diagnostic(
|
|
|
450
462
|
return safe
|
|
451
463
|
if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
|
|
452
464
|
return DIAGNOSTIC_TRUNCATION_MARKER[:max_chars]
|
|
453
|
-
return
|
|
465
|
+
return (
|
|
466
|
+
safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)]
|
|
467
|
+
+ DIAGNOSTIC_TRUNCATION_MARKER
|
|
468
|
+
)
|
|
454
469
|
|
|
455
470
|
|
|
456
471
|
def bounded_redacted_diagnostics(
|
|
@@ -465,7 +480,9 @@ def bounded_redacted_diagnostics(
|
|
|
465
480
|
parts: list[str] = []
|
|
466
481
|
for label, value in (("stdout", stdout), ("stderr", stderr)):
|
|
467
482
|
if value:
|
|
468
|
-
parts.append(
|
|
483
|
+
parts.append(
|
|
484
|
+
f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}"
|
|
485
|
+
)
|
|
469
486
|
combined = "\n".join(parts)
|
|
470
487
|
return bounded_diagnostic(combined, max_chars=max_chars, secrets=secrets)
|
|
471
488
|
|
|
@@ -473,7 +490,14 @@ def bounded_redacted_diagnostics(
|
|
|
473
490
|
def _credential_environment_values(env: Mapping[str, str] | None) -> tuple[str, ...]:
|
|
474
491
|
if env is None:
|
|
475
492
|
return ()
|
|
476
|
-
credential_markers = (
|
|
493
|
+
credential_markers = (
|
|
494
|
+
"API_KEY",
|
|
495
|
+
"TOKEN",
|
|
496
|
+
"SECRET",
|
|
497
|
+
"PASSWORD",
|
|
498
|
+
"AUTH",
|
|
499
|
+
"CREDENTIAL",
|
|
500
|
+
)
|
|
477
501
|
return tuple(
|
|
478
502
|
value
|
|
479
503
|
for name, value in env.items()
|
|
@@ -582,14 +606,17 @@ def require_zero_exit(
|
|
|
582
606
|
"""Raise a bounded non-zero diagnostic while preserving normalized results."""
|
|
583
607
|
|
|
584
608
|
if result.returncode != 0:
|
|
585
|
-
details =
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
609
|
+
details = (
|
|
610
|
+
request_sensitive_diagnostics(
|
|
611
|
+
request,
|
|
612
|
+
result.stdout,
|
|
613
|
+
result.stderr,
|
|
614
|
+
max_chars=diagnostic_limit,
|
|
615
|
+
env=env,
|
|
616
|
+
extra=secrets,
|
|
617
|
+
)
|
|
618
|
+
or f"exit code {result.returncode}"
|
|
619
|
+
)
|
|
593
620
|
raise RunnerNonzeroExitError(
|
|
594
621
|
f"runner {request.profile_id!r} ({request.runner_type}) failed at {request.stage!r}",
|
|
595
622
|
details=details,
|