@widgex/wgx 0.0.0-stage → 0.4.2
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.
- package/LICENSE.txt +49 -0
- package/README.md +33 -2
- package/ThirdPartyNotices.txt +4685 -0
- package/package.json +30 -4
- package/provider-clis.json +1211 -0
- package/python/XACTOR.md +146 -0
- package/python/browser_actor_host.py +456 -0
- package/python/cdp_page.py +363 -0
- package/python/harness_check.py +77 -0
- package/python/requirements.txt +1 -0
- package/python/test_line_endings.py +185 -0
- package/python/xactor/__init__.py +18 -0
- package/python/xactor/__main__.py +226 -0
- package/python/xactor/api.py +882 -0
- package/python/xactor/bridge.py +784 -0
- package/python/xactor/dom.py +117 -0
- package/python/xactor/man/add_card.md +89 -0
- package/python/xactor/man/agent-vs-fork.md +108 -0
- package/python/xactor/man/agent.md +333 -0
- package/python/xactor/man/ask_model.md +110 -0
- package/python/xactor/man/ask_reference.md +56 -0
- package/python/xactor/man/ask_user.md +103 -0
- package/python/xactor/man/attach_app.md +595 -0
- package/python/xactor/man/attach_ide.md +65 -0
- package/python/xactor/man/attach_ide_surface.md +31 -0
- package/python/xactor/man/await_user.md +214 -0
- package/python/xactor/man/bash.md +142 -0
- package/python/xactor/man/browser-driver.md +137 -0
- package/python/xactor/man/browser_activity.md +53 -0
- package/python/xactor/man/browser_attach.md +84 -0
- package/python/xactor/man/browser_close_tab.md +50 -0
- package/python/xactor/man/browser_detach.md +50 -0
- package/python/xactor/man/browser_exec.md +66 -0
- package/python/xactor/man/browser_focus_tab.md +47 -0
- package/python/xactor/man/browser_hybrid.md +47 -0
- package/python/xactor/man/browser_open.md +50 -0
- package/python/xactor/man/browser_open_tab.md +58 -0
- package/python/xactor/man/browser_plumbing.md +107 -0
- package/python/xactor/man/browser_screenshot.md +56 -0
- package/python/xactor/man/browser_snapshot.md +52 -0
- package/python/xactor/man/browser_tabs.md +67 -0
- package/python/xactor/man/bulletin.md +88 -0
- package/python/xactor/man/capture_page.md +54 -0
- package/python/xactor/man/cd.md +64 -0
- package/python/xactor/man/chess_move.md +53 -0
- package/python/xactor/man/children.md +89 -0
- package/python/xactor/man/close_task.md +14 -0
- package/python/xactor/man/compact.md +75 -0
- package/python/xactor/man/drive.md +140 -0
- package/python/xactor/man/drive_space.md +72 -0
- package/python/xactor/man/driving.md +168 -0
- package/python/xactor/man/edit_file.md +112 -0
- package/python/xactor/man/finding.md +194 -0
- package/python/xactor/man/fork_agent.md +45 -0
- package/python/xactor/man/glob.md +83 -0
- package/python/xactor/man/grep.md +115 -0
- package/python/xactor/man/harness.md +165 -0
- package/python/xactor/man/harness_check.md +70 -0
- package/python/xactor/man/harness_format.md +257 -0
- package/python/xactor/man/highlight.md +66 -0
- package/python/xactor/man/instruct.md +111 -0
- package/python/xactor/man/ls.md +63 -0
- package/python/xactor/man/man.md +86 -0
- package/python/xactor/man/merge_wiz.md +40 -0
- package/python/xactor/man/notebook.md +98 -0
- package/python/xactor/man/overview.md +89 -0
- package/python/xactor/man/permissions.md +137 -0
- package/python/xactor/man/phase.md +150 -0
- package/python/xactor/man/powershell.md +74 -0
- package/python/xactor/man/profiles.md +60 -0
- package/python/xactor/man/project_drive.md +49 -0
- package/python/xactor/man/read_bulletin.md +89 -0
- package/python/xactor/man/read_file.md +90 -0
- package/python/xactor/man/relay.md +216 -0
- package/python/xactor/man/reply.md +72 -0
- package/python/xactor/man/reply_message.md +78 -0
- package/python/xactor/man/report_bug.md +118 -0
- package/python/xactor/man/request_answer.md +142 -0
- package/python/xactor/man/request_merge_decisions.md +121 -0
- package/python/xactor/man/request_operator_action.md +152 -0
- package/python/xactor/man/request_review.md +122 -0
- package/python/xactor/man/request_user_action.md +126 -0
- package/python/xactor/man/return_card.md +71 -0
- package/python/xactor/man/run.md +99 -0
- package/python/xactor/man/sandbox.md +144 -0
- package/python/xactor/man/session_fork.md +74 -0
- package/python/xactor/man/session_harness.md +109 -0
- package/python/xactor/man/session_join.md +102 -0
- package/python/xactor/man/session_land.md +73 -0
- package/python/xactor/man/session_nudge.md +73 -0
- package/python/xactor/man/session_rest.md +73 -0
- package/python/xactor/man/session_scope.md +84 -0
- package/python/xactor/man/session_terminate.md +58 -0
- package/python/xactor/man/session_token.md +78 -0
- package/python/xactor/man/sessions.md +93 -0
- package/python/xactor/man/set_harness.md +15 -0
- package/python/xactor/man/spawn_swarm.md +80 -0
- package/python/xactor/man/submit_evidence.md +54 -0
- package/python/xactor/man/swarm_brief.md +30 -0
- package/python/xactor/man/task_status.md +82 -0
- package/python/xactor/man/tasks.md +47 -0
- package/python/xactor/man/todo_write.md +104 -0
- package/python/xactor/man/update_card.md +70 -0
- package/python/xactor/man/use_profile.md +90 -0
- package/python/xactor/man/use_widget.md +82 -0
- package/python/xactor/man/wait_for_task.md +105 -0
- package/python/xactor/man/widget-model.md +284 -0
- package/python/xactor/man/widgets.md +59 -0
- package/python/xactor/man/write_file.md +76 -0
- package/python/xactor/man/writing-a-widget.md +142 -0
- package/python/xactor/man/xylon.md +120 -0
- package/python/xactor/man/xylon_blame.md +80 -0
- package/python/xactor/man/xylon_read.md +131 -0
- package/python/xactor/man/xylon_search.md +125 -0
- package/python/xactor/manual.py +195 -0
- package/python/xactor/parent_watch.py +115 -0
- package/python/xactor/plan_mode.py +350 -0
- package/python/xactor/sandbox.py +856 -0
- package/python/xactor/seals.py +438 -0
- package/python/xactor/shell_hints.py +210 -0
- package/python/xactor/states/__init__.py +13 -0
- package/python/xactor/states/base.py +523 -0
- package/python/xactor/states/registry.py +274 -0
- package/python/xactor/stdlib_api.py +6345 -0
- package/python/xactor/widgets/__init__.py +1 -0
- package/python/xactor/widgets/github_tokens.py +314 -0
- package/python/xactor/widgets/google_aistudio.py +365 -0
- package/settings-schema.json +403 -0
- package/viewer/index.css +160 -0
- package/viewer/index.js +15 -0
- package/wgx.mjs +290659 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
"""Sandbox — audit hook + permission enforcement for the xactor Python shell.
|
|
2
|
+
|
|
3
|
+
Uses sys.addaudithook() (PEP 578) to intercept all security-sensitive
|
|
4
|
+
operations at the C level. Once installed, hooks CANNOT be removed by
|
|
5
|
+
executing code.
|
|
6
|
+
|
|
7
|
+
Two nested scopes decide how strictly an operation is judged:
|
|
8
|
+
|
|
9
|
+
* **user-code scope** — the agent's own code, running inside the exec
|
|
10
|
+
wrapper. Direct OS access is denied outright here: files, processes,
|
|
11
|
+
sockets and imports must go through ``api.*``.
|
|
12
|
+
* **api scope** — the body of a ``StdlibAPI`` method, entered from inside
|
|
13
|
+
user code. Denials relax back to the path/subprocess/network rules, since
|
|
14
|
+
the API is the sanctioned way to touch the outside world.
|
|
15
|
+
|
|
16
|
+
Both are ContextVars, so concurrent execs (each its own asyncio task) and
|
|
17
|
+
the bridge's own I/O never see each other's scope.
|
|
18
|
+
|
|
19
|
+
Permission config is set at startup from TypeScript and can be updated
|
|
20
|
+
via bridge messages at runtime.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import contextvars
|
|
25
|
+
import fnmatch
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Iterator
|
|
30
|
+
|
|
31
|
+
# Imported at load rather than inside the hook: an import raised from inside an
|
|
32
|
+
# audit hook is the kind of thing that fails in ways nobody sees. `seals`
|
|
33
|
+
# imports nothing from this package, so there is no cycle.
|
|
34
|
+
from . import seals as _seals
|
|
35
|
+
|
|
36
|
+
# ── Permission state (set by TypeScript before any user code runs) ─────
|
|
37
|
+
|
|
38
|
+
_config = {
|
|
39
|
+
# Working directory — all relative paths resolve against this
|
|
40
|
+
"cwd": os.getcwd(),
|
|
41
|
+
|
|
42
|
+
# Allowed path prefixes for read access (absolute paths)
|
|
43
|
+
"read_allow": [], # type: list[str]
|
|
44
|
+
|
|
45
|
+
# Allowed path prefixes for write access (absolute paths)
|
|
46
|
+
"write_allow": [], # type: list[str]
|
|
47
|
+
|
|
48
|
+
# Whether subprocess execution is allowed
|
|
49
|
+
"allow_subprocess": False,
|
|
50
|
+
|
|
51
|
+
# Whether network access is allowed
|
|
52
|
+
"allow_network": False,
|
|
53
|
+
|
|
54
|
+
# Permission mode: "strict", "permissive", "open"
|
|
55
|
+
"mode": "permissive",
|
|
56
|
+
|
|
57
|
+
# Blocked modules (always blocked regardless of mode)
|
|
58
|
+
"blocked_modules": {"ctypes", "ctypes.util", "_ctypes"},
|
|
59
|
+
|
|
60
|
+
# A drive-channel task's folder, or None. When set the mode is strict and
|
|
61
|
+
# `cwd` is this folder; see `fence_allows`. '' is a fence whose folder the
|
|
62
|
+
# host could not name, and admits nothing.
|
|
63
|
+
"fence": None, # type: str | None
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FenceDenied(PermissionError):
|
|
68
|
+
"""A path outside a drive-channel task's folder, refused so it can be asked.
|
|
69
|
+
|
|
70
|
+
Its own class so the api wrapper can tell "outside the folder, ask the user"
|
|
71
|
+
from every other refusal, which it must not turn into a card.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, path: str, write: bool, message: str) -> None:
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
self.path = path
|
|
77
|
+
self.write = write
|
|
78
|
+
|
|
79
|
+
# Track whether the hook has been installed (one-shot)
|
|
80
|
+
_installed = False
|
|
81
|
+
|
|
82
|
+
# Scratch space, pre-cached to avoid re-entrant audit calls from path
|
|
83
|
+
# resolution.
|
|
84
|
+
#
|
|
85
|
+
# `gettempdir()` alone is not enough and the gap is invisible from the code: on
|
|
86
|
+
# macOS it answers with the per-user `/private/var/folders/…/T`, so `/tmp` —
|
|
87
|
+
# which is what an agent writes when it wants a scratch file, and what every
|
|
88
|
+
# tool it shells out to expects — was refused. Twice reported, both times worked
|
|
89
|
+
# around by putting scratch files inside the user's repository instead, which is
|
|
90
|
+
# the outcome the allowance exists to prevent.
|
|
91
|
+
#
|
|
92
|
+
# Both, then, and each resolved: on macOS `/tmp` is a symlink to `/private/tmp`
|
|
93
|
+
# and the audit hook sees the resolved path.
|
|
94
|
+
import tempfile as _tempfile
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolved(path: str) -> str | None:
|
|
98
|
+
try:
|
|
99
|
+
return str(Path(path).resolve())
|
|
100
|
+
except (OSError, ValueError):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_TMP_DIRS: list[str] = []
|
|
105
|
+
for _candidate in (_tempfile.gettempdir(), "/tmp", os.environ.get("TMPDIR", "")):
|
|
106
|
+
if not _candidate:
|
|
107
|
+
continue
|
|
108
|
+
_r = _resolved(_candidate)
|
|
109
|
+
if _r and _r not in _TMP_DIRS:
|
|
110
|
+
_TMP_DIRS.append(_r)
|
|
111
|
+
# What Git Bash mounts at `/tmp` on Windows — see `native_path`.
|
|
112
|
+
_USER_TEMP = _tempfile.gettempdir()
|
|
113
|
+
del _tempfile
|
|
114
|
+
|
|
115
|
+
# Python's own directories — always allowed for reads (stdlib, site-packages)
|
|
116
|
+
_python_prefixes: list[str] = []
|
|
117
|
+
for _p in sys.path:
|
|
118
|
+
if _p:
|
|
119
|
+
try:
|
|
120
|
+
_python_prefixes.append(str(Path(_p).resolve()))
|
|
121
|
+
except (OSError, ValueError):
|
|
122
|
+
pass
|
|
123
|
+
# Also allow the Python executable's directory
|
|
124
|
+
try:
|
|
125
|
+
_python_prefixes.append(str(Path(sys.executable).parent.resolve()))
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# Re-entrancy guard — the hook must not trigger itself
|
|
130
|
+
_in_hook = False
|
|
131
|
+
|
|
132
|
+
# ── Execution scopes ───────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
# Depth of agent-authored code on the stack (set by the exec wrapper).
|
|
135
|
+
_user_depth: contextvars.ContextVar[int] = contextvars.ContextVar("user_depth", default=0)
|
|
136
|
+
|
|
137
|
+
# Depth of StdlibAPI method bodies on the stack (set by the @api_method
|
|
138
|
+
# decorator). Non-zero means the operation was requested through the API.
|
|
139
|
+
_api_depth: contextvars.ContextVar[int] = contextvars.ContextVar("api_depth", default=0)
|
|
140
|
+
|
|
141
|
+
# Audit-event prefixes that constitute "direct OS access" — denied whenever
|
|
142
|
+
# agent code performs them itself instead of calling api.*.
|
|
143
|
+
_OS_EVENT_PREFIXES = (
|
|
144
|
+
"os.", "shutil.", "subprocess.", "socket.", "pty.", "fcntl.",
|
|
145
|
+
"msvcrt.", "winreg.", "ctypes.", "glob.", "webbrowser.", "tempfile.",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
_USE_API = (
|
|
149
|
+
"Use the api.* methods instead — the shell exposes file, shell, search "
|
|
150
|
+
"and session operations through `api` (see the tool description)."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Attributes of `os` that read or write the process environment. The audit
|
|
154
|
+
# hook cannot cover these: `os.environ` is an ordinary mapping and `os.getenv`
|
|
155
|
+
# an ordinary lookup, so CPython raises **no audit event** for either. Every
|
|
156
|
+
# other route into the OS announces itself and is denied above; this one is
|
|
157
|
+
# silent, which is exactly why it needs naming here.
|
|
158
|
+
_ENV_ATTRS = frozenset({
|
|
159
|
+
"environ", "environb", "getenv", "getenvb", "putenv", "unsetenv",
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _GuardedOS:
|
|
164
|
+
"""`os` as agent code sees it: everything except the environment.
|
|
165
|
+
|
|
166
|
+
`os` is bound into the exec scope so agent code can do path and data
|
|
167
|
+
manipulation — `os.path.join`, `os.sep`, `os.pathsep` — which is worth
|
|
168
|
+
keeping. What came with it was the whole process environment, readable by
|
|
169
|
+
anything the agent cared to write, in every permission mode. That is the
|
|
170
|
+
one hole in the sandbox's stated contract that the outside world is
|
|
171
|
+
reached through `api.*`.
|
|
172
|
+
|
|
173
|
+
Three things this deliberately does not touch, because each would be a
|
|
174
|
+
regression rather than a fix:
|
|
175
|
+
|
|
176
|
+
- **The real `os` module is untouched.** `StdlibAPI` imports it normally
|
|
177
|
+
and keeps reading PATH to resolve a shell. This proxy exists only in the
|
|
178
|
+
namespace agent code runs in.
|
|
179
|
+
- **`api.bash` subprocesses still inherit the real environment**, so a
|
|
180
|
+
build that needs registry or git credentials still works. Scrubbing the
|
|
181
|
+
daemon's environment at spawn would have taken that away, which is why
|
|
182
|
+
the guard is here and not there.
|
|
183
|
+
- **It restores the point of `api.extract_secret`**, whose contract is to
|
|
184
|
+
read a value into an env var *without it passing through this layer*.
|
|
185
|
+
Agent code that could read the variable back defeated that entirely.
|
|
186
|
+
|
|
187
|
+
**The honest limit**, so nobody reads more into this than it claims: this
|
|
188
|
+
closes the direct route, not every escape. Python's object graph can
|
|
189
|
+
generally be walked back to a module, and nothing here pretends otherwise.
|
|
190
|
+
It makes the contract true for the obvious path — the same standard the
|
|
191
|
+
`open()` denial meets — rather than being a containment boundary against
|
|
192
|
+
a determined attempt.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
__slots__ = ()
|
|
196
|
+
|
|
197
|
+
def __getattr__(self, name: str) -> Any:
|
|
198
|
+
if name in _ENV_ATTRS:
|
|
199
|
+
raise PermissionError(
|
|
200
|
+
f"Sandbox: os.{name} is not available to shell code — the "
|
|
201
|
+
"process environment is not readable from here. To use a "
|
|
202
|
+
"secret in a command, api.bash() inherits the real "
|
|
203
|
+
"environment, and api.extract_secret() puts a value there "
|
|
204
|
+
"without it passing through this layer."
|
|
205
|
+
)
|
|
206
|
+
return getattr(os, name)
|
|
207
|
+
|
|
208
|
+
def __dir__(self) -> list[str]:
|
|
209
|
+
# Honest about what is missing, so `dir(os)` does not advertise an
|
|
210
|
+
# attribute that raises. The difflib "did you mean" hints elsewhere in
|
|
211
|
+
# the shell read this kind of listing.
|
|
212
|
+
return [n for n in dir(os) if n not in _ENV_ATTRS]
|
|
213
|
+
|
|
214
|
+
def __repr__(self) -> str:
|
|
215
|
+
return "<module 'os' (sandboxed: no environment access)>"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def guarded_os() -> _GuardedOS:
|
|
219
|
+
"""The `os` to bind into the agent's exec scope. See `_GuardedOS`."""
|
|
220
|
+
return _GuardedOS()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _GuardedTime:
|
|
224
|
+
"""`time` as agent code sees it: every clock, but no blocking sleep.
|
|
225
|
+
|
|
226
|
+
`time.sleep` would stop the daemon's event loop, and with it every other
|
|
227
|
+
exec in flight — a delegate shares its parent's shell — and the reads that
|
|
228
|
+
deliver api results. Agents reached for it to wait on something, and the
|
|
229
|
+
only route they found was `api.bash('powershell -c "Start-Sleep 5"')`, 54
|
|
230
|
+
times: a third interpreter to do what `await asyncio.sleep` does in place.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
__slots__ = ()
|
|
234
|
+
|
|
235
|
+
def __getattr__(self, name: str) -> Any:
|
|
236
|
+
if name == "sleep":
|
|
237
|
+
raise PermissionError(
|
|
238
|
+
"Sandbox: time.sleep() would freeze the whole shell, including "
|
|
239
|
+
"other calls in flight. Write `await asyncio.sleep(seconds)`."
|
|
240
|
+
)
|
|
241
|
+
import time
|
|
242
|
+
return getattr(time, name)
|
|
243
|
+
|
|
244
|
+
def __dir__(self) -> list[str]:
|
|
245
|
+
import time
|
|
246
|
+
return [n for n in dir(time) if n != "sleep"]
|
|
247
|
+
|
|
248
|
+
def __repr__(self) -> str:
|
|
249
|
+
return "<module 'time' (sandboxed: use await asyncio.sleep)>"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── Imports from agent code ────────────────────────────────────────────
|
|
253
|
+
#
|
|
254
|
+
# `import json` used to be refused outright — while `json` was already bound in
|
|
255
|
+
# the very scope the import ran in. 111 refused calls in the transcripts, most
|
|
256
|
+
# of them for modules the shell already had (json 34, os 28, asyncio 10), and a
|
|
257
|
+
# refused import kills every line of the call, used or not.
|
|
258
|
+
#
|
|
259
|
+
# Worse than the cost was where it sent people: with no `re`, no `base64` and no
|
|
260
|
+
# way to decode a screenshot, agents moved the work into
|
|
261
|
+
# `api.bash("python -c ...")` — a second, *unsandboxed* interpreter reached
|
|
262
|
+
# through two more quoting layers. The refusal made the sandbox weaker and the
|
|
263
|
+
# scripts more fragile at once.
|
|
264
|
+
#
|
|
265
|
+
# So pure computation imports normally. Nothing here can reach the outside world
|
|
266
|
+
# by itself: any file, process or socket a module could open still raises the
|
|
267
|
+
# audit event the hook denies from user code.
|
|
268
|
+
|
|
269
|
+
IMPORTABLE_MODULES = frozenset({
|
|
270
|
+
"abc", "ast", "base64", "binascii", "bisect", "calendar", "cmath",
|
|
271
|
+
"collections", "collections.abc", "contextlib", "copy", "csv",
|
|
272
|
+
"dataclasses", "datetime", "decimal", "difflib", "enum", "fnmatch",
|
|
273
|
+
"fractions", "functools", "gzip", "hashlib", "heapq", "hmac", "html",
|
|
274
|
+
"html.parser", "inspect", "io", "itertools", "json", "keyword", "math",
|
|
275
|
+
"numbers", "operator", "pprint", "random", "re", "secrets", "shlex",
|
|
276
|
+
"statistics", "string", "struct", "textwrap", "tomllib", "traceback",
|
|
277
|
+
"types", "typing", "unicodedata", "urllib", "urllib.parse", "uuid",
|
|
278
|
+
"xml", "xml.etree", "xml.etree.ElementTree", "zlib",
|
|
279
|
+
# Bound or substituted, below: the real module is never handed out.
|
|
280
|
+
"asyncio", "os", "os.path", "pathlib", "time",
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
# Where to go instead, for the modules people actually reached for. Keyed on the
|
|
284
|
+
# top-level name. Anything unlisted gets the general answer.
|
|
285
|
+
_IMPORT_REDIRECTS = {
|
|
286
|
+
"subprocess": "run a program with api.run([...]) — no shell, no quoting — or a command line with api.bash(cmd)",
|
|
287
|
+
"shutil": "copy or move with api.bash('cp …'), and write with api.write_file()",
|
|
288
|
+
"glob": "find files with api.glob(pattern)",
|
|
289
|
+
"requests": "fetch with api.run(['curl', '-s', url])",
|
|
290
|
+
"http": "fetch with api.run(['curl', '-s', url])",
|
|
291
|
+
"socket": "fetch with api.run(['curl', '-s', url])",
|
|
292
|
+
"tempfile": "write scratch files under /tmp or the working directory with api.write_file()",
|
|
293
|
+
"sys": "print() already writes to your result, and api.cwd is the working directory",
|
|
294
|
+
"xactor": "PageState, Observation, Transition, Step and registry are already bound",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_GENERAL_REDIRECT = (
|
|
298
|
+
"modules that reach the system, and third-party ones, are not importable "
|
|
299
|
+
"here — run that code as a script with api.run(['python', '-'], input=source)"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _refuse_import(name: str) -> ImportError:
|
|
304
|
+
top = name.split(".")[0]
|
|
305
|
+
where = _IMPORT_REDIRECTS.get(top, _GENERAL_REDIRECT)
|
|
306
|
+
return ImportError(
|
|
307
|
+
f"Sandbox: `import {name}` is not available in shell code — {where}. "
|
|
308
|
+
"Pure standard-library modules import normally (json, re, math, "
|
|
309
|
+
"datetime, collections, itertools, base64, hashlib, textwrap, difflib, "
|
|
310
|
+
"…); api.man('sandbox') lists them all."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _load(name: str) -> Any:
|
|
315
|
+
"""The module `name`, imported on the api's authority if not yet loaded."""
|
|
316
|
+
if name == "os":
|
|
317
|
+
return _GuardedOS()
|
|
318
|
+
if name == "time":
|
|
319
|
+
return _GuardedTime()
|
|
320
|
+
module = sys.modules.get(name)
|
|
321
|
+
if module is None:
|
|
322
|
+
import importlib
|
|
323
|
+
# The audit hook refuses an `import` raised from user code; this one is
|
|
324
|
+
# the shell's own doing, on a name it has already vetted.
|
|
325
|
+
with api_scope():
|
|
326
|
+
module = importlib.import_module(name)
|
|
327
|
+
return module
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def sandboxed_import(
|
|
331
|
+
name: str,
|
|
332
|
+
globals: Any = None,
|
|
333
|
+
locals: Any = None,
|
|
334
|
+
fromlist: Any = (),
|
|
335
|
+
level: int = 0,
|
|
336
|
+
) -> Any:
|
|
337
|
+
"""`__import__` for agent code: the allowlist above, and nothing else.
|
|
338
|
+
|
|
339
|
+
Mirrors the builtin's return contract, which is what the import statement
|
|
340
|
+
relies on: `import a.b` binds `a`, so the top-level package comes back;
|
|
341
|
+
`from a.b import c` needs `a.b` itself, so the named module comes back.
|
|
342
|
+
"""
|
|
343
|
+
if level != 0:
|
|
344
|
+
raise _refuse_import("." * level + (name or ""))
|
|
345
|
+
if name not in IMPORTABLE_MODULES:
|
|
346
|
+
raise _refuse_import(name)
|
|
347
|
+
|
|
348
|
+
module = _load(name)
|
|
349
|
+
for item in fromlist or ():
|
|
350
|
+
sub = f"{name}.{item}"
|
|
351
|
+
if sub in IMPORTABLE_MODULES and name not in ("os", "time"):
|
|
352
|
+
_load(sub)
|
|
353
|
+
if fromlist:
|
|
354
|
+
return module
|
|
355
|
+
return _load(name.split(".")[0])
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@contextlib.contextmanager
|
|
359
|
+
def user_code_scope() -> Iterator[None]:
|
|
360
|
+
"""Mark the enclosed block as agent-authored code (strict denials)."""
|
|
361
|
+
token = _user_depth.set(_user_depth.get() + 1)
|
|
362
|
+
try:
|
|
363
|
+
yield
|
|
364
|
+
finally:
|
|
365
|
+
_user_depth.reset(token)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@contextlib.contextmanager
|
|
369
|
+
def api_scope() -> Iterator[None]:
|
|
370
|
+
"""Mark the enclosed block as a sanctioned StdlibAPI call."""
|
|
371
|
+
token = _api_depth.set(_api_depth.get() + 1)
|
|
372
|
+
try:
|
|
373
|
+
yield
|
|
374
|
+
finally:
|
|
375
|
+
_api_depth.reset(token)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_raw_user_code() -> bool:
|
|
379
|
+
"""True when agent code is touching the OS without going through api.*."""
|
|
380
|
+
return _user_depth.get() > 0 and _api_depth.get() == 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def configure(**kwargs: Any) -> None:
|
|
384
|
+
"""Update sandbox configuration. Call before install()."""
|
|
385
|
+
for k, v in kwargs.items():
|
|
386
|
+
if k in _config:
|
|
387
|
+
_config[k] = v
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def install() -> None:
|
|
391
|
+
"""Install the audit hook. Idempotent but irreversible."""
|
|
392
|
+
global _installed
|
|
393
|
+
if _installed:
|
|
394
|
+
return
|
|
395
|
+
_installed = True
|
|
396
|
+
sys.addaudithook(_audit_hook)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── One path dialect ───────────────────────────────────────────────────
|
|
400
|
+
#
|
|
401
|
+
# On Windows the shell speaks two. `api.bash` is Git Bash, where `pwd` prints
|
|
402
|
+
# `/c/Users/…`, `/tmp` is the user's temp directory and `$HOME` is
|
|
403
|
+
# `/c/Users/Andy`; every `api.*` path went through pathlib, where `/c/Users` is
|
|
404
|
+
# a rooted path on the current drive — `C:\c\Users` — and `/tmp` is `C:\tmp`.
|
|
405
|
+
# So a path copied from one half of the shell to the other pointed somewhere
|
|
406
|
+
# else: `api.write_file('/tmp/rr.sh')` succeeded and `bash /tmp/rr.sh` found
|
|
407
|
+
# nothing, and widget files were refused at `C:\c\Users\Andy\.widgex\…`.
|
|
408
|
+
# `~` was not expanded anywhere, and `os.path.expanduser` is not reachable
|
|
409
|
+
# from agent code, so a home-relative path had no spelling at all.
|
|
410
|
+
#
|
|
411
|
+
# The translation is Git for Windows' own mount table, which is fixed rather
|
|
412
|
+
# than configured: cygdrive at `/` (so `/c` is `C:`), `/tmp` is the user temp
|
|
413
|
+
# directory, `/bin` is `/usr/bin`, and the rest is under the install root.
|
|
414
|
+
# Derived rather than asked of `cygpath`, because asking means a subprocess and
|
|
415
|
+
# strict mode allows none.
|
|
416
|
+
|
|
417
|
+
_msys_root_finder: Any = None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def set_msys_root_finder(finder: Any) -> None:
|
|
421
|
+
"""Tell the sandbox how to find Git Bash's install root (stdlib_api does)."""
|
|
422
|
+
global _msys_root_finder
|
|
423
|
+
_msys_root_finder = finder
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _msys_root() -> str | None:
|
|
427
|
+
try:
|
|
428
|
+
return _msys_root_finder() if _msys_root_finder else None
|
|
429
|
+
except Exception:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def native_path(p: str) -> str:
|
|
434
|
+
"""`p` as this platform spells it: `~` expanded, and on Windows, MSYS paths
|
|
435
|
+
translated to the Windows paths Git Bash means by them.
|
|
436
|
+
|
|
437
|
+
Anything that is already native — `C:\\x`, `C:/x`, a relative path, a UNC
|
|
438
|
+
share — comes back unchanged.
|
|
439
|
+
"""
|
|
440
|
+
if not isinstance(p, str) or not p:
|
|
441
|
+
return p
|
|
442
|
+
if p == "~" or p.startswith(("~/", "~\\")):
|
|
443
|
+
p = str(Path.home()) + p[1:]
|
|
444
|
+
if sys.platform != "win32" or not p.startswith("/") or p.startswith("//"):
|
|
445
|
+
return p
|
|
446
|
+
|
|
447
|
+
parts = [s for s in p.split("/") if s]
|
|
448
|
+
if not parts:
|
|
449
|
+
root = _msys_root()
|
|
450
|
+
return root if root else p
|
|
451
|
+
head, rest = parts[0], parts[1:]
|
|
452
|
+
if len(head) == 1 and head.isalpha():
|
|
453
|
+
return f"{head.upper()}:\\" + "\\".join(rest)
|
|
454
|
+
if head == "tmp":
|
|
455
|
+
return str(Path(_USER_TEMP, *rest))
|
|
456
|
+
root = _msys_root()
|
|
457
|
+
if root is None:
|
|
458
|
+
raise FileNotFoundError(
|
|
459
|
+
f"{p!r} is a Git Bash path, and no Git Bash was found to say what it "
|
|
460
|
+
"means on Windows. Pass a Windows path such as C:/Users/... instead."
|
|
461
|
+
)
|
|
462
|
+
if head == "bin":
|
|
463
|
+
return str(Path(root, "usr", "bin", *rest))
|
|
464
|
+
return str(Path(root, *parts))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _resolve_path(p: str) -> str:
|
|
468
|
+
"""Resolve a path to absolute form against the configured cwd."""
|
|
469
|
+
# Never raises: this runs inside the audit hook, where an exception would
|
|
470
|
+
# refuse an open that the path rules might well allow.
|
|
471
|
+
try:
|
|
472
|
+
p = native_path(p)
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
path = Path(p)
|
|
476
|
+
if not path.is_absolute():
|
|
477
|
+
path = Path(_config["cwd"]) / path
|
|
478
|
+
try:
|
|
479
|
+
return str(path.resolve())
|
|
480
|
+
except (OSError, ValueError):
|
|
481
|
+
return str(path)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _contains(root: str, resolved: str) -> bool:
|
|
485
|
+
"""Whether `resolved` is `root` or sits beneath it.
|
|
486
|
+
|
|
487
|
+
Containment, not string prefix: `startswith` let `/repo/foobar` pass as if
|
|
488
|
+
it were inside `/repo/foo`.
|
|
489
|
+
"""
|
|
490
|
+
return resolved == root or resolved.startswith(root.rstrip(os.sep) + os.sep)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _writable_roots() -> list[str]:
|
|
494
|
+
"""Where a write is permitted, in the order the checks try them.
|
|
495
|
+
|
|
496
|
+
Exists so the refusal can name them. The old message ended with
|
|
497
|
+
`Mode: permissive`, which is true, is not the reason, and reads as a
|
|
498
|
+
contradiction to whoever was just refused by it — the mode is what allows
|
|
499
|
+
*reads* outside the cwd, and it has no bearing on the write that failed.
|
|
500
|
+
"""
|
|
501
|
+
roots = [str(Path(_config["cwd"]).resolve())]
|
|
502
|
+
roots.extend(_TMP_DIRS)
|
|
503
|
+
roots.extend(str(Path(p).resolve()) for p in _config["write_allow"])
|
|
504
|
+
roots.extend(_widget_dirs())
|
|
505
|
+
return roots
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _widget_dirs() -> list[str]:
|
|
509
|
+
"""Where authored widgets live: ``~/.widgex/widgets``.
|
|
510
|
+
|
|
511
|
+
`writing-a-widget` tells an author to save the widget there and the loader
|
|
512
|
+
reads nothing else, yet it is outside every workspace, so the write was
|
|
513
|
+
refused. Authors fell back to a bash heredoc, which corrupted at least one
|
|
514
|
+
file with nothing to say so. Resolved on each call, not at import, so a
|
|
515
|
+
changed HOME is honoured.
|
|
516
|
+
"""
|
|
517
|
+
d = _resolved(os.path.join(os.path.expanduser("~"), ".widgex", "widgets"))
|
|
518
|
+
return [d] if d else []
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _is_path_allowed(resolved: str, write: bool = False) -> bool:
|
|
522
|
+
"""Check if a resolved absolute path is within allowed prefixes."""
|
|
523
|
+
mode = _config["mode"]
|
|
524
|
+
|
|
525
|
+
# Open mode allows everything
|
|
526
|
+
if mode == "open":
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
# Always allow reading/writing within cwd — except under a fence the host
|
|
530
|
+
# could not name (`--fence ''`), which admits no folder at all.
|
|
531
|
+
if _config.get("fence") != "" and _contains(str(Path(_config["cwd"]).resolve()), resolved):
|
|
532
|
+
return True
|
|
533
|
+
|
|
534
|
+
# Always allow temp directories (cached to avoid re-entrant audit calls)
|
|
535
|
+
if any(_contains(root, resolved) for root in _TMP_DIRS):
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
# The authored-widgets directory is writable (and, being outside the cwd,
|
|
539
|
+
# would otherwise be readable only). See `_widget_dirs`.
|
|
540
|
+
if any(_contains(root, resolved) for root in _widget_dirs()):
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
# Always allow Python's own directories (stdlib, site-packages) for reads
|
|
544
|
+
if not write:
|
|
545
|
+
for prefix in _python_prefixes:
|
|
546
|
+
if resolved.startswith(prefix):
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
# Check explicit allow lists. Containment, as everywhere else here: these
|
|
550
|
+
# carry the folders a fenced session was allowed into, and `startswith` let
|
|
551
|
+
# an allowed `C:\x\foo` admit `C:\x\foobar`.
|
|
552
|
+
allow_list = _config["write_allow"] if write else _config["read_allow"]
|
|
553
|
+
for prefix in allow_list:
|
|
554
|
+
if _contains(str(Path(prefix).resolve()), resolved):
|
|
555
|
+
return True
|
|
556
|
+
if _fence_transient_allows(resolved, write):
|
|
557
|
+
return True
|
|
558
|
+
|
|
559
|
+
# Permissive mode: allow reads outside cwd, block writes
|
|
560
|
+
if mode == "permissive" and not write:
|
|
561
|
+
return True
|
|
562
|
+
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ── The folder fence ───────────────────────────────────────────────────
|
|
567
|
+
#
|
|
568
|
+
# A drive-channel task may use its folder freely and must ask for anything
|
|
569
|
+
# else. Two checks, and they draw the line in different places on purpose.
|
|
570
|
+
#
|
|
571
|
+
# `fence_allows` is the question asked about a path the *agent named* —
|
|
572
|
+
# `api.read_file(p)`, `api.glob(pattern, path)` — before the call runs. It
|
|
573
|
+
# admits the folder and what the user has allowed since, and nothing else: not
|
|
574
|
+
# the temp directory, not the widgets folder, since the agent choosing to read
|
|
575
|
+
# `/tmp/x` is exactly as much "outside" as choosing `C:/Users`.
|
|
576
|
+
#
|
|
577
|
+
# The audit hook is the backstop for what an api *body* opens on its own, and
|
|
578
|
+
# keeps the ordinary exemptions: `api.powershell` writes its script to a temp
|
|
579
|
+
# file, and refusing that would break the shell rather than fence anything.
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _fence_key(p: str) -> str:
|
|
583
|
+
return os.path.normcase(os.path.normpath(p))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def fenced() -> bool:
|
|
587
|
+
"""Whether this shell is fenced at all — including to a folder nobody could name."""
|
|
588
|
+
return _config.get("fence") is not None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def fence_root() -> str | None:
|
|
592
|
+
"""The folder this session is fenced to; '' when the host could not say
|
|
593
|
+
which (every path is asked about), None for an ordinary session."""
|
|
594
|
+
return _config.get("fence")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# Paths allowed for one api call only, as (resolved, write): what an allow
|
|
598
|
+
# grants when the folder it would cover is a drive root or the home folder.
|
|
599
|
+
# A ContextVar holding a list, set per call by the api wrapper, so a grant made
|
|
600
|
+
# while the call runs is seen by the rest of that call and by nothing after it.
|
|
601
|
+
_fence_transient: contextvars.ContextVar[list[tuple[str, bool]] | None] = contextvars.ContextVar(
|
|
602
|
+
"fence_transient", default=None)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@contextlib.contextmanager
|
|
606
|
+
def fence_call_scope() -> Iterator[None]:
|
|
607
|
+
"""One api call's lifetime for the grants `fence_grant` will not keep."""
|
|
608
|
+
token = _fence_transient.set([])
|
|
609
|
+
try:
|
|
610
|
+
yield
|
|
611
|
+
finally:
|
|
612
|
+
_fence_transient.reset(token)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _fence_transient_allows(resolved: str, write: bool) -> bool:
|
|
616
|
+
target = _fence_key(resolved)
|
|
617
|
+
for path, writes in _fence_transient.get() or ():
|
|
618
|
+
if (writes or not write) and _contains(_fence_key(path), target):
|
|
619
|
+
return True
|
|
620
|
+
return False
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def fence_allows(resolved: str, write: bool = False) -> bool:
|
|
624
|
+
"""Whether an agent-named path is inside the fence or a folder allowed since.
|
|
625
|
+
|
|
626
|
+
True for every path when there is no fence. Case-blind on Windows, like
|
|
627
|
+
the filesystem; `_contains` alone is not, and a path spelled `c:\\` against
|
|
628
|
+
a fence spelled `C:\\` would ask about the agent's own folder.
|
|
629
|
+
"""
|
|
630
|
+
if not fenced():
|
|
631
|
+
return True
|
|
632
|
+
root = fence_root()
|
|
633
|
+
target = _fence_key(resolved)
|
|
634
|
+
allowed = ([root] if root else []) + list(_config["write_allow"])
|
|
635
|
+
if not write:
|
|
636
|
+
allowed += _config["read_allow"]
|
|
637
|
+
for prefix in allowed:
|
|
638
|
+
try:
|
|
639
|
+
base = _fence_key(str(Path(prefix).resolve()))
|
|
640
|
+
except (OSError, ValueError):
|
|
641
|
+
base = _fence_key(prefix)
|
|
642
|
+
if _contains(base, target):
|
|
643
|
+
return True
|
|
644
|
+
return _fence_transient_allows(resolved, write)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _too_broad_to_keep(folder: str) -> bool:
|
|
648
|
+
"""A drive root or the home folder: an allow there covers the one call."""
|
|
649
|
+
resolved = _resolved(folder) or folder
|
|
650
|
+
if os.path.dirname(resolved) == resolved:
|
|
651
|
+
return True
|
|
652
|
+
home = _resolved(os.path.expanduser("~"))
|
|
653
|
+
return bool(home) and _fence_key(resolved) == _fence_key(home)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def fence_grant(path: str, folder: str, write: bool) -> None:
|
|
657
|
+
"""Remember what the user allowed.
|
|
658
|
+
|
|
659
|
+
The folder, for the rest of this shell's life — unless it is a drive root
|
|
660
|
+
or the home folder, where remembering it would take the fence down for
|
|
661
|
+
everything. Then only `path` is allowed, and only for the call in progress.
|
|
662
|
+
"""
|
|
663
|
+
if _too_broad_to_keep(folder):
|
|
664
|
+
transient = _fence_transient.get()
|
|
665
|
+
if transient is not None:
|
|
666
|
+
transient.append((_resolved(path) or path, write))
|
|
667
|
+
return
|
|
668
|
+
resolved = _resolved(folder) or folder
|
|
669
|
+
if resolved not in _config["read_allow"]:
|
|
670
|
+
_config["read_allow"].append(resolved)
|
|
671
|
+
if write and resolved not in _config["write_allow"]:
|
|
672
|
+
_config["write_allow"].append(resolved)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _audit_hook(event: str, args: tuple) -> None:
|
|
676
|
+
"""The audit hook — called by CPython on every security-sensitive op."""
|
|
677
|
+
global _in_hook
|
|
678
|
+
if _in_hook:
|
|
679
|
+
return # Prevent re-entrant calls (e.g. path resolution triggering open)
|
|
680
|
+
_in_hook = True
|
|
681
|
+
try:
|
|
682
|
+
_audit_hook_inner(event, args)
|
|
683
|
+
finally:
|
|
684
|
+
_in_hook = False
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _event_path(value: Any) -> str | None:
|
|
688
|
+
if isinstance(value, bytes):
|
|
689
|
+
value = value.decode(errors="replace")
|
|
690
|
+
if isinstance(value, os.PathLike):
|
|
691
|
+
value = os.fspath(value)
|
|
692
|
+
return _resolve_path(value) if isinstance(value, str) and value else None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _seal_event(event: str, args: tuple) -> None:
|
|
696
|
+
"""Seals, for a file an api body touches. Raises `seals.SealDenied`.
|
|
697
|
+
|
|
698
|
+
The backstop described in `seals.py`: `check_call` judges a call by its
|
|
699
|
+
arguments, and this judges what its body actually opened. Only api bodies
|
|
700
|
+
reach here — raw agent code is refused every file outright, above.
|
|
701
|
+
"""
|
|
702
|
+
seals = _seals
|
|
703
|
+
if not seals.backstop_active():
|
|
704
|
+
return
|
|
705
|
+
if event == "open":
|
|
706
|
+
path = _event_path(args[0] if args else None)
|
|
707
|
+
if not path:
|
|
708
|
+
return
|
|
709
|
+
file_mode = (args[1] if len(args) > 1 else "r") or "r"
|
|
710
|
+
writes = any(c in file_mode for c in "wxa+")
|
|
711
|
+
if "r" in file_mode or "+" in file_mode or not writes:
|
|
712
|
+
seals.check_event("R", path)
|
|
713
|
+
if writes:
|
|
714
|
+
seals.check_event("W", path)
|
|
715
|
+
elif event in ("os.remove", "os.unlink", "os.rmdir", "os.truncate"):
|
|
716
|
+
path = _event_path(args[0] if args else None)
|
|
717
|
+
if path:
|
|
718
|
+
seals.check_event("W", path)
|
|
719
|
+
elif event in ("os.rename", "os.replace"):
|
|
720
|
+
src = _event_path(args[0] if args else None)
|
|
721
|
+
dst = _event_path(args[1] if len(args) > 1 else None)
|
|
722
|
+
if src:
|
|
723
|
+
seals.check_event("M", src)
|
|
724
|
+
if dst:
|
|
725
|
+
seals.check_event("W", dst)
|
|
726
|
+
elif event in ("shutil.copyfile", "shutil.copytree"):
|
|
727
|
+
src = _event_path(args[0] if args else None)
|
|
728
|
+
dst = _event_path(args[1] if len(args) > 1 else None)
|
|
729
|
+
if src:
|
|
730
|
+
seals.check_event("C", src)
|
|
731
|
+
if dst:
|
|
732
|
+
seals.check_event("W", dst)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _audit_hook_inner(event: str, args: tuple) -> None:
|
|
736
|
+
"""Inner audit logic (called with re-entrancy guard held)."""
|
|
737
|
+
mode = _config["mode"]
|
|
738
|
+
|
|
739
|
+
# Seals first, and ahead of `open` mode: a seal is org policy, and no
|
|
740
|
+
# sandbox mode is a way around it.
|
|
741
|
+
if _api_depth.get() > 0 and not _is_raw_user_code():
|
|
742
|
+
_seal_event(event, args)
|
|
743
|
+
|
|
744
|
+
# Open mode: no restrictions (useful for trusted sessions)
|
|
745
|
+
if mode == "open":
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
# ── Direct OS access from agent code ──
|
|
749
|
+
# Enforced in every mode: the shell's contract is that the outside world
|
|
750
|
+
# is reached through api.*, so raw access is refused before the softer
|
|
751
|
+
# path/subprocess/network rules below get a say.
|
|
752
|
+
if _is_raw_user_code():
|
|
753
|
+
if event == "open":
|
|
754
|
+
path_arg = args[0] if args else ""
|
|
755
|
+
p = path_arg.decode() if isinstance(path_arg, bytes) else path_arg
|
|
756
|
+
raise PermissionError(
|
|
757
|
+
f"Sandbox: direct file access is not allowed from shell code ({p!r}). "
|
|
758
|
+
f"Use api.read_file() / api.write_file() / api.edit_file() instead."
|
|
759
|
+
)
|
|
760
|
+
if event == "import":
|
|
761
|
+
module_name = args[0] if args else ""
|
|
762
|
+
raise ImportError(
|
|
763
|
+
f"Sandbox: importing {module_name!r} is not allowed from shell code. "
|
|
764
|
+
f"{_USE_API}"
|
|
765
|
+
)
|
|
766
|
+
if event.startswith(_OS_EVENT_PREFIXES) or event == "exec":
|
|
767
|
+
raise PermissionError(
|
|
768
|
+
f"Sandbox: direct OS access is not allowed from shell code "
|
|
769
|
+
f"(attempted {event!r}). {_USE_API}"
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# ── File access ──
|
|
773
|
+
if event == "open":
|
|
774
|
+
path_arg, file_mode, _flags = args
|
|
775
|
+
if isinstance(path_arg, (str, bytes)):
|
|
776
|
+
p = path_arg.decode() if isinstance(path_arg, bytes) else path_arg
|
|
777
|
+
resolved = _resolve_path(p)
|
|
778
|
+
is_write = any(c in (file_mode or "r") for c in "wxa+")
|
|
779
|
+
if not _is_path_allowed(resolved, write=is_write):
|
|
780
|
+
op = "write" if is_write else "read"
|
|
781
|
+
# Name the roots, not the mode. `Mode: permissive` was true and
|
|
782
|
+
# actively misleading: permissive is what allows reads outside
|
|
783
|
+
# the cwd and says nothing about writes, so a refusal that ends
|
|
784
|
+
# with it reads as a contradiction and sends the reader looking
|
|
785
|
+
# at configuration instead of at the path.
|
|
786
|
+
where = (
|
|
787
|
+
" Writable: " + ", ".join(_writable_roots()) + ". A file "
|
|
788
|
+
"elsewhere is changed by a command (api.powershell or "
|
|
789
|
+
"api.bash), which asks the user first; parse what you "
|
|
790
|
+
"wrote afterwards, since an invalid settings or config "
|
|
791
|
+
"file stops its app saving anything."
|
|
792
|
+
if is_write else
|
|
793
|
+
f" Mode: {mode}."
|
|
794
|
+
)
|
|
795
|
+
if fenced():
|
|
796
|
+
# Raised as its own class so the api wrapper asks the user
|
|
797
|
+
# about this path rather than failing with no card.
|
|
798
|
+
raise FenceDenied(
|
|
799
|
+
resolved, is_write,
|
|
800
|
+
f"Sandbox: {p!r} (resolved: {resolved}) is outside this "
|
|
801
|
+
f"task's folder, {fence_root() or '(unknown)'}.",
|
|
802
|
+
)
|
|
803
|
+
raise PermissionError(
|
|
804
|
+
f"Sandbox: {op} access denied for {p!r} "
|
|
805
|
+
f"(resolved: {resolved}).{where}"
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
# ── Subprocess execution ──
|
|
809
|
+
elif event in ("subprocess.Popen", "os.system"):
|
|
810
|
+
if not _config["allow_subprocess"]:
|
|
811
|
+
raise PermissionError(
|
|
812
|
+
f"Sandbox: subprocess execution denied. "
|
|
813
|
+
f"Event: {event}, Mode: {mode}"
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
elif event.startswith("os.exec") or event.startswith("os.spawn"):
|
|
817
|
+
if not _config["allow_subprocess"]:
|
|
818
|
+
raise PermissionError(
|
|
819
|
+
f"Sandbox: exec/spawn denied. Event: {event}, Mode: {mode}"
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
# ── Network access ──
|
|
823
|
+
elif event in ("socket.connect", "socket.bind", "socket.sendto"):
|
|
824
|
+
if not _config["allow_network"]:
|
|
825
|
+
# Allow asyncio's internal socketpair (127.0.0.1 loopback for self-pipe)
|
|
826
|
+
if event == "socket.bind" and args:
|
|
827
|
+
addr = args[1] if len(args) > 1 else args[0]
|
|
828
|
+
if isinstance(addr, tuple) and addr[0] in ("127.0.0.1", "::1", ""):
|
|
829
|
+
return # asyncio internal
|
|
830
|
+
if event == "socket.connect" and args:
|
|
831
|
+
addr = args[1] if len(args) > 1 else args[0]
|
|
832
|
+
if isinstance(addr, tuple) and addr[0] in ("127.0.0.1", "::1"):
|
|
833
|
+
return # asyncio internal / localhost
|
|
834
|
+
raise PermissionError(
|
|
835
|
+
f"Sandbox: network access denied. "
|
|
836
|
+
f"Event: {event}, Mode: {mode}"
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
# ── Dangerous builtins ──
|
|
840
|
+
elif event == "compile" and mode == "strict":
|
|
841
|
+
# In strict mode, only allow compilation from our own exec wrapper
|
|
842
|
+
source = args[0] if args else ""
|
|
843
|
+
if isinstance(source, str) and "<xactor-exec>" not in source:
|
|
844
|
+
pass # Allow — can't easily distinguish in strict mode
|
|
845
|
+
|
|
846
|
+
# ── Module imports ──
|
|
847
|
+
elif event == "import":
|
|
848
|
+
module_name = args[0] if args else ""
|
|
849
|
+
if isinstance(module_name, str) and module_name in _config["blocked_modules"]:
|
|
850
|
+
raise ImportError(
|
|
851
|
+
f"Sandbox: import of {module_name!r} is blocked"
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# ── ctypes / native code loading ──
|
|
855
|
+
elif event == "ctypes.dlopen":
|
|
856
|
+
raise PermissionError("Sandbox: loading native libraries via ctypes is blocked")
|