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
|
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
|
+
}
|