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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|