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.
Files changed (57) hide show
  1. {agentshim-0.6.4 → agentshim-0.6.6}/CHANGELOG.md +36 -0
  2. {agentshim-0.6.4 → agentshim-0.6.6}/PKG-INFO +1 -1
  3. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/__init__.py +5 -3
  4. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/__init__.py +2 -0
  5. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/provider.py +17 -8
  6. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/sandbox.py +36 -8
  7. agentshim-0.6.6/agentshim/providers/claude/user_hooks.py +142 -0
  8. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/__init__.py +7 -1
  9. agentshim-0.6.6/agentshim/providers/codex/_toml.py +90 -0
  10. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/provider.py +103 -53
  11. agentshim-0.6.6/agentshim/providers/codex/sandbox.py +158 -0
  12. {agentshim-0.6.4 → agentshim-0.6.6}/pyproject.toml +3 -1
  13. {agentshim-0.6.4 → agentshim-0.6.6}/.gitignore +0 -0
  14. {agentshim-0.6.4 → agentshim-0.6.6}/README.md +0 -0
  15. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/agent.py +0 -0
  16. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/__init__.py +0 -0
  17. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/_files.py +0 -0
  18. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/env.py +0 -0
  19. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/errors.py +0 -0
  20. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/events.py +0 -0
  21. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/mcp.py +0 -0
  22. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/profile.py +0 -0
  23. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/provider.py +0 -0
  24. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/schema.py +0 -0
  25. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/stream.py +0 -0
  26. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/turn.py +0 -0
  27. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/core/usage.py +0 -0
  28. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/__init__.py +0 -0
  29. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/executor.py +0 -0
  30. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/host.py +0 -0
  31. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/execution/transform.py +0 -0
  32. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/__init__.py +0 -0
  33. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/events.py +0 -0
  34. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/hooks/__init__.py +0 -0
  35. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  36. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/parser.py +0 -0
  37. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/claude/scripted.py +0 -0
  38. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/events.py +0 -0
  39. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/parser.py +0 -0
  40. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/codex/scripted.py +0 -0
  41. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/__init__.py +0 -0
  42. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/events.py +0 -0
  43. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/parser.py +0 -0
  44. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/provider.py +0 -0
  45. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/copilot/scripted.py +0 -0
  46. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/__init__.py +0 -0
  47. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/events.py +0 -0
  48. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/parser.py +0 -0
  49. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/provider.py +0 -0
  50. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/gemini/scripted.py +0 -0
  51. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/__init__.py +0 -0
  52. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/events.py +0 -0
  53. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/parser.py +0 -0
  54. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/provider.py +0 -0
  55. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/providers/opencode/scripted.py +0 -0
  56. {agentshim-0.6.4 → agentshim-0.6.6}/agentshim/py.typed +0 -0
  57. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.4
3
+ Version: 0.6.6
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -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.4"
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`` is a provider option, not a portable one."""
70
+ """Claude Code. ``sandbox`` and ``hooks`` are provider options, not portable ones."""
70
71
 
71
72
  profile = PROFILE
72
73
 
73
- def __init__(self, *, sandbox: bool | SandboxConfig | None = None) -> None:
74
- """Fix the sandbox option for every turn this provider runs.
75
-
76
- ``True`` takes the default config; ``None`` and ``False`` both mean
77
- unsandboxed.
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
- if self.sandbox is not None:
110
- argv += ["--settings", json.dumps(build_settings(self.sandbox))]
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(config: SandboxConfig) -> dict[str, Any]:
98
- """Build the ``settings.json`` payload that enables the sandbox."""
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 += ["--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--json"]
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={_toml_str(ctx.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={_toml_str(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 = ["--config", f"{prefix}.url={_toml_str(server.url)}"]
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={_toml_str(server.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}.args={_toml_array(list(server.args))}",
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}={_toml_str(env_value)}"]
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 index, arg in enumerate(args):
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"] = _parse_toml_str(raw_value)
292
+ entry["command"] = _parsetoml_str(raw_value)
279
293
  elif field == "args":
280
- entry["args"] = _parse_toml_array(raw_value)
294
+ entry["args"] = _parsetoml_array(raw_value)
281
295
  elif field == "url":
282
- entry["url"] = _parse_toml_str(raw_value)
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.")] = _parse_toml_str(raw_value)
303
+ entry.setdefault("env", {})[field.removeprefix("env.")] = _parsetoml_str(raw_value)
290
304
 
291
305
 
292
- def _parse_toml_str(literal: str) -> str:
293
- """Reverse ``_toml_str``: unescape one TOML basic string literal."""
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 _unescape_toml(match.group(1))
298
-
299
-
300
- def _parse_toml_array(literal: str) -> list[str]:
301
- """Reverse ``_toml_array``: unescape every string in a TOML inline array."""
302
- return [_unescape_toml(inner) for inner in _TOML_STRING_RE.findall(literal)]
303
-
304
-
305
- def _unescape_toml(escaped: str) -> str:
306
- """Undo the backslash-escaping ``_toml_str`` applies to a string body."""
307
- result: list[str] = []
308
- index = 0
309
- while index < len(escaped):
310
- char = escaped[index]
311
- if char == "\\" and index + 1 < len(escaped):
312
- result.append(escaped[index + 1])
313
- index += 2
314
- else:
315
- result.append(char)
316
- index += 1
317
- return "".join(result)
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.4"
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