agentshim 0.6.6__tar.gz → 0.6.7__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 (58) hide show
  1. {agentshim-0.6.6 → agentshim-0.6.7}/CHANGELOG.md +31 -0
  2. {agentshim-0.6.6 → agentshim-0.6.7}/PKG-INFO +1 -1
  3. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/__init__.py +1 -1
  4. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/agent.py +1 -0
  5. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/provider.py +3 -0
  6. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/__init__.py +14 -1
  7. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/provider.py +67 -3
  8. agentshim-0.6.7/agentshim/providers/codex/rules.py +125 -0
  9. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/sandbox.py +60 -1
  10. {agentshim-0.6.6 → agentshim-0.6.7}/pyproject.toml +1 -1
  11. {agentshim-0.6.6 → agentshim-0.6.7}/.gitignore +0 -0
  12. {agentshim-0.6.6 → agentshim-0.6.7}/README.md +0 -0
  13. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/__init__.py +0 -0
  14. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/_files.py +0 -0
  15. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/env.py +0 -0
  16. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/errors.py +0 -0
  17. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/events.py +0 -0
  18. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/mcp.py +0 -0
  19. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/profile.py +0 -0
  20. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/schema.py +0 -0
  21. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/stream.py +0 -0
  22. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/turn.py +0 -0
  23. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/usage.py +0 -0
  24. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/__init__.py +0 -0
  25. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/executor.py +0 -0
  26. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/host.py +0 -0
  27. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/transform.py +0 -0
  28. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/__init__.py +0 -0
  29. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/__init__.py +0 -0
  30. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/events.py +0 -0
  31. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/hooks/__init__.py +0 -0
  32. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  33. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/parser.py +0 -0
  34. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/provider.py +0 -0
  35. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/sandbox.py +0 -0
  36. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/scripted.py +0 -0
  37. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/user_hooks.py +0 -0
  38. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/_toml.py +0 -0
  39. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/events.py +0 -0
  40. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/parser.py +0 -0
  41. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/scripted.py +0 -0
  42. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/__init__.py +0 -0
  43. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/events.py +0 -0
  44. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/parser.py +0 -0
  45. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/provider.py +0 -0
  46. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/scripted.py +0 -0
  47. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/__init__.py +0 -0
  48. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/events.py +0 -0
  49. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/parser.py +0 -0
  50. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/provider.py +0 -0
  51. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/scripted.py +0 -0
  52. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/__init__.py +0 -0
  53. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/events.py +0 -0
  54. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/parser.py +0 -0
  55. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/provider.py +0 -0
  56. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/scripted.py +0 -0
  57. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/py.typed +0 -0
  58. {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/testing/__init__.py +0 -0
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.7 (2026-09-27)
4
+
5
+ Additive, with one tightening: a sandboxed Codex turn no longer applies the
6
+ user's exec-policy rules (see Fixed).
7
+
8
+ ### Added
9
+
10
+ - `CodexSandboxConfig(excluded_commands=[...])` lets named commands run
11
+ outside Codex's sandbox while everything else stays confined, the Codex
12
+ counterpart of Claude's `excludedCommands`. Each entry is shell words
13
+ matched as a command prefix. Codex supports this only as exec-policy
14
+ `prefix_rule(decision="allow")` rules read from `$CODEX_HOME/rules/`, so
15
+ `agentshim.providers.codex.install_rules(home, config)` writes them into a
16
+ dedicated Codex home and the turn runs with `CODEX_HOME` set to it. The
17
+ provider refuses a missing or relative `CODEX_HOME`, or one the sandbox lets
18
+ commands write. `render_rules` and `parse_rules` expose the rules file.
19
+ - `ArgvContext.cwd` (default `None`): the turn's cwd, for a provider to
20
+ validate paths against. It is never rendered into argv.
21
+
22
+ ### Fixed
23
+
24
+ - Sandboxed Codex turns pass `--ignore-rules`. An `allow` rule in the user's
25
+ `~/.codex/rules/` (which the TUI writes when a command is approved) used to
26
+ run its command outside a sandbox agentshim had asked for.
27
+
28
+ ### Documented
29
+
30
+ - Codex often omits commands its sandbox denied from `--json` output, and
31
+ exposes them nowhere else in structured form, so no `ToolCall` event is
32
+ emitted for them.
33
+
3
34
  ## 0.6.6 (2026-09-27)
4
35
 
5
36
  Additive: every new option defaults to earlier behaviour.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.6
3
+ Version: 0.6.7
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -84,7 +84,7 @@ 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.6"
87
+ __version__ = "0.6.7"
88
88
 
89
89
  __all__ = [
90
90
  "AgentEvent",
@@ -258,6 +258,7 @@ class AgentSession:
258
258
  schema_path=schema_path,
259
259
  mcp_argv=installation.argv,
260
260
  extra_args=req.extra_args,
261
+ cwd=cwd,
261
262
  )
262
263
  )
