@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -0,0 +1,102 @@
1
+ """Where a service's things live (fabric-service/0.1): the services directory, a service's data, config, logs and run
2
+ directories per OS, private directories and atomic writes. Part of the service kit (FAA-65, layer L3)."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Dict
10
+
11
+ import sys as _sys # noqa: E402
12
+ from pathlib import Path as _Path # noqa: E402
13
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
14
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
15
+ from fabric_base import (
16
+ WINDOWS,
17
+ _require_id,
18
+ )
19
+ from fabric_platform import (
20
+ _win_protect,
21
+ )
22
+
23
+
24
+ def services_dir() -> Path:
25
+ """Descriptor directory: FABRIC_SERVICES_DIR, else the per-user OS location."""
26
+ override = os.environ.get("FABRIC_SERVICES_DIR")
27
+ if override:
28
+ return Path(override).expanduser()
29
+ home = Path.home()
30
+ if sys.platform == "darwin":
31
+ return home / "Library/Application Support/ai.passioncode.fabric/services"
32
+ if sys.platform == "win32":
33
+ # DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
34
+ return _local_app_data() / "passioncode-fabric" / "services"
35
+ base = os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")
36
+ return Path(base) / "passioncode-fabric/services"
37
+
38
+
39
+ def service_dirs(service_id: str) -> Dict[str, Path]:
40
+ """State lives outside code: data+config, logs and cache in per-user OS directories."""
41
+ _require_id(service_id)
42
+ home = Path.home()
43
+ if sys.platform == "darwin":
44
+ return {
45
+ "data": home / "Library/Application Support" / service_id,
46
+ "logs": home / "Library/Logs" / service_id,
47
+ "cache": home / "Library/Caches" / service_id,
48
+ }
49
+ if sys.platform == "win32":
50
+ data = _local_app_data() / service_id
51
+ return {"data": data, "logs": data / "Logs", "cache": data / "Cache"}
52
+ data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / service_id
53
+ state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / service_id
54
+ cache = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache") / service_id
55
+ return {"data": data, "logs": state / "logs", "cache": cache}
56
+
57
+
58
+ def _read_at(fd: int, n: int, offset: int) -> bytes:
59
+ """`os.pread` where it exists; Windows has none, so seek and read (the descriptor's appends
60
+ still go to the end: O_APPEND moves to it before every write)."""
61
+ if hasattr(os, "pread"):
62
+ return os.pread(fd, n, offset)
63
+ os.lseek(fd, offset, os.SEEK_SET)
64
+ return os.read(fd, n)
65
+
66
+
67
+ def _local_app_data() -> Path:
68
+ value = os.environ.get("LOCALAPPDATA")
69
+ return Path(value) if value else Path.home() / "AppData" / "Local"
70
+
71
+
72
+ def ensure_private_dir(path: Path) -> Path:
73
+ path.mkdir(parents=True, exist_ok=True)
74
+ if WINDOWS:
75
+ _win_protect(path, folder=True)
76
+ else:
77
+ os.chmod(path, 0o700)
78
+ return path
79
+
80
+
81
+ def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
82
+ """Temporary file in the same directory, fsync, rename: a reader never sees half a file."""
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
85
+ try:
86
+ if WINDOWS:
87
+ # The file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in — so
88
+ # it does not depend on what the folder hands down. A rename keeps it.
89
+ _win_protect(Path(tmp), folder=False)
90
+ with os.fdopen(fd, "wb") as handle:
91
+ handle.write(data)
92
+ handle.flush()
93
+ os.fsync(handle.fileno())
94
+ if not WINDOWS:
95
+ os.chmod(tmp, mode)
96
+ os.replace(tmp, path)
97
+ except BaseException:
98
+ try:
99
+ os.unlink(tmp)
100
+ except FileNotFoundError:
101
+ pass
102
+ raise
@@ -0,0 +1,338 @@
1
+ """Windows, where the kit needs it (DEC-0032): named pipes that hold one copy, protecting a file to its owner, and
2
+ reading a file's ACL to refuse a token file others can read. On other systems the same names exist and do nothing
3
+ or answer "absent". Part of the service kit (FAA-65, layer L3)."""
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+ import stat
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple
11
+
12
+ import sys as _sys # noqa: E402
13
+ from pathlib import Path as _Path # noqa: E402
14
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
15
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
16
+ from fabric_base import (
17
+ ServiceError,
18
+ WINDOWS,
19
+ )
20
+
21
+
22
+ #: The trustees a token file may grant anything to besides the current user: LocalSystem and the
23
+ #: Administrators group — the same trust as root reading a 0600 file on POSIX.
24
+ WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
25
+
26
+
27
+ def token_file_problem(path: Path, file: Dict[str, Any], user: str) -> Optional[str]:
28
+ """windows_token_acl_problem as one sentence naming the file and the refusal, or None."""
29
+ problem = windows_token_acl_problem(file, user)
30
+ return None if problem is None else "Token file %s refused: %s." % (path, problem["message"])
31
+
32
+
33
+ SYSTEM_SID = "S-1-5-18"
34
+
35
+
36
+ ADMINISTRATORS_SID = "S-1-5-32-544"
37
+
38
+
39
+ #: A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each.
40
+ _SID_TEXT = re.compile(r"S-1-[0-9]{1,15}(?:-[0-9]{1,10}){0,15}", re.IGNORECASE | re.ASCII)
41
+
42
+
43
+ def _sid(value: Any) -> Optional[str]:
44
+ """A readable SID in upper case (SIDs compare case-insensitively on their S), or None."""
45
+ if not isinstance(value, str) or not _SID_TEXT.fullmatch(value):
46
+ return None
47
+ return value.upper() if all(int(part) <= 0xFFFFFFFF for part in value.split("-")[3:]) else None
48
+
49
+
50
+ def windows_token_acl_problem(file: Any, current_user_sid: Any) -> Optional[Dict[str, Any]]:
51
+ """service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read:
52
+ ``file`` is ``{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}``, the mask
53
+ the ACE's unsigned 32-bit access mask. The first refusal ``{problem, at, sid?, message}``, or None when the file may
54
+ be read: a regular file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every
55
+ allow ACE with a non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything
56
+ unreadable refused. The contract's ``windowsTokenAclProblem``; a refusal names the SID, never the contents."""
57
+ def refuse(problem: str, at: str, message: str, sid: Optional[str] = None) -> Dict[str, Any]:
58
+ out = {"problem": problem, "at": at, "message": message}
59
+ if sid is not None:
60
+ out["sid"] = sid
61
+ return out
62
+ user = _sid(current_user_sid)
63
+ if user is None:
64
+ return refuse("unreadable", "/currentUserSid", "the current user's SID is unreadable")
65
+ if not isinstance(file, dict):
66
+ return refuse("unreadable", "/file", "the token file's metadata is unreadable")
67
+ for flag in ("isRegularFile", "isReparsePoint", "realPathInProfile"):
68
+ if not isinstance(file.get(flag), bool):
69
+ return refuse("unreadable", "/file/" + flag, flag + " is unreadable")
70
+ if file["isReparsePoint"]:
71
+ return refuse("reparse-point", "/file/isReparsePoint", "the token file is a reparse point (a symlink or a junction)")
72
+ if not file["isRegularFile"]:
73
+ return refuse("not-regular-file", "/file/isRegularFile", "the token file is not a regular file")
74
+ if not file["realPathInProfile"]:
75
+ return refuse("outside-profile", "/file/realPathInProfile", "the token file's real path lies outside the user's profile")
76
+ trusted = {user, SYSTEM_SID, ADMINISTRATORS_SID}
77
+ owner = _sid(file.get("ownerSid"))
78
+ if owner is None:
79
+ return refuse("unreadable", "/file/ownerSid", "the owner SID is unreadable")
80
+ if owner not in trusted:
81
+ return refuse("owner", "/file/ownerSid", "the token file is owned by %s, not by the current user, SYSTEM or Administrators" % owner, owner)
82
+ aces = file.get("aces")
83
+ if not isinstance(aces, list):
84
+ return refuse("unreadable", "/file/aces", "the ACL is unreadable")
85
+ for index, ace in enumerate(aces):
86
+ at = "/file/aces/%d" % index
87
+ if not isinstance(ace, dict):
88
+ return refuse("unreadable", at, "ACE %d is unreadable" % index)
89
+ if not isinstance(ace.get("type"), str):
90
+ return refuse("unreadable", at + "/type", "ACE %d has an unreadable type" % index)
91
+ trustee = _sid(ace.get("sid"))
92
+ if trustee is None:
93
+ return refuse("unreadable", at + "/sid", "ACE %d names an unreadable SID" % index)
94
+ mask = ace.get("mask")
95
+ if (not isinstance(mask, (int, float)) or isinstance(mask, bool) or (isinstance(mask, float) and not mask.is_integer())
96
+ or not 0 <= mask <= 0xFFFFFFFF):
97
+ return refuse("unreadable", at + "/mask", "ACE %d has an unreadable access mask" % index)
98
+ if ace["type"] == "deny":
99
+ continue
100
+ if ace["type"] != "allow":
101
+ return refuse("ace-type", at + "/type", "ACE %d for %s is of a type the rule does not read" % (index, trustee), trustee)
102
+ if mask == 0:
103
+ continue
104
+ if trustee not in trusted:
105
+ return refuse("ace-sid", at + "/sid", "the token file grants access to %s; only the current user, SYSTEM and Administrators may hold any" % trustee, trustee)
106
+ return None
107
+
108
+
109
+ #: SDDL ACE types (MS-DTYP §2.5.1.1) as the rule's ACE type: allow and deny are read, every other type is refused.
110
+ _SDDL_ACE_TYPES = {"A": "allow", "D": "deny", "OA": "allow-object", "OD": "deny-object", "XA": "allow-callback",
111
+ "XD": "deny-callback", "ZA": "allow-object-callback"}
112
+
113
+
114
+ #: SDDL access-right aliases (MS-DTYP §2.5.1.1, "ace_rights") and their masks.
115
+ _SDDL_RIGHTS = {"GA": 0x10000000, "GR": 0x80000000, "GW": 0x40000000, "GX": 0x20000000, "RC": 0x20000, "SD": 0x10000,
116
+ "WD": 0x40000, "WO": 0x80000, "RP": 0x10, "WP": 0x20, "CC": 0x1, "DC": 0x2, "LC": 0x4, "SW": 0x8,
117
+ "LO": 0x80, "DT": 0x40, "CR": 0x100, "FA": 0x1F01FF, "FR": 0x120089, "FW": 0x120116, "FX": 0x1200A0,
118
+ "KA": 0xF003F, "KR": 0x20019, "KX": 0x20019, "KW": 0x20006}
119
+
120
+
121
+ def _sddl_ace_strings(dacl: str) -> List[str]:
122
+ """The top-level parenthesized ACE strings of an SDDL DACL; a callback ACE's conditional expression may nest."""
123
+ aces, depth, start = [], 0, 0
124
+ for index, char in enumerate(dacl):
125
+ if char == "(":
126
+ if depth == 0:
127
+ start = index + 1
128
+ depth += 1
129
+ elif char == ")" and depth:
130
+ depth -= 1
131
+ if depth == 0:
132
+ aces.append(dacl[start:index])
133
+ return aces
134
+
135
+
136
+ def _sddl_mask(rights: str) -> Optional[int]:
137
+ """An SDDL rights field as the unsigned 32-bit mask, or None when it cannot be read."""
138
+ if re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}", rights):
139
+ return int(rights, 16)
140
+ if len(rights) % 2:
141
+ return None
142
+ mask = 0
143
+ for i in range(0, len(rights), 2):
144
+ if rights[i:i + 2] not in _SDDL_RIGHTS:
145
+ return None
146
+ mask |= _SDDL_RIGHTS[rights[i:i + 2]]
147
+ return mask
148
+
149
+
150
+ def acl_from_sddl(sddl: str, canonical: Callable[[str], str] = lambda sid: sid) -> Tuple[str, List[Dict[str, Any]]]:
151
+ """(owner SID, the ACEs that apply to the object itself as ``{type, sid, mask}``) from an SDDL owner-and-DACL
152
+ string. ``canonical`` turns an SDDL alias (SY, BA, WD…) into its SID. A missing or NULL DACL grants everyone
153
+ everything and is read as an allow ACE for Everyone (S-1-1-0); an inherit-only ACE (flag IO) applies to children
154
+ only and is not read; an unreadable rights field gives mask None, which the rule refuses."""
155
+ owner = sddl.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in sddl else ""
156
+ dacl = sddl.split("D:", 1)[1] if "D:" in sddl else ""
157
+ owner_sid = canonical(owner) if owner else ""
158
+ if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
159
+ return owner_sid, [{"type": "allow", "sid": "S-1-1-0", "mask": 0x1F01FF}]
160
+ aces = []
161
+ for text in _sddl_ace_strings(dacl):
162
+ fields = text.split(";", 6)
163
+ if len(fields) < 6: # unreadable: the rule refuses it
164
+ aces.append({"type": None, "sid": None, "mask": None})
165
+ continue
166
+ kind, flags, rights, trustee = fields[0], fields[1], fields[2], fields[5]
167
+ if "IO" in [flags[i:i + 2] for i in range(0, len(flags), 2)]:
168
+ continue
169
+ aces.append({"type": _SDDL_ACE_TYPES.get(kind, kind), "sid": canonical(trustee), "mask": _sddl_mask(rights)})
170
+ return owner_sid, aces
171
+
172
+
173
+ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
174
+ import ctypes
175
+ from ctypes import wintypes
176
+
177
+ _advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
178
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
179
+ _PVOID = ctypes.c_void_p
180
+ _SE_FILE_OBJECT, _OWNER_INFO, _DACL_INFO, _PROTECTED_DACL = 1, 0x1, 0x4, 0x80000000
181
+ _REPARSE_POINT = 0x400
182
+ _advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD] + [ctypes.POINTER(_PVOID)] * 5
183
+ _advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
184
+ _advapi32.SetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD] + [_PVOID] * 4
185
+ _advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD
186
+ _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW.argtypes = [
187
+ _PVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.LPWSTR), ctypes.POINTER(wintypes.ULONG)]
188
+ _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
189
+ wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_PVOID), ctypes.POINTER(wintypes.ULONG)]
190
+ _advapi32.GetSecurityDescriptorDacl.argtypes = [_PVOID, ctypes.POINTER(wintypes.BOOL), ctypes.POINTER(_PVOID),
191
+ ctypes.POINTER(wintypes.BOOL)]
192
+ _advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
193
+ _advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, _PVOID, wintypes.DWORD,
194
+ ctypes.POINTER(wintypes.DWORD)]
195
+ _advapi32.ConvertSidToStringSidW.argtypes = [_PVOID, ctypes.POINTER(wintypes.LPWSTR)]
196
+ _advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_PVOID)]
197
+ _kernel32.GetCurrentProcess.restype = wintypes.HANDLE
198
+ _kernel32.LocalFree.argtypes = [_PVOID]
199
+ _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
200
+ _CANONICAL: Dict[str, str] = {}
201
+
202
+ def _win_error(what: str, code: Optional[int] = None) -> OSError:
203
+ code = ctypes.get_last_error() if code is None else code
204
+ return OSError(code, "%s: %s" % (what, ctypes.FormatError(code).strip()))
205
+
206
+ def _sid_text(sid: Any) -> str:
207
+ text = wintypes.LPWSTR()
208
+ if not _advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
209
+ raise _win_error("ConvertSidToStringSidW")
210
+ try:
211
+ return text.value
212
+ finally:
213
+ _kernel32.LocalFree(text)
214
+
215
+ def _canonical_sid(trustee: str) -> str:
216
+ """SDDL writes well-known accounts by alias (SY, BA, LA, OW); compare SIDs."""
217
+ if trustee not in _CANONICAL:
218
+ sid = _PVOID()
219
+ if _advapi32.ConvertStringSidToSidW(trustee, ctypes.byref(sid)):
220
+ try:
221
+ _CANONICAL[trustee] = _sid_text(sid)
222
+ finally:
223
+ _kernel32.LocalFree(sid)
224
+ else:
225
+ _CANONICAL[trustee] = trustee
226
+ return _CANONICAL[trustee]
227
+
228
+ def windows_user_sid() -> str:
229
+ token = wintypes.HANDLE()
230
+ if not _advapi32.OpenProcessToken(_kernel32.GetCurrentProcess(), 0x8, ctypes.byref(token)):
231
+ raise _win_error("OpenProcessToken")
232
+ try:
233
+ size = wintypes.DWORD()
234
+ _advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
235
+ buf = ctypes.create_string_buffer(size.value)
236
+ if not _advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)):
237
+ raise _win_error("GetTokenInformation")
238
+ return _sid_text(ctypes.cast(buf, ctypes.POINTER(_PVOID))[0])
239
+ finally:
240
+ _kernel32.CloseHandle(token)
241
+
242
+ def _sddl(path: Path) -> str:
243
+ sd = _PVOID()
244
+ refs = [_PVOID() for _ in range(4)]
245
+ code = _advapi32.GetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _OWNER_INFO | _DACL_INFO,
246
+ *[ctypes.byref(r) for r in refs], ctypes.byref(sd))
247
+ if code:
248
+ raise _win_error("GetNamedSecurityInfoW", code)
249
+ try:
250
+ text = wintypes.LPWSTR()
251
+ if not _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW(
252
+ sd, 1, _OWNER_INFO | _DACL_INFO, ctypes.byref(text), None):
253
+ raise _win_error("ConvertSecurityDescriptorToStringSecurityDescriptorW")
254
+ try:
255
+ return text.value or ""
256
+ finally:
257
+ _kernel32.LocalFree(text)
258
+ finally:
259
+ _kernel32.LocalFree(sd)
260
+
261
+ def windows_acl(path: Path) -> Tuple[str, List[Dict[str, Any]]]:
262
+ """(owner SID, the ACEs on the object itself as ``{type, sid, mask}``), read from its SDDL (acl_from_sddl)."""
263
+ return acl_from_sddl(_sddl(path), _canonical_sid)
264
+
265
+ def _win_protect(path: Path, folder: bool) -> None:
266
+ """A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
267
+ inherited by what a folder holds."""
268
+ inherit = "OICI" if folder else ""
269
+ sddl = "D:P" + "".join("(A;%s;FA;;;%s)" % (inherit, who) for who in (windows_user_sid(), "SY", "BA"))
270
+ sd = _PVOID()
271
+ if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(sd), None):
272
+ raise _win_error("ConvertStringSecurityDescriptorToSecurityDescriptorW")
273
+ try:
274
+ present, defaulted, dacl = wintypes.BOOL(), wintypes.BOOL(), _PVOID()
275
+ if not _advapi32.GetSecurityDescriptorDacl(sd, ctypes.byref(present), ctypes.byref(dacl), ctypes.byref(defaulted)):
276
+ raise _win_error("GetSecurityDescriptorDacl")
277
+ code = _advapi32.SetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _DACL_INFO | _PROTECTED_DACL,
278
+ None, None, dacl, None)
279
+ if code:
280
+ raise _win_error("SetNamedSecurityInfoW", code)
281
+ finally:
282
+ _kernel32.LocalFree(sd)
283
+
284
+ _kernel32.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR] + [wintypes.DWORD] * 6 + [_PVOID]
285
+ _kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
286
+ _INVALID_HANDLE = wintypes.HANDLE(-1).value
287
+
288
+ def _create_first_pipe(name: str) -> Any:
289
+ """The pipe's first instance, or None when another process holds it (ERROR_ACCESS_DENIED)."""
290
+ # PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE; PIPE_REJECT_REMOTE_CLIENTS; unlimited instances,
291
+ # as libuv creates them: then a second first-instance attempt, ours or the Node kit's, is refused with
292
+ # ERROR_ACCESS_DENIED (Node: EADDRINUSE), never ERROR_PIPE_BUSY.
293
+ handle = _kernel32.CreateNamedPipeW(name, 0x1 | 0x00080000, 0x8, 255, 0, 0, 0, None)
294
+ if handle == _INVALID_HANDLE:
295
+ code = ctypes.get_last_error()
296
+ if code in (5, 231): # ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY: another process holds it
297
+ return None
298
+ raise _win_error("CreateNamedPipeW", code)
299
+ return handle
300
+
301
+ def _close_handle(handle: Any) -> None:
302
+ _kernel32.CloseHandle(handle)
303
+
304
+ _kernel32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
305
+ _kernel32.WaitNamedPipeW.restype = wintypes.BOOL
306
+
307
+ def _pipe_exists(name: str) -> bool:
308
+ """Some process holds the pipe: waiting for it does not fail with ERROR_FILE_NOT_FOUND. Nothing connects."""
309
+ if _kernel32.WaitNamedPipeW(name, 1):
310
+ return True
311
+ return ctypes.get_last_error() != 2
312
+
313
+ def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
314
+ profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
315
+ real = os.path.normcase(os.path.realpath(str(path)))
316
+ try:
317
+ inside = os.path.commonpath([profile, real]) == profile
318
+ except ValueError: # another drive
319
+ inside = False
320
+ owner, aces = windows_acl(path)
321
+ file = {"isRegularFile": stat.S_ISREG(info.st_mode), "isReparsePoint": bool(getattr(info, "st_file_attributes", 0) & _REPARSE_POINT),
322
+ "realPathInProfile": inside, "ownerSid": owner, "aces": aces}
323
+ return token_file_problem(path, file, windows_user_sid())
324
+ else:
325
+ def _create_first_pipe(name: str) -> Any: # noqa: ARG001
326
+ raise ServiceError("named pipes exist only on Windows")
327
+
328
+ def _close_handle(handle: Any) -> None: # noqa: ARG001
329
+ raise ServiceError("handles exist only on Windows")
330
+
331
+ def _pipe_exists(name: str) -> bool: # noqa: ARG001
332
+ raise ServiceError("named pipes exist only on Windows")
333
+
334
+ def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
335
+ raise ServiceError("Windows ACLs exist only on Windows")
336
+
337
+ def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]: # noqa: ARG001
338
+ raise ServiceError("Windows token rules apply only on Windows")
@@ -19,6 +19,10 @@ import shutil
19
19
  import subprocess
