agentic-runner 2.6.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.
Files changed (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Protocol
13
+
14
+ from agentic_runner_contracts.redaction import redact_secret_like_text
15
+
16
+ DEFAULT_OUTPUT_LIMIT_BYTES = 16_384
17
+ # 15 minutes: the Verifier runs the workspace's full pytest suite (path selection is
18
+ # forbidden by _validate_argv) on a CPU-limited worker pod, where the ~2-minute
19
+ # laptop wall time stretches severalfold; 300s produced false exit-124 failures.
20
+ # Must stay under run_verifier's start_to_close_timeout in workflows/ralph.py.
21
+ DEFAULT_TIMEOUT_SECONDS = 900.0
22
+ STARTUP_FAILURE_EXIT_CODE = 126
23
+ TIMEOUT_EXIT_CODE = 124
24
+ SAFE_ENV_KEYS = frozenset(("PATH", "LANG", "LC_ALL", "TZ"))
25
+ SECRET_KEY_PATTERN = re.compile(
26
+ r"(secret|token|password|passwd|credential|apikey|api_key|key)", re.I
27
+ )
28
+ SHELL_METACHAR_PATTERN = re.compile(r"[;&|<>`$(){}\[\]*?~!#\n\r]")
29
+ PYTEST_SAFE_ARGS = frozenset(("--version", "-q", "-v"))
30
+ PNPM_COMMANDS = frozenset(("build", "lint", "test", "install"))
31
+ PNPM_CWD_OPTIONS = frozenset(("--dir", "-C", "--prefix"))
32
+
33
+
34
+ class CommandValidationError(ValueError):
35
+ """Raised when a verifier command is outside the product-configured safety policy."""
36
+
37
+
38
+ class CommandRunner(Protocol):
39
+ def __call__(
40
+ self,
41
+ argv: tuple[str, ...],
42
+ *,
43
+ cwd: Path,
44
+ env: dict[str, str],
45
+ timeout_seconds: float,
46
+ ) -> subprocess.CompletedProcess[str]: ...
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class VerificationResult:
51
+ command_hash: str
52
+ exit_code: int
53
+ passed: bool
54
+ stdout: str
55
+ stderr: str
56
+ elapsed_ms: int
57
+ working_directory: str
58
+ argv: tuple[str, ...]
59
+ env_keys: tuple[str, ...]
60
+
61
+ def to_evidence(self) -> dict[str, object]:
62
+ return {
63
+ "command_hash": self.command_hash,
64
+ "exit_code": self.exit_code,
65
+ "passed": self.passed,
66
+ "stdout": self.stdout,
67
+ "stderr": self.stderr,
68
+ "elapsed_ms": self.elapsed_ms,
69
+ "working_directory": self.working_directory,
70
+ "argv": self.argv,
71
+ "env_keys": self.env_keys,
72
+ }
73
+
74
+
75
+ def _subprocess_runner(
76
+ argv: tuple[str, ...],
77
+ *,
78
+ cwd: Path,
79
+ env: dict[str, str],
80
+ timeout_seconds: float,
81
+ preexec_fn: Callable[[], None] | None = None,
82
+ user: int | None = None,
83
+ group: int | None = None,
84
+ extra_groups: list[int] | None = None,
85
+ ) -> subprocess.CompletedProcess[str]:
86
+ """Spawn the verifier.
87
+
88
+ The last four carry the Contract's sandbox floor (ADR-0015 §1): the verifier runs the
89
+ repository's own test suite, so it is one of the things that must run as the
90
+ Contract's uid and under its rlimits, not as the Runner. They are optional so a Runner
91
+ that cannot separate uids still verifies, and they arrive together from
92
+ ``DirectiveSandbox.spawn_kwargs`` — the uid drop in subprocess's own C fork-exec path,
93
+ only the rlimits in the Python hook.
94
+ """
95
+
96
+ return subprocess.run(
97
+ list(argv),
98
+ cwd=cwd,
99
+ env=env,
100
+ timeout=timeout_seconds,
101
+ check=False,
102
+ capture_output=True,
103
+ text=True,
104
+ preexec_fn=preexec_fn,
105
+ user=user,
106
+ group=group,
107
+ extra_groups=extra_groups,
108
+ )
109
+
110
+
111
+ def run(
112
+ argv: tuple[str, ...],
113
+ *,
114
+ working_directory: Path,
115
+ workspace_root: Path,
116
+ safe_env: Mapping[str, str] | None = None,
117
+ output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES,
118
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
119
+ runner: CommandRunner = _subprocess_runner,
120
+ ) -> VerificationResult:
121
+ if output_limit_bytes < 0:
122
+ raise CommandValidationError("output limit must be non-negative")
123
+ if timeout_seconds <= 0:
124
+ raise CommandValidationError("timeout must be positive")
125
+
126
+ validated_argv = _validate_argv(argv)
127
+ resolved_cwd = _validate_working_directory(working_directory, workspace_root)
128
+ env, redaction_values = _build_env(safe_env)
129
+ redaction_values = redaction_values | _secret_values_from_argv(validated_argv)
130
+ redacted_argv = _redact_argv(validated_argv)
131
+ command_hash = _command_hash(validated_argv, resolved_cwd)
132
+
133
+ start = time.monotonic()
134
+ try:
135
+ completed = runner(
136
+ validated_argv,
137
+ cwd=resolved_cwd,
138
+ env=env,
139
+ timeout_seconds=timeout_seconds,
140
+ )
141
+ except subprocess.TimeoutExpired as exc:
142
+ elapsed_ms = max(0, int((time.monotonic() - start) * 1000))
143
+ return _failed_result(
144
+ command_hash=command_hash,
145
+ exit_code=TIMEOUT_EXIT_CODE,
146
+ stderr=f"command timed out after {exc.timeout:g} seconds",
147
+ elapsed_ms=elapsed_ms,
148
+ working_directory=resolved_cwd,
149
+ argv=redacted_argv,
150
+ env=env,
151
+ redaction_values=redaction_values,
152
+ output_limit_bytes=output_limit_bytes,
153
+ )
154
+ except OSError as exc:
155
+ elapsed_ms = max(0, int((time.monotonic() - start) * 1000))
156
+ return _failed_result(
157
+ command_hash=command_hash,
158
+ exit_code=STARTUP_FAILURE_EXIT_CODE,
159
+ stderr=f"command startup failed: {type(exc).__name__}",
160
+ elapsed_ms=elapsed_ms,
161
+ working_directory=resolved_cwd,
162
+ argv=redacted_argv,
163
+ env=env,
164
+ redaction_values=redaction_values,
165
+ output_limit_bytes=output_limit_bytes,
166
+ )
167
+ elapsed_ms = max(0, int((time.monotonic() - start) * 1000))
168
+
169
+ return VerificationResult(
170
+ command_hash=command_hash,
171
+ exit_code=completed.returncode,
172
+ passed=completed.returncode == 0,
173
+ stdout=_truncate(
174
+ _redact_persisted_output(completed.stdout, redaction_values),
175
+ output_limit_bytes,
176
+ ),
177
+ stderr=_truncate(
178
+ _redact_persisted_output(completed.stderr, redaction_values),
179
+ output_limit_bytes,
180
+ ),
181
+ elapsed_ms=elapsed_ms,
182
+ working_directory=str(resolved_cwd),
183
+ argv=redacted_argv,
184
+ env_keys=tuple(sorted(env)),
185
+ )
186
+
187
+
188
+ def _failed_result(
189
+ *,
190
+ command_hash: str,
191
+ exit_code: int,
192
+ stderr: str,
193
+ elapsed_ms: int,
194
+ working_directory: Path,
195
+ argv: tuple[str, ...],
196
+ env: dict[str, str],
197
+ redaction_values: frozenset[str],
198
+ output_limit_bytes: int,
199
+ ) -> VerificationResult:
200
+ return VerificationResult(
201
+ command_hash=command_hash,
202
+ exit_code=exit_code,
203
+ passed=False,
204
+ stdout="",
205
+ stderr=_truncate(_redact_persisted_output(stderr, redaction_values), output_limit_bytes),
206
+ elapsed_ms=elapsed_ms,
207
+ working_directory=str(working_directory),
208
+ argv=argv,
209
+ env_keys=tuple(sorted(env)),
210
+ )
211
+
212
+
213
+ def _validate_argv(argv: tuple[str, ...]) -> tuple[str, ...]:
214
+ if not argv:
215
+ raise CommandValidationError("command argv must not be empty")
216
+
217
+ for token in argv:
218
+ if token == "" or SHELL_METACHAR_PATTERN.search(token):
219
+ raise CommandValidationError(
220
+ "shell metacharacters are not allowed in verifier commands"
221
+ )
222
+ if " " in token and not token.startswith("--"):
223
+ raise CommandValidationError("compound shell command strings are not allowed")
224
+ if token in {".", ".."}:
225
+ raise CommandValidationError("path selector arguments are not allowed")
226
+ if "/" in token or "\\" in token:
227
+ raise CommandValidationError("path-like argv entries are not allowed")
228
+
229
+ executable = argv[0]
230
+ if executable == "python" and len(argv) >= 3 and argv[1] == "-m" and argv[2] == "pytest":
231
+ return _validate_pytest_argv(argv)
232
+ if executable == "pnpm" and len(argv) >= 2 and argv[1] in PNPM_COMMANDS:
233
+ return _validate_pnpm_argv(argv)
234
+ if executable == "npm" and argv == ("npm", "--version"):
235
+ return argv
236
+
237
+ raise CommandValidationError(f"command binary or argv shape is not allowed: {executable}")
238
+
239
+
240
+ def _validate_pytest_argv(argv: tuple[str, ...]) -> tuple[str, ...]:
241
+ for token in argv[3:]:
242
+ option, separator, value = token.partition("=")
243
+ if option in {"--rootdir", "--confcutdir", "--basetemp"}:
244
+ raise CommandValidationError("pytest cwd or path selector arguments are not allowed")
245
+ if separator and value in {".", ".."}:
246
+ raise CommandValidationError("pytest path selector arguments are not allowed")
247
+ if token not in PYTEST_SAFE_ARGS:
248
+ raise CommandValidationError("pytest argument is not allowed by verifier schema")
249
+ return argv
250
+
251
+
252
+ def _validate_pnpm_argv(argv: tuple[str, ...]) -> tuple[str, ...]:
253
+ index = 2
254
+ while index < len(argv):
255
+ token = argv[index]
256
+ option, separator, value = token.partition("=")
257
+ if token in PNPM_CWD_OPTIONS or option in PNPM_CWD_OPTIONS:
258
+ raise CommandValidationError("pnpm cwd-changing arguments are not allowed")
259
+ if separator and value in {".", ".."}:
260
+ raise CommandValidationError("pnpm path selector arguments are not allowed")
261
+ if option == "--token" and separator and value:
262
+ index += 1
263
+ continue
264
+ raise CommandValidationError("pnpm argument is not allowed by verifier schema")
265
+ return argv
266
+
267
+
268
+ def _validate_working_directory(working_directory: Path, workspace_root: Path) -> Path:
269
+ if ".." in working_directory.parts:
270
+ raise CommandValidationError("working directory must not contain path traversal")
271
+
272
+ resolved_root = workspace_root.resolve(strict=True)
273
+ resolved_cwd = working_directory.resolve(strict=True)
274
+ if resolved_cwd != resolved_root and resolved_root not in resolved_cwd.parents:
275
+ raise CommandValidationError("working directory must be inside workspace root")
276
+ return resolved_cwd
277
+
278
+
279
+ def _build_env(safe_env: Mapping[str, str] | None) -> tuple[dict[str, str], frozenset[str]]:
280
+ env: dict[str, str] = {}
281
+ path = os.environ.get("PATH")
282
+ if path:
283
+ env["PATH"] = path
284
+
285
+ redaction_values: set[str] = set()
286
+ for key, value in (safe_env or {}).items():
287
+ if SECRET_KEY_PATTERN.search(key):
288
+ if value:
289
+ redaction_values.add(value)
290
+ continue
291
+ if key in SAFE_ENV_KEYS or key.startswith("SAFE_"):
292
+ env[key] = value
293
+
294
+ return env, frozenset(redaction_values)
295
+
296
+
297
+ def _secret_values_from_argv(argv: tuple[str, ...]) -> frozenset[str]:
298
+ values: set[str] = set()
299
+ for token in argv:
300
+ option, separator, value = token.partition("=")
301
+ if separator and SECRET_KEY_PATTERN.search(option) and value:
302
+ values.add(value)
303
+ return frozenset(values)
304
+
305
+
306
+ def _redact_argv(argv: tuple[str, ...]) -> tuple[str, ...]:
307
+ redacted: list[str] = []
308
+ for token in argv:
309
+ option, separator, value = token.partition("=")
310
+ if separator and SECRET_KEY_PATTERN.search(option) and value:
311
+ redacted.append(f"{option}=[REDACTED]")
312
+ else:
313
+ redacted.append(token)
314
+ return tuple(redacted)
315
+
316
+
317
+ def _redact(value: str, secrets: frozenset[str]) -> str:
318
+ redacted = value
319
+ for secret in sorted(secrets, key=len, reverse=True):
320
+ if secret:
321
+ redacted = redacted.replace(secret, "[REDACTED]")
322
+ return redacted
323
+
324
+
325
+ def _redact_persisted_output(value: str, secrets: frozenset[str]) -> str:
326
+ return redact_secret_like_text(_redact(value, secrets))
327
+
328
+
329
+ def _truncate(value: str, limit_bytes: int) -> str:
330
+ encoded = value.encode("utf-8")
331
+ if len(encoded) <= limit_bytes:
332
+ return value
333
+
334
+ prefix = encoded[:limit_bytes].decode("utf-8", errors="ignore")
335
+ return f"{prefix}...[truncated {len(encoded) - limit_bytes} bytes]"
336
+
337
+
338
+ def _command_hash(argv: tuple[str, ...], cwd: Path) -> str:
339
+ payload = json.dumps(
340
+ {"argv": argv, "working_directory": str(cwd)},
341
+ sort_keys=True,
342
+ separators=(",", ":"),
343
+ ).encode("utf-8")
344
+ return hashlib.sha256(payload).hexdigest()