obey-cli 0.1.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 (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,341 @@
1
+ """Run one agent invocation in a Docker container with no direct route to the internet.
2
+
3
+ Layout per batch: an internal Docker network, and a sidecar container on that network (alias `obey-gw`) that is
4
+ also attached to the default bridge. The sidecar runs the model gateway (port 8787, holds the API key) and the
5
+ allow-listing egress proxy (port 3128). Agent containers join only the internal network, so the proxy is their
6
+ only way out and the gateway is their only model endpoint.
7
+
8
+ Files move in and out with `docker cp` tar streams rather than bind mounts, so the sandbox works the same with
9
+ Docker Desktop, colima or a remote daemon, and the agent never sees a host path.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import tarfile
20
+ import time
21
+ import uuid
22
+ from collections.abc import Mapping, Sequence
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ AGENT_UID = 1000
28
+ AGENT_HOME = "/home/agent"
29
+ WORK_ROOT = "/srv/obey" # holds work/ (the repository copy) and .remote/ (a bare origin)
30
+ GATEWAY_HOST = "obey-gw"
31
+ GATEWAY_PORT = 8787
32
+ PROXY_PORT = 3128
33
+ SIDECAR_FILES = ("gateway.py", "egress_proxy.py", "sidecar.py")
34
+
35
+
36
+ class SandboxError(RuntimeError):
37
+ pass
38
+
39
+
40
+ @dataclass
41
+ class Invocation:
42
+ """What an adapter asks the sandbox to run."""
43
+
44
+ argv: list[str]
45
+ env: dict[str, str] = field(default_factory=dict)
46
+ home_files: dict[str, str] = field(default_factory=dict) # path relative to $HOME -> content
47
+ timeout_s: int = 600
48
+
49
+
50
+ @dataclass
51
+ class RawRun:
52
+ root: Path | None # the copied-back WORK_ROOT (work/ and .remote/) when requested
53
+ out_dir: Path # $HOME/.obey-out: stream.jsonl, stderr.log, exit_code, end-state.json, hook logs
54
+ exit_code: int | None
55
+ timed_out: bool
56
+ duration_s: float
57
+
58
+
59
+ def docker_cmd() -> list[str]:
60
+ return os.environ.get("OBEY_DOCKER", "docker").split()
61
+
62
+
63
+ def _docker(
64
+ *args: str,
65
+ input_bytes: bytes | None = None,
66
+ timeout: float = 120,
67
+ env: Mapping[str, str] | None = None,
68
+ check: bool = True,
69
+ ) -> subprocess.CompletedProcess[bytes]:
70
+ proc = subprocess.run(
71
+ [*docker_cmd(), *args],
72
+ input=input_bytes,
73
+ capture_output=True,
74
+ timeout=timeout,
75
+ env={**os.environ, **(env or {})},
76
+ check=False,
77
+ )
78
+ if check and proc.returncode != 0:
79
+ raise SandboxError(f"docker {args[0]} failed: {proc.stderr.decode('utf-8', 'replace')[:400]}")
80
+ return proc
81
+
82
+
83
+ def docker_available() -> bool:
84
+ if shutil.which(docker_cmd()[0]) is None:
85
+ return False
86
+ try:
87
+ return _docker("info", "--format", "{{.ServerVersion}}", timeout=20, check=False).returncode == 0
88
+ except (OSError, subprocess.TimeoutExpired):
89
+ return False
90
+
91
+
92
+ def tar_tree(entries: Mapping[str, Path | str | bytes], mode_exec: Sequence[str] = ()) -> bytes:
93
+ """A tar archive owned by the sandbox user. Values are directories/files on disk or literal contents."""
94
+ buf = io.BytesIO()
95
+ with tarfile.open(fileobj=buf, mode="w") as tar:
96
+
97
+ def own(info: tarfile.TarInfo) -> tarfile.TarInfo:
98
+ info.uid = info.gid = AGENT_UID
99
+ info.uname = info.gname = "agent"
100
+ return info
101
+
102
+ for name, value in entries.items():
103
+ if isinstance(value, Path):
104
+ tar.add(value, arcname=name, filter=own)
105
+ continue
106
+ data = value.encode() if isinstance(value, str) else value
107
+ info = own(tarfile.TarInfo(name))
108
+ info.size = len(data)
109
+ info.mode = 0o755 if name in mode_exec else 0o644
110
+ info.mtime = int(time.time())
111
+ # Parent directories are created by docker cp with root ownership unless listed; add them.
112
+ parts = name.split("/")[:-1]
113
+ for i in range(1, len(parts) + 1):
114
+ d = own(tarfile.TarInfo("/".join(parts[:i])))
115
+ d.type = tarfile.DIRTYPE
116
+ d.mode = 0o755
117
+ if d.name not in tar.getnames():
118
+ tar.addfile(d)
119
+ tar.addfile(info, io.BytesIO(data))
120
+ return buf.getvalue()
121
+
122
+
123
+ @dataclass
124
+ class Batch:
125
+ network: str
126
+ sidecar: str
127
+ image: str
128
+ allow_hosts: list[str]
129
+ started: bool = False
130
+
131
+ @property
132
+ def gateway_url(self) -> str:
133
+ return f"http://{GATEWAY_HOST}:{GATEWAY_PORT}"
134
+
135
+
136
+ class DockerSandbox:
137
+ def __init__(self, image: str, *, cpus: str = "2", memory: str = "4g", pids: int = 1024):
138
+ self.image = image
139
+ self.cpus = cpus
140
+ self.memory = memory
141
+ self.pids = pids
142
+
143
+ def start_batch(
144
+ self,
145
+ *,
146
+ key: str,
147
+ model: str | None,
148
+ price: Mapping[str, float],
149
+ allow_hosts: Sequence[str],
150
+ run_cap: float | None,
151
+ budget: float | None,
152
+ upstreams: Mapping[str, str] | None = None,
153
+ run_ids: Sequence[str] | None = None,
154
+ ) -> Batch:
155
+ tag = uuid.uuid4().hex[:10]
156
+ batch = Batch(
157
+ network=f"obey-net-{tag}", sidecar=f"obey-gw-{tag}", image=self.image, allow_hosts=list(allow_hosts)
158
+ )
159
+ _docker("network", "create", "--internal", batch.network)
160
+ # The sidecar's configuration holds the key: never leave it behind when the batch fails to start.
161
+ try:
162
+ args = ["--model", model] if model else []
163
+ for name, value in price.items():
164
+ args += [f"--price-{name.replace('_', '-')}", str(value)]
165
+ if run_cap is not None:
166
+ args += ["--run-cap", str(run_cap)]
167
+ if budget is not None:
168
+ args += ["--budget", str(budget)]
169
+ for api, url in (upstreams or {}).items():
170
+ args += ["--upstream", f"{api}={url}"]
171
+ for host in allow_hosts:
172
+ args += ["--allow", host]
173
+ # Registered run ids: a run cannot reset its spend cap by calling the gateway under another id.
174
+ for run_id in run_ids or ():
175
+ args += ["--run-id", run_id]
176
+ _docker(
177
+ "create",
178
+ "--name",
179
+ batch.sidecar,
180
+ "--network",
181
+ "bridge",
182
+ "--entrypoint",
183
+ "python3",
184
+ "-e",
185
+ "OBEY_UPSTREAM_KEY",
186
+ "--cpus",
187
+ "1",
188
+ "--memory",
189
+ "512m",
190
+ "--pids-limit",
191
+ "256",
192
+ self.image,
193
+ f"{AGENT_HOME}/sidecar/sidecar.py",
194
+ *args,
195
+ env={"OBEY_UPSTREAM_KEY": key},
196
+ )
197
+ here = Path(__file__).parent
198
+ files = {f"sidecar/{name}": (here / name).read_bytes() for name in SIDECAR_FILES}
199
+ _docker("cp", "-a", "-", f"{batch.sidecar}:{AGENT_HOME}", input_bytes=tar_tree(files))
200
+ _docker("network", "connect", "--alias", GATEWAY_HOST, batch.network, batch.sidecar)
201
+ _docker("start", batch.sidecar)
202
+ batch.started = True
203
+ self._wait_ready(batch)
204
+ except BaseException:
205
+ self.stop_batch(batch)
206
+ raise
207
+ return batch
208
+
209
+ def _wait_ready(self, batch: Batch, timeout_s: float = 30) -> None:
210
+ probe = (
211
+ "import socket,sys\n"
212
+ f"for p in ({GATEWAY_PORT},{PROXY_PORT}):\n"
213
+ " socket.create_connection(('127.0.0.1',p),timeout=1).close()\n"
214
+ )
215
+ deadline = time.monotonic() + timeout_s
216
+ while time.monotonic() < deadline:
217
+ if _docker("exec", batch.sidecar, "python3", "-c", probe, check=False).returncode == 0:
218
+ return
219
+ time.sleep(0.5)
220
+ logs = _docker("logs", batch.sidecar, check=False).stderr.decode("utf-8", "replace")[-800:]
221
+ raise SandboxError(f"sidecar did not become ready: {logs}")
222
+
223
+ def run(
224
+ self,
225
+ batch: Batch,
226
+ root: Path,
227
+ inv: Invocation,
228
+ out_dir: Path,
229
+ run_id: str,
230
+ *,
231
+ base_sha: str | None = None,
232
+ copy_back_work: bool = False,
233
+ ) -> RawRun:
234
+ """Copy `root` (work/ and .remote/) into the container, run the invocation, copy everything back."""
235
+ name = f"obey-run-{run_id[:40]}-{uuid.uuid4().hex[:6]}"
236
+ proxy = f"http://{GATEWAY_HOST}:{PROXY_PORT}"
237
+ env = {
238
+ "HOME": AGENT_HOME,
239
+ "HTTPS_PROXY": proxy,
240
+ "https_proxy": proxy,
241
+ "HTTP_PROXY": proxy,
242
+ "http_proxy": proxy,
243
+ "NO_PROXY": f"{GATEWAY_HOST},localhost,127.0.0.1",
244
+ "no_proxy": f"{GATEWAY_HOST},localhost,127.0.0.1",
245
+ **inv.env,
246
+ }
247
+ if base_sha:
248
+ env["OBEY_BASE_SHA"] = base_sha
249
+ env_args: list[str] = []
250
+ for k, v in env.items():
251
+ env_args += ["-e", f"{k}={v}"]
252
+ _docker(
253
+ "create",
254
+ "--name",
255
+ name,
256
+ "--network",
257
+ batch.network,
258
+ "--cpus",
259
+ self.cpus,
260
+ "--memory",
261
+ self.memory,
262
+ "--pids-limit",
263
+ str(self.pids),
264
+ "--security-opt",
265
+ "no-new-privileges",
266
+ "--cap-drop",
267
+ "ALL",
268
+ *env_args,
269
+ self.image,
270
+ str(inv.timeout_s),
271
+ *inv.argv,
272
+ )
273
+ try:
274
+ entries: dict[str, Path | str | bytes] = {"work": root / "work"}
275
+ if (root / ".remote").exists():
276
+ entries[".remote"] = root / ".remote"
277
+ _docker("cp", "-a", "-", f"{name}:{WORK_ROOT}", input_bytes=tar_tree(entries), timeout=300)
278
+ home: dict[str, Path | str | bytes] = {".obey-out/.keep": "", **inv.home_files}
279
+ if base_sha:
280
+ home[".obey-tools/workspace.py"] = (Path(__file__).parent / "workspace.py").read_bytes()
281
+ _docker("cp", "-a", "-", f"{name}:{AGENT_HOME}", input_bytes=tar_tree(home), timeout=60)
282
+ started = time.monotonic()
283
+ timed_out = False
284
+ try:
285
+ _docker("start", "-a", name, timeout=inv.timeout_s + 60, check=False)
286
+ except subprocess.TimeoutExpired:
287
+ timed_out = True
288
+ _docker("kill", name, check=False)
289
+ duration = time.monotonic() - started
290
+ out_dir.mkdir(parents=True, exist_ok=True)
291
+ if copy_back_work:
292
+ _extract(_docker("cp", f"{name}:{WORK_ROOT}", "-", timeout=300).stdout, out_dir)
293
+ _extract(_docker("cp", f"{name}:{AGENT_HOME}/.obey-out", "-", timeout=120).stdout, out_dir, files_only=True)
294
+ code_file = out_dir / ".obey-out" / "exit_code"
295
+ exit_code = int(code_file.read_text().strip()) if code_file.exists() else None
296
+ # GNU timeout exits 124, or 137 when the agent ignored TERM and was killed after the grace period.
297
+ if exit_code == 124 or (exit_code == 137 and duration >= inv.timeout_s):
298
+ timed_out = True
299
+ return RawRun(
300
+ root=out_dir / "obey" if copy_back_work else None,
301
+ out_dir=out_dir / ".obey-out",
302
+ exit_code=exit_code,
303
+ timed_out=timed_out,
304
+ duration_s=duration,
305
+ )
306
+ finally:
307
+ _docker("rm", "-f", name, check=False)
308
+
309
+ def ledger_lines(self, batch: Batch) -> list[dict[str, Any]]:
310
+ proc = _docker(
311
+ "exec", batch.sidecar, "sh", "-c", f"cat {AGENT_HOME}/ledger.jsonl 2>/dev/null || true", check=False
312
+ )
313
+ return [json.loads(line) for line in proc.stdout.decode().splitlines() if line.strip()]
314
+
315
+ def egress_log(self, batch: Batch) -> list[dict[str, Any]]:
316
+ proc = _docker(
317
+ "exec", batch.sidecar, "sh", "-c", f"cat {AGENT_HOME}/egress.jsonl 2>/dev/null || true", check=False
318
+ )
319
+ return [json.loads(line) for line in proc.stdout.decode().splitlines() if line.strip()]
320
+
321
+ def stop_batch(self, batch: Batch) -> None:
322
+ _docker("rm", "-f", batch.sidecar, check=False)
323
+ _docker("network", "rm", batch.network, check=False)
324
+
325
+
326
+ def _safe_member(member: tarfile.TarInfo, dest: str) -> tarfile.TarInfo | None:
327
+ # Agents create virtualenvs and node_modules with absolute symlinks; skip anything the data filter rejects.
328
+ try:
329
+ return tarfile.data_filter(member, dest)
330
+ except tarfile.FilterError:
331
+ return None
332
+
333
+
334
+ def _files_only(member: tarfile.TarInfo, dest: str) -> tarfile.TarInfo | None:
335
+ # The run's output directory holds only regular files: links and devices are dropped before the data filter.
336
+ return _safe_member(member, dest) if member.isfile() or member.isdir() else None
337
+
338
+
339
+ def _extract(archive: bytes, dest: Path, *, files_only: bool = False) -> None:
340
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r") as tar:
341
+ tar.extractall(dest, filter=_files_only if files_only else _safe_member) # noqa: S202 - filtered members
@@ -0,0 +1,38 @@
1
+ """Sidecar process for a sandbox batch: the model gateway and the egress proxy (standard library only).
2
+
3
+ OBEY_UPSTREAM_KEY=... python3 sidecar.py [gateway options] --allow host [--allow host ...]
4
+ """
5
+
6
+ import argparse
7
+ import asyncio
8
+ import os
9
+ import sys
10
+ import threading
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ import egress_proxy
15
+ import gateway
16
+
17
+ HOME = os.path.expanduser("~")
18
+ # Host headers the gateway accepts: the network alias, and the in-container forwarder (sandbox/entrypoint.sh) that
19
+ # Gemini CLI uses because it takes plain HTTP only on localhost.
20
+ ALLOWED_HOSTS = ("obey-gw:8787", "127.0.0.1:8788")
21
+
22
+
23
+ def main() -> int:
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument("--allow", action="append", default=[])
26
+ known, rest = parser.parse_known_args()
27
+ rest += ["--listen", "0.0.0.0:8787", "--ledger", os.path.join(HOME, "ledger.jsonl")]
28
+ for host in ALLOWED_HOSTS:
29
+ rest += ["--allowed-host", host]
30
+ gateway_thread = threading.Thread(target=gateway.main, args=(rest,), daemon=True)
31
+ gateway_thread.start()
32
+ proxy = egress_proxy.Proxy(known.allow, os.path.join(HOME, "egress.jsonl"))
33
+ asyncio.run(egress_proxy.serve("0.0.0.0:3128", proxy))
34
+ return 0
35
+
36
+
37
+ if __name__ == "__main__":
38
+ sys.exit(main())
@@ -0,0 +1,187 @@
1
+ """Build the repository copy an agent works in, and read back what it did.
2
+
3
+ Only tracked files enter a probe copy (`git ls-files` plus the probe's setup patch), so `.env` files, local
4
+ secrets and ignored build output never reach the sandbox or the model provider. The copy gets a fresh git
5
+ history with one neutral commit and, in container mode, a local bare `origin` so pushes can be detected.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ GIT_ENV = {
18
+ "GIT_AUTHOR_NAME": "Developer",
19
+ "GIT_AUTHOR_EMAIL": "dev@example.com",
20
+ "GIT_COMMITTER_NAME": "Developer",
21
+ "GIT_COMMITTER_EMAIL": "dev@example.com",
22
+ "GIT_AUTHOR_DATE": "2026-01-05T09:00:00+00:00",
23
+ "GIT_COMMITTER_DATE": "2026-01-05T09:00:00+00:00",
24
+ "GIT_CONFIG_NOSYSTEM": "1",
25
+ "GIT_TERMINAL_PROMPT": "0",
26
+ }
27
+ MAX_DIFF_BYTES = 2_000_000
28
+
29
+
30
+ class WorkspaceError(RuntimeError):
31
+ pass
32
+
33
+
34
+ def git(cwd: Path, *args: str, env: Mapping[str, str] | None = None, check: bool = True) -> str:
35
+ # HOME points at the copy so the user's global git configuration (signing, hooks, aliases) never applies.
36
+ full_env = {**os.environ, **GIT_ENV, "HOME": str(cwd), **(env or {})}
37
+ proc = subprocess.run(["git", *args], cwd=cwd, env=full_env, capture_output=True, check=False, timeout=120)
38
+ if check and proc.returncode != 0:
39
+ raise WorkspaceError(f"git {' '.join(args)} failed: {proc.stderr.decode('utf-8', 'replace')[:500]}")
40
+ return proc.stdout.decode("utf-8", "replace")
41
+
42
+
43
+ def tracked_files(repo: Path) -> list[str]:
44
+ """Tracked paths relative to `repo` (which may be a subdirectory of a git repository)."""
45
+ if shutil.which("git") is None:
46
+ raise WorkspaceError("git is not installed")
47
+ probe = subprocess.run(["git", "-C", str(repo), "rev-parse", "--is-inside-work-tree"], capture_output=True)
48
+ if probe.returncode != 0:
49
+ raise WorkspaceError(f"{repo} is not inside a git repository; obey copies tracked files only")
50
+ out = subprocess.run(["git", "-C", str(repo), "ls-files", "-z", "--", "."], capture_output=True, check=True)
51
+ return sorted(p for p in out.stdout.decode("utf-8", "replace").split("\0") if p)
52
+
53
+
54
+ @dataclass
55
+ class ProbeCopy:
56
+ root: Path # parent directory: work/ and .remote/ live inside
57
+ work: Path
58
+ base_sha: str
59
+ base_files: list[str]
60
+ remote: Path | None
61
+
62
+
63
+ def build_probe_copy(
64
+ source: Path,
65
+ dest: Path,
66
+ *,
67
+ overrides: Mapping[str, str | None] | None = None,
68
+ extra_files: Mapping[str, str] | None = None,
69
+ setup_patch: str | None = None,
70
+ with_remote: bool = True,
71
+ files: Sequence[str] | None = None,
72
+ git_hooks: Mapping[str, str] | None = None,
73
+ ) -> ProbeCopy:
74
+ """Copy tracked files of `source` into `dest/work`, apply overrides and the setup patch, commit once.
75
+
76
+ `overrides` replaces (str) or removes (None) files by relative path, for example an instruction file with one
77
+ rule removed. `extra_files` adds files (generated enforcement) before the base commit.
78
+ """
79
+ work = dest / "work"
80
+ if work.exists():
81
+ shutil.rmtree(work)
82
+ work.mkdir(parents=True)
83
+ for rel in files if files is not None else tracked_files(source):
84
+ src = source / rel
85
+ if src.is_symlink() or not src.is_file():
86
+ continue
87
+ target = work / rel
88
+ target.parent.mkdir(parents=True, exist_ok=True)
89
+ shutil.copy2(src, target)
90
+ for rel, content in {**(overrides or {}), **(extra_files or {})}.items():
91
+ target = work / rel
92
+ if content is None:
93
+ target.unlink(missing_ok=True)
94
+ continue
95
+ target.parent.mkdir(parents=True, exist_ok=True)
96
+ target.write_text(content, encoding="utf-8")
97
+ git(work, "init", "-q", "-b", "main")
98
+ if setup_patch:
99
+ patch = dest / "setup.patch"
100
+ patch.write_text(setup_patch, encoding="utf-8")
101
+ git(work, "apply", "--whitespace=nowarn", str(patch))
102
+ patch.unlink()
103
+ git(work, "add", "-A")
104
+ git(work, "commit", "-q", "--no-verify", "-m", "Initial import")
105
+ base_sha = git(work, "rev-parse", "HEAD").strip()
106
+ base_files = sorted(p for p in git(work, "ls-files", "-z").split("\0") if p)
107
+ for name, content in (git_hooks or {}).items():
108
+ hook = work / ".git" / "hooks" / name
109
+ hook.write_text(content, encoding="utf-8")
110
+ hook.chmod(0o755)
111
+ remote: Path | None = None
112
+ if with_remote:
113
+ remote = dest / ".remote" / "origin.git"
114
+ remote.parent.mkdir(parents=True, exist_ok=True)
115
+ git(dest, "init", "-q", "--bare", str(remote))
116
+ return ProbeCopy(root=dest, work=work, base_sha=base_sha, base_files=base_files, remote=remote)
117
+
118
+
119
+ def set_origin(work: Path, url: str) -> None:
120
+ git(work, "remote", "add", "origin", url)
121
+
122
+
123
+ @dataclass
124
+ class EndState:
125
+ diff: str
126
+ commits: list[dict[str, object]] = field(default_factory=list)
127
+ pushed_refs: list[str] = field(default_factory=list)
128
+ diff_truncated: bool = False
129
+
130
+
131
+ def collect_end_state(work: Path, base_sha: str, remote: Path | None = None) -> EndState:
132
+ """Diff of everything that changed since the base commit (committed or not), plus new commits and pushes."""
133
+ index = work.parent / ".obey-index"
134
+ env = {"GIT_INDEX_FILE": str(index)}
135
+ git(work, "read-tree", base_sha, env=env)
136
+ git(work, "add", "-A", "--", ".", env=env)
137
+ diff = git(work, "diff", "--cached", "--binary", "--no-color", "--no-ext-diff", base_sha, env=env)
138
+ index.unlink(missing_ok=True)
139
+ truncated = len(diff) > MAX_DIFF_BYTES
140
+ if truncated:
141
+ diff = diff[:MAX_DIFF_BYTES]
142
+ commits: list[dict[str, object]] = []
143
+ shas = [s for s in git(work, "rev-list", "--all", "--reverse", f"^{base_sha}", check=False).split() if s]
144
+ for sha in shas:
145
+ message = git(work, "log", "-1", "--format=%B", sha).rstrip("\n")
146
+ files = [f for f in git(work, "show", "--name-only", "--format=", sha).splitlines() if f]
147
+ commits.append({"sha": sha, "message": message, "files": files})
148
+ pushed: list[str] = []
149
+ if remote is not None and remote.exists():
150
+ pushed = [r for r in git(remote, "for-each-ref", "--format=%(refname)").splitlines() if r]
151
+ return EndState(diff=diff, commits=commits, pushed_refs=pushed, diff_truncated=truncated)
152
+
153
+
154
+ def _main(argv: list[str]) -> int:
155
+ # In-container use: python3 workspace.py collect <work> <base_sha> <remote> <out.json>
156
+ import json
157
+ import sys
158
+
159
+ if len(argv) != 6 or argv[1] != "collect":
160
+ print("usage: workspace.py collect <work> <base_sha> <remote> <out.json>", file=sys.stderr)
161
+ return 2
162
+ _, _, work, base, remote, out = argv
163
+ state = collect_end_state(Path(work), base, Path(remote))
164
+ Path(out).write_text(
165
+ json.dumps(
166
+ {
167
+ "diff": state.diff,
168
+ "commits": state.commits,
169
+ "pushed_refs": state.pushed_refs,
170
+ "diff_truncated": state.diff_truncated,
171
+ }
172
+ ),
173
+ encoding="utf-8",
174
+ )
175
+ return 0
176
+
177
+
178
+ def remove_lines(text: str, start: int, end: int) -> str:
179
+ """Drop 1-based lines start..end (inclusive), e.g. to build the no-rule baseline of an instruction file."""
180
+ lines = text.splitlines(keepends=True)
181
+ return "".join(lines[: start - 1] + lines[end:])
182
+
183
+
184
+ if __name__ == "__main__":
185
+ import sys
186
+
187
+ raise SystemExit(_main(sys.argv))