20
20
  from typing import Any, Callable, Dict, List, Optional, Sequence
21
21
 
22
+ import sys as _sys # noqa: E402
23
+ from pathlib import Path as _Path # noqa: E402
24
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
25
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
22
26
  import fabric_estate as _estate
23
27
  import fabric_identifiers as _ids
24
28
  import fabric_secrets as _secrets
@@ -32,34 +36,27 @@ MAX_PROBE_MS = 5000
32
36
  #: excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends.
33
37
  OPEN_VENDOR = "open-vendor"
34
38
  USER_CONTENT = "user-content"
35
- _BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
39
+ _BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z")
36
40
 
37
41
 
38
42
  class ProfileError(ValueError):
39
43
  pass
40
44
 
41
45
 
42
- def _list(value: Any) -> List[Dict[str, Any]]:
43
- return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
46
+ from fabric_jsvalue import objects as _list # noqa: E402 - the core's (FAA-65)
47
+ from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
48
+ from fabric_jsvalue import indexed_objects as _indexed # noqa: E402 - the core's (FAA-65)
49
+ import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
44
50
 
45
51
 
46
- _MISSING_KEY = _estate._MISSING
52
+ _MISSING_KEY = _jsv.MISSING
47
53
 
48
54
 
49
55
  def _at(value: Any, key: str) -> Any:
