agentshim 0.6.5__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 (59) hide show
  1. {agentshim-0.6.5 → agentshim-0.6.7}/CHANGELOG.md +62 -0
  2. {agentshim-0.6.5 → agentshim-0.6.7}/PKG-INFO +1 -1
  3. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/__init__.py +5 -3
  4. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/agent.py +1 -0
  5. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/provider.py +3 -0
  6. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/__init__.py +2 -0
  7. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/provider.py +17 -8
  8. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/sandbox.py +36 -8
  9. agentshim-0.6.7/agentshim/providers/claude/user_hooks.py +142 -0
  10. agentshim-0.6.7/agentshim/providers/codex/__init__.py +35 -0
  11. agentshim-0.6.7/agentshim/providers/codex/_toml.py +90 -0
  12. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/provider.py +156 -49
  13. agentshim-0.6.7/agentshim/providers/codex/rules.py +125 -0
  14. agentshim-0.6.7/agentshim/providers/codex/sandbox.py +217 -0
  15. {agentshim-0.6.5 → agentshim-0.6.7}/pyproject.toml +3 -1
  16. agentshim-0.6.5/agentshim/providers/codex/__init__.py +0 -16
  17. {agentshim-0.6.5 → agentshim-0.6.7}/.gitignore +0 -0
  18. {agentshim-0.6.5 → agentshim-0.6.7}/README.md +0 -0
  19. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/__init__.py +0 -0
  20. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/_files.py +0 -0
  21. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/env.py +0 -0
  22. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/errors.py +0 -0
  23. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/events.py +0 -0
  24. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/mcp.py +0 -0
  25. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/profile.py +0 -0
  26. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/schema.py +0 -0
  27. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/stream.py +0 -0
  28. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/turn.py +0 -0
  29. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/usage.py +0 -0
  30. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/__init__.py +0 -0
  31. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/executor.py +0 -0
  32. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/host.py +0 -0
  33. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/transform.py +0 -0
  34. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/__init__.py +0 -0
  35. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/events.py +0 -0
  36. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/hooks/__init__.py +0 -0
  37. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  38. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/parser.py +0 -0
  39. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/scripted.py +0 -0
  40. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/events.py +0 -0
  41. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/parser.py +0 -0
  42. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/scripted.py +0 -0
  43. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/__init__.py +0 -0
  44. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/events.py +0 -0
  45. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/parser.py +0 -0
  46. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/provider.py +0 -0
  47. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/scripted.py +0 -0
  48. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/__init__.py +0 -0
  49. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/events.py +0 -0
  50. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/parser.py +0 -0
  51. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/provider.py +0 -0
  52. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/scripted.py +0 -0
  53. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/__init__.py +0 -0
  54. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/events.py +0 -0
  55. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/parser.py +0 -0
  56. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/provider.py +0 -0
  57. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/scripted.py +0 -0
  58. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/py.typed +0 -0
  59. {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/testing/__init__.py +0 -0
@@ -1,5 +1,67 @@
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
+
34
+ ## 0.6.6 (2026-09-27)
35
+
36
+ Additive: every new option defaults to earlier behaviour.
37
+
38
+ ### Added
39
+
40
+ - `CodexProvider(sandbox=CodexSandboxConfig(...))` keeps Codex's own OS
41
+ sandbox on instead of bypassing it. `mode` is `read-only`,
42
+ `workspace-write` (default) or `danger-full-access`; `workspace-write` also
43
+ takes absolute `writable_roots`, `network_access` and `writable_tmp`. The
44
+ config is rendered as `--config` overrides so resumed turns keep it, pins
45
+ every `workspace-write` key so user config cannot widen it, and pins
46
+ `approval_policy="never"`. Invalid combinations raise on construction.
47
+ Without a config the argv is unchanged.
48
+ - `agentshim.providers.codex.parse_sandbox(argv)` inverts that rendering.
49
+ - `ClaudeProvider(hooks=[ClaudeHook(event, command, matcher, timeout_s)])`
50
+ adds caller hooks to the turn's inline settings, after agentshim's own
51
+ read-confinement hook. `command` is an argv quoted with `shlex.join`. Hooks
52
+ work with or without a sandbox. `build_settings` now takes
53
+ `SandboxConfig | None` and a keyword `hooks`; existing calls are unchanged.
54
+ - Hypothesis property tests, with a `fuzz` profile (`HYPOTHESIS_PROFILE=fuzz`).
55
+ - Cheap-model e2e knobs `AGENTSHIM_E2E_CLAUDE_MODEL` and
56
+ `AGENTSHIM_E2E_CODEX_MODEL`, and a credential-free Codex sandbox
57
+ enforcement matrix that runs whenever `codex` is installed.
58
+
59
+ ### Fixed
60
+
61
+ - Codex `--config` string values escape control characters. A newline or
62
+ other control character in an MCP command, argument, env value or `PATH`
63
+ used to produce invalid TOML, which Codex silently keeps as a raw string.
64
+
3
65
  ## 0.6.5 (2026-09-27)
4
66
 
5
67
  - Mark invocation-scoped Codex MCP servers as required so slow servers remain
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.5
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
@@ -78,13 +78,13 @@ from .execution import (
78
78
  TransformingExecutor,
79
79
  )
80
80
  from .providers import get_provider, provider_names
81
- from .providers.claude import ClaudeProvider, SandboxConfig
82
- from .providers.codex import CodexProvider
81
+ from .providers.claude import ClaudeHook, ClaudeProvider, SandboxConfig
82
+ from .providers.codex import CodexProvider, CodexSandboxConfig
83
83
  from .providers.copilot import CopilotProvider
84
84
  from .providers.gemini import GeminiProvider
85
85
  from .providers.opencode import OpencodeProvider
86
86
 
87
- __version__ = "0.6.5"
87
+ __version__ = "0.6.7"
88
88
 
89
89
  __all__ = [
90
90
  "AgentEvent",
@@ -94,6 +94,7 @@ __all__ = [
94
94
  "ArgvContext",
95
95
  "AssistantText",
96
96
  "CallbackCommandStreamSink",
97
+ "ClaudeHook",
97
98
  "ClaudeProvider",
98
99
  "CliAgent",
99
100
  "CliCheckError",
@@ -101,6 +102,7 @@ __all__ = [
101
102
  "CliNotFoundError",
102
103
  "CliTimeoutError",
103
104
  "CodexProvider",
105
+ "CodexSandboxConfig",
104
106
  "CommandExecutor",
105
107
  "CommandHandle",
106
108
  "CommandRequest",
@@ -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)
@@ -6,9 +6,11 @@ from .parser import ClaudeStreamParser
6
6
  from .provider import PROFILE, ClaudeProvider, mcp_entry
7
7
  from .sandbox import SandboxConfig, build_settings, resolve_sandbox
8
8
  from .scripted import resume_failure_lines, scripted_lines
9
+ from .user_hooks import ClaudeHook
9
10
 
10
11
  __all__ = [
11
12
  "PROFILE",
13
+ "ClaudeHook",
12
14
  "ClaudeProvider",
13
15
  "ClaudeStreamParser",
14
16
  "SandboxConfig",
@@ -11,6 +11,7 @@ from agentshim.core.profile import McpMechanism, OutputSchemaStyle, ProviderProf
11
11
 
12
12
  from .parser import ClaudeStreamParser
13
13
  from .sandbox import SANDBOX_ENV, SandboxConfig, build_settings, resolve_sandbox
14
+ from .user_hooks import ClaudeHook, resolve_hooks
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from collections.abc import Callable, Sequence
@@ -66,17 +67,24 @@ PROFILE = ProviderProfile(
66
67
 
67
68
 
68
69
  class ClaudeProvider:
69
- """Claude Code. ``sandbox`` is a provider option, not a portable one."""
70
+ """Claude Code. ``sandbox`` and ``hooks`` are provider options, not portable ones."""
70
71
 
71
72
  profile = PROFILE
72
73
 
73
- def __init__(self, *, sandbox: bool | SandboxConfig | None = None) -> None:
74
- """Fix the sandbox option for every turn this provider runs.
75
-
76
- ``True`` takes the default config; ``None`` and ``False`` both mean
77
- unsandboxed.
74
+ def __init__(
75
+ self,
76
+ *,
77
+ sandbox: bool | SandboxConfig | None = None,
78
+ hooks: Sequence[ClaudeHook] = (),
79
+ ) -> None:
80
+ """Fix the sandbox and hooks for every turn this provider runs.
81
+
82
+ ``sandbox=True`` takes the default config; ``None`` and ``False`` both
83
+ mean unsandboxed. ``hooks`` are added to the settings Claude Code
84
+ loads for the turn, after agentshim's own read-confinement hook.
78
85
  """
79
86
  self.sandbox: SandboxConfig | None = resolve_sandbox(sandbox)
87
+ self.hooks: tuple[ClaudeHook, ...] = resolve_hooks(hooks)
80
88
 
81
89
  @property
82
90
  def sandbox_env(self) -> dict[str, str]:
@@ -106,8 +114,9 @@ class ClaudeProvider:
106
114
  argv += ["--effort", ctx.reasoning_effort]
107
115
  if ctx.schema_inline:
108
116
  argv += ["--json-schema", ctx.schema_inline]
109
- if self.sandbox is not None:
110
- argv += ["--settings", json.dumps(build_settings(self.sandbox))]
117
+ settings = build_settings(self.sandbox, hooks=self.hooks)
118
+ if settings:
119
+ argv += ["--settings", json.dumps(settings)]
111
120
  argv += list(ctx.mcp_argv)
112
121
  argv += list(ctx.extra_args)
113
122
  return argv
@@ -16,7 +16,14 @@ import shlex
16
16
  import sys
17
17
  from dataclasses import dataclass, field
18
18
  from pathlib import Path
19
- from typing import Any
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from .user_hooks import merge_hooks, render_hooks
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Sequence
25
+
26
+ from .user_hooks import ClaudeHook
20
27
 
21
28
  # ``absolute()`` rather than ``resolve()``: the hook path is only handed back to
22
29
  # the interpreter, and an install reached through a symlinked tree should keep
@@ -94,8 +101,33 @@ def resolve_sandbox(value: object) -> SandboxConfig | None:
94
101
  raise TypeError(msg)
95
102
 
96
103
 
97
- def build_settings(config: SandboxConfig) -> dict[str, Any]:
98
- """Build the ``settings.json`` payload that enables the sandbox."""
104
+ def build_settings(
105
+ config: SandboxConfig | None, *, hooks: Sequence[ClaudeHook] = ()
106
+ ) -> dict[str, Any]:
107
+ """Build the inline ``settings.json`` payload for a turn.
108
+
109
+ Args:
110
+ config: The sandbox to enable, or ``None`` for no ``sandbox`` block.
111
+ hooks: Caller hooks, appended after agentshim's own read-confinement
112
+ hook on the same event, so neither replaces the other.
113
+
114
+ Returns:
115
+ The settings object; empty when there is nothing to set.
116
+ """
117
+ settings: dict[str, Any] = {}
118
+ own_hooks: dict[str, list[dict[str, Any]]] = {}
119
+ if config is not None:
120
+ settings["sandbox"] = _sandbox_block(config)
121
+ if config.confine_native_reads_to:
122
+ own_hooks = _confine_reads_hook(config.confine_native_reads_to)
123
+ merged = merge_hooks(own_hooks, render_hooks(hooks))
124
+ if merged:
125
+ settings["hooks"] = merged
126
+ return settings
127
+
128
+
129
+ def _sandbox_block(config: SandboxConfig) -> dict[str, Any]:
130
+ """Build the ``sandbox`` key of the settings object."""
99
131
  sandbox: dict[str, Any] = {
100
132
  "enabled": True,
101
133
  "failIfUnavailable": config.fail_if_unavailable,
@@ -121,11 +153,7 @@ def build_settings(config: SandboxConfig) -> dict[str, Any]:
121
153
  sandbox["network"] = {"allowedDomains": list(config.allowed_domains)}
122
154
 
123
155
  sandbox.update(config.extra_settings)
124
-
125
- settings: dict[str, Any] = {"sandbox": sandbox}
126
- if config.confine_native_reads_to:
127
- settings["hooks"] = _confine_reads_hook(config.confine_native_reads_to)
128
- return settings
156
+ return sandbox
129
157
 
130
158
 
131
159
  def _confine_reads_hook(roots: list[str]) -> dict[str, Any]:
@@ -0,0 +1,142 @@
1
+ """Caller-supplied Claude Code hooks.
2
+
3
+ Claude Code runs a hook command on an event such as ``PreToolUse`` and acts
4
+ on the JSON it prints, which is how a caller enforces a policy the sandbox
5
+ cannot express, for example refusing one executable in Bash. agentshim
6
+ already passes its own settings inline through ``--settings``; a second
7
+ ``--settings`` in ``extra_args`` would compete with that one, so the provider
8
+ takes hooks as an option and merges them into the single settings object.
9
+
10
+ See https://code.claude.com/docs/en/hooks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import re
17
+ import shlex
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass
20
+ from typing import Any, cast
21
+
22
+ #: Claude Code's hook events are PascalCase names. New events appear between
23
+ #: CLI releases, so the shape is checked rather than a closed list.
24
+ _EVENT_RE = re.compile(r"[A-Z][A-Za-z]*")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ClaudeHook:
29
+ """One command Claude Code runs on a hook event.
30
+
31
+ Attributes:
32
+ event: The hook event, such as ``PreToolUse`` or ``PostToolUse``.
33
+ command: The hook's argv. Claude Code runs hooks through a shell, so
34
+ agentshim quotes it with ``shlex.join``: every element reaches the
35
+ process literally, whatever it contains. Use an absolute
36
+ executable path; the agent's PATH is not the caller's. Any
37
+ sequence of strings is accepted and stored as a tuple.
38
+ matcher: For tool events, the tool-name pattern the hook applies to,
39
+ such as ``Bash`` or ``Edit|Write``. ``None`` matches every tool.
40
+ timeout_s: Seconds Claude Code allows the hook before giving up;
41
+ ``None`` keeps the CLI default.
42
+ """
43
+
44
+ event: str
45
+ command: Sequence[str]
46
+ matcher: str | None = None
47
+ timeout_s: float | None = None
48
+
49
+ def __post_init__(self) -> None:
50
+ """Reject a hook Claude Code would refuse or run incorrectly."""
51
+ _check_event(self.event)
52
+ object.__setattr__(self, "command", _as_argv(self.command))
53
+ _check_matcher(self.matcher)
54
+ if self.timeout_s is not None and not _positive_finite(self.timeout_s):
55
+ msg = f"timeout_s must be a positive finite number, got {self.timeout_s!r}"
56
+ raise ValueError(msg)
57
+
58
+
59
+ def _check_event(event: object) -> None:
60
+ if not isinstance(event, str) or not _EVENT_RE.fullmatch(event):
61
+ msg = f"event must be a PascalCase hook event name, got {event!r}"
62
+ raise ValueError(msg)
63
+
64
+
65
+ def _check_matcher(matcher: object) -> None:
66
+ if matcher is not None and (not isinstance(matcher, str) or not matcher):
67
+ msg = f"matcher must be a non-empty string or None, got {matcher!r}"
68
+ raise ValueError(msg)
69
+
70
+
71
+ def _as_argv(value: object) -> tuple[str, ...]:
72
+ """Validate a hook command and freeze it into a tuple.
73
+
74
+ A bare string is rejected rather than split: ``"python3 hook.py"`` would
75
+ otherwise become one argv element naming a file that does not exist.
76
+ """
77
+ if isinstance(value, str) or not isinstance(value, Sequence):
78
+ msg = f"command must be an argv sequence, not {type(value).__name__}"
79
+ raise TypeError(msg)
80
+ argv = tuple(cast("Sequence[object]", value))
81
+ if not argv:
82
+ msg = "command must not be empty"
83
+ raise ValueError(msg)
84
+ for arg in argv:
85
+ if not isinstance(arg, str):
86
+ msg = f"command elements must be str, got {type(arg).__name__}"
87
+ raise TypeError(msg)
88
+ if "\x00" in arg:
89
+ msg = f"command element contains a NUL byte: {arg!r}"
90
+ raise ValueError(msg)
91
+ try:
92
+ arg.encode("utf-8")
93
+ except UnicodeEncodeError:
94
+ msg = f"command element is not valid UTF-8 text: {arg!r}"
95
+ raise ValueError(msg) from None
96
+ return cast("tuple[str, ...]", argv)
97
+
98
+
99
+ def _positive_finite(value: object) -> bool:
100
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
101
+ return False
102
+ return math.isfinite(value) and value > 0
103
+
104
+
105
+ def resolve_hooks(value: object) -> tuple[ClaudeHook, ...]:
106
+ """Normalize the ``hooks`` provider option to a tuple of hooks."""
107
+ if isinstance(value, ClaudeHook) or not isinstance(value, Sequence):
108
+ msg = f"hooks must be a sequence of ClaudeHook, got {type(value).__name__}"
109
+ raise TypeError(msg)
110
+ hooks = tuple(cast("Sequence[object]", value))
111
+ for hook in hooks:
112
+ if not isinstance(hook, ClaudeHook):
113
+ msg = f"hooks must contain ClaudeHook, got {type(hook).__name__}"
114
+ raise TypeError(msg)
115
+ return cast("tuple[ClaudeHook, ...]", hooks)
116
+
117
+
118
+ def render_hooks(hooks: Sequence[ClaudeHook]) -> dict[str, list[dict[str, Any]]]:
119
+ """Render *hooks* as a settings ``hooks`` block, one entry per hook.
120
+
121
+ Entries keep the caller's order within each event. One entry per hook,
122
+ rather than grouping by matcher, keeps every hook's timeout its own.
123
+ """
124
+ block: dict[str, list[dict[str, Any]]] = {}
125
+ for hook in hooks:
126
+ handler: dict[str, Any] = {"type": "command", "command": shlex.join(hook.command)}
127
+ if hook.timeout_s is not None:
128
+ handler["timeout"] = hook.timeout_s
129
+ entry: dict[str, Any] = {"hooks": [handler]}
130
+ if hook.matcher is not None:
131
+ entry["matcher"] = hook.matcher
132
+ block.setdefault(hook.event, []).append(entry)
133
+ return block
134
+
135
+
136
+ def merge_hooks(*blocks: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
137
+ """Concatenate hook blocks event by event, earlier blocks first."""
138
+ merged: dict[str, list[dict[str, Any]]] = {}
139
+ for block in blocks:
140
+ for event, entries in block.items():
141
+ merged.setdefault(event, []).extend(entries)
142
+ return merged
@@ -0,0 +1,35 @@
1
+ """Codex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .parser import CodexStreamParser
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
15
+ from .sandbox import SANDBOX_MODES, CodexSandboxConfig, SandboxMode
16
+ from .scripted import resume_failure_lines, scripted_lines
17
+
18
+ __all__ = [
19
+ "BYPASS_FLAG",
20
+ "IGNORE_RULES_FLAG",
21
+ "PROFILE",
22
+ "RULES_FILENAME",
23
+ "SANDBOX_MODES",
24
+ "CodexProvider",
25
+ "CodexSandboxConfig",
26
+ "CodexStreamParser",
27
+ "SandboxMode",
28
+ "install_rules",
29
+ "parse_mcp_servers",
30
+ "parse_rules",
31
+ "parse_sandbox",
32
+ "render_rules",
33
+ "resume_failure_lines",
34
+ "scripted_lines",
35
+ ]
@@ -0,0 +1,90 @@
1
+ """TOML literals for Codex's ``--config key=value`` overrides.
2
+
3
+ Codex parses the value of each override as TOML and, when that fails, uses
4
+ the raw text as a string instead. A malformed literal therefore does not
5
+ error: it silently changes the value's type. Everything this provider puts in
6
+ an override goes through these helpers, and ``unescape_toml`` inverts
7
+ ``toml_str`` so the argv parsers can read back exactly what was rendered.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ #: TOML's short escapes. Every other control character becomes ``\\uXXXX``.
18
+ _SHORT_ESCAPES = {
19
+ "\\": "\\\\",
20
+ '"': '\\"',
21
+ "\b": "\\b",
22
+ "\t": "\\t",
23
+ "\n": "\\n",
24
+ "\f": "\\f",
25
+ "\r": "\\r",
26
+ }
27
+ _UNESCAPES = {escape[1]: char for char, escape in _SHORT_ESCAPES.items()}
28
+ _UNICODE_ESCAPE_WIDTHS = {"u": 4, "U": 8}
29
+ _DEL = 0x7F
30
+ _FIRST_PRINTABLE = 0x20
31
+
32
+
33
+ def toml_str(value: str) -> str:
34
+ """Quote *value* as a TOML basic string that parses back to *value*.
35
+
36
+ TOML forbids raw control characters in a basic string, and Codex treats
37
+ an override that fails to parse as TOML as a raw literal instead, so an
38
+ unescaped newline would silently change the value's type.
39
+ """
40
+ parts: list[str] = []
41
+ for char in value:
42
+ if char in _SHORT_ESCAPES:
43
+ parts.append(_SHORT_ESCAPES[char])
44
+ elif ord(char) < _FIRST_PRINTABLE or ord(char) == _DEL:
45
+ parts.append(f"\\u{ord(char):04X}")
46
+ else:
47
+ parts.append(char)
48
+ return '"' + "".join(parts) + '"'
49
+
50
+
51
+ def toml_array(values: Sequence[str]) -> str:
52
+ """Render strings as a TOML inline array."""
53
+ return "[" + ",".join(toml_str(value) for value in values) + "]"
54
+
55
+
56
+ def toml_bool(value: bool) -> str: # noqa: FBT001 - a value, not a mode switch
57
+ """Render a TOML boolean."""
58
+ return "true" if value else "false"
59
+
60
+
61
+ def unescape_toml(body: str) -> str:
62
+ """Invert ``toml_str`` on the text between the quotes.
63
+
64
+ Raises:
65
+ ValueError: *body* holds an escape ``toml_str`` never writes.
66
+ """
67
+ result: list[str] = []
68
+ index = 0
69
+ while index < len(body):
70
+ char = body[index]
71
+ if char != "\\":
72
+ result.append(char)
73
+ index += 1
74
+ continue
75
+ code = body[index + 1 : index + 2]
76
+ if code in _UNESCAPES:
77
+ result.append(_UNESCAPES[code])
78
+ index += 2
79
+ elif code in _UNICODE_ESCAPE_WIDTHS:
80
+ width = _UNICODE_ESCAPE_WIDTHS[code]
81
+ digits = body[index + 2 : index + 2 + width]
82
+ if len(digits) != width:
83
+ msg = f"truncated \\{code} escape in {body!r}"
84
+ raise ValueError(msg)
85
+ result.append(chr(int(digits, 16)))
86
+ index += 2 + width
87
+ else:
88
+ msg = f"invalid TOML escape \\{code} in {body!r}"
89
+ raise ValueError(msg)
90
+ return "".join(result)