agentshim 0.6.6__tar.gz → 0.6.8__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 (59) hide show
  1. {agentshim-0.6.6 → agentshim-0.6.8}/CHANGELOG.md +54 -0
  2. {agentshim-0.6.6 → agentshim-0.6.8}/PKG-INFO +1 -1
  3. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/__init__.py +1 -1
  4. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/agent.py +1 -0
  5. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/profile.py +6 -3
  6. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/provider.py +3 -0
  7. agentshim-0.6.8/agentshim/core/schema.py +352 -0
  8. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/__init__.py +14 -1
  9. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/provider.py +67 -3
  10. agentshim-0.6.8/agentshim/providers/codex/rules.py +125 -0
  11. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/sandbox.py +60 -1
  12. {agentshim-0.6.6 → agentshim-0.6.8}/pyproject.toml +1 -1
  13. agentshim-0.6.6/agentshim/core/schema.py +0 -281
  14. {agentshim-0.6.6 → agentshim-0.6.8}/.gitignore +0 -0
  15. {agentshim-0.6.6 → agentshim-0.6.8}/README.md +0 -0
  16. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/__init__.py +0 -0
  17. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/_files.py +0 -0
  18. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/env.py +0 -0
  19. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/errors.py +0 -0
  20. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/events.py +0 -0
  21. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/mcp.py +0 -0
  22. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/stream.py +0 -0
  23. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/turn.py +0 -0
  24. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/core/usage.py +0 -0
  25. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/execution/__init__.py +0 -0
  26. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/execution/executor.py +0 -0
  27. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/execution/host.py +0 -0
  28. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/execution/transform.py +0 -0
  29. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/__init__.py +0 -0
  30. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/__init__.py +0 -0
  31. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/events.py +0 -0
  32. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/hooks/__init__.py +0 -0
  33. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  34. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/parser.py +0 -0
  35. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/provider.py +0 -0
  36. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/sandbox.py +0 -0
  37. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/scripted.py +0 -0
  38. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/claude/user_hooks.py +0 -0
  39. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/_toml.py +0 -0
  40. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/events.py +0 -0
  41. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/parser.py +0 -0
  42. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/codex/scripted.py +0 -0
  43. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/copilot/__init__.py +0 -0
  44. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/copilot/events.py +0 -0
  45. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/copilot/parser.py +0 -0
  46. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/copilot/provider.py +0 -0
  47. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/copilot/scripted.py +0 -0
  48. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/gemini/__init__.py +0 -0
  49. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/gemini/events.py +0 -0
  50. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/gemini/parser.py +0 -0
  51. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/gemini/provider.py +0 -0
  52. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/gemini/scripted.py +0 -0
  53. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/opencode/__init__.py +0 -0
  54. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/opencode/events.py +0 -0
  55. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/opencode/parser.py +0 -0
  56. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/opencode/provider.py +0 -0
  57. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/providers/opencode/scripted.py +0 -0
  58. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/py.typed +0 -0
  59. {agentshim-0.6.6 → agentshim-0.6.8}/agentshim/testing/__init__.py +0 -0
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.8 (2026-09-27)
4
+
5
+ A tightening of the Codex (`STRICT`) schema check: schemas it now rejects
6
+ were already rejected by the API, after a full model turn.
7
+
8
+ ### Fixed
9
+
10
+ - A Codex output schema is checked against OpenAI's strict structured-output
11
+ rules before the CLI starts. `turn()` raises `SchemaDialectError`, naming
12
+ each offending node by JSON pointer, when an object leaves a property out
13
+ of `required` (declare it required and nullable instead), lists a
14
+ `required` name missing from `properties`, omits `properties` (`{}` is
15
+ fine) or `additionalProperties: false` (including on a nullable
16
+ `["object", "null"]` object), uses `anyOf` at
17
+ the root, or has a `$ref` that resolves to nothing. Codex used to fail such
18
+ a turn with `invalid_json_schema` only after the model ran. The Claude
19
+ (`OPEN`) dialect is unchanged.
20
+ - A `$ref` of `"#"` (root recursion) is accepted as local in both dialects.
21
+ - Problem pointers escape `/` and `~` in property names (RFC 6901), and
22
+ `enum`, `const` and `required` values are no longer inspected as schemas.
23
+ - `normalize` closes a nullable object node too, and gives an object with
24
+ no `properties` an empty one, so its output passes the `STRICT` check.
25
+
26
+ ## 0.6.7 (2026-09-27)
27
+
28
+ Additive, with one tightening: a sandboxed Codex turn no longer applies the
29
+ user's exec-policy rules (see Fixed).
30
+
31
+ ### Added
32
+
33
+ - `CodexSandboxConfig(excluded_commands=[...])` lets named commands run
34
+ outside Codex's sandbox while everything else stays confined, the Codex
35
+ counterpart of Claude's `excludedCommands`. Each entry is shell words
36
+ matched as a command prefix. Codex supports this only as exec-policy
37
+ `prefix_rule(decision="allow")` rules read from `$CODEX_HOME/rules/`, so
38
+ `agentshim.providers.codex.install_rules(home, config)` writes them into a
39
+ dedicated Codex home and the turn runs with `CODEX_HOME` set to it. The
40
+ provider refuses a missing or relative `CODEX_HOME`, or one the sandbox lets
41
+ commands write. `render_rules` and `parse_rules` expose the rules file.
42
+ - `ArgvContext.cwd` (default `None`): the turn's cwd, for a provider to
43
+ validate paths against. It is never rendered into argv.
44
+
45
+ ### Fixed
46
+
47
+ - Sandboxed Codex turns pass `--ignore-rules`. An `allow` rule in the user's
48
+ `~/.codex/rules/` (which the TUI writes when a command is approved) used to
49
+ run its command outside a sandbox agentshim had asked for.
50
+
51
+ ### Documented
52
+
53
+ - Codex often omits commands its sandbox denied from `--json` output, and
54
+ exposes them nowhere else in structured form, so no `ToolCall` event is
55
+ emitted for them.
56
+
3
57
  ## 0.6.6 (2026-09-27)