50
56
  """value[key] as JavaScript reads a member: absent is distinct from null (``_MISSING_KEY``)."""
51
- return _estate._get(value, key)
57
+ return _jsv.get(value, key)
52
58
 
53
59
 
54
- def _json_equal(a: Any, b: Any) -> bool:
55
- """The contract's jsonEqual on parsed JSON: arrays by item, objects by member whatever the order, anything else by
56
- ===, so 1 and true differ and NaN equals nothing."""
57
- if isinstance(a, list) or isinstance(b, list):
58
- return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
59
- if isinstance(a, dict) or isinstance(b, dict):
60
- return (isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b)
61
- and all(k in b and _json_equal(v, b[k]) for k, v in a.items()))
62
- return _estate._same(a, b) # NaN != NaN in Python too
63
60
 
64
61
 
65
62
  def _without_nan(value: Any) -> Any:
@@ -71,10 +68,6 @@ def _without_nan(value: Any) -> Any:
71
68
  return None if isinstance(value, float) and not math.isfinite(value) else value
72
69
 
73
70
 
74
- def _indexed(value: Any) -> List[tuple]:
75
- """The object items of an array with their index as written, so an instancePath names the item even after a
76
- non-object (the contract's ``indexed``)."""
77
- return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
78
71
 
79
72
 
