blun-king-cli 9.1.564 → 9.1.566

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 (88) hide show
  1. package/LIESMICH.txt +2 -2
  2. package/README.md +1 -1
  3. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  4. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  5. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  6. package/agent-spine-plugin/.mcp.json +8 -8
  7. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  8. package/agent-spine-plugin/LICENSE +186 -186
  9. package/agent-spine-plugin/README.md +406 -0
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  12. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  13. package/agent-spine-plugin/bin/agentspine.js +7 -7
  14. package/agent-spine-plugin/blun.plugin.json +33 -33
  15. package/agent-spine-plugin/hooks/codex.json +47 -47
  16. package/agent-spine-plugin/hooks/hooks.json +106 -106
  17. package/agent-spine-plugin/hooks/version.json +5 -5
  18. package/agent-spine-plugin/package.json +69 -69
  19. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  20. package/agent-spine-plugin/skill/SKILL.md +76 -76
  21. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  22. package/agent-spine-plugin/src/cli.js +1488 -1442
  23. package/agent-spine-plugin/src/hook.js +882 -861
  24. package/agent-spine-plugin/src/index.js +94 -93
  25. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  26. package/agent-spine-plugin/src/lib/attention.js +755 -755
  27. package/agent-spine-plugin/src/lib/audit.js +351 -342
  28. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  29. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  30. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  31. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  32. package/agent-spine-plugin/src/lib/context.js +154 -154
  33. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  34. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  35. package/agent-spine-plugin/src/lib/documents.js +217 -217
  36. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  37. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  38. package/agent-spine-plugin/src/lib/gateway-runtime.js +1623 -760
  39. package/agent-spine-plugin/src/lib/graph.js +337 -337
  40. package/agent-spine-plugin/src/lib/hook-audit.js +29 -29
  41. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  42. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  43. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  44. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  45. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  46. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  47. package/agent-spine-plugin/src/lib/paths.js +109 -109
  48. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  49. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  50. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  51. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  52. package/agent-spine-plugin/src/lib/selfstarter.js +911 -866
  53. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  54. package/agent-spine-plugin/src/lib/source-roots.js +530 -498
  55. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  56. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  57. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  58. package/agent-spine-plugin/src/mcp.js +597 -572
  59. package/agent-spine-plugin/src/version.js +1 -1
  60. package/agent-spine-plugin/src/worker.js +210 -195
  61. package/bin/active-steer-priority-policy.cjs +24 -0
  62. package/bin/launcher-runtime.js +8 -1
  63. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  64. package/bin/native-runtime-cache.cjs +76 -0
  65. package/bin/thinking-activity-status-policy.cjs +132 -0
  66. package/bin/thinking-only-guard.cjs +75 -0
  67. package/bin/tool-call-loop-policy.cjs +53 -0
  68. package/bin/turn-thinking-policy.cjs +25 -1
  69. package/blun.mjs +556 -63
  70. package/package.json +4 -1
  71. package/standard-skills/translate-native/LICENSE +21 -21
  72. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  73. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  74. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  75. package/standard-skills/translate-native/references/structured-content.md +72 -72
  76. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  77. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  78. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  79. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  80. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  81. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  82. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  83. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  84. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  85. package/standard-tools/language-guard/check_diacritics.py +353 -353
  86. package/standard-tools/language-guard/guard_service_client.py +264 -264
  87. package/standard-tools/language-guard/language_quality.py +377 -377
  88. 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