agentshim 0.6.4__tar.gz → 0.6.6__tar.gz
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.
- {agentshim-0.6.4 → agentshim-0.6.6}/CHANGELOG.md +36 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/PKG-INFO +1 -1
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/__init__.py +5 -3
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/__init__.py +2 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/provider.py +17 -8
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/sandbox.py +36 -8
- agentshim-0.6.6/agentshim/providers/claude/user_hooks.py +142 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/__init__.py +7 -1
- agentshim-0.6.6/agentshim/providers/codex/_toml.py +90 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/provider.py +103 -53
- agentshim-0.6.6/agentshim/providers/codex/sandbox.py +158 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/pyproject.toml +3 -1
- {agentshim-0.6.4 → agentshim-0.6.6}/.gitignore +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/README.md +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/agent.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/env.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/events.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/mcp.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/profile.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/provider.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/schema.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/usage.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/parser.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/scripted.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/scripted.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/scripted.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/scripted.py +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/py.typed +0 -0
- {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/testing/__init__.py +0 -0
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.6 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
Additive: every new option defaults to earlier behaviour.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `CodexProvider(sandbox=CodexSandboxConfig(...))` keeps Codex's own OS
|
|
10
|
+
sandbox on instead of bypassing it. `mode` is `read-only`,
|
|
11
|
+
`workspace-write` (default) or `danger-full-access`; `workspace-write` also
|
|
12
|
+
takes absolute `writable_roots`, `network_access` and `writable_tmp`. The
|
|
13
|
+
config is rendered as `--config` overrides so resumed turns keep it, pins
|
|
14
|
+
every `workspace-write` key so user config cannot widen it, and pins
|
|
15
|
+
`approval_policy="never"`. Invalid combinations raise on construction.
|
|
16
|
+
Without a config the argv is unchanged.
|
|
17
|
+
- `agentshim.providers.codex.parse_sandbox(argv)` inverts that rendering.
|
|
18
|
+
- `ClaudeProvider(hooks=[ClaudeHook(event, command, matcher, timeout_s)])`
|
|
19
|
+
adds caller hooks to the turn's inline settings, after agentshim's own
|
|
20
|
+
read-confinement hook. `command` is an argv quoted with `shlex.join`. Hooks
|
|
21
|
+
work with or without a sandbox. `build_settings` now takes
|
|
22
|
+
`SandboxConfig | None` and a keyword `hooks`; existing calls are unchanged.
|
|
23
|
+
- Hypothesis property tests, with a `fuzz` profile (`HYPOTHESIS_PROFILE=fuzz`).
|
|
24
|
+
- Cheap-model e2e knobs `AGENTSHIM_E2E_CLAUDE_MODEL` and
|
|
25
|
+
`AGENTSHIM_E2E_CODEX_MODEL`, and a credential-free Codex sandbox
|
|
26
|
+
enforcement matrix that runs whenever `codex` is installed.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Codex `--config` string values escape control characters. A newline or
|
|
31
|
+
other control character in an MCP command, argument, env value or `PATH`
|
|
32
|
+
used to produce invalid TOML, which Codex silently keeps as a raw string.
|
|
33
|
+
|
|
34
|
+
## 0.6.5 (2026-09-27)
|
|
35
|
+
|
|
36
|
+
- Mark invocation-scoped Codex MCP servers as required so slow servers remain
|
|
37
|
+
in the initial tool catalog and honor their configured startup timeout.
|
|
38
|
+
|
|
3
39
|
## 0.6.4 (2026-09-27)
|
|
4
40
|
|
|
5
41
|
- Add optional positive finite `startup_timeout_s` to stdio and HTTP MCP
|
|
@@ -78,13 +78,13 @@ from .execution import (
|
|
|
78
78
|
TransformingExecutor,
|
|
79
79
|
)
|
|
80
80
|
from .providers import get_provider, provider_names
|
|
81
|
-
from .providers.claude import ClaudeProvider, SandboxConfig
|
|
82
|
-
from .providers.codex import CodexProvider
|
|
81
|
+
from .providers.claude import ClaudeHook, ClaudeProvider, SandboxConfig
|
|
82
|
+
from .providers.codex import CodexProvider, CodexSandboxConfig
|
|
83
83
|
from .providers.copilot import CopilotProvider
|
|
84
84
|
from .providers.gemini import GeminiProvider
|
|
85
85
|
from .providers.opencode import OpencodeProvider
|
|
86
86
|
|
|
87
|
-
__version__ = "0.6.
|
|
87
|
+
__version__ = "0.6.6"
|
|
88
88
|
|
|
89
89
|
__all__ = [
|
|
90
90
|
"AgentEvent",
|
|
@@ -94,6 +94,7 @@ __all__ = [
|
|
|
94
94
|
"ArgvContext",
|
|
95
95
|
"AssistantText",
|
|
96
96
|
"CallbackCommandStreamSink",
|
|
97
|
+
"ClaudeHook",
|
|
97
98
|
"ClaudeProvider",
|
|
98
99
|
"CliAgent",
|
|
99
100
|
"CliCheckError",
|
|
@@ -101,6 +102,7 @@ __all__ = [
|
|
|
101
102
|
"CliNotFoundError",
|
|
102
103
|
"CliTimeoutError",
|
|
103
104
|
"CodexProvider",
|
|
105
|
+
"CodexSandboxConfig",
|
|
104
106
|
"CommandExecutor",
|
|
105
107
|
"CommandHandle",
|
|
106
108
|
"CommandRequest",
|
|
@@ -6,9 +6,11 @@ from .parser import ClaudeStreamParser
|
|
|
6
6
|
from .provider import PROFILE, ClaudeProvider, mcp_entry
|
|
7
7
|
from .sandbox import SandboxConfig, build_settings, resolve_sandbox
|
|
8
8
|
from .scripted import resume_failure_lines, scripted_lines
|
|
9
|
+
from .user_hooks import ClaudeHook
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
11
12
|
"PROFILE",
|
|
13
|
+
"ClaudeHook",
|
|
12
14
|
"ClaudeProvider",
|
|
13
15
|
"ClaudeStreamParser",
|
|
14
16
|
"SandboxConfig",
|
|
@@ -11,6 +11,7 @@ from agentshim.core.profile import McpMechanism, OutputSchemaStyle, ProviderProf
|
|
|
11
11
|
|
|
12
12
|
from .parser import ClaudeStreamParser
|
|
13
13
|
from .sandbox import SANDBOX_ENV, SandboxConfig, build_settings, resolve_sandbox
|
|
14
|
+
from .user_hooks import ClaudeHook, resolve_hooks
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from collections.abc import Callable, Sequence
|
|
@@ -66,17 +67,24 @@ PROFILE = ProviderProfile(
|
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
class ClaudeProvider:
|
|
69
|
-
"""Claude Code. ``sandbox``
|
|
70
|
+
"""Claude Code. ``sandbox`` and ``hooks`` are provider options, not portable ones."""
|
|
70
71
|
|
|
71
72
|
profile = PROFILE
|
|
72
73
|
|
|
73
|
-
def __init__(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
sandbox: bool | SandboxConfig | None = None,
|
|
78
|
+
hooks: Sequence[ClaudeHook] = (),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Fix the sandbox and hooks for every turn this provider runs.
|
|
81
|
+
|
|
82
|
+
``sandbox=True`` takes the default config; ``None`` and ``False`` both
|
|
83
|
+
mean unsandboxed. ``hooks`` are added to the settings Claude Code
|
|
84
|
+
loads for the turn, after agentshim's own read-confinement hook.
|
|
78
85
|
"""
|
|
79
86
|
self.sandbox: SandboxConfig | None = resolve_sandbox(sandbox)
|
|
87
|
+
self.hooks: tuple[ClaudeHook, ...] = resolve_hooks(hooks)
|
|
80
88
|
|
|
81
89
|
@property
|
|
82
90
|
def sandbox_env(self) -> dict[str, str]:
|
|
@@ -106,8 +114,9 @@ class ClaudeProvider:
|
|
|
106
114
|
argv += ["--effort", ctx.reasoning_effort]
|
|
107
115
|
if ctx.schema_inline:
|
|
108
116
|
argv += ["--json-schema", ctx.schema_inline]
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
settings = build_settings(self.sandbox, hooks=self.hooks)
|
|
118
|
+
if settings:
|
|
119
|
+
argv += ["--settings", json.dumps(settings)]
|
|
111
120
|
argv += list(ctx.mcp_argv)
|
|
112
121
|
argv += list(ctx.extra_args)
|
|
113
122
|
return argv
|
|
@@ -16,7 +16,14 @@ import shlex
|
|
|
16
16
|
import sys
|
|
17
17
|
from dataclasses import dataclass, field
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import Any
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from .user_hooks import merge_hooks, render_hooks
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
from .user_hooks import ClaudeHook
|
|
20
27
|
|
|
21
28
|
# ``absolute()`` rather than ``resolve()``: the hook path is only handed back to
|
|
22
29
|
# the interpreter, and an install reached through a symlinked tree should keep
|
|
@@ -94,8 +101,33 @@ def resolve_sandbox(value: object) -> SandboxConfig | None:
|
|
|
94
101
|
raise TypeError(msg)
|
|
95
102
|
|
|
96
103
|
|
|
97
|
-
def build_settings(
|
|
98
|
-
|
|
104
|
+
def build_settings(
|
|
105
|
+
config: SandboxConfig | None, *, hooks: Sequence[ClaudeHook] = ()
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Build the inline ``settings.json`` payload for a turn.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
config: The sandbox to enable, or ``None`` for no ``sandbox`` block.
|
|
111
|
+
hooks: Caller hooks, appended after agentshim's own read-confinement
|
|
112
|
+
hook on the same event, so neither replaces the other.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The settings object; empty when there is nothing to set.
|
|
116
|
+
"""
|
|
117
|
+
settings: dict[str, Any] = {}
|
|
118
|
+
own_hooks: dict[str, list[dict[str, Any]]] = {}
|
|
119
|
+
if config is not None:
|
|
120
|
+
settings["sandbox"] = _sandbox_block(config)
|
|
121
|
+
if config.confine_native_reads_to:
|
|
122
|
+
own_hooks = _confine_reads_hook(config.confine_native_reads_to)
|
|
123
|
+
merged = merge_hooks(own_hooks, render_hooks(hooks))
|
|
124
|
+
if merged:
|
|
125
|
+
settings["hooks"] = merged
|
|
126
|
+
return settings
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sandbox_block(config: SandboxConfig) -> dict[str, Any]:
|
|
130
|
+
"""Build the ``sandbox`` key of the settings object."""
|
|
99
131
|
sandbox: dict[str, Any] = {
|
|
100
132
|
"enabled": True,
|
|
101
133
|
"failIfUnavailable": config.fail_if_unavailable,
|
|
@@ -121,11 +153,7 @@ def build_settings(config: SandboxConfig) -> dict[str, Any]:
|
|
|
121
153
|
sandbox["network"] = {"allowedDomains": list(config.allowed_domains)}
|
|
122
154
|
|
|
123
155
|
sandbox.update(config.extra_settings)
|
|
124
|
-
|
|
125
|
-
settings: dict[str, Any] = {"sandbox": sandbox}
|
|
126
|
-
if config.confine_native_reads_to:
|
|
127
|
-
settings["hooks"] = _confine_reads_hook(config.confine_native_reads_to)
|
|
128
|
-
return settings
|
|
156
|
+
return sandbox
|
|
129
157
|
|
|
130
158
|
|
|
131
159
|
def _confine_reads_hook(roots: list[str]) -> dict[str, Any]:
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Caller-supplied Claude Code hooks.
|
|
2
|
+
|
|
3
|
+
Claude Code runs a hook command on an event such as ``PreToolUse`` and acts
|
|
4
|
+
on the JSON it prints, which is how a caller enforces a policy the sandbox
|
|
5
|
+
cannot express, for example refusing one executable in Bash. agentshim
|
|
6
|
+
already passes its own settings inline through ``--settings``; a second
|
|
7
|
+
``--settings`` in ``extra_args`` would compete with that one, so the provider
|
|
8
|
+
takes hooks as an option and merges them into the single settings object.
|
|
9
|
+
|
|
10
|
+
See https://code.claude.com/docs/en/hooks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, cast
|
|
21
|
+
|
|
22
|
+
#: Claude Code's hook events are PascalCase names. New events appear between
|
|
23
|
+
#: CLI releases, so the shape is checked rather than a closed list.
|
|
24
|
+
_EVENT_RE = re.compile(r"[A-Z][A-Za-z]*")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ClaudeHook:
|
|
29
|
+
"""One command Claude Code runs on a hook event.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
event: The hook event, such as ``PreToolUse`` or ``PostToolUse``.
|
|
33
|
+
command: The hook's argv. Claude Code runs hooks through a shell, so
|
|
34
|
+
agentshim quotes it with ``shlex.join``: every element reaches the
|
|
35
|
+
process literally, whatever it contains. Use an absolute
|
|
36
|
+
executable path; the agent's PATH is not the caller's. Any
|
|
37
|
+
sequence of strings is accepted and stored as a tuple.
|
|
38
|
+
matcher: For tool events, the tool-name pattern the hook applies to,
|
|
39
|
+
such as ``Bash`` or ``Edit|Write``. ``None`` matches every tool.
|
|
40
|
+
timeout_s: Seconds Claude Code allows the hook before giving up;
|
|
41
|
+
``None`` keeps the CLI default.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
event: str
|
|
45
|
+
command: Sequence[str]
|
|
46
|
+
matcher: str | None = None
|
|
47
|
+
timeout_s: float | None = None
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
"""Reject a hook Claude Code would refuse or run incorrectly."""
|
|
51
|
+
_check_event(self.event)
|
|
52
|
+
object.__setattr__(self, "command", _as_argv(self.command))
|
|
53
|
+
_check_matcher(self.matcher)
|
|
54
|
+
if self.timeout_s is not None and not _positive_finite(self.timeout_s):
|
|
55
|
+
msg = f"timeout_s must be a positive finite number, got {self.timeout_s!r}"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_event(event: object) -> None:
|
|
60
|
+
if not isinstance(event, str) or not _EVENT_RE.fullmatch(event):
|
|
61
|
+
msg = f"event must be a PascalCase hook event name, got {event!r}"
|
|
62
|
+
raise ValueError(msg)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_matcher(matcher: object) -> None:
|
|
66
|
+
if matcher is not None and (not isinstance(matcher, str) or not matcher):
|
|
67
|
+
msg = f"matcher must be a non-empty string or None, got {matcher!r}"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _as_argv(value: object) -> tuple[str, ...]:
|
|
72
|
+
"""Validate a hook command and freeze it into a tuple.
|
|
73
|
+
|
|
74
|
+
A bare string is rejected rather than split: ``"python3 hook.py"`` would
|
|
75
|
+
otherwise become one argv element naming a file that does not exist.
|
|
76
|
+
"""
|
|
77
|
+
if isinstance(value, str) or not isinstance(value, Sequence):
|
|
78
|
+
msg = f"command must be an argv sequence, not {type(value).__name__}"
|
|
79
|
+
raise TypeError(msg)
|
|
80
|
+
argv = tuple(cast("Sequence[object]", value))
|
|
81
|
+
if not argv:
|
|
82
|
+
msg = "command must not be empty"
|
|
83
|
+
raise ValueError(msg)
|
|
84
|
+
for arg in argv:
|
|
85
|
+
if not isinstance(arg, str):
|
|
86
|
+
msg = f"command elements must be str, got {type(arg).__name__}"
|
|
87
|
+
raise TypeError(msg)
|
|
88
|
+
if "\x00" in arg:
|
|
89
|
+
msg = f"command element contains a NUL byte: {arg!r}"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
try:
|
|
92
|
+
arg.encode("utf-8")
|
|
93
|
+
except UnicodeEncodeError:
|
|
94
|
+
msg = f"command element is not valid UTF-8 text: {arg!r}"
|
|
95
|
+
raise ValueError(msg) from None
|
|
96
|
+
return cast("tuple[str, ...]", argv)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _positive_finite(value: object) -> bool:
|
|
100
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
101
|
+
return False
|
|
102
|
+
return math.isfinite(value) and value > 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_hooks(value: object) -> tuple[ClaudeHook, ...]:
|
|
106
|
+
"""Normalize the ``hooks`` provider option to a tuple of hooks."""
|
|
107
|
+
if isinstance(value, ClaudeHook) or not isinstance(value, Sequence):
|
|
108
|
+
msg = f"hooks must be a sequence of ClaudeHook, got {type(value).__name__}"
|
|
109
|
+
raise TypeError(msg)
|
|
110
|
+
hooks = tuple(cast("Sequence[object]", value))
|
|
111
|
+
for hook in hooks:
|
|
112
|
+
if not isinstance(hook, ClaudeHook):
|
|
113
|
+
msg = f"hooks must contain ClaudeHook, got {type(hook).__name__}"
|
|
114
|
+
raise TypeError(msg)
|
|
115
|
+
return cast("tuple[ClaudeHook, ...]", hooks)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render_hooks(hooks: Sequence[ClaudeHook]) -> dict[str, list[dict[str, Any]]]:
|
|
119
|
+
"""Render *hooks* as a settings ``hooks`` block, one entry per hook.
|
|
120
|
+
|
|
121
|
+
Entries keep the caller's order within each event. One entry per hook,
|
|
122
|
+
rather than grouping by matcher, keeps every hook's timeout its own.
|
|
123
|
+
"""
|
|
124
|
+
block: dict[str, list[dict[str, Any]]] = {}
|
|
125
|
+
for hook in hooks:
|
|
126
|
+
handler: dict[str, Any] = {"type": "command", "command": shlex.join(hook.command)}
|
|
127
|
+
if hook.timeout_s is not None:
|
|
128
|
+
handler["timeout"] = hook.timeout_s
|
|
129
|
+
entry: dict[str, Any] = {"hooks": [handler]}
|
|
130
|
+
if hook.matcher is not None:
|
|
131
|
+
entry["matcher"] = hook.matcher
|
|
132
|
+
block.setdefault(hook.event, []).append(entry)
|
|
133
|
+
return block
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def merge_hooks(*blocks: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
|
|
137
|
+
"""Concatenate hook blocks event by event, earlier blocks first."""
|
|
138
|
+
merged: dict[str, list[dict[str, Any]]] = {}
|
|
139
|
+
for block in blocks:
|
|
140
|
+
for event, entries in block.items():
|
|
141
|
+
merged.setdefault(event, []).extend(entries)
|
|
142
|
+
return merged
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from .parser import CodexStreamParser
|
|
6
|
-
from .provider import PROFILE, CodexProvider, parse_mcp_servers
|
|
6
|
+
from .provider import BYPASS_FLAG, PROFILE, CodexProvider, parse_mcp_servers, parse_sandbox
|
|
7
|
+
from .sandbox import SANDBOX_MODES, CodexSandboxConfig, SandboxMode
|
|
7
8
|
from .scripted import resume_failure_lines, scripted_lines
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
11
|
+
"BYPASS_FLAG",
|
|
10
12
|
"PROFILE",
|
|
13
|
+
"SANDBOX_MODES",
|
|
11
14
|
"CodexProvider",
|
|
15
|
+
"CodexSandboxConfig",
|
|
12
16
|
"CodexStreamParser",
|
|
17
|
+
"SandboxMode",
|
|
13
18
|
"parse_mcp_servers",
|
|
19
|
+
"parse_sandbox",
|
|
14
20
|
"resume_failure_lines",
|
|
15
21
|
"scripted_lines",
|
|
16
22
|
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""TOML literals for Codex's ``--config key=value`` overrides.
|
|
2
|
+
|
|
3
|
+
Codex parses the value of each override as TOML and, when that fails, uses
|
|
4
|
+
the raw text as a string instead. A malformed literal therefore does not
|
|
5
|
+
error: it silently changes the value's type. Everything this provider puts in
|
|
6
|
+
an override goes through these helpers, and ``unescape_toml`` inverts
|
|
7
|
+
``toml_str`` so the argv parsers can read back exactly what was rendered.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
#: TOML's short escapes. Every other control character becomes ``\\uXXXX``.
|
|
18
|
+
_SHORT_ESCAPES = {
|
|
19
|
+
"\\": "\\\\",
|
|
20
|
+
'"': '\\"',
|
|
21
|
+
"\b": "\\b",
|
|
22
|
+
"\t": "\\t",
|
|
23
|
+
"\n": "\\n",
|
|
24
|
+
"\f": "\\f",
|
|
25
|
+
"\r": "\\r",
|
|
26
|
+
}
|
|
27
|
+
_UNESCAPES = {escape[1]: char for char, escape in _SHORT_ESCAPES.items()}
|
|
28
|
+
_UNICODE_ESCAPE_WIDTHS = {"u": 4, "U": 8}
|
|
29
|
+
_DEL = 0x7F
|
|
30
|
+
_FIRST_PRINTABLE = 0x20
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def toml_str(value: str) -> str:
|
|
34
|
+
"""Quote *value* as a TOML basic string that parses back to *value*.
|
|
35
|
+
|
|
36
|
+
TOML forbids raw control characters in a basic string, and Codex treats
|
|
37
|
+
an override that fails to parse as TOML as a raw literal instead, so an
|
|
38
|
+
unescaped newline would silently change the value's type.
|
|
39
|
+
"""
|
|
40
|
+
parts: list[str] = []
|
|
41
|
+
for char in value:
|
|
42
|
+
if char in _SHORT_ESCAPES:
|
|
43
|
+
parts.append(_SHORT_ESCAPES[char])
|
|
44
|
+
elif ord(char) < _FIRST_PRINTABLE or ord(char) == _DEL:
|
|
45
|
+
parts.append(f"\\u{ord(char):04X}")
|
|
46
|
+
else:
|
|
47
|
+
parts.append(char)
|
|
48
|
+
return '"' + "".join(parts) + '"'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def toml_array(values: Sequence[str]) -> str:
|
|
52
|
+
"""Render strings as a TOML inline array."""
|
|
53
|
+
return "[" + ",".join(toml_str(value) for value in values) + "]"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def toml_bool(value: bool) -> str: # noqa: FBT001 - a value, not a mode switch
|
|
57
|
+
"""Render a TOML boolean."""
|
|
58
|
+
return "true" if value else "false"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def unescape_toml(body: str) -> str:
|
|
62
|
+
"""Invert ``toml_str`` on the text between the quotes.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: *body* holds an escape ``toml_str`` never writes.
|
|
66
|
+
"""
|
|
67
|
+
result: list[str] = []
|
|
68
|
+
index = 0
|
|
69
|
+
while index < len(body):
|
|
70
|
+
char = body[index]
|
|
71
|
+
if char != "\\":
|
|
72
|
+
result.append(char)
|
|
73
|
+
index += 1
|
|
74
|
+
continue
|
|
75
|
+
code = body[index + 1 : index + 2]
|
|
76
|
+
if code in _UNESCAPES:
|
|
77
|
+
result.append(_UNESCAPES[code])
|
|
78
|
+
index += 2
|
|
79
|
+
elif code in _UNICODE_ESCAPE_WIDTHS:
|
|
80
|
+
width = _UNICODE_ESCAPE_WIDTHS[code]
|
|
81
|
+
digits = body[index + 2 : index + 2 + width]
|
|
82
|
+
if len(digits) != width:
|
|
83
|
+
msg = f"truncated \\{code} escape in {body!r}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
result.append(chr(int(digits, 16)))
|
|
86
|
+
index += 2 + width
|
|
87
|
+
else:
|
|
88
|
+
msg = f"invalid TOML escape \\{code} in {body!r}"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
return "".join(result)
|
|
@@ -14,7 +14,9 @@ from agentshim.core.profile import (
|
|
|
14
14
|
SchemaDialect,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
+
from ._toml import toml_array, toml_str, unescape_toml
|
|
17
18
|
from .parser import CodexStreamParser
|
|
19
|
+
from .sandbox import CodexSandboxConfig, resolve_sandbox, sandbox_overrides
|
|
18
20
|
|
|
19
21
|
if TYPE_CHECKING:
|
|
20
22
|
from collections.abc import Callable, Mapping, Sequence
|
|
@@ -85,11 +87,24 @@ PROFILE = ProviderProfile(
|
|
|
85
87
|
)
|
|
86
88
|
|
|
87
89
|
|
|
90
|
+
#: Turns off both Codex's sandbox and its approval prompts.
|
|
91
|
+
BYPASS_FLAG = "--dangerously-bypass-approvals-and-sandbox"
|
|
92
|
+
|
|
93
|
+
|
|
88
94
|
class CodexProvider:
|
|
89
|
-
"""Codex (``codex exec --json``)."""
|
|
95
|
+
"""Codex (``codex exec --json``). ``sandbox`` is a provider option."""
|
|
90
96
|
|
|
91
97
|
profile = PROFILE
|
|
92
98
|
|
|
99
|
+
def __init__(self, *, sandbox: CodexSandboxConfig | None = None) -> None:
|
|
100
|
+
"""Fix the sandbox for every turn this provider runs.
|
|
101
|
+
|
|
102
|
+
``None``, the default, bypasses Codex's sandbox and approvals, for a
|
|
103
|
+
caller that isolates the whole process itself. A
|
|
104
|
+
``CodexSandboxConfig`` keeps the CLI's own sandbox on instead.
|
|
105
|
+
"""
|
|
106
|
+
self.sandbox: CodexSandboxConfig | None = resolve_sandbox(sandbox)
|
|
107
|
+
|
|
93
108
|
def build_argv(self, ctx: ArgvContext) -> list[str]:
|
|
94
109
|
"""Build the ``codex exec`` command line for one turn.
|
|
95
110
|
|
|
@@ -101,18 +116,27 @@ class CodexProvider:
|
|
|
101
116
|
argv = [ctx.binary_path, "exec"]
|
|
102
117
|
if ctx.resume_session_id:
|
|
103
118
|
argv += ["resume", ctx.resume_session_id, "-"]
|
|
104
|
-
argv +=
|
|
119
|
+
argv += self._sandbox_argv()
|
|
120
|
+
argv += ["--skip-git-repo-check", "--json"]
|
|
105
121
|
if ctx.model:
|
|
106
122
|
argv += ["--model", ctx.model]
|
|
107
123
|
argv += _shell_path_config(ctx.env)
|
|
108
124
|
if ctx.reasoning_effort:
|
|
109
|
-
argv += ["--config", f"model_reasoning_effort={
|
|
125
|
+
argv += ["--config", f"model_reasoning_effort={toml_str(ctx.reasoning_effort)}"]
|
|
110
126
|
argv += list(ctx.mcp_argv)
|
|
111
127
|
if ctx.schema_path:
|
|
112
128
|
argv += ["--output-schema", ctx.schema_path]
|
|
113
129
|
argv += list(ctx.extra_args)
|
|
114
130
|
return argv
|
|
115
131
|
|
|
132
|
+
def _sandbox_argv(self) -> list[str]:
|
|
133
|
+
if self.sandbox is None:
|
|
134
|
+
return [BYPASS_FLAG]
|
|
135
|
+
argv: list[str] = []
|
|
136
|
+
for key, value in sandbox_overrides(self.sandbox):
|
|
137
|
+
argv += ["--config", f"{key}={value}"]
|
|
138
|
+
return argv
|
|
139
|
+
|
|
116
140
|
def new_parser(
|
|
117
141
|
self,
|
|
118
142
|
emit: Callable[[AgentEvent], None],
|
|
@@ -173,7 +197,7 @@ def _shell_path_config(env: Mapping[str, str]) -> list[str]:
|
|
|
173
197
|
path = env.get("PATH")
|
|
174
198
|
if not path:
|
|
175
199
|
return []
|
|
176
|
-
return ["--config", f"shell_environment_policy.set.PATH={
|
|
200
|
+
return ["--config", f"shell_environment_policy.set.PATH={toml_str(path)}"]
|
|
177
201
|
|
|
178
202
|
|
|
179
203
|
def _server_flags(server: McpServer) -> list[str]:
|
|
@@ -189,16 +213,23 @@ def _server_flags(server: McpServer) -> list[str]:
|
|
|
189
213
|
raise ProviderCapabilityError(_HTTP_HEADERS_UNSUPPORTED)
|
|
190
214
|
# A ``--config`` override carries the address and nothing else, so
|
|
191
215
|
# Codex works the transport out from the endpoint itself.
|
|
192
|
-
flags = [
|
|
216
|
+
flags = [
|
|
217
|
+
"--config",
|
|
218
|
+
f"{prefix}.url={toml_str(server.url)}",
|
|
219
|
+
"--config",
|
|
220
|
+
f"{prefix}.required=true",
|
|
221
|
+
]
|
|
193
222
|
return flags + _timeout_flags(prefix, server)
|
|
194
223
|
flags = [
|
|
195
224
|
"--config",
|
|
196
|
-
f"{prefix}.command={
|
|
225
|
+
f"{prefix}.command={toml_str(server.command)}",
|
|
226
|
+
"--config",
|
|
227
|
+
f"{prefix}.args={toml_array(list(server.args))}",
|
|
197
228
|
"--config",
|
|
198
|
-
f"{prefix}.
|
|
229
|
+
f"{prefix}.required=true",
|
|
199
230
|
]
|
|
200
231
|
for env_key, env_value in server.env.items():
|
|
201
|
-
flags += ["--config", f"{prefix}.env.{env_key}={
|
|
232
|
+
flags += ["--config", f"{prefix}.env.{env_key}={toml_str(env_value)}"]
|
|
202
233
|
return flags + _timeout_flags(prefix, server)
|
|
203
234
|
|
|
204
235
|
|
|
@@ -212,17 +243,6 @@ def _timeout_flags(prefix: str, server: McpServer) -> list[str]:
|
|
|
212
243
|
return flags
|
|
213
244
|
|
|
214
245
|
|
|
215
|
-
def _toml_str(value: str) -> str:
|
|
216
|
-
"""Quote *value* as a TOML basic string literal."""
|
|
217
|
-
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
218
|
-
return f'"{escaped}"'
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _toml_array(values: Sequence[str]) -> str:
|
|
222
|
-
"""Render a sequence of strings as a TOML inline array."""
|
|
223
|
-
return "[" + ",".join(_toml_str(value) for value in values) + "]"
|
|
224
|
-
|
|
225
|
-
|
|
226
246
|
_CONFIG_OVERRIDE_RE = re.compile(r"^mcp_servers\.([^.=]+)\.(.+)$")
|
|
227
247
|
_TOML_STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
228
248
|
|
|
@@ -257,14 +277,8 @@ def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
|
257
277
|
|
|
258
278
|
def _mcp_config_overrides(argv: Sequence[str]) -> list[tuple[str, str, str]]:
|
|
259
279
|
"""Pull every ``mcp_servers.<key>.<field>=<value>`` override out of *argv*."""
|
|
260
|
-
args = list(argv)
|
|
261
280
|
overrides: list[tuple[str, str, str]] = []
|
|
262
|
-
for
|
|
263
|
-
if arg != "--config" or index + 1 >= len(args):
|
|
264
|
-
continue
|
|
265
|
-
path, sep, raw_value = args[index + 1].partition("=")
|
|
266
|
-
if not sep:
|
|
267
|
-
continue
|
|
281
|
+
for path, raw_value in _config_overrides(argv):
|
|
268
282
|
match = _CONFIG_OVERRIDE_RE.match(path)
|
|
269
283
|
if match is not None:
|
|
270
284
|
key, field = match.groups()
|
|
@@ -275,43 +289,79 @@ def _mcp_config_overrides(argv: Sequence[str]) -> list[tuple[str, str, str]]:
|
|
|
275
289
|
def _apply_mcp_override(entry: dict[str, Any], field: str, raw_value: str) -> None:
|
|
276
290
|
"""Fold one dotted-path override into the entry being rebuilt for it."""
|
|
277
291
|
if field == "command":
|
|
278
|
-
entry["command"] =
|
|
292
|
+
entry["command"] = _parsetoml_str(raw_value)
|
|
279
293
|
elif field == "args":
|
|
280
|
-
entry["args"] =
|
|
294
|
+
entry["args"] = _parsetoml_array(raw_value)
|
|
281
295
|
elif field == "url":
|
|
282
|
-
entry["url"] =
|
|
296
|
+
entry["url"] = _parsetoml_str(raw_value)
|
|
283
297
|
entry["transport"] = "http"
|
|
284
298
|
elif field == "tool_timeout_sec":
|
|
285
299
|
entry["tool_timeout_s"] = float(raw_value)
|
|
286
300
|
elif field == "startup_timeout_sec":
|
|
287
301
|
entry["startup_timeout_s"] = float(raw_value)
|
|
288
302
|
elif field.startswith("env."):
|
|
289
|
-
entry.setdefault("env", {})[field.removeprefix("env.")] =
|
|
303
|
+
entry.setdefault("env", {})[field.removeprefix("env.")] = _parsetoml_str(raw_value)
|
|
290
304
|
|
|
291
305
|
|
|
292
|
-
def
|
|
293
|
-
"""Reverse ``
|
|
306
|
+
def _parsetoml_str(literal: str) -> str:
|
|
307
|
+
"""Reverse ``toml_str``: unescape one TOML basic string literal."""
|
|
294
308
|
match = _TOML_STRING_RE.fullmatch(literal)
|
|
295
309
|
if match is None:
|
|
296
310
|
return literal
|
|
297
|
-
return
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def
|
|
301
|
-
"""Reverse ``
|
|
302
|
-
return [
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
""
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
311
|
+
return unescape_toml(match.group(1))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parsetoml_array(literal: str) -> list[str]:
|
|
315
|
+
"""Reverse ``toml_array``: unescape every string in a TOML inline array."""
|
|
316
|
+
return [unescape_toml(inner) for inner in _TOML_STRING_RE.findall(literal)]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
_SANDBOX_KEYS = {
|
|
320
|
+
"sandbox_mode",
|
|
321
|
+
"sandbox_workspace_write.writable_roots",
|
|
322
|
+
"sandbox_workspace_write.network_access",
|
|
323
|
+
"sandbox_workspace_write.exclude_slash_tmp",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_sandbox(argv: Sequence[str]) -> CodexSandboxConfig | None:
|
|
328
|
+
"""Recover the sandbox rendered into *argv* by ``CodexProvider``.
|
|
329
|
+
|
|
330
|
+
The inverse of ``CodexProvider._sandbox_argv``, kept next to it so the
|
|
331
|
+
two cannot drift.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
``None`` when the turn bypassed Codex's sandbox, else the config it
|
|
338
|
+
imposed.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ValueError: *argv* neither bypasses the sandbox nor selects a mode.
|
|
342
|
+
"""
|
|
343
|
+
if BYPASS_FLAG in argv:
|
|
344
|
+
return None
|
|
345
|
+
overrides = {key: value for key, value in _config_overrides(argv) if key in _SANDBOX_KEYS}
|
|
346
|
+
if "sandbox_mode" not in overrides:
|
|
347
|
+
msg = f"argv neither bypasses nor selects a Codex sandbox: {list(argv)!r}"
|
|
348
|
+
raise ValueError(msg)
|
|
349
|
+
prefix = "sandbox_workspace_write"
|
|
350
|
+
return CodexSandboxConfig(
|
|
351
|
+
mode=_parsetoml_str(overrides["sandbox_mode"]), # pyright: ignore[reportArgumentType]
|
|
352
|
+
writable_roots=_parsetoml_array(overrides.get(f"{prefix}.writable_roots", "[]")),
|
|
353
|
+
network_access=overrides.get(f"{prefix}.network_access") == "true",
|
|
354
|
+
writable_tmp=overrides.get(f"{prefix}.exclude_slash_tmp", "false") == "false",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _config_overrides(argv: Sequence[str]) -> list[tuple[str, str]]:
|
|
359
|
+
"""Pull every ``--config key=value`` pair out of *argv*, in order."""
|
|
360
|
+
args = list(argv)
|
|
361
|
+
pairs: list[tuple[str, str]] = []
|
|
362
|
+
for index, arg in enumerate(args[:-1]):
|
|
363
|
+
if arg == "--config":
|
|
364
|
+
key, sep, value = args[index + 1].partition("=")
|
|
365
|
+
if sep:
|
|
366
|
+
pairs.append((key, value))
|
|
367
|
+
return pairs
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Codex's OS sandbox for the commands the model runs.
|
|
2
|
+
|
|
3
|
+
Codex confines model-generated shell commands itself (Landlock and seccomp on
|
|
4
|
+
Linux, Seatbelt on macOS). By default agentshim turns that off with
|
|
5
|
+
``--dangerously-bypass-approvals-and-sandbox``, which suits a caller that
|
|
6
|
+
already isolates the whole process. ``CodexSandboxConfig`` keeps the CLI's own
|
|
7
|
+
sandbox on instead.
|
|
8
|
+
|
|
9
|
+
Every setting is rendered as a ``--config`` override rather than ``--sandbox``,
|
|
10
|
+
because ``codex exec resume`` accepts ``--config`` but not ``--sandbox``: one
|
|
11
|
+
rendering serves fresh and resumed turns alike. For ``workspace-write`` every
|
|
12
|
+
key is emitted explicitly, even at its default, so a user's
|
|
13
|
+
``~/.codex/config.toml`` cannot widen the sandbox a caller asked for.
|
|
14
|
+
|
|
15
|
+
See https://developers.openai.com/codex/security.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Literal, cast, get_args
|
|
24
|
+
|
|
25
|
+
from ._toml import toml_array, toml_bool, toml_str
|
|
26
|
+
|
|
27
|
+
SandboxMode = Literal["read-only", "workspace-write", "danger-full-access"]
|
|
28
|
+
|
|
29
|
+
#: Every mode Codex accepts, in its own spelling.
|
|
30
|
+
SANDBOX_MODES: tuple[SandboxMode, ...] = get_args(SandboxMode)
|
|
31
|
+
|
|
32
|
+
_WORKSPACE_WRITE: SandboxMode = "workspace-write"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _roots() -> tuple[str, ...]:
|
|
36
|
+
return ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CodexSandboxConfig:
|
|
41
|
+
"""How Codex sandboxes the commands the model runs.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
mode: ``read-only`` lets commands read but not write anywhere and
|
|
45
|
+
blocks the network. ``workspace-write`` also lets them write the
|
|
46
|
+
working directory, ``writable_roots`` and (by default) the temp
|
|
47
|
+
directories. ``danger-full-access`` removes the sandbox but, unlike
|
|
48
|
+
the bypass flag, keeps Codex's approval policy in force.
|
|
49
|
+
writable_roots: Extra absolute directories commands may write.
|
|
50
|
+
``workspace-write`` only. Any sequence of strings is accepted and
|
|
51
|
+
stored as a tuple, so equal configs compare and hash equal.
|
|
52
|
+
network_access: Let commands open network sockets, including local
|
|
53
|
+
ones such as the Docker socket. ``workspace-write`` only.
|
|
54
|
+
writable_tmp: Keep ``/tmp`` and ``$TMPDIR`` writable, which is Codex's
|
|
55
|
+
own default. ``workspace-write`` only.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
mode: SandboxMode = _WORKSPACE_WRITE
|
|
59
|
+
writable_roots: Sequence[str] = field(default_factory=_roots)
|
|
60
|
+
network_access: bool = False
|
|
61
|
+
writable_tmp: bool = True
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
"""Reject a config Codex would reject or silently ignore."""
|
|
65
|
+
if self.mode not in SANDBOX_MODES:
|
|
66
|
+
msg = f"mode must be one of {', '.join(SANDBOX_MODES)}; got {self.mode!r}"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
object.__setattr__(self, "writable_roots", _as_roots(self.writable_roots))
|
|
69
|
+
for name in ("network_access", "writable_tmp"):
|
|
70
|
+
value: object = getattr(self, name)
|
|
71
|
+
if not isinstance(value, bool):
|
|
72
|
+
msg = f"{name} must be a bool, got {type(value).__name__}"
|
|
73
|
+
raise TypeError(msg)
|
|
74
|
+
if self.mode != _WORKSPACE_WRITE and self._widens_workspace_write():
|
|
75
|
+
msg = (
|
|
76
|
+
"writable_roots, network_access and writable_tmp only apply to "
|
|
77
|
+
f"workspace-write; {self.mode} would ignore them"
|
|
78
|
+
)
|
|
79
|
+
raise ValueError(msg)
|
|
80
|
+
|
|
81
|
+
def _widens_workspace_write(self) -> bool:
|
|
82
|
+
return bool(self.writable_roots) or self.network_access or not self.writable_tmp
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _as_roots(value: object) -> tuple[str, ...]:
|
|
86
|
+
"""Validate ``writable_roots`` and freeze it into a tuple.
|
|
87
|
+
|
|
88
|
+
A bare string is rejected rather than iterated, which would turn
|
|
89
|
+
``"/data"`` into the roots ``"/"``, ``"d"``, ``"a"``, ... A set is
|
|
90
|
+
rejected too: its order is arbitrary, so the rendered argv would be.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(value, str) or not isinstance(value, Sequence):
|
|
93
|
+
msg = f"writable_roots must be a sequence of paths, not {type(value).__name__}"
|
|
94
|
+
raise TypeError(msg)
|
|
95
|
+
roots = tuple(cast("Sequence[object]", value))
|
|
96
|
+
for root in roots:
|
|
97
|
+
_check_root(root)
|
|
98
|
+
return cast("tuple[str, ...]", roots)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_root(root: object) -> None:
|
|
102
|
+
if not isinstance(root, str):
|
|
103
|
+
msg = f"writable_roots entries must be str, got {type(root).__name__}"
|
|
104
|
+
raise TypeError(msg)
|
|
105
|
+
if "\x00" in root:
|
|
106
|
+
msg = f"writable_roots entry contains a NUL byte: {root!r}"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
if not _is_utf8(root):
|
|
109
|
+
msg = f"writable_roots entry is not valid UTF-8 text: {root!r}"
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
if not os.path.isabs(root): # noqa: PTH117 - a str contract, not a Path
|
|
112
|
+
msg = f"writable_roots entries must be absolute; got {root!r}"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_utf8(text: str) -> bool:
|
|
117
|
+
"""TOML and argv both need text that encodes; a lone surrogate does not."""
|
|
118
|
+
try:
|
|
119
|
+
text.encode("utf-8")
|
|
120
|
+
except UnicodeEncodeError:
|
|
121
|
+
return False
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_sandbox(value: object) -> CodexSandboxConfig | None:
|
|
126
|
+
"""Normalize the ``sandbox`` provider option to a config or ``None``.
|
|
127
|
+
|
|
128
|
+
``None`` keeps agentshim's historical behaviour: Codex's sandbox and
|
|
129
|
+
approvals are bypassed. There is deliberately no ``True`` shorthand,
|
|
130
|
+
because no one mode is an obvious default for every caller.
|
|
131
|
+
"""
|
|
132
|
+
if value is None or isinstance(value, CodexSandboxConfig):
|
|
133
|
+
return value
|
|
134
|
+
msg = f"sandbox must be a CodexSandboxConfig or None, got {type(value).__name__}"
|
|
135
|
+
raise TypeError(msg)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sandbox_overrides(config: CodexSandboxConfig) -> list[tuple[str, str]]:
|
|
139
|
+
"""Return the ``(key, TOML value)`` pairs that impose *config*.
|
|
140
|
+
|
|
141
|
+
``approval_policy = "never"`` rides along because a headless turn has no
|
|
142
|
+
one to approve anything: without it a command the sandbox refuses would
|
|
143
|
+
wait for an answer that never comes.
|
|
144
|
+
"""
|
|
145
|
+
pairs = [
|
|
146
|
+
("sandbox_mode", toml_str(config.mode)),
|
|
147
|
+
("approval_policy", toml_str("never")),
|
|
148
|
+
]
|
|
149
|
+
if config.mode == _WORKSPACE_WRITE:
|
|
150
|
+
prefix = "sandbox_workspace_write"
|
|
151
|
+
excluded = toml_bool(not config.writable_tmp)
|
|
152
|
+
pairs += [
|
|
153
|
+
(f"{prefix}.writable_roots", toml_array(config.writable_roots)),
|
|
154
|
+
(f"{prefix}.network_access", toml_bool(config.network_access)),
|
|
155
|
+
(f"{prefix}.exclude_slash_tmp", excluded),
|
|
156
|
+
(f"{prefix}.exclude_tmpdir_env_var", excluded),
|
|
157
|
+
]
|
|
158
|
+
return pairs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agentshim"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.6"
|
|
4
4
|
description = "Provider-agnostic coding-agent CLI shims"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -17,10 +17,12 @@ build-backend = "hatchling.build"
|
|
|
17
17
|
|
|
18
18
|
[dependency-groups]
|
|
19
19
|
dev = [
|
|
20
|
+
"hypothesis>=6.168.1",
|
|
20
21
|
"import-linter>=2.0",
|
|
21
22
|
"pyright>=1.1.408",
|
|
22
23
|
"pytest>=8.0.0",
|
|
23
24
|
"ruff==0.15.12",
|
|
25
|
+
"tomli>=2 ; python_full_version < '3.11'",
|
|
24
26
|
]
|
|
25
27
|
docs = [
|
|
26
28
|
"mkdocs>=1.6.1",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|