agentic-runner 2.6.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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from agentic_runner.workers._runtime_support import (
|
|
9
|
+
SubprocessResult,
|
|
10
|
+
apply_extra_env,
|
|
11
|
+
bound_text,
|
|
12
|
+
command_policy_refusal,
|
|
13
|
+
hash_command,
|
|
14
|
+
is_relative_to,
|
|
15
|
+
redact_match,
|
|
16
|
+
run_subprocess_exec,
|
|
17
|
+
workspace_id,
|
|
18
|
+
)
|
|
19
|
+
from agentic_runner.workers.agent_runtime import (
|
|
20
|
+
AgentRuntime,
|
|
21
|
+
AuthModel,
|
|
22
|
+
DirectiveEvidence,
|
|
23
|
+
DirectiveRequest,
|
|
24
|
+
DirectiveResult,
|
|
25
|
+
)
|
|
26
|
+
from agentic_runner.workers.mcp_config import claude_mcp_config
|
|
27
|
+
from agentic_runner.workers.settings import WorkerSettings
|
|
28
|
+
|
|
29
|
+
__all__ = ["ClaudeRuntime"]
|
|
30
|
+
|
|
31
|
+
_SECRET_VALUE_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
|
|
32
|
+
re.compile(
|
|
33
|
+
r"(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|credential)\s*=\s*)([^\s,}]+)"
|
|
34
|
+
),
|
|
35
|
+
re.compile(
|
|
36
|
+
r"(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|credential)\s*:\s*)([^\s,}]+)"
|
|
37
|
+
),
|
|
38
|
+
re.compile(
|
|
39
|
+
r"(?i)((?:\"(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|credential)\"\s*:\s*\"))([^\"]+)(\")"
|
|
40
|
+
),
|
|
41
|
+
re.compile(r"(?i)((?:--password|--token|--secret|--api-key)\s+)([^\s]+)"),
|
|
42
|
+
re.compile(r"(?i)(authorization:\s*bearer\s+)([^\s]+)"),
|
|
43
|
+
re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._~+/-]{8,})"),
|
|
44
|
+
re.compile(r"(?i)(sk-[A-Za-z0-9_-]{8,})"),
|
|
45
|
+
re.compile(r"(?i)(?:~|/[^\s]*)/\.claude(?:/[^\s-]+)?"),
|
|
46
|
+
re.compile(r"(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
47
|
+
re.compile(r"(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ClaudeRuntime(AgentRuntime):
|
|
52
|
+
"""Worker-local Claude Code CLI runtime.
|
|
53
|
+
|
|
54
|
+
Authenticates with an API key (``ANTHROPIC_API_KEY``) rather than a device-login session,
|
|
55
|
+
so an unattended Persona can run without an expiring browser session (ADR-0006). Like the
|
|
56
|
+
Codex runtime it confines execution to the supplied workspace, refuses fail-closed unless the
|
|
57
|
+
autonomous-permission guard is configured, and returns bounded, redacted evidence safe for
|
|
58
|
+
control-plane storage — the API key never appears in returned text.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
auth_model = AuthModel.API_KEY
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
settings: WorkerSettings,
|
|
67
|
+
runner: Callable[..., Awaitable[SubprocessResult]] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._settings = settings
|
|
70
|
+
self._runner = runner or run_subprocess_exec
|
|
71
|
+
|
|
72
|
+
async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult:
|
|
73
|
+
workspace_path = request.workspace_path.resolve(strict=False)
|
|
74
|
+
workspace_root = self._settings.WORKSPACE_ROOT.resolve(strict=False)
|
|
75
|
+
if not is_relative_to(workspace_path, workspace_root):
|
|
76
|
+
raise ValueError("workspace_path must be under WORKSPACE_ROOT")
|
|
77
|
+
|
|
78
|
+
directive_workspace_id = workspace_id(workspace_path, workspace_root)
|
|
79
|
+
guard_mode = _guard_mode(self._settings)
|
|
80
|
+
if guard_mode is None:
|
|
81
|
+
return _refused_result(
|
|
82
|
+
workspace_id=directive_workspace_id,
|
|
83
|
+
base_branch=request.base_branch,
|
|
84
|
+
work_branch=request.work_branch,
|
|
85
|
+
evidence_limit_bytes=self._settings.CLAUDE_CLI_OUTPUT_LIMIT_BYTES,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
argv = _claude_argv(self._settings)
|
|
89
|
+
if request.mcp_servers is not None:
|
|
90
|
+
# Only what the Grant let through: `--strict-mcp-config` ignores every other
|
|
91
|
+
# MCP source, so nothing in the harness root can add an ungranted server.
|
|
92
|
+
if request.mcp_servers:
|
|
93
|
+
argv += ["--mcp-config", claude_mcp_config(request.mcp_servers)]
|
|
94
|
+
argv.append("--strict-mcp-config")
|
|
95
|
+
floor_refusal = command_policy_refusal(
|
|
96
|
+
argv=argv,
|
|
97
|
+
program="claude",
|
|
98
|
+
workspace_path=workspace_path,
|
|
99
|
+
workspace_root=workspace_root,
|
|
100
|
+
base_branch=request.base_branch,
|
|
101
|
+
work_branch=request.work_branch,
|
|
102
|
+
)
|
|
103
|
+
if floor_refusal is not None:
|
|
104
|
+
return _refused_result(
|
|
105
|
+
workspace_id=directive_workspace_id,
|
|
106
|
+
base_branch=request.base_branch,
|
|
107
|
+
work_branch=request.work_branch,
|
|
108
|
+
evidence_limit_bytes=self._settings.CLAUDE_CLI_OUTPUT_LIMIT_BYTES,
|
|
109
|
+
guard_mode=f"refused: command policy ({floor_refusal})",
|
|
110
|
+
)
|
|
111
|
+
command_hash = hash_command(argv)
|
|
112
|
+
# Per-Contract CLAUDE_CONFIG_DIR, never a fleet-wide one (ADR-0015 §4).
|
|
113
|
+
sandbox = request.sandbox
|
|
114
|
+
env = {"ANTHROPIC_API_KEY": self._settings.ANTHROPIC_API_KEY}
|
|
115
|
+
if sandbox is not None:
|
|
116
|
+
env["CLAUDE_CONFIG_DIR"] = str(sandbox.harness_config_dir)
|
|
117
|
+
env["HOME"] = str(sandbox.home_dir)
|
|
118
|
+
env["TMPDIR"] = str(sandbox.tmp_dir)
|
|
119
|
+
apply_extra_env(env, request.extra_env)
|
|
120
|
+
notes: list[str] = []
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
raw_result = await self._runner(
|
|
124
|
+
argv=argv,
|
|
125
|
+
cwd=workspace_path,
|
|
126
|
+
env=env,
|
|
127
|
+
stdin=request.prompt,
|
|
128
|
+
timeout_seconds=self._settings.CLAUDE_CLI_TIMEOUT_SECONDS,
|
|
129
|
+
output_limit_bytes=self._settings.CLAUDE_CLI_OUTPUT_LIMIT_BYTES,
|
|
130
|
+
sandbox=sandbox,
|
|
131
|
+
)
|
|
132
|
+
error = ""
|
|
133
|
+
except asyncio.CancelledError:
|
|
134
|
+
raise
|
|
135
|
+
except TimeoutError as exception:
|
|
136
|
+
raw_result = SubprocessResult(exit_code=124, stdout="", stderr="")
|
|
137
|
+
error = str(exception) or "Claude Code CLI timed out"
|
|
138
|
+
except OSError as exception:
|
|
139
|
+
raw_result = SubprocessResult(exit_code=127, stdout="", stderr="")
|
|
140
|
+
error = str(exception)
|
|
141
|
+
|
|
142
|
+
limit_bytes = self._settings.CLAUDE_CLI_OUTPUT_LIMIT_BYTES
|
|
143
|
+
stdout = bound_text(_redact_text(raw_result.stdout), limit_bytes, notes, "stdout")
|
|
144
|
+
if raw_result.stdout_truncated:
|
|
145
|
+
notes.append(f"stdout truncated to {limit_bytes} bytes")
|
|
146
|
+
stderr = bound_text(_redact_text(raw_result.stderr), limit_bytes, notes, "stderr")
|
|
147
|
+
if raw_result.stderr_truncated:
|
|
148
|
+
notes.append(f"stderr truncated to {limit_bytes} bytes")
|
|
149
|
+
redacted_error = bound_text(_redact_text(error), limit_bytes, notes, "error")
|
|
150
|
+
|
|
151
|
+
evidence = DirectiveEvidence(
|
|
152
|
+
workspace_id=_redact_text(directive_workspace_id),
|
|
153
|
+
base_branch=_redact_evidence_field(request.base_branch, limit_bytes),
|
|
154
|
+
work_branch=_redact_evidence_field(request.work_branch, limit_bytes),
|
|
155
|
+
command_hash=command_hash,
|
|
156
|
+
guard_mode=guard_mode,
|
|
157
|
+
notes=notes,
|
|
158
|
+
)
|
|
159
|
+
return DirectiveResult(
|
|
160
|
+
exit_code=raw_result.exit_code,
|
|
161
|
+
stdout=stdout,
|
|
162
|
+
stderr=stderr,
|
|
163
|
+
error=redacted_error,
|
|
164
|
+
command_hash=command_hash,
|
|
165
|
+
evidence=evidence,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _guard_mode(settings: WorkerSettings) -> str | None:
|
|
170
|
+
if settings.ANTHROPIC_API_KEY and settings.CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS:
|
|
171
|
+
return "auth=api-key; permission=skip-in-sandbox; output=json"
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _claude_argv(settings: WorkerSettings) -> list[str]:
|
|
176
|
+
return [
|
|
177
|
+
"claude",
|
|
178
|
+
"--print",
|
|
179
|
+
"--bare",
|
|
180
|
+
"--output-format",
|
|
181
|
+
"json",
|
|
182
|
+
"--model",
|
|
183
|
+
settings.CLAUDE_MODEL,
|
|
184
|
+
"--dangerously-skip-permissions",
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _refused_result(
|
|
189
|
+
*,
|
|
190
|
+
workspace_id: str,
|
|
191
|
+
base_branch: str,
|
|
192
|
+
work_branch: str,
|
|
193
|
+
evidence_limit_bytes: int,
|
|
194
|
+
guard_mode: str = "refused: missing API key or autonomous permission configuration",
|
|
195
|
+
error: str = (
|
|
196
|
+
"Claude Code API key and autonomous permission configuration are required "
|
|
197
|
+
"before worker-local execution"
|
|
198
|
+
),
|
|
199
|
+
) -> DirectiveResult:
|
|
200
|
+
command_hash = hash_command(["claude", "refused"])
|
|
201
|
+
return DirectiveResult(
|
|
202
|
+
exit_code=126,
|
|
203
|
+
stdout="",
|
|
204
|
+
stderr="",
|
|
205
|
+
error=error,
|
|
206
|
+
command_hash=command_hash,
|
|
207
|
+
evidence=DirectiveEvidence(
|
|
208
|
+
workspace_id=_redact_text(workspace_id),
|
|
209
|
+
command_hash=command_hash,
|
|
210
|
+
guard_mode=guard_mode,
|
|
211
|
+
base_branch=_redact_evidence_field(base_branch, evidence_limit_bytes),
|
|
212
|
+
work_branch=_redact_evidence_field(work_branch, evidence_limit_bytes),
|
|
213
|
+
notes=[],
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _redact_text(value: str) -> str:
|
|
219
|
+
redacted = value
|
|
220
|
+
for pattern in _SECRET_VALUE_PATTERNS:
|
|
221
|
+
redacted = pattern.sub(redact_match, redacted)
|
|
222
|
+
return redacted
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _redact_evidence_field(value: str, limit_bytes: int) -> str:
|
|
226
|
+
return bound_text(_redact_text(value), limit_bytes, [], "evidence")
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from agentic_runner.workers._runtime_support import (
|
|
11
|
+
AsyncSubprocessRunner,
|
|
12
|
+
SubprocessResult,
|
|
13
|
+
apply_extra_env,
|
|
14
|
+
bound_text,
|
|
15
|
+
command_policy_refusal,
|
|
16
|
+
hash_command,
|
|
17
|
+
is_relative_to,
|
|
18
|
+
redact_match,
|
|
19
|
+
run_subprocess_exec,
|
|
20
|
+
workspace_id,
|
|
21
|
+
)
|
|
22
|
+
from agentic_runner.workers.agent_runtime import (
|
|
23
|
+
AgentRuntime,
|
|
24
|
+
AuthModel,
|
|
25
|
+
DirectiveEvidence,
|
|
26
|
+
DirectiveRequest,
|
|
27
|
+
DirectiveResult,
|
|
28
|
+
)
|
|
29
|
+
from agentic_runner.workers.mcp_config import codex_mcp_argv
|
|
30
|
+
from agentic_runner.workers.settings import WorkerSettings
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AsyncSubprocessRunner",
|
|
34
|
+
"CodexRuntime",
|
|
35
|
+
"SubprocessResult",
|
|
36
|
+
"build_codex_subprocess_env",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# The Dockerfile installs `codex` (and its `node` interpreter) here; used as a PATH fallback
|
|
40
|
+
# so a bare `codex` still resolves even if the parent PATH is somehow unset.
|
|
41
|
+
_CODEX_INSTALL_DIR: Final[str] = "/usr/local/bin"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_codex_subprocess_env(codex_home: Path) -> dict[str, str]:
|
|
45
|
+
"""Codex child env for the worker runtime: CODEX_HOME + a resolvable PATH.
|
|
46
|
+
|
|
47
|
+
An env with only CODEX_HOME cannot resolve the bare ``codex``
|
|
48
|
+
in ``argv[0]`` (Python falls back to ``os.defpath`` = ``/bin:/usr/bin``, but codex is at
|
|
49
|
+
``/usr/local/bin/codex``), so every worker Directive exited 127. Inherit the parent PATH
|
|
50
|
+
(which reaches ``/usr/local/bin`` in the pod) so both ``codex`` and its
|
|
51
|
+
``#!/usr/bin/env node`` shebang resolve, and pass HOME when set; add no parent secrets.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
inherited_path = os.environ.get("PATH") or ""
|
|
55
|
+
path = inherited_path or os.pathsep.join((_CODEX_INSTALL_DIR, os.defpath))
|
|
56
|
+
env = {"CODEX_HOME": str(codex_home), "PATH": path}
|
|
57
|
+
home = os.environ.get("HOME")
|
|
58
|
+
if home:
|
|
59
|
+
env["HOME"] = home
|
|
60
|
+
return env
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_SECRET_VALUE_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
|
|
64
|
+
re.compile(
|
|
65
|
+
r"(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|credential)\s*=\s*)([^\s,}]+)"
|
|
66
|
+
),
|
|
67
|
+
re.compile(
|
|
68
|
+
r"(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|credential)\s*:\s*)([^\s,}]+)"
|
|
69
|
+
),
|
|
70
|
+
re.compile(
|
|
71
|
+
r"(?i)((?:\"(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|credential)\"\s*:\s*\"))([^\"]+)(\")"
|
|
72
|
+
),
|
|
73
|
+
re.compile(r"(?i)((?:--password|--token|--secret|--api-key)\s+)([^\s]+)"),
|
|
74
|
+
re.compile(r"(?i)(authorization:\s*bearer\s+)([^\s]+)"),
|
|
75
|
+
re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._~+/-]{8,})"),
|
|
76
|
+
re.compile(r"(?i)(sk-[A-Za-z0-9_-]{8,})"),
|
|
77
|
+
re.compile(r"(?i)(?:~|/[^\s]*)/\.codex(?:/[^\s-]+)?"),
|
|
78
|
+
re.compile(r"(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
79
|
+
re.compile(r"(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CodexRuntime(AgentRuntime):
|
|
84
|
+
"""Worker-local Codex CLI runtime.
|
|
85
|
+
|
|
86
|
+
The runtime does not accept caller-supplied secrets. It constructs the Codex environment from
|
|
87
|
+
worker settings only, keeps execution inside the supplied workspace, and returns bounded
|
|
88
|
+
redacted evidence suitable for control-plane storage. Codex authenticates via a device-login
|
|
89
|
+
session on a worker-local PVC (CODEX_HOME).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
auth_model = AuthModel.DEVICE_LOGIN
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
settings: WorkerSettings,
|
|
98
|
+
runner: Callable[..., Awaitable[SubprocessResult]] | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._settings = settings
|
|
101
|
+
self._runner = runner or run_subprocess_exec
|
|
102
|
+
|
|
103
|
+
async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult:
|
|
104
|
+
workspace_path = request.workspace_path.resolve(strict=False)
|
|
105
|
+
workspace_root = self._settings.WORKSPACE_ROOT.resolve(strict=False)
|
|
106
|
+
if not is_relative_to(workspace_path, workspace_root):
|
|
107
|
+
raise ValueError("workspace_path must be under WORKSPACE_ROOT")
|
|
108
|
+
|
|
109
|
+
directive_workspace_id = workspace_id(workspace_path, workspace_root)
|
|
110
|
+
allowlisted = bool(request.egress_allow_list)
|
|
111
|
+
guard_mode = _guard_mode(self._settings, allowlisted=allowlisted)
|
|
112
|
+
if guard_mode is None:
|
|
113
|
+
return _refused_result(
|
|
114
|
+
workspace_id=directive_workspace_id,
|
|
115
|
+
base_branch=request.base_branch,
|
|
116
|
+
work_branch=request.work_branch,
|
|
117
|
+
evidence_limit_bytes=self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
argv = _codex_argv(self._settings, allowlisted=allowlisted)
|
|
121
|
+
# Locked with no server bound too: otherwise a `[mcp_servers.*]` table in the
|
|
122
|
+
# Workspace or harness-root config.toml starts a process before any model turn (LA-19b).
|
|
123
|
+
argv[-1:-1] = codex_mcp_argv(request.mcp_servers or (), workspace_path)
|
|
124
|
+
floor_refusal = command_policy_refusal(
|
|
125
|
+
argv=argv,
|
|
126
|
+
program="codex",
|
|
127
|
+
workspace_path=workspace_path,
|
|
128
|
+
workspace_root=workspace_root,
|
|
129
|
+
base_branch=request.base_branch,
|
|
130
|
+
work_branch=request.work_branch,
|
|
131
|
+
)
|
|
132
|
+
if floor_refusal is not None:
|
|
133
|
+
return _refused_result(
|
|
134
|
+
workspace_id=directive_workspace_id,
|
|
135
|
+
base_branch=request.base_branch,
|
|
136
|
+
work_branch=request.work_branch,
|
|
137
|
+
evidence_limit_bytes=self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
138
|
+
guard_mode=f"refused: command policy ({floor_refusal})",
|
|
139
|
+
)
|
|
140
|
+
command_hash = hash_command(argv)
|
|
141
|
+
# The Contract's own harness config root, never the fleet-wide one (ADR-0015 §4):
|
|
142
|
+
# a device login materialised into it is readable only by that Contract's uid.
|
|
143
|
+
# The shared CODEX_HOME survives only as the no-isolation fallback.
|
|
144
|
+
sandbox = request.sandbox
|
|
145
|
+
env = build_codex_subprocess_env(
|
|
146
|
+
self._settings.CODEX_HOME if sandbox is None else sandbox.harness_config_dir
|
|
147
|
+
)
|
|
148
|
+
if sandbox is not None:
|
|
149
|
+
env["HOME"] = str(sandbox.home_dir)
|
|
150
|
+
env["TMPDIR"] = str(sandbox.tmp_dir)
|
|
151
|
+
apply_extra_env(env, request.extra_env)
|
|
152
|
+
notes: list[str] = []
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
raw_result = await self._runner(
|
|
156
|
+
argv=argv,
|
|
157
|
+
cwd=workspace_path,
|
|
158
|
+
env=env,
|
|
159
|
+
stdin=request.prompt,
|
|
160
|
+
timeout_seconds=self._settings.CODEX_CLI_TIMEOUT_SECONDS,
|
|
161
|
+
output_limit_bytes=self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
162
|
+
sandbox=sandbox,
|
|
163
|
+
)
|
|
164
|
+
error = ""
|
|
165
|
+
except asyncio.CancelledError:
|
|
166
|
+
raise
|
|
167
|
+
except TimeoutError as exception:
|
|
168
|
+
raw_result = SubprocessResult(exit_code=124, stdout="", stderr="")
|
|
169
|
+
error = str(exception) or "Codex CLI timed out"
|
|
170
|
+
except OSError as exception:
|
|
171
|
+
raw_result = SubprocessResult(exit_code=127, stdout="", stderr="")
|
|
172
|
+
error = str(exception)
|
|
173
|
+
|
|
174
|
+
stdout = bound_text(
|
|
175
|
+
_redact_text(raw_result.stdout),
|
|
176
|
+
self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
177
|
+
notes,
|
|
178
|
+
"stdout",
|
|
179
|
+
)
|
|
180
|
+
if raw_result.stdout_truncated:
|
|
181
|
+
notes.append(f"stdout truncated to {self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES} bytes")
|
|
182
|
+
stderr = bound_text(
|
|
183
|
+
_redact_text(raw_result.stderr),
|
|
184
|
+
self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
185
|
+
notes,
|
|
186
|
+
"stderr",
|
|
187
|
+
)
|
|
188
|
+
if raw_result.stderr_truncated:
|
|
189
|
+
notes.append(f"stderr truncated to {self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES} bytes")
|
|
190
|
+
redacted_error = bound_text(
|
|
191
|
+
_redact_text(error),
|
|
192
|
+
self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
193
|
+
notes,
|
|
194
|
+
"error",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
evidence = DirectiveEvidence(
|
|
198
|
+
workspace_id=_redact_text(directive_workspace_id),
|
|
199
|
+
base_branch=_redact_evidence_field(
|
|
200
|
+
request.base_branch,
|
|
201
|
+
self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
202
|
+
),
|
|
203
|
+
work_branch=_redact_evidence_field(
|
|
204
|
+
request.work_branch,
|
|
205
|
+
self._settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
|
|
206
|
+
),
|
|
207
|
+
command_hash=command_hash,
|
|
208
|
+
guard_mode=guard_mode,
|
|
209
|
+
notes=notes,
|
|
210
|
+
)
|
|
211
|
+
return DirectiveResult(
|
|
212
|
+
exit_code=raw_result.exit_code,
|
|
213
|
+
stdout=stdout,
|
|
214
|
+
stderr=stderr,
|
|
215
|
+
error=redacted_error,
|
|
216
|
+
command_hash=command_hash,
|
|
217
|
+
evidence=evidence,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _guard_mode(settings: WorkerSettings, *, allowlisted: bool = False) -> str | None:
|
|
222
|
+
if not (
|
|
223
|
+
settings.CODEX_ASK_FOR_APPROVAL == "never"
|
|
224
|
+
and settings.CODEX_STRICT_CONFIG
|
|
225
|
+
and settings.CODEX_POLICY_HOOK_CONFIGURED
|
|
226
|
+
):
|
|
227
|
+
return None
|
|
228
|
+
if settings.CODEX_SANDBOX_MODE == "workspace-write":
|
|
229
|
+
# The Profile's egress list replaced the old all-or-nothing `CODEX_NETWORK_ACCESS`
|
|
230
|
+
# toggle (PRD issue 58): with a list, Codex's sandbox lets traffic out and the
|
|
231
|
+
# attempt's egress proxy decides where it may go; without one, none leaves.
|
|
232
|
+
network = "allowlisted" if allowlisted else "disabled"
|
|
233
|
+
return (
|
|
234
|
+
"sandbox=workspace-write; approval=never; strict-config; "
|
|
235
|
+
f"network={network}; policy-hook=configured"
|
|
236
|
+
)
|
|
237
|
+
if settings.CODEX_SANDBOX_MODE == "danger-full-access":
|
|
238
|
+
# Containerized posture: Codex's own OS sandbox cannot run inside the worker
|
|
239
|
+
# pod (bwrap needs to create user namespaces, which the container runtime
|
|
240
|
+
# denies — every exec/apply_patch failed with "bwrap: No permissions to
|
|
241
|
+
# create a new namespace"). The pod is the isolation boundary instead:
|
|
242
|
+
# dedicated container, PVC-scoped workspaces, cluster network policy.
|
|
243
|
+
return (
|
|
244
|
+
"sandbox=danger-full-access (pod-isolated); approval=never; strict-config; "
|
|
245
|
+
"policy-hook=configured"
|
|
246
|
+
)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _codex_argv(settings: WorkerSettings, *, allowlisted: bool = False) -> list[str]:
|
|
251
|
+
argv = [
|
|
252
|
+
"codex",
|
|
253
|
+
"exec",
|
|
254
|
+
# JSONL event stream (research/29 §1.6) -- without it stdout is human prose and
|
|
255
|
+
# `harness_usage.extract_codex_turn_usage` never finds a `turn.completed` line, so
|
|
256
|
+
# a device-login Directive's harness Usage Record (PRD issue 31, 17 A9) is silent.
|
|
257
|
+
"--json",
|
|
258
|
+
"--sandbox",
|
|
259
|
+
settings.CODEX_SANDBOX_MODE,
|
|
260
|
+
# `codex exec` is non-interactive and rejects `--ask-for-approval`
|
|
261
|
+
# (codex-cli 0.141.x exits 2 on it); the approval policy is expressed as a
|
|
262
|
+
# config override instead.
|
|
263
|
+
"--config",
|
|
264
|
+
f"approval_policy={settings.CODEX_ASK_FOR_APPROVAL}",
|
|
265
|
+
"--strict-config",
|
|
266
|
+
]
|
|
267
|
+
if settings.CODEX_SANDBOX_MODE == "workspace-write":
|
|
268
|
+
network_access = "true" if allowlisted else "false"
|
|
269
|
+
argv += ["--config", f"sandbox_workspace_write.network_access={network_access}"]
|
|
270
|
+
argv.append("-")
|
|
271
|
+
return argv
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _refused_result(
|
|
275
|
+
*,
|
|
276
|
+
workspace_id: str,
|
|
277
|
+
base_branch: str,
|
|
278
|
+
work_branch: str,
|
|
279
|
+
evidence_limit_bytes: int,
|
|
280
|
+
guard_mode: str = "refused: missing sandbox/policy configuration",
|
|
281
|
+
error: str = (
|
|
282
|
+
"Codex sandbox/policy guard configuration is required before worker-local execution"
|
|
283
|
+
),
|
|
284
|
+
) -> DirectiveResult:
|
|
285
|
+
command_hash = hash_command(["codex", "exec", "refused"])
|
|
286
|
+
return DirectiveResult(
|
|
287
|
+
exit_code=126,
|
|
288
|
+
stdout="",
|
|
289
|
+
stderr="",
|
|
290
|
+
error=error,
|
|
291
|
+
command_hash=command_hash,
|
|
292
|
+
evidence=DirectiveEvidence(
|
|
293
|
+
workspace_id=_redact_text(workspace_id),
|
|
294
|
+
command_hash=command_hash,
|
|
295
|
+
guard_mode=guard_mode,
|
|
296
|
+
base_branch=_redact_evidence_field(base_branch, evidence_limit_bytes),
|
|
297
|
+
work_branch=_redact_evidence_field(work_branch, evidence_limit_bytes),
|
|
298
|
+
notes=[],
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _redact_text(value: str) -> str:
|
|
304
|
+
redacted = value
|
|
305
|
+
for pattern in _SECRET_VALUE_PATTERNS:
|
|
306
|
+
redacted = pattern.sub(redact_match, redacted)
|
|
307
|
+
return redacted
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _redact_evidence_field(value: str, limit_bytes: int) -> str:
|
|
311
|
+
return bound_text(_redact_text(value), limit_bytes, [], "evidence")
|