80
73
  def _set_key(value: Any) -> Any:
@@ -87,7 +80,7 @@ def _set_key(value: Any) -> Any:
87
80
  if isinstance(value, bool):
88
81
  return ("boolean", value)
89
82
  if isinstance(value, (int, float)):
90
- return ("number", _estate._double(value))
83
+ return ("number", _jsv.double(value))
91
84
  if isinstance(value, str):
92
85
  return ("string", value)
93
86
  return ("object", id(value))
@@ -138,7 +131,7 @@ def _resolve(*, catalogue: Any = None, profiles: Any = None, choices: Any = None
138
131
  return {"from": level, "profile": profile_id, "models": within}
139
132
  meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
140
133
  return {"refused": "model-classes-empty", "profile": profile_id,
141
- "policy": {"id": _estate._show(_at(meta, "id")), "revision": _estate._js_to_number(_at(meta, "revision"))}}
134
+ "policy": {"id": _jsv.show(_at(meta, "id")), "revision": _jsv.js_to_number(_at(meta, "revision"))}}
142
135
 
143
136
 
144
137
  def _finding(code: str, path: str, message: str) -> Dict[str, str]:
@@ -171,16 +164,16 @@ def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
171
164
  def _runner_profile(value: Dict[str, Any]) -> List[Dict[str, str]]:
