outerloop-science 0.1.0.dev0__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 (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/github.py ADDED
@@ -0,0 +1,1486 @@
1
+ """GitHub operations for the orchestrator: REST and git, behind a token provider.
2
+
3
+ Auth flows through :class:`TokenProvider` so a GitHub App installation-token
4
+ provider can replace the PAT file without touching callers
5
+ (docs/design/external.md). Every mutating operation respects ``dry_run``: log
6
+ the intent, touch nothing.
7
+
8
+ Credential rules enforced here:
9
+ - Tokens never appear in process arguments or in ``.git/config``; git auth is
10
+ injected per-invocation via ``GIT_CONFIG_*`` environment variables.
11
+ - Only network git subcommands get that environment. Local subcommands run
12
+ token-free with hooks disabled, so repo content written by an agent session
13
+ (hooks, filters) can never read the credential.
14
+ - Credentialed invocations additionally neutralize every git setting that can
15
+ spawn a child process (ssh command, credential helpers, alternate
16
+ protocols), because the session owns the clone's ``.git/config``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import contextlib
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import stat
28
+ import subprocess
29
+ import urllib.error
30
+ import urllib.parse
31
+ import urllib.request
32
+ from collections.abc import Callable
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any, Protocol
36
+
37
+ from outerloop.contract import CONTRACT_NAMES, find_contract
38
+ from outerloop.markers import legacy_marker, marker
39
+
40
+ DEFAULT_BOT_LOGIN = "agentic-learning-bot"
41
+
42
+
43
+ def bot_login_from_env(default: str = DEFAULT_BOT_LOGIN) -> str:
44
+ """The login the kernel posts and pushes as — the bot ACCOUNT under a PAT,
45
+ the App's `<slug>[bot]` under App auth (docs/design/github-app-auth.md).
46
+ `AUTORESEARCH_BOT_LOGIN` sets it for every role at once (the tick exports
47
+ it, jobs inherit it); every own-comment filter, alarm-issue lookup and
48
+ intake-claim scan keys on this string, so it must follow the credential."""
49
+ return os.environ.get("AUTORESEARCH_BOT_LOGIN", "").strip() or default
50
+
51
+
52
+ def bot_aliases_from_env() -> tuple[str, ...]:
53
+ """Former logins the kernel posted as (comma-separated
54
+ `AUTORESEARCH_BOT_ALIASES`). Every issue, claim, alarm and PR created
55
+ before an identity flip carries the OLD login; recognition stays keyed on
56
+ login — never on the public markers, which anyone can paste — so the set
57
+ of our logins is what a flip must widen (live: after the App flip the
58
+ intake lane claimed the kernel's own research-log issue, authored by the
59
+ PAT account)."""
60
+ raw = os.environ.get("AUTORESEARCH_BOT_ALIASES", "")
61
+ return tuple(dict.fromkeys(a.strip() for a in raw.split(",") if a.strip()))
62
+
63
+
64
+ def is_own_login(login: str, bot_login: str) -> bool:
65
+ """Whether `login` is one of the kernel's identities: the login it posts
66
+ as now, or a former one. Blank never matches (a blank identity must fail
67
+ closed at every gate, as before)."""
68
+ who = login.strip().casefold()
69
+ if not who or not bot_login.strip():
70
+ return False # no identity of our own = no identity at all (aliases included)
71
+ ours = {bot_login.strip().casefold()} | {a.casefold() for a in bot_aliases_from_env()}
72
+ ours.discard("")
73
+ return who in ours
74
+
75
+
76
+ log = logging.getLogger(__name__)
77
+
78
+ API = "https://api.github.com"
79
+ NETWORK_GIT_COMMANDS = frozenset({"clone", "fetch", "pull", "push", "ls-remote"})
80
+ # a remote that accepts the connection but never finishes must not hang the
81
+ # wake forever (the run would sit blocked until Slurm's walltime kill)
82
+ NETWORK_GIT_TIMEOUT_S = 900
83
+ # Settings a session could add to .git/config that make git spawn a child
84
+ # process; neutralized on every credentialed invocation.
85
+ SAFE_GIT_FLAGS = (
86
+ "-c",
87
+ "core.hooksPath=/dev/null",
88
+ "-c",
89
+ "core.sshCommand=false",
90
+ "-c",
91
+ "credential.helper=",
92
+ "-c",
93
+ "protocol.allow=never",
94
+ "-c",
95
+ "protocol.https.allow=always",
96
+ "-c",
97
+ "protocol.file.allow=always",
98
+ "-c",
99
+ "core.fsmonitor=",
100
+ "-c",
101
+ "core.quotePath=false",
102
+ )
103
+ Transport = Callable[[urllib.request.Request], Any]
104
+
105
+
106
+ class GitHubError(RuntimeError):
107
+ """A GitHub API call failed."""
108
+
109
+ def __init__(self, status: int, path: str, message: str) -> None:
110
+ super().__init__(f"{status} on {path}: {message}")
111
+ self.status = status
112
+ self.path = path
113
+
114
+
115
+ class GitError(RuntimeError):
116
+ """A git subcommand failed; carries git's own explanation."""
117
+
118
+
119
+ class NothingToCommit(GitError):
120
+ """Nothing staged — an ordinary outcome, not a crash."""
121
+
122
+
123
+ class ForbiddenPathError(GitError):
124
+ """A commit tried to stage a path the contract forbids."""
125
+
126
+
127
+ class TokenProvider(Protocol):
128
+ def token(self) -> str: ...
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class EnvTokenProvider:
133
+ """Reads a token from an environment variable (CI-supplied credentials)."""
134
+
135
+ variable: str
136
+
137
+ def token(self) -> str:
138
+ token = os.environ.get(self.variable, "").strip()
139
+ if not token:
140
+ raise ValueError(f"{self.variable} is unset or empty")
141
+ return token
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class FileTokenProvider:
146
+ """Reads a credential file (the bot PAT on the orchestrator host)."""
147
+
148
+ path: Path
149
+
150
+ def token(self) -> str:
151
+ if not self.path.is_file():
152
+ raise ValueError(f"{self.path} is not a readable credential file")
153
+ mode = self.path.stat().st_mode & 0o077
154
+ if mode:
155
+ raise PermissionError(f"{self.path} is group/world accessible; chmod 600 it")
156
+ token = self.path.read_text().strip()
157
+ if not token:
158
+ raise ValueError(f"{self.path} is empty")
159
+ return token
160
+
161
+
162
+ class _NoAuthRedirect(urllib.request.HTTPRedirectHandler):
163
+ """Drop the Authorization header when a redirect changes host."""
164
+
165
+ def redirect_request(
166
+ self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
167
+ ) -> Any:
168
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
169
+ old_parts = urllib.parse.urlparse(req.full_url)
170
+ new_parts = urllib.parse.urlparse(newurl)
171
+ if new is not None and (
172
+ new_parts.netloc != old_parts.netloc or new_parts.scheme != old_parts.scheme
173
+ ):
174
+ for header in ("Authorization", "authorization"):
175
+ new.headers.pop(header, None)
176
+ new.unredirected_hdrs.pop(header, None)
177
+ return new
178
+
179
+
180
+ # The one opener every API call shares — a redirect that changes host loses
181
+ # the Authorization header instead of forwarding the credential.
182
+ AUTH_SAFE_OPENER = urllib.request.build_opener(_NoAuthRedirect)
183
+
184
+
185
+ def _raw_transport(request: urllib.request.Request) -> str:
186
+ """Fetch a non-JSON body (the diff media type returns text/plain)."""
187
+ try:
188
+ with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
189
+ return str(response.read().decode(errors="replace"))
190
+ except urllib.error.HTTPError as exc:
191
+ body = exc.read().decode(errors="replace")[:500]
192
+ raise GitHubError(exc.code, urllib.parse.urlparse(request.full_url).path, body) from None
193
+ except urllib.error.URLError as exc:
194
+ raise GitHubError(
195
+ 0, urllib.parse.urlparse(request.full_url).path, str(exc.reason)
196
+ ) from None
197
+
198
+
199
+ def _default_transport(request: urllib.request.Request) -> Any:
200
+ try:
201
+ with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
202
+ payload = response.read()
203
+ except urllib.error.HTTPError as exc:
204
+ body = exc.read().decode(errors="replace")[:500]
205
+ raise GitHubError(exc.code, urllib.parse.urlparse(request.full_url).path, body) from None
206
+ except urllib.error.URLError as exc:
207
+ raise GitHubError(
208
+ 0, urllib.parse.urlparse(request.full_url).path, str(exc.reason)
209
+ ) from None
210
+ return json.loads(payload) if payload else None
211
+
212
+
213
+ @dataclass
214
+ class GitHubClient:
215
+ """Minimal REST surface the orchestrator needs. Mutations honor dry_run."""
216
+
217
+ auth: TokenProvider
218
+ transport: Transport = field(default=_default_transport)
219
+ raw_transport: Callable[[urllib.request.Request], str] = field(default=_raw_transport)
220
+ dry_run: bool = False
221
+
222
+ def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
223
+ request = urllib.request.Request(
224
+ f"{API}{path}",
225
+ method=method,
226
+ data=json.dumps(body).encode() if body is not None else None,
227
+ headers={
228
+ "Authorization": f"Bearer {self.auth.token()}",
229
+ "Accept": "application/vnd.github+json",
230
+ "Content-Type": "application/json",
231
+ },
232
+ )
233
+ return self.transport(request)
234
+
235
+ @staticmethod
236
+ def _expect_dict(data: Any, path: str) -> dict[str, Any]:
237
+ if not isinstance(data, dict):
238
+ raise GitHubError(200, path, f"expected an object, got {type(data).__name__}")
239
+ return data
240
+
241
+ def default_branch(self, repo: str) -> str:
242
+ path = f"/repos/{urllib.parse.quote(repo)}"
243
+ return str(self._expect_dict(self._request("GET", path), path)["default_branch"])
244
+
245
+ def get_file(self, repo: str, path: str, ref: str) -> str:
246
+ """Fetch a file's text at a ref — used to read contracts from the
247
+ default branch, never from PR branches."""
248
+ query = urllib.parse.urlencode({"ref": ref})
249
+ api_path = f"/repos/{urllib.parse.quote(repo)}/contents/{urllib.parse.quote(path)}?{query}"
250
+ data = self._expect_dict(self._request("GET", api_path), api_path)
251
+ if data.get("type") != "file":
252
+ raise GitHubError(200, api_path, f"not a file (type={data.get('type')!r})")
253
+ if data.get("encoding") != "base64":
254
+ raise GitHubError(200, api_path, f"unreadable encoding {data.get('encoding')!r}")
255
+ return base64.b64decode(data["content"]).decode()
256
+
257
+ def create_pr(self, repo: str, head: str, base: str, title: str, body: str) -> int | None:
258
+ if self.dry_run:
259
+ log.info("[dry-run] create PR %s: %s <- %s (%r)", repo, base, head, title)
260
+ return None
261
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls"
262
+ data = self._expect_dict(
263
+ self._request("POST", path, {"title": title, "head": head, "base": base, "body": body}),
264
+ path,
265
+ )
266
+ if "number" not in data:
267
+ raise GitHubError(200, path, f"no PR number in response: {data.get('message')}")
268
+ return int(data["number"])
269
+
270
+ def get_issue(self, repo: str, number: int) -> dict[str, Any]:
271
+ path = f"/repos/{urllib.parse.quote(repo)}/issues/{number}"
272
+ return self._expect_dict(self._request("GET", path), path)
273
+
274
+ def list_open_issues(self, repo: str, max_pages: int = 3) -> list[dict[str, Any]]:
275
+ """Open issues (PRs excluded — the issues API mixes them in)."""
276
+ items = self._paginate(f"/repos/{urllib.parse.quote(repo)}/issues", max_pages)
277
+ return [i for i in items if "pull_request" not in i]
278
+
279
+ def create_pull(
280
+ self, repo: str, title: str, head: str, base: str, body: str, draft: bool = False
281
+ ) -> str:
282
+ """Open a pull request; returns its html url."""
283
+ if self.dry_run:
284
+ log.info("[dry-run] PR on %s: %s (%s -> %s)", repo, title, head, base)
285
+ return f"https://github.com/{repo}/pull/dry-run"
286
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls"
287
+ data = self._request(
288
+ "POST",
289
+ path,
290
+ {"title": title, "head": head, "base": base, "body": body, "draft": draft},
291
+ )
292
+ if not isinstance(data, dict) or "html_url" not in data:
293
+ raise GitHubError(0, path, f"unexpected create_pull response: {str(data)[:200]}")
294
+ return str(data["html_url"])
295
+
296
+ def get_pull_request(self, repo: str, number: int) -> dict[str, Any]:
297
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
298
+ return self._expect_dict(self._request("GET", path), path)
299
+
300
+ def find_open_pull_for_head(
301
+ self, repo: str, head_branch: str, base: str
302
+ ) -> dict[str, Any] | None:
303
+ """The OPEN PR from `head_branch` INTO `base` (owner:branch, base-scoped
304
+ so a same-branch PR to a different base is never matched), or None.
305
+ Returns the raw PR dict (`html_url`, `number`, `draft`, …). Used for
306
+ idempotency: a wake that died after opening the PR but before recording
307
+ it reconciles to that PR instead of re-pushing (non-fast-forward) and
308
+ opening a duplicate."""
309
+ owner = repo.split("/")[0]
310
+ query = urllib.parse.urlencode(
311
+ {"head": f"{owner}:{head_branch}", "base": base, "state": "open"}
312
+ )
313
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls?{query}"
314
+ data = self._request("GET", path)
315
+ if isinstance(data, list):
316
+ for item in data:
317
+ if isinstance(item, dict) and item.get("html_url"):
318
+ return item
319
+ return None
320
+
321
+ BODY_EDIT_MARKER = marker("body-edit")
322
+ # the orchestrator-owned candidate row in pr_body's results table
323
+ # \r-tolerant: a human web-UI edit can normalize the body to CRLF
324
+ _CANDIDATE_ROW = re.compile(r"^\| candidate \| .* \|(\r?)$", re.MULTILINE)
325
+
326
+ def update_candidate_row(
327
+ self, repo: str, number: int, candidate: float, digits: int | None = None
328
+ ) -> bool:
329
+ """Rewrite the results table's candidate row in place (PATCH).
330
+
331
+ The row is orchestrator-owned and mechanical — the ONE part of the
332
+ body that must never go stale when a follow-up push re-measures
333
+ (rewrite the measured numbers,
334
+ never the narrative). Returns False when the row is not found
335
+ (older body formats), in which case the Edit addendum still
336
+ carries the number.
337
+ """
338
+ if self.dry_run:
339
+ log.info("[dry-run] update candidate row %s#%d -> %s", repo, number, candidate)
340
+ return True
341
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
342
+ current = str(self._expect_dict(self._request("GET", path), path).get("body") or "")
343
+ # only the FIRST match, and only in the orchestrator's preamble
344
+ # (before the report section) — agent report text could contain a
345
+ # lookalike row, and it must stay untouched
346
+ head, sep, tail = current.partition("## Research report")
347
+ if not sep:
348
+ # No report heading -> the preamble boundary is gone (human
349
+ # body edit?): fail CLOSED rather than rewrite report text —
350
+ # the Edit addendum already carries the number.
351
+ return False
352
+ if not self._CANDIDATE_ROW.search(head):
353
+ return False
354
+ from outerloop.progress import fmt_metric
355
+
356
+ head = self._CANDIDATE_ROW.sub(
357
+ rf"| candidate | {fmt_metric(candidate, digits)} |\1", head, count=1
358
+ )
359
+ self._request("PATCH", path, {"body": f"{head}{sep}{tail}"})
360
+ return True
361
+
362
+ def append_pull_body(self, repo: str, number: int, addendum: str) -> None:
363
+ """Upsert an EDIT addendum onto a PR body (read-modify-write PATCH).
364
+
365
+ Follow-up commits desync the report frozen into the body at publish.
366
+ The addendum marks the body EDITED rather than rewriting history in
367
+ place — and
368
+ REPLACES any previous addendum (marker-delimited) instead of
369
+ stacking one per round, so the body stays bounded and always points
370
+ at the latest state.
371
+ """
372
+ if self.dry_run:
373
+ log.info("[dry-run] upsert PR body edit %s#%d (%d chars)", repo, number, len(addendum))
374
+ return
375
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
376
+ current = str(self._expect_dict(self._request("GET", path), path).get("body") or "")
377
+ # Strip ONLY a previous addendum of ours: marker followed by our
378
+ # exact format. Agent-authored report text could contain the marker
379
+ # string (it is public), and splitting on it blindly would truncate
380
+ # the frozen report — the history this method exists to preserve.
381
+ base = current
382
+ # a previous addendum of ours may carry the pre-rename marker
383
+ for old in (self.BODY_EDIT_MARKER, legacy_marker("body-edit")):
384
+ idx = current.rfind(old)
385
+ if idx != -1 and current[idx + len(old) :].lstrip().startswith("---"):
386
+ base = current[:idx].rstrip()
387
+ break
388
+ self._request("PATCH", path, {"body": f"{base}\n\n{self.BODY_EDIT_MARKER}\n{addendum}"})
389
+
390
+ def _graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
391
+ data = self._request("POST", "/graphql", {"query": query, "variables": variables})
392
+ if not isinstance(data, dict):
393
+ raise GitHubError(0, "/graphql", f"expected an object, got {type(data).__name__}")
394
+ # GraphQL reports failures inside a 200; status 0 keeps callers'
395
+ # retry/permanence classification honest (this is not an HTTP 200
396
+ # success and not an HTTP error either).
397
+ if data.get("errors"):
398
+ raise GitHubError(0, "/graphql", str(data["errors"])[:300])
399
+ inner = data.get("data")
400
+ return inner if isinstance(inner, dict) else {}
401
+
402
+ def review_decision(self, repo: str, number: int) -> str:
403
+ """The PR's reviewDecision ("REVIEW_REQUIRED", "APPROVED",
404
+ "CHANGES_REQUESTED", or "" when the base branch requires no
405
+ reviews). GraphQL-only; readable with normal read permissions."""
406
+ owner, _, name = repo.partition("/")
407
+ query = (
408
+ "query($owner: String!, $name: String!, $number: Int!) {"
409
+ " repository(owner: $owner, name: $name) {"
410
+ " pullRequest(number: $number) { reviewDecision } } }"
411
+ )
412
+ data = self._graphql(query, {"owner": owner, "name": name, "number": number})
413
+ pr = (data.get("repository") or {}).get("pullRequest") or {}
414
+ return str(pr.get("reviewDecision") or "")
415
+
416
+ def allowed_merge_methods(self, repo: str) -> list[str]:
417
+ """Merge methods the repo permits, in our preference order (merge
418
+ commits first — trailer/provenance-preserving)."""
419
+ path = f"/repos/{urllib.parse.quote(repo)}"
420
+ settings = self._expect_dict(self._request("GET", path), path)
421
+ order = [
422
+ ("MERGE", "allow_merge_commit"),
423
+ ("SQUASH", "allow_squash_merge"),
424
+ ("REBASE", "allow_rebase_merge"),
425
+ ]
426
+ return [m for m, key in order if settings.get(key)]
427
+
428
+ def enable_auto_merge(
429
+ self, repo: str, number: int, method: str = "MERGE", expected_head: str = ""
430
+ ) -> None:
431
+ """Arm GitHub's auto-merge on a PR (a GraphQL-only capability).
432
+ `expected_head` binds the arm to that head oid: GitHub refuses when
433
+ the PR head has moved, so a push racing the caller's check can never
434
+ be the thing that merges.
435
+
436
+ Arming does not merge anything: it hands the merge to whatever
437
+ branch protection still requires. Callers that must preserve the
438
+ bot-never-merges rule should use arm_auto_merge_when_review_required
439
+ instead of calling this directly. Repos that also run the follow-up
440
+ lane should have dismiss-stale-reviews enabled, so a bot push after
441
+ arming re-requires a human look instead of merging unseen code.
442
+ """
443
+ if self.dry_run:
444
+ log.info("[dry-run] arm auto-merge on %s#%d", repo, number)
445
+ return
446
+ pr_path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
447
+ node_id = self.get_pull_request(repo, number).get("node_id")
448
+ if not node_id:
449
+ raise GitHubError(0, pr_path, "no node_id in PR payload")
450
+ variables: dict[str, Any] = {"pr": str(node_id), "method": method}
451
+ if expected_head:
452
+ mutation = (
453
+ "mutation($pr: ID!, $method: PullRequestMergeMethod!, $head: GitObjectID!) {"
454
+ " enablePullRequestAutoMerge(input: {pullRequestId: $pr, mergeMethod: $method,"
455
+ " expectedHeadOid: $head}) { pullRequest { number } } }"
456
+ )
457
+ variables["head"] = expected_head
458
+ else:
459
+ mutation = (
460
+ "mutation($pr: ID!, $method: PullRequestMergeMethod!) {"
461
+ " enablePullRequestAutoMerge(input: {pullRequestId: $pr, mergeMethod: $method})"
462
+ " { pullRequest { number } } }"
463
+ )
464
+ self._graphql(mutation, variables)
465
+
466
+ def arm_auto_merge_when_review_required(self, repo: str, number: int) -> bool:
467
+ """Arm auto-merge ONLY when branch protection makes a human review
468
+ the missing condition (reviewDecision == REVIEW_REQUIRED).
469
+
470
+ On a repo without required reviews, arming would merge the bot's own
471
+ PR the moment CI is green — no human ever acts. That would break the
472
+ bot-never-merges rule via nothing but per-repo config drift, so the
473
+ guard lives here, in code. The merge method falls back through what
474
+ the repo allows (merge-commit preferred: it preserves the Agent
475
+ trailers and commit sequence as provenance)."""
476
+ decision = self.review_decision(repo, number)
477
+ if decision != "REVIEW_REQUIRED":
478
+ log.warning(
479
+ "not arming auto-merge on %s#%d: reviewDecision=%r "
480
+ "(no required human review would stand between arming and merging)",
481
+ repo,
482
+ number,
483
+ decision,
484
+ )
485
+ return False
486
+ methods = self.allowed_merge_methods(repo) or ["MERGE"]
487
+ self.enable_auto_merge(repo, number, method=methods[0])
488
+ return True
489
+
490
+ def disable_auto_merge(self, repo: str, number: int) -> bool:
491
+ """Disarm GitHub auto-merge (GraphQL). The follow-up lane calls this
492
+ before pushing new commits to an auto-mode PR: an armed PR would
493
+ merge the NEW head on green CI without a fresh gate/suite/panel
494
+ (terra #171). Returns False when nothing was armed or on error."""
495
+ if self.dry_run:
496
+ log.info("[dry-run] disarm auto-merge on %s#%d", repo, number)
497
+ return True
498
+ try:
499
+ node_id = self.get_pull_request(repo, number).get("node_id")
500
+ if not node_id:
501
+ return False
502
+ mutation = (
503
+ "mutation($pr: ID!) {"
504
+ " disablePullRequestAutoMerge(input: {pullRequestId: $pr})"
505
+ " { pullRequest { number } } }"
506
+ )
507
+ self._graphql(mutation, {"pr": str(node_id)})
508
+ return True
509
+ except GitHubError as exc:
510
+ if "not enabled" in str(exc).casefold():
511
+ # nothing was armed — the state we wanted; pushing is safe
512
+ return True
513
+ log.warning("auto-merge disarm on %s#%s failed: %s", repo, number, exc)
514
+ return False
515
+
516
+ def merge_pull(
517
+ self, repo: str, number: int, method: str = "merge", expected_head: str = ""
518
+ ) -> bool:
519
+ """Directly merge a pull request (REST). Used only by AUTO merge mode
520
+ when nothing is pending for auto-merge to arm against. `expected_head`
521
+ rides the API's `sha` guard: GitHub refuses (409) when the head moved
522
+ since the caller checked it."""
523
+ if self.dry_run:
524
+ log.info("[dry-run] merge %s#%s", repo, number)
525
+ return True
526
+ body: dict[str, Any] = {"merge_method": method}
527
+ if expected_head:
528
+ body["sha"] = expected_head
529
+ try:
530
+ self._request(
531
+ "PUT",
532
+ f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/merge",
533
+ body,
534
+ )
535
+ return True
536
+ except GitHubError as exc:
537
+ log.warning("direct merge of %s#%s failed: %s", repo, number, exc)
538
+ return False
539
+
540
+ def arm_auto_merge_auto_mode(self, repo: str, number: int, expected_head: str = "") -> bool:
541
+ """AUTO merge mode (the contract's `merge: auto` dial): arm
542
+ auto-merge so the PR merges when its required checks pass; when
543
+ GitHub declines ONLY because nothing is pending (clean status),
544
+ merge directly. Any other decline — auto-merge disabled in repo
545
+ settings, missing permission — is a repo-owner control and STOPS
546
+ here (terra #171: the broad fallback would have bulldozed a
547
+ deliberately disabled auto-merge setting). The manual-mode
548
+ review-required guard deliberately does not apply — the owner
549
+ opted this repo in, and the gate/panel bound before publish."""
550
+ methods = self.allowed_merge_methods(repo) or ["MERGE"]
551
+ try:
552
+ self.enable_auto_merge(repo, number, method=methods[0], expected_head=expected_head)
553
+ return True
554
+ except GitHubError as exc:
555
+ if "clean status" not in str(exc).casefold():
556
+ log.warning(
557
+ "auto-merge arming on %s#%s failed (%s); NOT merging "
558
+ "directly — the decline may be a repo-owner control",
559
+ repo,
560
+ number,
561
+ exc,
562
+ )
563
+ return False
564
+ log.info(
565
+ "auto-merge arming on %s#%s: PR already clean; merging directly",
566
+ repo,
567
+ number,
568
+ )
569
+ return self.merge_pull(repo, number, method=methods[0].lower(), expected_head=expected_head)
570
+
571
+ def get_pull_request_diff(self, repo: str, number: int) -> str:
572
+ """Fetch a PR's unified diff (uses the diff media type)."""
573
+ path = f"/repos/{urllib.parse.quote(repo)}/pulls/{number}"
574
+ request = urllib.request.Request(
575
+ f"{API}{path}",
576
+ method="GET",
577
+ headers={
578
+ "Authorization": f"Bearer {self.auth.token()}",
579
+ "Accept": "application/vnd.github.v3.diff",
580
+ },
581
+ )
582
+ return self.raw_transport(request)
583
+
584
+ def get_pull_request_files(
585
+ self, repo: str, number: int, max_pages: int = 5
586
+ ) -> list[dict[str, Any]]:
587
+ """Changed files of a PR (filename, status), following pagination."""
588
+ out: list[dict[str, Any]] = []
589
+ for page in range(1, max_pages + 1):
590
+ path = (
591
+ f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/files?per_page=100&page={page}"
592
+ )
593
+ data = self._request("GET", path)
594
+ if not isinstance(data, list) or not data:
595
+ break
596
+ out.extend(item for item in data if isinstance(item, dict))
597
+ if len(data) < 100:
598
+ break
599
+ return out
600
+
601
+ def list_directory(self, repo: str, path: str, ref: str) -> list[dict[str, Any]]:
602
+ """Entries of a directory at a ref ([] when absent or not a dir).
603
+
604
+ ONE request, deliberately: the contents API returns the whole
605
+ listing for a directory (capped ~1,000 entries) and IGNORES
606
+ page/per_page — a pagination loop would re-fetch the same list and
607
+ accumulate duplicates. Trees larger than the cap need the git
608
+ trees API; callers here read a handful of entries.
609
+ """
610
+ query = urllib.parse.urlencode({"ref": ref})
611
+ api_path = f"/repos/{urllib.parse.quote(repo)}/contents/{urllib.parse.quote(path)}?{query}"
612
+ try:
613
+ data = self._request("GET", api_path)
614
+ except GitHubError as exc:
615
+ if exc.status == 404:
616
+ return []
617
+ raise
618
+ return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
619
+
620
+ def get_file_content(self, repo: str, path: str, ref: str) -> str | None:
621
+ """A file's text at `ref`, or None when it can't be provided.
622
+
623
+ Best-effort by design (reviewer context): directories, submodules,
624
+ files over the API's inline limit, and missing paths all return None
625
+ rather than raising.
626
+ """
627
+ api_path = (
628
+ f"/repos/{urllib.parse.quote(repo)}/contents/"
629
+ f"{urllib.parse.quote(path)}?ref={urllib.parse.quote(ref)}"
630
+ )
631
+ try:
632
+ data = self._request("GET", api_path)
633
+ except GitHubError as exc:
634
+ if exc.status != 404: # 404 = expected (path gone); the rest deserve a trace
635
+ log.warning("context fetch failed for %s@%s: %s", path, ref, exc)
636
+ return None
637
+ if not isinstance(data, dict) or data.get("encoding") != "base64":
638
+ return None
639
+ try:
640
+ raw = base64.b64decode(data.get("content", ""))
641
+ if b"\x00" in raw:
642
+ return None # binary
643
+ return raw.decode("utf-8")
644
+ except (ValueError, TypeError, UnicodeDecodeError):
645
+ return None
646
+
647
+ def list_comments(
648
+ self, repo: str, issue_number: int, max_pages: int = 20
649
+ ) -> list[dict[str, Any]]:
650
+ """All comments on an issue/PR, following pagination."""
651
+ comments: list[dict[str, Any]] = []
652
+ for page in range(1, max_pages + 1):
653
+ path = (
654
+ f"/repos/{urllib.parse.quote(repo)}/issues/{issue_number}"
655
+ f"/comments?per_page=100&page={page}"
656
+ )
657
+ data = self._request("GET", path)
658
+ if not isinstance(data, list) or not data:
659
+ break
660
+ comments.extend(item for item in data if isinstance(item, dict))
661
+ if len(data) < 100:
662
+ break
663
+ return comments
664
+
665
+ def list_pr_reviews(self, repo: str, number: int, max_pages: int = 10) -> list[dict[str, Any]]:
666
+ """Top-level PR reviews (the 'Review changes' submissions)."""
667
+ return self._paginate(
668
+ f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/reviews", max_pages
669
+ )
670
+
671
+ def list_pr_review_comments(
672
+ self, repo: str, number: int, max_pages: int = 10
673
+ ) -> list[dict[str, Any]]:
674
+ """Inline (Files changed) review comments."""
675
+ return self._paginate(
676
+ f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/comments", max_pages
677
+ )
678
+
679
+ def _paginate(self, base_path: str, max_pages: int) -> list[dict[str, Any]]:
680
+ items: list[dict[str, Any]] = []
681
+ for page in range(1, max_pages + 1):
682
+ data = self._request("GET", f"{base_path}?per_page=100&page={page}")
683
+ if not isinstance(data, list) or not data:
684
+ break
685
+ items.extend(item for item in data if isinstance(item, dict))
686
+ if len(data) < 100:
687
+ break
688
+ return items
689
+
690
+ def upsert_comment(self, repo: str, issue_number: int, marker: str, body: str) -> None:
691
+ """Post the comment, or edit the existing one carrying `marker`.
692
+
693
+ Keeps the reviewer to one thread per PR however many times it runs.
694
+ """
695
+ if self.dry_run:
696
+ log.info("[dry-run] upsert comment on %s#%s (%d chars)", repo, issue_number, len(body))
697
+ return
698
+ for comment in self.list_comments(repo, issue_number):
699
+ # Only ever edit a bot's own comment: a human who quote-replies
700
+ # copies the marker, and overwriting their text would be worse
701
+ # than posting a second thread.
702
+ author_type = str((comment.get("user") or {}).get("type", ""))
703
+ if author_type.casefold() != "bot":
704
+ continue
705
+ if marker in str(comment.get("body", "")):
706
+ path = f"/repos/{urllib.parse.quote(repo)}/issues/comments/{int(comment['id'])}"
707
+ self._request("PATCH", path, {"body": body})
708
+ return
709
+ self.comment(repo, issue_number, body)
710
+
711
+ def create_pr_review(
712
+ self,
713
+ repo: str,
714
+ number: int,
715
+ body: str,
716
+ comments: list[dict[str, Any]] | None = None,
717
+ ) -> None:
718
+ """Post a PR review with optional inline comments.
719
+
720
+ The event is hard-coded to COMMENT: this client must never be able
721
+ to approve or request changes — a Write-role bot review that blocks
722
+ or endorses would hand a model role the exact powers the
723
+ constitution denies it.
724
+ """
725
+ if self.dry_run:
726
+ log.info(
727
+ "[dry-run] review on %s#%s (%d chars, %d inline)",
728
+ repo,
729
+ number,
730
+ len(body),
731
+ len(comments or []),
732
+ )
733
+ return
734
+ payload: dict[str, Any] = {"body": body, "event": "COMMENT"}
735
+ if comments:
736
+ payload["comments"] = comments
737
+ self._request(
738
+ "POST",
739
+ f"/repos/{urllib.parse.quote(repo)}/pulls/{number}/reviews",
740
+ payload,
741
+ )
742
+
743
+ def create_issue(self, repo: str, title: str, body: str) -> int:
744
+ """Open an issue; returns its number (0 in dry-run)."""
745
+ if self.dry_run:
746
+ log.info("[dry-run] issue on %s: %s", repo, title)
747
+ return 0
748
+ data = self._request(
749
+ "POST",
750
+ f"/repos/{urllib.parse.quote(repo)}/issues",
751
+ {"title": title, "body": body},
752
+ )
753
+ return int(data.get("number", 0)) if isinstance(data, dict) else 0
754
+
755
+ def close_issue(self, repo: str, number: int) -> None:
756
+ if self.dry_run:
757
+ log.info("[dry-run] close issue %s#%s", repo, number)
758
+ return
759
+ self._request(
760
+ "PATCH",
761
+ f"/repos/{urllib.parse.quote(repo)}/issues/{number}",
762
+ {"state": "closed"},
763
+ )
764
+
765
+ def ensure_branch(self, repo: str, branch: str) -> bool:
766
+ """The branch exists (created from the default branch's head if not).
767
+ Returns False only when creation failed."""
768
+ quoted = urllib.parse.quote(repo)
769
+ try:
770
+ self._request("GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(branch)}")
771
+ return True
772
+ except GitHubError:
773
+ pass
774
+ if self.dry_run:
775
+ log.info("[dry-run] create branch %s on %s", branch, repo)
776
+ return True
777
+ try:
778
+ default = self.default_branch(repo)
779
+ ref = self._request(
780
+ "GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(default)}"
781
+ )
782
+ sha = ref["object"]["sha"] if isinstance(ref, dict) else ""
783
+ self._request(
784
+ "POST", f"/repos/{quoted}/git/refs", {"ref": f"refs/heads/{branch}", "sha": sha}
785
+ )
786
+ return True
787
+ except (GitHubError, KeyError, TypeError) as exc:
788
+ log.warning("could not create branch %s on %s: %s", branch, repo, exc)
789
+ return False
790
+
791
+ def put_file(self, repo: str, path: str, content: str, branch: str, message: str) -> str:
792
+ """Create or update one file on `branch` via the contents API.
793
+ Returns "created" | "updated" ("" on failure) — callers use the
794
+ distinction for idempotency (an update means this artifact was
795
+ already published once)."""
796
+ if self.dry_run:
797
+ log.info("[dry-run] put %s on %s@%s", path, repo, branch)
798
+ return "created"
799
+ quoted = urllib.parse.quote(repo)
800
+ api = f"/repos/{quoted}/contents/{urllib.parse.quote(path)}"
801
+ body: dict[str, Any] = {
802
+ "message": message,
803
+ "content": base64.b64encode(content.encode()).decode(),
804
+ "branch": branch,
805
+ }
806
+ try:
807
+ existing = self._request("GET", f"{api}?ref={urllib.parse.quote(branch)}")
808
+ if isinstance(existing, dict) and existing.get("sha"):
809
+ body["sha"] = existing["sha"]
810
+ except GitHubError:
811
+ pass # new file
812
+ try:
813
+ self._request("PUT", api, body)
814
+ return "updated" if "sha" in body else "created"
815
+ except GitHubError as exc:
816
+ log.warning("could not put %s on %s@%s: %s", path, repo, branch, exc)
817
+ return ""
818
+
819
+ def branch_head(self, repo: str, branch: str) -> str | None:
820
+ """The branch's current commit sha; "" when the branch does not
821
+ exist (nothing to protect), None on an outage or malformed reply
822
+ (the caller must not write unguarded)."""
823
+ quoted = urllib.parse.quote(repo)
824
+ try:
825
+ ref = self._request(
826
+ "GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(branch)}"
827
+ )
828
+ return str(ref["object"]["sha"])
829
+ except GitHubError as exc:
830
+ return "" if getattr(exc, "status", None) == 404 else None
831
+ except (KeyError, TypeError):
832
+ return None
833
+
834
+ def put_files(
835
+ self,
836
+ repo: str,
837
+ files: dict[str, str],
838
+ branch: str,
839
+ message: str,
840
+ expected_head: str | None = None,
841
+ ) -> bool:
842
+ """Create or update SEVERAL files on `branch` as ONE commit (git data
843
+ API: blobs -> tree -> commit -> ref). All-or-nothing: True only when
844
+ the ref moved; on failure nothing changed and the caller retries the
845
+ whole batch next pass.
846
+
847
+ Concurrency: pass `expected_head` (the head the caller SNAPSHOTTED
848
+ before reading the files it compared against) and the batch refuses
849
+ when the branch has moved since — a write that landed mid-pass is
850
+ never buried under stale content. A write landing after this check
851
+ still cannot be clobbered: the unforced ref update rejects any
852
+ non-fast-forward."""
853
+ if not files:
854
+ return True
855
+ if self.dry_run:
856
+ log.info("[dry-run] batch put %d file(s) on %s@%s", len(files), repo, branch)
857
+ return True
858
+ quoted = urllib.parse.quote(repo)
859
+ ref_path = f"/repos/{quoted}/git/refs/heads/{urllib.parse.quote(branch)}"
860
+ try:
861
+ ref = self._request(
862
+ "GET", f"/repos/{quoted}/git/ref/heads/{urllib.parse.quote(branch)}"
863
+ )
864
+ base_sha = ref["object"]["sha"]
865
+ if expected_head and base_sha != expected_head:
866
+ log.warning(
867
+ "batch on %s@%s refused: head moved %s -> %s (retry next pass)",
868
+ repo,
869
+ branch,
870
+ expected_head[:9],
871
+ str(base_sha)[:9],
872
+ )
873
+ return False
874
+ base_tree = self._request("GET", f"/repos/{quoted}/git/commits/{base_sha}")["tree"][
875
+ "sha"
876
+ ]
877
+ entries = []
878
+ for path, content in sorted(files.items()):
879
+ blob = self._request(
880
+ "POST",
881
+ f"/repos/{quoted}/git/blobs",
882
+ {
883
+ "content": base64.b64encode(content.encode()).decode(),
884
+ "encoding": "base64",
885
+ },
886
+ )
887
+ entries.append({"path": path, "mode": "100644", "type": "blob", "sha": blob["sha"]})
888
+ tree = self._request(
889
+ "POST", f"/repos/{quoted}/git/trees", {"base_tree": base_tree, "tree": entries}
890
+ )
891
+ commit = self._request(
892
+ "POST",
893
+ f"/repos/{quoted}/git/commits",
894
+ {"message": message, "tree": tree["sha"], "parents": [base_sha]},
895
+ )
896
+ self._request("PATCH", ref_path, {"sha": commit["sha"]})
897
+ return True
898
+ except (GitHubError, KeyError, TypeError) as exc:
899
+ log.warning(
900
+ "batched put of %d file(s) on %s@%s failed: %s", len(files), repo, branch, exc
901
+ )
902
+ return False
903
+
904
+ def comment(self, repo: str, issue_number: int, body: str) -> None:
905
+ if self.dry_run:
906
+ log.info("[dry-run] comment on %s#%s (%d chars)", repo, issue_number, len(body))
907
+ return
908
+ path = f"/repos/{urllib.parse.quote(repo)}/issues/{issue_number}/comments"
909
+ self._request("POST", path, {"body": body})
910
+
911
+
912
+ _MINIMAL_GIT_CONFIG = b"[core]\n\trepositoryformatversion = 0\n"
913
+ # config sections that can REDIRECT a git operation to attacker-chosen refs
914
+ # or files: url.*.insteadOf rewrites, and include/includeIf that pull in more
915
+ # config (possibly a FIFO that would block git's own parse forever). The
916
+ # session-writable .git/config is the only config source under credential
917
+ # (global/system are /dev/null), so stripping these here disarms the whole
918
+ # class before any git subcommand — local or network — reads the file.
919
+ _REDIRECT_SECTIONS = ("url", "include", "includeif")
920
+ _SECTION_RE = re.compile(r"^\s*\[\s*([A-Za-z0-9.-]+)")
921
+
922
+
923
+ # The parts of a workspace's .git the kernel relies on after a session ran.
924
+ # 2026-09-03 an author copied the pack files into the container's private
925
+ # /tmp and symlinked objects/pack there, so the kernel's git on the host
926
+ # found refs with no objects behind them. The guard runs inside every kernel
927
+ # git call (Workspace.git / git_network), so no path can skip it.
928
+ # config and config.worktree are NOT here: _ensure_regular_config sanitizes
929
+ # them in place (a FIFO or symlink config is replaced, not fatal) and runs
930
+ # first, so the guard's own git subprocess never reads a tampered config.
931
+ _GIT_REGULAR_FILES = ("HEAD",) # must exist and be regular files
932
+ _GIT_REGULAR_IF_PRESENT = (
933
+ "index",
934
+ "packed-refs",
935
+ "shallow",
936
+ "MERGE_HEAD",
937
+ "FETCH_HEAD",
938
+ "ORIG_HEAD",
939
+ "COMMIT_EDITMSG",
940
+ "description",
941
+ "objects/info/packs",
942
+ "info/exclude", # the wake writes it; a symlink would carry the write elsewhere
943
+ )
944
+ _GIT_DIRS = ("objects", "refs") # must exist and be directories
945
+ _GIT_DIRS_IF_PRESENT = ("objects/pack", "objects/info", "hooks", "info", "logs", "logs/refs")
946
+ # No symlink may appear among .git's own entries, nor ANYWHERE under the
947
+ # small control trees git writes to or reads from (refs, reflogs, hooks,
948
+ # info): a link there carries a ref update, a reflog append, or a hook read
949
+ # outside the workspace. The object store is checked one level deep plus
950
+ # its pack/info dirs (loose-object trees are large; a missing object is
951
+ # caught by the HEAD readability check below).
952
+ _GIT_NO_SYMLINK_TREES = ("refs", "logs", "hooks", "info")
953
+ _GIT_NO_SYMLINK_ENTRIES = ("", "objects", "objects/info", "objects/pack")
954
+
955
+
956
+ GUARD_GIT_TIMEOUT_S = 20 # the guard's own git read; a stall here is a stall everywhere
957
+
958
+
959
+ def _regular_files_only(git_dir: Path, rel: str, allow_dirs: tuple[str, ...] = ()) -> None:
960
+ """Every entry of .git/<rel> must be a regular file (directory-entry type,
961
+ no per-file stat); `allow_dirs` names the subdirectories git itself
962
+ creates there (objects/info/commit-graphs for a split commit graph),
963
+ which are checked the same way one level down."""
964
+ try:
965
+ with os.scandir(git_dir / rel) as entries:
966
+ for entry in entries:
967
+ if entry.is_dir(follow_symlinks=False) and entry.name in allow_dirs:
968
+ _regular_files_only(git_dir, f"{rel}/{entry.name}")
969
+ continue
970
+ if not entry.is_file(follow_symlinks=False):
971
+ raise _altered(f".git/{rel}/{entry.name} is not a regular file")
972
+ except PermissionError:
973
+ raise _altered(f".git/{rel} is unreadable") from None
974
+ except FileNotFoundError:
975
+ return
976
+
977
+
978
+ def _altered(what: str) -> GitError:
979
+ return GitError(f"workspace .git altered by the session: {what}")
980
+
981
+
982
+ def ensure_regular_git_dir(root: Path | None) -> None:
983
+ """Refuse a workspace whose .git a session reshaped. `.git` must be a
984
+ real directory (no symlink, no gitdir file); HEAD and config must be
985
+ regular files and the other control files regular when present (a FIFO
986
+ would hang the next git call); objects and refs must be directories, as
987
+ must objects/pack when present; object alternates are never ours. A
988
+ root with no .git at all is left to git's own error."""
989
+ if root is None:
990
+ return
991
+ git_dir = Path(root) / ".git"
992
+ try:
993
+ st = os.lstat(git_dir)
994
+ except OSError:
995
+ return
996
+ if stat.S_ISLNK(st.st_mode):
997
+ raise _altered(".git is a symlink")
998
+ if not stat.S_ISDIR(st.st_mode):
999
+ raise _altered(".git is not a directory (a gitdir file)")
1000
+ _ensure_regular_config(root) # before any git subprocess below reads it
1001
+ # The kernel clones with the files ref backend; a reftable repository
1002
+ # (`git init --ref-format=reftable`, or a `git refs migrate`) keeps its
1003
+ # refs in `.git/reftable/`, where none of the ref reads below would see
1004
+ # them, so an emptied object store could slip past. It is not one the
1005
+ # kernel made: refuse it.
1006
+ if os.path.lexists(git_dir / "reftable"):
1007
+ raise _altered("ref storage is not the files backend (reftable present)")
1008
+
1009
+ def check(rel: str, want_dir: bool, required: bool) -> None:
1010
+ try:
1011
+ st = os.lstat(git_dir / rel)
1012
+ except OSError:
1013
+ if required:
1014
+ raise _altered(f".git/{rel} is missing") from None
1015
+ return
1016
+ if stat.S_ISLNK(st.st_mode):
1017
+ raise _altered(f".git/{rel} is a symlink")
1018
+ if want_dir and not stat.S_ISDIR(st.st_mode):
1019
+ raise _altered(f".git/{rel} is not a directory")
1020
+ if not want_dir and not stat.S_ISREG(st.st_mode):
1021
+ raise _altered(f".git/{rel} is not a regular file")
1022
+
1023
+ for rel in _GIT_REGULAR_FILES:
1024
+ check(rel, want_dir=False, required=True)
1025
+ for rel in _GIT_REGULAR_IF_PRESENT:
1026
+ check(rel, want_dir=False, required=False)
1027
+ for rel in _GIT_DIRS:
1028
+ check(rel, want_dir=True, required=True)
1029
+ for rel in _GIT_DIRS_IF_PRESENT:
1030
+ check(rel, want_dir=True, required=False)
1031
+ for rel in _GIT_NO_SYMLINK_ENTRIES:
1032
+ try:
1033
+ with os.scandir(git_dir / rel if rel else git_dir) as entries:
1034
+ for entry in entries:
1035
+ if entry.is_symlink():
1036
+ shown = f"{rel}/{entry.name}" if rel else entry.name
1037
+ raise _altered(f".git/{shown} is a symlink")
1038
+ except PermissionError:
1039
+ raise _altered(f".git/{rel or '.'} is unreadable") from None
1040
+ except OSError:
1041
+ continue
1042
+
1043
+ def _unreadable(err: OSError) -> None:
1044
+ shown = os.path.relpath(err.filename or "?", git_dir)
1045
+ raise _altered(f".git/{shown} is unreadable") from None
1046
+
1047
+ for rel in _GIT_NO_SYMLINK_TREES:
1048
+ top = git_dir / rel
1049
+ if not top.is_dir():
1050
+ continue
1051
+ for dirpath, dirnames, filenames in os.walk(top, followlinks=False, onerror=_unreadable):
1052
+ for name in (*dirnames, *filenames):
1053
+ full = os.path.join(dirpath, name)
1054
+ if os.path.islink(full):
1055
+ shown = os.path.relpath(full, git_dir)
1056
+ raise _altered(f".git/{shown} is a symlink")
1057
+ for name in filenames:
1058
+ # a FIFO or device where a loose ref, reflog, or hook was would
1059
+ # stall the next git command that scans this tree
1060
+ full = os.path.join(dirpath, name)
1061
+ try:
1062
+ if not stat.S_ISREG(os.lstat(full).st_mode):
1063
+ shown = os.path.relpath(full, git_dir)
1064
+ raise _altered(f".git/{shown} is not a regular file")
1065
+ except FileNotFoundError:
1066
+ continue
1067
+ if os.path.lexists(git_dir / "objects" / "info" / "alternates"):
1068
+ raise _altered("object alternates are present")
1069
+ # Every loose object and pack file must be a regular file: a FIFO in
1070
+ # place of one would stall the next git call that reads it. d_type from
1071
+ # scandir, no per-file stat, so this stays cheap on large stores.
1072
+ objects = git_dir / "objects"
1073
+ try:
1074
+ fanouts = [e for e in os.scandir(objects) if e.is_dir(follow_symlinks=False)]
1075
+ except OSError:
1076
+ fanouts = []
1077
+ for fan in fanouts:
1078
+ if fan.name not in ("pack", "info") and not (
1079
+ len(fan.name) == 2 and _HEX2.fullmatch(fan.name)
1080
+ ):
1081
+ raise _altered(f".git/objects/{fan.name} is not a git object directory")
1082
+ _regular_files_only(git_dir, f"objects/{fan.name}", allow_dirs=("commit-graphs",))
1083
+ # The structure can be intact with the objects gone (pack files deleted,
1084
+ # or moved and the link removed): a commit the refs name must still be
1085
+ # readable. HEAD's commit when HEAD is born; otherwise any other ref (a
1086
+ # clone whose remote HEAD names an unpushed branch has an unborn local
1087
+ # HEAD beside real remote refs); a fresh repository with no refs at all
1088
+ # has nothing to check.
1089
+ sha = _head_commit_sha(git_dir) or _any_ref_sha(git_dir)
1090
+ if sha is not None:
1091
+ try:
1092
+ _run_git(
1093
+ ["git", "-C", str(root), *SAFE_GIT_FLAGS, "cat-file", "-e", f"{sha}^{{commit}}"],
1094
+ _git_env(None, Path(root)),
1095
+ timeout=GUARD_GIT_TIMEOUT_S, # a stalled read is refused, not waited on
1096
+ )
1097
+ except GitError as exc:
1098
+ if "timed out" in str(exc):
1099
+ raise _altered("the object store did not answer: a git read stalled") from None
1100
+ raise _altered(
1101
+ "HEAD's commit is unreadable: the object store was emptied or moved"
1102
+ ) from None
1103
+
1104
+
1105
+ _SHA_RE = re.compile(r"[0-9a-f]{40,64}")
1106
+ _HEX2 = re.compile(r"[0-9a-f]{2}")
1107
+ _REF_RE = re.compile(r"refs/[A-Za-z0-9][A-Za-z0-9._/-]{0,200}")
1108
+
1109
+
1110
+ PACKED_REFS_MAX_BYTES = 64 * 1024 * 1024 # far beyond any real repository's packed-refs
1111
+
1112
+
1113
+ def _read_small_regular(path: Path, limit: int = 512) -> str | None:
1114
+ """Read a control file only if it is a regular file (never follow a
1115
+ symlink or block on a FIFO); None when absent. A file larger than
1116
+ `limit` is refused rather than truncated: a truncated read would silently
1117
+ drop entries (a HEAD ref past the cut would read as unborn)."""
1118
+ try:
1119
+ st = os.lstat(path)
1120
+ except OSError:
1121
+ return None
1122
+ if not stat.S_ISREG(st.st_mode):
1123
+ raise _altered(f"{path.name} is not a regular file")
1124
+ if st.st_size > limit:
1125
+ raise _altered(f"{path.name} is oversized ({st.st_size} bytes)")
1126
+ try:
1127
+ with open(path, "rb") as fh:
1128
+ return fh.read(limit).decode("utf-8", errors="replace")
1129
+ except OSError as exc: # chmod 000 and friends: unreadable is altered, not "absent"
1130
+ raise _altered(f"{path.name} is unreadable ({exc.strerror})") from None
1131
+
1132
+
1133
+ def _ref_sha(git_dir: Path, ref: str, _depth: int = 0) -> str | None:
1134
+ """A ref's sha from its loose file (inside .git only) or packed-refs,
1135
+ following a symbolic loose ref (`ref: refs/...`) the way git resolves it.
1136
+ Without the chain, a HEAD -> refs/heads/X -> refs/heads/real symref left the
1137
+ object unverified (the non-sha loose file read as unborn), so an emptied
1138
+ object store behind `real` slipped past the guard and only failed later at a
1139
+ raw `git reset` ("Could not parse object 'HEAD'"). A too-deep chain is a
1140
+ cycle git never writes: tampering, not a state to resolve."""
1141
+ if _depth > 8:
1142
+ raise _altered("a ref symref chain is too deep (a cycle?)")
1143
+ loose = _read_small_regular(git_dir / ref)
1144
+ if loose is not None:
1145
+ value = loose.strip()
1146
+ if _SHA_RE.fullmatch(value):
1147
+ return value
1148
+ if value.startswith("ref: "):
1149
+ target = value[5:].strip()
1150
+ if not _REF_RE.fullmatch(target) or ".." in target.split("/"):
1151
+ raise _altered("a ref names a target outside refs/")
1152
+ return _ref_sha(git_dir, target, _depth + 1)
1153
+ return None # neither a sha nor a symref: an unborn/placeholder ref
1154
+ packed = _read_small_regular(git_dir / "packed-refs", limit=PACKED_REFS_MAX_BYTES) or ""
1155
+ for line in packed.splitlines():
1156
+ if line.endswith(" " + ref) and _SHA_RE.fullmatch(line.split(" ", 1)[0]):
1157
+ return line.split(" ", 1)[0]
1158
+ return None
1159
+
1160
+
1161
+ def _head_commit_sha(git_dir: Path) -> str | None:
1162
+ """The sha HEAD names, or None when HEAD's branch is unborn. A HEAD that
1163
+ is not a plain sha or a well-formed `ref: refs/...` (a path with `..`, an
1164
+ absolute path, junk) is tampering, not a state git ever writes."""
1165
+ head = (_read_small_regular(git_dir / "HEAD") or "").strip()
1166
+ if not head:
1167
+ raise _altered("HEAD is empty")
1168
+ if _SHA_RE.fullmatch(head):
1169
+ return head
1170
+ if not head.startswith("ref: "):
1171
+ raise _altered("HEAD is malformed")
1172
+ ref = head[5:].strip()
1173
+ if not _REF_RE.fullmatch(ref) or ".." in ref.split("/"):
1174
+ raise _altered("HEAD names a ref outside refs/")
1175
+ return _ref_sha(git_dir, ref)
1176
+
1177
+
1178
+ def _any_ref_sha(git_dir: Path) -> str | None:
1179
+ """Some commit-bearing ref's sha (packed first, then loose under refs/),
1180
+ used to verify the object store when HEAD is unborn; None when the
1181
+ repository has no refs at all."""
1182
+ packed = _read_small_regular(git_dir / "packed-refs", limit=PACKED_REFS_MAX_BYTES) or ""
1183
+ for line in packed.splitlines():
1184
+ if line and not line.startswith(("#", "^")):
1185
+ sha = line.split(" ", 1)[0]
1186
+ if _SHA_RE.fullmatch(sha):
1187
+ return sha
1188
+ top = git_dir / "refs"
1189
+ if top.is_dir():
1190
+ for path in sorted(top.rglob("*")):
1191
+ if path.is_file() and not path.is_symlink():
1192
+ value = (_read_small_regular(path) or "").strip()
1193
+ if _SHA_RE.fullmatch(value):
1194
+ return value
1195
+ return None
1196
+
1197
+
1198
+ def _ensure_regular_config(root: Path | None) -> None:
1199
+ """Sanitize .git/config before any git command reads it. A session can
1200
+ (a) replace the file with a FIFO/symlink/device — git's own parse, or a
1201
+ read, would BLOCK forever with no writer, hanging the wake — or (b) leave
1202
+ a regular file that INCLUDES a FIFO or rewrites URLs. Non-regular files are
1203
+ replaced with a minimal config; a regular file's redirect sections
1204
+ (url/include/includeIf) are stripped in place. A clean config is left
1205
+ untouched (a cheap lstat and, at most, one small read)."""
1206
+ if root is None:
1207
+ return
1208
+ # every session-writable config file git reads under credential: the
1209
+ # main local config and the per-worktree config (extensions.worktreeConfig).
1210
+ # global/system are /dev/null via the env, so this list is exhaustive.
1211
+ for cfg in (root / ".git" / "config", root / ".git" / "config.worktree"):
1212
+ _sanitize_one_config(cfg)
1213
+
1214
+
1215
+ def _sanitize_one_config(cfg: Path) -> None:
1216
+ try:
1217
+ st = os.lstat(cfg)
1218
+ except OSError:
1219
+ return
1220
+
1221
+ def _write(data: bytes) -> None:
1222
+ with contextlib.suppress(OSError):
1223
+ if cfg.exists():
1224
+ cfg.unlink()
1225
+ fd = os.open(cfg, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
1226
+ try:
1227
+ os.write(fd, data)
1228
+ finally:
1229
+ os.close(fd)
1230
+
1231
+ if not stat.S_ISREG(st.st_mode) or st.st_size > 1_000_000:
1232
+ _write(_MINIMAL_GIT_CONFIG) # FIFO/symlink/device/oversize: hostile
1233
+ return
1234
+ # a regular file never blocks a read (reading does NOT follow includes),
1235
+ # so this is safe; only the redirect sections are removed
1236
+ try:
1237
+ text = cfg.read_text(errors="replace")
1238
+ except OSError:
1239
+ return
1240
+ kept: list[str] = []
1241
+ dropping = False
1242
+ changed = False
1243
+ for line in text.splitlines(keepends=True):
1244
+ m = _SECTION_RE.match(line)
1245
+ if m is not None: # a new section header decides the next block
1246
+ dropping = m.group(1).split(".", 1)[0].casefold() in _REDIRECT_SECTIONS
1247
+ if dropping:
1248
+ changed = True
1249
+ continue
1250
+ kept.append(line)
1251
+ if changed:
1252
+ _write("".join(kept).encode())
1253
+
1254
+
1255
+ def _filter_override_pairs(root: Path | None) -> list[tuple[str, str]]:
1256
+ """GIT_CONFIG pairs that neutralize every filter driver the REPO config
1257
+ defines (each overridden to a passthrough) plus attribute files. The
1258
+ workspace's .git/config and .gitattributes are session-written, so a
1259
+ checkout there must never execute a configured smudge/clean/process
1260
+ command with the orchestrator's permissions — the same neutralization the
1261
+ dispatched job script applies. Env-var pairs, not `-c`: a driver name
1262
+ containing '=' or dots survives correctly."""
1263
+ pairs: list[tuple[str, str]] = [("core.attributesFile", "/dev/null")]
1264
+ if root is None:
1265
+ return pairs
1266
+ listing = subprocess.run(
1267
+ [
1268
+ "git",
1269
+ "-C",
1270
+ str(root),
1271
+ "config",
1272
+ "-z",
1273
+ "--get-regexp",
1274
+ r"^filter\..*\.(clean|smudge|process)$",
1275
+ ],
1276
+ env={
1277
+ **os.environ,
1278
+ "GIT_CONFIG_GLOBAL": "/dev/null",
1279
+ "GIT_CONFIG_SYSTEM": "/dev/null",
1280
+ "GIT_CONFIG_COUNT": "0",
1281
+ "GIT_TERMINAL_PROMPT": "0",
1282
+ },
1283
+ capture_output=True,
1284
+ timeout=30,
1285
+ )
1286
+ # BYTES + surrogateescape, never text=True: the config is session-written,
1287
+ # so a non-UTF-8 byte in a value must not crash the git call — and the
1288
+ # surrogates roundtrip through the env (os.fsencode), so an override key
1289
+ # still matches a weird-byte driver name EXACTLY (a lossy decode would
1290
+ # silently fail to neutralize that driver).
1291
+ # -z: NUL-separated records, each "key\nvalue" — a value containing a
1292
+ # newline can never masquerade as a second record.
1293
+ for record in listing.stdout.decode("utf-8", "surrogateescape").split("\0"):
1294
+ key = record.split("\n", 1)[0]
1295
+ if not key.startswith("filter.") or "." not in key[len("filter.") :]:
1296
+ continue
1297
+ driver = key[len("filter.") : key.rindex(".")]
1298
+ pairs += [
1299
+ (f"filter.{driver}.clean", "cat"),
1300
+ (f"filter.{driver}.smudge", "cat"),
1301
+ (f"filter.{driver}.process", ""),
1302
+ ]
1303
+ return pairs
1304
+
1305
+
1306
+ def _git_env(token: str | None, root: Path | None = None) -> dict[str, str]:
1307
+ """Environment for a git invocation: host global/system config never
1308
+ loads (a host-configured filter driver must not be selectable by a
1309
+ session-written .gitattributes), repo-defined filter drivers are
1310
+ overridden to passthrough, and the token — present only for network
1311
+ subcommands — rides an env-injected header."""
1312
+ env = dict(os.environ)
1313
+ env["GIT_TERMINAL_PROMPT"] = "0"
1314
+ env["GIT_CONFIG_GLOBAL"] = "/dev/null"
1315
+ env["GIT_CONFIG_SYSTEM"] = "/dev/null"
1316
+ # The session owns the clone and can write refs/replace/*, which git
1317
+ # honors in ancestry checks (merge-base) and object lookups
1318
+ # (rev-parse sha:path) — either would let a doctored object stand in
1319
+ # for a real one during post-session verification. The kernel never
1320
+ # uses replace refs; disable them on every invocation.
1321
+ env["GIT_NO_REPLACE_OBJECTS"] = "1"
1322
+ pairs = _filter_override_pairs(root)
1323
+ if token is not None:
1324
+ basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
1325
+ pairs.append(("http.https://github.com/.extraheader", f"Authorization: Basic {basic}"))
1326
+ env["GIT_CONFIG_COUNT"] = str(len(pairs))
1327
+ for i, (k, v) in enumerate(pairs):
1328
+ env[f"GIT_CONFIG_KEY_{i}"] = k
1329
+ env[f"GIT_CONFIG_VALUE_{i}"] = v
1330
+ return env
1331
+
1332
+
1333
+ def _run_git(args: list[str], env: dict[str, str], timeout: float | None = None) -> str:
1334
+ try:
1335
+ result = subprocess.run(
1336
+ args, capture_output=True, text=True, env=env, check=False, timeout=timeout
1337
+ )
1338
+ except subprocess.TimeoutExpired as exc:
1339
+ raise GitError(f"git timed out after {timeout:.0f}s: {' '.join(args[3:5])}") from exc
1340
+ if result.returncode != 0:
1341
+ detail = (result.stderr or result.stdout or "").strip()
1342
+ skip = {"-C", "-c"}
1343
+ subcommand = next(
1344
+ (a for i, a in enumerate(args[1:], 1) if a not in skip and args[i - 1] not in skip),
1345
+ "?",
1346
+ )
1347
+ raise GitError(f"git {subcommand} failed: {detail}")
1348
+ return result.stdout.strip()
1349
+
1350
+
1351
+ @dataclass
1352
+ class Workspace:
1353
+ """A working clone the agent session edits; pushes happen orchestrator-side."""
1354
+
1355
+ root: Path
1356
+ auth: TokenProvider | None = None
1357
+ dry_run: bool = False
1358
+ url: str | None = None
1359
+
1360
+ def git(self, *args: str) -> str:
1361
+ """Run a local git subcommand: no credential, no child-spawning config,
1362
+ repo-defined filter drivers neutralized. A session-reshaped .git is
1363
+ refused before git runs (ensure_regular_git_dir, which also
1364
+ sanitizes the config first)."""
1365
+ ensure_regular_git_dir(self.root)
1366
+ return _run_git(
1367
+ ["git", "-C", str(self.root), *SAFE_GIT_FLAGS, *args], _git_env(None, self.root)
1368
+ )
1369
+
1370
+ def git_network(self, *args: str) -> str:
1371
+ """Run a git subcommand that talks to the remote, with credentials.
1372
+ The config is sanitized first (redirect sections stripped), and the
1373
+ fetch/push pass an explicit URL + refspec, so no session-controlled
1374
+ remote or rewrite can steer a credentialed op."""
1375
+ if args and args[0] not in NETWORK_GIT_COMMANDS:
1376
+ raise ValueError(f"{args[0]!r} is not a network git command")
1377
+ ensure_regular_git_dir(self.root) # sanitizes the config first, too
1378
+ token = self.auth.token() if self.auth is not None else None
1379
+ return _run_git(
1380
+ ["git", "-C", str(self.root), *SAFE_GIT_FLAGS, *args],
1381
+ _git_env(token, self.root),
1382
+ timeout=NETWORK_GIT_TIMEOUT_S,
1383
+ )
1384
+
1385
+ def remote_url(self) -> str:
1386
+ """The remote URL as recorded at clone time, read token-free."""
1387
+ return self.git("config", "--get", "remote.origin.url")
1388
+
1389
+ @classmethod
1390
+ def clone(
1391
+ cls,
1392
+ url: str,
1393
+ dest: Path,
1394
+ auth: TokenProvider | None = None,
1395
+ dry_run: bool = False,
1396
+ ) -> Workspace:
1397
+ token = auth.token() if auth is not None else None
1398
+ _run_git(["git", "clone", "--quiet", *SAFE_GIT_FLAGS, url, str(dest)], _git_env(token))
1399
+ return cls(root=dest, auth=auth, dry_run=dry_run, url=url)
1400
+
1401
+ def branch(self, name: str) -> None:
1402
+ self.git("switch", "-c", name)
1403
+
1404
+ def staged_paths(self) -> list[str]:
1405
+ """Staged paths, NUL-delimited so unicode/space/newline names survive."""
1406
+ output = self.git("diff", "--cached", "--name-only", "-z")
1407
+ return [entry for entry in output.split("\0") if entry]
1408
+
1409
+ def commit_all(
1410
+ self,
1411
+ message: str,
1412
+ author: str,
1413
+ forbidden: Callable[[str], bool] | None = None,
1414
+ ) -> None:
1415
+ """Stage everything and commit.
1416
+
1417
+ `forbidden(path)` — normally `partial(path_is_forbidden, contract=...)`
1418
+ — vetoes the commit if the session touched a path the contract puts
1419
+ off-limits, so the invariant is enforced against the diff, not only
1420
+ against the contract's own scope list.
1421
+ """
1422
+ self.git("add", "-A")
1423
+ staged = self.staged_paths()
1424
+ if not staged:
1425
+ raise NothingToCommit("nothing to commit; working tree clean")
1426
+ if forbidden is not None:
1427
+ violations = [p for p in staged if forbidden(p)]
1428
+ if violations:
1429
+ self.git("reset")
1430
+ raise ForbiddenPathError(f"commit touches forbidden paths: {sorted(violations)}")
1431
+ self.git(
1432
+ "-c",
1433
+ f"user.name={author}",
1434
+ "-c",
1435
+ f"user.email={author}@users.noreply.github.com",
1436
+ "commit",
1437
+ "-m",
1438
+ message,
1439
+ )
1440
+
1441
+ def fetch_origin(self) -> None:
1442
+ """Refresh refs/remotes/origin/* from the URL captured at clone time —
1443
+ never the "origin" remote, whose url and uploadpack live in
1444
+ session-writable .git/config. The credential is host-scoped
1445
+ (extraheader), so a rewritten URL could not receive it, but the
1446
+ CONTENT must come from the canonical repo too: a poisoned fetch
1447
+ source would forge what origin/<base> means to every downstream
1448
+ comparison."""
1449
+ if self.dry_run:
1450
+ log.info("[dry-run] fetch into %s", self.root)
1451
+ return
1452
+ target = self.url or self.remote_url()
1453
+ self.git_network("fetch", "--prune", target, "--", "+refs/heads/*:refs/remotes/origin/*")
1454
+
1455
+ def fetch_branch(self, branch: str) -> None:
1456
+ """Fetch one branch into FETCH_HEAD from the canonical URL (resolved
1457
+ before the clean-config window, never the mutable "origin" remote)."""
1458
+ target = self.url or self.remote_url()
1459
+ self.git_network("fetch", target, branch)
1460
+
1461
+ def push(self, branch: str) -> None:
1462
+ if self.dry_run:
1463
+ log.info("[dry-run] push %s from %s", branch, self.root)
1464
+ return
1465
+ # Push to the URL captured at clone time: the session can rewrite
1466
+ # remote.origin.url, and "origin" would follow it.
1467
+ target = self.url or self.remote_url()
1468
+ self.git_network("push", target, "--", f"{branch}:{branch}")
1469
+
1470
+
1471
+ def contract_at(ws: Any, sha: str) -> str:
1472
+ """The target's contract text at `sha` — `.outerloop.yaml`, else the legacy
1473
+ `.autoresearch.yaml`. `ws` is a Workspace (anything with `.git(*args)`); a
1474
+ missing path is a git failure, which is the fallback signal. Raises GitError
1475
+ when the commit has neither, naming both candidates."""
1476
+
1477
+ def read(name: str) -> str | None:
1478
+ try:
1479
+ return ws.git("show", f"{sha}:{name}")
1480
+ except GitError:
1481
+ return None
1482
+
1483
+ found = find_contract(read)
1484
+ if found is None:
1485
+ raise GitError(f"no contract at {sha} ({' or '.join(CONTRACT_NAMES)})")
1486
+ return found[1]