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