172
165
  findings = []
173
166
  if value.get("route") == "subscription" and isinstance(value.get("subscription"), dict):
174
- login = _estate._show(_at(value["subscription"], "login"))
167
+ login = _jsv.show(_at(value["subscription"], "login"))
175
168
  runner = SUBSCRIPTION_RUNNER.get(login, _MISSING_KEY)
176
- if not _estate._same(runner, _at(value, "runnerKind")):
169
+ if not _jsv.same(runner, _at(value, "runnerKind")):
177
170
  findings.append(_finding("FAC-SEM-059", "/runnerKind", "a %s subscription runs only in %s, not in %s; another runner takes the key route"
178
- % (login, runner if runner is not _MISSING_KEY else "its vendor's runner", _estate._show(_at(value, "runnerKind")))))
171
+ % (login, runner if runner is not _MISSING_KEY else "its vendor's runner", _jsv.show(_at(value, "runnerKind")))))
179
172
  if value.get("route") == "key" and isinstance(value.get("key"), dict):
180
173
  key = value["key"]
181
- if not _estate._same(_at(key, "modelAllowList"), _at(value, "modelProfile")):
174
+ if not _jsv.same(_at(key, "modelAllowList"), _at(value, "modelProfile")):
182
175
  findings.append(_finding("FAC-SEM-059", "/key/modelAllowList", "the key admits the models of %s, but the profile runs %s"
183
- % (_estate._show(_at(key, "modelAllowList")), _estate._show(_at(value, "modelProfile")))))
176
+ % (_jsv.show(_at(key, "modelAllowList")), _jsv.show(_at(value, "modelProfile")))))
184
177
  if isinstance(key.get("ref"), str) and _secrets.secret_value(key["ref"]):