263
264
  command = CommandRequest(argv=argv, stdin=req.prompt, cwd=cwd, env=env, timeout=timeout)
@@ -34,6 +34,9 @@ class ArgvContext:
34
34
  schema_path: str | None
35
35
  mcp_argv: Sequence[str] = ()
36
36
  extra_args: Sequence[str] = ()
37
+ #: The directory the CLI will run in, or ``None`` for the executor's own.
38
+ #: Argv never carries it; a provider reads it to validate paths against it.
39
+ cwd: str | None = None
37
40
 
38
41
 
39
42
  @dataclass(frozen=True)
@@ -3,20 +3,33 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .parser import CodexStreamParser
6
- from .provider import BYPASS_FLAG, PROFILE, CodexProvider, parse_mcp_servers, parse_sandbox
6
+ from .provider import (
7
+ BYPASS_FLAG,
8
+ IGNORE_RULES_FLAG,
9
+ PROFILE,
10
+ CodexProvider,
11
+ parse_mcp_servers,
12
+ parse_sandbox,
13
+ )
14
+ from .rules import RULES_FILENAME, install_rules, parse_rules, render_rules
7
15
  from .sandbox import SANDBOX_MODES, CodexSandboxConfig, SandboxMode
8
16
  from .scripted import resume_failure_lines, scripted_lines
9
17
 
