@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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- 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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py
ADDED
|
@@ -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")
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
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 =
|
|
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
|
|
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",
|
|
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":
|
|
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 =
|
|
167
|
+
login = _jsv.show(_at(value["subscription"], "login"))
|
|
175
168
|
runner = SUBSCRIPTION_RUNNER.get(login, _MISSING_KEY)
|
|
176
|
-
if not
|
|
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",
|
|
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
|
|
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
|
-
% (
|
|
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" %
|
|
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 [
|
|
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" % (
|
|
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
|
|
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" %
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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" %
|
|
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(
|
|
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
|
|
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.
|