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.
Files changed (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. 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())