185
178
  findings.append(_finding("FAC-SEM-059", "/key/ref", "the key is named by its secret reference, never by its value"))
186
179
  return findings
@@ -206,7 +199,7 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
206
199
  choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
207
200
  expected = _resolve(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
208
201
  if not _json_equal(_at(value, "resolved"), expected):
209
- findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _estate._json_stringify(_without_nan(expected))))
202
+ findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _jsv.json_stringify(_without_nan(expected))))
210
203
  return findings
211
204
 
212
205
 
@@ -223,7 +216,7 @@ def closed_user_content_route(profile: Any, catalogue: Any) -> Optional[List[str
223
216
  if isinstance(row.get("id"), str) and OPEN_VENDOR in _strings(row.get("classes"))}
224
217
  if any(isinstance(model, str) and model in open_ids for model in route):
225
218
  return None
226
- return [_estate._show(model) for model in route]
219
+ return [_jsv.show(model) for model in route]
227
220
 
228
221
 
229
222
  def _closed_route(fallback: Any, closed: List[str]) -> str:
@@ -257,7 +250,7 @@ def _open_vendor_routes(value: Dict[str, Any]) -> List[Dict[str, str]]:
257
250
  policy = value.get("policy")
258
251
  meta = policy.get("meta") if isinstance(policy, dict) and isinstance(policy.get("meta"), dict) else {}
259
252
  findings.append(_finding("FAC-SEM-112", "/resolved/models", "the policy %s leaves profile %s's lane on closed vendors alone: of the "
260
- "models it admits, %s open-vendor in the catalogue" % (_estate._show(meta["id"]) if "id" in meta else "undefined",
253
+ "models it admits, %s open-vendor in the catalogue" % (_jsv.show(meta["id"]) if "id" in meta else "undefined",
261
254
  _show_id(profile), _closed_route(profile.get("fallback"), closed))))
262
255
  return findings
263
256
 
@@ -265,7 +258,7 @@ def _open_vendor_routes(value: Dict[str, Any]) -> List[Dict[str, str]]:
265
258
 
266
259
  def _show_id(profile: Dict[str, Any]) -> str:
267
260
  """show(profile.id), where an absent id is JavaScript's undefined."""
268
- return _estate._show(profile["id"]) if "id" in profile else "undefined"
261
+ return _jsv.show(profile["id"]) if "id" in profile else "undefined"
269
262
 
270
263
 
271
264
  def profile_problems(kind: str, value: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
@@ -350,11 +343,11 @@ def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
350
343
  # The last row of an id wins, as a JavaScript Map built from the rows; only a string id can match a model's name.
351
344
  rows = {row["id"]: row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None) if isinstance(row.get("id"), str)}
352
345
  if not isinstance(model, str) or model not in rows:
353
- return ["model %s is not in the model catalogue" % _estate._show(model)]
346
+ return ["model %s is not in the model catalogue" % _jsv.show(model)]
354
347
  if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
355
348
  allowed = set(_strings(policy["modelClasses"]))
356
349
  meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
357
- policy_id = _estate._show(_at(meta, "id"))
350
+ policy_id = _jsv.show(_at(meta, "id"))
358
351
  admission = [c for c in _strings(rows[model].get("classes")) if c != OPEN_VENDOR] # DEC-0083: descriptive
359
352
  if not admission:
360
353
  return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, policy_id)]
