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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. 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