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,921 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import stat
6
+ import subprocess
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+ from urllib.parse import urlsplit
12
+ from uuid import UUID
13
+
14
+ from agentic_runner.integrations.git.contracts import (
15
+ AdoptWorkspaceRequest,
16
+ CheckoutWorkBranchRequest,
17
+ CheckoutWorkBranchResult,
18
+ CloneWorkspaceRequest,
19
+ CloneWorkspaceResult,
20
+ CommitAllRequest,
21
+ CommitAllResult,
22
+ FetchBranchRequest,
23
+ FetchBranchResult,
24
+ PrepareVerifierWorkspaceRequest,
25
+ PrepareVerifierWorkspaceResult,
26
+ PushBranchRequest,
27
+ PushBranchResult,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from agentic_runner.integrations.git.evidence import GitEvidence
32
+
33
+ PROTECTED_BRANCHES = frozenset(("main", "master"))
34
+ _GENERATED_WORK_BRANCH_RE = re.compile(
35
+ r"^agent/(?P<work_record_id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-"
36
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})-"
37
+ r"(?P<slug>[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)$"
38
+ )
39
+ _UNSAFE_BRANCH_TOKENS = (
40
+ "..",
41
+ " ",
42
+ ":",
43
+ "+",
44
+ "~",
45
+ "^",
46
+ "?",
47
+ "*",
48
+ "[",
49
+ "\\",
50
+ "refs/tags",
51
+ )
52
+ _BASE_BRANCH_ALLOWED_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
53
+ _COMMIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
54
+ _REPO_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
55
+ _WORKSPACE_ID_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
56
+ _TOKEN_ENV_KEY = "AGENTIC_OS_GIT_TOKEN"
57
+ _ASKPASS_SCRIPT = """#!/bin/sh
58
+ case "$1" in
59
+ *Username*) printf '%s\\n' 'x-access-token' ;;
60
+ *) printf '%s\\n' "${AGENTIC_OS_GIT_TOKEN}" ;;
61
+ esac
62
+ """
63
+
64
+
65
+ class GitWorkspacePolicyError(ValueError):
66
+ """Raised when a workspace or branch violates local git safety policy."""
67
+
68
+
69
+ class GitWorkspaceCommandError(RuntimeError):
70
+ """Raised when a sanitized local git subprocess fails."""
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class GitCommandResult:
75
+ """Sanitized, bounded public record of one git subprocess invocation."""
76
+
77
+ argv: tuple[str, ...]
78
+ cwd: Path | None
79
+ returncode: int
80
+ stdout: str
81
+ stderr: str
82
+ timed_out: bool = False
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class _LocalWorkspaceRecord:
87
+ repo_full_name: str
88
+ remote_url: str
89
+ workspace_root: Path
90
+ workspace_path: Path
91
+
92
+
93
+ def validate_work_branch(work_branch: str) -> str:
94
+ """Validate that a branch is a generated WorkRecord branch, not a refspec."""
95
+
96
+ if work_branch in PROTECTED_BRANCHES:
97
+ raise GitWorkspacePolicyError(
98
+ f"Protected branch '{work_branch}' cannot be used as work branch"
99
+ )
100
+ if work_branch in {"--all", "--mirror"} or work_branch.startswith("-"):
101
+ raise GitWorkspacePolicyError(f"Refspec-like branch '{work_branch}' is not allowed")
102
+ if work_branch.startswith(("/", ".")) or work_branch.endswith(("/", ".")):
103
+ raise GitWorkspacePolicyError(f"Unsafe branch boundary in '{work_branch}'")
104
+ if any(token in work_branch for token in _UNSAFE_BRANCH_TOKENS):
105
+ raise GitWorkspacePolicyError(f"Unsafe branch content in '{work_branch}'")
106
+
107
+ match = _GENERATED_WORK_BRANCH_RE.fullmatch(work_branch)
108
+ if match is None:
109
+ raise GitWorkspacePolicyError(
110
+ "Work branch must match 'agent/<work_record_id>-<slug>' with a UUID work_record_id"
111
+ )
112
+
113
+ UUID(match.group("work_record_id"))
114
+ return work_branch
115
+
116
+
117
+ def validate_base_branch(base_branch: str) -> str:
118
+ """Validate that a base branch is a safe short branch name, not a ref/revision."""
119
+
120
+ if base_branch == "":
121
+ raise GitWorkspacePolicyError("Base branch cannot be empty")
122
+ if not _BASE_BRANCH_ALLOWED_RE.fullmatch(base_branch):
123
+ raise GitWorkspacePolicyError(f"Unsafe base branch content in '{base_branch}'")
124
+ if any(ord(character) < 32 or ord(character) == 127 for character in base_branch):
125
+ raise GitWorkspacePolicyError(f"Unsafe base branch control character in '{base_branch}'")
126
+ if base_branch in {"--all", "--mirror"} or base_branch.startswith("-"):
127
+ raise GitWorkspacePolicyError(f"Refspec-like base branch '{base_branch}' is not allowed")
128
+ if base_branch == "HEAD" or base_branch.startswith("refs/"):
129
+ raise GitWorkspacePolicyError(f"Ref-like base branch '{base_branch}' is not allowed")
130
+ if base_branch.startswith(("/", ".")) or base_branch.endswith(("/", ".")):
131
+ raise GitWorkspacePolicyError(f"Unsafe base branch boundary in '{base_branch}'")
132
+ if any(token in base_branch for token in ("..", "//")):
133
+ raise GitWorkspacePolicyError(f"Unsafe base branch content in '{base_branch}'")
134
+ if any(component.startswith(".") for component in base_branch.split("/")):
135
+ raise GitWorkspacePolicyError(f"Unsafe base branch path component in '{base_branch}'")
136
+ if any(component.endswith(".lock") for component in base_branch.split("/")):
137
+ raise GitWorkspacePolicyError(f"Unsafe base branch lock suffix in '{base_branch}'")
138
+
139
+ return base_branch
140
+
141
+
142
+ def validate_push_branch(
143
+ *,
144
+ work_branch: str,
145
+ base_branch: str,
146
+ protected_branches: tuple[str, ...] = (),
147
+ ) -> str:
148
+ """Reject pushes to protected or request-base branches before building a refspec."""
149
+
150
+ protected = PROTECTED_BRANCHES | frozenset(protected_branches) | {base_branch}
151
+ if work_branch in protected:
152
+ raise GitWorkspacePolicyError(f"Protected branch '{work_branch}' cannot be pushed")
153
+ return validate_work_branch(work_branch)
154
+
155
+
156
+ def validate_commit_sha(commit_sha: str) -> str:
157
+ """Validate a full commit SHA before using it as a verifier reset target."""
158
+
159
+ if _COMMIT_SHA_RE.fullmatch(commit_sha) is None:
160
+ raise GitWorkspacePolicyError("Approved head must be a full 40-character commit SHA")
161
+ return commit_sha.lower()
162
+
163
+
164
+ def build_push_refspec(work_branch: str) -> str:
165
+ """Build the only allowed push refspec for generated work branches."""
166
+
167
+ safe_branch = validate_work_branch(work_branch)
168
+ return f"HEAD:refs/heads/{safe_branch}"
169
+
170
+
171
+ def contract_workspace_path(
172
+ *,
173
+ workspace_root: Path,
174
+ contract_id: str,
175
+ work_record_id: str,
176
+ ) -> Path:
177
+ """``WORKSPACE_ROOT/{contract_id}/{work_record_id}`` (ADR-0015 §2, PRD issue 30).
178
+
179
+ The Contract owns the Workspace, not the repository: two Contracts' Work Records on
180
+ one repo run concurrently on their own branches, and there is no repository lock —
181
+ conflicts surface at the pull request, as between two human contractors. Replaces the
182
+ ``{owner}/{repo}/{work_record_id}`` layout, which keyed the tree on something that is
183
+ neither a platform id nor an isolation boundary.
184
+
185
+ Both segments are platform ids (map ticket 07); anything else is refused here rather
186
+ than allowed to walk out of the workspace root.
187
+ """
188
+
189
+ for label, segment in (("contract id", contract_id), ("work record id", work_record_id)):
190
+ if not _WORKSPACE_ID_SEGMENT_RE.fullmatch(segment):
191
+ raise GitWorkspacePolicyError(f"{label} is not a safe path segment: {segment!r}")
192
+ return workspace_root / contract_id / work_record_id
193
+
194
+
195
+ GIT_METADATA_DIR = ".git"
196
+
197
+
198
+ def require_runner_owned_git_dir(workspace_path: Path) -> None:
199
+ """Refuse to run the Runner's git over a ``.git`` the Contract could have replaced.
200
+
201
+ ``workers/contract_isolation.hand_workspace_to_contract`` keeps ``.git`` owned by the
202
+ Runner, and that is *not* by itself the boundary: the Contract owns the Workspace
203
+ directory containing it, and on POSIX rename/create/unlink of an entry is governed by
204
+ write+execute on the parent, never by the entry's own ownership. A Directive can
205
+ therefore ``mv .git .git.stash; cp -r .git.stash .git`` and end up owning a ``.git``
206
+ of its own carrying an arbitrary ``hooks/pre-commit``, ``core.fsmonitor`` or
207
+ ``filter.*.clean`` — all of which the Runner's next ``status``/``add``/``commit``
208
+ would execute as the Runner, with CAP_SETUID, CAP_CHOWN and CAP_DAC_OVERRIDE. The
209
+ ownership check that would have caught it is exactly the one ``-c safe.directory``
210
+ suppresses, so it is re-made here, on every Runner git call into a Workspace.
211
+
212
+ A ``lstat``, so a ``.git`` symlinked or replaced by a gitfile pointing at a tree the
213
+ Contract owns is refused too. There is no TOCTOU window worth chasing: the Runner's
214
+ git steps run between Directives, with none of that Contract's processes executing.
215
+ """
216
+
217
+ git_dir = workspace_path / GIT_METADATA_DIR
218
+ try:
219
+ entry = os.lstat(git_dir)
220
+ except OSError as error:
221
+ raise GitWorkspacePolicyError(
222
+ f"Git metadata '{git_dir}' is missing or unreadable"
223
+ ) from error
224
+ if not stat.S_ISDIR(entry.st_mode) or entry.st_uid != os.geteuid():
225
+ raise GitWorkspacePolicyError(
226
+ f"Git metadata '{git_dir}' is not a directory owned by the Runner "
227
+ f"(uid {entry.st_uid}, mode {entry.st_mode:#o}): the Workspace was tampered with"
228
+ )
229
+
230
+
231
+ def resolve_workspace_path(workspace_root: Path, workspace_path: Path) -> Path:
232
+ """Resolve a workspace path and ensure it stays inside the worker workspace root."""
233
+
234
+ resolved_root = workspace_root.resolve()
235
+ resolved_path = workspace_path.resolve()
236
+ if resolved_path != resolved_root and resolved_root not in resolved_path.parents:
237
+ raise GitWorkspacePolicyError(
238
+ f"Workspace path '{resolved_path}' escapes worker workspace root '{resolved_root}'"
239
+ )
240
+ return resolved_path
241
+
242
+
243
+ class LocalGitWorkspace:
244
+ """Subprocess-backed git workspace using tokenless remotes and isolated auth state."""
245
+
246
+ def __init__(
247
+ self,
248
+ *,
249
+ git_token: str | None = None,
250
+ git_token_provider: Callable[[], str | None] | None = None,
251
+ command_timeout_seconds: int = 30,
252
+ output_limit_bytes: int = 4096,
253
+ allow_local_file_remotes: bool = False,
254
+ ) -> None:
255
+ if command_timeout_seconds <= 0:
256
+ raise ValueError("command_timeout_seconds must be positive")
257
+ if output_limit_bytes <= 0:
258
+ raise ValueError("output_limit_bytes must be positive")
259
+ if git_token is not None and git_token_provider is not None:
260
+ raise ValueError("Provide either git_token or git_token_provider, not both")
261
+ self._static_git_token = git_token
262
+ self._git_token_provider = git_token_provider
263
+ self._has_token_auth = git_token is not None or git_token_provider is not None
264
+ self._last_resolved_token: str | None = None
265
+ self._command_timeout_seconds = command_timeout_seconds
266
+ self._output_limit_bytes = output_limit_bytes
267
+ self._allow_local_file_remotes = allow_local_file_remotes
268
+ self._command_results: list[GitCommandResult] = []
269
+ self._workspaces: dict[Path, _LocalWorkspaceRecord] = {}
270
+
271
+ def _resolve_git_token(self) -> str | None:
272
+ """Resolve the current git token, minting a fresh one per call when a provider is set."""
273
+
274
+ if self._git_token_provider is not None:
275
+ token = self._git_token_provider()
276
+ else:
277
+ token = self._static_git_token
278
+ self._last_resolved_token = token or None
279
+ return self._last_resolved_token
280
+
281
+ @property
282
+ def command_results(self) -> tuple[GitCommandResult, ...]:
283
+ return tuple(self._command_results)
284
+
285
+ def clone_repository(self, request: CloneWorkspaceRequest) -> CloneWorkspaceResult:
286
+ _validate_tokenless_remote_url(
287
+ request.remote_url,
288
+ repo_full_name=request.repo_full_name,
289
+ require_github_match=self._has_token_auth,
290
+ allow_local_file_remotes=self._allow_local_file_remotes,
291
+ )
292
+ workspace_path = resolve_workspace_path(request.workspace_root, request.workspace_path)
293
+ workspace_root = request.workspace_root.resolve()
294
+ workspace_root.mkdir(parents=True, exist_ok=True)
295
+ workspace_path.parent.mkdir(parents=True, exist_ok=True)
296
+ record = _LocalWorkspaceRecord(
297
+ repo_full_name=request.repo_full_name,
298
+ remote_url=request.remote_url,
299
+ workspace_root=workspace_root,
300
+ workspace_path=workspace_path,
301
+ )
302
+ if workspace_path.exists():
303
+ record = self._rehydrate_existing_clone(record)
304
+ return CloneWorkspaceResult(
305
+ repo_full_name=record.repo_full_name,
306
+ remote_url=record.remote_url,
307
+ workspace_root=record.workspace_root,
308
+ workspace_path=record.workspace_path,
309
+ )
310
+
311
+ self._run_git(
312
+ ["clone", "--no-checkout", request.remote_url, str(workspace_path)],
313
+ cwd=None,
314
+ record=record,
315
+ )
316
+ self._workspaces[workspace_path] = record
317
+ return CloneWorkspaceResult(
318
+ repo_full_name=request.repo_full_name,
319
+ remote_url=request.remote_url,
320
+ workspace_root=workspace_root,
321
+ workspace_path=workspace_path,
322
+ )
323
+
324
+ def _rehydrate_existing_clone(self, record: _LocalWorkspaceRecord) -> _LocalWorkspaceRecord:
325
+ try:
326
+ is_work_tree = self._run_git(
327
+ ["rev-parse", "--is-inside-work-tree"],
328
+ cwd=record.workspace_path,
329
+ record=record,
330
+ ).stdout.strip()
331
+ top_level = self._run_git(
332
+ ["rev-parse", "--show-toplevel"],
333
+ cwd=record.workspace_path,
334
+ record=record,
335
+ ).stdout.strip()
336
+ origin_url = self._run_git(
337
+ ["config", "--get", "remote.origin.url"],
338
+ cwd=record.workspace_path,
339
+ record=record,
340
+ ).stdout.strip()
341
+ except GitWorkspaceCommandError as error:
342
+ raise GitWorkspacePolicyError(
343
+ f"Existing clone workspace '{record.workspace_path}' is not a git work tree"
344
+ ) from error
345
+
346
+ if is_work_tree != "true" or Path(top_level).resolve() != record.workspace_path:
347
+ raise GitWorkspacePolicyError(
348
+ f"Existing clone workspace '{record.workspace_path}' is not a git work tree root"
349
+ )
350
+ if origin_url != record.remote_url:
351
+ raise GitWorkspacePolicyError(
352
+ "Existing clone workspace origin does not match expected origin"
353
+ )
354
+
355
+ self._workspaces[record.workspace_path] = record
356
+ return record
357
+
358
+ def adopt_existing_clone(self, request: AdoptWorkspaceRequest) -> CloneWorkspaceResult:
359
+ work_branch = validate_work_branch(request.work_branch)
360
+ record = self._rehydrate_existing_clone(
361
+ _LocalWorkspaceRecord(
362
+ repo_full_name=request.repo_full_name,
363
+ remote_url=request.remote_url,
364
+ workspace_root=request.workspace_root.resolve(),
365
+ workspace_path=resolve_workspace_path(
366
+ request.workspace_root, request.workspace_path
367
+ ),
368
+ )
369
+ )
370
+ self._require_current_branch(record, work_branch, operation="adopt")
371
+ return CloneWorkspaceResult(
372
+ repo_full_name=record.repo_full_name,
373
+ remote_url=record.remote_url,
374
+ workspace_root=record.workspace_root,
375
+ workspace_path=record.workspace_path,
376
+ )
377
+
378
+ def fetch_base_branch(self, request: FetchBranchRequest) -> FetchBranchResult:
379
+ base_branch = validate_base_branch(request.base_branch)
380
+ record = self._get_workspace(request.workspace_path)
381
+ self._require_same_repo(record, request.repo_full_name)
382
+ self._run_git(
383
+ ["fetch", "origin", f"{base_branch}:refs/remotes/origin/{base_branch}"],
384
+ cwd=record.workspace_path,
385
+ record=record,
386
+ )
387
+ return FetchBranchResult(
388
+ repo_full_name=request.repo_full_name,
389
+ workspace_path=record.workspace_path,
390
+ base_branch=base_branch,
391
+ )
392
+
393
+ def checkout_work_branch(
394
+ self,
395
+ request: CheckoutWorkBranchRequest,
396
+ ) -> CheckoutWorkBranchResult:
397
+ base_branch = validate_base_branch(request.base_branch)
398
+ work_branch = validate_work_branch(request.work_branch)
399
+ record = self._get_workspace(request.workspace_path)
400
+ self._require_same_repo(record, request.repo_full_name)
401
+ checkout_ref = f"refs/remotes/origin/{base_branch}"
402
+ if self._remote_work_branch_exists(record, work_branch):
403
+ checkout_ref = f"refs/remotes/origin/{work_branch}"
404
+ self._run_git(
405
+ ["fetch", "origin", f"{work_branch}:{checkout_ref}"],
406
+ cwd=record.workspace_path,
407
+ record=record,
408
+ )
409
+ self._run_git(
410
+ ["checkout", "-B", work_branch, checkout_ref],
411
+ cwd=record.workspace_path,
412
+ record=record,
413
+ )
414
+ return CheckoutWorkBranchResult(
415
+ repo_full_name=request.repo_full_name,
416
+ workspace_path=record.workspace_path,
417
+ base_branch=base_branch,
418
+ work_branch=work_branch,
419
+ )
420
+
421
+ def _remote_work_branch_exists(
422
+ self,
423
+ record: _LocalWorkspaceRecord,
424
+ work_branch: str,
425
+ ) -> bool:
426
+ try:
427
+ self._run_git(
428
+ ["ls-remote", "--exit-code", "origin", f"refs/heads/{work_branch}"],
429
+ cwd=record.workspace_path,
430
+ record=record,
431
+ )
432
+ except GitWorkspaceCommandError:
433
+ latest_result = self._command_results[-1]
434
+ if latest_result.returncode == 2:
435
+ return False
436
+ raise
437
+ return True
438
+
439
+ def commit_all(self, request: CommitAllRequest) -> CommitAllResult:
440
+ work_branch = validate_work_branch(request.work_branch)
441
+ record = self._get_workspace(request.workspace_path)
442
+ self._require_same_repo(record, request.repo_full_name)
443
+ self._require_current_branch(record, work_branch, operation="commit")
444
+
445
+ self._run_git(["add", "--all"], cwd=record.workspace_path, record=record)
446
+ status = self._run_git(["status", "--porcelain"], cwd=record.workspace_path, record=record)
447
+ if status.stdout == "":
448
+ commit_id = self._run_git(
449
+ ["rev-parse", "HEAD"],
450
+ cwd=record.workspace_path,
451
+ record=record,
452
+ ).stdout.strip()
453
+ return CommitAllResult(
454
+ repo_full_name=request.repo_full_name,
455
+ workspace_path=record.workspace_path,
456
+ work_branch=work_branch,
457
+ commit_message=request.commit_message,
458
+ author_name=request.author_name,
459
+ author_email=request.author_email,
460
+ commit_id=commit_id,
461
+ )
462
+ self._run_git(
463
+ [
464
+ "-c",
465
+ f"user.name={request.author_name}",
466
+ "-c",
467
+ f"user.email={request.author_email}",
468
+ "commit",
469
+ "-m",
470
+ request.commit_message,
471
+ "--author",
472
+ f"{request.author_name} <{request.author_email}>",
473
+ ],
474
+ cwd=record.workspace_path,
475
+ record=record,
476
+ )
477
+ commit_id = self._run_git(
478
+ ["rev-parse", "HEAD"],
479
+ cwd=record.workspace_path,
480
+ record=record,
481
+ ).stdout.strip()
482
+ return CommitAllResult(
483
+ repo_full_name=request.repo_full_name,
484
+ workspace_path=record.workspace_path,
485
+ work_branch=work_branch,
486
+ commit_message=request.commit_message,
487
+ author_name=request.author_name,
488
+ author_email=request.author_email,
489
+ commit_id=commit_id,
490
+ )
491
+
492
+ def push_branch(self, request: PushBranchRequest) -> PushBranchResult:
493
+ validate_push_branch(
494
+ work_branch=request.work_branch,
495
+ base_branch=request.base_branch,
496
+ protected_branches=request.protected_branches,
497
+ )
498
+ record = self._get_workspace(request.workspace_path)
499
+ self._require_same_repo(record, request.repo_full_name)
500
+ self._require_current_branch(record, request.work_branch, operation="push")
501
+ commit_id = self._run_git(
502
+ ["rev-parse", "HEAD"],
503
+ cwd=record.workspace_path,
504
+ record=record,
505
+ ).stdout.strip()
506
+ refspec = build_push_refspec(request.work_branch)
507
+ self._run_git(["push", "origin", refspec], cwd=record.workspace_path, record=record)
508
+ return PushBranchResult(
509
+ repo_full_name=request.repo_full_name,
510
+ workspace_path=record.workspace_path,
511
+ work_branch=request.work_branch,
512
+ refspec=refspec,
513
+ commit_id=commit_id,
514
+ )
515
+
516
+ def prepare_verifier_workspace(
517
+ self,
518
+ request: PrepareVerifierWorkspaceRequest,
519
+ ) -> PrepareVerifierWorkspaceResult:
520
+ work_branch = validate_work_branch(request.work_branch)
521
+ approved_head_sha = validate_commit_sha(request.approved_head_sha)
522
+ record = self._get_or_rehydrate_verifier_workspace(
523
+ workspace_root=request.workspace_root,
524
+ workspace_path=request.workspace_path,
525
+ repo_full_name=request.repo_full_name,
526
+ )
527
+ self._require_same_repo(record, request.repo_full_name)
528
+ self._require_clean_verifier_workspace(record)
529
+
530
+ remote_ref = f"refs/remotes/origin/{work_branch}"
531
+ self._run_git(
532
+ ["fetch", "origin", f"{work_branch}:{remote_ref}"],
533
+ cwd=record.workspace_path,
534
+ record=record,
535
+ )
536
+ fetched_head_sha = (
537
+ self._run_git(
538
+ ["rev-parse", remote_ref],
539
+ cwd=record.workspace_path,
540
+ record=record,
541
+ )
542
+ .stdout.strip()
543
+ .lower()
544
+ )
545
+ if fetched_head_sha != approved_head_sha:
546
+ raise GitWorkspacePolicyError(
547
+ f"Fetched work branch head '{fetched_head_sha}' does not match "
548
+ f"approved head '{approved_head_sha}'"
549
+ )
550
+
551
+ local_head_sha = self._local_head_sha_or_none(record)
552
+ if local_head_sha is not None and local_head_sha != approved_head_sha:
553
+ raise GitWorkspacePolicyError(
554
+ f"Verifier workspace local HEAD '{local_head_sha}' does not match "
555
+ f"approved head '{approved_head_sha}' before verifier reset"
556
+ )
557
+
558
+ self._run_git(
559
+ ["checkout", "-B", work_branch, approved_head_sha],
560
+ cwd=record.workspace_path,
561
+ record=record,
562
+ )
563
+ self._run_git(
564
+ ["reset", "--hard", approved_head_sha],
565
+ cwd=record.workspace_path,
566
+ record=record,
567
+ )
568
+ attested_head_sha = (
569
+ self._run_git(
570
+ ["rev-parse", "HEAD"],
571
+ cwd=record.workspace_path,
572
+ record=record,
573
+ )
574
+ .stdout.strip()
575
+ .lower()
576
+ )
577
+ if attested_head_sha != approved_head_sha:
578
+ raise GitWorkspacePolicyError(
579
+ f"Verifier workspace HEAD '{attested_head_sha}' does not match "
580
+ f"approved head '{approved_head_sha}'"
581
+ )
582
+
583
+ status_summary = self._require_clean_verifier_workspace(record)
584
+
585
+ return PrepareVerifierWorkspaceResult(
586
+ repo_full_name=request.repo_full_name,
587
+ workspace_root=record.workspace_root,
588
+ workspace_path=record.workspace_path,
589
+ work_branch=work_branch,
590
+ approved_head_sha=approved_head_sha,
591
+ attested_head_sha=attested_head_sha,
592
+ status_summary=status_summary,
593
+ )
594
+
595
+ def collect_git_evidence(
596
+ self,
597
+ workspace_path: Path,
598
+ output_limit_bytes: int | None = None,
599
+ ) -> GitEvidence:
600
+ from agentic_runner.integrations.git.evidence import collect_git_evidence
601
+
602
+ record = self._get_workspace(workspace_path)
603
+ return collect_git_evidence(
604
+ workspace_path=record.workspace_path,
605
+ workspace_root=record.workspace_root,
606
+ output_limit_bytes=output_limit_bytes or self._output_limit_bytes,
607
+ command_timeout_seconds=self._command_timeout_seconds,
608
+ )
609
+
610
+ def _get_workspace(self, workspace_path: Path) -> _LocalWorkspaceRecord:
611
+ resolved_path = workspace_path.resolve()
612
+ try:
613
+ record = self._workspaces[resolved_path]
614
+ except KeyError as error:
615
+ raise GitWorkspacePolicyError(
616
+ f"Git workspace '{resolved_path}' was not cloned"
617
+ ) from error
618
+ resolve_workspace_path(record.workspace_root, workspace_path)
619
+ return record
620
+
621
+ def _get_or_rehydrate_verifier_workspace(
622
+ self,
623
+ *,
624
+ workspace_root: Path,
625
+ workspace_path: Path,
626
+ repo_full_name: str,
627
+ ) -> _LocalWorkspaceRecord:
628
+ resolved_path = resolve_workspace_path(workspace_root, workspace_path)
629
+ try:
630
+ record = self._workspaces[resolved_path]
631
+ except KeyError:
632
+ record = self._rehydrate_verifier_workspace(
633
+ repo_full_name=repo_full_name,
634
+ workspace_root=workspace_root,
635
+ workspace_path=resolved_path,
636
+ )
637
+ else:
638
+ resolve_workspace_path(record.workspace_root, workspace_path)
639
+ return record
640
+
641
+ def _rehydrate_verifier_workspace(
642
+ self,
643
+ *,
644
+ repo_full_name: str,
645
+ workspace_root: Path,
646
+ workspace_path: Path,
647
+ ) -> _LocalWorkspaceRecord:
648
+ expected_remote_url = _expected_github_remote_url(repo_full_name)
649
+ record = _LocalWorkspaceRecord(
650
+ repo_full_name=repo_full_name,
651
+ remote_url=expected_remote_url,
652
+ workspace_root=workspace_root.resolve(),
653
+ workspace_path=workspace_path,
654
+ )
655
+ if not record.workspace_path.exists():
656
+ raise GitWorkspacePolicyError(
657
+ f"Existing verifier workspace '{record.workspace_path}' does not exist"
658
+ )
659
+ try:
660
+ is_work_tree = self._run_git(
661
+ ["rev-parse", "--is-inside-work-tree"],
662
+ cwd=record.workspace_path,
663
+ record=record,
664
+ ).stdout.strip()
665
+ top_level = self._run_git(
666
+ ["rev-parse", "--show-toplevel"],
667
+ cwd=record.workspace_path,
668
+ record=record,
669
+ ).stdout.strip()
670
+ origin_url = self._run_git(
671
+ ["config", "--get", "remote.origin.url"],
672
+ cwd=record.workspace_path,
673
+ record=record,
674
+ ).stdout.strip()
675
+ except GitWorkspaceCommandError as error:
676
+ raise GitWorkspacePolicyError(
677
+ f"Existing verifier workspace '{record.workspace_path}' is not a git work tree"
678
+ ) from error
679
+
680
+ if is_work_tree != "true" or Path(top_level).resolve() != record.workspace_path:
681
+ raise GitWorkspacePolicyError(
682
+ f"Existing verifier workspace '{record.workspace_path}' is not a git work tree root"
683
+ )
684
+ if origin_url != expected_remote_url:
685
+ raise GitWorkspacePolicyError(
686
+ f"Existing verifier workspace origin '{origin_url}' does not match expected "
687
+ f"origin '{expected_remote_url}'"
688
+ )
689
+ _validate_tokenless_remote_url(
690
+ origin_url,
691
+ repo_full_name=repo_full_name,
692
+ require_github_match=True,
693
+ allow_local_file_remotes=False,
694
+ )
695
+
696
+ self._workspaces[record.workspace_path] = record
697
+ return record
698
+
699
+ def _require_same_repo(self, record: _LocalWorkspaceRecord, repo_full_name: str) -> None:
700
+ if record.repo_full_name != repo_full_name:
701
+ raise GitWorkspacePolicyError(
702
+ f"Workspace repo '{record.repo_full_name}' does not match request repo "
703
+ f"'{repo_full_name}'"
704
+ )
705
+
706
+ def _require_current_branch(
707
+ self,
708
+ record: _LocalWorkspaceRecord,
709
+ work_branch: str,
710
+ *,
711
+ operation: str,
712
+ ) -> None:
713
+ current_branch = self._run_git(
714
+ ["branch", "--show-current"],
715
+ cwd=record.workspace_path,
716
+ record=record,
717
+ ).stdout.strip()
718
+ if current_branch != work_branch:
719
+ raise GitWorkspacePolicyError(
720
+ f"Cannot {operation} branch '{work_branch}' while current branch is "
721
+ f"'{current_branch}'"
722
+ )
723
+
724
+ def _require_clean_verifier_workspace(self, record: _LocalWorkspaceRecord) -> str:
725
+ status_summary = self._run_git(
726
+ ["status", "--short"],
727
+ cwd=record.workspace_path,
728
+ record=record,
729
+ ).stdout
730
+ if status_summary:
731
+ raise GitWorkspacePolicyError(
732
+ "Verifier workspace is dirty immediately before verifier execution: "
733
+ f"{status_summary!r}"
734
+ )
735
+ return status_summary
736
+
737
+ def _local_head_sha_or_none(self, record: _LocalWorkspaceRecord) -> str | None:
738
+ try:
739
+ return (
740
+ self._run_git(
741
+ ["rev-parse", "--verify", "HEAD"],
742
+ cwd=record.workspace_path,
743
+ record=record,
744
+ )
745
+ .stdout.strip()
746
+ .lower()
747
+ )
748
+ except GitWorkspaceCommandError:
749
+ return None
750
+
751
+ def _run_git(
752
+ self,
753
+ args: list[str],
754
+ *,
755
+ cwd: Path | None,
756
+ record: _LocalWorkspaceRecord,
757
+ ) -> GitCommandResult:
758
+ # Two settings guard the same seam: the Runner runs git as its own uid over a
759
+ # worktree owned by a Contract's uid (ADR-0015 §1 — the Runner is the only thing
760
+ # that may touch the git credential, so the clone/fetch/commit/push half stays
761
+ # here while the Directive runs as the Contract).
762
+ #
763
+ # `safe.directory` names this one workspace and never `*`. Without it git refuses
764
+ # every command in that tree with "detected dubious ownership"; with `*` it would
765
+ # also trust every *other* repository it meets, including one the Contract made.
766
+ # It also suppresses the one check that would notice the Contract swapping the
767
+ # whole `.git` out from under the Runner (it owns the directory that contains
768
+ # it), so `require_runner_owned_git_dir` re-makes that check below before every
769
+ # call into a Workspace: what git reads as commands there (hooks,
770
+ # `core.fsmonitor`, `filter.*.clean`) has to be the Runner's own.
771
+ #
772
+ # `diff.ignoreSubmodules=dirty` covers the way back in. A Directive can `git init`
773
+ # a directory inside its checkout, and once `git add --all` records that as a
774
+ # gitlink the Runner's next `status`/`diff` descends into it to ask whether it is
775
+ # dirty — which runs a git *inside* it, reading the config the Contract owns:
776
+ # `core.fsmonitor` and `filter.*.clean` there both execute as the Runner. The
777
+ # ownership check does not fire on that path (a submodule is opened from the
778
+ # superproject, not discovered from a directory), so the descent itself is what
779
+ # has to stop; `git status` honours this through `git_diff_ui_config`. `dirty`
780
+ # rather than `all`: it drops exactly the worktree question that needs the
781
+ # descent, while a gitlink still shows up and still commits — with `all`, `git
782
+ # commit` reports "nothing to commit" for a gitlink-only change and exits 1.
783
+ # Nothing real is lost: the Runner clones `--no-checkout` and never initialises a
784
+ # submodule, so it has no submodule worktree to report on.
785
+ argv = [
786
+ "git",
787
+ "-c",
788
+ "credential.helper=",
789
+ "-c",
790
+ f"safe.directory={record.workspace_path}",
791
+ "-c",
792
+ "diff.ignoreSubmodules=dirty",
793
+ *args,
794
+ ]
795
+ if cwd is not None:
796
+ require_runner_owned_git_dir(record.workspace_path)
797
+ env = self._git_env(record.workspace_root)
798
+ try:
799
+ completed = subprocess.run(
800
+ argv,
801
+ cwd=cwd,
802
+ env=env,
803
+ shell=False,
804
+ capture_output=True,
805
+ text=True,
806
+ timeout=self._command_timeout_seconds,
807
+ check=False,
808
+ )
809
+ except subprocess.TimeoutExpired as error:
810
+ result = GitCommandResult(
811
+ argv=tuple(argv),
812
+ cwd=cwd,
813
+ returncode=-1,
814
+ stdout=self._sanitize_output(error.stdout),
815
+ stderr=self._sanitize_output(error.stderr),
816
+ timed_out=True,
817
+ )
818
+ self._command_results.append(result)
819
+ raise GitWorkspaceCommandError(
820
+ f"Git command timed out after {self._command_timeout_seconds}s: "
821
+ f"{_format_safe_argv(argv)}"
822
+ ) from None
823
+
824
+ result = GitCommandResult(
825
+ argv=tuple(argv),
826
+ cwd=cwd,
827
+ returncode=completed.returncode,
828
+ stdout=self._sanitize_output(completed.stdout),
829
+ stderr=self._sanitize_output(completed.stderr),
830
+ )
831
+ self._command_results.append(result)
832
+ if completed.returncode != 0:
833
+ raise GitWorkspaceCommandError(
834
+ f"Git command failed with exit code {completed.returncode}: "
835
+ f"{_format_safe_argv(argv)}; stderr={result.stderr!r}"
836
+ ) from None
837
+ return result
838
+
839
+ def _git_env(self, workspace_root: Path) -> dict[str, str]:
840
+ home = workspace_root / ".agentic-os-git-home"
841
+ home.mkdir(mode=0o700, parents=True, exist_ok=True)
842
+ askpass_path = home / "askpass.sh"
843
+ if not askpass_path.exists() or askpass_path.read_text(encoding="utf-8") != _ASKPASS_SCRIPT:
844
+ askpass_path.write_text(_ASKPASS_SCRIPT, encoding="utf-8")
845
+ askpass_path.chmod(0o700)
846
+
847
+ env = _base_git_subprocess_env()
848
+ env["GIT_TERMINAL_PROMPT"] = "0"
849
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
850
+ env["HOME"] = str(home)
851
+ env["GIT_ASKPASS"] = str(askpass_path)
852
+ token = self._resolve_git_token()
853
+ if token:
854
+ env[_TOKEN_ENV_KEY] = token
855
+ return env
856
+
857
+ def _sanitize_output(self, output: str | bytes | None) -> str:
858
+ if output is None:
859
+ return ""
860
+ text = output.decode(errors="replace") if isinstance(output, bytes) else output
861
+ token = self._last_resolved_token
862
+ if token:
863
+ text = text.replace(token, "[REDACTED]")
864
+ return text[: self._output_limit_bytes]
865
+
866
+
867
+ def _base_git_subprocess_env() -> dict[str, str]:
868
+ env: dict[str, str] = {}
869
+ for key in ("PATH", "LANG", "LC_ALL", "LC_CTYPE"):
870
+ value = os.environ.get(key)
871
+ if value is not None:
872
+ env[key] = value
873
+ return env
874
+
875
+
876
+ def _validate_tokenless_remote_url(
877
+ remote_url: str,
878
+ *,
879
+ repo_full_name: str,
880
+ require_github_match: bool,
881
+ allow_local_file_remotes: bool,
882
+ ) -> None:
883
+ if Path(remote_url).is_absolute():
884
+ if allow_local_file_remotes:
885
+ return
886
+ raise GitWorkspacePolicyError(
887
+ "Local absolute remotes are only allowed when local file remotes are enabled"
888
+ )
889
+
890
+ parsed = urlsplit(remote_url)
891
+ if parsed.scheme != "https" or not parsed.netloc:
892
+ raise GitWorkspacePolicyError("Remote URL must be a tokenless HTTPS URL")
893
+ if parsed.username is not None or parsed.password is not None:
894
+ raise GitWorkspacePolicyError("Remote URL must be tokenless and must not contain userinfo")
895
+ if parsed.query or parsed.fragment:
896
+ raise GitWorkspacePolicyError("Remote URL must not contain query or fragment components")
897
+ if require_github_match:
898
+ expected_path = _validated_github_repo_path(repo_full_name)
899
+ if parsed.hostname != "github.com" or parsed.path != expected_path:
900
+ raise GitWorkspacePolicyError(
901
+ "Tokenized git operations require a github.com remote matching repo_full_name"
902
+ )
903
+
904
+
905
+ def _validated_github_repo_path(repo_full_name: str) -> str:
906
+ parts = repo_full_name.split("/")
907
+ if len(parts) != 2:
908
+ raise GitWorkspacePolicyError("repo_full_name must be an owner/repo name")
909
+ if any(
910
+ part in {"", ".", ".."} or _REPO_PATH_SEGMENT_RE.fullmatch(part) is None for part in parts
911
+ ):
912
+ raise GitWorkspacePolicyError("repo_full_name must contain safe owner/repo segments")
913
+ return f"/{parts[0]}/{parts[1]}.git"
914
+
915
+
916
+ def _expected_github_remote_url(repo_full_name: str) -> str:
917
+ return f"https://github.com{_validated_github_repo_path(repo_full_name)}"
918
+
919
+
920
+ def _format_safe_argv(argv: list[str]) -> str:
921
+ return " ".join(argv)