fable-engine 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fable_compressor.py +356 -0
- fable_engine/__init__.py +1 -0
- fable_engine/actions/__init__.py +291 -0
- fable_engine/actions/cas.py +182 -0
- fable_engine/actions/deliberation.py +523 -0
- fable_engine/actions/fleet.py +807 -0
- fable_engine/actions/lifecycle.py +298 -0
- fable_engine/actions/scrapers.py +116 -0
- fable_engine/actions/system3.py +815 -0
- fable_engine/browser.py +824 -0
- fable_engine/cas.py +974 -0
- fable_engine/fable_session.json +510 -0
- fable_engine/guards.py +283 -0
- fable_engine/schema.py +714 -0
- fable_engine/scrapers/__init__.py +32 -0
- fable_engine/scrapers/arxiv.py +115 -0
- fable_engine/scrapers/base.py +386 -0
- fable_engine/scrapers/github.py +129 -0
- fable_engine/scrapers/reddit.py +154 -0
- fable_engine/scrapers/web.py +120 -0
- fable_engine/scrapers/x.py +125 -0
- fable_engine/scrapers/youtube.py +132 -0
- fable_engine/server.py +414 -0
- fable_engine/session.py +1819 -0
- fable_engine/test_server.py +1362 -0
- fable_engine/updater.py +541 -0
- fable_engine-1.3.1.dist-info/LICENSE +22 -0
- fable_engine-1.3.1.dist-info/METADATA +173 -0
- fable_engine-1.3.1.dist-info/RECORD +104 -0
- fable_engine-1.3.1.dist-info/WHEEL +5 -0
- fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
- fable_engine-1.3.1.dist-info/top_level.txt +6 -0
- fable_mode/__init__.py +3 -0
- fable_mode/__main__.py +4 -0
- fable_mode/adapters.py +1014 -0
- fable_mode/installer.py +553 -0
- fable_mode/launcher.py +437 -0
- fable_mode/manifest.py +142 -0
- fable_mode/resources.json +114 -0
- fable_mode/safety.py +103 -0
- fable_mode_entry.py +10 -0
- fable_v2/__init__.py +146 -0
- fable_v2/adapters.py +151 -0
- fable_v2/coder_fleet/__init__.py +100 -0
- fable_v2/coder_fleet/ast_tools.py +158 -0
- fable_v2/coder_fleet/compute.py +199 -0
- fable_v2/coder_fleet/design_engine.py +1316 -0
- fable_v2/coder_fleet/diagnostics.py +293 -0
- fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
- fable_v2/coder_fleet/mock_auditor.py +306 -0
- fable_v2/coder_fleet/mutation.py +216 -0
- fable_v2/coder_fleet/property_oracle.py +260 -0
- fable_v2/coder_fleet/receipt_attestor.py +122 -0
- fable_v2/coder_fleet/red_team_swarm.py +908 -0
- fable_v2/coder_fleet/test_harness.py +198 -0
- fable_v2/coder_fleet/vector_engine.py +1287 -0
- fable_v2/coder_fleet/visual.py +357 -0
- fable_v2/coder_fleet/workspace.py +153 -0
- fable_v2/cortical/__init__.py +20 -0
- fable_v2/cortical/plasticity_engine.py +992 -0
- fable_v2/execution_broker.py +811 -0
- fable_v2/proof_engine.py +1141 -0
- fable_v2/protocol.py +485 -0
- fable_v2/runtime.py +1010 -0
- fable_v2/system3/__init__.py +204 -0
- fable_v2/system3/causal.py +558 -0
- fable_v2/system3/dialectical.py +577 -0
- fable_v2/system3/evolution.py +503 -0
- fable_v2/system3/executive.py +338 -0
- fable_v2/system3/free_energy.py +479 -0
- fable_v2/system3/hyperbolic.py +555 -0
- fable_v2/system3/induction.py +336 -0
- fable_v2/system3/kripke.py +548 -0
- fable_v2/system3/oracle.py +745 -0
- fable_v2/verifiers.py +72 -0
- tests/__init__.py +1 -0
- tests/test_anti_loop_circuit_breaker.py +64 -0
- tests/test_auto_updater.py +407 -0
- tests/test_coder_fleet.py +535 -0
- tests/test_delegation_compiler.py +54 -0
- tests/test_descriptor_boundaries.py +126 -0
- tests/test_design_engine.py +603 -0
- tests/test_epistemic_evidence_validator.py +66 -0
- tests/test_execution_broker.py +233 -0
- tests/test_fable_v2.py +406 -0
- tests/test_fleet_transitions.py +116 -0
- tests/test_fsm_redteam_evolution.py +406 -0
- tests/test_goal_rubric_and_pipeline.py +367 -0
- tests/test_hebbian_plasticity.py +585 -0
- tests/test_packaging_runtime.py +194 -0
- tests/test_proof_engine.py +259 -0
- tests/test_red_team_swarm.py +645 -0
- tests/test_redteam_remediation.py +169 -0
- tests/test_registration_transaction.py +375 -0
- tests/test_requested_regressions.py +467 -0
- tests/test_scrapers.py +370 -0
- tests/test_server_actions.py +93 -0
- tests/test_server_frontier_actions.py +269 -0
- tests/test_server_protocol.py +88 -0
- tests/test_stealth_browser.py +970 -0
- tests/test_system3.py +381 -0
- tests/test_system3_deep_integration.py +385 -0
- tests/test_system3_frontier.py +436 -0
- tests/test_vector_engine.py +608 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"""Process-isolated execution boundary for Fable V2.
|
|
2
|
+
|
|
3
|
+
The broker is the only component in this foundation that should be granted
|
|
4
|
+
workspace write permission. It exposes a small JSON-lines protocol so hosts
|
|
5
|
+
can launch it as a child process and keep model-facing tools away from direct
|
|
6
|
+
filesystem writes. This is a policy boundary, not a complete OS sandbox;
|
|
7
|
+
production deployments should add containers, OS MAC, seccomp/job objects,
|
|
8
|
+
or an equivalent hardened isolation layer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
import argparse
|
|
15
|
+
import errno
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import shutil
|
|
23
|
+
import signal
|
|
24
|
+
import stat
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import threading
|
|
29
|
+
from typing import Any, Iterable
|
|
30
|
+
|
|
31
|
+
from .system3 import KripkeStructure, KripkeModelChecker, CausalDAG, CausalNode, CausalNodeType
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BrokerPolicy:
|
|
36
|
+
workspace: Path
|
|
37
|
+
allowed_executables: tuple[str, ...] = ("python", "python3", "pytest")
|
|
38
|
+
max_output_bytes: int = 1_000_000
|
|
39
|
+
write_token_digest: str | None = None
|
|
40
|
+
resolved_executables: dict[str, str] = field(
|
|
41
|
+
init=False, default_factory=dict, repr=False, compare=False
|
|
42
|
+
) # populated from trusted PATH at startup
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
workspace = self.workspace.expanduser().resolve()
|
|
46
|
+
if not workspace.exists() or not workspace.is_dir():
|
|
47
|
+
raise ValueError("workspace must be an existing directory")
|
|
48
|
+
if self.max_output_bytes < 1:
|
|
49
|
+
raise ValueError("max_output_bytes must be positive")
|
|
50
|
+
if not self.allowed_executables:
|
|
51
|
+
raise ValueError("at least one executable must be allowlisted")
|
|
52
|
+
resolved: dict[str, str] = {}
|
|
53
|
+
normalized_names: list[str] = []
|
|
54
|
+
for item in self.allowed_executables:
|
|
55
|
+
requested = Path(item).expanduser()
|
|
56
|
+
located = requested if requested.is_absolute() else Path(shutil.which(str(requested)) or "")
|
|
57
|
+
if not located or not located.exists() or not located.is_file():
|
|
58
|
+
continue
|
|
59
|
+
absolute = str(located.resolve())
|
|
60
|
+
key = os.path.normcase(Path(item).name)
|
|
61
|
+
if key in resolved and os.path.normcase(resolved[key]) != os.path.normcase(absolute):
|
|
62
|
+
raise ValueError(f"ambiguous executable allowlist entry: {item}")
|
|
63
|
+
resolved[key] = absolute
|
|
64
|
+
normalized_names.append(Path(item).name)
|
|
65
|
+
if not resolved:
|
|
66
|
+
raise ValueError("no allowlisted executable could be resolved at broker startup")
|
|
67
|
+
object.__setattr__(self, "workspace", workspace)
|
|
68
|
+
object.__setattr__(self, "allowed_executables", tuple(dict.fromkeys(normalized_names)))
|
|
69
|
+
object.__setattr__(self, "resolved_executables", resolved)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
MAX_TIMEOUT_SECONDS = 3600.0
|
|
73
|
+
# JSON-lines is an interactive protocol: a peer may keep stdin open while
|
|
74
|
+
# waiting for a response. Keep both the raw frame and protocol diagnostics
|
|
75
|
+
# bounded independently of the peer's eventual EOF.
|
|
76
|
+
MAX_FRAME_BYTES = 1 * 1024 * 1024
|
|
77
|
+
MAX_ERROR_TEXT = 8 * 1024
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bounded_text(value: object, limit: int = MAX_ERROR_TEXT) -> str:
|
|
81
|
+
"""Convert protocol diagnostics to bounded, valid UTF-8 text."""
|
|
82
|
+
text = str(value)
|
|
83
|
+
encoded = text.encode("utf-8", "replace")
|
|
84
|
+
if len(encoded) <= limit:
|
|
85
|
+
return text
|
|
86
|
+
suffix = b" [truncated]"
|
|
87
|
+
prefix_limit = max(0, limit - len(suffix))
|
|
88
|
+
return encoded[:prefix_limit].decode("utf-8", "ignore") + suffix.decode()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bounded_lines(stream: Any, limit: int = MAX_FRAME_BYTES):
|
|
92
|
+
"""Yield newline-delimited raw frames without waiting for EOF.
|
|
93
|
+
|
|
94
|
+
``readline`` and large ``read(n)`` calls are unsuitable for an interactive
|
|
95
|
+
pipe: depending on the buffering layer they can wait for more input even
|
|
96
|
+
after a complete frame has arrived. Reading one raw byte at a time gives
|
|
97
|
+
prompt framing while retaining only ``limit`` bytes; oversized content is
|
|
98
|
+
consumed through its newline so the next frame remains synchronized.
|
|
99
|
+
"""
|
|
100
|
+
raw_stream = getattr(stream, "buffer", None)
|
|
101
|
+
if raw_stream is None or not hasattr(raw_stream, "read"):
|
|
102
|
+
raw_stream = stream
|
|
103
|
+
pending = bytearray()
|
|
104
|
+
oversized = False
|
|
105
|
+
while True:
|
|
106
|
+
unit = raw_stream.read(1)
|
|
107
|
+
if not unit:
|
|
108
|
+
if pending or oversized:
|
|
109
|
+
yield bytes(pending), oversized
|
|
110
|
+
return
|
|
111
|
+
if isinstance(unit, str):
|
|
112
|
+
encoded = unit.encode("utf-8", "replace")
|
|
113
|
+
else:
|
|
114
|
+
encoded = bytes(unit)
|
|
115
|
+
for byte in encoded:
|
|
116
|
+
if byte == 0x0A:
|
|
117
|
+
yield bytes(pending), oversized
|
|
118
|
+
pending.clear()
|
|
119
|
+
oversized = False
|
|
120
|
+
elif not oversized:
|
|
121
|
+
pending.append(byte)
|
|
122
|
+
if len(pending) > limit:
|
|
123
|
+
oversized = True
|
|
124
|
+
del pending[limit:]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _protocol_error(error: object, message: object) -> dict[str, object]:
|
|
128
|
+
return {"ok": False, "error": _bounded_text(error),
|
|
129
|
+
"message": _bounded_text(message)}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _path_parts(relative_path: str) -> tuple[str, ...]:
|
|
133
|
+
"""Return safe lexical components without resolving attacker-controlled paths."""
|
|
134
|
+
if not isinstance(relative_path, str) or not relative_path.strip():
|
|
135
|
+
raise ValueError("path must be a non-empty relative path")
|
|
136
|
+
# Normalize backslashes to forward slashes for Windows and cross-platform compatibility
|
|
137
|
+
normalized = relative_path.replace("\\", "/")
|
|
138
|
+
candidate = Path(normalized)
|
|
139
|
+
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
|
140
|
+
raise PermissionError("path must be a normalized relative path")
|
|
141
|
+
if any("\x00" in part or ":" in part for part in candidate.parts):
|
|
142
|
+
raise PermissionError("path contains an unsafe component")
|
|
143
|
+
return tuple(candidate.parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _no_follow_flags(directory: bool = False) -> int:
|
|
147
|
+
flags = os.O_RDONLY
|
|
148
|
+
if directory:
|
|
149
|
+
flags |= getattr(os, "O_DIRECTORY", 0)
|
|
150
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
151
|
+
return flags
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _open_child_dirs(root_fd: int, parts: tuple[str, ...], *, create: bool = False) -> int:
|
|
155
|
+
"""Open a directory chain relative to a pinned root, never following links."""
|
|
156
|
+
current = os.dup(root_fd)
|
|
157
|
+
try:
|
|
158
|
+
for part in parts:
|
|
159
|
+
try:
|
|
160
|
+
child = os.open(part, _no_follow_flags(directory=True), dir_fd=current)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
if not create:
|
|
163
|
+
raise
|
|
164
|
+
os.mkdir(part, 0o700, dir_fd=current)
|
|
165
|
+
child = os.open(part, _no_follow_flags(directory=True), dir_fd=current)
|
|
166
|
+
st = os.fstat(child)
|
|
167
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
168
|
+
os.close(child)
|
|
169
|
+
raise NotADirectoryError(part)
|
|
170
|
+
os.close(current)
|
|
171
|
+
current = child
|
|
172
|
+
return current
|
|
173
|
+
except Exception:
|
|
174
|
+
os.close(current)
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ExecutionBroker:
|
|
179
|
+
"""Allowlisted command and write broker intended to run in a child process.
|
|
180
|
+
|
|
181
|
+
Timeout cleanup kills the POSIX process group; Windows uses direct-child
|
|
182
|
+
termination because portable stdlib Job Objects are unavailable. This is
|
|
183
|
+
a bounded policy boundary, not a descriptor-perfect OS sandbox.
|
|
184
|
+
|
|
185
|
+
General interpreters and shell entry points are blocked while writes are
|
|
186
|
+
locked. ``shell=False`` alone is not a filesystem sandbox: an invocation
|
|
187
|
+
such as ``python -c`` can still write files directly.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
READ_LOCKED_INTERPRETERS = frozenset({
|
|
191
|
+
"bash", "cmd", "node", "perl", "php", "pypy", "powershell",
|
|
192
|
+
"pwsh", "python", "pytest", "ruby", "sh", "zsh",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
def __init__(self, policy: BrokerPolicy):
|
|
196
|
+
self.policy = policy
|
|
197
|
+
self._writes_unlocked = False
|
|
198
|
+
self._workspace_fd: int | None = None
|
|
199
|
+
if os.name == "posix":
|
|
200
|
+
try:
|
|
201
|
+
self._workspace_fd = os.open(
|
|
202
|
+
str(policy.workspace), _no_follow_flags(directory=True)
|
|
203
|
+
)
|
|
204
|
+
st = os.fstat(self._workspace_fd)
|
|
205
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
206
|
+
raise NotADirectoryError(str(policy.workspace))
|
|
207
|
+
except OSError as exc:
|
|
208
|
+
if self._workspace_fd is not None:
|
|
209
|
+
os.close(self._workspace_fd)
|
|
210
|
+
self._workspace_fd = None
|
|
211
|
+
raise PermissionError("workspace cannot be pinned safely") from exc
|
|
212
|
+
|
|
213
|
+
def __del__(self) -> None:
|
|
214
|
+
fd = getattr(self, "_workspace_fd", None)
|
|
215
|
+
if fd is not None:
|
|
216
|
+
try:
|
|
217
|
+
os.close(fd)
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
def probe(self) -> dict[str, Any]:
|
|
222
|
+
available = list(self.policy.resolved_executables)
|
|
223
|
+
return {
|
|
224
|
+
"host": "fable-execution-broker",
|
|
225
|
+
"capabilities": [
|
|
226
|
+
"execute_command", "inspect_files", "probe_capabilities", "write_file"
|
|
227
|
+
],
|
|
228
|
+
"available_executables": available,
|
|
229
|
+
"writes_enabled": self._writes_unlocked,
|
|
230
|
+
"read_locked_interpreters": sorted(self.READ_LOCKED_INTERPRETERS),
|
|
231
|
+
"workspace": str(self.policy.workspace),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
def _safe_path(self, relative_path: str) -> Path:
|
|
235
|
+
if not isinstance(relative_path, str) or not relative_path.strip():
|
|
236
|
+
raise ValueError("path must be a non-empty relative path")
|
|
237
|
+
normalized = relative_path.replace("\\", "/")
|
|
238
|
+
raw_candidate = self.policy.workspace / normalized
|
|
239
|
+
# Resolve only after rejecting links/reparse points in the lexical
|
|
240
|
+
# path; otherwise a symlink could turn strict workspace containment
|
|
241
|
+
# into a pathname illusion.
|
|
242
|
+
cur = raw_candidate
|
|
243
|
+
parts: list[Path] = []
|
|
244
|
+
while True:
|
|
245
|
+
parts.append(cur)
|
|
246
|
+
if cur == self.policy.workspace or cur.parent == cur:
|
|
247
|
+
break
|
|
248
|
+
cur = cur.parent
|
|
249
|
+
for part in reversed(parts):
|
|
250
|
+
try:
|
|
251
|
+
st = part.lstat()
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
continue
|
|
254
|
+
attrs = int(getattr(st, "st_file_attributes", 0))
|
|
255
|
+
if (attrs & 0x400 or stat.S_ISLNK(st.st_mode) or stat.S_ISSOCK(st.st_mode)
|
|
256
|
+
or stat.S_ISFIFO(st.st_mode) or stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode)):
|
|
257
|
+
raise PermissionError("workspace path contains an unsafe link or special file")
|
|
258
|
+
candidate = raw_candidate.resolve()
|
|
259
|
+
try:
|
|
260
|
+
candidate.relative_to(self.policy.workspace)
|
|
261
|
+
except ValueError as exc:
|
|
262
|
+
raise PermissionError("path escapes the broker workspace") from exc
|
|
263
|
+
return candidate
|
|
264
|
+
|
|
265
|
+
def unlock_writes(self, token: str) -> None:
|
|
266
|
+
"""Unlock writes through an administrator-only control channel."""
|
|
267
|
+
if self._writes_unlocked:
|
|
268
|
+
return
|
|
269
|
+
digest = self.policy.write_token_digest
|
|
270
|
+
if not digest or not token:
|
|
271
|
+
raise PermissionError("workspace writes are locked")
|
|
272
|
+
supplied = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
273
|
+
if not hmac.compare_digest(supplied, digest):
|
|
274
|
+
raise PermissionError("invalid write authorization")
|
|
275
|
+
self._writes_unlocked = True
|
|
276
|
+
|
|
277
|
+
def _authorize_write(self) -> None:
|
|
278
|
+
if not self._writes_unlocked:
|
|
279
|
+
raise PermissionError("workspace writes are locked")
|
|
280
|
+
|
|
281
|
+
def _pinned_parent(self, relative_path: str, *, create: bool = False) -> tuple[int, str, tuple[str, ...]]:
|
|
282
|
+
parts = _path_parts(relative_path)
|
|
283
|
+
if self._workspace_fd is None:
|
|
284
|
+
raise PermissionError("descriptor-relative workspace operations require POSIX")
|
|
285
|
+
parent_fd = _open_child_dirs(self._workspace_fd, parts[:-1], create=create)
|
|
286
|
+
return parent_fd, parts[-1], parts
|
|
287
|
+
|
|
288
|
+
def inspect_files(self, relative_path: str, max_bytes: int | None = None) -> dict[str, Any]:
|
|
289
|
+
"""Read one workspace file through a pinned directory descriptor."""
|
|
290
|
+
limit = self.policy.max_output_bytes if max_bytes is None else int(max_bytes)
|
|
291
|
+
if limit < 1:
|
|
292
|
+
raise ValueError("max_bytes must be positive")
|
|
293
|
+
limit = min(limit, self.policy.max_output_bytes)
|
|
294
|
+
if self._workspace_fd is None:
|
|
295
|
+
# Windows fallback: use the checked path-based implementation and
|
|
296
|
+
# fail closed on unsafe nodes. Native Windows handle-relative
|
|
297
|
+
# validation is a release-runner responsibility.
|
|
298
|
+
target = self._safe_path(relative_path)
|
|
299
|
+
target_st = target.lstat()
|
|
300
|
+
if (not stat.S_ISREG(target_st.st_mode) or target_st.st_nlink != 1
|
|
301
|
+
or (os.name != "nt" and stat.S_IMODE(target_st.st_mode) & 0o022)):
|
|
302
|
+
raise PermissionError("inspect path must be a private, non-hardlinked file")
|
|
303
|
+
with target.open("rb") as handle:
|
|
304
|
+
raw = handle.read(limit + 1)
|
|
305
|
+
parts = _path_parts(relative_path)
|
|
306
|
+
truncated = len(raw) > limit
|
|
307
|
+
raw = raw[:limit]
|
|
308
|
+
return {"path": "/".join(parts), "content": raw.decode("utf-8", errors="replace"),
|
|
309
|
+
"content_hash": hashlib.sha256(raw).hexdigest(), "truncated": truncated}
|
|
310
|
+
parent_fd, name, parts = self._pinned_parent(relative_path)
|
|
311
|
+
try:
|
|
312
|
+
fd = os.open(name, _no_follow_flags(), dir_fd=parent_fd)
|
|
313
|
+
try:
|
|
314
|
+
target_st = os.fstat(fd)
|
|
315
|
+
if (not stat.S_ISREG(target_st.st_mode) or target_st.st_nlink != 1
|
|
316
|
+
or (os.name != "nt" and stat.S_IMODE(target_st.st_mode) & 0o022)):
|
|
317
|
+
raise PermissionError("inspect path must be a private, non-hardlinked file")
|
|
318
|
+
raw = os.read(fd, limit + 1)
|
|
319
|
+
finally:
|
|
320
|
+
os.close(fd)
|
|
321
|
+
except FileNotFoundError as exc:
|
|
322
|
+
raise ValueError("inspect path must be a file inside the workspace") from exc
|
|
323
|
+
finally:
|
|
324
|
+
os.close(parent_fd)
|
|
325
|
+
truncated = len(raw) > limit
|
|
326
|
+
raw = raw[:limit]
|
|
327
|
+
return {
|
|
328
|
+
"path": "/".join(parts),
|
|
329
|
+
"content": raw.decode("utf-8", errors="replace"),
|
|
330
|
+
"content_hash": hashlib.sha256(raw).hexdigest(),
|
|
331
|
+
"truncated": truncated,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
def write_file(self, relative_path: str, content: str) -> dict[str, Any]:
|
|
335
|
+
self._authorize_write()
|
|
336
|
+
if not isinstance(content, str):
|
|
337
|
+
raise ValueError("file content must be text")
|
|
338
|
+
if self._workspace_fd is None:
|
|
339
|
+
target = self._safe_path(relative_path)
|
|
340
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
fd, temporary = tempfile.mkstemp(prefix=".fable-", dir=str(target.parent), text=True)
|
|
342
|
+
temp = Path(temporary)
|
|
343
|
+
try:
|
|
344
|
+
if hasattr(os, "fchmod"):
|
|
345
|
+
os.fchmod(fd, 0o600)
|
|
346
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
347
|
+
handle.write(content); handle.flush(); os.fsync(handle.fileno())
|
|
348
|
+
self._safe_path(relative_path)
|
|
349
|
+
os.replace(temp, target)
|
|
350
|
+
finally:
|
|
351
|
+
if temp.exists():
|
|
352
|
+
temp.unlink()
|
|
353
|
+
return {"path": relative_path, "content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
354
|
+
"writes_enabled": True}
|
|
355
|
+
parent_fd, name, parts = self._pinned_parent(relative_path, create=True)
|
|
356
|
+
temporary_name = f".fable-{os.getpid()}-{threading.get_ident()}-{os.urandom(8).hex()}"
|
|
357
|
+
temp_fd = None
|
|
358
|
+
try:
|
|
359
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
360
|
+
temp_fd = os.open(temporary_name, flags, 0o600, dir_fd=parent_fd)
|
|
361
|
+
raw = content.encode("utf-8")
|
|
362
|
+
view = memoryview(raw)
|
|
363
|
+
while view:
|
|
364
|
+
written = os.write(temp_fd, view)
|
|
365
|
+
view = view[written:]
|
|
366
|
+
os.fsync(temp_fd)
|
|
367
|
+
os.close(temp_fd)
|
|
368
|
+
temp_fd = None
|
|
369
|
+
os.replace(temporary_name, name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
370
|
+
finally:
|
|
371
|
+
if temp_fd is not None:
|
|
372
|
+
os.close(temp_fd)
|
|
373
|
+
try:
|
|
374
|
+
os.unlink(temporary_name, dir_fd=parent_fd)
|
|
375
|
+
except (FileNotFoundError, OSError):
|
|
376
|
+
pass
|
|
377
|
+
os.close(parent_fd)
|
|
378
|
+
return {
|
|
379
|
+
"path": "/".join(parts),
|
|
380
|
+
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
381
|
+
"writes_enabled": True,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
def check_kripke_pre_execution_invariants(self, command: Iterable[str]) -> dict[str, Any]:
|
|
385
|
+
"""Verify modal safety invariants AG(safe_execution) prior to running a command."""
|
|
386
|
+
kripke = KripkeStructure()
|
|
387
|
+
kripke.add_world("w_pre", propositions={"ready", "safe_execution", "workspace_isolated"})
|
|
388
|
+
kripke.add_world("w_exec", propositions={"running", "safe_execution", "workspace_isolated"})
|
|
389
|
+
kripke.add_world("w_post", propositions={"completed", "safe_execution", "workspace_isolated"})
|
|
390
|
+
kripke.add_transition("w_pre", "w_exec")
|
|
391
|
+
kripke.add_transition("w_exec", "w_post")
|
|
392
|
+
kripke.add_transition("w_post", "w_post")
|
|
393
|
+
checker = KripkeModelChecker(kripke)
|
|
394
|
+
res = checker.check("AG(safe_execution)", "w_pre")
|
|
395
|
+
return {
|
|
396
|
+
"formula": "AG(safe_execution)",
|
|
397
|
+
"is_satisfied": res.is_satisfied,
|
|
398
|
+
"satisfying_worlds": sorted(list(res.satisfied_worlds)),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
def validate_causal_boundaries(self, command: Iterable[str], cwd: str | None = None) -> dict[str, Any]:
|
|
402
|
+
"""Validate causal isolation boundaries do(Execute(cmd)) prior to running."""
|
|
403
|
+
cmd_list = list(command)
|
|
404
|
+
exe_name = Path(cmd_list[0]).name if cmd_list else "unknown"
|
|
405
|
+
dag = CausalDAG(name=f"CausalBroker_{exe_name}")
|
|
406
|
+
dag.add_node(node_id="node_workspace", name="WorkspaceIsolation", node_type=CausalNodeType.EXOGENOUS, value=1.0)
|
|
407
|
+
dag.add_node(node_id="node_intervention", name=f"do(Execute({exe_name}))", node_type=CausalNodeType.INTERVENTION, value=1.0)
|
|
408
|
+
dag.add_node(node_id="node_output", name="SafeOutput", node_type=CausalNodeType.METRIC, value=0.99)
|
|
409
|
+
dag.add_edge("node_workspace", "node_intervention", weight=1.0)
|
|
410
|
+
dag.add_edge("node_intervention", "node_output", weight=0.99)
|
|
411
|
+
report = dag.evaluate_brittleness(target_metric="node_output")
|
|
412
|
+
return {
|
|
413
|
+
"is_valid": report.overall_brittleness_score < 0.8,
|
|
414
|
+
"brittleness_score": report.overall_brittleness_score,
|
|
415
|
+
"dag": dag.to_dict(),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
def execute_command(
|
|
419
|
+
self,
|
|
420
|
+
command: Iterable[str],
|
|
421
|
+
cwd: str | None = None,
|
|
422
|
+
timeout_seconds: float = 120.0,
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
argv = tuple(command)
|
|
425
|
+
if not argv or any(not isinstance(item, str) or not item for item in argv):
|
|
426
|
+
raise ValueError("command must be a non-empty sequence of strings")
|
|
427
|
+
# System 3 Pre-Execution Invariant Verification
|
|
428
|
+
kripke_check = self.check_kripke_pre_execution_invariants(argv)
|
|
429
|
+
if not kripke_check["is_satisfied"]:
|
|
430
|
+
raise PermissionError("System 3 Kripke modal safety invariant AG(safe_execution) violated")
|
|
431
|
+
causal_check = self.validate_causal_boundaries(argv, cwd)
|
|
432
|
+
if not causal_check["is_valid"]:
|
|
433
|
+
raise PermissionError("System 3 Causal boundary check failed")
|
|
434
|
+
|
|
435
|
+
requested_executable = Path(argv[0])
|
|
436
|
+
executable = requested_executable.name
|
|
437
|
+
executable_key = Path(executable).stem.lower()
|
|
438
|
+
is_interpreter = (
|
|
439
|
+
executable_key in self.READ_LOCKED_INTERPRETERS
|
|
440
|
+
or executable_key.startswith("python")
|
|
441
|
+
)
|
|
442
|
+
registered_path = self.policy.resolved_executables.get(os.path.normcase(executable))
|
|
443
|
+
if not registered_path:
|
|
444
|
+
raise PermissionError(f"executable is not allowlisted: {executable}")
|
|
445
|
+
requested_path = requested_executable if requested_executable.is_absolute() else Path(
|
|
446
|
+
shutil.which(str(requested_executable)) or ""
|
|
447
|
+
)
|
|
448
|
+
if not requested_path or not requested_path.exists():
|
|
449
|
+
raise PermissionError(f"executable cannot be resolved: {argv[0]}")
|
|
450
|
+
if os.path.normcase(str(requested_path.resolve())) != os.path.normcase(registered_path):
|
|
451
|
+
raise PermissionError("executable path does not match its startup registration")
|
|
452
|
+
if is_interpreter and not self._writes_unlocked:
|
|
453
|
+
raise PermissionError(
|
|
454
|
+
"general interpreters and shells are blocked while workspace writes are locked"
|
|
455
|
+
)
|
|
456
|
+
try:
|
|
457
|
+
timeout_seconds = float(timeout_seconds)
|
|
458
|
+
except (TypeError, ValueError) as exc:
|
|
459
|
+
raise ValueError("timeout_seconds must be a finite positive number") from exc
|
|
460
|
+
if not math.isfinite(timeout_seconds) or not (0 < timeout_seconds <= MAX_TIMEOUT_SECONDS):
|
|
461
|
+
raise ValueError(f"timeout_seconds must be finite and between 0 and {MAX_TIMEOUT_SECONDS}")
|
|
462
|
+
cwd_fd: int | None = None
|
|
463
|
+
if self._workspace_fd is None:
|
|
464
|
+
directory = self.policy.workspace if cwd is None else self._safe_path(cwd)
|
|
465
|
+
if not directory.is_dir():
|
|
466
|
+
raise ValueError("cwd must be a directory inside the workspace")
|
|
467
|
+
cwd_display = "." if cwd is None else str(Path(cwd))
|
|
468
|
+
elif sys.platform == "darwin":
|
|
469
|
+
# macOS has /dev/fd, but its descriptors are not consistently
|
|
470
|
+
# usable as subprocess cwd paths. Keep the validated path fallback
|
|
471
|
+
# for native compatibility; inspect/write remain descriptor-relative.
|
|
472
|
+
directory = self.policy.workspace if cwd is None else self._safe_path(cwd)
|
|
473
|
+
if not directory.is_dir():
|
|
474
|
+
raise ValueError("cwd must be a directory inside the workspace")
|
|
475
|
+
cwd_display = "." if cwd is None else str(Path(cwd))
|
|
476
|
+
else:
|
|
477
|
+
if cwd is None:
|
|
478
|
+
cwd_fd = os.dup(self._workspace_fd)
|
|
479
|
+
cwd_display = "."
|
|
480
|
+
else:
|
|
481
|
+
cwd_parts = _path_parts(cwd)
|
|
482
|
+
cwd_fd = _open_child_dirs(self._workspace_fd, cwd_parts)
|
|
483
|
+
cwd_display = "/".join(cwd_parts)
|
|
484
|
+
directory = f"/proc/self/fd/{cwd_fd}"
|
|
485
|
+
if not os.path.isdir(directory):
|
|
486
|
+
os.close(cwd_fd)
|
|
487
|
+
cwd_fd = None
|
|
488
|
+
raise PermissionError("workspace cwd cannot be pinned safely")
|
|
489
|
+
# Keep only execution essentials. Windows child processes need
|
|
490
|
+
# SystemRoot/PATHEXT and temp roots; do not pass arbitrary user
|
|
491
|
+
# credentials or configuration variables through the broker.
|
|
492
|
+
env: dict[str, str] = {
|
|
493
|
+
key: os.environ[key]
|
|
494
|
+
for key in ("PATH", "PATHEXT")
|
|
495
|
+
if os.environ.get(key)
|
|
496
|
+
}
|
|
497
|
+
if os.name == "nt":
|
|
498
|
+
for win_key in (
|
|
499
|
+
"SYSTEMROOT", "SystemRoot", "WINDIR", "windir",
|
|
500
|
+
"TEMP", "TMP", "temp", "tmp", "SYSTEMDRIVE", "COMSPEC", "ComSpec",
|
|
501
|
+
):
|
|
502
|
+
val = os.environ.get(win_key)
|
|
503
|
+
if val:
|
|
504
|
+
env[win_key] = val
|
|
505
|
+
env[win_key.upper()] = val
|
|
506
|
+
else:
|
|
507
|
+
for posix_key in ("TEMP", "TMP", "TMPDIR"):
|
|
508
|
+
val = os.environ.get(posix_key)
|
|
509
|
+
if val:
|
|
510
|
+
env[posix_key] = val
|
|
511
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
512
|
+
process: subprocess.Popen[bytes] | None = None
|
|
513
|
+
output_limit = self.policy.max_output_bytes
|
|
514
|
+
captured = {"stdout": bytearray(), "stderr": bytearray()}
|
|
515
|
+
output_limited = threading.Event()
|
|
516
|
+
kill_lock = threading.Lock()
|
|
517
|
+
|
|
518
|
+
def stop_process() -> None:
|
|
519
|
+
if process is None:
|
|
520
|
+
return
|
|
521
|
+
with kill_lock:
|
|
522
|
+
if process.poll() is not None:
|
|
523
|
+
return
|
|
524
|
+
try:
|
|
525
|
+
if os.name == "posix":
|
|
526
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
527
|
+
else:
|
|
528
|
+
process.kill()
|
|
529
|
+
except (ProcessLookupError, PermissionError):
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
def drain(name: str, stream: Any) -> None:
|
|
533
|
+
bucket = captured[name]
|
|
534
|
+
while True:
|
|
535
|
+
chunk = stream.read(64 * 1024)
|
|
536
|
+
if not chunk:
|
|
537
|
+
return
|
|
538
|
+
remaining = output_limit - len(bucket)
|
|
539
|
+
if remaining <= 0:
|
|
540
|
+
output_limited.set()
|
|
541
|
+
stop_process()
|
|
542
|
+
while stream.read(64 * 1024):
|
|
543
|
+
pass
|
|
544
|
+
return
|
|
545
|
+
if len(chunk) > remaining:
|
|
546
|
+
bucket.extend(chunk[:remaining])
|
|
547
|
+
output_limited.set()
|
|
548
|
+
stop_process()
|
|
549
|
+
while stream.read(64 * 1024):
|
|
550
|
+
pass
|
|
551
|
+
return
|
|
552
|
+
else:
|
|
553
|
+
bucket.extend(chunk)
|
|
554
|
+
|
|
555
|
+
executed_argv = (registered_path, *argv[1:])
|
|
556
|
+
try:
|
|
557
|
+
process = subprocess.Popen(
|
|
558
|
+
executed_argv,
|
|
559
|
+
cwd=directory,
|
|
560
|
+
env=env,
|
|
561
|
+
stdin=subprocess.DEVNULL,
|
|
562
|
+
stdout=subprocess.PIPE,
|
|
563
|
+
stderr=subprocess.PIPE,
|
|
564
|
+
shell=False,
|
|
565
|
+
start_new_session=(os.name == "posix"),
|
|
566
|
+
creationflags=(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
567
|
+
if os.name == "nt" else 0),
|
|
568
|
+
)
|
|
569
|
+
assert process.stdout is not None and process.stderr is not None
|
|
570
|
+
readers = [
|
|
571
|
+
threading.Thread(target=drain, args=("stdout", process.stdout), daemon=True),
|
|
572
|
+
threading.Thread(target=drain, args=("stderr", process.stderr), daemon=True),
|
|
573
|
+
]
|
|
574
|
+
for reader in readers:
|
|
575
|
+
reader.start()
|
|
576
|
+
try:
|
|
577
|
+
exit_code = process.wait(timeout=timeout_seconds)
|
|
578
|
+
timed_out = False
|
|
579
|
+
except subprocess.TimeoutExpired:
|
|
580
|
+
timed_out = True
|
|
581
|
+
stop_process()
|
|
582
|
+
exit_code = None
|
|
583
|
+
for reader in readers:
|
|
584
|
+
reader.join(timeout=5)
|
|
585
|
+
if process.poll() is None:
|
|
586
|
+
stop_process()
|
|
587
|
+
process.wait(timeout=5)
|
|
588
|
+
finally:
|
|
589
|
+
if process is not None:
|
|
590
|
+
if process.stdout is not None:
|
|
591
|
+
process.stdout.close()
|
|
592
|
+
if process.stderr is not None:
|
|
593
|
+
process.stderr.close()
|
|
594
|
+
if cwd_fd is not None:
|
|
595
|
+
try:
|
|
596
|
+
os.close(cwd_fd)
|
|
597
|
+
except OSError:
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
stdout = bytes(captured["stdout"]).decode("utf-8", errors="replace")
|
|
601
|
+
stderr = bytes(captured["stderr"]).decode("utf-8", errors="replace")
|
|
602
|
+
|
|
603
|
+
def truncate(value: str) -> str:
|
|
604
|
+
# Readers enforce this bound before decoding; this is only a
|
|
605
|
+
# defensive guard for future callers that supply strings directly.
|
|
606
|
+
encoded = value.encode("utf-8")
|
|
607
|
+
if len(encoded) <= output_limit:
|
|
608
|
+
return value
|
|
609
|
+
return encoded[:output_limit].decode("utf-8", errors="ignore") + "\n[truncated]"
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
"command": list(executed_argv),
|
|
613
|
+
"cwd": cwd_display,
|
|
614
|
+
"exit_code": exit_code,
|
|
615
|
+
"timed_out": timed_out,
|
|
616
|
+
"output_limited": output_limited.is_set(),
|
|
617
|
+
"stdout": truncate(stdout),
|
|
618
|
+
"stderr": truncate(stderr),
|
|
619
|
+
"success": exit_code == 0 and not timed_out and not output_limited.is_set(),
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
def handle(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
623
|
+
action = request.get("action")
|
|
624
|
+
if action in {"probe", "probe_capabilities"}:
|
|
625
|
+
return self.probe()
|
|
626
|
+
if action == "inspect_files":
|
|
627
|
+
return self.inspect_files(
|
|
628
|
+
request.get("path", ""),
|
|
629
|
+
max_bytes=request.get("max_bytes"),
|
|
630
|
+
)
|
|
631
|
+
if action == "execute_command":
|
|
632
|
+
return self.execute_command(
|
|
633
|
+
request.get("command", ()),
|
|
634
|
+
cwd=request.get("cwd"),
|
|
635
|
+
timeout_seconds=float(request.get("timeout_seconds", 120.0)),
|
|
636
|
+
)
|
|
637
|
+
if action == "write_file":
|
|
638
|
+
# No authorization token is accepted on the model JSON channel.
|
|
639
|
+
return self.write_file(request.get("path", ""), request.get("content", ""))
|
|
640
|
+
raise ValueError(f"unsupported broker action: {action}")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _serve_admin_fd(broker: ExecutionBroker, fd: int) -> None:
|
|
644
|
+
"""Consume admin commands from an inherited, non-model file descriptor."""
|
|
645
|
+
with os.fdopen(os.dup(fd), "r", encoding="utf-8") as channel:
|
|
646
|
+
for raw_line, oversized in _bounded_lines(channel, MAX_FRAME_BYTES):
|
|
647
|
+
if oversized:
|
|
648
|
+
print("admin control error: oversized frame", file=sys.stderr)
|
|
649
|
+
continue
|
|
650
|
+
if not raw_line.strip():
|
|
651
|
+
continue
|
|
652
|
+
try:
|
|
653
|
+
request = json.loads(raw_line.decode("utf-8", "replace"))
|
|
654
|
+
if not isinstance(request, dict) or request.get("action") != "unlock_writes":
|
|
655
|
+
raise ValueError("unsupported admin action")
|
|
656
|
+
broker.unlock_writes(request.get("token", ""))
|
|
657
|
+
except Exception as exc:
|
|
658
|
+
print(f"admin control error: {_bounded_text(type(exc).__name__)}: "
|
|
659
|
+
f"{_bounded_text(exc)}", file=sys.stderr)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _serve_admin_file(broker: ExecutionBroker, file_path: str | Path) -> None:
|
|
663
|
+
"""Consume admin commands from a private command file (safe token exchange for Windows and POSIX)."""
|
|
664
|
+
cmd_file = Path(file_path).resolve()
|
|
665
|
+
while True:
|
|
666
|
+
try:
|
|
667
|
+
if cmd_file.exists() and cmd_file.is_file():
|
|
668
|
+
text = cmd_file.read_text(encoding="utf-8").strip()
|
|
669
|
+
if text:
|
|
670
|
+
for line in text.splitlines():
|
|
671
|
+
line = line.strip()
|
|
672
|
+
if not line:
|
|
673
|
+
continue
|
|
674
|
+
try:
|
|
675
|
+
request = json.loads(line)
|
|
676
|
+
if isinstance(request, dict) and request.get("action") == "unlock_writes":
|
|
677
|
+
broker.unlock_writes(request.get("token", ""))
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
print(f"admin file control error: {_bounded_text(exc)}", file=sys.stderr)
|
|
680
|
+
try:
|
|
681
|
+
cmd_file.unlink()
|
|
682
|
+
except OSError:
|
|
683
|
+
pass
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
time.sleep(0.05)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _serve_admin_socket(broker: ExecutionBroker, host_port: str) -> None:
|
|
690
|
+
"""Consume admin commands from a localhost control socket."""
|
|
691
|
+
import socket
|
|
692
|
+
parts = host_port.split(":", 1)
|
|
693
|
+
host = parts[0] if parts[0] else "127.0.0.1"
|
|
694
|
+
port = int(parts[1]) if len(parts) > 1 else 0
|
|
695
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
696
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
697
|
+
sock.bind((host, port))
|
|
698
|
+
sock.listen(5)
|
|
699
|
+
actual_port = sock.getsockname()[1]
|
|
700
|
+
print(f"ADMIN_PORT={actual_port}", file=sys.stderr, flush=True)
|
|
701
|
+
while True:
|
|
702
|
+
try:
|
|
703
|
+
conn, addr = sock.accept()
|
|
704
|
+
if addr[0] not in ("127.0.0.1", "::1"):
|
|
705
|
+
conn.close()
|
|
706
|
+
continue
|
|
707
|
+
with conn:
|
|
708
|
+
data = conn.recv(MAX_FRAME_BYTES)
|
|
709
|
+
if data:
|
|
710
|
+
line = data.decode("utf-8", "replace").strip()
|
|
711
|
+
try:
|
|
712
|
+
req = json.loads(line)
|
|
713
|
+
if isinstance(req, dict) and req.get("action") == "unlock_writes":
|
|
714
|
+
broker.unlock_writes(req.get("token", ""))
|
|
715
|
+
conn.sendall(b'{"ok": true}\n')
|
|
716
|
+
else:
|
|
717
|
+
conn.sendall(b'{"ok": false, "error": "unsupported action"}\n')
|
|
718
|
+
except Exception as exc:
|
|
719
|
+
conn.sendall(f'{{"ok": false, "error": "{exc}"}}\n'.encode())
|
|
720
|
+
except Exception:
|
|
721
|
+
pass
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def serve(
|
|
725
|
+
broker: ExecutionBroker,
|
|
726
|
+
admin_fd: int | None = None,
|
|
727
|
+
admin_file: str | Path | None = None,
|
|
728
|
+
admin_socket: str | None = None,
|
|
729
|
+
) -> None:
|
|
730
|
+
if admin_file is not None:
|
|
731
|
+
threading.Thread(target=_serve_admin_file, args=(broker, admin_file), daemon=True).start()
|
|
732
|
+
if admin_socket is not None:
|
|
733
|
+
threading.Thread(target=_serve_admin_socket, args=(broker, admin_socket), daemon=True).start()
|
|
734
|
+
if admin_fd is not None:
|
|
735
|
+
if os.name == "nt":
|
|
736
|
+
try:
|
|
737
|
+
import msvcrt
|
|
738
|
+
try:
|
|
739
|
+
channel_fd = os.dup(admin_fd)
|
|
740
|
+
except OSError:
|
|
741
|
+
channel_fd = msvcrt.open_osfhandle(admin_fd, os.O_RDONLY)
|
|
742
|
+
threading.Thread(target=_serve_admin_fd, args=(broker, channel_fd), daemon=True).start()
|
|
743
|
+
except Exception as exc:
|
|
744
|
+
print(f"admin control warning: Windows handle {admin_fd} could not be opened: {exc}", file=sys.stderr)
|
|
745
|
+
else:
|
|
746
|
+
threading.Thread(target=_serve_admin_fd, args=(broker, admin_fd), daemon=True).start()
|
|
747
|
+
# Do not use ``for line in sys.stdin``: TextIOWrapper iteration calls an
|
|
748
|
+
# unbounded readline and can wait for EOF on an otherwise healthy client.
|
|
749
|
+
for raw_line, oversized in _bounded_lines(sys.stdin, MAX_FRAME_BYTES):
|
|
750
|
+
if oversized:
|
|
751
|
+
response = _protocol_error("InvalidFrame", "request frame exceeds maximum size")
|
|
752
|
+
elif not raw_line.strip():
|
|
753
|
+
continue
|
|
754
|
+
else:
|
|
755
|
+
try:
|
|
756
|
+
request = json.loads(raw_line.decode("utf-8", "replace"))
|
|
757
|
+
if not isinstance(request, dict):
|
|
758
|
+
raise ValueError("request must be a JSON object")
|
|
759
|
+
response = {"ok": True, "result": broker.handle(request)}
|
|
760
|
+
except Exception as exc: # protocol boundary: never crash the broker loop
|
|
761
|
+
response = _protocol_error(type(exc).__name__, exc)
|
|
762
|
+
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
763
|
+
sys.stdout.flush()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _load_write_token_digest() -> str | None:
|
|
767
|
+
"""Load write authorization from administrator-controlled configuration."""
|
|
768
|
+
digest = os.environ.get("FABLE_BROKER_WRITE_TOKEN_DIGEST", "").strip()
|
|
769
|
+
digest_file = os.environ.get("FABLE_BROKER_WRITE_TOKEN_DIGEST_FILE", "").strip()
|
|
770
|
+
if digest and digest_file:
|
|
771
|
+
raise ValueError("configure only one write-token digest source")
|
|
772
|
+
if digest_file:
|
|
773
|
+
digest = Path(digest_file).read_text(encoding="utf-8").strip()
|
|
774
|
+
if not digest:
|
|
775
|
+
return None
|
|
776
|
+
if len(digest) != 64 or any(char not in "0123456789abcdefABCDEF" for char in digest):
|
|
777
|
+
raise ValueError("write-token digest must be a SHA-256 hexadecimal string")
|
|
778
|
+
return digest.lower()
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def main(argv: list[str] | None = None) -> int:
|
|
782
|
+
parser = argparse.ArgumentParser(description="Fable V2 execution broker")
|
|
783
|
+
parser.add_argument("--workspace", required=True, type=Path)
|
|
784
|
+
parser.add_argument("--allow-executable", action="append", default=[])
|
|
785
|
+
parser.add_argument(
|
|
786
|
+
"--admin-fd", type=int,
|
|
787
|
+
help="POSIX inherited one-way admin control FD; never expose to a model",
|
|
788
|
+
)
|
|
789
|
+
parser.add_argument(
|
|
790
|
+
"--admin-file", type=Path,
|
|
791
|
+
help="Admin control token file for Windows and POSIX; never expose to a model",
|
|
792
|
+
)
|
|
793
|
+
parser.add_argument(
|
|
794
|
+
"--admin-socket", type=str,
|
|
795
|
+
help="Localhost admin control socket (e.g. 127.0.0.1:0); never expose to a model",
|
|
796
|
+
)
|
|
797
|
+
args = parser.parse_args(argv)
|
|
798
|
+
allowed = tuple(args.allow_executable) or BrokerPolicy.allowed_executables
|
|
799
|
+
policy = BrokerPolicy(
|
|
800
|
+
workspace=args.workspace,
|
|
801
|
+
allowed_executables=allowed,
|
|
802
|
+
write_token_digest=_load_write_token_digest(),
|
|
803
|
+
)
|
|
804
|
+
admin_file = args.admin_file or os.environ.get("FABLE_BROKER_ADMIN_FILE")
|
|
805
|
+
admin_socket = args.admin_socket or os.environ.get("FABLE_BROKER_ADMIN_SOCKET")
|
|
806
|
+
serve(ExecutionBroker(policy), admin_fd=args.admin_fd, admin_file=admin_file, admin_socket=admin_socket)
|
|
807
|
+
return 0
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
if __name__ == "__main__":
|
|
811
|
+
raise SystemExit(main())
|