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/rules.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build and merge obey.yaml rule lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from obey_cli import __version__
|
|
10
|
+
from obey_cli.config import DEFAULT_ALLOW_HOSTS
|
|
11
|
+
from obey_cli.discover import InstructionFile, discover
|
|
12
|
+
from obey_cli.extract import Extracted, extract
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sources(files: Iterable[InstructionFile]) -> list[dict[str, Any]]:
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
"file": f.path,
|
|
19
|
+
"sha256": f.sha256,
|
|
20
|
+
"kind": f.kind,
|
|
21
|
+
"scope": list(f.scope_paths),
|
|
22
|
+
"loaded_by": list(f.loaded_by),
|
|
23
|
+
}
|
|
24
|
+
for f in files
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def merge(fresh: Sequence[dict[str, Any]], previous: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
29
|
+
"""Keep what a person changed in obey.yaml (status, checker, probes) for rules whose text did not change."""
|
|
30
|
+
old = {r["id"]: r for r in previous}
|
|
31
|
+
merged = []
|
|
32
|
+
for rule in fresh:
|
|
33
|
+
before = old.get(rule["id"])
|
|
34
|
+
if before is None:
|
|
35
|
+
merged.append(rule)
|
|
36
|
+
continue
|
|
37
|
+
keep = dict(rule)
|
|
38
|
+
for key in ("status", "checker", "reason", "probes", "scope"):
|
|
39
|
+
if key in before:
|
|
40
|
+
keep[key] = before[key]
|
|
41
|
+
if before.get("origin") == "user":
|
|
42
|
+
keep["origin"] = "user"
|
|
43
|
+
merged.append(keep)
|
|
44
|
+
# Rules a person added by hand survive re-extraction.
|
|
45
|
+
merged += [r for r in previous if r.get("origin") == "user" and r["id"] not in {m["id"] for m in merged}]
|
|
46
|
+
return merged
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_config(
|
|
50
|
+
root: Path, previous: dict[str, Any] | None = None, include_local: bool = False, proposals: Sequence[Extracted] = ()
|
|
51
|
+
) -> tuple[dict[str, Any], list[InstructionFile]]:
|
|
52
|
+
files = discover(root, include_local=include_local)
|
|
53
|
+
items = extract(files, root)
|
|
54
|
+
# A proposal replaces the uncheckable rule it was made for (same text, so the same id).
|
|
55
|
+
proposed = {r["id"]: r for r in (p.as_rule() for p in proposals)}
|
|
56
|
+
fresh = [proposed.pop(r["id"], r) for r in (x.as_rule() for x in items)] + list(proposed.values())
|
|
57
|
+
rules = merge(fresh, (previous or {}).get("rules") or [])
|
|
58
|
+
config: dict[str, Any] = {
|
|
59
|
+
"version": 1,
|
|
60
|
+
"generated_by": f"obey {__version__}",
|
|
61
|
+
"sources": sources(files),
|
|
62
|
+
"sandbox": (previous or {}).get("sandbox")
|
|
63
|
+
or {"image": None, "allow_hosts": list(DEFAULT_ALLOW_HOSTS), "timeout_s": 600},
|
|
64
|
+
"rules": rules,
|
|
65
|
+
}
|
|
66
|
+
if previous and previous.get("agents"):
|
|
67
|
+
config["agents"] = previous["agents"]
|
|
68
|
+
return config, files
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def counts(rules: Iterable[dict[str, Any]]) -> dict[str, int]:
|
|
72
|
+
out: dict[str, int] = {}
|
|
73
|
+
for r in rules:
|
|
74
|
+
out[r["status"]] = out.get(r["status"], 0) + 1
|
|
75
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Agent adapters by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from obey_cli.runner.agents.base import Adapter, ParsedStream, RunContext
|
|
6
|
+
from obey_cli.runner.agents.claude_code import ClaudeCode
|
|
7
|
+
from obey_cli.runner.agents.codex import Codex
|
|
8
|
+
from obey_cli.runner.agents.copilot import Copilot
|
|
9
|
+
from obey_cli.runner.agents.gemini import Gemini
|
|
10
|
+
from obey_cli.runner.agents.opencode import OpenCode
|
|
11
|
+
|
|
12
|
+
ADAPTERS: dict[str, type[Adapter]] = {a.name: a for a in (ClaudeCode, Codex, OpenCode, Gemini, Copilot)}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get(name: str) -> Adapter:
|
|
16
|
+
try:
|
|
17
|
+
return ADAPTERS[name]()
|
|
18
|
+
except KeyError:
|
|
19
|
+
raise ValueError(f"unknown agent {name!r}; choose from {', '.join(sorted(ADAPTERS))}") from None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["ADAPTERS", "Adapter", "ParsedStream", "RunContext", "get"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Common interface for agent adapters: how to invoke an agent headless, and how to read its stream back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from obey_cli.runner.sandbox import AGENT_HOME, WORK_ROOT, Invocation
|
|
11
|
+
|
|
12
|
+
WORKDIR = f"{WORK_ROOT}/work"
|
|
13
|
+
DUMMY_TOKEN = "obey-dummy-token" # noqa: S105 - agents never see a real key; the gateway swaps it
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ParsedStream:
|
|
18
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
19
|
+
final_message: str = ""
|
|
20
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
instruction_files_loaded: list[str] | None = None # None: the agent does not report it
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RunContext:
|
|
27
|
+
run_id: str
|
|
28
|
+
model: str
|
|
29
|
+
gateway_url: str # e.g. http://obey-gw:8787
|
|
30
|
+
timeout_s: int = 600
|
|
31
|
+
enforced: bool = False
|
|
32
|
+
workdir: str = WORKDIR # where the repository copy is, as the agent sees it
|
|
33
|
+
home: str = AGENT_HOME # the agent's (empty) home directory
|
|
34
|
+
local: bool = False # --unsafe-local: no container, so the agent's own sandbox must be on
|
|
35
|
+
|
|
36
|
+
def base(self, api: str) -> str:
|
|
37
|
+
return f"{self.gateway_url}/r/{self.run_id}/{api}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Adapter:
|
|
41
|
+
"""Subclasses set the class attributes and implement `invocation` and `parse`."""
|
|
42
|
+
|
|
43
|
+
name = ""
|
|
44
|
+
display = ""
|
|
45
|
+
binary = ""
|
|
46
|
+
api = "openai" # which gateway skin the agent speaks: anthropic | openai | gemini
|
|
47
|
+
instruction_files: tuple[str, ...] = ()
|
|
48
|
+
# Decoy global instruction files: if isolation works the agent never sees them (contract check).
|
|
49
|
+
decoys: dict[str, str] = {} # noqa: RUF012 - read-only class data
|
|
50
|
+
|
|
51
|
+
def invocation(self, prompt: str, ctx: RunContext) -> Invocation:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def parse(self, stream: str, stderr: str = "", workdir: str = WORKDIR) -> ParsedStream:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def version_argv(self) -> list[str]:
|
|
58
|
+
return [self.binary, "--version"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def as_dict(value: Any) -> dict[str, Any]:
|
|
62
|
+
return value if isinstance(value, dict) else {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def json_lines(text: str) -> Iterator[dict[str, Any]]:
|
|
66
|
+
for line in text.splitlines():
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line.startswith("{"):
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
obj = json.loads(line)
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
if isinstance(obj, dict):
|
|
75
|
+
yield obj
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_WORKDIR = [WORKDIR]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_workdir(path: str) -> None:
|
|
82
|
+
"""Where relpath() strips from (the container path by default; a temporary clone in --unsafe-local)."""
|
|
83
|
+
_WORKDIR[0] = path.rstrip("/")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def relpath(path: str) -> str:
|
|
87
|
+
"""Repository-relative POSIX path for an absolute path inside the work directory."""
|
|
88
|
+
p = path.replace("\\", "/")
|
|
89
|
+
for prefix in (_WORKDIR[0] + "/", WORKDIR + "/", "./"):
|
|
90
|
+
if p.startswith(prefix):
|
|
91
|
+
p = p[len(prefix) :]
|
|
92
|
+
return p
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def truncate(text: str, limit: int = 4000) -> str:
|
|
96
|
+
return text if len(text) <= limit else text[:limit] + f"\n… [{len(text) - limit} more characters]"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Claude Code: `claude -p --output-format stream-json --verbose`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from obey_cli.runner.agents.base import (
|
|
9
|
+
DUMMY_TOKEN,
|
|
10
|
+
WORKDIR,
|
|
11
|
+
Adapter,
|
|
12
|
+
ParsedStream,
|
|
13
|
+
RunContext,
|
|
14
|
+
json_lines,
|
|
15
|
+
relpath,
|
|
16
|
+
set_workdir,
|
|
17
|
+
truncate,
|
|
18
|
+
)
|
|
19
|
+
from obey_cli.runner.sandbox import AGENT_HOME, Invocation
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = f"{AGENT_HOME}/.obey-claude"
|
|
22
|
+
_EXIT_RE = re.compile(r"(?:^|\n)\s*(?:Error: )?Exit code (\d+)", re.IGNORECASE)
|
|
23
|
+
FILE_TOOLS = {"Edit": "edit", "MultiEdit": "edit", "Write": "write", "NotebookEdit": "edit"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClaudeCode(Adapter):
|
|
27
|
+
name = "claude-code"
|
|
28
|
+
display = "Claude Code"
|
|
29
|
+
binary = "claude"
|
|
30
|
+
api = "anthropic"
|
|
31
|
+
instruction_files = ("CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md (only when no CLAUDE.md exists)")
|
|
32
|
+
decoys = {".claude/CLAUDE.md": "End every final message with the line `global-config-leaked`.\n"} # noqa: RUF012
|
|
33
|
+
|
|
34
|
+
def invocation(self, prompt: str, ctx: RunContext) -> Invocation:
|
|
35
|
+
argv = [
|
|
36
|
+
self.binary,
|
|
37
|
+
"-p",
|
|
38
|
+
prompt,
|
|
39
|
+
"--output-format",
|
|
40
|
+
"stream-json",
|
|
41
|
+
"--verbose",
|
|
42
|
+
"--permission-mode",
|
|
43
|
+
"bypassPermissions",
|
|
44
|
+
"--include-hook-events",
|
|
45
|
+
"--model",
|
|
46
|
+
ctx.model,
|
|
47
|
+
]
|
|
48
|
+
env = {
|
|
49
|
+
"ANTHROPIC_BASE_URL": ctx.base("anthropic"),
|
|
50
|
+
"ANTHROPIC_AUTH_TOKEN": DUMMY_TOKEN,
|
|
51
|
+
"ANTHROPIC_API_KEY": "",
|
|
52
|
+
"ANTHROPIC_MODEL": ctx.model,
|
|
53
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": ctx.model,
|
|
54
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": ctx.model,
|
|
55
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": ctx.model,
|
|
56
|
+
"CLAUDE_CODE_SUBAGENT_MODEL": ctx.model,
|
|
57
|
+
# An empty config directory: no global CLAUDE.md, settings, hooks, plugins or MCP servers.
|
|
58
|
+
"CLAUDE_CONFIG_DIR": f"{ctx.home}/.obey-claude",
|
|
59
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
60
|
+
"DISABLE_AUTOUPDATER": "1",
|
|
61
|
+
"DISABLE_TELEMETRY": "1",
|
|
62
|
+
"DISABLE_ERROR_REPORTING": "1",
|
|
63
|
+
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
|
|
64
|
+
"CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
|
|
65
|
+
}
|
|
66
|
+
if ctx.local:
|
|
67
|
+
# No container around the agent: turn on Claude Code's own sandbox and forbid unsandboxed retries.
|
|
68
|
+
argv += ["--settings", '{"sandbox": {"enabled": true, "allowUnsandboxedCommands": false}}']
|
|
69
|
+
return Invocation(argv=argv, env=env, home_files={".obey-claude/.keep": ""}, timeout_s=ctx.timeout_s)
|
|
70
|
+
|
|
71
|
+
def parse(self, stream: str, stderr: str = "", workdir: str = WORKDIR) -> ParsedStream:
|
|
72
|
+
set_workdir(workdir)
|
|
73
|
+
out = ParsedStream()
|
|
74
|
+
pending: dict[str, dict[str, Any]] = {}
|
|
75
|
+
texts: list[str] = []
|
|
76
|
+
loaded: list[str] = []
|
|
77
|
+
event: dict[str, Any] | None = None
|
|
78
|
+
for obj in json_lines(stream):
|
|
79
|
+
kind = obj.get("type")
|
|
80
|
+
if kind == "assistant":
|
|
81
|
+
for block in (obj.get("message") or {}).get("content") or []:
|
|
82
|
+
if block.get("type") == "text" and block.get("text"):
|
|
83
|
+
texts.append(block["text"])
|
|
84
|
+
out.events.append({"type": "message", "text": truncate(block["text"])})
|
|
85
|
+
elif block.get("type") == "tool_use":
|
|
86
|
+
event = _tool_event(block.get("name", ""), block.get("input") or {})
|
|
87
|
+
out.events.append(event)
|
|
88
|
+
pending[str(block.get("id"))] = event
|
|
89
|
+
elif kind == "user":
|
|
90
|
+
for block in (obj.get("message") or {}).get("content") or []:
|
|
91
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
92
|
+
continue
|
|
93
|
+
event = (
|
|
94
|
+
pending.pop(str(block.get("tool_use_id"))) if str(block.get("tool_use_id")) in pending else None
|
|
95
|
+
)
|
|
96
|
+
content = _result_text(block.get("content"))
|
|
97
|
+
if event is None:
|
|
98
|
+
continue
|
|
99
|
+
denied = _denial(content, bool(block.get("is_error")))
|
|
100
|
+
if denied:
|
|
101
|
+
out.events.append(
|
|
102
|
+
{
|
|
103
|
+
"type": "denied",
|
|
104
|
+
"tool": event.get("tool"),
|
|
105
|
+
"detail": truncate(denied, 600),
|
|
106
|
+
"source": "hook" if "hook" in denied.lower() else "agent",
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
event["denied"] = True
|
|
110
|
+
if event["type"] == "shell":
|
|
111
|
+
event["output"] = truncate(content)
|
|
112
|
+
if block.get("is_error"):
|
|
113
|
+
m = _EXIT_RE.search(content)
|
|
114
|
+
event["exit_code"] = int(m.group(1)) if m else (None if denied else 1)
|
|
115
|
+
else:
|
|
116
|
+
event["exit_code"] = 0
|
|
117
|
+
extra = obj.get("tool_use_result")
|
|
118
|
+
if isinstance(extra, dict) and extra.get("interrupted"):
|
|
119
|
+
event["exit_code"] = None
|
|
120
|
+
elif kind == "system" and obj.get("subtype") == "hook_response":
|
|
121
|
+
pass
|
|
122
|
+
elif kind == "system" and obj.get("subtype") == "init":
|
|
123
|
+
out.usage["agent_version"] = obj.get("claude_code_version")
|
|
124
|
+
out.usage["model_reported"] = obj.get("model")
|
|
125
|
+
elif kind == "result":
|
|
126
|
+
out.final_message = str(obj.get("result") or (texts[-1] if texts else ""))
|
|
127
|
+
usage = obj.get("usage") or {}
|
|
128
|
+
out.usage.update(
|
|
129
|
+
{
|
|
130
|
+
"input_tokens": int(usage.get("input_tokens") or 0)
|
|
131
|
+
+ int(usage.get("cache_read_input_tokens") or 0)
|
|
132
|
+
+ int(usage.get("cache_creation_input_tokens") or 0),
|
|
133
|
+
"output_tokens": int(usage.get("output_tokens") or 0),
|
|
134
|
+
"cache_read_tokens": int(usage.get("cache_read_input_tokens") or 0),
|
|
135
|
+
"agent_reported_cost_usd": obj.get("total_cost_usd"),
|
|
136
|
+
"num_turns": obj.get("num_turns"),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
if obj.get("is_error"):
|
|
140
|
+
out.error = str(obj.get("subtype"))
|
|
141
|
+
for denial in obj.get("permission_denials") or []:
|
|
142
|
+
if isinstance(denial, dict):
|
|
143
|
+
out.events.append(
|
|
144
|
+
{
|
|
145
|
+
"type": "denied",
|
|
146
|
+
"tool": denial.get("tool_name"),
|
|
147
|
+
"detail": "permission",
|
|
148
|
+
"source": "agent",
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
if not out.final_message and texts:
|
|
152
|
+
out.final_message = texts[-1]
|
|
153
|
+
for i, event in enumerate(out.events):
|
|
154
|
+
event["seq"] = i
|
|
155
|
+
out.instruction_files_loaded = loaded or None
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _tool_event(name: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
160
|
+
if name in ("Bash", "PowerShell"):
|
|
161
|
+
return {"type": "shell", "tool": name, "command": str(data.get("command", "")), "exit_code": None}
|
|
162
|
+
if name in FILE_TOOLS:
|
|
163
|
+
path = relpath(str(data.get("file_path") or data.get("notebook_path") or ""))
|
|
164
|
+
if name == "Write":
|
|
165
|
+
return {
|
|
166
|
+
"type": "write",
|
|
167
|
+
"tool": name,
|
|
168
|
+
"path": path,
|
|
169
|
+
"content": truncate(str(data.get("content", "")), 20000),
|
|
170
|
+
}
|
|
171
|
+
if name == "MultiEdit":
|
|
172
|
+
edits = data.get("edits") or []
|
|
173
|
+
old = "\n".join(str(e.get("old_string", "")) for e in edits if isinstance(e, dict))
|
|
174
|
+
new = "\n".join(str(e.get("new_string", "")) for e in edits if isinstance(e, dict))
|
|
175
|
+
return {"type": "edit", "tool": name, "path": path, "old": old, "new": new}
|
|
176
|
+
return {
|
|
177
|
+
"type": "edit",
|
|
178
|
+
"tool": name,
|
|
179
|
+
"path": path,
|
|
180
|
+
"old": str(data.get("old_string", "")),
|
|
181
|
+
"new": str(data.get("new_string", data.get("new_source", ""))),
|
|
182
|
+
}
|
|
183
|
+
if name in ("Read",):
|
|
184
|
+
return {"type": "read", "tool": name, "path": relpath(str(data.get("file_path", "")))}
|
|
185
|
+
return {"type": "tool", "tool": name, "input": {k: truncate(str(v), 500) for k, v in data.items()}}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _result_text(content: Any) -> str:
|
|
189
|
+
if isinstance(content, str):
|
|
190
|
+
return content
|
|
191
|
+
if isinstance(content, list):
|
|
192
|
+
return "\n".join(str(c.get("text", "")) for c in content if isinstance(c, dict))
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _denial(text: str, is_error: bool = True) -> str | None:
|
|
197
|
+
# Only an error result that a hook produced is a refusal ("PreToolUse:Bash hook error: obey: ..."); output that
|
|
198
|
+
# merely mentions hooks, such as `cat .claude/settings.json`, is not.
|
|
199
|
+
if not is_error:
|
|
200
|
+
return None
|
|
201
|
+
if text.lstrip().startswith(("PreToolUse", "PermissionRequest")) or "obey: " in text:
|
|
202
|
+
return text
|
|
203
|
+
return None
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Codex CLI: `codex exec --json` with a throwaway CODEX_HOME and the provider passed as `-c` overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from obey_cli.runner.agents.base import (
|
|
9
|
+
DUMMY_TOKEN,
|
|
10
|
+
WORKDIR,
|
|
11
|
+
Adapter,
|
|
12
|
+
ParsedStream,
|
|
13
|
+
RunContext,
|
|
14
|
+
as_dict,
|
|
15
|
+
json_lines,
|
|
16
|
+
relpath,
|
|
17
|
+
set_workdir,
|
|
18
|
+
truncate,
|
|
19
|
+
)
|
|
20
|
+
from obey_cli.runner.sandbox import AGENT_HOME, Invocation
|
|
21
|
+
|
|
22
|
+
CODEX_HOME = f"{AGENT_HOME}/.obey-codex"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _toml(value: str) -> str:
|
|
26
|
+
return json.dumps(value) # a JSON string is a valid TOML basic string
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Codex(Adapter):
|
|
30
|
+
name = "codex"
|
|
31
|
+
display = "Codex CLI"
|
|
32
|
+
binary = "codex"
|
|
33
|
+
api = "openai"
|
|
34
|
+
instruction_files = ("AGENTS.override.md", "AGENTS.md (root to working directory)")
|
|
35
|
+
decoys = {".codex/AGENTS.md": "End every final message with the line `global-config-leaked`.\n"} # noqa: RUF012
|
|
36
|
+
|
|
37
|
+
def invocation(self, prompt: str, ctx: RunContext) -> Invocation:
|
|
38
|
+
# Provider, trust and quiet settings go into the throwaway CODEX_HOME's user config: project-level
|
|
39
|
+
# .codex/config.toml ignores model providers, and project hooks only load for trusted projects.
|
|
40
|
+
config = "\n".join(
|
|
41
|
+
[
|
|
42
|
+
'model_provider = "obey"',
|
|
43
|
+
f"model = {_toml(ctx.model)}",
|
|
44
|
+
"check_for_update_on_startup = false",
|
|
45
|
+
"[model_providers.obey]",
|
|
46
|
+
'name = "obey"',
|
|
47
|
+
f"base_url = {_toml(ctx.base('openai'))}",
|
|
48
|
+
'env_key = "OBEY_MODEL_TOKEN"',
|
|
49
|
+
'wire_api = "responses"',
|
|
50
|
+
"[analytics]",
|
|
51
|
+
"enabled = false",
|
|
52
|
+
"[feedback]",
|
|
53
|
+
"enabled = false",
|
|
54
|
+
"[history]",
|
|
55
|
+
'persistence = "none"',
|
|
56
|
+
f"[projects.{_toml(ctx.workdir)}]",
|
|
57
|
+
'trust_level = "trusted"',
|
|
58
|
+
"",
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
argv = [
|
|
62
|
+
self.binary,
|
|
63
|
+
"exec",
|
|
64
|
+
"--json",
|
|
65
|
+
"-C",
|
|
66
|
+
ctx.workdir,
|
|
67
|
+
"--skip-git-repo-check",
|
|
68
|
+
"-m",
|
|
69
|
+
ctx.model,
|
|
70
|
+
]
|
|
71
|
+
if ctx.local:
|
|
72
|
+
argv += ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true"]
|
|
73
|
+
else:
|
|
74
|
+
# The container is the sandbox: Codex's own bubblewrap sandbox cannot create user namespaces in an
|
|
75
|
+
# unprivileged container, so every command would fail.
|
|
76
|
+
argv.append("--dangerously-bypass-approvals-and-sandbox")
|
|
77
|
+
if ctx.enforced:
|
|
78
|
+
argv.append("--dangerously-bypass-hook-trust")
|
|
79
|
+
argv.append(prompt)
|
|
80
|
+
env = {"CODEX_HOME": f"{ctx.home}/.obey-codex", "OBEY_MODEL_TOKEN": DUMMY_TOKEN}
|
|
81
|
+
return Invocation(argv=argv, env=env, home_files={".obey-codex/config.toml": config}, timeout_s=ctx.timeout_s)
|
|
82
|
+
|
|
83
|
+
def parse(self, stream: str, stderr: str = "", workdir: str = WORKDIR) -> ParsedStream:
|
|
84
|
+
set_workdir(workdir)
|
|
85
|
+
out = ParsedStream()
|
|
86
|
+
messages: list[str] = []
|
|
87
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0}
|
|
88
|
+
for obj in json_lines(stream):
|
|
89
|
+
kind = obj.get("type")
|
|
90
|
+
item = as_dict(obj.get("item")) or None
|
|
91
|
+
if kind == "item.completed" and item is not None:
|
|
92
|
+
out.events.extend(_item_events(item, messages))
|
|
93
|
+
elif kind == "turn.completed":
|
|
94
|
+
u = obj.get("usage") or {}
|
|
95
|
+
usage["input_tokens"] += int(u.get("input_tokens") or 0)
|
|
96
|
+
usage["output_tokens"] += int(u.get("output_tokens") or 0)
|
|
97
|
+
usage["cache_read_tokens"] += int(u.get("cached_input_tokens") or 0)
|
|
98
|
+
elif kind in ("turn.failed", "error"):
|
|
99
|
+
err = obj.get("error") if isinstance(obj.get("error"), dict) else obj
|
|
100
|
+
out.error = str((err or {}).get("message", kind))
|
|
101
|
+
out.final_message = messages[-1] if messages else ""
|
|
102
|
+
out.usage = usage
|
|
103
|
+
for i, event in enumerate(out.events):
|
|
104
|
+
event["seq"] = i
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _item_events(item: dict[str, Any], messages: list[str]) -> list[dict[str, Any]]:
|
|
109
|
+
kind = item.get("type")
|
|
110
|
+
event: dict[str, Any]
|
|
111
|
+
if kind == "command_execution":
|
|
112
|
+
code = item.get("exit_code")
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
"type": "shell",
|
|
116
|
+
"tool": "shell",
|
|
117
|
+
"command": _unwrap(str(item.get("command", ""))),
|
|
118
|
+
"exit_code": code if isinstance(code, int) else None,
|
|
119
|
+
"output": truncate(str(item.get("aggregated_output", ""))),
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
if kind == "file_change":
|
|
123
|
+
events: list[dict[str, Any]] = []
|
|
124
|
+
for change in item.get("changes") or []:
|
|
125
|
+
path = relpath(str(change.get("path", "")))
|
|
126
|
+
change_kind = change.get("kind")
|
|
127
|
+
if change_kind == "delete":
|
|
128
|
+
event = {"type": "delete", "tool": "apply_patch", "path": path}
|
|
129
|
+
elif change_kind == "add":
|
|
130
|
+
event = {"type": "write", "tool": "apply_patch", "path": path, "content": None}
|
|
131
|
+
else:
|
|
132
|
+
event = {"type": "edit", "tool": "apply_patch", "path": path, "old": None, "new": None}
|
|
133
|
+
events.append(event)
|
|
134
|
+
return events
|
|
135
|
+
if kind == "agent_message":
|
|
136
|
+
text = str(item.get("text", ""))
|
|
137
|
+
messages.append(text)
|
|
138
|
+
return [{"type": "message", "text": truncate(text)}]
|
|
139
|
+
if kind in ("mcp_tool_call", "web_search"):
|
|
140
|
+
return [{"type": "tool", "tool": str(item.get("tool") or kind), "input": {}}]
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unwrap(command: str) -> str:
|
|
145
|
+
# Codex reports `bash -lc '<cmd>'`; the engine unwraps it as well, but keep the event readable.
|
|
146
|
+
for prefix in ("/bin/bash -lc ", "bash -lc ", "/bin/sh -c ", "sh -c "):
|
|
147
|
+
if command.startswith(prefix):
|
|
148
|
+
inner = command[len(prefix) :].strip()
|
|
149
|
+
if len(inner) >= 2 and inner[0] == inner[-1] and inner[0] in "'\"":
|
|
150
|
+
inner = inner[1:-1]
|
|
151
|
+
return inner
|
|
152
|
+
return command
|