@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.
Files changed (131) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4685 -0
  4. package/package.json +30 -4
  5. package/provider-clis.json +1211 -0
  6. package/python/XACTOR.md +146 -0
  7. package/python/browser_actor_host.py +456 -0
  8. package/python/cdp_page.py +363 -0
  9. package/python/harness_check.py +77 -0
  10. package/python/requirements.txt +1 -0
  11. package/python/test_line_endings.py +185 -0
  12. package/python/xactor/__init__.py +18 -0
  13. package/python/xactor/__main__.py +226 -0
  14. package/python/xactor/api.py +882 -0
  15. package/python/xactor/bridge.py +784 -0
  16. package/python/xactor/dom.py +117 -0
  17. package/python/xactor/man/add_card.md +89 -0
  18. package/python/xactor/man/agent-vs-fork.md +108 -0
  19. package/python/xactor/man/agent.md +333 -0
  20. package/python/xactor/man/ask_model.md +110 -0
  21. package/python/xactor/man/ask_reference.md +56 -0
  22. package/python/xactor/man/ask_user.md +103 -0
  23. package/python/xactor/man/attach_app.md +595 -0
  24. package/python/xactor/man/attach_ide.md +65 -0
  25. package/python/xactor/man/attach_ide_surface.md +31 -0
  26. package/python/xactor/man/await_user.md +214 -0
  27. package/python/xactor/man/bash.md +142 -0
  28. package/python/xactor/man/browser-driver.md +137 -0
  29. package/python/xactor/man/browser_activity.md +53 -0
  30. package/python/xactor/man/browser_attach.md +84 -0
  31. package/python/xactor/man/browser_close_tab.md +50 -0
  32. package/python/xactor/man/browser_detach.md +50 -0
  33. package/python/xactor/man/browser_exec.md +66 -0
  34. package/python/xactor/man/browser_focus_tab.md +47 -0
  35. package/python/xactor/man/browser_hybrid.md +47 -0
  36. package/python/xactor/man/browser_open.md +50 -0
  37. package/python/xactor/man/browser_open_tab.md +58 -0
  38. package/python/xactor/man/browser_plumbing.md +107 -0
  39. package/python/xactor/man/browser_screenshot.md +56 -0
  40. package/python/xactor/man/browser_snapshot.md +52 -0
  41. package/python/xactor/man/browser_tabs.md +67 -0
  42. package/python/xactor/man/bulletin.md +88 -0
  43. package/python/xactor/man/capture_page.md +54 -0
  44. package/python/xactor/man/cd.md +64 -0
  45. package/python/xactor/man/chess_move.md +53 -0
  46. package/python/xactor/man/children.md +89 -0
  47. package/python/xactor/man/close_task.md +14 -0
  48. package/python/xactor/man/compact.md +75 -0
  49. package/python/xactor/man/drive.md +140 -0
  50. package/python/xactor/man/drive_space.md +72 -0
  51. package/python/xactor/man/driving.md +168 -0
  52. package/python/xactor/man/edit_file.md +112 -0
  53. package/python/xactor/man/finding.md +194 -0
  54. package/python/xactor/man/fork_agent.md +45 -0
  55. package/python/xactor/man/glob.md +83 -0
  56. package/python/xactor/man/grep.md +115 -0
  57. package/python/xactor/man/harness.md +165 -0
  58. package/python/xactor/man/harness_check.md +70 -0
  59. package/python/xactor/man/harness_format.md +257 -0
  60. package/python/xactor/man/highlight.md +66 -0
  61. package/python/xactor/man/instruct.md +111 -0
  62. package/python/xactor/man/ls.md +63 -0
  63. package/python/xactor/man/man.md +86 -0
  64. package/python/xactor/man/merge_wiz.md +40 -0
  65. package/python/xactor/man/notebook.md +98 -0
  66. package/python/xactor/man/overview.md +89 -0
  67. package/python/xactor/man/permissions.md +137 -0
  68. package/python/xactor/man/phase.md +150 -0
  69. package/python/xactor/man/powershell.md +74 -0
  70. package/python/xactor/man/profiles.md +60 -0
  71. package/python/xactor/man/project_drive.md +49 -0
  72. package/python/xactor/man/read_bulletin.md +89 -0
  73. package/python/xactor/man/read_file.md +90 -0
  74. package/python/xactor/man/relay.md +216 -0
  75. package/python/xactor/man/reply.md +72 -0
  76. package/python/xactor/man/reply_message.md +78 -0
  77. package/python/xactor/man/report_bug.md +118 -0
  78. package/python/xactor/man/request_answer.md +142 -0
  79. package/python/xactor/man/request_merge_decisions.md +121 -0
  80. package/python/xactor/man/request_operator_action.md +152 -0
  81. package/python/xactor/man/request_review.md +122 -0
  82. package/python/xactor/man/request_user_action.md +126 -0
  83. package/python/xactor/man/return_card.md +71 -0
  84. package/python/xactor/man/run.md +99 -0
  85. package/python/xactor/man/sandbox.md +144 -0
  86. package/python/xactor/man/session_fork.md +74 -0
  87. package/python/xactor/man/session_harness.md +109 -0
  88. package/python/xactor/man/session_join.md +102 -0
  89. package/python/xactor/man/session_land.md +73 -0
  90. package/python/xactor/man/session_nudge.md +73 -0
  91. package/python/xactor/man/session_rest.md +73 -0
  92. package/python/xactor/man/session_scope.md +84 -0
  93. package/python/xactor/man/session_terminate.md +58 -0
  94. package/python/xactor/man/session_token.md +78 -0
  95. package/python/xactor/man/sessions.md +93 -0
  96. package/python/xactor/man/set_harness.md +15 -0
  97. package/python/xactor/man/spawn_swarm.md +80 -0
  98. package/python/xactor/man/submit_evidence.md +54 -0
  99. package/python/xactor/man/swarm_brief.md +30 -0
  100. package/python/xactor/man/task_status.md +82 -0
  101. package/python/xactor/man/tasks.md +47 -0
  102. package/python/xactor/man/todo_write.md +104 -0
  103. package/python/xactor/man/update_card.md +70 -0
  104. package/python/xactor/man/use_profile.md +90 -0
  105. package/python/xactor/man/use_widget.md +82 -0
  106. package/python/xactor/man/wait_for_task.md +105 -0
  107. package/python/xactor/man/widget-model.md +284 -0
  108. package/python/xactor/man/widgets.md +59 -0
  109. package/python/xactor/man/write_file.md +76 -0
  110. package/python/xactor/man/writing-a-widget.md +142 -0
  111. package/python/xactor/man/xylon.md +120 -0
  112. package/python/xactor/man/xylon_blame.md +80 -0
  113. package/python/xactor/man/xylon_read.md +131 -0
  114. package/python/xactor/man/xylon_search.md +125 -0
  115. package/python/xactor/manual.py +195 -0
  116. package/python/xactor/parent_watch.py +115 -0
  117. package/python/xactor/plan_mode.py +350 -0
  118. package/python/xactor/sandbox.py +856 -0
  119. package/python/xactor/seals.py +438 -0
  120. package/python/xactor/shell_hints.py +210 -0
  121. package/python/xactor/states/__init__.py +13 -0
  122. package/python/xactor/states/base.py +523 -0
  123. package/python/xactor/states/registry.py +274 -0
  124. package/python/xactor/stdlib_api.py +6345 -0
  125. package/python/xactor/widgets/__init__.py +1 -0
  126. package/python/xactor/widgets/github_tokens.py +314 -0
  127. package/python/xactor/widgets/google_aistudio.py +365 -0
  128. package/settings-schema.json +403 -0
  129. package/viewer/index.css +160 -0
  130. package/viewer/index.js +15 -0
  131. 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")