10
18
  __all__ = [
11
19
  "BYPASS_FLAG",
20
+ "IGNORE_RULES_FLAG",
12
21
  "PROFILE",
22
+ "RULES_FILENAME",
13
23
  "SANDBOX_MODES",
14
24
  "CodexProvider",
15
25
  "CodexSandboxConfig",
16
26
  "CodexStreamParser",
17
27
  "SandboxMode",
28
+ "install_rules",
18
29
  "parse_mcp_servers",
30
+ "parse_rules",
19
31
  "parse_sandbox",
32
+ "render_rules",
20
33
  "resume_failure_lines",
21
34
  "scripted_lines",
22
35
  ]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import re
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
@@ -16,6 +17,7 @@ from agentshim.core.profile import (
16
17
 
17
18
  from ._toml import toml_array, toml_str, unescape_toml
18
19
  from .parser import CodexStreamParser
20
+ from .rules import RULES_FILENAME
19
21
  from .sandbox import CodexSandboxConfig, resolve_sandbox, sandbox_overrides
20
22
 
21
23
  if TYPE_CHECKING:
@@ -90,6 +92,9 @@ PROFILE = ProviderProfile(
90
92
  #: Turns off both Codex's sandbox and its approval prompts.
91
93
  BYPASS_FLAG = "--dangerously-bypass-approvals-and-sandbox"
92
94
 
95
+ #: Keeps user and project ``.rules`` files out of a sandboxed turn.
96
+ IGNORE_RULES_FLAG = "--ignore-rules"
97
+
93
98
 
94
99
  class CodexProvider:
95
100
  """Codex (``codex exec --json``). ``sandbox`` is a provider option."""
@@ -116,7 +121,7 @@ class CodexProvider:
116
121
  argv = [ctx.binary_path, "exec"]
117
122
  if ctx.resume_session_id:
118
123
  argv += ["resume", ctx.resume_session_id, "-"]
119
- argv += self._sandbox_argv()
124
+ argv += self._sandbox_argv(ctx)
120
125
  argv += ["--skip-git-repo-check", "--json"]
121
126
  if ctx.model:
122
127
  argv += ["--model", ctx.model]
@@ -129,12 +134,24 @@ class CodexProvider:
129
134
  argv += list(ctx.extra_args)
130
135
  return argv
131
136
 
132
- def _sandbox_argv(self) -> list[str]:
137
+ def _sandbox_argv(self, ctx: ArgvContext) -> list[str]:
138
+ """Render the sandbox, and decide which exec-policy rules may apply.
139
+
140
+ Codex runs a command that an ``allow`` rule matches outside its
141
+ sandbox, and loads rules from ``$CODEX_HOME/rules`` and trusted
142
+ projects. Without exemptions ``--ignore-rules`` keeps every such file
143
+ out, the user's own included. With them the rules have to load, so
144
+ the home they come from is checked instead.
145
+ """
133
146
  if self.sandbox is None:
134
147
  return [BYPASS_FLAG]
135
148
  argv: list[str] = []
136
149
  for key, value in sandbox_overrides(self.sandbox):
137
150
  argv += ["--config", f"{key}={value}"]
151
+ if self.sandbox.excluded_commands:
152
+ _check_rules_home(self.sandbox, ctx)
153
+ else:
154
+ argv.append(IGNORE_RULES_FLAG)
138
155
  return argv
139
156
 
140
157
  def new_parser(
@@ -178,6 +195,50 @@ class CodexProvider:
178
195
  )
179
196
 
180
197
 
198
+ def _check_rules_home(config: CodexSandboxConfig, ctx: ArgvContext) -> None:
199
+ """Refuse a ``CODEX_HOME`` that cannot hold the exemptions safely.
200
+
201
+ The rules are read from ``$CODEX_HOME/rules`` on every turn, so a home the
202
+ sandbox lets commands write would let the model install a rule exempting
203
+ anything, and have it apply from the next turn on.
204
+ """
205
+ home = ctx.env.get("CODEX_HOME")
206
+ if not home or not os.path.isabs(home): # noqa: PTH117 - a str contract, not a Path
207
+ msg = (
208
+ "excluded_commands are read from $CODEX_HOME/rules/"
209
+ f"{RULES_FILENAME}: run the turn with CODEX_HOME set to the absolute "
210
+ f"path of a dedicated home prepared with install_rules (got {home!r})"
211
+ )
212
+ raise ProviderCapabilityError(msg)
213
+ for directory in _writable_dirs(config, ctx):
214
+ if _is_within(home, directory) or _is_within(directory, home):
215
+ msg = (
216
+ f"CODEX_HOME {home} overlaps {directory}, which the sandbox lets "
217
+ "commands write, so a command could add rules exempting itself"
218
+ )
219
+ raise ProviderCapabilityError(msg)
220
+
221
+
222
+ def _writable_dirs(config: CodexSandboxConfig, ctx: ArgvContext) -> list[str]:
223
+ """The directories *config* lets sandboxed commands write, where known."""
224
+ if config.mode != "workspace-write":
225
+ return []
226
+ dirs = list(config.writable_roots)
227
+ if ctx.cwd:
228
+ dirs.append(ctx.cwd)
229
+ if config.writable_tmp:
230
+ dirs.append("/tmp") # noqa: S108 - Codex's own writable /tmp
231
+ tmpdir = ctx.env.get("TMPDIR")
232
+ if tmpdir:
233
+ dirs.append(tmpdir)
234
+ return dirs
235
+
236
+
237
+ def _is_within(path: str, directory: str) -> bool:
238
+ resolved, parent = os.path.realpath(path), os.path.realpath(directory)
239
+ return resolved == parent or resolved.startswith(parent.rstrip(os.sep) + os.sep)
240
+
241
+
181
242
  def _is_missing_rollout(stderr: str) -> bool:
182
243
  return "thread/resume failed" in stderr and "no rollout found" in stderr
183
244
 
@@ -335,7 +396,10 @@ def parse_sandbox(argv: Sequence[str]) -> CodexSandboxConfig | None:
335
396
 
336
397
  Returns:
337
398
  ``None`` when the turn bypassed Codex's sandbox, else the config it
338
- imposed.
399
+ imposed. ``excluded_commands`` is always empty: exemptions live in the
400
+ rules file under ``CODEX_HOME``, not in argv (read them back with
401
+ ``parse_rules``). The absence of ``--ignore-rules`` is the argv's only
402
+ sign that the turn loaded rules.
339
403
 
340
404
  Raises:
341
405
  ValueError: *argv* neither bypasses the sandbox nor selects a mode.
@@ -0,0 +1,125 @@
1
+ """Codex exec-policy rules that let named commands run outside the sandbox.
2
+
3
+ Codex decides per command whether to sandbox it by evaluating exec-policy
4
+ rules, Starlark files named ``*.rules``. A command whose words start with a
5
+ ``prefix_rule(..., decision="allow")`` pattern skips the sandbox; every other
6
+ command stays confined. That is the Codex counterpart of Claude Code's
7
+ ``excludedCommands``, and the only per-command exemption Codex has: network
8
+ and filesystem settings apply to the whole session.
9
+
10
+ Codex reads rules only from files: ``$CODEX_HOME/rules/`` and the
11
+ ``.codex/rules/`` of trusted projects. There is no ``--config`` key for them,
12
+ so ``CodexSandboxConfig.excluded_commands`` cannot travel in argv like the
13
+ rest of the sandbox. ``install_rules`` writes them into a Codex home the
14
+ caller dedicates to the sandboxed agent, and the turn points ``CODEX_HOME``
15
+ at it. A sandboxed turn without exemptions passes ``--ignore-rules`` instead,
16
+ so no rules file, the user's included, can widen it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import shlex
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING:
29
+ from .sandbox import CodexSandboxConfig
30
+
31
+ #: The file ``install_rules`` owns inside ``$CODEX_HOME/rules/``.
32
+ RULES_FILENAME = "agentshim.rules"
33
+
34
+ _HEADER = (
35
+ "# Written by agentshim from CodexSandboxConfig.excluded_commands.\n"
36
+ "# Each rule lets commands starting with these words run outside Codex's sandbox.\n"
37
+ )
38
+ _RULE_RE = re.compile(r'^prefix_rule\(pattern=\[(.*)\], decision="allow"\)$')
39
+ _STRING_RE = re.compile(r'"((?:[^"\\]|\\["\\])*)"')
40
+
41
+
42
+ def command_words(command: str) -> list[str]:
43
+ """Split one ``excluded_commands`` entry into the words Codex matches."""
44
+ return shlex.split(command)
45
+
46
+
47
+ def render_rules(config: CodexSandboxConfig) -> str:
48
+ """Render *config*'s ``excluded_commands`` as a Codex ``.rules`` file."""
49
+ lines = [_HEADER]
50
+ for command in config.excluded_commands:
51
+ words = ", ".join(_starlark_str(word) for word in command_words(command))
52
+ lines.append(f'prefix_rule(pattern=[{words}], decision="allow")\n')
53
+ return "".join(lines)
54
+
55
+
56
+ def parse_rules(text: str) -> list[list[str]]:
57
+ """Recover the word prefixes ``render_rules`` wrote, in order.
58
+
59
+ Raises:
60
+ ValueError: *text* holds a line ``render_rules`` never writes.
61
+ """
62
+ prefixes: list[list[str]] = []
63
+ for line in text.splitlines():
64
+ if not line or line.startswith("#"):
65
+ continue
66
+ match = _RULE_RE.match(line)
67
+ if match is None:
68
+ msg = f"not a rule agentshim writes: {line!r}"
69
+ raise ValueError(msg)
70
+ prefixes.append([_unescape(body) for body in _STRING_RE.findall(match.group(1))])
71
+ return prefixes
72
+
73
+
74
+ def install_rules(codex_home: str | os.PathLike[str], config: CodexSandboxConfig) -> Path:
75
+ """Write *config*'s exemptions to ``<codex_home>/rules/agentshim.rules``.
76
+
77
+ Run the turn with ``CODEX_HOME`` set to *codex_home*. The home should be
78
+ dedicated to the sandboxed agent: every Codex process that uses it, and
79
+ does not pass ``--ignore-rules``, applies these rules. It must also lie
80
+ outside every directory the sandbox lets commands write, or a command
81
+ could add a rule of its own; ``CodexProvider`` refuses such a home.
82
+
83
+ The write is atomic, so a turn starting concurrently reads either the old
84
+ rules or the new ones.
85
+
86
+ Returns:
87
+ The path of the rules file.
88
+
89
+ Raises:
90
+ ValueError: ``rules/`` already holds another ``*.rules`` file. Codex
91
+ would load it too, so it could exempt commands the config does not.
92
+ """
93
+ rules_dir = Path(codex_home) / "rules"
94
+ rules_dir.mkdir(parents=True, exist_ok=True)
95
+ others = sorted(p.name for p in rules_dir.glob("*.rules") if p.name != RULES_FILENAME)
96
+ if others:
97
+ msg = (
98
+ f"{rules_dir} also holds {', '.join(others)}; Codex would load them "
99
+ "alongside the exemptions, so use a dedicated CODEX_HOME"
100
+ )
101
+ raise ValueError(msg)
102
+ target = rules_dir / RULES_FILENAME
103
+ fd, tmp = tempfile.mkstemp(dir=rules_dir, prefix=".agentshim-", suffix=".tmp")
104
+ try:
105
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
106
+ handle.write(render_rules(config))
107
+ Path(tmp).replace(target)
108
+ except BaseException:
109
+ Path(tmp).unlink(missing_ok=True)
110
+ raise
111
+ return target
112
+
113
+
114
+ def _starlark_str(word: str) -> str:
115
+ """Quote *word* as a Starlark string literal.
116
+
117
+ ``CodexSandboxConfig`` rejects control characters, so only the quote and
118
+ the backslash need escaping; everything else is written literally, which
119
+ Starlark accepts for any code point outside the surrogate range.
120
+ """
121
+ return '"' + word.replace("\\", "\\\\").replace('"', '\\"') + '"'
122
+
123
+
124
+ def _unescape(body: str) -> str:
125
+ return re.sub(r'\\(["\\])', r"\1", body)
@@ -10,7 +10,11 @@ Every setting is rendered as a ``--config`` override rather than ``--sandbox``,
10
10
  because ``codex exec resume`` accepts ``--config`` but not ``--sandbox``: one
11
11
  rendering serves fresh and resumed turns alike. For ``workspace-write`` every
12
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.
13
+ ``~/.codex/config.toml`` cannot widen the sandbox a caller asked for, and
14
+ ``--ignore-rules`` keeps its exec-policy ``.rules`` files from exempting
15
+ commands. ``excluded_commands`` is the one exception: Codex reads exemptions
16
+ only from rules files, so they go in a dedicated ``CODEX_HOME`` (see
17
+ ``rules.py``).
14
18
 
15
19
  See https://developers.openai.com/codex/security.
16
20
  """
@@ -18,6 +22,7 @@ See https://developers.openai.com/codex/security.
18
22
  from __future__ import annotations
19
23
 
20
24
  import os
25
+ import shlex
21
26
  from collections.abc import Sequence
22
27
  from dataclasses import dataclass, field
23
28
  from typing import Literal, cast, get_args
@@ -30,12 +35,18 @@ SandboxMode = Literal["read-only", "workspace-write", "danger-full-access"]
30
35
  SANDBOX_MODES: tuple[SandboxMode, ...] = get_args(SandboxMode)
31
36
 
32
37
  _WORKSPACE_WRITE: SandboxMode = "workspace-write"
38
+ _FIRST_PRINTABLE = 0x20
39
+ _DEL = 0x7F
33
40
 
34
41
 
35
42
  def _roots() -> tuple[str, ...]:
36
43
  return ()
37
44
 
38
45
 
46
+ def _commands() -> tuple[str, ...]:
47
+ return ()
48
+
49
+
39
50
  @dataclass(frozen=True)
40
51
  class CodexSandboxConfig:
41
52
  """How Codex sandboxes the commands the model runs.
@@ -53,12 +64,25 @@ class CodexSandboxConfig:
53
64
  ones such as the Docker socket. ``workspace-write`` only.
54
65
  writable_tmp: Keep ``/tmp`` and ``$TMPDIR`` writable, which is Codex's
55
66
  own default. ``workspace-write`` only.
67
+ excluded_commands: Commands that run outside the sandbox, each written
68
+ as shell words, e.g. ``"sdo detector check"``. An exempt command
69
+ runs with no confinement at all: it can write anywhere and reach
70
+ any socket the user can, so exempt only commands you trust with
71
+ whatever arguments the model adds. A command the model
72
+ runs is exempt when its words start with one of these, so
73
+ ``sdo detector check --all`` is exempt and ``sdo detector`` is not.
74
+ Anything else on the same command line (``&&``, ``|``, ``;``,
75
+ ``$(...)``) keeps the whole line sandboxed. ``read-only`` and
76
+ ``workspace-write`` only. Rendered as Codex exec-policy rules,
77
+ which must be installed in a dedicated ``CODEX_HOME`` with
78
+ ``install_rules``; see ``agentshim.providers.codex.rules``.
56
79
  """
57
80
 
58
81
  mode: SandboxMode = _WORKSPACE_WRITE
59
82
  writable_roots: Sequence[str] = field(default_factory=_roots)
60
83
  network_access: bool = False
61
84
  writable_tmp: bool = True
85
+ excluded_commands: Sequence[str] = field(default_factory=_commands)
62
86
 
63
87
  def __post_init__(self) -> None:
64
88
  """Reject a config Codex would reject or silently ignore."""
@@ -77,6 +101,10 @@ class CodexSandboxConfig:
77
101
  f"workspace-write; {self.mode} would ignore them"
78
102
  )
79
103
  raise ValueError(msg)
104
+ object.__setattr__(self, "excluded_commands", _as_commands(self.excluded_commands))
105
+ if self.mode == "danger-full-access" and self.excluded_commands:
106
+ msg = "excluded_commands needs a sandbox to exempt from; danger-full-access has none"
107
+ raise ValueError(msg)
80
108
 
81
109
  def _widens_workspace_write(self) -> bool:
82
110
  return bool(self.writable_roots) or self.network_access or not self.writable_tmp
@@ -113,6 +141,37 @@ def _check_root(root: object) -> None:
113
141
  raise ValueError(msg)
114
142
 
115
143
 
144
+ def _as_commands(value: object) -> tuple[str, ...]:
145
+ """Validate ``excluded_commands`` and freeze it into a tuple."""
146
+ if isinstance(value, str) or not isinstance(value, Sequence):
147
+ msg = f"excluded_commands must be a sequence of commands, not {type(value).__name__}"
148
+ raise TypeError(msg)
149
+ commands = tuple(cast("Sequence[object]", value))
150
+ for command in commands:
151
+ _check_command(command)
152
+ return cast("tuple[str, ...]", commands)
153
+
154
+
155
+ def _check_command(command: object) -> None:
156
+ if not isinstance(command, str):
157
+ msg = f"excluded_commands entries must be str, got {type(command).__name__}"
158
+ raise TypeError(msg)
159
+ if any(ord(char) < _FIRST_PRINTABLE or ord(char) == _DEL for char in command):
160
+ msg = f"excluded_commands entry contains a control character: {command!r}"
161
+ raise ValueError(msg)
162
+ if not _is_utf8(command):
163
+ msg = f"excluded_commands entry is not valid UTF-8 text: {command!r}"
164
+ raise ValueError(msg)
165
+ try:
166
+ words = shlex.split(command)
167
+ except ValueError as exc:
168
+ msg = f"excluded_commands entry is not valid shell words ({exc}): {command!r}"
169
+ raise ValueError(msg) from exc
170
+ if not words or not all(words):
171
+ msg = f"excluded_commands entry must be non-empty words: {command!r}"
172
+ raise ValueError(msg)
173
+
174
+
116
175
  def _is_utf8(text: str) -> bool:
117
176
  """TOML and argv both need text that encodes; a lone surrogate does not."""
118
177
  try:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.6.6"
3
+ version = "0.6.7"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes
File without changes