minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Sandbox configuration, validation, and path/network policy checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import ConfigError
|
|
13
|
+
from .network import host_allowed, network_signals
|
|
14
|
+
|
|
15
|
+
SANDBOX_BACKENDS: tuple[str, ...] = (
|
|
16
|
+
"auto",
|
|
17
|
+
"local",
|
|
18
|
+
"bwrap",
|
|
19
|
+
"firejail",
|
|
20
|
+
"sandbox-exec",
|
|
21
|
+
"docker",
|
|
22
|
+
)
|
|
23
|
+
NETWORK_POLICIES: tuple[str, ...] = ("deny", "allowlist", "allow")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_within(path: Path, root: Path) -> bool:
|
|
27
|
+
"""Case-insensitive containment check that works with resolved paths."""
|
|
28
|
+
child = os.path.normcase(os.path.abspath(str(path)))
|
|
29
|
+
parent = os.path.normcase(os.path.abspath(str(root))).rstrip(os.sep)
|
|
30
|
+
if child == parent:
|
|
31
|
+
return True
|
|
32
|
+
return child.startswith(parent + os.sep)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _string_tuple(value: Any, label: str) -> tuple[str, ...]:
|
|
36
|
+
"""Validate a configuration value that must be a list of non-empty strings."""
|
|
37
|
+
if value is None:
|
|
38
|
+
return ()
|
|
39
|
+
if not isinstance(value, (list, tuple)):
|
|
40
|
+
raise ConfigError(f"{label} must be a list of strings")
|
|
41
|
+
result: list[str] = []
|
|
42
|
+
for item in value:
|
|
43
|
+
if not isinstance(item, str) or not item.strip():
|
|
44
|
+
raise ConfigError(f"{label} must contain non-empty strings")
|
|
45
|
+
result.append(item.strip())
|
|
46
|
+
return tuple(result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _number(value: Any, label: str) -> float:
|
|
50
|
+
"""Validate a finite numeric setting, rejecting bool as an int subclass."""
|
|
51
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
52
|
+
raise ConfigError(f"{label} must be a number")
|
|
53
|
+
try:
|
|
54
|
+
numeric = float(value)
|
|
55
|
+
except (OverflowError, ValueError) as exc:
|
|
56
|
+
raise ConfigError(f"{label} must be a finite number") from exc
|
|
57
|
+
if not math.isfinite(numeric):
|
|
58
|
+
raise ConfigError(f"{label} must be a finite number")
|
|
59
|
+
return numeric
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class SandboxPolicy:
|
|
64
|
+
"""Immutable policy shared by path checks, backends, and the runner."""
|
|
65
|
+
|
|
66
|
+
enabled: bool
|
|
67
|
+
backend: str
|
|
68
|
+
allowed_roots: tuple[Path, ...]
|
|
69
|
+
network_policy: str
|
|
70
|
+
allowed_hosts: tuple[str, ...]
|
|
71
|
+
timeout_seconds: float
|
|
72
|
+
max_output_chars: int
|
|
73
|
+
allow_sensitive_files: bool
|
|
74
|
+
max_memory_mb: int
|
|
75
|
+
max_processes: int
|
|
76
|
+
docker_image: str
|
|
77
|
+
workspace: Path
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def disabled(cls, workspace: str | Path) -> SandboxPolicy:
|
|
81
|
+
"""Return a permissive execution policy with file checks still active."""
|
|
82
|
+
root = Path(workspace).resolve(strict=False)
|
|
83
|
+
return cls(
|
|
84
|
+
enabled=False,
|
|
85
|
+
backend="local",
|
|
86
|
+
allowed_roots=(root,),
|
|
87
|
+
network_policy="allow",
|
|
88
|
+
allowed_hosts=(),
|
|
89
|
+
timeout_seconds=3600.0,
|
|
90
|
+
max_output_chars=10_000_000,
|
|
91
|
+
# File checks stay active in this class's own contract: only the
|
|
92
|
+
# command execution layer is disabled.
|
|
93
|
+
allow_sensitive_files=False,
|
|
94
|
+
max_memory_mb=0,
|
|
95
|
+
max_processes=0,
|
|
96
|
+
docker_image="",
|
|
97
|
+
workspace=root,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_settings(
|
|
102
|
+
cls,
|
|
103
|
+
settings: Any,
|
|
104
|
+
workspace: str | Path,
|
|
105
|
+
*,
|
|
106
|
+
overrides: Mapping[str, Any] | None = None,
|
|
107
|
+
) -> SandboxPolicy:
|
|
108
|
+
"""Build a policy from settings, treating malformed values as fatal.
|
|
109
|
+
|
|
110
|
+
Configuration validation normally catches these cases, but the same
|
|
111
|
+
builder is used by tests and embedders, so it must not silently widen
|
|
112
|
+
the policy when a value is missing or wrong.
|
|
113
|
+
"""
|
|
114
|
+
root = Path(workspace).resolve()
|
|
115
|
+
raw = settings.get("sandbox", {}) if hasattr(settings, "get") else {}
|
|
116
|
+
data: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
|
|
117
|
+
if overrides:
|
|
118
|
+
data.update(
|
|
119
|
+
{key: value for key, value in overrides.items() if value is not None}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
enabled = data.get("enabled", False)
|
|
123
|
+
if not isinstance(enabled, bool):
|
|
124
|
+
raise ConfigError("sandbox.enabled must be a boolean")
|
|
125
|
+
backend = data.get("backend", "local")
|
|
126
|
+
if not isinstance(backend, str) or backend not in SANDBOX_BACKENDS:
|
|
127
|
+
raise ConfigError(
|
|
128
|
+
f"sandbox.backend must be one of {', '.join(SANDBOX_BACKENDS)}"
|
|
129
|
+
)
|
|
130
|
+
network_policy = data.get("network_policy", "deny")
|
|
131
|
+
if network_policy not in NETWORK_POLICIES:
|
|
132
|
+
raise ConfigError(
|
|
133
|
+
f"sandbox.network_policy must be one of {', '.join(NETWORK_POLICIES)}"
|
|
134
|
+
)
|
|
135
|
+
allowed_hosts = _string_tuple(
|
|
136
|
+
data.get("allowed_hosts", ()), "sandbox.allowed_hosts"
|
|
137
|
+
)
|
|
138
|
+
if network_policy == "allowlist" and not allowed_hosts:
|
|
139
|
+
raise ConfigError(
|
|
140
|
+
"sandbox.network_policy is 'allowlist' but sandbox.allowed_hosts is empty"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# The workspace is always the first root; extras are resolved relative
|
|
144
|
+
# to it when not absolute.
|
|
145
|
+
roots: list[Path] = [root]
|
|
146
|
+
for entry in _string_tuple(
|
|
147
|
+
data.get("allowed_roots", ()), "sandbox.allowed_roots"
|
|
148
|
+
):
|
|
149
|
+
try:
|
|
150
|
+
candidate = Path(entry).expanduser()
|
|
151
|
+
if not candidate.is_absolute():
|
|
152
|
+
candidate = root / candidate
|
|
153
|
+
resolved = candidate.resolve(strict=False)
|
|
154
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
155
|
+
raise ConfigError(
|
|
156
|
+
f"invalid sandbox.allowed_roots entry: {entry!r}"
|
|
157
|
+
) from exc
|
|
158
|
+
if resolved not in roots:
|
|
159
|
+
roots.append(resolved)
|
|
160
|
+
|
|
161
|
+
timeout = _number(data.get("timeout_seconds", 120), "sandbox.timeout_seconds")
|
|
162
|
+
if not 1 <= timeout <= 3600:
|
|
163
|
+
raise ConfigError("sandbox.timeout_seconds must be from 1 to 3600")
|
|
164
|
+
max_output = data.get("max_output_chars", 60_000)
|
|
165
|
+
if (
|
|
166
|
+
isinstance(max_output, bool)
|
|
167
|
+
or not isinstance(max_output, int)
|
|
168
|
+
or max_output < 1
|
|
169
|
+
):
|
|
170
|
+
raise ConfigError("sandbox.max_output_chars must be a positive integer")
|
|
171
|
+
allow_sensitive = data.get("allow_sensitive_files", False)
|
|
172
|
+
if not isinstance(allow_sensitive, bool):
|
|
173
|
+
raise ConfigError("sandbox.allow_sensitive_files must be a boolean")
|
|
174
|
+
max_memory = data.get("max_memory_mb", 0)
|
|
175
|
+
if (
|
|
176
|
+
isinstance(max_memory, bool)
|
|
177
|
+
or not isinstance(max_memory, int)
|
|
178
|
+
or max_memory < 0
|
|
179
|
+
):
|
|
180
|
+
raise ConfigError("sandbox.max_memory_mb must be a non-negative integer")
|
|
181
|
+
max_processes = data.get("max_processes", 0)
|
|
182
|
+
if (
|
|
183
|
+
isinstance(max_processes, bool)
|
|
184
|
+
or not isinstance(max_processes, int)
|
|
185
|
+
or max_processes < 0
|
|
186
|
+
):
|
|
187
|
+
raise ConfigError("sandbox.max_processes must be a non-negative integer")
|
|
188
|
+
docker_image = data.get("docker_image", "alpine:3")
|
|
189
|
+
if not isinstance(docker_image, str) or not docker_image.strip():
|
|
190
|
+
raise ConfigError("sandbox.docker_image must be a non-empty string")
|
|
191
|
+
|
|
192
|
+
return cls(
|
|
193
|
+
enabled=enabled,
|
|
194
|
+
backend=backend,
|
|
195
|
+
allowed_roots=tuple(roots),
|
|
196
|
+
network_policy=network_policy,
|
|
197
|
+
allowed_hosts=allowed_hosts,
|
|
198
|
+
timeout_seconds=timeout,
|
|
199
|
+
max_output_chars=max_output,
|
|
200
|
+
allow_sensitive_files=allow_sensitive,
|
|
201
|
+
max_memory_mb=max_memory,
|
|
202
|
+
max_processes=max_processes,
|
|
203
|
+
docker_image=docker_image.strip(),
|
|
204
|
+
workspace=root,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def contains(self, path: Path) -> bool:
|
|
208
|
+
"""Return True when ``path`` is inside any allowed root."""
|
|
209
|
+
return any(_is_within(path, root) for root in self.allowed_roots)
|
|
210
|
+
|
|
211
|
+
def write_roots(self, cwd: Path) -> tuple[Path, ...]:
|
|
212
|
+
"""Return roots a filesystem-capable backend must mount read-write."""
|
|
213
|
+
roots = list(self.allowed_roots)
|
|
214
|
+
resolved_cwd = Path(cwd).resolve(strict=False)
|
|
215
|
+
if not any(_is_within(resolved_cwd, root) for root in roots):
|
|
216
|
+
# An explicit legacy opt-out still needs the working directory
|
|
217
|
+
# mounted by filesystem-capable backends.
|
|
218
|
+
roots.append(resolved_cwd)
|
|
219
|
+
return tuple(roots)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def isolates_network(self) -> bool:
|
|
223
|
+
"""Return True when a backend must remove network access entirely.
|
|
224
|
+
|
|
225
|
+
``allowlist`` isolates exactly like ``deny``. A container or network
|
|
226
|
+
namespace cannot enforce a host allowlist, so treating it as ``allow``
|
|
227
|
+
handed the backend's process unrestricted egress and left only the
|
|
228
|
+
string gate -- best-effort by design -- as the control. The host check
|
|
229
|
+
stays in `network_denial`; only ``allow`` opens the network.
|
|
230
|
+
"""
|
|
231
|
+
return self.network_policy != "allow"
|
|
232
|
+
|
|
233
|
+
def network_denial(self, command: str) -> str | None:
|
|
234
|
+
"""Return a denial reason, or None when the command may run."""
|
|
235
|
+
if not self.enabled or self.network_policy == "allow":
|
|
236
|
+
return None
|
|
237
|
+
detected, hosts = network_signals(command)
|
|
238
|
+
if not detected:
|
|
239
|
+
return None
|
|
240
|
+
if self.network_policy == "deny":
|
|
241
|
+
return (
|
|
242
|
+
"network access is disabled by sandbox.network_policy=deny "
|
|
243
|
+
"(rejected command resembles a network client)"
|
|
244
|
+
)
|
|
245
|
+
if not hosts:
|
|
246
|
+
return (
|
|
247
|
+
"sandbox.network_policy=allowlist cannot verify the target host "
|
|
248
|
+
"for this command"
|
|
249
|
+
)
|
|
250
|
+
for host in hosts:
|
|
251
|
+
if not host_allowed(host, self.allowed_hosts):
|
|
252
|
+
return f"host {host!r} is not in sandbox.allowed_hosts"
|
|
253
|
+
return None
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""The unified path and command sandbox runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import ToolError
|
|
13
|
+
from .audit import SandboxAudit
|
|
14
|
+
from .backends import (
|
|
15
|
+
CommandRequest,
|
|
16
|
+
LocalSandboxBackend,
|
|
17
|
+
SandboxBackend,
|
|
18
|
+
SandboxUnavailable,
|
|
19
|
+
backend_for,
|
|
20
|
+
)
|
|
21
|
+
from .execution import _child_env, _execute, _resource_limiter
|
|
22
|
+
from .policy import SandboxPolicy
|
|
23
|
+
from .sensitive import is_sensitive_path, sensitive_command_paths
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sensitive_command_denial(command: str) -> str | None:
|
|
27
|
+
"""Return a denial message for credentials a shell command names.
|
|
28
|
+
|
|
29
|
+
`read` and `write` already refuse `.env` / `~/.ssh/id_rsa`; without this the
|
|
30
|
+
same file came back through `type .env` or `cat ~/.ssh/id_rsa`.
|
|
31
|
+
"""
|
|
32
|
+
hits = sensitive_command_paths(command)
|
|
33
|
+
if not hits:
|
|
34
|
+
return None
|
|
35
|
+
return (
|
|
36
|
+
f"sensitive path is blocked by sandbox policy: {', '.join(hits)} "
|
|
37
|
+
"(set sandbox.allow_sensitive_files=true to allow)"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SandboxRunner:
|
|
42
|
+
"""Single entry point for sandboxed paths and child processes."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
policy: SandboxPolicy,
|
|
47
|
+
*,
|
|
48
|
+
backend: SandboxBackend | None = None,
|
|
49
|
+
audit: SandboxAudit | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.policy = policy
|
|
52
|
+
self.backend = backend if backend is not None else backend_for(policy.backend)
|
|
53
|
+
self.audit = audit or SandboxAudit()
|
|
54
|
+
self.last_temp_dir: Path | None = None
|
|
55
|
+
self.last_temp_cleaned: bool | None = None
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def disabled(cls, workspace: str | Path) -> SandboxRunner:
|
|
59
|
+
"""Create a runner that keeps file checks but skips command isolation."""
|
|
60
|
+
return cls(
|
|
61
|
+
SandboxPolicy.disabled(workspace),
|
|
62
|
+
backend=LocalSandboxBackend(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_settings(
|
|
67
|
+
cls,
|
|
68
|
+
settings: Any,
|
|
69
|
+
workspace: str | Path,
|
|
70
|
+
*,
|
|
71
|
+
overrides: Mapping[str, Any] | None = None,
|
|
72
|
+
) -> SandboxRunner:
|
|
73
|
+
"""Build a runner and policy from configuration."""
|
|
74
|
+
return cls(
|
|
75
|
+
SandboxPolicy.from_settings(settings, workspace, overrides=overrides)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def resolve_path(
|
|
79
|
+
self,
|
|
80
|
+
raw: str | Path,
|
|
81
|
+
*,
|
|
82
|
+
cwd: Path,
|
|
83
|
+
operation: str = "read",
|
|
84
|
+
allow_outside: bool = False,
|
|
85
|
+
) -> Path:
|
|
86
|
+
"""Resolve a tool path and enforce roots and sensitive-file policy.
|
|
87
|
+
|
|
88
|
+
``sandbox.enabled`` only governs how *commands* are executed. The
|
|
89
|
+
filesystem boundary and the sensitive-file filter stay in force even
|
|
90
|
+
when the execution layer is disabled, so ``--no-sandbox`` cannot
|
|
91
|
+
silently turn every file tool into an unrestricted one.
|
|
92
|
+
"""
|
|
93
|
+
if not isinstance(raw, (str, Path)) or (
|
|
94
|
+
isinstance(raw, str) and not raw.strip()
|
|
95
|
+
):
|
|
96
|
+
raise ToolError("path must be a non-empty string")
|
|
97
|
+
try:
|
|
98
|
+
path = Path(raw).expanduser()
|
|
99
|
+
if not path.is_absolute():
|
|
100
|
+
path = Path(cwd) / path
|
|
101
|
+
resolved = path.resolve(strict=False)
|
|
102
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
103
|
+
raise ToolError(f"invalid path: {raw}") from exc
|
|
104
|
+
if not allow_outside and not self.policy.contains(resolved):
|
|
105
|
+
reason = "path escapes the sandbox allowed roots"
|
|
106
|
+
self.audit.deny(operation, str(resolved), reason)
|
|
107
|
+
raise ToolError(
|
|
108
|
+
f"path is outside workspace: {resolved} (sandbox denied: {reason})"
|
|
109
|
+
)
|
|
110
|
+
if not self.policy.allow_sensitive_files and is_sensitive_path(resolved):
|
|
111
|
+
reason = "sensitive file or directory"
|
|
112
|
+
self.audit.deny(operation, str(resolved), reason)
|
|
113
|
+
raise ToolError(
|
|
114
|
+
f"sensitive path is blocked by sandbox policy: {resolved} "
|
|
115
|
+
"(set sandbox.allow_sensitive_files=true to allow)"
|
|
116
|
+
)
|
|
117
|
+
return resolved
|
|
118
|
+
|
|
119
|
+
def is_allowed(
|
|
120
|
+
self,
|
|
121
|
+
path: Path,
|
|
122
|
+
*,
|
|
123
|
+
operation: str = "read",
|
|
124
|
+
allow_outside: bool = False,
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Non-raising variant used to filter bulk search results."""
|
|
127
|
+
candidate = Path(path)
|
|
128
|
+
if not candidate.is_absolute():
|
|
129
|
+
# A relative candidate belongs to the tool workspace, not to the
|
|
130
|
+
# process's own working directory.
|
|
131
|
+
candidate = self.policy.workspace / candidate
|
|
132
|
+
try:
|
|
133
|
+
resolved = candidate.resolve(strict=False)
|
|
134
|
+
except (OSError, RuntimeError, ValueError):
|
|
135
|
+
return False
|
|
136
|
+
if not allow_outside and not self.policy.contains(resolved):
|
|
137
|
+
return False
|
|
138
|
+
return self.policy.allow_sensitive_files or not is_sensitive_path(resolved)
|
|
139
|
+
|
|
140
|
+
def summary(self) -> dict[str, Any]:
|
|
141
|
+
"""Return the sandbox status payload shown by the CLI."""
|
|
142
|
+
# One audit snapshot keeps the counter and the history consistent even
|
|
143
|
+
# when worker threads record denials at the same time.
|
|
144
|
+
audit = self.audit.summary()
|
|
145
|
+
return {
|
|
146
|
+
"enabled": self.policy.enabled,
|
|
147
|
+
"backend": self.backend.name,
|
|
148
|
+
"backend_requested": self.policy.backend,
|
|
149
|
+
"backend_available": self.backend.available(),
|
|
150
|
+
"network_policy": self.policy.network_policy,
|
|
151
|
+
"allowed_hosts": list(self.policy.allowed_hosts),
|
|
152
|
+
"allowed_roots": [str(root) for root in self.policy.allowed_roots],
|
|
153
|
+
"allow_sensitive_files": self.policy.allow_sensitive_files,
|
|
154
|
+
"timeout_seconds": self.policy.timeout_seconds,
|
|
155
|
+
"max_output_chars": self.policy.max_output_chars,
|
|
156
|
+
"max_memory_mb": self.policy.max_memory_mb,
|
|
157
|
+
"max_processes": self.policy.max_processes,
|
|
158
|
+
"total_denials": audit["total_denials"],
|
|
159
|
+
"recent_denials": audit["recent"][-5:],
|
|
160
|
+
"notes": audit["notes"][-5:],
|
|
161
|
+
"last_temp_cleaned": self.last_temp_cleaned,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def _sandbox_metadata(self, cleaned: bool | None) -> dict[str, Any]:
|
|
165
|
+
"""Describe the execution environment attached to command results."""
|
|
166
|
+
return {
|
|
167
|
+
"backend": self.backend.name,
|
|
168
|
+
"network_policy": self.policy.network_policy,
|
|
169
|
+
"temp_dir_cleaned": cleaned,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def _denied_result(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
shell_label: str,
|
|
176
|
+
message: str,
|
|
177
|
+
cleaned: bool | None = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Shape a refusal exactly like a command result for the caller."""
|
|
180
|
+
return {
|
|
181
|
+
"status": "error",
|
|
182
|
+
"shell": shell_label,
|
|
183
|
+
"message": message,
|
|
184
|
+
"sandbox": self._sandbox_metadata(cleaned),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def run_argv(
|
|
188
|
+
self,
|
|
189
|
+
argv: list[str] | tuple[str, ...],
|
|
190
|
+
*,
|
|
191
|
+
command: str | None = None,
|
|
192
|
+
shell_kind: str = "",
|
|
193
|
+
shell_label: str = "",
|
|
194
|
+
cwd: Path,
|
|
195
|
+
timeout: float,
|
|
196
|
+
max_output_chars: int,
|
|
197
|
+
env: Mapping[str, str] | None = None,
|
|
198
|
+
stdin_text: str | None = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Execute argv through the configured backend and policy."""
|
|
201
|
+
if not argv:
|
|
202
|
+
return {
|
|
203
|
+
"status": "error",
|
|
204
|
+
"shell": shell_label,
|
|
205
|
+
"message": "command is empty",
|
|
206
|
+
}
|
|
207
|
+
try:
|
|
208
|
+
requested_timeout = float(timeout)
|
|
209
|
+
except (TypeError, ValueError, OverflowError):
|
|
210
|
+
return {
|
|
211
|
+
"status": "error",
|
|
212
|
+
"shell": shell_label,
|
|
213
|
+
"message": "timeout must be a number",
|
|
214
|
+
}
|
|
215
|
+
if math.isnan(requested_timeout):
|
|
216
|
+
return {
|
|
217
|
+
"status": "error",
|
|
218
|
+
"shell": shell_label,
|
|
219
|
+
"message": "timeout must be a number",
|
|
220
|
+
}
|
|
221
|
+
try:
|
|
222
|
+
requested_output = int(max_output_chars)
|
|
223
|
+
except (TypeError, ValueError, OverflowError):
|
|
224
|
+
requested_output = 1
|
|
225
|
+
requested_output = max(1, requested_output)
|
|
226
|
+
|
|
227
|
+
# The sensitive-file rule is a path policy, not an execution setting: it
|
|
228
|
+
# stays in force when the sandbox is disabled, exactly like `read` and
|
|
229
|
+
# `write`. Only shell strings are inspected; internal argv searches
|
|
230
|
+
# filter their own results through `is_allowed`.
|
|
231
|
+
if command is not None and not self.policy.allow_sensitive_files:
|
|
232
|
+
sensitive_reason = _sensitive_command_denial(command)
|
|
233
|
+
if sensitive_reason is not None:
|
|
234
|
+
self.audit.deny("shell", command, sensitive_reason)
|
|
235
|
+
return self._denied_result(
|
|
236
|
+
shell_label=shell_label, message=sensitive_reason
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# With execution isolation disabled, still run through the same bounded
|
|
240
|
+
# executor so timeouts and output caps behave consistently.
|
|
241
|
+
if not self.policy.enabled:
|
|
242
|
+
result = _execute(
|
|
243
|
+
list(argv),
|
|
244
|
+
cwd=cwd,
|
|
245
|
+
timeout=max(1.0, min(requested_timeout, 3600.0)),
|
|
246
|
+
max_output_chars=requested_output,
|
|
247
|
+
env=_child_env(env, None),
|
|
248
|
+
stdin_text=stdin_text,
|
|
249
|
+
shell_label=shell_label,
|
|
250
|
+
)
|
|
251
|
+
result.setdefault("sandbox", self._sandbox_metadata(None))
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
effective_timeout = max(
|
|
255
|
+
1.0, min(requested_timeout, self.policy.timeout_seconds)
|
|
256
|
+
)
|
|
257
|
+
# Output policy applies to shell strings; internal argv searches have
|
|
258
|
+
# their own byte/line budgets.
|
|
259
|
+
effective_output = (
|
|
260
|
+
min(requested_output, self.policy.max_output_chars)
|
|
261
|
+
if command is not None
|
|
262
|
+
else requested_output
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if command is not None:
|
|
266
|
+
denial = self.policy.network_denial(command)
|
|
267
|
+
if denial is not None:
|
|
268
|
+
self.audit.deny("shell", command, denial)
|
|
269
|
+
return self._denied_result(
|
|
270
|
+
shell_label=shell_label,
|
|
271
|
+
message=f"network access denied by sandbox policy: {denial}",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if not self.backend.available():
|
|
275
|
+
reason = (
|
|
276
|
+
f"sandbox backend {self.backend.name!r} is unavailable; "
|
|
277
|
+
"refusing to execute (fail-closed)"
|
|
278
|
+
)
|
|
279
|
+
self.audit.deny("shell", command or " ".join(argv[:3]), reason)
|
|
280
|
+
return self._denied_result(shell_label=shell_label, message=reason)
|
|
281
|
+
|
|
282
|
+
temp_dir: Path | None = None
|
|
283
|
+
cleaned: bool | None = None
|
|
284
|
+
failure: str | None = None
|
|
285
|
+
try:
|
|
286
|
+
try:
|
|
287
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="minimalcode-sandbox-"))
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
reason = f"cannot create sandbox temporary directory: {exc}"
|
|
290
|
+
self.audit.deny("shell", command or " ".join(argv[:3]), reason)
|
|
291
|
+
return self._denied_result(shell_label=shell_label, message=reason)
|
|
292
|
+
self.last_temp_dir = temp_dir
|
|
293
|
+
request = CommandRequest(
|
|
294
|
+
argv=tuple(argv),
|
|
295
|
+
command=command,
|
|
296
|
+
shell_kind=shell_kind,
|
|
297
|
+
shell_label=shell_label,
|
|
298
|
+
cwd=Path(cwd),
|
|
299
|
+
temp_dir=temp_dir,
|
|
300
|
+
policy=self.policy,
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
prepared = self.backend.wrap(request)
|
|
304
|
+
except SandboxUnavailable as exc:
|
|
305
|
+
failure = str(exc)
|
|
306
|
+
self.audit.deny("shell", command or " ".join(argv[:3]), failure)
|
|
307
|
+
result: dict[str, Any] = {}
|
|
308
|
+
if failure is None:
|
|
309
|
+
result = _execute(
|
|
310
|
+
prepared,
|
|
311
|
+
cwd=Path(cwd),
|
|
312
|
+
timeout=effective_timeout,
|
|
313
|
+
max_output_chars=effective_output,
|
|
314
|
+
env=_child_env(env, self.backend.temp_path(request)),
|
|
315
|
+
stdin_text=stdin_text,
|
|
316
|
+
shell_label=shell_label,
|
|
317
|
+
preexec=_resource_limiter(self.policy, effective_timeout),
|
|
318
|
+
on_timeout=lambda: self.backend.cleanup(request),
|
|
319
|
+
)
|
|
320
|
+
finally:
|
|
321
|
+
# Per-command temp directories are always removed, including when
|
|
322
|
+
# backend wrapping or process startup raises.
|
|
323
|
+
cleaned = True
|
|
324
|
+
if temp_dir is not None:
|
|
325
|
+
try:
|
|
326
|
+
shutil.rmtree(temp_dir)
|
|
327
|
+
except OSError as exc:
|
|
328
|
+
cleaned = False
|
|
329
|
+
self.audit.note(
|
|
330
|
+
f"temporary directory cleanup failed: {temp_dir}: {exc}"
|
|
331
|
+
)
|
|
332
|
+
self.last_temp_cleaned = cleaned
|
|
333
|
+
|
|
334
|
+
if failure is not None:
|
|
335
|
+
return self._denied_result(
|
|
336
|
+
shell_label=shell_label, message=failure, cleaned=cleaned
|
|
337
|
+
)
|
|
338
|
+
if result.get("status") == "timeout":
|
|
339
|
+
self.audit.note(
|
|
340
|
+
f"command timed out after {effective_timeout:g}s; process tree killed"
|
|
341
|
+
)
|
|
342
|
+
if result.get("truncated"):
|
|
343
|
+
self.audit.note(
|
|
344
|
+
f"command output truncated at {effective_output} characters"
|
|
345
|
+
)
|
|
346
|
+
result["sandbox"] = self._sandbox_metadata(cleaned)
|
|
347
|
+
return result
|