@@ -406,7 +399,7 @@ _MISSING = object()
406
399
  def _field(value: Dict[str, Any], key: str) -> str:
407
400
  """The contract's show(value[key]): `undefined` for a missing key, `null` for null, and an object whose own
408
401
  `toString` is data as its JSON, where String() would throw."""
409
- return _estate._show(_at(value, key))
402
+ return _jsv.show(_at(value, key))
410
403
 
411
404
 
412
405
  def _object_at(value: Any, key: str) -> Dict[str, Any]:
@@ -501,7 +494,7 @@ def _index_of(ref: Dict[str, Any]) -> float:
501
494
  index = ref.get("index")
502
495
  if isinstance(index, bool) or not isinstance(index, (int, float)):
503
496
  return -1
504
- number = _estate._double(index)
497
+ number = _jsv.double(index)
505
498
  return number if math.isfinite(number) and number.is_integer() else -1
506
499
 
507
500
 
@@ -524,7 +517,7 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
524
517
  def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
525
518
  index = _index_of(ref)
526
519
  candidate = candidates[int(index)] if 0 <= index < len(candidates) else None
527
- if (candidate is None or not _estate._same(_at(candidate, "runnerKind"), _at(ref, "runnerKind"))
520
+ if (candidate is None or not _jsv.same(_at(candidate, "runnerKind"), _at(ref, "runnerKind"))
528
521
  or ("provider" in ref and not _same_ref(candidate.get("provider"), ref["provider"]))):
529
522
  wrong(path, "candidate %s (%s) is not that position of the route" % (_field(ref, "index"), _field(ref, "runnerKind")))
530
523
  return None
@@ -542,14 +535,14 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
542
535
  indexes = [_index_of(p) for p in probes]
543
536
  # Compared with 0...count-1 before any list is built, and the message lists at most 64 candidates, so a count from
544
537
  # an index of 2**32 allocates nothing (contract route-rules.ts at DEC-0084, from this kit's report).
545
- if _estate._same(len(indexes), count) and all(_estate._same(index, position) for position, index in enumerate(indexes)):
538
+ if _jsv.same(len(indexes), count) and all(_jsv.same(index, position) for position, index in enumerate(indexes)):
546
539
  return
547
540
  if count <= 64: # NaN is not; Array.from({length: count}) takes ToLength
548
- expected = ", ".join(map(_estate._js_string, range(int(count) if count > 0 else 0))) or "none"
541
+ expected = ", ".join(map(_jsv.js_string, range(int(count) if count > 0 else 0))) or "none"
549
542
  else:
550
- expected = "0 to %s" % _estate._js_string(count - 1)
543
+ expected = "0 to %s" % _jsv.js_string(count - 1)
551
544
  wrong("/probes", "the walk must record candidates %s in order; it records %s"
552
- % (expected, ", ".join(map(_estate._js_string, indexes)) or "none"))
545
+ % (expected, ", ".join(map(_jsv.js_string, indexes)) or "none"))
553
546
 
554
547
  if event.get("kind") == "runner-exhausted":
555
548
  expect_probes(len(candidates))
@@ -565,7 +558,7 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
565
558
  wrong("/selected/session", "%s has attach: never, so the host cannot attach it" % _field(selected, "runnerKind"))
566
559
  if selected.get("session") == "spawned" and session.get("spawn") != "allowed":
567
560
  wrong("/selected/session", "%s has spawn: never, so the host cannot spawn it" % _field(selected, "runnerKind"))
568
- if "drive" in chosen and "drive" in selected and not _estate._same(selected["drive"], chosen["drive"]):
561
+ if "drive" in chosen and "drive" in selected and not _jsv.same(selected["drive"], chosen["drive"]):
569
562
  wrong("/selected/drive", "the route runs %s through %s, not %s" % (_field(selected, "runnerKind"), _field(chosen, "drive"), _field(selected, "drive")))
570
563
  if event.get("sticky") is True:
571
564
  # A sticky relaunch keeps the conversation's runner and walks nothing above it.