4
58
 
5
59
  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.8
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.8"
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,9 +34,12 @@ class OutputSchemaStyle(Enum):
34
34
  class SchemaDialect(Enum):
35
35
  """Which JSON Schema subset a provider accepts.
36
36
 
37
- ``STRICT`` is Codex's ``--output-schema`` subset: every object declares
38
- its properties and forbids undeclared keys. ``OPEN`` also accepts a
39
- schema-valued or ``true`` ``additionalProperties``.
37
+ ``STRICT`` is Codex's ``--output-schema`` subset, which is OpenAI's strict
38
+ structured outputs: every object declares ``properties``, sets
39
+ ``additionalProperties: false`` and lists every property in ``required`` (optional values are made
40
+ nullable instead), the root is not an ``anyOf``, and every ``$ref``
41
+ resolves. ``OPEN`` also accepts optional properties and a schema-valued,
42
+ ``true`` or absent ``additionalProperties``.
40
43
  """
41
44
 
42
45
  STRICT = "strict"
@@ -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)
@@ -0,0 +1,352 @@
1
+ """Output-schema dialect checks and materialization.
2
+
3
+ Providers disagree about which JSON Schema subset their structured-output
4
+ flag accepts, and the failure mode is an expensive agent turn that ends in a
5
+ CLI parse error. These checks run before the process starts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any, cast
15
+
16
+ from ._files import atomic_write
17
+ from .errors import ProviderCapabilityError
18
+ from .profile import SchemaDialect
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+ from pathlib import Path
23
+
24
+ # Keywords that need schema-evaluation features outside the subset the
25
+ # provider CLIs implement. Field names are never inspected as keywords:
26
+ # ``properties``, ``$defs`` and ``definitions`` are traversed as maps of
27
+ # subschemas, so a property literally named ``if`` is fine.
28
+ UNSUPPORTED_KEYWORDS = frozenset(
29
+ {
30
+ "$anchor",
31
+ "$dynamicAnchor",
32
+ "$dynamicRef",
33
+ "$id",
34
+ "$schema",
35
+ "allOf",
36
+ "contains",
37
+ "dependentRequired",
38
+ "dependentSchemas",
39
+ "else",
40
+ "if",
41
+ "maxContains",
42
+ "minContains",
43
+ "not",
44
+ "oneOf",
45
+ "patternProperties",
46
+ "prefixItems",
47
+ "propertyNames",
48
+ "then",
49
+ "unevaluatedItems",
50
+ "unevaluatedProperties",
51
+ }
52
+ )
53
+
54
+ _SUBSCHEMA_MAPS = frozenset({"properties", "$defs", "definitions"})
55
+
56
+ # Annotations that describe a schema rather than constrain a value. They are
57
+ # what a generator such as Pydantic emits alongside the real constraints, and
58
+ # what the strictest CLI subset refuses to read, so ``normalize`` removes
59
+ # them. Field names are never matched against this set: ``properties`` is
60
+ # traversed as a map of subschemas, so a property named ``title`` survives.
61
+ _METADATA_KEYWORDS = frozenset({"$schema", "$id", "title", "description", "examples"})
62
+
63
+ # ``$schema`` and ``$id`` identify the dialect and the document; a CLI that
64
+ # accepts open-ended schemas ignores them rather than failing on them. Codex's
65
+ # ``--output-schema`` subset does not, so ``STRICT`` keeps reporting them.
66
+ _OPEN_DIALECT_TOLERATES = frozenset({"$schema", "$id"})
67
+
68
+ # Keywords whose values are data, never subschemas. ``required`` is a list of
69
+ # field names and ``enum``/``const`` hold instance values, so a name such as
70
+ # ``"$ref"`` inside them must not be read as a keyword.
71
+ _NON_SCHEMA_KEYWORDS = frozenset({"required", "enum", "const"})
72
+
73
+
74
+ def dialect_problems(schema: Mapping[str, Any], dialect: SchemaDialect) -> list[str]:
75
+ """Return every reason *schema* is not expressible in *dialect*.
76
+
77
+ An empty list means the provider will accept it. The list is returned
78
+ rather than raised so a caller can decide between failing and falling
79
+ back to a prompt-level schema instruction. Each problem starts with the
80
+ JSON pointer of the offending node.
81
+
82
+ ``STRICT`` is OpenAI's strict structured-output subset, which Codex sends
83
+ with ``strict: true``: every object declares ``properties``, sets
84
+ ``additionalProperties: false`` and lists exactly its ``properties`` in
85
+ ``required`` (an optional value is
86
+ expressed as nullable instead), the root is not an ``anyOf``, and every
87
+ ``$ref`` resolves inside the document.
88
+ """
89
+ root = dict(schema)
90
+ walk = _Walk(root=root, dialect=dialect, problems=[])
91
+ if schema.get("type") != "object":
92
+ walk.problems.append("# root must be a schema with type 'object'")
93
+ if dialect is SchemaDialect.STRICT and "anyOf" in schema:
94
+ walk.problems.append("# root must not use 'anyOf'")
95
+ walk.visit(root, "#")
96
+ return walk.problems
97
+
98
+
99
+ def _pointer_token(name: str) -> str:
100
+ """Escape a field name as one JSON pointer reference token (RFC 6901)."""
101
+ return name.replace("~", "~0").replace("/", "~1")
102
+
103
+
104
+ def _is_object_node(mapping: dict[str, Any]) -> bool:
105
+ """Say whether a node describes an object, nullable or not."""
106
+ if mapping.get("properties") is not None:
107
+ return True
108
+ declared = mapping.get("type")
109
+ if isinstance(declared, list):
110
+ return "object" in cast("list[object]", declared)
111
+ return declared == "object"
112
+
113
+
114
+ @dataclass
115
+ class _Walk:
116
+ """One pass of ``dialect_problems`` over a schema document.
117
+
118
+ Traversal is by JSON pointer so each problem names the offending node.
119
+ """
120
+
121
+ root: dict[str, Any]
122
+ dialect: SchemaDialect
123
+ problems: list[str]
124
+
125
+ def visit(self, node: object, location: str) -> None:
126
+ """Walk one schema node, appending a problem for every unsupported construct.
127
+
128
+ A ``$ref`` node terminates the walk: its siblings are annotations the
129
+ strict subset ignores, and the target is checked where it is defined,
130
+ since every local target is inside the document this walk covers.
131
+ """
132
+ if isinstance(node, list):
133
+ for index, value in enumerate(cast("list[object]", node)):
134
+ self.visit(value, f"{location}/{index}")
135
+ return
136
+ if not isinstance(node, dict):
137
+ return
138
+ mapping = cast("dict[str, Any]", node)
139
+
140
+ reference = mapping.get("$ref")
141
+ if reference is not None:
142
+ self._check_ref(reference, location)
143
+ return
144
+
145
+ if not self._check_properties_shape(mapping, location):
146
+ return
147
+ if self.dialect is SchemaDialect.STRICT and _is_object_node(mapping):
148
+ self._check_closed_object(mapping, location)
149
+ self._check_all_required(mapping, location)
150
+ self._visit_members(mapping, location)
151
+
152
+ def _check_ref(self, reference: object, location: str) -> None:
153
+ """Reject a ``$ref`` the CLI would have to fetch, or one that dangles."""
154
+ if not isinstance(reference, str) or not (reference == "#" or reference.startswith("#/")):
155
+ self.problems.append(f"{location} uses a non-local $ref")
156
+ return
157
+ if self.dialect is SchemaDialect.STRICT and not _resolves(self.root, reference):
158
+ self.problems.append(f"{location} has a $ref {reference!r} that resolves to nothing")
159
+
160
+ def _check_properties_shape(self, mapping: dict[str, Any], location: str) -> bool:
161
+ """Check that ``properties``, if present, is an object.
162
+
163
+ Returns ``False`` when it is not, in which case the caller stops:
164
+ nothing below a malformed ``properties`` can be read as a schema, and
165
+ descending would only report the same defect once per child.
166
+ """
167
+ properties = mapping.get("properties")
168
+ if properties is not None and not isinstance(properties, dict):
169
+ self.problems.append(f"{location}/properties must be an object")
170
+ return False
171
+ return True
172
+
173
+ def _check_closed_object(self, mapping: dict[str, Any], location: str) -> None:
174
+ """Require an object node to declare ``properties`` and close itself.
175
+
176
+ Strict mode needs ``additionalProperties`` present, not merely not
177
+ ``true``: an absent one is an open object in JSON Schema. It also
178
+ refuses an object with no ``properties`` at all, even an empty one.
179
+ """
180
+ if "properties" not in mapping:
181
+ self.problems.append(f"{location} must declare properties (use {{}} for none)")
182
+ if "additionalProperties" not in mapping:
183
+ self.problems.append(f"{location} must set additionalProperties: false")
184
+ elif mapping["additionalProperties"] is not False:
185
+ self.problems.append(f"{location} allows arbitrary object keys")
186
+
187
+ def _check_all_required(self, mapping: dict[str, Any], location: str) -> None:
188
+ """Require ``required`` to list exactly the declared properties.
189
+
190
+ Strict mode has no optional properties: a value that may be absent is
191
+ declared required and nullable, e.g. ``{"type": ["string", "null"]}``.
192
+ """
193
+ properties = cast("dict[str, Any]", mapping.get("properties") or {})
194
+ required = mapping.get("required", [])
195
+ if not isinstance(required, list) or not all(
196
+ isinstance(name, str) for name in cast("list[object]", required)
197
+ ):
198
+ self.problems.append(f"{location}/required must be an array of property names")
199
+ return
200
+ listed = set(cast("list[str]", required))
201
+ for name in properties:
202
+ if name not in listed:
203
+ self.problems.append(
204
+ f"{location}/properties/{_pointer_token(name)} is optional; strict mode "
205
+ "requires every property in 'required' (make it nullable instead)"
206
+ )
207
+ for name in cast("list[str]", required):
208
+ if name not in properties:
209
+ self.problems.append(
210
+ f"{location}/required names {name!r}, which is not in 'properties'"
211
+ )
212
+
213
+ def _visit_members(self, mapping: dict[str, Any], location: str) -> None:
214
+ """Check every entry of a schema node, keyword or subschema.
215
+
216
+ Only keys reached here are matched against ``UNSUPPORTED_KEYWORDS``,
217
+ which is what keeps a property named ``if`` from being read as the
218
+ ``if`` keyword.
219
+ """
220
+ for key, value in mapping.items():
221
+ if key in _SUBSCHEMA_MAPS:
222
+ self._visit_subschema_map(value, f"{location}/{key}")
223
+ elif key in UNSUPPORTED_KEYWORDS:
224
+ if self.dialect is not SchemaDialect.STRICT and key in _OPEN_DIALECT_TOLERATES:
225
+ continue
226
+ self.problems.append(f"{location} uses unsupported keyword {key!r}")
227
+ elif key in _METADATA_KEYWORDS or key in _NON_SCHEMA_KEYWORDS:
228
+ # Annotations or plain values: nothing below them is a schema.
229
+ continue
230
+ else:
231
+ self.visit(value, f"{location}/{key}")
232
+
233
+ def _visit_subschema_map(self, value: object, location: str) -> None:
234
+ """Walk a map of named subschemas such as ``properties`` or ``$defs``.
235
+
236
+ The names are user-chosen field names, so they are traversed as data
237
+ and never inspected as schema keywords.
238
+ """
239
+ if not isinstance(value, dict):
240
+ self.problems.append(f"{location} must be an object")
241
+ return
242
+ for name, subschema in cast("dict[str, Any]", value).items():
243
+ self.visit(subschema, f"{location}/{_pointer_token(name)}")
244
+
245
+
246
+ def _resolves(root: dict[str, Any], reference: str) -> bool:
247
+ """Say whether a local ``$ref`` names a schema inside *root*."""
248
+ node: object = root
249
+ for raw in reference[2:].split("/") if reference != "#" else []:
250
+ token = raw.replace("~1", "/").replace("~0", "~")
251
+ if isinstance(node, dict):
252
+ mapping = cast("dict[str, object]", node)
253
+ if token not in mapping:
254
+ return False
255
+ node = mapping[token]
256
+ elif isinstance(node, list):
257
+ items = cast("list[object]", node)
258
+ if not token.isdigit() or int(token) >= len(items):
259
+ return False
260
+ node = items[int(token)]
261
+ else:
262
+ return False
263
+ return isinstance(node, dict)
264
+
265
+
266
+ def normalize(schema: Mapping[str, Any], dialect: SchemaDialect) -> dict[str, Any]:
267
+ """Return a copy of *schema* rewritten into the provider's house style.
268
+
269
+ Generators such as Pydantic omit defaulted properties from ``required``
270
+ and leave ``additionalProperties`` unset, which the CLIs read as "any
271
+ subset of these keys, plus anything else". This closes every object,
272
+ requires every declared property, drops ``default``, strips the annotation
273
+ siblings of a ``$ref`` that the strict subset forbids, and removes the
274
+ document metadata (``$schema``, ``$id``, ``title``, ``description``,
275
+ ``examples``) that ``dialect_problems`` reports under ``STRICT``. What
276
+ comes back is therefore a schema the strict subset accepts. Callers that
277
+ want the schema passed through untouched skip this.
278
+ """
279
+ return cast("dict[str, Any]", _normalized(copy.deepcopy(dict(schema)), dialect))
280
+
281
+
282
+ def _normalized(node: object, dialect: SchemaDialect) -> object:
283
+ if isinstance(node, list):
284
+ return [_normalized(value, dialect) for value in cast("list[object]", node)]
285
+ if not isinstance(node, dict):
286
+ return node
287
+ mapping = cast("dict[str, Any]", node)
288
+
289
+ reference = mapping.get("$ref")
290
+ if isinstance(reference, str):
291
+ return {"$ref": reference}
292
+
293
+ mapping.pop("default", None)
294
+ for annotation in _METADATA_KEYWORDS:
295
+ mapping.pop(annotation, None)
296
+ properties = mapping.get("properties")
297
+ if isinstance(properties, dict):
298
+ _close_object(mapping, dialect)
299
+ mapping["required"] = list(cast("dict[str, Any]", properties))
300
+ elif _is_object_node(mapping):
301
+ _close_object(mapping, dialect)
302
+ mapping["properties"] = {}
303
+ mapping["required"] = []
304
+
305
+ for key, value in list(mapping.items()):
306
+ if key in _SUBSCHEMA_MAPS and isinstance(value, dict):
307
+ mapping[key] = {
308
+ name: _normalized(subschema, dialect)
309
+ for name, subschema in cast("dict[str, Any]", value).items()
310
+ }
311
+ continue
312
+ mapping[key] = _normalized(value, dialect)
313
+ return mapping
314
+
315
+
316
+ def _close_object(mapping: dict[str, Any], dialect: SchemaDialect) -> None:
317
+ additional = mapping.get("additionalProperties")
318
+ if additional in (None, False):
319
+ mapping["additionalProperties"] = False
320
+ return
321
+ if dialect is SchemaDialect.STRICT:
322
+ # dialect_problems already reported this; normalize does not guess.
323
+ mapping["additionalProperties"] = False
324
+
325
+
326
+ def compact_json(schema: Mapping[str, Any]) -> str:
327
+ """Serialize *schema* for an inline CLI flag, keeping argv small."""
328
+ return json.dumps(dict(schema), separators=(",", ":"))
329
+
330
+
331
+ def materialize(schema: Mapping[str, Any], host_dir: Path) -> Path:
332
+ """Write *schema* under *host_dir* as ``<sha>.json`` and return the path.
333
+
334
+ The name is content-addressed so repeated turns with the same schema
335
+ reuse one file, and the write is atomic so a CLI reading the path
336
+ concurrently never sees a partial document.
337
+
338
+ A schema carrying a value JSON cannot express, a ``NaN`` or an infinity,
339
+ raises ``ProviderCapabilityError``: the provider cannot be given this
340
+ schema, which is the same answer a dialect problem gets, and it is what
341
+ keeps a bare ``ValueError`` out of ``turn()``.
342
+ """
343
+ try:
344
+ serialized = json.dumps(dict(schema), indent=2, sort_keys=True, allow_nan=False)
345
+ except ValueError as exc:
346
+ msg = f"output schema cannot be serialized as JSON: {exc}"
347
+ raise ProviderCapabilityError(msg) from exc
348
+ encoded = (serialized + "\n").encode()
349
+ digest = hashlib.sha256(encoded).hexdigest()[:16]
350
+ target = host_dir / f"{digest}.json"
351
+ atomic_write(target, encoded)
352
+ return target
@@ -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.