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
obey_cli/runner/local.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""--unsafe-local: run the agent on this machine without a container.
|
|
2
|
+
|
|
3
|
+
For users without Docker. Each run gets a fresh copy of the tracked files in a temporary directory with no git
|
|
4
|
+
remotes, an empty HOME, no credential environment variables and no git credential helpers. The agent's own sandbox
|
|
5
|
+
mode is switched on where it has one (Claude Code, Codex CLI, Gemini CLI). The model gateway runs in this process on
|
|
6
|
+
127.0.0.1, so the API key still never reaches the agent. There is no egress allow-list: commands the agent runs can
|
|
7
|
+
reach the network. Probes for "never commit" and "never push" are refused in this mode.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from obey_cli.runner import gateway
|
|
26
|
+
from obey_cli.runner.sandbox import Invocation, RawRun
|
|
27
|
+
from obey_cli.runner.workspace import collect_end_state
|
|
28
|
+
|
|
29
|
+
KEEP_ENV = ("PATH", "LANG", "LC_ALL", "TZ", "TMPDIR")
|
|
30
|
+
REFUSED_TYPES = ("no_commit", "no_push")
|
|
31
|
+
WARNING = (
|
|
32
|
+
"obey: --unsafe-local runs the agent directly on this machine. It works in a temporary copy with an empty HOME and "
|
|
33
|
+
"no credentials, and turns on the agent's own sandbox where it has one, but commands it runs can still reach the "
|
|
34
|
+
"network and anything your user can read. Prefer the default container sandbox."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LocalBatch:
|
|
40
|
+
server: Any
|
|
41
|
+
config: gateway.Config
|
|
42
|
+
ledger_path: Path
|
|
43
|
+
gateway_url: str
|
|
44
|
+
homes: list[Path] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LocalSandbox:
|
|
48
|
+
def start_batch(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
key: str,
|
|
52
|
+
model: str | None,
|
|
53
|
+
price: Mapping[str, float],
|
|
54
|
+
allow_hosts: Sequence[str],
|
|
55
|
+
run_cap: float | None,
|
|
56
|
+
budget: float | None,
|
|
57
|
+
upstreams: Mapping[str, str] | None = None,
|
|
58
|
+
run_ids: Sequence[str] | None = None,
|
|
59
|
+
) -> LocalBatch:
|
|
60
|
+
ledger_path = Path(tempfile.mkdtemp(prefix="obey-ledger-")) / "ledger.jsonl"
|
|
61
|
+
config = gateway.Config(
|
|
62
|
+
key,
|
|
63
|
+
model,
|
|
64
|
+
str(ledger_path),
|
|
65
|
+
dict(price),
|
|
66
|
+
run_cap,
|
|
67
|
+
budget,
|
|
68
|
+
{**gateway.UPSTREAMS, **(upstreams or {})},
|
|
69
|
+
run_ids=run_ids,
|
|
70
|
+
)
|
|
71
|
+
server = gateway.make_server("127.0.0.1:0", config)
|
|
72
|
+
port = server.server_address[1]
|
|
73
|
+
config.allowed_hosts = [f"127.0.0.1:{port}", f"localhost:{port}"]
|
|
74
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
75
|
+
return LocalBatch(server=server, config=config, ledger_path=ledger_path, gateway_url=f"http://127.0.0.1:{port}")
|
|
76
|
+
|
|
77
|
+
def run(
|
|
78
|
+
self,
|
|
79
|
+
batch: LocalBatch,
|
|
80
|
+
root: Path,
|
|
81
|
+
inv: Invocation,
|
|
82
|
+
out_dir: Path,
|
|
83
|
+
run_id: str,
|
|
84
|
+
*,
|
|
85
|
+
base_sha: str | None = None,
|
|
86
|
+
copy_back_work: bool = False,
|
|
87
|
+
) -> RawRun:
|
|
88
|
+
batch.config.register_run(run_id)
|
|
89
|
+
work = root / "work"
|
|
90
|
+
home = root / "home"
|
|
91
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
for rel, content in inv.home_files.items():
|
|
93
|
+
target = home / rel
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
target.write_text(content if isinstance(content, str) else content.decode(), encoding="utf-8")
|
|
96
|
+
# Strip the copy's remotes and credential helpers: nothing to push to, nothing to log in with.
|
|
97
|
+
for remote in subprocess.run(
|
|
98
|
+
["git", "remote"], cwd=work, capture_output=True, text=True, check=False
|
|
99
|
+
).stdout.split():
|
|
100
|
+
subprocess.run(["git", "remote", "remove", remote], cwd=work, check=False)
|
|
101
|
+
env = {k: os.environ[k] for k in KEEP_ENV if k in os.environ}
|
|
102
|
+
env.update(
|
|
103
|
+
{
|
|
104
|
+
"HOME": str(home),
|
|
105
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
106
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
107
|
+
"GIT_CONFIG_GLOBAL": str(home / ".gitconfig"),
|
|
108
|
+
"TERM": "dumb",
|
|
109
|
+
**inv.env,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
(home / ".gitconfig").write_text(
|
|
113
|
+
"[user]\n\tname = Developer\n\temail = dev@example.com\n[credential]\n\thelper =\n", encoding="utf-8"
|
|
114
|
+
)
|
|
115
|
+
out = out_dir / ".obey-out"
|
|
116
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
started = time.monotonic()
|
|
118
|
+
timed_out = False
|
|
119
|
+
(home / ".obey-out").mkdir(exist_ok=True)
|
|
120
|
+
code: int | None = None
|
|
121
|
+
with (out / "stream.jsonl").open("wb") as stdout, (out / "stderr.log").open("wb") as stderr:
|
|
122
|
+
# A session of its own, so a timeout (or the end of the run) also stops what the agent left running.
|
|
123
|
+
proc = subprocess.Popen(
|
|
124
|
+
inv.argv,
|
|
125
|
+
cwd=work,
|
|
126
|
+
env=env,
|
|
127
|
+
stdin=subprocess.DEVNULL,
|
|
128
|
+
stdout=stdout,
|
|
129
|
+
stderr=stderr,
|
|
130
|
+
start_new_session=True,
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
code = proc.wait(timeout=inv.timeout_s)
|
|
134
|
+
except subprocess.TimeoutExpired:
|
|
135
|
+
timed_out = True
|
|
136
|
+
finally:
|
|
137
|
+
_kill_group(proc)
|
|
138
|
+
duration = time.monotonic() - started
|
|
139
|
+
if code is not None:
|
|
140
|
+
(out / "exit_code").write_text(str(code), encoding="utf-8")
|
|
141
|
+
if base_sha:
|
|
142
|
+
state = collect_end_state(work, base_sha, None)
|
|
143
|
+
(out / "end-state.json").write_text(
|
|
144
|
+
json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"diff": state.diff,
|
|
147
|
+
"commits": state.commits,
|
|
148
|
+
"pushed_refs": [],
|
|
149
|
+
"diff_truncated": state.diff_truncated,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
hook_log = Path(inv.env.get("OBEY_HOOK_LOG", ""))
|
|
154
|
+
if hook_log.name and hook_log.exists():
|
|
155
|
+
(out / "hook-log.jsonl").write_bytes(hook_log.read_bytes())
|
|
156
|
+
return RawRun(
|
|
157
|
+
root=root if copy_back_work else None, out_dir=out, exit_code=code, timed_out=timed_out, duration_s=duration
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def ledger_lines(self, batch: LocalBatch) -> list[dict[str, Any]]:
|
|
161
|
+
if not batch.ledger_path.exists():
|
|
162
|
+
return []
|
|
163
|
+
return [json.loads(line) for line in batch.ledger_path.read_text().splitlines() if line.strip()]
|
|
164
|
+
|
|
165
|
+
def egress_log(self, batch: LocalBatch) -> list[dict[str, Any]]:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
def stop_batch(self, batch: LocalBatch) -> None:
|
|
169
|
+
batch.server.shutdown()
|
|
170
|
+
batch.server.server_close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _kill_group(proc: subprocess.Popen[bytes]) -> None:
|
|
174
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
175
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
176
|
+
proc.wait()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Plan and execute probe runs: arms, trials, concurrency, budget, and scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from obey_cli.runner.ledger import BudgetExceeded, Entry, Ledger
|
|
16
|
+
from obey_cli.runner.workspace import remove_lines
|
|
17
|
+
|
|
18
|
+
# Rough tokens per run by agent, used only for --estimate before any run has been measured.
|
|
19
|
+
EST_TOKENS = {
|
|
20
|
+
"claude-code": (450_000, 6_000),
|
|
21
|
+
"codex": (250_000, 5_000),
|
|
22
|
+
"opencode": (200_000, 4_000),
|
|
23
|
+
"copilot": (250_000, 5_000),
|
|
24
|
+
"gemini": (250_000, 5_000),
|
|
25
|
+
}
|
|
26
|
+
CACHE_SHARE = 0.85
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PlannedRun:
|
|
31
|
+
run_id: str
|
|
32
|
+
rule: dict[str, Any]
|
|
33
|
+
probe: dict[str, Any]
|
|
34
|
+
arm: str # rule | baseline | enforced
|
|
35
|
+
trial: int
|
|
36
|
+
overrides: dict[str, str | None] = field(default_factory=dict)
|
|
37
|
+
extra_files: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
git_hooks: dict[str, str] = field(default_factory=dict)
|
|
39
|
+
condition: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rid(*parts: object) -> str:
|
|
43
|
+
stamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
44
|
+
text = "-".join(str(p) for p in parts)
|
|
45
|
+
import hashlib
|
|
46
|
+
|
|
47
|
+
return f"{stamp}-{hashlib.sha1(text.encode()).hexdigest()[:10]}" # noqa: S324 - an identifier
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def plan_runs(
|
|
51
|
+
repo: Path,
|
|
52
|
+
rules: Sequence[dict[str, Any]],
|
|
53
|
+
*,
|
|
54
|
+
trials: int,
|
|
55
|
+
baseline_trials: int,
|
|
56
|
+
enforced: Mapping[str, str] | None = None,
|
|
57
|
+
enforced_hooks: Mapping[str, str] | None = None,
|
|
58
|
+
only_enforced: bool = False,
|
|
59
|
+
) -> list[PlannedRun]:
|
|
60
|
+
"""One PlannedRun per (rule, probe, arm, trial). `enforced` maps repository paths to enforcement file contents."""
|
|
61
|
+
planned: list[PlannedRun] = []
|
|
62
|
+
for rule in rules:
|
|
63
|
+
for probe in rule.get("probes") or []:
|
|
64
|
+
if not only_enforced:
|
|
65
|
+
for t in range(trials):
|
|
66
|
+
planned.append(PlannedRun(_rid(rule["id"], probe["id"], "rule", t), rule, probe, "rule", t))
|
|
67
|
+
src = rule["source"]
|
|
68
|
+
path = repo / src["file"]
|
|
69
|
+
if baseline_trials and path.exists():
|
|
70
|
+
text = remove_lines(path.read_text(encoding="utf-8"), src["lines"][0], src["lines"][1])
|
|
71
|
+
for t in range(baseline_trials):
|
|
72
|
+
planned.append(
|
|
73
|
+
PlannedRun(
|
|
74
|
+
_rid(rule["id"], probe["id"], "baseline", t),
|
|
75
|
+
rule,
|
|
76
|
+
probe,
|
|
77
|
+
"baseline",
|
|
78
|
+
t,
|
|
79
|
+
overrides={src["file"]: text},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
if enforced is not None:
|
|
83
|
+
for t in range(trials):
|
|
84
|
+
planned.append(
|
|
85
|
+
PlannedRun(
|
|
86
|
+
_rid(rule["id"], probe["id"], "enforced", t),
|
|
87
|
+
rule,
|
|
88
|
+
probe,
|
|
89
|
+
"enforced",
|
|
90
|
+
t,
|
|
91
|
+
extra_files=dict(enforced),
|
|
92
|
+
git_hooks=dict(enforced_hooks or {}),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return planned
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def estimate(
|
|
99
|
+
planned: Sequence[PlannedRun], agent: str, price: Mapping[str, float], measured_per_run: float | None = None
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
if measured_per_run is not None:
|
|
102
|
+
per_run = measured_per_run
|
|
103
|
+
source = "measured from previous runs"
|
|
104
|
+
else:
|
|
105
|
+
tokens_in, tokens_out = EST_TOKENS.get(agent, (300_000, 5_000))
|
|
106
|
+
cached = tokens_in * CACHE_SHARE
|
|
107
|
+
fresh = tokens_in - cached
|
|
108
|
+
per_run = (
|
|
109
|
+
fresh * price.get("prompt", 0.0)
|
|
110
|
+
+ cached * price.get("cache_read", price.get("prompt", 0.0))
|
|
111
|
+
+ tokens_out * price.get("completion", 0.0)
|
|
112
|
+
)
|
|
113
|
+
source = "default token profile × model price"
|
|
114
|
+
return {"runs": len(planned), "per_run_usd": per_run, "total_usd": per_run * len(planned), "source": source}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def model_price(model: str, client: Any = None) -> dict[str, float]:
|
|
118
|
+
"""USD per token for an OpenRouter model id (empty when unknown or offline)."""
|
|
119
|
+
import httpx
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
resp = (client or httpx).get("https://openrouter.ai/api/v1/models", timeout=20)
|
|
123
|
+
for m in resp.json().get("data", []):
|
|
124
|
+
if m.get("id") == model:
|
|
125
|
+
p = m.get("pricing") or {}
|
|
126
|
+
out = {"prompt": float(p.get("prompt") or 0), "completion": float(p.get("completion") or 0)}
|
|
127
|
+
if p.get("input_cache_read"):
|
|
128
|
+
out["cache_read"] = float(p["input_cache_read"])
|
|
129
|
+
if p.get("input_cache_write"):
|
|
130
|
+
out["cache_write"] = float(p["input_cache_write"])
|
|
131
|
+
return out
|
|
132
|
+
except (httpx.HTTPError, ValueError):
|
|
133
|
+
pass
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Execution:
|
|
139
|
+
records: list[dict[str, Any]]
|
|
140
|
+
stopped: str | None # why the batch stopped early, if it did
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def execute_all(
|
|
144
|
+
planned: Sequence[PlannedRun],
|
|
145
|
+
run_one: Callable[[PlannedRun], dict[str, Any]],
|
|
146
|
+
*,
|
|
147
|
+
ledger: Ledger,
|
|
148
|
+
per_run_estimate: float,
|
|
149
|
+
concurrency: int,
|
|
150
|
+
out: Path,
|
|
151
|
+
progress: Callable[[str], None] = print,
|
|
152
|
+
) -> Execution:
|
|
153
|
+
"""Run everything with a thread pool, writing each record as it finishes and stopping before the budget."""
|
|
154
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
lock = threading.Lock()
|
|
156
|
+
records: list[dict[str, Any]] = []
|
|
157
|
+
stop: list[str] = []
|
|
158
|
+
started = time.monotonic()
|
|
159
|
+
|
|
160
|
+
def guarded(run: PlannedRun) -> dict[str, Any] | None:
|
|
161
|
+
if stop:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
with lock:
|
|
165
|
+
ledger.check(per_run_estimate)
|
|
166
|
+
except BudgetExceeded as exc:
|
|
167
|
+
stop.append(str(exc))
|
|
168
|
+
return None
|
|
169
|
+
record = run_one(run)
|
|
170
|
+
with lock:
|
|
171
|
+
ledger.record(
|
|
172
|
+
Entry(
|
|
173
|
+
ts=time.time(),
|
|
174
|
+
run_id=record["run_id"],
|
|
175
|
+
kind="agent",
|
|
176
|
+
model=record.get("model", ""),
|
|
177
|
+
input_tokens=record["usage"]["input_tokens"],
|
|
178
|
+
output_tokens=record["usage"]["output_tokens"],
|
|
179
|
+
cache_read_tokens=record["usage"]["cache_read_tokens"],
|
|
180
|
+
cost_usd=record["usage"]["cost_usd"],
|
|
181
|
+
cost_source="provider",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
with out.open("a", encoding="utf-8") as fh:
|
|
185
|
+
fh.write(json.dumps(record) + "\n")
|
|
186
|
+
return record
|
|
187
|
+
|
|
188
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
|
|
189
|
+
futures = {pool.submit(guarded, run): run for run in planned}
|
|
190
|
+
for done, future in enumerate(as_completed(futures), 1):
|
|
191
|
+
record = future.result()
|
|
192
|
+
if record is not None:
|
|
193
|
+
records.append(record)
|
|
194
|
+
elapsed = time.monotonic() - started
|
|
195
|
+
progress(
|
|
196
|
+
f"[{done}/{len(planned)}] {record['arm']:8s} {record['rule_id'][:40]:40s} "
|
|
197
|
+
f"{record['status']:7s} ${record['usage']['cost_usd']:.4f} ({elapsed:.0f}s)"
|
|
198
|
+
)
|
|
199
|
+
return Execution(records=records, stopped=stop[0] if stop else None)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def outcomes(records: Iterable[dict[str, Any]], rules: Mapping[str, dict[str, Any]]) -> list[Any]:
|
|
203
|
+
"""Score every run against its own rule with the deterministic checker."""
|
|
204
|
+
from obey_cli.checkers import check
|
|
205
|
+
from obey_cli.score import Outcome
|
|
206
|
+
|
|
207
|
+
result = []
|
|
208
|
+
for rec in records:
|
|
209
|
+
rule = rules.get(rec["rule_id"])
|
|
210
|
+
if rule is None:
|
|
211
|
+
continue
|
|
212
|
+
verdict = check(rule["checker"], rec)
|
|
213
|
+
denied = sum(1 for h in rec.get("hook_log") or [] if h.get("decision") in ("deny", "block"))
|
|
214
|
+
result.append(
|
|
215
|
+
Outcome(
|
|
216
|
+
rule_id=rec["rule_id"],
|
|
217
|
+
agent=rec["agent"],
|
|
218
|
+
arm=rec["arm"],
|
|
219
|
+
verdict=verdict.verdict,
|
|
220
|
+
run_id=rec["run_id"],
|
|
221
|
+
denied=denied,
|
|
222
|
+
evidence=verdict.evidence,
|
|
223
|
+
condition=rec.get("condition"),
|
|
224
|
+
status=rec.get("status", "ok"),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return result
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Default obey sandbox: Python, Node, Go, git and the headless agent CLIs, run as a non-root user.
|
|
2
|
+
# Build: obey doctor --build-image (or: docker build -t obey-sandbox:<version> <this directory>)
|
|
3
|
+
ARG GO_IMAGE=golang:1.27.3-trixie
|
|
4
|
+
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.9
|
|
5
|
+
FROM ${GO_IMAGE} AS go
|
|
6
|
+
FROM ${UV_IMAGE} AS uv
|
|
7
|
+
|
|
8
|
+
FROM node:24.22.1-trixie-slim
|
|
9
|
+
ARG CLAUDE_CODE_VERSION=2.1.309
|
|
10
|
+
ARG CODEX_VERSION=0.164.0
|
|
11
|
+
ARG OPENCODE_VERSION=1.19.4
|
|
12
|
+
ARG GEMINI_CLI_VERSION=0.65.0
|
|
13
|
+
ARG COPILOT_CLI_VERSION=1.0.103
|
|
14
|
+
ARG PNPM_VERSION=9.15.9
|
|
15
|
+
ENV DEBIAN_FRONTEND=noninteractive \
|
|
16
|
+
PATH=/usr/local/go/bin:/home/agent/.local/bin:$PATH \
|
|
17
|
+
UV_PYTHON_DOWNLOADS=never \
|
|
18
|
+
UV_LINK_MODE=copy \
|
|
19
|
+
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
|
20
|
+
NODE_USE_ENV_PROXY=1 \
|
|
21
|
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
22
|
+
RUN apt-get update \
|
|
23
|
+
&& apt-get install -y --no-install-recommends git ca-certificates python3 python3-venv python3-pip ripgrep \
|
|
24
|
+
procps less jq patch make \
|
|
25
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
26
|
+
&& usermod -l agent -d /home/agent -m node && groupmod -n agent node
|
|
27
|
+
COPY --from=go /usr/local/go /usr/local/go
|
|
28
|
+
COPY --from=uv /uv /uvx /usr/local/bin/
|
|
29
|
+
RUN set -e; pkgs="pnpm@$PNPM_VERSION"; \
|
|
30
|
+
[ -n "$CLAUDE_CODE_VERSION" ] && pkgs="$pkgs @anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"; \
|
|
31
|
+
[ -n "$CODEX_VERSION" ] && pkgs="$pkgs @openai/codex@$CODEX_VERSION"; \
|
|
32
|
+
[ -n "$OPENCODE_VERSION" ] && pkgs="$pkgs opencode-ai@$OPENCODE_VERSION"; \
|
|
33
|
+
[ -n "$GEMINI_CLI_VERSION" ] && pkgs="$pkgs @google/gemini-cli@$GEMINI_CLI_VERSION"; \
|
|
34
|
+
[ -n "$COPILOT_CLI_VERSION" ] && pkgs="$pkgs @github/copilot@$COPILOT_CLI_VERSION"; \
|
|
35
|
+
npm install -g --omit=dev $pkgs && npm cache clean --force
|
|
36
|
+
COPY entrypoint.sh /usr/local/bin/obey-entrypoint
|
|
37
|
+
RUN chmod 755 /usr/local/bin/obey-entrypoint && mkdir -p /srv/obey/work && chown -R agent:agent /home/agent /srv/obey
|
|
38
|
+
USER agent
|
|
39
|
+
# The repository copy lives outside $HOME, so files in $HOME are never mistaken for a parent project's files.
|
|
40
|
+
WORKDIR /srv/obey/work
|
|
41
|
+
ENTRYPOINT ["/usr/local/bin/obey-entrypoint"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Runs one agent command with a deadline and keeps its stdout/stderr for obey to read back.
|
|
3
|
+
# Usage: obey-entrypoint <timeout-seconds> <command> [args...]
|
|
4
|
+
set -u
|
|
5
|
+
deadline="$1"; shift
|
|
6
|
+
mkdir -p /home/agent/.obey-out
|
|
7
|
+
cd /srv/obey/work || exit 97
|
|
8
|
+
if [ -n "${OBEY_LOCAL_FORWARD:-}" ]; then
|
|
9
|
+
# Some CLIs accept plain HTTP only on localhost: forward 127.0.0.1:$OBEY_LOCAL_FORWARD to the gateway.
|
|
10
|
+
python3 - "$OBEY_LOCAL_FORWARD" <<'PY' &
|
|
11
|
+
import asyncio, sys
|
|
12
|
+
async def pipe(r, w):
|
|
13
|
+
try:
|
|
14
|
+
while (data := await r.read(65536)):
|
|
15
|
+
w.write(data); await w.drain()
|
|
16
|
+
finally:
|
|
17
|
+
w.close()
|
|
18
|
+
async def handle(r, w):
|
|
19
|
+
ur, uw = await asyncio.open_connection("obey-gw", 8787)
|
|
20
|
+
await asyncio.gather(pipe(r, uw), pipe(ur, w))
|
|
21
|
+
async def main():
|
|
22
|
+
server = await asyncio.start_server(handle, "127.0.0.1", int(sys.argv[1]))
|
|
23
|
+
async with server:
|
|
24
|
+
await server.serve_forever()
|
|
25
|
+
asyncio.run(main())
|
|
26
|
+
PY
|
|
27
|
+
sleep 0.3
|
|
28
|
+
fi
|
|
29
|
+
timeout --signal=TERM --kill-after=10 "$deadline" "$@" \
|
|
30
|
+
< /dev/null > /home/agent/.obey-out/stream.jsonl 2> /home/agent/.obey-out/stderr.log
|
|
31
|
+
code=$?
|
|
32
|
+
echo "$code" > /home/agent/.obey-out/exit_code
|
|
33
|
+
if [ -f /home/agent/.obey-tools/workspace.py ]; then
|
|
34
|
+
# Read the end state here, so only small JSON files travel back to the host.
|
|
35
|
+
python3 /home/agent/.obey-tools/workspace.py collect /srv/obey/work "${OBEY_BASE_SHA:-HEAD}" \
|
|
36
|
+
/srv/obey/.remote/origin.git /home/agent/.obey-out/end-state.json 2> /home/agent/.obey-out/collect.log
|
|
37
|
+
fi
|
|
38
|
+
exit 0
|