langchain 1.0.0rc2__py3-none-any.whl → 1.0.1__py3-none-any.whl
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.
- langchain/__init__.py +1 -1
- langchain/agents/factory.py +26 -20
- langchain/agents/middleware/__init__.py +12 -0
- langchain/agents/middleware/_execution.py +388 -0
- langchain/agents/middleware/_redaction.py +350 -0
- langchain/agents/middleware/file_search.py +382 -0
- langchain/agents/middleware/pii.py +43 -477
- langchain/agents/middleware/shell_tool.py +718 -0
- langchain/agents/middleware/types.py +7 -5
- langchain/chat_models/base.py +7 -17
- langchain/embeddings/__init__.py +6 -0
- langchain/embeddings/base.py +21 -7
- langchain/tools/tool_node.py +47 -45
- {langchain-1.0.0rc2.dist-info → langchain-1.0.1.dist-info}/METADATA +12 -9
- {langchain-1.0.0rc2.dist-info → langchain-1.0.1.dist-info}/RECORD +17 -13
- {langchain-1.0.0rc2.dist-info → langchain-1.0.1.dist-info}/WHEEL +0 -0
- {langchain-1.0.0rc2.dist-info → langchain-1.0.1.dist-info}/licenses/LICENSE +0 -0
langchain/__init__.py
CHANGED
langchain/agents/factory.py
CHANGED
|
@@ -3,15 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import itertools
|
|
6
|
-
from typing import
|
|
7
|
-
TYPE_CHECKING,
|
|
8
|
-
Annotated,
|
|
9
|
-
Any,
|
|
10
|
-
cast,
|
|
11
|
-
get_args,
|
|
12
|
-
get_origin,
|
|
13
|
-
get_type_hints,
|
|
14
|
-
)
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated, Any, cast, get_args, get_origin, get_type_hints
|
|
15
7
|
|
|
16
8
|
from langchain_core.language_models.chat_models import BaseChatModel
|
|
17
9
|
from langchain_core.messages import AIMessage, AnyMessage, SystemMessage, ToolMessage
|
|
@@ -31,7 +23,8 @@ from langchain.agents.middleware.types import (
|
|
|
31
23
|
ModelRequest,
|
|
32
24
|
ModelResponse,
|
|
33
25
|
OmitFromSchema,
|
|
34
|
-
|
|
26
|
+
_InputAgentState,
|
|
27
|
+
_OutputAgentState,
|
|
35
28
|
)
|
|
36
29
|
from langchain.agents.structured_output import (
|
|
37
30
|
AutoStrategy,
|
|
@@ -527,12 +520,12 @@ def create_agent( # noqa: PLR0915
|
|
|
527
520
|
name: str | None = None,
|
|
528
521
|
cache: BaseCache | None = None,
|
|
529
522
|
) -> CompiledStateGraph[
|
|
530
|
-
AgentState[ResponseT], ContextT,
|
|
523
|
+
AgentState[ResponseT], ContextT, _InputAgentState, _OutputAgentState[ResponseT]
|
|
531
524
|
]:
|
|
532
525
|
"""Creates an agent graph that calls tools in a loop until a stopping condition is met.
|
|
533
526
|
|
|
534
527
|
For more details on using `create_agent`,
|
|
535
|
-
visit [Agents](https://docs.langchain.com/oss/python/langchain/agents)
|
|
528
|
+
visit the [Agents](https://docs.langchain.com/oss/python/langchain/agents) docs.
|
|
536
529
|
|
|
537
530
|
Args:
|
|
538
531
|
model: The language model for the agent. Can be a string identifier
|
|
@@ -600,7 +593,7 @@ def create_agent( # noqa: PLR0915
|
|
|
600
593
|
|
|
601
594
|
|
|
602
595
|
graph = create_agent(
|
|
603
|
-
model="anthropic:claude-sonnet-4-5
|
|
596
|
+
model="anthropic:claude-sonnet-4-5",
|
|
604
597
|
tools=[check_weather],
|
|
605
598
|
system_prompt="You are a helpful assistant",
|
|
606
599
|
)
|
|
@@ -780,7 +773,7 @@ def create_agent( # noqa: PLR0915
|
|
|
780
773
|
|
|
781
774
|
# create graph, add nodes
|
|
782
775
|
graph: StateGraph[
|
|
783
|
-
AgentState[ResponseT], ContextT,
|
|
776
|
+
AgentState[ResponseT], ContextT, _InputAgentState, _OutputAgentState[ResponseT]
|
|
784
777
|
] = StateGraph(
|
|
785
778
|
state_schema=resolved_state_schema,
|
|
786
779
|
input_schema=input_schema,
|
|
@@ -1225,6 +1218,15 @@ def create_agent( # noqa: PLR0915
|
|
|
1225
1218
|
graph.add_edge(START, entry_node)
|
|
1226
1219
|
# add conditional edges only if tools exist
|
|
1227
1220
|
if tool_node is not None:
|
|
1221
|
+
# Only include exit_node in destinations if any tool has return_direct=True
|
|
1222
|
+
# or if there are structured output tools
|
|
1223
|
+
tools_to_model_destinations = [loop_entry_node]
|
|
1224
|
+
if (
|
|
1225
|
+
any(tool.return_direct for tool in tool_node.tools_by_name.values())
|
|
1226
|
+
or structured_output_tools
|
|
1227
|
+
):
|
|
1228
|
+
tools_to_model_destinations.append(exit_node)
|
|
1229
|
+
|
|
1228
1230
|
graph.add_conditional_edges(
|
|
1229
1231
|
"tools",
|
|
1230
1232
|
_make_tools_to_model_edge(
|
|
@@ -1233,7 +1235,7 @@ def create_agent( # noqa: PLR0915
|
|
|
1233
1235
|
structured_output_tools=structured_output_tools,
|
|
1234
1236
|
end_destination=exit_node,
|
|
1235
1237
|
),
|
|
1236
|
-
|
|
1238
|
+
tools_to_model_destinations,
|
|
1237
1239
|
)
|
|
1238
1240
|
|
|
1239
1241
|
# base destinations are tools and exit_node
|
|
@@ -1498,10 +1500,12 @@ def _make_tools_to_model_edge(
|
|
|
1498
1500
|
last_ai_message, tool_messages = _fetch_last_ai_and_tool_messages(state["messages"])
|
|
1499
1501
|
|
|
1500
1502
|
# 1. Exit condition: All executed tools have return_direct=True
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
for c in last_ai_message.tool_calls
|
|
1504
|
-
|
|
1503
|
+
# Filter to only client-side tools (provider tools are not in tool_node)
|
|
1504
|
+
client_side_tool_calls = [
|
|
1505
|
+
c for c in last_ai_message.tool_calls if c["name"] in tool_node.tools_by_name
|
|
1506
|
+
]
|
|
1507
|
+
if client_side_tool_calls and all(
|
|
1508
|
+
tool_node.tools_by_name[c["name"]].return_direct for c in client_side_tool_calls
|
|
1505
1509
|
):
|
|
1506
1510
|
return end_destination
|
|
1507
1511
|
|
|
@@ -1518,7 +1522,9 @@ def _make_tools_to_model_edge(
|
|
|
1518
1522
|
|
|
1519
1523
|
|
|
1520
1524
|
def _add_middleware_edge(
|
|
1521
|
-
graph: StateGraph[
|
|
1525
|
+
graph: StateGraph[
|
|
1526
|
+
AgentState[ResponseT], ContextT, _InputAgentState, _OutputAgentState[ResponseT]
|
|
1527
|
+
],
|
|
1522
1528
|
*,
|
|
1523
1529
|
name: str,
|
|
1524
1530
|
default_destination: str,
|
|
@@ -17,6 +17,13 @@ from .human_in_the_loop import (
|
|
|
17
17
|
from .model_call_limit import ModelCallLimitMiddleware
|
|
18
18
|
from .model_fallback import ModelFallbackMiddleware
|
|
19
19
|
from .pii import PIIDetectionError, PIIMiddleware
|
|
20
|
+
from .shell_tool import (
|
|
21
|
+
CodexSandboxExecutionPolicy,
|
|
22
|
+
DockerExecutionPolicy,
|
|
23
|
+
HostExecutionPolicy,
|
|
24
|
+
RedactionRule,
|
|
25
|
+
ShellToolMiddleware,
|
|
26
|
+
)
|
|
20
27
|
from .summarization import SummarizationMiddleware
|
|
21
28
|
from .todo import TodoListMiddleware
|
|
22
29
|
from .tool_call_limit import ToolCallLimitMiddleware
|
|
@@ -42,7 +49,10 @@ __all__ = [
|
|
|
42
49
|
"AgentMiddleware",
|
|
43
50
|
"AgentState",
|
|
44
51
|
"ClearToolUsesEdit",
|
|
52
|
+
"CodexSandboxExecutionPolicy",
|
|
45
53
|
"ContextEditingMiddleware",
|
|
54
|
+
"DockerExecutionPolicy",
|
|
55
|
+
"HostExecutionPolicy",
|
|
46
56
|
"HumanInTheLoopMiddleware",
|
|
47
57
|
"InterruptOnConfig",
|
|
48
58
|
"LLMToolEmulator",
|
|
@@ -53,6 +63,8 @@ __all__ = [
|
|
|
53
63
|
"ModelResponse",
|
|
54
64
|
"PIIDetectionError",
|
|
55
65
|
"PIIMiddleware",
|
|
66
|
+
"RedactionRule",
|
|
67
|
+
"ShellToolMiddleware",
|
|
56
68
|
"SummarizationMiddleware",
|
|
57
69
|
"TodoListMiddleware",
|
|
58
70
|
"ToolCallLimitMiddleware",
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Execution policies for the persistent shell middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import typing
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
try: # pragma: no cover - optional dependency on POSIX platforms
|
|
17
|
+
import resource
|
|
18
|
+
except ImportError: # pragma: no cover - non-POSIX systems
|
|
19
|
+
resource = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SHELL_TEMP_PREFIX = "langchain-shell-"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _launch_subprocess(
|
|
26
|
+
command: Sequence[str],
|
|
27
|
+
*,
|
|
28
|
+
env: Mapping[str, str],
|
|
29
|
+
cwd: Path,
|
|
30
|
+
preexec_fn: typing.Callable[[], None] | None,
|
|
31
|
+
start_new_session: bool,
|
|
32
|
+
) -> subprocess.Popen[str]:
|
|
33
|
+
return subprocess.Popen( # noqa: S603
|
|
34
|
+
list(command),
|
|
35
|
+
stdin=subprocess.PIPE,
|
|
36
|
+
stdout=subprocess.PIPE,
|
|
37
|
+
stderr=subprocess.PIPE,
|
|
38
|
+
cwd=cwd,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
bufsize=1,
|
|
43
|
+
env=env,
|
|
44
|
+
preexec_fn=preexec_fn, # noqa: PLW1509
|
|
45
|
+
start_new_session=start_new_session,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if typing.TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Mapping, Sequence
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class BaseExecutionPolicy(abc.ABC):
|
|
56
|
+
"""Configuration contract for persistent shell sessions.
|
|
57
|
+
|
|
58
|
+
Concrete subclasses encapsulate how a shell process is launched and constrained.
|
|
59
|
+
Each policy documents its security guarantees and the operating environments in
|
|
60
|
+
which it is appropriate. Use :class:`HostExecutionPolicy` for trusted, same-host
|
|
61
|
+
execution; :class:`CodexSandboxExecutionPolicy` when the Codex CLI sandbox is
|
|
62
|
+
available and you want additional syscall restrictions; and
|
|
63
|
+
:class:`DockerExecutionPolicy` for container-level isolation using Docker.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
command_timeout: float = 30.0
|
|
67
|
+
startup_timeout: float = 30.0
|
|
68
|
+
termination_timeout: float = 10.0
|
|
69
|
+
max_output_lines: int = 100
|
|
70
|
+
max_output_bytes: int | None = None
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.max_output_lines <= 0:
|
|
74
|
+
msg = "max_output_lines must be positive."
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
|
|
77
|
+
@abc.abstractmethod
|
|
78
|
+
def spawn(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
workspace: Path,
|
|
82
|
+
env: Mapping[str, str],
|
|
83
|
+
command: Sequence[str],
|
|
84
|
+
) -> subprocess.Popen[str]:
|
|
85
|
+
"""Launch the persistent shell process."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class HostExecutionPolicy(BaseExecutionPolicy):
|
|
90
|
+
"""Run the shell directly on the host process.
|
|
91
|
+
|
|
92
|
+
This policy is best suited for trusted or single-tenant environments (CI jobs,
|
|
93
|
+
developer workstations, pre-sandboxed containers) where the agent must access the
|
|
94
|
+
host filesystem and tooling without additional isolation. It enforces optional CPU
|
|
95
|
+
and memory limits to prevent runaway commands but offers **no** filesystem or network
|
|
96
|
+
sandboxing; commands can modify anything the process user can reach.
|
|
97
|
+
|
|
98
|
+
On Linux platforms resource limits are applied with ``resource.prlimit`` after the
|
|
99
|
+
shell starts. On macOS, where ``prlimit`` is unavailable, limits are set in a
|
|
100
|
+
``preexec_fn`` before ``exec``. In both cases the shell runs in its own process group
|
|
101
|
+
so timeouts can terminate the full subtree.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
cpu_time_seconds: int | None = None
|
|
105
|
+
memory_bytes: int | None = None
|
|
106
|
+
create_process_group: bool = True
|
|
107
|
+
|
|
108
|
+
_limits_requested: bool = field(init=False, repr=False, default=False)
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
super().__post_init__()
|
|
112
|
+
if self.cpu_time_seconds is not None and self.cpu_time_seconds <= 0:
|
|
113
|
+
msg = "cpu_time_seconds must be positive if provided."
|
|
114
|
+
raise ValueError(msg)
|
|
115
|
+
if self.memory_bytes is not None and self.memory_bytes <= 0:
|
|
116
|
+
msg = "memory_bytes must be positive if provided."
|
|
117
|
+
raise ValueError(msg)
|
|
118
|
+
self._limits_requested = any(
|
|
119
|
+
value is not None for value in (self.cpu_time_seconds, self.memory_bytes)
|
|
120
|
+
)
|
|
121
|
+
if self._limits_requested and resource is None:
|
|
122
|
+
msg = (
|
|
123
|
+
"HostExecutionPolicy cpu/memory limits require the Python 'resource' module. "
|
|
124
|
+
"Either remove the limits or run on a POSIX platform."
|
|
125
|
+
)
|
|
126
|
+
raise RuntimeError(msg)
|
|
127
|
+
|
|
128
|
+
def spawn(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
workspace: Path,
|
|
132
|
+
env: Mapping[str, str],
|
|
133
|
+
command: Sequence[str],
|
|
134
|
+
) -> subprocess.Popen[str]:
|
|
135
|
+
process = _launch_subprocess(
|
|
136
|
+
list(command),
|
|
137
|
+
env=env,
|
|
138
|
+
cwd=workspace,
|
|
139
|
+
preexec_fn=self._create_preexec_fn(),
|
|
140
|
+
start_new_session=self.create_process_group,
|
|
141
|
+
)
|
|
142
|
+
self._apply_post_spawn_limits(process)
|
|
143
|
+
return process
|
|
144
|
+
|
|
145
|
+
def _create_preexec_fn(self) -> typing.Callable[[], None] | None:
|
|
146
|
+
if not self._limits_requested or self._can_use_prlimit():
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def _configure() -> None: # pragma: no cover - depends on OS
|
|
150
|
+
if self.cpu_time_seconds is not None:
|
|
151
|
+
limit = (self.cpu_time_seconds, self.cpu_time_seconds)
|
|
152
|
+
resource.setrlimit(resource.RLIMIT_CPU, limit)
|
|
153
|
+
if self.memory_bytes is not None:
|
|
154
|
+
limit = (self.memory_bytes, self.memory_bytes)
|
|
155
|
+
if hasattr(resource, "RLIMIT_AS"):
|
|
156
|
+
resource.setrlimit(resource.RLIMIT_AS, limit)
|
|
157
|
+
elif hasattr(resource, "RLIMIT_DATA"):
|
|
158
|
+
resource.setrlimit(resource.RLIMIT_DATA, limit)
|
|
159
|
+
|
|
160
|
+
return _configure
|
|
161
|
+
|
|
162
|
+
def _apply_post_spawn_limits(self, process: subprocess.Popen[str]) -> None:
|
|
163
|
+
if not self._limits_requested or not self._can_use_prlimit():
|
|
164
|
+
return
|
|
165
|
+
if resource is None: # pragma: no cover - defensive
|
|
166
|
+
return
|
|
167
|
+
pid = process.pid
|
|
168
|
+
if pid is None:
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
prlimit = typing.cast("typing.Any", resource).prlimit
|
|
172
|
+
if self.cpu_time_seconds is not None:
|
|
173
|
+
prlimit(pid, resource.RLIMIT_CPU, (self.cpu_time_seconds, self.cpu_time_seconds))
|
|
174
|
+
if self.memory_bytes is not None:
|
|
175
|
+
limit = (self.memory_bytes, self.memory_bytes)
|
|
176
|
+
if hasattr(resource, "RLIMIT_AS"):
|
|
177
|
+
prlimit(pid, resource.RLIMIT_AS, limit)
|
|
178
|
+
elif hasattr(resource, "RLIMIT_DATA"):
|
|
179
|
+
prlimit(pid, resource.RLIMIT_DATA, limit)
|
|
180
|
+
except OSError as exc: # pragma: no cover - depends on platform support
|
|
181
|
+
msg = "Failed to apply resource limits via prlimit."
|
|
182
|
+
raise RuntimeError(msg) from exc
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _can_use_prlimit() -> bool:
|
|
186
|
+
return (
|
|
187
|
+
resource is not None
|
|
188
|
+
and hasattr(resource, "prlimit")
|
|
189
|
+
and sys.platform.startswith("linux")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class CodexSandboxExecutionPolicy(BaseExecutionPolicy):
|
|
195
|
+
"""Launch the shell through the Codex CLI sandbox.
|
|
196
|
+
|
|
197
|
+
Ideal when you have the Codex CLI installed and want the additional syscall and
|
|
198
|
+
filesystem restrictions provided by Anthropic's Seatbelt (macOS) or Landlock/seccomp
|
|
199
|
+
(Linux) profiles. Commands still run on the host, but within the sandbox requested by
|
|
200
|
+
the CLI. If the Codex binary is unavailable or the runtime lacks the required
|
|
201
|
+
kernel features (e.g., Landlock inside some containers), process startup fails with a
|
|
202
|
+
:class:`RuntimeError`.
|
|
203
|
+
|
|
204
|
+
Configure sandbox behaviour via ``config_overrides`` to align with your Codex CLI
|
|
205
|
+
profile. This policy does not add its own resource limits; combine it with
|
|
206
|
+
host-level guards (cgroups, container resource limits) as needed.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
binary: str = "codex"
|
|
210
|
+
platform: typing.Literal["auto", "macos", "linux"] = "auto"
|
|
211
|
+
config_overrides: Mapping[str, typing.Any] = field(default_factory=dict)
|
|
212
|
+
|
|
213
|
+
def spawn(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
workspace: Path,
|
|
217
|
+
env: Mapping[str, str],
|
|
218
|
+
command: Sequence[str],
|
|
219
|
+
) -> subprocess.Popen[str]:
|
|
220
|
+
full_command = self._build_command(command)
|
|
221
|
+
return _launch_subprocess(
|
|
222
|
+
full_command,
|
|
223
|
+
env=env,
|
|
224
|
+
cwd=workspace,
|
|
225
|
+
preexec_fn=None,
|
|
226
|
+
start_new_session=False,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _build_command(self, command: Sequence[str]) -> list[str]:
|
|
230
|
+
binary = self._resolve_binary()
|
|
231
|
+
platform_arg = self._determine_platform()
|
|
232
|
+
full_command: list[str] = [binary, "sandbox", platform_arg]
|
|
233
|
+
for key, value in sorted(dict(self.config_overrides).items()):
|
|
234
|
+
full_command.extend(["-c", f"{key}={self._format_override(value)}"])
|
|
235
|
+
full_command.append("--")
|
|
236
|
+
full_command.extend(command)
|
|
237
|
+
return full_command
|
|
238
|
+
|
|
239
|
+
def _resolve_binary(self) -> str:
|
|
240
|
+
path = shutil.which(self.binary)
|
|
241
|
+
if path is None:
|
|
242
|
+
msg = (
|
|
243
|
+
"Codex sandbox policy requires the '%s' CLI to be installed and available on PATH."
|
|
244
|
+
)
|
|
245
|
+
raise RuntimeError(msg % self.binary)
|
|
246
|
+
return path
|
|
247
|
+
|
|
248
|
+
def _determine_platform(self) -> str:
|
|
249
|
+
if self.platform != "auto":
|
|
250
|
+
return self.platform
|
|
251
|
+
if sys.platform.startswith("linux"):
|
|
252
|
+
return "linux"
|
|
253
|
+
if sys.platform == "darwin":
|
|
254
|
+
return "macos"
|
|
255
|
+
msg = (
|
|
256
|
+
"Codex sandbox policy could not determine a supported platform; "
|
|
257
|
+
"set 'platform' explicitly."
|
|
258
|
+
)
|
|
259
|
+
raise RuntimeError(msg)
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _format_override(value: typing.Any) -> str:
|
|
263
|
+
try:
|
|
264
|
+
return json.dumps(value)
|
|
265
|
+
except TypeError:
|
|
266
|
+
return str(value)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class DockerExecutionPolicy(BaseExecutionPolicy):
|
|
271
|
+
"""Run the shell inside a dedicated Docker container.
|
|
272
|
+
|
|
273
|
+
Choose this policy when commands originate from untrusted users or you require
|
|
274
|
+
strong isolation between sessions. By default the workspace is bind-mounted only when
|
|
275
|
+
it refers to an existing non-temporary directory; ephemeral sessions run without a
|
|
276
|
+
mount to minimise host exposure. The container's network namespace is disabled by
|
|
277
|
+
default (``--network none``) and you can enable further hardening via
|
|
278
|
+
``read_only_rootfs`` and ``user``.
|
|
279
|
+
|
|
280
|
+
The security guarantees depend on your Docker daemon configuration. Run the agent on
|
|
281
|
+
a host where Docker is locked down (rootless mode, AppArmor/SELinux, etc.) and review
|
|
282
|
+
any additional volumes or capabilities passed through ``extra_run_args``. The default
|
|
283
|
+
image is ``python:3.12-alpine3.19``; supply a custom image if you need preinstalled
|
|
284
|
+
tooling.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
binary: str = "docker"
|
|
288
|
+
image: str = "python:3.12-alpine3.19"
|
|
289
|
+
remove_container_on_exit: bool = True
|
|
290
|
+
network_enabled: bool = False
|
|
291
|
+
extra_run_args: Sequence[str] | None = None
|
|
292
|
+
memory_bytes: int | None = None
|
|
293
|
+
cpu_time_seconds: typing.Any | None = None
|
|
294
|
+
cpus: str | None = None
|
|
295
|
+
read_only_rootfs: bool = False
|
|
296
|
+
user: str | None = None
|
|
297
|
+
|
|
298
|
+
def __post_init__(self) -> None:
|
|
299
|
+
super().__post_init__()
|
|
300
|
+
if self.memory_bytes is not None and self.memory_bytes <= 0:
|
|
301
|
+
msg = "memory_bytes must be positive if provided."
|
|
302
|
+
raise ValueError(msg)
|
|
303
|
+
if self.cpu_time_seconds is not None:
|
|
304
|
+
msg = (
|
|
305
|
+
"DockerExecutionPolicy does not support cpu_time_seconds; configure CPU limits "
|
|
306
|
+
"using Docker run options such as '--cpus'."
|
|
307
|
+
)
|
|
308
|
+
raise RuntimeError(msg)
|
|
309
|
+
if self.cpus is not None and not self.cpus.strip():
|
|
310
|
+
msg = "cpus must be a non-empty string when provided."
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
if self.user is not None and not self.user.strip():
|
|
313
|
+
msg = "user must be a non-empty string when provided."
|
|
314
|
+
raise ValueError(msg)
|
|
315
|
+
self.extra_run_args = tuple(self.extra_run_args or ())
|
|
316
|
+
|
|
317
|
+
def spawn(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
workspace: Path,
|
|
321
|
+
env: Mapping[str, str],
|
|
322
|
+
command: Sequence[str],
|
|
323
|
+
) -> subprocess.Popen[str]:
|
|
324
|
+
full_command = self._build_command(workspace, env, command)
|
|
325
|
+
host_env = os.environ.copy()
|
|
326
|
+
return _launch_subprocess(
|
|
327
|
+
full_command,
|
|
328
|
+
env=host_env,
|
|
329
|
+
cwd=workspace,
|
|
330
|
+
preexec_fn=None,
|
|
331
|
+
start_new_session=False,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _build_command(
|
|
335
|
+
self,
|
|
336
|
+
workspace: Path,
|
|
337
|
+
env: Mapping[str, str],
|
|
338
|
+
command: Sequence[str],
|
|
339
|
+
) -> list[str]:
|
|
340
|
+
binary = self._resolve_binary()
|
|
341
|
+
full_command: list[str] = [binary, "run", "-i"]
|
|
342
|
+
if self.remove_container_on_exit:
|
|
343
|
+
full_command.append("--rm")
|
|
344
|
+
if not self.network_enabled:
|
|
345
|
+
full_command.extend(["--network", "none"])
|
|
346
|
+
if self.memory_bytes is not None:
|
|
347
|
+
full_command.extend(["--memory", str(self.memory_bytes)])
|
|
348
|
+
if self._should_mount_workspace(workspace):
|
|
349
|
+
host_path = str(workspace)
|
|
350
|
+
full_command.extend(["-v", f"{host_path}:{host_path}"])
|
|
351
|
+
full_command.extend(["-w", host_path])
|
|
352
|
+
else:
|
|
353
|
+
full_command.extend(["-w", "/"])
|
|
354
|
+
if self.read_only_rootfs:
|
|
355
|
+
full_command.append("--read-only")
|
|
356
|
+
for key, value in env.items():
|
|
357
|
+
full_command.extend(["-e", f"{key}={value}"])
|
|
358
|
+
if self.cpus is not None:
|
|
359
|
+
full_command.extend(["--cpus", self.cpus])
|
|
360
|
+
if self.user is not None:
|
|
361
|
+
full_command.extend(["--user", self.user])
|
|
362
|
+
if self.extra_run_args:
|
|
363
|
+
full_command.extend(self.extra_run_args)
|
|
364
|
+
full_command.append(self.image)
|
|
365
|
+
full_command.extend(command)
|
|
366
|
+
return full_command
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _should_mount_workspace(workspace: Path) -> bool:
|
|
370
|
+
return not workspace.name.startswith(SHELL_TEMP_PREFIX)
|
|
371
|
+
|
|
372
|
+
def _resolve_binary(self) -> str:
|
|
373
|
+
path = shutil.which(self.binary)
|
|
374
|
+
if path is None:
|
|
375
|
+
msg = (
|
|
376
|
+
"Docker execution policy requires the '%s' CLI to be installed"
|
|
377
|
+
" and available on PATH."
|
|
378
|
+
)
|
|
379
|
+
raise RuntimeError(msg % self.binary)
|
|
380
|
+
return path
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
__all__ = [
|
|
384
|
+
"BaseExecutionPolicy",
|
|
385
|
+
"CodexSandboxExecutionPolicy",
|
|
386
|
+
"DockerExecutionPolicy",
|
|
387
|
+
"HostExecutionPolicy",
|
|
388
|
+
]
|