blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,264 +1,264 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Zero-dependency client for the isolated BLUN Language Guard service."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
import socket
|
|
9
|
-
import stat
|
|
10
|
-
from contextlib import contextmanager
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any, Iterator
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
16
|
-
MAX_SERVICE_TOKEN_BYTES = 64 * 1024
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class GuardServiceError(RuntimeError):
|
|
20
|
-
"""Raised when the isolated guard cannot evaluate a request safely."""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _token_file_identity(details: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
24
|
-
return (
|
|
25
|
-
details.st_dev,
|
|
26
|
-
details.st_ino,
|
|
27
|
-
details.st_nlink,
|
|
28
|
-
details.st_size,
|
|
29
|
-
details.st_ctime_ns,
|
|
30
|
-
details.st_mtime_ns,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _token_directory_identity(
|
|
35
|
-
details: os.stat_result,
|
|
36
|
-
) -> tuple[int, int, int, int, int]:
|
|
37
|
-
return (
|
|
38
|
-
details.st_dev,
|
|
39
|
-
details.st_ino,
|
|
40
|
-
details.st_mode,
|
|
41
|
-
details.st_uid,
|
|
42
|
-
details.st_gid,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _validate_token_directory(path: Path, details: os.stat_result) -> None:
|
|
47
|
-
if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
|
48
|
-
raise GuardServiceError(f"guard service token directory is invalid: {path}")
|
|
49
|
-
if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o022:
|
|
50
|
-
raise GuardServiceError(
|
|
51
|
-
f"guard service token directory is writable outside its owner: {path}"
|
|
52
|
-
)
|
|
53
|
-
if hasattr(os, "getuid") and details.st_uid != os.getuid():
|
|
54
|
-
raise GuardServiceError(f"guard service token directory has the wrong owner: {path}")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _existing_token_directory_anchor(path: Path) -> Path:
|
|
58
|
-
candidate = path
|
|
59
|
-
while True:
|
|
60
|
-
try:
|
|
61
|
-
candidate.lstat()
|
|
62
|
-
return candidate
|
|
63
|
-
except FileNotFoundError:
|
|
64
|
-
parent = candidate.parent
|
|
65
|
-
if parent == candidate:
|
|
66
|
-
raise GuardServiceError(
|
|
67
|
-
f"guard service token directory has no existing anchor: {path}"
|
|
68
|
-
)
|
|
69
|
-
candidate = parent
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _assert_token_directory_unchanged(
|
|
73
|
-
path: Path, expected: tuple[int, int, int, int, int],
|
|
74
|
-
) -> None:
|
|
75
|
-
try:
|
|
76
|
-
current = path.lstat()
|
|
77
|
-
except OSError as error:
|
|
78
|
-
raise GuardServiceError(
|
|
79
|
-
f"guard service token directory cannot be rechecked: {path}"
|
|
80
|
-
) from error
|
|
81
|
-
_validate_token_directory(path, current)
|
|
82
|
-
if _token_directory_identity(current) != expected:
|
|
83
|
-
raise GuardServiceError(
|
|
84
|
-
f"guard service token directory changed during read: {path}"
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@contextmanager
|
|
89
|
-
def _open_token_directory(path: Path) -> Iterator[int | None]:
|
|
90
|
-
if os.name == "nt":
|
|
91
|
-
yield None
|
|
92
|
-
return
|
|
93
|
-
anchor = Path.home()
|
|
94
|
-
try:
|
|
95
|
-
relative = path.parent.relative_to(anchor)
|
|
96
|
-
except ValueError:
|
|
97
|
-
anchor = _existing_token_directory_anchor(path.parent)
|
|
98
|
-
relative = path.parent.relative_to(anchor)
|
|
99
|
-
flags = (
|
|
100
|
-
os.O_RDONLY
|
|
101
|
-
| getattr(os, "O_BINARY", 0)
|
|
102
|
-
| getattr(os, "O_DIRECTORY", 0)
|
|
103
|
-
| getattr(os, "O_NOFOLLOW", 0)
|
|
104
|
-
)
|
|
105
|
-
descriptor = None
|
|
106
|
-
current_path = anchor
|
|
107
|
-
try:
|
|
108
|
-
try:
|
|
109
|
-
descriptor = os.open(anchor, flags)
|
|
110
|
-
_validate_token_directory(anchor, os.fstat(descriptor))
|
|
111
|
-
for component in relative.parts:
|
|
112
|
-
current_path = current_path / component
|
|
113
|
-
child = os.open(component, flags, dir_fd=descriptor)
|
|
114
|
-
try:
|
|
115
|
-
_validate_token_directory(current_path, os.fstat(child))
|
|
116
|
-
except Exception:
|
|
117
|
-
os.close(child)
|
|
118
|
-
raise
|
|
119
|
-
os.close(descriptor)
|
|
120
|
-
descriptor = child
|
|
121
|
-
expected = _token_directory_identity(os.fstat(descriptor))
|
|
122
|
-
except (FileNotFoundError, GuardServiceError):
|
|
123
|
-
raise
|
|
124
|
-
except OSError as error:
|
|
125
|
-
raise GuardServiceError(
|
|
126
|
-
f"guard service token directory cannot be opened safely: {current_path}"
|
|
127
|
-
) from error
|
|
128
|
-
try:
|
|
129
|
-
yield descriptor
|
|
130
|
-
finally:
|
|
131
|
-
_assert_token_directory_unchanged(path.parent, expected)
|
|
132
|
-
finally:
|
|
133
|
-
if descriptor is not None:
|
|
134
|
-
os.close(descriptor)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _validate_token_file(details: os.stat_result) -> None:
|
|
138
|
-
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
|
139
|
-
raise GuardServiceError("guard service token must be a regular file")
|
|
140
|
-
if details.st_nlink != 1:
|
|
141
|
-
raise GuardServiceError("guard service token must not have additional hard links")
|
|
142
|
-
if details.st_size < 32 or details.st_size > MAX_SERVICE_TOKEN_BYTES:
|
|
143
|
-
raise GuardServiceError("guard service token has an invalid size")
|
|
144
|
-
if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o077:
|
|
145
|
-
raise GuardServiceError("guard service token permissions must be owner-only")
|
|
146
|
-
if hasattr(os, "getuid") and details.st_uid != os.getuid():
|
|
147
|
-
raise GuardServiceError("guard service token has the wrong owner")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _token_lstat(path: Path, directory: int | None) -> os.stat_result:
|
|
151
|
-
if directory is None:
|
|
152
|
-
return os.lstat(path)
|
|
153
|
-
return os.stat(path.name, dir_fd=directory, follow_symlinks=False)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _open_token_file(path: Path, directory: int | None) -> int:
|
|
157
|
-
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
158
|
-
if directory is None:
|
|
159
|
-
return os.open(path, flags)
|
|
160
|
-
return os.open(path.name, flags, dir_fd=directory)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _token_fstat(descriptor: int) -> os.stat_result:
|
|
164
|
-
return os.fstat(descriptor)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def load_service_token(path: Path) -> str:
|
|
168
|
-
"""Read a bounded owner-only service token without following a replaced file."""
|
|
169
|
-
with _open_token_directory(path) as directory:
|
|
170
|
-
before = _token_lstat(path, directory)
|
|
171
|
-
_validate_token_file(before)
|
|
172
|
-
descriptor = _open_token_file(path, directory)
|
|
173
|
-
try:
|
|
174
|
-
opened = _token_fstat(descriptor)
|
|
175
|
-
_validate_token_file(opened)
|
|
176
|
-
if _token_file_identity(opened) != _token_file_identity(before):
|
|
177
|
-
raise GuardServiceError("guard service token changed while opening")
|
|
178
|
-
with os.fdopen(descriptor, "rb", closefd=False) as handle:
|
|
179
|
-
raw = handle.read(MAX_SERVICE_TOKEN_BYTES + 1)
|
|
180
|
-
after = _token_fstat(descriptor)
|
|
181
|
-
after_path = _token_lstat(path, directory)
|
|
182
|
-
if (
|
|
183
|
-
_token_file_identity(after) != _token_file_identity(opened)
|
|
184
|
-
or _token_file_identity(after_path) != _token_file_identity(opened)
|
|
185
|
-
):
|
|
186
|
-
raise GuardServiceError("guard service token changed while reading")
|
|
187
|
-
finally:
|
|
188
|
-
os.close(descriptor)
|
|
189
|
-
if len(raw) > MAX_SERVICE_TOKEN_BYTES:
|
|
190
|
-
raise GuardServiceError("guard service token has an invalid size")
|
|
191
|
-
try:
|
|
192
|
-
token = raw.decode("utf-8-sig").strip()
|
|
193
|
-
except UnicodeDecodeError as error:
|
|
194
|
-
raise GuardServiceError("guard service token is not valid UTF-8") from error
|
|
195
|
-
if len(token) < 32:
|
|
196
|
-
raise GuardServiceError("guard service token is invalid")
|
|
197
|
-
return token
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def parse_endpoint(endpoint: str) -> tuple[str, str | tuple[str, int]]:
|
|
201
|
-
value = str(endpoint or "").strip()
|
|
202
|
-
if value.startswith("unix:") and value[5:]:
|
|
203
|
-
return "unix", value[5:]
|
|
204
|
-
if value.startswith("tcp:"):
|
|
205
|
-
host_port = value[4:]
|
|
206
|
-
host, separator, raw_port = host_port.rpartition(":")
|
|
207
|
-
if separator and host in {"127.0.0.1", "localhost", "::1"}:
|
|
208
|
-
try:
|
|
209
|
-
port = int(raw_port)
|
|
210
|
-
except ValueError as error:
|
|
211
|
-
raise GuardServiceError("invalid guard service TCP port") from error
|
|
212
|
-
if 1 <= port <= 65535:
|
|
213
|
-
return "tcp", (host, port)
|
|
214
|
-
raise GuardServiceError("guard service endpoint must be unix:/path or loopback tcp:host:port")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def call_guard_service(
|
|
218
|
-
endpoint: str,
|
|
219
|
-
request: dict[str, Any],
|
|
220
|
-
*,
|
|
221
|
-
auth_token: str = "",
|
|
222
|
-
timeout: float = 10.0,
|
|
223
|
-
) -> dict[str, Any]:
|
|
224
|
-
transport, address = parse_endpoint(endpoint)
|
|
225
|
-
payload = dict(request)
|
|
226
|
-
if auth_token:
|
|
227
|
-
payload["service_token"] = auth_token
|
|
228
|
-
encoded = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
229
|
-
if len(encoded) > MAX_RESPONSE_BYTES:
|
|
230
|
-
raise GuardServiceError("guard service request is too large")
|
|
231
|
-
try:
|
|
232
|
-
if transport == "unix":
|
|
233
|
-
if not hasattr(socket, "AF_UNIX"):
|
|
234
|
-
raise GuardServiceError("Unix sockets are unavailable on this platform")
|
|
235
|
-
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
236
|
-
else:
|
|
237
|
-
client = socket.create_connection(address, timeout=timeout)
|
|
238
|
-
with client:
|
|
239
|
-
client.settimeout(timeout)
|
|
240
|
-
if transport == "unix":
|
|
241
|
-
client.connect(address)
|
|
242
|
-
client.sendall(encoded)
|
|
243
|
-
chunks: list[bytes] = []
|
|
244
|
-
size = 0
|
|
245
|
-
while True:
|
|
246
|
-
chunk = client.recv(65536)
|
|
247
|
-
if not chunk:
|
|
248
|
-
break
|
|
249
|
-
chunks.append(chunk)
|
|
250
|
-
size += len(chunk)
|
|
251
|
-
if size > MAX_RESPONSE_BYTES:
|
|
252
|
-
raise GuardServiceError("guard service response is too large")
|
|
253
|
-
if b"\n" in chunk:
|
|
254
|
-
break
|
|
255
|
-
except (OSError, TimeoutError) as error:
|
|
256
|
-
raise GuardServiceError("guard service is unavailable") from error
|
|
257
|
-
raw = b"".join(chunks).split(b"\n", 1)[0]
|
|
258
|
-
try:
|
|
259
|
-
response = json.loads(raw.decode("utf-8"))
|
|
260
|
-
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
261
|
-
raise GuardServiceError("guard service returned an invalid response") from error
|
|
262
|
-
if not isinstance(response, dict):
|
|
263
|
-
raise GuardServiceError("guard service response must be an object")
|
|
264
|
-
return response
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Zero-dependency client for the isolated BLUN Language Guard service."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import stat
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
MAX_SERVICE_TOKEN_BYTES = 64 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GuardServiceError(RuntimeError):
|
|
20
|
+
"""Raised when the isolated guard cannot evaluate a request safely."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _token_file_identity(details: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
24
|
+
return (
|
|
25
|
+
details.st_dev,
|
|
26
|
+
details.st_ino,
|
|
27
|
+
details.st_nlink,
|
|
28
|
+
details.st_size,
|
|
29
|
+
details.st_ctime_ns,
|
|
30
|
+
details.st_mtime_ns,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _token_directory_identity(
|
|
35
|
+
details: os.stat_result,
|
|
36
|
+
) -> tuple[int, int, int, int, int]:
|
|
37
|
+
return (
|
|
38
|
+
details.st_dev,
|
|
39
|
+
details.st_ino,
|
|
40
|
+
details.st_mode,
|
|
41
|
+
details.st_uid,
|
|
42
|
+
details.st_gid,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _validate_token_directory(path: Path, details: os.stat_result) -> None:
|
|
47
|
+
if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
|
48
|
+
raise GuardServiceError(f"guard service token directory is invalid: {path}")
|
|
49
|
+
if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o022:
|
|
50
|
+
raise GuardServiceError(
|
|
51
|
+
f"guard service token directory is writable outside its owner: {path}"
|
|
52
|
+
)
|
|
53
|
+
if hasattr(os, "getuid") and details.st_uid != os.getuid():
|
|
54
|
+
raise GuardServiceError(f"guard service token directory has the wrong owner: {path}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _existing_token_directory_anchor(path: Path) -> Path:
|
|
58
|
+
candidate = path
|
|
59
|
+
while True:
|
|
60
|
+
try:
|
|
61
|
+
candidate.lstat()
|
|
62
|
+
return candidate
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
parent = candidate.parent
|
|
65
|
+
if parent == candidate:
|
|
66
|
+
raise GuardServiceError(
|
|
67
|
+
f"guard service token directory has no existing anchor: {path}"
|
|
68
|
+
)
|
|
69
|
+
candidate = parent
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _assert_token_directory_unchanged(
|
|
73
|
+
path: Path, expected: tuple[int, int, int, int, int],
|
|
74
|
+
) -> None:
|
|
75
|
+
try:
|
|
76
|
+
current = path.lstat()
|
|
77
|
+
except OSError as error:
|
|
78
|
+
raise GuardServiceError(
|
|
79
|
+
f"guard service token directory cannot be rechecked: {path}"
|
|
80
|
+
) from error
|
|
81
|
+
_validate_token_directory(path, current)
|
|
82
|
+
if _token_directory_identity(current) != expected:
|
|
83
|
+
raise GuardServiceError(
|
|
84
|
+
f"guard service token directory changed during read: {path}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@contextmanager
|
|
89
|
+
def _open_token_directory(path: Path) -> Iterator[int | None]:
|
|
90
|
+
if os.name == "nt":
|
|
91
|
+
yield None
|
|
92
|
+
return
|
|
93
|
+
anchor = Path.home()
|
|
94
|
+
try:
|
|
95
|
+
relative = path.parent.relative_to(anchor)
|
|
96
|
+
except ValueError:
|
|
97
|
+
anchor = _existing_token_directory_anchor(path.parent)
|
|
98
|
+
relative = path.parent.relative_to(anchor)
|
|
99
|
+
flags = (
|
|
100
|
+
os.O_RDONLY
|
|
101
|
+
| getattr(os, "O_BINARY", 0)
|
|
102
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
103
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
104
|
+
)
|
|
105
|
+
descriptor = None
|
|
106
|
+
current_path = anchor
|
|
107
|
+
try:
|
|
108
|
+
try:
|
|
109
|
+
descriptor = os.open(anchor, flags)
|
|
110
|
+
_validate_token_directory(anchor, os.fstat(descriptor))
|
|
111
|
+
for component in relative.parts:
|
|
112
|
+
current_path = current_path / component
|
|
113
|
+
child = os.open(component, flags, dir_fd=descriptor)
|
|
114
|
+
try:
|
|
115
|
+
_validate_token_directory(current_path, os.fstat(child))
|
|
116
|
+
except Exception:
|
|
117
|
+
os.close(child)
|
|
118
|
+
raise
|
|
119
|
+
os.close(descriptor)
|
|
120
|
+
descriptor = child
|
|
121
|
+
expected = _token_directory_identity(os.fstat(descriptor))
|
|
122
|
+
except (FileNotFoundError, GuardServiceError):
|
|
123
|
+
raise
|
|
124
|
+
except OSError as error:
|
|
125
|
+
raise GuardServiceError(
|
|
126
|
+
f"guard service token directory cannot be opened safely: {current_path}"
|
|
127
|
+
) from error
|
|
128
|
+
try:
|
|
129
|
+
yield descriptor
|
|
130
|
+
finally:
|
|
131
|
+
_assert_token_directory_unchanged(path.parent, expected)
|
|
132
|
+
finally:
|
|
133
|
+
if descriptor is not None:
|
|
134
|
+
os.close(descriptor)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_token_file(details: os.stat_result) -> None:
|
|
138
|
+
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
|
139
|
+
raise GuardServiceError("guard service token must be a regular file")
|
|
140
|
+
if details.st_nlink != 1:
|
|
141
|
+
raise GuardServiceError("guard service token must not have additional hard links")
|
|
142
|
+
if details.st_size < 32 or details.st_size > MAX_SERVICE_TOKEN_BYTES:
|
|
143
|
+
raise GuardServiceError("guard service token has an invalid size")
|
|
144
|
+
if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o077:
|
|
145
|
+
raise GuardServiceError("guard service token permissions must be owner-only")
|
|
146
|
+
if hasattr(os, "getuid") and details.st_uid != os.getuid():
|
|
147
|
+
raise GuardServiceError("guard service token has the wrong owner")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _token_lstat(path: Path, directory: int | None) -> os.stat_result:
|
|
151
|
+
if directory is None:
|
|
152
|
+
return os.lstat(path)
|
|
153
|
+
return os.stat(path.name, dir_fd=directory, follow_symlinks=False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _open_token_file(path: Path, directory: int | None) -> int:
|
|
157
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
158
|
+
if directory is None:
|
|
159
|
+
return os.open(path, flags)
|
|
160
|
+
return os.open(path.name, flags, dir_fd=directory)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _token_fstat(descriptor: int) -> os.stat_result:
|
|
164
|
+
return os.fstat(descriptor)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load_service_token(path: Path) -> str:
|
|
168
|
+
"""Read a bounded owner-only service token without following a replaced file."""
|
|
169
|
+
with _open_token_directory(path) as directory:
|
|
170
|
+
before = _token_lstat(path, directory)
|
|
171
|
+
_validate_token_file(before)
|
|
172
|
+
descriptor = _open_token_file(path, directory)
|
|
173
|
+
try:
|
|
174
|
+
opened = _token_fstat(descriptor)
|
|
175
|
+
_validate_token_file(opened)
|
|
176
|
+
if _token_file_identity(opened) != _token_file_identity(before):
|
|
177
|
+
raise GuardServiceError("guard service token changed while opening")
|
|
178
|
+
with os.fdopen(descriptor, "rb", closefd=False) as handle:
|
|
179
|
+
raw = handle.read(MAX_SERVICE_TOKEN_BYTES + 1)
|
|
180
|
+
after = _token_fstat(descriptor)
|
|
181
|
+
after_path = _token_lstat(path, directory)
|
|
182
|
+
if (
|
|
183
|
+
_token_file_identity(after) != _token_file_identity(opened)
|
|
184
|
+
or _token_file_identity(after_path) != _token_file_identity(opened)
|
|
185
|
+
):
|
|
186
|
+
raise GuardServiceError("guard service token changed while reading")
|
|
187
|
+
finally:
|
|
188
|
+
os.close(descriptor)
|
|
189
|
+
if len(raw) > MAX_SERVICE_TOKEN_BYTES:
|
|
190
|
+
raise GuardServiceError("guard service token has an invalid size")
|
|
191
|
+
try:
|
|
192
|
+
token = raw.decode("utf-8-sig").strip()
|
|
193
|
+
except UnicodeDecodeError as error:
|
|
194
|
+
raise GuardServiceError("guard service token is not valid UTF-8") from error
|
|
195
|
+
if len(token) < 32:
|
|
196
|
+
raise GuardServiceError("guard service token is invalid")
|
|
197
|
+
return token
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_endpoint(endpoint: str) -> tuple[str, str | tuple[str, int]]:
|
|
201
|
+
value = str(endpoint or "").strip()
|
|
202
|
+
if value.startswith("unix:") and value[5:]:
|
|
203
|
+
return "unix", value[5:]
|
|
204
|
+
if value.startswith("tcp:"):
|
|
205
|
+
host_port = value[4:]
|
|
206
|
+
host, separator, raw_port = host_port.rpartition(":")
|
|
207
|
+
if separator and host in {"127.0.0.1", "localhost", "::1"}:
|
|
208
|
+
try:
|
|
209
|
+
port = int(raw_port)
|
|
210
|
+
except ValueError as error:
|
|
211
|
+
raise GuardServiceError("invalid guard service TCP port") from error
|
|
212
|
+
if 1 <= port <= 65535:
|
|
213
|
+
return "tcp", (host, port)
|
|
214
|
+
raise GuardServiceError("guard service endpoint must be unix:/path or loopback tcp:host:port")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def call_guard_service(
|
|
218
|
+
endpoint: str,
|
|
219
|
+
request: dict[str, Any],
|
|
220
|
+
*,
|
|
221
|
+
auth_token: str = "",
|
|
222
|
+
timeout: float = 10.0,
|
|
223
|
+
) -> dict[str, Any]:
|
|
224
|
+
transport, address = parse_endpoint(endpoint)
|
|
225
|
+
payload = dict(request)
|
|
226
|
+
if auth_token:
|
|
227
|
+
payload["service_token"] = auth_token
|
|
228
|
+
encoded = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
229
|
+
if len(encoded) > MAX_RESPONSE_BYTES:
|
|
230
|
+
raise GuardServiceError("guard service request is too large")
|
|
231
|
+
try:
|
|
232
|
+
if transport == "unix":
|
|
233
|
+
if not hasattr(socket, "AF_UNIX"):
|
|
234
|
+
raise GuardServiceError("Unix sockets are unavailable on this platform")
|
|
235
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
236
|
+
else:
|
|
237
|
+
client = socket.create_connection(address, timeout=timeout)
|
|
238
|
+
with client:
|
|
239
|
+
client.settimeout(timeout)
|
|
240
|
+
if transport == "unix":
|
|
241
|
+
client.connect(address)
|
|
242
|
+
client.sendall(encoded)
|
|
243
|
+
chunks: list[bytes] = []
|
|
244
|
+
size = 0
|
|
245
|
+
while True:
|
|
246
|
+
chunk = client.recv(65536)
|
|
247
|
+
if not chunk:
|
|
248
|
+
break
|
|
249
|
+
chunks.append(chunk)
|
|
250
|
+
size += len(chunk)
|
|
251
|
+
if size > MAX_RESPONSE_BYTES:
|
|
252
|
+
raise GuardServiceError("guard service response is too large")
|
|
253
|
+
if b"\n" in chunk:
|
|
254
|
+
break
|
|
255
|
+
except (OSError, TimeoutError) as error:
|
|
256
|
+
raise GuardServiceError("guard service is unavailable") from error
|
|
257
|
+
raw = b"".join(chunks).split(b"\n", 1)[0]
|
|
258
|
+
try:
|
|
259
|
+
response = json.loads(raw.decode("utf-8"))
|
|
260
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
261
|
+
raise GuardServiceError("guard service returned an invalid response") from error
|
|
262
|
+
if not isinstance(response, dict):
|
|
263
|
+
raise GuardServiceError("guard service response must be an object")
|
|
264
|
+
return response
|