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.
- {agentshim-0.6.6 → agentshim-0.6.7}/CHANGELOG.md +31 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/PKG-INFO +1 -1
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/__init__.py +1 -1
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/agent.py +1 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/provider.py +3 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/__init__.py +14 -1
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/provider.py +67 -3
- agentshim-0.6.7/agentshim/providers/codex/rules.py +125 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/sandbox.py +60 -1
- {agentshim-0.6.6 → agentshim-0.6.7}/pyproject.toml +1 -1
- {agentshim-0.6.6 → agentshim-0.6.7}/.gitignore +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/README.md +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/env.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/events.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/mcp.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/profile.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/schema.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/core/usage.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/parser.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/provider.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/scripted.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/codex/scripted.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/copilot/scripted.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/providers/opencode/scripted.py +0 -0
- {agentshim-0.6.6 → agentshim-0.6.7}/agentshim/py.typed +0 -0
- {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.
|
|
@@ -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
|
|
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:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|