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
Binary file
@@ -0,0 +1,241 @@
1
+ """HTTP request construction and retry policy for provider calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import ssl
7
+ import time
8
+ from typing import Any
9
+
10
+ import httpx2
11
+
12
+ from .. import httpclient
13
+ from ..errors import ProviderError
14
+ from .common import iter_sse_data
15
+
16
+ # The policy itself lives in ``minimalcode.httpclient`` so the web and vision
17
+ # clients cannot drift from the protocol client. ``time`` stays imported here
18
+ # because backoff sleeps are patched at this module by the retry tests.
19
+ # Backoff runs 0.5, 1, 2, 4, then 8 seconds per attempt, so all 11 attempts span
20
+ # roughly a minute: long enough to ride out a provider hiccup, short enough that
21
+ # a dead endpoint still fails inside one agent turn.
22
+
23
+ # A TLS session can be reset after the handshake but before the response headers
24
+ # ("[SSL: UNEXPECTED_EOF_WHILE_READING]"), which httpcore surfaces as ReadError or
25
+ # as a bare ssl.SSLError. One-shot handshakes (session create, challenge fetch) are
26
+ # idempotent and cost nothing server-side, so they retry these; a streamed
27
+ # generation does not, because retrying it could duplicate output and billing.
28
+ HANDSHAKE_RETRY_ERRORS = (
29
+ ssl.SSLError,
30
+ httpx2.ReadError,
31
+ httpx2.WriteError,
32
+ httpx2.PoolTimeout,
33
+ ConnectionError,
34
+ )
35
+
36
+
37
+ class TransportMixin:
38
+ """Open a provider response with connection and HTTP-status retries."""
39
+
40
+ # Attributes supplied by OpenAICompatibleProvider.
41
+ _client: httpx2.Client
42
+ notify: Any
43
+ timeout_seconds: float
44
+
45
+ def _endpoint(self) -> str:
46
+ raise NotImplementedError
47
+
48
+ def _checked_lines(self, lines):
49
+ """Yield SSE lines while enforcing the whole-stream deadline."""
50
+ deadline = getattr(self, "_stream_deadline", None)
51
+ for line in lines:
52
+ if deadline is not None and time.monotonic() > deadline:
53
+ raise ProviderError(
54
+ "provider stream exceeded timeout_seconds "
55
+ f"({self.timeout_seconds:g}s)"
56
+ )
57
+ yield line
58
+
59
+ def _sse_events(self, lines):
60
+ """Yield one payload per SSE event, joined across `data:` lines."""
61
+ yield from iter_sse_data(self._checked_lines(lines))
62
+
63
+ def _sse_lines(self, response: httpx2.Response):
64
+ """Yield ``response.iter_lines()`` under the whole-stream deadline.
65
+
66
+ Protocols that read raw SSE lines rather than whole events still have to
67
+ inherit ``timeout_seconds``; calling ``iter_lines`` directly would let a
68
+ server that trickles one byte hold the turn open indefinitely.
69
+ """
70
+ yield from self._checked_lines(response.iter_lines())
71
+
72
+ def _read_body(self, response: httpx2.Response) -> bytes:
73
+ """Read a response body under the same whole-request deadline.
74
+
75
+ ``response.read()`` is bounded only by the per-socket read timeout, so a
76
+ server that keeps trickling a byte would hold a non-streaming request
77
+ open forever. Reading chunks and checking the deadline applies the
78
+ configured ``timeout_seconds`` to the whole body.
79
+ """
80
+ deadline = getattr(self, "_stream_deadline", None)
81
+ chunks: list[bytes] = []
82
+ try:
83
+ for chunk in response.iter_bytes():
84
+ if deadline is not None and time.monotonic() > deadline:
85
+ raise ProviderError(
86
+ "provider response exceeded timeout_seconds "
87
+ f"({self.timeout_seconds:g}s)"
88
+ )
89
+ chunks.append(chunk)
90
+ except (httpx2.HTTPError, OSError) as exc:
91
+ raise ProviderError(f"provider response read failed: {exc}") from exc
92
+ return b"".join(chunks)
93
+
94
+ def _post_json(self, url: str, payload: dict[str, Any]) -> bytes:
95
+ """POST a small idempotent JSON request and return its body.
96
+
97
+ Used for provider handshakes (session creation, challenge fetch) that sit
98
+ outside the single wire-protocol call. Routed through ``_open_url`` so they
99
+ inherit the connection retry policy, and given the extra TLS-reset
100
+ retryable set because a dropped handshake costs nothing to repeat.
101
+ """
102
+ response = self._open_url(url, payload, retry_extra=HANDSHAKE_RETRY_ERRORS)
103
+ try:
104
+ return self._read_body(response)
105
+ finally:
106
+ response.close()
107
+
108
+ def _open(self, payload: dict[str, Any]) -> httpx2.Response:
109
+ """POST ``payload`` to the protocol endpoint and return a response."""
110
+ return self._open_url(self._endpoint(), payload)
111
+
112
+ def _open_url(
113
+ self,
114
+ url: str,
115
+ payload: dict[str, Any],
116
+ *,
117
+ retry_extra: tuple[type[BaseException], ...] = (),
118
+ ) -> httpx2.Response:
119
+ """POST ``payload`` to ``url`` and return a successful response.
120
+
121
+ Retries cover connection failures that occurred before a response and
122
+ one 429/503 retry. Any other status is reported through ProviderError
123
+ with the bounded response body. ``retry_extra`` lets an idempotent
124
+ handshake treat TLS resets as retryable without loosening the policy for
125
+ a streamed generation.
126
+ """
127
+ # Keep the transport streamed even for a `stream: false` request: the
128
+ # parsers read either SSE lines or the whole body through `_read_body`,
129
+ # which is the only path that enforces the whole-generation deadline.
130
+ # Letting httpx2 read a non-streaming body internally would bypass it.
131
+ stream = True
132
+ last_error: ProviderError | None = None
133
+ # Retries share the request's time budget: without it a 1s timeout could
134
+ # still spend ~55s in connection backoff before giving up.
135
+ deadline = time.monotonic() + self.timeout_seconds
136
+ # Counts failures, not requests: the status retry below is charged outside
137
+ # this budget, so one call issues at most httpclient.CONNECTION_ATTEMPTS + 1 requests.
138
+ connection_failures = 0
139
+ status_retried = False
140
+ retryable_types = httpclient.RETRYABLE_CONNECTION_ERRORS + tuple(retry_extra)
141
+ while True:
142
+ try:
143
+ remaining = deadline - time.monotonic()
144
+ if remaining <= 0:
145
+ # The budget covers the attempts themselves, not only the
146
+ # backoff: with a 20s connect timeout a second attempt could
147
+ # overrun a 30s `timeout_seconds` and report much later.
148
+ last_error = ProviderError(
149
+ "provider request exceeded timeout_seconds "
150
+ f"({self.timeout_seconds:g}s)"
151
+ )
152
+ break
153
+ request = self._client.build_request(
154
+ "POST",
155
+ url,
156
+ json=payload,
157
+ timeout=httpx2.Timeout(remaining, connect=min(20.0, remaining)),
158
+ )
159
+ response = self._client.send(request, stream=stream)
160
+ except (httpx2.HTTPError, httpx2.InvalidURL, ValueError, OSError) as exc:
161
+ last_error = ProviderError(f"provider network error: {exc}")
162
+ connection_failures += 1
163
+ if (
164
+ connection_failures >= httpclient.CONNECTION_ATTEMPTS
165
+ or not isinstance(exc, retryable_types)
166
+ ):
167
+ break
168
+ delay = min(
169
+ httpclient.connection_backoff(connection_failures - 1),
170
+ deadline - time.monotonic(),
171
+ )
172
+ if delay <= 0:
173
+ # The budget is spent; retrying now would exceed
174
+ # `timeout_seconds` instead of reporting the failure.
175
+ break
176
+ if self.notify is not None:
177
+ self.notify(
178
+ "[retry] provider did not respond; attempt "
179
+ f"{connection_failures}/{httpclient.CONNECTION_ATTEMPTS - 1} "
180
+ f"in {delay:g}s ({exc})"
181
+ )
182
+ time.sleep(delay)
183
+ continue
184
+ if 200 <= response.status_code < 300:
185
+ # The per-read timeout only bounds a single socket read; this
186
+ # deadline bounds the whole streamed generation.
187
+ self._stream_deadline = time.monotonic() + self.timeout_seconds
188
+ return response
189
+
190
+ status_code = response.status_code
191
+ if 300 <= status_code < 400:
192
+ location = response.headers.get("Location", "")
193
+ response.close()
194
+ raise ProviderError(
195
+ "provider redirected the request to "
196
+ f"{location or 'another location'}; refusing to forward "
197
+ "credentials and the request body"
198
+ )
199
+ # Headers must be read before the body is consumed and closed.
200
+ retry_after = response.headers.get("Retry-After", "1")
201
+ try:
202
+ body = response.read().decode("utf-8", errors="replace")[:2000]
203
+ except httpx2.HTTPError as exc:
204
+ body = ""
205
+ last_error = ProviderError(
206
+ f"provider network error while reading response: {exc}",
207
+ status_code=status_code,
208
+ )
209
+ else:
210
+ last_error = ProviderError(
211
+ f"provider returned HTTP {status_code}: {body}",
212
+ status_code=status_code,
213
+ body=body,
214
+ )
215
+ finally:
216
+ response.close()
217
+ # Rate limits and unavailable responses get one retry, after honouring
218
+ # Retry-After; the flag is never reset, so a repeated 429/503 is
219
+ # reported instead of looping. Other statuses are final, because the
220
+ # provider may already have started (and billed) a generation.
221
+ if status_code not in {429, 503} or status_retried:
222
+ break
223
+ status_retried = True
224
+ try:
225
+ # Retry-After is seconds in practice. The floor keeps "0" from
226
+ # collapsing the single status retry into an immediate repeat;
227
+ # the ceiling stops a provider from parking the turn for minutes.
228
+ requested_delay = float(retry_after)
229
+ if not math.isfinite(requested_delay):
230
+ requested_delay = 1.0
231
+ delay = min(max(requested_delay, 0.2), 5.0)
232
+ except ValueError:
233
+ delay = 1.0
234
+ if delay >= deadline - time.monotonic():
235
+ # Honouring Retry-After is not worth exceeding the caller's
236
+ # timeout; report the status instead of stalling.
237
+ break
238
+ time.sleep(delay)
239
+ if last_error is None: # pragma: no cover - defensive
240
+ raise ProviderError("provider request failed")
241
+ raise last_error
@@ -0,0 +1,54 @@
1
+ """Provider-neutral reply and tool-call model objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class ToolCall:
11
+ """One function call requested by the model."""
12
+
13
+ id: str
14
+ name: str
15
+ arguments: str
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class ProviderReply:
20
+ """Normalized result of one provider turn."""
21
+
22
+ content: str = ""
23
+ tool_calls: list[ToolCall] = field(default_factory=list)
24
+ finish_reason: str | None = None
25
+ usage: dict[str, int] = field(default_factory=dict)
26
+ # Anthropic thinking blocks must be replayed with their signatures while a
27
+ # tool call is open. Other protocols ignore this field, and the Chat payload
28
+ # builder strips it before sending.
29
+ provider_content: list[dict[str, Any]] | None = None
30
+
31
+ def as_message(self) -> dict[str, Any]:
32
+ """Render the reply as an assistant message for the conversation log."""
33
+ message: dict[str, Any] = {
34
+ "role": "assistant",
35
+ # A Chat assistant message without tool calls must carry a string;
36
+ # null content is rejected by strict OpenAI-compatible servers and
37
+ # would poison every later request in the session.
38
+ "content": self.content if self.content or not self.tool_calls else None,
39
+ }
40
+ if self.provider_content:
41
+ message["provider_content"] = self.provider_content
42
+ if self.tool_calls:
43
+ message["tool_calls"] = [
44
+ {
45
+ "id": call.id,
46
+ "type": "function",
47
+ "function": {
48
+ "name": call.name,
49
+ "arguments": call.arguments,
50
+ },
51
+ }
52
+ for call in self.tool_calls
53
+ ]
54
+ return message
@@ -0,0 +1,64 @@
1
+ """Fail-closed sandbox policy, backends, and bounded execution.
2
+
3
+ The package is split into policy, backend, execution, and runner modules.
4
+ ``__all__`` is the complete public surface; private helpers moved out of the
5
+ pre-0.7 single ``sandbox`` module are imported from the submodule that defines
6
+ them (``minimalcode.sandbox.execution``, ``.backends``, ``.policy``,
7
+ ``.network``, ``.sensitive``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .audit import SandboxAudit, SandboxDenial
13
+ from .backends import (
14
+ BwrapBackend,
15
+ CommandRequest,
16
+ DockerBackend,
17
+ FirejailBackend,
18
+ LocalSandboxBackend,
19
+ MacSandboxExecBackend,
20
+ SandboxBackend,
21
+ SandboxUnavailable,
22
+ available_backends,
23
+ backend_for,
24
+ )
25
+ from .execution import truncate_text
26
+ from .network import host_allowed, network_signals
27
+ from .policy import NETWORK_POLICIES, SANDBOX_BACKENDS, SandboxPolicy
28
+ from .runner import SandboxRunner
29
+ from .sensitive import (
30
+ SAFE_ENV_TEMPLATES,
31
+ SENSITIVE_DIRECTORY_NAMES,
32
+ SENSITIVE_FILE_NAMES,
33
+ SENSITIVE_SEARCH_GLOBS,
34
+ SENSITIVE_SUFFIXES,
35
+ is_sensitive_path,
36
+ )
37
+
38
+ __all__ = [
39
+ "NETWORK_POLICIES",
40
+ "SAFE_ENV_TEMPLATES",
41
+ "SANDBOX_BACKENDS",
42
+ "SENSITIVE_DIRECTORY_NAMES",
43
+ "SENSITIVE_FILE_NAMES",
44
+ "SENSITIVE_SEARCH_GLOBS",
45
+ "SENSITIVE_SUFFIXES",
46
+ "BwrapBackend",
47
+ "CommandRequest",
48
+ "DockerBackend",
49
+ "FirejailBackend",
50
+ "LocalSandboxBackend",
51
+ "MacSandboxExecBackend",
52
+ "SandboxAudit",
53
+ "SandboxBackend",
54
+ "SandboxDenial",
55
+ "SandboxPolicy",
56
+ "SandboxRunner",
57
+ "SandboxUnavailable",
58
+ "available_backends",
59
+ "backend_for",
60
+ "host_allowed",
61
+ "is_sensitive_path",
62
+ "network_signals",
63
+ "truncate_text",
64
+ ]
@@ -0,0 +1,120 @@
1
+ """Bounded audit records for sandbox denials and operational notes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import threading
7
+ from collections import deque
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ # A command that was refused on network grounds is the one most likely to
12
+ # carry a credential: `curl -H "Authorization: Bearer ..."` is exactly what the
13
+ # policy stops. Records must not become a second plaintext copy of the key.
14
+ _SECRET_PATTERNS = (
15
+ re.compile(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]+"),
16
+ re.compile(r"(?i)\b(authorization\s*:\s*)(?!bearer)[^\s'\"]+"),
17
+ re.compile(r"(?i)\b(x-api-key\s*:\s*)[^\s'\"]+"),
18
+ re.compile(
19
+ # `access_token=...` and `client-secret: ...` are as sensitive as a
20
+ # bare `token=...`, so allow a name prefix before the keyword.
21
+ r"(?i)\b([\w-]*(?:api[_-]?key|token|password|passwd|secret)\s*[=:]\s*)"
22
+ r"[^\s'\"]+"
23
+ ),
24
+ re.compile(r"(?i)(--(?:password|token|api-key|header)\s+)[^\s'\"]+"),
25
+ re.compile(
26
+ # The value may be attached (`-uadmin:pw`), `=`-joined
27
+ # (`--user=admin:pw`), quoted (`-b "session=abc"`), or a separate
28
+ # argument. The quoted branch matters: `[^\s'\"]` could never match a
29
+ # value that starts with a quote, so the credential was recorded in
30
+ # plaintext.
31
+ r"(?i)(\s(?:-u|--user|--password|-b|--cookie|-H|--header)"
32
+ r"(?:\s+|=\s*|(?=[^-])))"
33
+ r"(?:['\"][^'\"]*['\"]|[^\s'\"]+)"
34
+ ),
35
+ # `mysql -phunter2` only attaches its password; a bare `-p` flag such as
36
+ # `docker run -p 8080:80` must keep its argument readable.
37
+ re.compile(r"(?i)(\s-p(?=[^\s-]))[^\s'\"]+"),
38
+ # `https://user:password@host` puts the credential in the URL itself, which
39
+ # none of the option-shaped patterns above reach.
40
+ re.compile(r"(?i)(://[^/\s:@'\"]+:)[^/\s@'\"]+(?=@)"),
41
+ # `net use \\server\share /user:name password` passes the secret as a
42
+ # separate, positionally paired argument.
43
+ re.compile(r"(?i)(/user:\S+\s+)[^\s'\"]+"),
44
+ )
45
+ _MAX_TARGET_CHARS = 500
46
+
47
+
48
+ def redact_command(text: str) -> str:
49
+ """Mask credential-shaped values and bound the recorded text."""
50
+ redacted = str(text)
51
+ for pattern in _SECRET_PATTERNS:
52
+ redacted = pattern.sub(lambda match: match.group(1) + "<redacted>", redacted)
53
+ if len(redacted) > _MAX_TARGET_CHARS:
54
+ redacted = redacted[:_MAX_TARGET_CHARS] + " ...<truncated>"
55
+ return redacted
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class SandboxDenial:
60
+ """One refused operation and the policy reason behind it."""
61
+
62
+ operation: str
63
+ target: str
64
+ reason: str
65
+
66
+ def as_dict(self) -> dict[str, str]:
67
+ """Return a JSON-serializable representation."""
68
+ return {
69
+ "operation": self.operation,
70
+ "target": self.target,
71
+ "reason": self.reason,
72
+ }
73
+
74
+
75
+ class SandboxAudit:
76
+ """Bounded in-memory audit trail of refused operations and notes."""
77
+
78
+ def __init__(self, max_entries: int = 200) -> None:
79
+ # Both deques are bounded and share one lock so summary snapshots are
80
+ # internally consistent without retaining unbounded history.
81
+ self._lock = threading.Lock()
82
+ self._denials: deque[SandboxDenial] = deque(maxlen=max_entries)
83
+ self._notes: deque[str] = deque(maxlen=max_entries)
84
+ self.total_denials = 0
85
+
86
+ def deny(self, operation: str, target: str, reason: str) -> None:
87
+ """Record one denial and increment the lifetime counter."""
88
+ with self._lock:
89
+ self._denials.append(
90
+ SandboxDenial(
91
+ operation=str(operation),
92
+ target=redact_command(str(target)),
93
+ reason=str(reason),
94
+ )
95
+ )
96
+ self.total_denials += 1
97
+
98
+ def note(self, message: str) -> None:
99
+ """Record an operational note such as a retry or timeout."""
100
+ with self._lock:
101
+ self._notes.append(redact_command(str(message)))
102
+
103
+ def denials(self) -> list[SandboxDenial]:
104
+ """Return a snapshot of the recent denial ring buffer."""
105
+ with self._lock:
106
+ return list(self._denials)
107
+
108
+ def notes(self) -> list[str]:
109
+ """Return a snapshot of the recent note ring buffer."""
110
+ with self._lock:
111
+ return list(self._notes)
112
+
113
+ def summary(self) -> dict[str, Any]:
114
+ """Return counters and recent history for CLI/status output."""
115
+ with self._lock:
116
+ return {
117
+ "total_denials": self.total_denials,
118
+ "recent": [denial.as_dict() for denial in self._denials],
119
+ "notes": list(self._notes),
120
+ }