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.
- {agentshim-0.6.5 → agentshim-0.6.7}/CHANGELOG.md +62 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/PKG-INFO +1 -1
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/__init__.py +5 -3
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/agent.py +1 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/provider.py +3 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/__init__.py +2 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/provider.py +17 -8
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/sandbox.py +36 -8
- agentshim-0.6.7/agentshim/providers/claude/user_hooks.py +142 -0
- agentshim-0.6.7/agentshim/providers/codex/__init__.py +35 -0
- agentshim-0.6.7/agentshim/providers/codex/_toml.py +90 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/provider.py +156 -49
- agentshim-0.6.7/agentshim/providers/codex/rules.py +125 -0
- agentshim-0.6.7/agentshim/providers/codex/sandbox.py +217 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/pyproject.toml +3 -1
- agentshim-0.6.5/agentshim/providers/codex/__init__.py +0 -16
- {agentshim-0.6.5 → agentshim-0.6.7}/.gitignore +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/README.md +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/env.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/events.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/mcp.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/profile.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/schema.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/core/usage.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/parser.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/claude/scripted.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/codex/scripted.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/copilot/scripted.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/providers/opencode/scripted.py +0 -0
- {agentshim-0.6.5 → agentshim-0.6.7}/agentshim/py.typed +0 -0
- {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
|
|
@@ -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.
|
|
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",
|
|
@@ -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``
|
|
70
|
+
"""Claude Code. ``sandbox`` and ``hooks`` are provider options, not portable ones."""
|
|
70
71
|
|
|
71
72
|
profile = PROFILE
|
|
72
73
|
|
|
73
|
-
def __init__(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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(
|
|
98
|
-
|
|
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)
|