ai-push-hooks 0.2.0 → 0.3.0
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 +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Runner-neutral request, result, lifecycle, and diagnostic contracts.
|
|
2
|
+
|
|
3
|
+
This package is intentionally independent from workflow configuration. The
|
|
4
|
+
configuration layer resolves a profile into the flat :class:`RunnerRequest`;
|
|
5
|
+
runner adapters only consume that request and return a :class:`RunnerResult`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import pathlib
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Literal, Mapping, Protocol, Sequence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
RunnerMode = Literal["ask", "apply"]
|
|
19
|
+
ProjectAccess = Literal["artifacts", "project"]
|
|
20
|
+
PromptTransport = Literal["stdin", "argv"]
|
|
21
|
+
SessionState = Literal["persisted", "ephemeral", "deleted"]
|
|
22
|
+
|
|
23
|
+
MAX_DIAGNOSTIC_CHARS = 4_000
|
|
24
|
+
DIAGNOSTIC_TRUNCATION_MARKER = "[truncated]"
|
|
25
|
+
# Request bodies can be much larger than a useful error excerpt. Do not let
|
|
26
|
+
# redaction turn each word in a diff into another full-stream scan.
|
|
27
|
+
MAX_DIAGNOSTIC_REQUEST_CHARS = MAX_DIAGNOSTIC_CHARS * 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RunnerContractError(ValueError):
|
|
31
|
+
"""The caller supplied a request that does not satisfy the runner contract."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunnerError(RuntimeError):
|
|
35
|
+
"""Base class for fail-closed runner and process errors.
|
|
36
|
+
|
|
37
|
+
Error details are deliberately bounded and redacted at construction time.
|
|
38
|
+
Request prompts, artifact bodies, and environments are not accepted by this
|
|
39
|
+
class and therefore cannot accidentally appear in its message.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, *, details: str = "") -> None:
|
|
43
|
+
safe_message = bounded_diagnostic(message, max_chars=MAX_DIAGNOSTIC_CHARS)
|
|
44
|
+
safe_details = bounded_diagnostic(details, max_chars=MAX_DIAGNOSTIC_CHARS)
|
|
45
|
+
if safe_details:
|
|
46
|
+
safe_message = f"{safe_message}: {safe_details}"
|
|
47
|
+
super().__init__(safe_message)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RunnerExecutableNotFoundError(RunnerError):
|
|
51
|
+
"""The selected executable could not be spawned."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RunnerTimeoutError(RunnerError):
|
|
55
|
+
"""The selected process exceeded its timeout and was terminated."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RunnerSignalError(RunnerError):
|
|
59
|
+
"""The selected process terminated because of a signal."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunnerNonzeroExitError(RunnerError):
|
|
63
|
+
"""A process returned a non-zero status when success was required."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RunnerProtocolError(RunnerError):
|
|
67
|
+
"""A runner emitted malformed or incomplete protocol output."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RunnerMissingOutputError(RunnerProtocolError):
|
|
71
|
+
"""An analysis request completed without a final response."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RunnerAdapterUnavailableError(RunnerError):
|
|
75
|
+
"""A known adapter has not been installed/implemented in this build."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate_text(
|
|
79
|
+
value: object,
|
|
80
|
+
label: str,
|
|
81
|
+
*,
|
|
82
|
+
allow_empty: bool = False,
|
|
83
|
+
allow_line_breaks: bool = False,
|
|
84
|
+
allow_controls: bool = False,
|
|
85
|
+
) -> str:
|
|
86
|
+
if not isinstance(value, str) or (not allow_empty and not value.strip()):
|
|
87
|
+
raise RunnerContractError(f"{label} must be a non-empty string")
|
|
88
|
+
if allow_controls:
|
|
89
|
+
return value
|
|
90
|
+
allowed_controls = "\r\n\t" if allow_line_breaks else ""
|
|
91
|
+
if "\x00" in value or any(ord(character) < 32 and character not in allowed_controls for character in value):
|
|
92
|
+
raise RunnerContractError(f"{label} contains control characters")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_argv(values: Sequence[str], label: str) -> tuple[str, ...]:
|
|
97
|
+
if not isinstance(values, (tuple, list)) or not values:
|
|
98
|
+
raise RunnerContractError(f"{label} must be a non-empty argv sequence")
|
|
99
|
+
result: list[str] = []
|
|
100
|
+
for index, value in enumerate(values):
|
|
101
|
+
if not isinstance(value, str) or not value:
|
|
102
|
+
raise RunnerContractError(f"{label}[{index}] must be a non-empty string")
|
|
103
|
+
if "\x00" in value:
|
|
104
|
+
raise RunnerContractError(f"{label}[{index}] contains a NUL character")
|
|
105
|
+
result.append(value)
|
|
106
|
+
return tuple(result)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, repr=False)
|
|
110
|
+
class RunnerArtifact:
|
|
111
|
+
"""One ordered logical input artifact.
|
|
112
|
+
|
|
113
|
+
``path`` is an already validated hook-owned path for adapters with native
|
|
114
|
+
attachment support. It is intentionally not used to render prompt
|
|
115
|
+
packets, so adapters without attachments receive the same name and body.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
name: str
|
|
119
|
+
content: str = field(repr=False)
|
|
120
|
+
path: pathlib.Path | None = None
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
_validate_text(self.name, "artifact name")
|
|
124
|
+
_validate_text(
|
|
125
|
+
self.content,
|
|
126
|
+
"artifact content",
|
|
127
|
+
allow_empty=True,
|
|
128
|
+
allow_line_breaks=True,
|
|
129
|
+
allow_controls=True,
|
|
130
|
+
)
|
|
131
|
+
if self.path is not None and not isinstance(self.path, pathlib.Path):
|
|
132
|
+
object.__setattr__(self, "path", pathlib.Path(self.path))
|
|
133
|
+
if self.path is not None and "\x00" in str(self.path):
|
|
134
|
+
raise RunnerContractError("artifact path contains a NUL character")
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
path = f", path={self.path!r}" if self.path is not None else ""
|
|
138
|
+
return f"RunnerArtifact(name={self.name!r}, content=<redacted>{path})"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, repr=False)
|
|
142
|
+
class PromptPacket:
|
|
143
|
+
"""Deterministic prompt representation for adapters without attachments."""
|
|
144
|
+
|
|
145
|
+
instruction: str = field(repr=False)
|
|
146
|
+
artifacts: tuple[RunnerArtifact, ...] = ()
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
_validate_text(
|
|
150
|
+
self.instruction,
|
|
151
|
+
"instruction",
|
|
152
|
+
allow_empty=True,
|
|
153
|
+
allow_line_breaks=True,
|
|
154
|
+
allow_controls=True,
|
|
155
|
+
)
|
|
156
|
+
if not isinstance(self.artifacts, (tuple, list)):
|
|
157
|
+
raise RunnerContractError("prompt packet artifacts must be ordered")
|
|
158
|
+
if not all(isinstance(item, RunnerArtifact) for item in self.artifacts):
|
|
159
|
+
raise RunnerContractError("prompt packet artifacts must contain RunnerArtifact values")
|
|
160
|
+
object.__setattr__(self, "artifacts", tuple(self.artifacts))
|
|
161
|
+
|
|
162
|
+
def render(self) -> str:
|
|
163
|
+
"""Render names and bodies in order without exposing attachment paths."""
|
|
164
|
+
|
|
165
|
+
sections = [self.instruction]
|
|
166
|
+
for artifact in self.artifacts:
|
|
167
|
+
sections.append(
|
|
168
|
+
f"\n\n--- ai-push-hooks artifact: {artifact.name} ---\n"
|
|
169
|
+
f"{artifact.content}\n"
|
|
170
|
+
f"--- end ai-push-hooks artifact: {artifact.name} ---"
|
|
171
|
+
)
|
|
172
|
+
return "".join(sections)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def text(self) -> str:
|
|
176
|
+
return self.render()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, repr=False)
|
|
180
|
+
class RunnerRequest:
|
|
181
|
+
"""The complete, resolved input to one runner invocation."""
|
|
182
|
+
|
|
183
|
+
profile_id: str
|
|
184
|
+
runner_type: str
|
|
185
|
+
stage: str
|
|
186
|
+
purpose: str
|
|
187
|
+
mode: RunnerMode
|
|
188
|
+
instruction: str = field(repr=False)
|
|
189
|
+
artifacts: tuple[RunnerArtifact, ...] = ()
|
|
190
|
+
cwd: pathlib.Path = pathlib.Path(".")
|
|
191
|
+
timeout_seconds: float = 0
|
|
192
|
+
model: str | None = None
|
|
193
|
+
variant: str | None = None
|
|
194
|
+
project_access: ProjectAccess = "artifacts"
|
|
195
|
+
allow_paths: tuple[str, ...] = ()
|
|
196
|
+
command: tuple[str, ...] = ()
|
|
197
|
+
prompt_transport: PromptTransport = "stdin"
|
|
198
|
+
# These fields exist for the current OpenCode session/lifecycle integration
|
|
199
|
+
# only. Other adapters may ignore them and must remain session-optional.
|
|
200
|
+
session_id: str | None = field(default=None, repr=False)
|
|
201
|
+
resume_session: bool = False
|
|
202
|
+
integration_context: object | None = field(default=None, repr=False)
|
|
203
|
+
|
|
204
|
+
def __post_init__(self) -> None:
|
|
205
|
+
_validate_text(self.profile_id, "profile_id")
|
|
206
|
+
_validate_text(self.runner_type, "runner_type")
|
|
207
|
+
_validate_text(self.stage, "stage")
|
|
208
|
+
_validate_text(self.purpose, "purpose")
|
|
209
|
+
if self.mode not in {"ask", "apply"}:
|
|
210
|
+
raise RunnerContractError("mode must be 'ask' or 'apply'")
|
|
211
|
+
_validate_text(self.instruction, "instruction", allow_empty=True, allow_line_breaks=True)
|
|
212
|
+
if not isinstance(self.artifacts, (tuple, list)):
|
|
213
|
+
raise RunnerContractError("artifacts must be an ordered sequence")
|
|
214
|
+
normalized_artifacts: list[RunnerArtifact] = []
|
|
215
|
+
for artifact in self.artifacts:
|
|
216
|
+
if not isinstance(artifact, RunnerArtifact):
|
|
217
|
+
raise RunnerContractError("artifacts must contain RunnerArtifact values")
|
|
218
|
+
normalized_artifacts.append(artifact)
|
|
219
|
+
object.__setattr__(self, "artifacts", tuple(normalized_artifacts))
|
|
220
|
+
if not isinstance(self.cwd, pathlib.Path):
|
|
221
|
+
object.__setattr__(self, "cwd", pathlib.Path(self.cwd))
|
|
222
|
+
if (
|
|
223
|
+
isinstance(self.timeout_seconds, bool)
|
|
224
|
+
or not isinstance(self.timeout_seconds, (int, float))
|
|
225
|
+
or not math.isfinite(self.timeout_seconds)
|
|
226
|
+
):
|
|
227
|
+
raise RunnerContractError("timeout_seconds must be finite")
|
|
228
|
+
if self.timeout_seconds <= 0:
|
|
229
|
+
raise RunnerContractError("timeout_seconds must be greater than zero")
|
|
230
|
+
for value, label in ((self.model, "model"), (self.variant, "variant")):
|
|
231
|
+
if value is not None:
|
|
232
|
+
_validate_text(value, label, allow_empty=True)
|
|
233
|
+
if self.project_access not in {"artifacts", "project"}:
|
|
234
|
+
raise RunnerContractError("project_access must be 'artifacts' or 'project'")
|
|
235
|
+
if not isinstance(self.allow_paths, (tuple, list)):
|
|
236
|
+
raise RunnerContractError("allow_paths must be an ordered sequence")
|
|
237
|
+
object.__setattr__(self, "allow_paths", tuple(self.allow_paths))
|
|
238
|
+
for index, path in enumerate(self.allow_paths):
|
|
239
|
+
_validate_text(path, f"allow_paths[{index}]")
|
|
240
|
+
if not isinstance(self.command, (tuple, list)):
|
|
241
|
+
raise RunnerContractError("command must be an argv sequence")
|
|
242
|
+
if self.command:
|
|
243
|
+
object.__setattr__(self, "command", _validate_argv(self.command, "command"))
|
|
244
|
+
else:
|
|
245
|
+
object.__setattr__(self, "command", ())
|
|
246
|
+
if self.prompt_transport not in {"stdin", "argv"}:
|
|
247
|
+
raise RunnerContractError("prompt_transport must be 'stdin' or 'argv'")
|
|
248
|
+
if self.session_id is not None:
|
|
249
|
+
_validate_text(self.session_id, "session_id")
|
|
250
|
+
|
|
251
|
+
def prompt_packet(self) -> PromptPacket:
|
|
252
|
+
"""Build the ordered logical packet shared by non-attachment adapters."""
|
|
253
|
+
|
|
254
|
+
return PromptPacket(self.instruction, self.artifacts)
|
|
255
|
+
|
|
256
|
+
def __repr__(self) -> str:
|
|
257
|
+
return (
|
|
258
|
+
"RunnerRequest("
|
|
259
|
+
f"profile_id={self.profile_id!r}, runner_type={self.runner_type!r}, "
|
|
260
|
+
f"stage={self.stage!r}, purpose={self.purpose!r}, mode={self.mode!r}, "
|
|
261
|
+
f"artifact_count={len(self.artifacts)}, cwd={str(self.cwd)!r}, "
|
|
262
|
+
f"timeout_seconds={self.timeout_seconds!r}, model={self.model!r}, "
|
|
263
|
+
f"variant={self.variant!r}, project_access={self.project_access!r}, "
|
|
264
|
+
f"allow_path_count={len(self.allow_paths)}, command_arg_count={len(self.command)})"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True, repr=False)
|
|
269
|
+
class SessionMetadata:
|
|
270
|
+
"""Truthful lifecycle state reported by an adapter."""
|
|
271
|
+
|
|
272
|
+
session_id: str | None = None
|
|
273
|
+
state: SessionState = "ephemeral"
|
|
274
|
+
resumable: bool = False
|
|
275
|
+
transcript: str | None = field(default=None, repr=False)
|
|
276
|
+
|
|
277
|
+
def __post_init__(self) -> None:
|
|
278
|
+
if self.session_id is not None:
|
|
279
|
+
_validate_text(self.session_id, "session_id")
|
|
280
|
+
if self.state not in {"persisted", "ephemeral", "deleted"}:
|
|
281
|
+
raise RunnerContractError("session state is invalid")
|
|
282
|
+
if self.resumable and self.state != "persisted":
|
|
283
|
+
raise RunnerContractError("only persisted sessions can be resumable")
|
|
284
|
+
if self.transcript is not None:
|
|
285
|
+
_validate_text(
|
|
286
|
+
self.transcript,
|
|
287
|
+
"transcript",
|
|
288
|
+
allow_empty=True,
|
|
289
|
+
allow_line_breaks=True,
|
|
290
|
+
allow_controls=True,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def __repr__(self) -> str:
|
|
294
|
+
return (
|
|
295
|
+
"SessionMetadata("
|
|
296
|
+
f"session_id={self.session_id!r}, state={self.state!r}, "
|
|
297
|
+
f"resumable={self.resumable}, transcript=<redacted>)"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, repr=False)
|
|
302
|
+
class RunnerResult:
|
|
303
|
+
"""Normalized output from an adapter."""
|
|
304
|
+
|
|
305
|
+
final_text: str
|
|
306
|
+
returncode: int
|
|
307
|
+
stdout: str = field(repr=False)
|
|
308
|
+
stderr: str = field(repr=False)
|
|
309
|
+
session: SessionMetadata | None = None
|
|
310
|
+
transcript: str | None = field(default=None, repr=False)
|
|
311
|
+
|
|
312
|
+
def __post_init__(self) -> None:
|
|
313
|
+
_validate_text(self.final_text, "final_text", allow_empty=True, allow_controls=True)
|
|
314
|
+
if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
|
|
315
|
+
raise RunnerContractError("returncode must be an integer")
|
|
316
|
+
_validate_text(self.stdout, "stdout", allow_empty=True, allow_controls=True)
|
|
317
|
+
_validate_text(self.stderr, "stderr", allow_empty=True, allow_controls=True)
|
|
318
|
+
if self.session is not None and not isinstance(self.session, SessionMetadata):
|
|
319
|
+
raise RunnerContractError("session must be SessionMetadata or None")
|
|
320
|
+
if self.transcript is not None:
|
|
321
|
+
_validate_text(self.transcript, "transcript", allow_empty=True, allow_line_breaks=True)
|
|
322
|
+
|
|
323
|
+
def __repr__(self) -> str:
|
|
324
|
+
return (
|
|
325
|
+
"RunnerResult("
|
|
326
|
+
f"returncode={self.returncode!r}, session={self.session!r}, "
|
|
327
|
+
"final_text=<redacted>, stdout=<redacted>, stderr=<redacted>)"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass(frozen=True)
|
|
332
|
+
class RunnerCapabilities:
|
|
333
|
+
"""Optional adapter features; invocation never depends on them."""
|
|
334
|
+
|
|
335
|
+
supports_resume: bool = False
|
|
336
|
+
supports_finalize: bool = False
|
|
337
|
+
supports_transcript: bool = False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class Runner(Protocol):
|
|
341
|
+
"""Minimal adapter API shared by all runner types."""
|
|
342
|
+
|
|
343
|
+
capabilities: RunnerCapabilities
|
|
344
|
+
|
|
345
|
+
def run(self, request: RunnerRequest) -> RunnerResult:
|
|
346
|
+
...
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class RunnerLifecycle(Protocol):
|
|
350
|
+
"""Optional lifecycle API, implemented only by adapters that need it."""
|
|
351
|
+
|
|
352
|
+
def finalize(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
|
|
353
|
+
...
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def finalize_runner(runner: Runner, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
|
|
357
|
+
"""Finalize a result when the adapter explicitly advertises that capability."""
|
|
358
|
+
|
|
359
|
+
capabilities = getattr(runner, "capabilities", RunnerCapabilities())
|
|
360
|
+
finalizer = getattr(runner, "finalize", None)
|
|
361
|
+
if not capabilities.supports_finalize or not callable(finalizer):
|
|
362
|
+
return result
|
|
363
|
+
finalized = finalizer(request, result)
|
|
364
|
+
if not isinstance(finalized, RunnerResult):
|
|
365
|
+
raise RunnerProtocolError("runner finalizer returned an invalid result")
|
|
366
|
+
return finalized
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_ANSI_ESCAPE_PATTERN = re.compile(
|
|
370
|
+
r"(?:\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_])"
|
|
371
|
+
)
|
|
372
|
+
_UNSAFE_TERMINAL_CONTROL_PATTERN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
373
|
+
_DIAGNOSTIC_FRAGMENT_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+\-]{3,}")
|
|
374
|
+
_SECRET_PATTERNS = (
|
|
375
|
+
re.compile(r"(?i)(bearer\s+)[^\s,;]+"),
|
|
376
|
+
re.compile(
|
|
377
|
+
r"(?i)([\"']?(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|auth[_-]?token|authorization|credential|password|secret|token)[a-z0-9_-]*[\"']?\s*[:=]\s*[\"']?)[^\"'\s,;}]+"
|
|
378
|
+
),
|
|
379
|
+
re.compile(
|
|
380
|
+
r"(?i)(?<![a-z0-9])[a-z0-9][a-z0-9_.:/+\-]*(?:secret|api[_-]?key|access[_-]?token|auth[_-]?token|credential|password)[a-z0-9_.:/+\-]*(?![a-z0-9])"
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def strip_terminal_controls(value: str) -> str:
|
|
386
|
+
"""Remove ANSI/terminal controls while retaining ordinary text and newlines."""
|
|
387
|
+
|
|
388
|
+
without_ansi = _ANSI_ESCAPE_PATTERN.sub("", value)
|
|
389
|
+
return _UNSAFE_TERMINAL_CONTROL_PATTERN.sub("", without_ansi)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
393
|
+
"""Redact supplied secrets and common credential-shaped values."""
|
|
394
|
+
|
|
395
|
+
redacted = strip_terminal_controls(value)
|
|
396
|
+
for pattern in _SECRET_PATTERNS:
|
|
397
|
+
redacted = pattern.sub(
|
|
398
|
+
lambda match: (
|
|
399
|
+
f"{match.group(1)}[REDACTED]"
|
|
400
|
+
if match.lastindex
|
|
401
|
+
else "[REDACTED]"
|
|
402
|
+
),
|
|
403
|
+
redacted,
|
|
404
|
+
)
|
|
405
|
+
redaction_values: list[str] = []
|
|
406
|
+
seen: set[str] = set()
|
|
407
|
+
fragment_count = 0
|
|
408
|
+
for secret in secrets:
|
|
409
|
+
if not isinstance(secret, str) or not secret or secret in seen:
|
|
410
|
+
continue
|
|
411
|
+
seen.add(secret)
|
|
412
|
+
redaction_values.append(secret)
|
|
413
|
+
# A bounded fragment set catches a child echoing one prompt token,
|
|
414
|
+
# while skipping a whole large diff avoids the old O(words * stream)
|
|
415
|
+
# behavior. The request-sensitive helper additionally suppresses
|
|
416
|
+
# excerpts for genuinely large request bodies.
|
|
417
|
+
if len(secret) <= MAX_DIAGNOSTIC_REQUEST_CHARS:
|
|
418
|
+
for fragment in _DIAGNOSTIC_FRAGMENT_PATTERN.findall(secret):
|
|
419
|
+
if fragment not in seen:
|
|
420
|
+
seen.add(fragment)
|
|
421
|
+
redaction_values.append(fragment)
|
|
422
|
+
fragment_count += 1
|
|
423
|
+
if fragment_count >= 512:
|
|
424
|
+
break
|
|
425
|
+
if fragment_count >= 512:
|
|
426
|
+
continue
|
|
427
|
+
for secret in sorted(redaction_values, key=len, reverse=True):
|
|
428
|
+
redacted = redacted.replace(secret, "[REDACTED]")
|
|
429
|
+
return redacted
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def bounded_diagnostic(
|
|
433
|
+
value: str,
|
|
434
|
+
*,
|
|
435
|
+
max_chars: int = MAX_DIAGNOSTIC_CHARS,
|
|
436
|
+
secrets: Sequence[str] = (),
|
|
437
|
+
) -> str:
|
|
438
|
+
"""Return a bounded, redacted diagnostic excerpt; never an environment dump."""
|
|
439
|
+
|
|
440
|
+
if max_chars <= 0:
|
|
441
|
+
return ""
|
|
442
|
+
raw = str(value)
|
|
443
|
+
# Redact only the bounded preview. Anything after this point cannot be
|
|
444
|
+
# present in the returned diagnostic, and therefore does not need a scan.
|
|
445
|
+
# Credential-shaped values which reach the preview boundary are still
|
|
446
|
+
# handled by the regex redactor before the preview is returned.
|
|
447
|
+
preview = raw[:max_chars]
|
|
448
|
+
safe = redact_diagnostic(preview, secrets=secrets)
|
|
449
|
+
if len(raw) <= max_chars and len(safe) <= max_chars:
|
|
450
|
+
return safe
|
|
451
|
+
if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
|
|
452
|
+
return DIAGNOSTIC_TRUNCATION_MARKER[:max_chars]
|
|
453
|
+
return safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)] + DIAGNOSTIC_TRUNCATION_MARKER
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def bounded_redacted_diagnostics(
|
|
457
|
+
stdout: str = "",
|
|
458
|
+
stderr: str = "",
|
|
459
|
+
*,
|
|
460
|
+
max_chars: int = MAX_DIAGNOSTIC_CHARS,
|
|
461
|
+
secrets: Sequence[str] = (),
|
|
462
|
+
) -> str:
|
|
463
|
+
"""Format bounded child streams without including prompt or environment data."""
|
|
464
|
+
|
|
465
|
+
parts: list[str] = []
|
|
466
|
+
for label, value in (("stdout", stdout), ("stderr", stderr)):
|
|
467
|
+
if value:
|
|
468
|
+
parts.append(f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}")
|
|
469
|
+
combined = "\n".join(parts)
|
|
470
|
+
return bounded_diagnostic(combined, max_chars=max_chars, secrets=secrets)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _credential_environment_values(env: Mapping[str, str] | None) -> tuple[str, ...]:
|
|
474
|
+
if env is None:
|
|
475
|
+
return ()
|
|
476
|
+
credential_markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
|
|
477
|
+
return tuple(
|
|
478
|
+
value
|
|
479
|
+
for name, value in env.items()
|
|
480
|
+
if isinstance(name, str)
|
|
481
|
+
and isinstance(value, str)
|
|
482
|
+
and value
|
|
483
|
+
and any(marker in name.upper() for marker in credential_markers)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _request_sensitive_values(
|
|
488
|
+
request: RunnerRequest,
|
|
489
|
+
*,
|
|
490
|
+
env: Mapping[str, str] | None = None,
|
|
491
|
+
extra: Sequence[str] = (),
|
|
492
|
+
) -> tuple[str, ...]:
|
|
493
|
+
"""Return a small, deduplicated set of values safe to use for redaction.
|
|
494
|
+
|
|
495
|
+
In particular, this must not manufacture one secret per whitespace word
|
|
496
|
+
in a request. A large diff would otherwise make every diagnostic scan the
|
|
497
|
+
full child stream once per word. JSON-escaped forms cover a child that
|
|
498
|
+
embeds the packet in a JSON error object without retaining the packet as a
|
|
499
|
+
second unbounded secret.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
values = (
|
|
503
|
+
request.instruction,
|
|
504
|
+
*(artifact.content for artifact in request.artifacts),
|
|
505
|
+
request.model or "",
|
|
506
|
+
request.variant or "",
|
|
507
|
+
*_credential_environment_values(env),
|
|
508
|
+
*extra,
|
|
509
|
+
)
|
|
510
|
+
result: list[str] = []
|
|
511
|
+
seen: set[str] = set()
|
|
512
|
+
for value in values:
|
|
513
|
+
if not isinstance(value, str) or not value or value in seen:
|
|
514
|
+
continue
|
|
515
|
+
seen.add(value)
|
|
516
|
+
result.append(value)
|
|
517
|
+
# The encoded form is bounded independently below. It is useful for
|
|
518
|
+
# escaped newlines/quotes in JSON diagnostics, but not worth retaining
|
|
519
|
+
# for a request-sized value.
|
|
520
|
+
if len(value) <= MAX_DIAGNOSTIC_REQUEST_CHARS:
|
|
521
|
+
encoded = json.dumps(value, ensure_ascii=True)
|
|
522
|
+
if encoded not in seen:
|
|
523
|
+
seen.add(encoded)
|
|
524
|
+
result.append(encoded)
|
|
525
|
+
|
|
526
|
+
return tuple(result)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def request_sensitive_diagnostics(
|
|
530
|
+
request: RunnerRequest,
|
|
531
|
+
stdout: str = "",
|
|
532
|
+
stderr: str = "",
|
|
533
|
+
*,
|
|
534
|
+
max_chars: int = MAX_DIAGNOSTIC_CHARS,
|
|
535
|
+
env: Mapping[str, str] | None = None,
|
|
536
|
+
extra: Sequence[str] = (),
|
|
537
|
+
) -> str:
|
|
538
|
+
"""Build a bounded diagnostic while keeping request/environment data out.
|
|
539
|
+
|
|
540
|
+
If a request contains a body larger than the diagnostic budget, suppress
|
|
541
|
+
child excerpts entirely. It is not possible to prove that a short model
|
|
542
|
+
excerpt is unrelated to an arbitrary large prompt without an unbounded
|
|
543
|
+
substring search; a fixed explanation is safer and cheaper.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
request_values = (
|
|
547
|
+
request.instruction,
|
|
548
|
+
*(artifact.content for artifact in request.artifacts),
|
|
549
|
+
)
|
|
550
|
+
if any(len(value) > MAX_DIAGNOSTIC_REQUEST_CHARS for value in request_values):
|
|
551
|
+
return "diagnostic output suppressed for a large request"
|
|
552
|
+
return bounded_redacted_diagnostics(
|
|
553
|
+
stdout,
|
|
554
|
+
stderr,
|
|
555
|
+
max_chars=max_chars,
|
|
556
|
+
secrets=_request_sensitive_values(request, env=env, extra=extra),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def require_final_text(final_text: str, *, mode: RunnerMode) -> str:
|
|
561
|
+
"""Enforce the runner-neutral output rule for analysis versus apply."""
|
|
562
|
+
|
|
563
|
+
if mode == "ask" and not final_text.strip():
|
|
564
|
+
raise RunnerMissingOutputError("runner produced no final response")
|
|
565
|
+
return final_text
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def build_prompt_packet(request: RunnerRequest) -> PromptPacket:
|
|
569
|
+
"""Return a packet preserving the exact instruction and artifact order."""
|
|
570
|
+
|
|
571
|
+
return request.prompt_packet()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def require_zero_exit(
|
|
575
|
+
request: RunnerRequest,
|
|
576
|
+
result: RunnerResult,
|
|
577
|
+
*,
|
|
578
|
+
diagnostic_limit: int = MAX_DIAGNOSTIC_CHARS,
|
|
579
|
+
secrets: Sequence[str] = (),
|
|
580
|
+
env: Mapping[str, str] | None = None,
|
|
581
|
+
) -> RunnerResult:
|
|
582
|
+
"""Raise a bounded non-zero diagnostic while preserving normalized results."""
|
|
583
|
+
|
|
584
|
+
if result.returncode != 0:
|
|
585
|
+
details = request_sensitive_diagnostics(
|
|
586
|
+
request,
|
|
587
|
+
result.stdout,
|
|
588
|
+
result.stderr,
|
|
589
|
+
max_chars=diagnostic_limit,
|
|
590
|
+
env=env,
|
|
591
|
+
extra=secrets,
|
|
592
|
+
) or f"exit code {result.returncode}"
|
|
593
|
+
raise RunnerNonzeroExitError(
|
|
594
|
+
f"runner {request.profile_id!r} ({request.runner_type}) failed at {request.stage!r}",
|
|
595
|
+
details=details,
|
|
596
|
+
)
|
|
597
|
+
return result
|