@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,438 @@
|
|
|
1
|
+
"""Offline WidgeX marker enforcement for Python-only file operations.
|
|
2
|
+
|
|
3
|
+
The shell owns this implementation because reads and searches never cross the
|
|
4
|
+
TypeScript bridge. A bridge lookup would both add an RPC per read and disappear
|
|
5
|
+
when permissions are bypassed, precisely where an absolute organisational rule
|
|
6
|
+
must remain present.
|
|
7
|
+
|
|
8
|
+
Three layers live here: `check_call` judges an api call by its arguments (the
|
|
9
|
+
file tools, plus `drive`/`navigate`/`browser_open_tab` URLs and `aux_context`);
|
|
10
|
+
`check_event` is the audit hook's backstop for every file any other api body
|
|
11
|
+
opens; and the index remembers every seal it has seen, so a rewrite that drops
|
|
12
|
+
one does not unseal the file. Shell *commands* are judged in TypeScript
|
|
13
|
+
(`sealGate.ts`), which is where the permission request for them goes, and
|
|
14
|
+
their output is screened there too (`sealScreen.ts`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import contextvars
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Iterator
|
|
25
|
+
|
|
26
|
+
_PREFIX = "WGX" + "SEAL"
|
|
27
|
+
SEAL_DOC_DIRECTIVE = _PREFIX + "-DOC"
|
|
28
|
+
SEAL_OP_ORDER = "RWXCM"
|
|
29
|
+
_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
30
|
+
_CHUNK_DOMAIN = _PREFIX + "/v1/model\n"
|
|
31
|
+
_CHUNK_BYTES = 5
|
|
32
|
+
_CHUNK_SYMBOLS = 8
|
|
33
|
+
_MAX_SEAL_BYTES = 7 + 1 + 5 + 1 + (64 * 8) + 63
|
|
34
|
+
_SCAN_CHUNK_BYTES = 256 * 1024
|
|
35
|
+
_OVERLAP_BYTES = _MAX_SEAL_BYTES
|
|
36
|
+
_HUGE_FILE_BYTES = 64 * 1024 * 1024
|
|
37
|
+
_HUGE_HEAD_BYTES = 8 * 1024 * 1024
|
|
38
|
+
_HUGE_TAIL_BYTES = 1 * 1024 * 1024
|
|
39
|
+
_RECENT_WRITE_SECONDS = 2
|
|
40
|
+
_MAX_ENTRIES = 5000
|
|
41
|
+
_CROCKFORD_CLASS = "[0-9A-HJKMNP-TV-Z]"
|
|
42
|
+
|
|
43
|
+
# Assembly keeps this engine's grammar from becoming a live marker if the
|
|
44
|
+
# quarantine directive is ever damaged. The scanner must not make its own
|
|
45
|
+
# enforcement source uneditable merely because it explains the syntax.
|
|
46
|
+
_SEAL_RE = re.compile(
|
|
47
|
+
rf"(?<![0-9A-Za-z]){_PREFIX}-([{SEAL_OP_ORDER}]{{1,5}})"
|
|
48
|
+
rf"(?:-({_CROCKFORD_CLASS}{{{_CHUNK_SYMBOLS}}}(?:-{_CROCKFORD_CLASS}{{{_CHUNK_SYMBOLS}}}){{0,63}}))?"
|
|
49
|
+
rf"(?![-0-9A-Za-z])"
|
|
50
|
+
)
|
|
51
|
+
_DOC_RE = re.compile(rf"(?<![0-9A-Za-z]){SEAL_DOC_DIRECTIVE}(?![-0-9A-Za-z])")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ParsedSeal:
|
|
56
|
+
raw: str
|
|
57
|
+
source: str
|
|
58
|
+
index: int
|
|
59
|
+
denied_ops: frozenset[str]
|
|
60
|
+
chunks: tuple[str, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def has_seal_doc_directive(text: str) -> bool:
|
|
64
|
+
return bool(_DOC_RE.search(text))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize(ops: Any) -> list[str]:
|
|
68
|
+
seen: set[str] = set()
|
|
69
|
+
for raw in ops:
|
|
70
|
+
for char in str(raw).upper():
|
|
71
|
+
if char in SEAL_OP_ORDER:
|
|
72
|
+
seen.add(char)
|
|
73
|
+
return [op for op in SEAL_OP_ORDER if op in seen]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def model_chunk(key: str) -> str:
|
|
77
|
+
digest = hashlib.sha256((_CHUNK_DOMAIN + key).encode("utf-8")).digest()[:_CHUNK_BYTES]
|
|
78
|
+
value = 0
|
|
79
|
+
bits = 0
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
for byte in digest:
|
|
82
|
+
value = (value << 8) | byte
|
|
83
|
+
bits += 8
|
|
84
|
+
while bits >= 5:
|
|
85
|
+
out.append(_CROCKFORD[(value >> (bits - 5)) & 31])
|
|
86
|
+
bits -= 5
|
|
87
|
+
if bits:
|
|
88
|
+
out.append(_CROCKFORD[(value << (5 - bits)) & 31])
|
|
89
|
+
return "".join(out)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _find(text: str, source: str) -> list[ParsedSeal]:
|
|
93
|
+
if not text or _PREFIX not in text or has_seal_doc_directive(text):
|
|
94
|
+
return []
|
|
95
|
+
return [ParsedSeal(match.group(0), source, match.start(), frozenset(normalize([match.group(1)])),
|
|
96
|
+
tuple(match.group(2).split("-")) if match.group(2) else ())
|
|
97
|
+
for match in _SEAL_RE.finditer(text)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_seals_in_text(text: str) -> list[ParsedSeal]:
|
|
101
|
+
return _find(text, "text")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_seals_in_path(file_path: str) -> list[ParsedSeal]:
|
|
105
|
+
return _find(str(file_path).replace("\\", "/"), "path")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def seal_candidate_keys(model: Any) -> list[str]:
|
|
109
|
+
if isinstance(model, dict):
|
|
110
|
+
provider, model_id = str(model.get("providerId", "")), str(model.get("modelId", ""))
|
|
111
|
+
elif isinstance(model, (tuple, list)) and len(model) == 2:
|
|
112
|
+
provider, model_id = str(model[0]), str(model[1])
|
|
113
|
+
else:
|
|
114
|
+
value = str(model)
|
|
115
|
+
provider, sep, model_id = value.partition("/")
|
|
116
|
+
if not sep:
|
|
117
|
+
provider, model_id = "", value
|
|
118
|
+
bare = re.sub(r"-(?:\d{4}-\d{2}-\d{2}|\d{8})$", "", model_id)
|
|
119
|
+
return list(dict.fromkeys([f"{provider}/{model_id}", model_id, f"{provider}/{bare}", bare, f"{provider}/*"]))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def seal_applies_to(seal: ParsedSeal, model: Any) -> bool:
|
|
123
|
+
return not seal.chunks or bool(set(seal.chunks) & {model_chunk(key) for key in seal_candidate_keys(model)})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def denied_ops_for(seals: list[ParsedSeal], model: Any) -> set[str]:
|
|
127
|
+
denied: set[str] = set()
|
|
128
|
+
for seal in seals:
|
|
129
|
+
if seal_applies_to(seal, model):
|
|
130
|
+
denied.update(seal.denied_ops)
|
|
131
|
+
return denied
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _true_case(path: str) -> str:
|
|
135
|
+
"""`path` with each existing component spelled as the filesystem spells it.
|
|
136
|
+
|
|
137
|
+
Only matters where names are case-insensitive: there a lowercased spelling
|
|
138
|
+
of a sealed folder's name lands inside that folder, and only the real,
|
|
139
|
+
uppercase spelling parses as a seal. `realpath` does not correct case on
|
|
140
|
+
macOS, so each component is matched against its directory there. Windows'
|
|
141
|
+
realpath already answers with the real spelling of the longest existing
|
|
142
|
+
prefix, and elsewhere names are case-sensitive.
|
|
143
|
+
"""
|
|
144
|
+
if os.sys.platform != "darwin":
|
|
145
|
+
return path
|
|
146
|
+
drive, rest = os.path.splitdrive(path)
|
|
147
|
+
parts = [p for p in re.split(r"[\\/]+", rest) if p]
|
|
148
|
+
current = drive + os.sep if (drive or rest.startswith(("/", "\\"))) else ""
|
|
149
|
+
for i, part in enumerate(parts):
|
|
150
|
+
try:
|
|
151
|
+
names = os.listdir(current or ".")
|
|
152
|
+
except OSError:
|
|
153
|
+
return os.path.join(current, *parts[i:]) if current else path
|
|
154
|
+
match = next((n for n in names if n.lower() == part.lower()), None)
|
|
155
|
+
current = os.path.join(current, match if match is not None else part)
|
|
156
|
+
if match is None:
|
|
157
|
+
return os.path.join(current, *parts[i + 1:])
|
|
158
|
+
return current or path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class _CacheEntry:
|
|
163
|
+
mtime_ns: int
|
|
164
|
+
ctime_ns: int
|
|
165
|
+
size: int
|
|
166
|
+
marks: list[ParsedSeal]
|
|
167
|
+
truncated: bool
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _Index:
|
|
171
|
+
def __init__(self) -> None:
|
|
172
|
+
self.cache: dict[str, _CacheEntry] = {}
|
|
173
|
+
# Every body seal ever seen on a path. A rescan that finds fewer keeps
|
|
174
|
+
# the missing ones: `sed -i` on the marker line, or appending the DOC
|
|
175
|
+
# directive, must not unseal a file. Mirrors `SealIndex.seen` in TS.
|
|
176
|
+
self.seen: dict[str, list[ParsedSeal]] = {}
|
|
177
|
+
|
|
178
|
+
def _remember(self, key: str, marks: list[ParsedSeal]) -> list[ParsedSeal]:
|
|
179
|
+
before = self.seen.get(key, [])
|
|
180
|
+
if not before and not marks:
|
|
181
|
+
return marks
|
|
182
|
+
now = {mark.raw for mark in marks}
|
|
183
|
+
kept = [mark for mark in before if mark.raw not in now]
|
|
184
|
+
merged = [*marks, *kept]
|
|
185
|
+
self.seen[key] = merged
|
|
186
|
+
return merged
|
|
187
|
+
|
|
188
|
+
def forget(self, file_path: str | None = None) -> None:
|
|
189
|
+
if file_path is None:
|
|
190
|
+
self.cache.clear()
|
|
191
|
+
self.seen.clear()
|
|
192
|
+
return
|
|
193
|
+
resolved = os.path.abspath(file_path)
|
|
194
|
+
key = os.path.normcase(resolved) if os.name == "nt" or os.sys.platform == "darwin" else resolved
|
|
195
|
+
self.cache.pop(key, None)
|
|
196
|
+
self.seen.pop(key, None)
|
|
197
|
+
|
|
198
|
+
def _path_marks(self, path: str) -> list[ParsedSeal]:
|
|
199
|
+
marks = find_seals_in_path(path)
|
|
200
|
+
try:
|
|
201
|
+
real = _true_case(os.path.realpath(path))
|
|
202
|
+
except OSError:
|
|
203
|
+
real = path
|
|
204
|
+
if real == path:
|
|
205
|
+
return marks
|
|
206
|
+
seen = {mark.raw for mark in marks}
|
|
207
|
+
marks.extend(mark for mark in find_seals_in_path(real) if mark.raw not in seen)
|
|
208
|
+
return marks
|
|
209
|
+
|
|
210
|
+
def _scan(self, path: str, size: int) -> tuple[list[ParsedSeal], bool]:
|
|
211
|
+
huge = size > _HUGE_FILE_BYTES
|
|
212
|
+
marks: list[ParsedSeal] = []
|
|
213
|
+
seen: set[str] = set()
|
|
214
|
+
quarantined = False
|
|
215
|
+
try:
|
|
216
|
+
with open(path, "rb") as file:
|
|
217
|
+
def scan_span(start: int, end: int) -> None:
|
|
218
|
+
nonlocal quarantined
|
|
219
|
+
file.seek(start)
|
|
220
|
+
carry = b""
|
|
221
|
+
while file.tell() < end:
|
|
222
|
+
data = file.read(min(_SCAN_CHUNK_BYTES, end - file.tell()))
|
|
223
|
+
if not data:
|
|
224
|
+
break
|
|
225
|
+
text = (carry + data).decode("latin1")
|
|
226
|
+
quarantined = quarantined or has_seal_doc_directive(text)
|
|
227
|
+
for mark in find_seals_in_text(text):
|
|
228
|
+
if mark.raw not in seen:
|
|
229
|
+
seen.add(mark.raw)
|
|
230
|
+
marks.append(mark)
|
|
231
|
+
carry = (carry + data)[-_OVERLAP_BYTES:]
|
|
232
|
+
if huge:
|
|
233
|
+
scan_span(0, _HUGE_HEAD_BYTES)
|
|
234
|
+
scan_span(max(_HUGE_HEAD_BYTES, size - _HUGE_TAIL_BYTES), size)
|
|
235
|
+
else:
|
|
236
|
+
scan_span(0, size)
|
|
237
|
+
except OSError:
|
|
238
|
+
return [], False
|
|
239
|
+
return ([] if quarantined else marks), huge
|
|
240
|
+
|
|
241
|
+
def marks_for_path(self, file_path: str) -> tuple[list[ParsedSeal], list[ParsedSeal], bool]:
|
|
242
|
+
resolved = os.path.abspath(file_path)
|
|
243
|
+
path_marks = self._path_marks(resolved)
|
|
244
|
+
try:
|
|
245
|
+
stat = os.stat(resolved)
|
|
246
|
+
except OSError:
|
|
247
|
+
return path_marks, [], False
|
|
248
|
+
if not os.path.isfile(resolved):
|
|
249
|
+
return path_marks, [], False
|
|
250
|
+
key = os.path.normcase(resolved) if os.name == "nt" or os.sys.platform == "darwin" else resolved
|
|
251
|
+
hit = self.cache.get(key)
|
|
252
|
+
if (hit and hit.mtime_ns == stat.st_mtime_ns and hit.ctime_ns == stat.st_ctime_ns
|
|
253
|
+
and hit.size == stat.st_size and time.time() - stat.st_mtime > _RECENT_WRITE_SECONDS):
|
|
254
|
+
return path_marks, hit.marks, hit.truncated
|
|
255
|
+
marks, truncated = self._scan(resolved, stat.st_size)
|
|
256
|
+
marks = self._remember(key, marks)
|
|
257
|
+
self.cache.pop(key, None)
|
|
258
|
+
self.cache[key] = _CacheEntry(stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size, marks, truncated)
|
|
259
|
+
while len(self.cache) > _MAX_ENTRIES:
|
|
260
|
+
self.cache.pop(next(iter(self.cache)))
|
|
261
|
+
return path_marks, marks, truncated
|
|
262
|
+
|
|
263
|
+
def all_marks(self, file_path: str) -> list[ParsedSeal]:
|
|
264
|
+
path, content, _ = self.marks_for_path(file_path)
|
|
265
|
+
return [*path, *content]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_INDEX = _Index()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class SealDenied(PermissionError):
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _model_name(model_id: Any) -> str:
|
|
276
|
+
if isinstance(model_id, dict):
|
|
277
|
+
return f"{model_id.get('providerId', '')}/{model_id.get('modelId', '')}"
|
|
278
|
+
return str(model_id) if model_id else "an unidentified model"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def check_path(op: str, path: str, model_id: Any) -> None:
|
|
282
|
+
marks = _INDEX.all_marks(path)
|
|
283
|
+
if not marks:
|
|
284
|
+
return
|
|
285
|
+
denied = denied_ops_for(marks, model_id) if model_id else {op for mark in marks for op in mark.denied_ops}
|
|
286
|
+
if op in denied:
|
|
287
|
+
culprit = next((mark for mark in marks if op in mark.denied_ops), marks[0])
|
|
288
|
+
raise SealDenied(f"WidgeX Seal — `{culprit.raw}` on `{path}` forbids {op} for `{_model_name(model_id)}`. "
|
|
289
|
+
"This is absolute: no permission mode bypasses it and no approval unlocks it.")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def forget(file_path: str | None = None) -> None:
|
|
293
|
+
"""Drop what is remembered about one path, or all of them. Re-scan seals."""
|
|
294
|
+
_INDEX.forget(file_path)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── The backstop: every file an api body opens ──────────────────────────
|
|
298
|
+
#
|
|
299
|
+
# `check_call` judges a call by its arguments, which works for the four calls
|
|
300
|
+
# whose arguments *are* the file. Everything else that reads a file on the
|
|
301
|
+
# caller's behalf — an aux_context loader, an importer, a capture — named its
|
|
302
|
+
# path in some other argument or none at all, and was never checked. So the
|
|
303
|
+
# audit hook asks here about every `open`, `os.remove`, `os.rename` and
|
|
304
|
+
# `shutil.copyfile` an api body performs, judged against the model the exec
|
|
305
|
+
# was stamped with.
|
|
306
|
+
#
|
|
307
|
+
# The four that check themselves are exempt, because they legitimately open
|
|
308
|
+
# files the seal forbids *returning*: `write_file` reads the old bytes to judge
|
|
309
|
+
# integrity, `edit_file` has to read what it edits, and `grep` skips sealed
|
|
310
|
+
# files after looking at them. Their own checks are the stricter ones.
|
|
311
|
+
|
|
312
|
+
SELF_CHECKED = frozenset({"read_file", "write_file", "edit_file", "grep", "glob", "ls"})
|
|
313
|
+
|
|
314
|
+
_acting_model: contextvars.ContextVar[Any] = contextvars.ContextVar("seal_acting_model", default=None)
|
|
315
|
+
_backstop: contextvars.ContextVar[bool] = contextvars.ContextVar("seal_backstop", default=False)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@contextlib.contextmanager
|
|
319
|
+
def acting(model_id: Any, backstop: bool) -> Iterator[None]:
|
|
320
|
+
"""Run an api body as `model_id`, with or without the open() backstop."""
|
|
321
|
+
model_token = _acting_model.set(model_id)
|
|
322
|
+
backstop_token = _backstop.set(backstop)
|
|
323
|
+
try:
|
|
324
|
+
yield
|
|
325
|
+
finally:
|
|
326
|
+
_backstop.reset(backstop_token)
|
|
327
|
+
_acting_model.reset(model_token)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@contextlib.contextmanager
|
|
331
|
+
def internal() -> Iterator[None]:
|
|
332
|
+
"""Opens the shell makes for itself, not for the caller — exempt."""
|
|
333
|
+
token = _backstop.set(False)
|
|
334
|
+
try:
|
|
335
|
+
yield
|
|
336
|
+
finally:
|
|
337
|
+
_backstop.reset(token)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def backstop_active() -> bool:
|
|
341
|
+
return _backstop.get()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def check_event(op: str, path: str) -> None:
|
|
345
|
+
"""The audit hook's question: may this api body do `op` to `path`?
|
|
346
|
+
|
|
347
|
+
Raises `SealDenied`. Called from inside the hook with its re-entrancy guard
|
|
348
|
+
held, so the scan's own `open` calls are not audited back into here.
|
|
349
|
+
"""
|
|
350
|
+
if not _backstop.get():
|
|
351
|
+
return
|
|
352
|
+
check_path(op, path, _acting_model.get())
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _integrity(before: str, after: str) -> ParsedSeal | None:
|
|
356
|
+
had = find_seals_in_text(before)
|
|
357
|
+
kept = {seal.raw for seal in find_seals_in_text(after)}
|
|
358
|
+
return next((seal for seal in had if seal.raw not in kept), None)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def local_path_of_url(url: Any) -> str | None:
|
|
362
|
+
"""The file a `file://` URL or a bare absolute path names, or None.
|
|
363
|
+
|
|
364
|
+
`api.drive` accepts both, and a browser opened on a sealed file renders its
|
|
365
|
+
bytes into a page every page verb can read back.
|
|
366
|
+
"""
|
|
367
|
+
if not isinstance(url, str) or not url.strip():
|
|
368
|
+
return None
|
|
369
|
+
text = url.strip()
|
|
370
|
+
# Every spelling Chromium accepts: file:///x, file:/x, and view-source: of either.
|
|
371
|
+
if text.lower().startswith("view-source:"):
|
|
372
|
+
text = text[len("view-source:"):]
|
|
373
|
+
if text.lower().startswith("file:"):
|
|
374
|
+
from urllib.parse import unquote, urlparse
|
|
375
|
+
parsed = urlparse(text)
|
|
376
|
+
path = unquote(parsed.path)
|
|
377
|
+
if parsed.netloc and parsed.netloc.lower() != "localhost":
|
|
378
|
+
path = f"//{parsed.netloc}{path}" # a UNC share
|
|
379
|
+
if re.match(r"^/[A-Za-z]:", path):
|
|
380
|
+
path = path[1:]
|
|
381
|
+
return path or None
|
|
382
|
+
if os.path.isabs(text):
|
|
383
|
+
return text
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Calls that hand a file to something that will show it to a model, under an
|
|
388
|
+
# argument that is not `path`. Each is a read of that file.
|
|
389
|
+
_URL_ARGS = {"drive": "url", "browser_open_tab": "url", "navigate": "url"}
|
|
390
|
+
_AUX_CALLS = frozenset({"session_fork", "agent"})
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def check_call(name: str, args: dict[str, Any], model_id: Any) -> None:
|
|
394
|
+
if name in _URL_ARGS:
|
|
395
|
+
local = local_path_of_url(args.get(_URL_ARGS[name]))
|
|
396
|
+
if local:
|
|
397
|
+
check_path("R", local, model_id)
|
|
398
|
+
return
|
|
399
|
+
if name in _AUX_CALLS:
|
|
400
|
+
aux = args.get("aux_context")
|
|
401
|
+
if isinstance(aux, str) and aux.strip():
|
|
402
|
+
check_path("R", aux, model_id)
|
|
403
|
+
return
|
|
404
|
+
op = {"read_file": "R", "write_file": "W", "edit_file": "W"}.get(name)
|
|
405
|
+
if not op:
|
|
406
|
+
return
|
|
407
|
+
path = next((args[key] for key in ("file_path", "path", "notebook_path", "filePath")
|
|
408
|
+
if isinstance(args.get(key), str) and args[key]), None)
|
|
409
|
+
if not path:
|
|
410
|
+
return
|
|
411
|
+
check_path(op, path, model_id)
|
|
412
|
+
# An edit is also a read: finding `old_string`, and saying whether it was
|
|
413
|
+
# found, is an oracle on the file. A read seal refuses it (as `sealGate`).
|
|
414
|
+
if name == "edit_file":
|
|
415
|
+
check_path("R", path, model_id)
|
|
416
|
+
if op != "W":
|
|
417
|
+
return
|
|
418
|
+
# Integrity is model-independent: a second, unsealed model must not be able
|
|
419
|
+
# to remove a marker for the model it serves, or scoped seals are theatre.
|
|
420
|
+
try:
|
|
421
|
+
with internal(), open(path, "r", encoding="utf-8", errors="replace") as file:
|
|
422
|
+
before = file.read()
|
|
423
|
+
except OSError:
|
|
424
|
+
return
|
|
425
|
+
after = args.get("content") if name == "write_file" else None
|
|
426
|
+
# Bytes are judged as the text they decode to. `write_file` accepts bytes
|
|
427
|
+
# now, and a marker removed through bytes is removed all the same — left
|
|
428
|
+
# undecoded, this check would simply have been skipped.
|
|
429
|
+
if isinstance(after, (bytes, bytearray)):
|
|
430
|
+
after = bytes(after).decode("utf-8", errors="replace")
|
|
431
|
+
if name == "edit_file" and isinstance(args.get("old_string"), str) and isinstance(args.get("new_string"), str):
|
|
432
|
+
old, new = args["old_string"], args["new_string"]
|
|
433
|
+
after = before.replace(old, new) if args.get("replace_all") else before.replace(old, new, 1)
|
|
434
|
+
if isinstance(after, str):
|
|
435
|
+
lost = _integrity(before, after)
|
|
436
|
+
if lost:
|
|
437
|
+
raise SealDenied(f"WidgeX Seal — this edit would remove `{lost.raw}` from `{path}`. "
|
|
438
|
+
"A seal cannot delete itself, whatever operations it lists.")
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""What went wrong between the layers, said at the layer that did it.
|
|
2
|
+
|
|
3
|
+
A command on its way to a program crosses the agent's Python string, then
|
|
4
|
+
`bash -c`, then often a second interpreter. Each failure below was read out of
|
|
5
|
+
real transcripts, where it cost a round trip or more because the agent could
|
|
6
|
+
see neither what bash received nor that bash was the one who changed it. The
|
|
7
|
+
error it did see always came from the last layer, and named the symptom.
|
|
8
|
+
|
|
9
|
+
Two kinds of rule:
|
|
10
|
+
|
|
11
|
+
* **Definite** — the command is wrong whatever happened when it ran. bash
|
|
12
|
+
*will* expand `$_` inside double quotes; an unquoted `C:\\Users` *will* lose
|
|
13
|
+
its backslashes. These are noted every time, because the run that exits 0 is
|
|
14
|
+
exactly the one that does damage quietly: a `Stop-Process` that stopped
|
|
15
|
+
nothing, a `type` that read nothing.
|
|
16
|
+
* **On failure** — worth saying only once something has gone wrong, because
|
|
17
|
+
the same construct is often right: a missing tool, a backslash Python
|
|
18
|
+
consumed on purpose.
|
|
19
|
+
|
|
20
|
+
Pure functions over strings, so they can be tested without a shell.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# ── Definite ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
_POWERSHELL = re.compile(r"\b(?:powershell|pwsh)(?:\.exe)?\b", re.IGNORECASE)
|
|
31
|
+
_PS_VAR = re.compile(r"(?<!\\)\$(?:_|[A-Za-z][\w:]*|\{|\()")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _double_quoted_after(command: str, start: int) -> str | None:
|
|
35
|
+
"""The first bash double-quoted string after `start`, or None.
|
|
36
|
+
|
|
37
|
+
Tracks single quotes, inside which a double quote means nothing, and `\\"`
|
|
38
|
+
inside double quotes, which does not close them.
|
|
39
|
+
"""
|
|
40
|
+
i, n = start, len(command)
|
|
41
|
+
in_single = False
|
|
42
|
+
while i < n:
|
|
43
|
+
c = command[i]
|
|
44
|
+
if in_single:
|
|
45
|
+
if c == "'":
|
|
46
|
+
in_single = False
|
|
47
|
+
elif c == "'":
|
|
48
|
+
in_single = True
|
|
49
|
+
elif c in ";|&\n":
|
|
50
|
+
return None # the invocation ended without a double-quoted argument
|
|
51
|
+
elif c == '"':
|
|
52
|
+
j = i + 1
|
|
53
|
+
while j < n:
|
|
54
|
+
if command[j] == "\\":
|
|
55
|
+
j += 2
|
|
56
|
+
continue
|
|
57
|
+
if command[j] == '"':
|
|
58
|
+
return command[i + 1:j]
|
|
59
|
+
j += 1
|
|
60
|
+
return command[i + 1:]
|
|
61
|
+
i += 1
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def powershell_dollar(command: str) -> str | None:
|
|
66
|
+
"""`$` meant for PowerShell, sitting where bash expands it first."""
|
|
67
|
+
for m in _POWERSHELL.finditer(command):
|
|
68
|
+
body = _double_quoted_after(command, m.end())
|
|
69
|
+
if body is None:
|
|
70
|
+
continue
|
|
71
|
+
names = sorted({v.group(0) for v in _PS_VAR.finditer(body)})
|
|
72
|
+
if names:
|
|
73
|
+
shown = ", ".join(f"`{n}`" for n in names[:4])
|
|
74
|
+
return (
|
|
75
|
+
f"bash expanded {shown} inside the double quotes before PowerShell "
|
|
76
|
+
"ran — `$_` becomes bash's last argument and other names become "
|
|
77
|
+
"empty — so PowerShell did not get the script you wrote. Use "
|
|
78
|
+
"api.powershell(script), which passes it untouched."
|
|
79
|
+
)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_DRIVE_BACKSLASH = re.compile(r"[A-Za-z]:\\[^\s'\"]")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def unquoted_windows_path(command: str) -> str | None:
|
|
87
|
+
"""A `C:\\...` path outside quotes, whose backslashes bash removes."""
|
|
88
|
+
in_single = in_double = False
|
|
89
|
+
i, n = 0, len(command)
|
|
90
|
+
while i < n:
|
|
91
|
+
c = command[i]
|
|
92
|
+
if in_single:
|
|
93
|
+
if c == "'":
|
|
94
|
+
in_single = False
|
|
95
|
+
elif in_double:
|
|
96
|
+
if c == "\\":
|
|
97
|
+
i += 2
|
|
98
|
+
continue
|
|
99
|
+
if c == '"':
|
|
100
|
+
in_double = False
|
|
101
|
+
elif c == "'":
|
|
102
|
+
in_single = True
|
|
103
|
+
elif c == '"':
|
|
104
|
+
in_double = True
|
|
105
|
+
elif c == "\\":
|
|
106
|
+
i += 2
|
|
107
|
+
continue
|
|
108
|
+
else:
|
|
109
|
+
m = _DRIVE_BACKSLASH.match(command, i)
|
|
110
|
+
if m and (i == 0 or not command[i - 1].isalnum()):
|
|
111
|
+
end = i
|
|
112
|
+
while end < n and not command[end].isspace() and command[end] not in ";|&":
|
|
113
|
+
end += 1
|
|
114
|
+
path = command[i:end]
|
|
115
|
+
mangled = path.replace("\\", "")
|
|
116
|
+
return (
|
|
117
|
+
f"bash removes unquoted backslashes, so `{path}` reached the "
|
|
118
|
+
f"program as `{mangled}`. Write `{path.replace(chr(92), '/')}`, "
|
|
119
|
+
"or quote it."
|
|
120
|
+
)
|
|
121
|
+
i += 1
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── On failure ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
_NOT_FOUND = re.compile(r"(?:^|\n)(?:[^\n:]*: )?(?:line \d+: )?([\w.+-]+): command not found")
|
|
128
|
+
|
|
129
|
+
# Tools an agent expects and Git Bash on Windows does not have, with the way
|
|
130
|
+
# to get the same thing. Only names seen missing in transcripts.
|
|
131
|
+
_MISSING_TOOLS = {
|
|
132
|
+
"pkill": "stop processes with api.powershell(\"Stop-Process -Name <name>\")",
|
|
133
|
+
"pgrep": "list processes with api.powershell(\"Get-Process <name>\")",
|
|
134
|
+
"killall": "stop processes with api.powershell(\"Stop-Process -Name <name>\")",
|
|
135
|
+
"python3": "on Windows the interpreter is `python`",
|
|
136
|
+
"pwsh": "Windows PowerShell is `powershell`, or call api.powershell(script)",
|
|
137
|
+
"dig": "use `nslookup`, or api.powershell(\"Resolve-DnsName <name>\")",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def missing_tool(stderr: str) -> str | None:
|
|
142
|
+
for m in _NOT_FOUND.finditer(stderr or ""):
|
|
143
|
+
advice = _MISSING_TOOLS.get(m.group(1))
|
|
144
|
+
if advice:
|
|
145
|
+
return f"`{m.group(1)}` is not available in this shell: {advice}."
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_FAILURE_TEXT = re.compile(
|
|
150
|
+
r"SyntaxError|syntax error|unexpected (?:token|EOF|end of file)|unterminated|"
|
|
151
|
+
r"is not recognized|No such file|not found|ParserError|Missing (?:expression|closing)",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def looks_failed(result: dict[str, Any]) -> bool:
|
|
156
|
+
"""Whether a shell result shows any sign of having gone wrong."""
|
|
157
|
+
code = result.get("exit_code")
|
|
158
|
+
if isinstance(code, int) and code != 0:
|
|
159
|
+
return True
|
|
160
|
+
return bool(_FAILURE_TEXT.search(result.get("stderr") or ""))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def hints_for(command: str, result: dict[str, Any], literal_note: str | None = None) -> list[str]:
|
|
164
|
+
"""Every note this command and its result call for, most specific first."""
|
|
165
|
+
notes = [h for h in (powershell_dollar(command), unquoted_windows_path(command)) if h]
|
|
166
|
+
if "background_id" in result:
|
|
167
|
+
return notes
|
|
168
|
+
if looks_failed(result):
|
|
169
|
+
if literal_note:
|
|
170
|
+
notes.append(literal_note)
|
|
171
|
+
tool = missing_tool(result.get("stderr") or "")
|
|
172
|
+
if tool:
|
|
173
|
+
notes.append(tool)
|
|
174
|
+
return notes
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── The literal lint, computed when the exec is compiled ───────────────
|
|
178
|
+
|
|
179
|
+
_ESCAPE = re.compile(r"\\(\\|'|\"|n|t)")
|
|
180
|
+
_SHOWN = {"\\": "`\\\\` became `\\`", "'": "`\\'` became `'`", '"': '`\\"` became `"`',
|
|
181
|
+
"n": "`\\n` became a line break", "t": "`\\t` became a tab"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def literal_note(segment: str, value: str | None, line: int, call: str) -> str | None:
|
|
185
|
+
"""The note for one non-raw string literal passed to a shell call.
|
|
186
|
+
|
|
187
|
+
`segment` is the literal's source text, prefix and quotes included; `value`
|
|
188
|
+
is what Python made of it, when it is a plain constant.
|
|
189
|
+
"""
|
|
190
|
+
prefix = re.match(r"[A-Za-z]*", segment).group(0).lower()
|
|
191
|
+
if "r" in prefix:
|
|
192
|
+
return None
|
|
193
|
+
escapes = []
|
|
194
|
+
for m in _ESCAPE.finditer(segment):
|
|
195
|
+
shown = _SHOWN[m.group(1)]
|
|
196
|
+
if shown not in escapes:
|
|
197
|
+
escapes.append(shown)
|
|
198
|
+
if not escapes:
|
|
199
|
+
return None
|
|
200
|
+
target = "PowerShell" if call == "powershell" else ("the program" if call == "run" else "bash")
|
|
201
|
+
note = (
|
|
202
|
+
f"line {line}: the string passed to api.{call} is not raw, so Python "
|
|
203
|
+
f"processed it before {target} saw it — {', '.join(escapes[:3])}."
|
|
204
|
+
)
|
|
205
|
+
if value is not None:
|
|
206
|
+
received = value.replace("\n", "⏎")
|
|
207
|
+
if len(received) > 160:
|
|
208
|
+
received = received[:157] + "…"
|
|
209
|
+
note += f" {target[0].upper() + target[1:]} received: {received}"
|
|
210
|
+
return note + ' Write it as r"…" to pass backslashes through as written.'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Layer 2 — State machine infrastructure for browser automation."""
|
|
2
|
+
|
|
3
|
+
from .base import PageState, LoadingState, Observation, Transition, Step
|
|
4
|
+
from .registry import StateRegistry
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"PageState",
|
|
8
|
+
"LoadingState",
|
|
9
|
+
"Observation",
|
|
10
|
+
"Transition",
|
|
11
|
+
"Step",
|
|
12
|
+
"StateRegistry",
|
|
13
|
+
]
|