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.
Files changed (85) hide show
  1. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  2. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  3. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  4. package/agent-spine-plugin/.mcp.json +8 -8
  5. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  6. package/agent-spine-plugin/LICENSE +186 -186
  7. package/agent-spine-plugin/README.md +394 -0
  8. package/agent-spine-plugin/SECURITY.md +47 -0
  9. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  10. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  11. package/agent-spine-plugin/bin/agentspine.js +7 -7
  12. package/agent-spine-plugin/blun.plugin.json +33 -33
  13. package/agent-spine-plugin/hooks/codex.json +47 -47
  14. package/agent-spine-plugin/hooks/hooks.json +106 -106
  15. package/agent-spine-plugin/hooks/version.json +5 -5
  16. package/agent-spine-plugin/package.json +69 -69
  17. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  18. package/agent-spine-plugin/skill/SKILL.md +76 -76
  19. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  20. package/agent-spine-plugin/src/cli.js +1488 -1442
  21. package/agent-spine-plugin/src/hook.js +886 -812
  22. package/agent-spine-plugin/src/index.js +93 -93
  23. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  24. package/agent-spine-plugin/src/lib/attention.js +755 -755
  25. package/agent-spine-plugin/src/lib/audit.js +351 -342
  26. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  27. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  28. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  29. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  30. package/agent-spine-plugin/src/lib/context.js +154 -154
  31. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  32. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  33. package/agent-spine-plugin/src/lib/documents.js +217 -217
  34. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  35. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  36. package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
  37. package/agent-spine-plugin/src/lib/graph.js +337 -337
  38. package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
  39. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  40. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  41. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  42. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  43. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  44. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  45. package/agent-spine-plugin/src/lib/paths.js +109 -109
  46. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  47. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  48. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  49. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  50. package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
  51. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  52. package/agent-spine-plugin/src/lib/source-roots.js +529 -482
  53. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  54. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  55. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  56. package/agent-spine-plugin/src/mcp.js +597 -572
  57. package/agent-spine-plugin/src/version.js +1 -1
  58. package/agent-spine-plugin/src/worker.js +202 -195
  59. package/bin/active-steer-priority-policy.cjs +24 -0
  60. package/bin/launcher-runtime.js +8 -1
  61. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  62. package/bin/thinking-activity-status-policy.cjs +132 -0
  63. package/bin/thinking-only-guard.cjs +75 -0
  64. package/bin/tool-call-loop-policy.cjs +53 -0
  65. package/bin/turn-thinking-policy.cjs +25 -1
  66. package/blun.mjs +554 -64
  67. package/package.json +4 -1
  68. package/standard-skills/translate-native/LICENSE +21 -21
  69. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  70. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  71. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  72. package/standard-skills/translate-native/references/structured-content.md +72 -72
  73. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  74. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  75. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  76. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  77. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  78. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  79. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  80. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  81. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  82. package/standard-tools/language-guard/check_diacritics.py +353 -353
  83. package/standard-tools/language-guard/guard_service_client.py +264 -264
  84. package/standard-tools/language-guard/language_quality.py +377 -377
  85. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,377 +1,377 @@
1
- #!/usr/bin/env python3
2
- """Language identity, bidi, glossary, and signed release-receipt primitives."""
3
-
4
- from __future__ import annotations
5
-
6
- import base64
7
- import contextlib
8
- import hashlib
9
- import hmac
10
- import json
11
- import os
12
- import re
13
- import stat
14
- import time
15
- import unicodedata
16
- from pathlib import Path
17
- from typing import Any
18
-
19
-
20
- VERSION = "6.20.0"
21
- MAX_SIGNING_KEY_BYTES = 64 * 1024
22
- DANGEROUS_BIDI = {"\u202a", "\u202b", "\u202c", "\u202d", "\u202e"}
23
- ISOLATE_OPENERS = {"\u2066", "\u2067", "\u2068"}
24
- ISOLATE_CLOSER = "\u2069"
25
- SCRIPT_RANGES = {
26
- "Cyrl": ((0x0400, 0x052F),),
27
- "Grek": ((0x0370, 0x03FF),),
28
- "Arab": ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF)),
29
- "Hebr": ((0x0590, 0x05FF),),
30
- "Hang": ((0xAC00, 0xD7AF),),
31
- "Hani": ((0x3400, 0x4DBF), (0x4E00, 0x9FFF)),
32
- "Jpan": ((0x3040, 0x30FF), (0x3400, 0x9FFF)),
33
- "Latn": ((0x0041, 0x007A), (0x00C0, 0x024F)),
34
- }
35
- LANGUAGE_SCRIPTS = {
36
- "ar": "Arab", "fa": "Arab", "ur": "Arab", "he": "Hebr",
37
- "el": "Grek", "ru": "Cyrl", "uk": "Cyrl", "bg": "Cyrl",
38
- "mk": "Cyrl", "ko": "Hang", "ja": "Jpan", "zh": "Hani",
39
- }
40
-
41
-
42
- def canonical_text(text: str) -> str:
43
- """Normalize transport-only differences without hiding character corruption."""
44
- return unicodedata.normalize("NFC", text.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n"))
45
-
46
-
47
- def canonical_hash(text: str) -> str:
48
- return hashlib.sha256(canonical_text(text).encode("utf-8")).hexdigest()
49
-
50
-
51
- def _b64encode(data: bytes) -> str:
52
- return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
53
-
54
-
55
- def _b64decode(data: str) -> bytes:
56
- return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
57
-
58
-
59
- def _validate_signing_key_stat(details: os.stat_result) -> None:
60
- if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
61
- raise ValueError("signing key must be a regular file")
62
- if details.st_nlink != 1:
63
- raise ValueError("signing key must have exactly one hard link")
64
- if details.st_size < 32 or details.st_size > MAX_SIGNING_KEY_BYTES:
65
- raise ValueError("signing key has an invalid size")
66
- if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o077:
67
- raise ValueError("signing key permissions must be owner-only")
68
- if hasattr(os, "getuid") and details.st_uid != os.getuid():
69
- raise ValueError("signing key has the wrong owner")
70
-
71
-
72
- def _signing_key_identity(details: os.stat_result) -> tuple[int, int, int, int, int, int]:
73
- return (
74
- details.st_dev,
75
- details.st_ino,
76
- details.st_nlink,
77
- details.st_size,
78
- details.st_ctime_ns,
79
- details.st_mtime_ns,
80
- )
81
-
82
-
83
- def _signing_key_directory_identity(
84
- details: os.stat_result,
85
- ) -> tuple[int, int, int, int, int]:
86
- return (
87
- details.st_dev,
88
- details.st_ino,
89
- details.st_mode,
90
- details.st_uid,
91
- details.st_gid,
92
- )
93
-
94
-
95
- def _validate_signing_key_directory(path: Path, details: os.stat_result) -> None:
96
- if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
97
- raise ValueError(f"signing-key directory is not a directory: {path}")
98
- if stat.S_IMODE(details.st_mode) & 0o022:
99
- raise ValueError(f"signing-key directory is writable outside its owner: {path}")
100
- if hasattr(os, "getuid") and details.st_uid != os.getuid():
101
- raise ValueError(f"signing-key directory has the wrong owner: {path}")
102
-
103
-
104
- def _assert_signing_key_directory_unchanged(
105
- path: Path, expected: tuple[int, int, int, int, int],
106
- ) -> None:
107
- try:
108
- current = path.lstat()
109
- except OSError as error:
110
- raise ValueError(f"signing-key directory cannot be rechecked: {path}") from error
111
- _validate_signing_key_directory(path, current)
112
- if _signing_key_directory_identity(current) != expected:
113
- raise ValueError(f"signing-key directory changed during operation: {path}")
114
-
115
-
116
- def _existing_signing_key_directory_anchor(path: Path) -> Path:
117
- candidate = path
118
- while True:
119
- try:
120
- candidate.lstat()
121
- return candidate
122
- except FileNotFoundError:
123
- parent = candidate.parent
124
- if parent == candidate:
125
- raise ValueError(f"signing-key directory has no existing anchor: {path}")
126
- candidate = parent
127
-
128
-
129
- @contextlib.contextmanager
130
- def _open_signing_key_directory(path: Path, *, create: bool):
131
- if os.name == "nt":
132
- if create:
133
- path.parent.mkdir(parents=True, exist_ok=True)
134
- yield None
135
- return
136
- anchor = Path.home()
137
- try:
138
- relative = path.parent.relative_to(anchor)
139
- except ValueError:
140
- anchor = _existing_signing_key_directory_anchor(path.parent)
141
- relative = path.parent.relative_to(anchor)
142
- flags = (
143
- os.O_RDONLY
144
- | getattr(os, "O_BINARY", 0)
145
- | getattr(os, "O_DIRECTORY", 0)
146
- | getattr(os, "O_NOFOLLOW", 0)
147
- )
148
- descriptor = None
149
- current_path = anchor
150
- try:
151
- try:
152
- descriptor = os.open(anchor, flags)
153
- _validate_signing_key_directory(
154
- anchor, os.fstat(descriptor),
155
- )
156
- for component in relative.parts:
157
- current_path = current_path / component
158
- try:
159
- child = os.open(component, flags, dir_fd=descriptor)
160
- except FileNotFoundError:
161
- if not create:
162
- raise
163
- try:
164
- os.mkdir(component, mode=0o700, dir_fd=descriptor)
165
- except FileExistsError:
166
- pass
167
- child = os.open(component, flags, dir_fd=descriptor)
168
- try:
169
- _validate_signing_key_directory(
170
- current_path, os.fstat(child),
171
- )
172
- except Exception:
173
- os.close(child)
174
- raise
175
- os.close(descriptor)
176
- descriptor = child
177
- expected = _signing_key_directory_identity(
178
- os.fstat(descriptor),
179
- )
180
- except ValueError:
181
- raise
182
- except FileNotFoundError:
183
- raise
184
- except OSError as error:
185
- raise ValueError(
186
- f"cannot safely open signing-key directory: {current_path}"
187
- ) from error
188
- try:
189
- yield descriptor
190
- finally:
191
- _assert_signing_key_directory_unchanged(path.parent, expected)
192
- finally:
193
- if descriptor is not None:
194
- os.close(descriptor)
195
-
196
-
197
- def _signing_key_lstat(path: Path, directory: int | None) -> os.stat_result:
198
- if directory is None:
199
- return os.lstat(path)
200
- return os.stat(path.name, dir_fd=directory, follow_symlinks=False)
201
-
202
-
203
- def _open_signing_key_file(
204
- path: Path, directory: int | None, flags: int, mode: int = 0o600,
205
- ) -> int:
206
- if directory is None:
207
- return os.open(path, flags, mode)
208
- return os.open(path.name, flags, mode, dir_fd=directory)
209
-
210
-
211
- def _signing_key_fstat(descriptor: int) -> os.stat_result:
212
- return os.fstat(descriptor)
213
-
214
-
215
- def _load_existing_key_at(path: Path, directory: int | None) -> bytes:
216
- before = _signing_key_lstat(path, directory)
217
- _validate_signing_key_stat(before)
218
- descriptor = _open_signing_key_file(
219
- path,
220
- directory,
221
- os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0),
222
- )
223
- try:
224
- opened = _signing_key_fstat(descriptor)
225
- _validate_signing_key_stat(opened)
226
- if _signing_key_identity(opened) != _signing_key_identity(before):
227
- raise ValueError("signing key changed while opening")
228
- with os.fdopen(descriptor, "rb", closefd=False) as handle:
229
- key = handle.read(MAX_SIGNING_KEY_BYTES + 1)
230
- after = _signing_key_fstat(descriptor)
231
- if _signing_key_identity(after) != _signing_key_identity(opened):
232
- raise ValueError("signing key changed while reading")
233
- finally:
234
- os.close(descriptor)
235
- if len(key) < 32 or len(key) > MAX_SIGNING_KEY_BYTES:
236
- raise ValueError("signing key has an invalid size")
237
- return key
238
-
239
-
240
- def load_existing_key(path: Path) -> bytes:
241
- with _open_signing_key_directory(path, create=False) as directory:
242
- return _load_existing_key_at(path, directory)
243
-
244
-
245
- def load_or_create_key(path: Path) -> bytes:
246
- env_key = os.environ.get("BLUN_LANGUAGE_GUARD_KEY")
247
- if env_key:
248
- return hashlib.sha256(env_key.encode("utf-8")).digest()
249
- with _open_signing_key_directory(path, create=True) as directory:
250
- try:
251
- return _load_existing_key_at(path, directory)
252
- except FileNotFoundError:
253
- pass
254
- key = os.urandom(32)
255
- flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
256
- try:
257
- descriptor = _open_signing_key_file(path, directory, flags)
258
- except FileExistsError:
259
- return _load_existing_key_at(path, directory)
260
- try:
261
- with os.fdopen(descriptor, "wb", closefd=False) as handle:
262
- handle.write(key)
263
- handle.flush()
264
- os.fsync(descriptor)
265
- opened = _signing_key_fstat(descriptor)
266
- _validate_signing_key_stat(opened)
267
- installed = _signing_key_lstat(path, directory)
268
- _validate_signing_key_stat(installed)
269
- if _signing_key_identity(installed) != _signing_key_identity(opened):
270
- raise ValueError("signing key changed while creating")
271
- finally:
272
- os.close(descriptor)
273
- return key
274
-
275
-
276
- def issue_receipt(
277
- source: str, target: str, language: str, key: bytes, ttl: int = 86400,
278
- content_type: str = "prose", short_text_reviewed: bool = False,
279
- purpose: str = "translation",
280
- ) -> str:
281
- now = int(time.time())
282
- payload = {
283
- "v": VERSION,
284
- "source_sha256": canonical_hash(source),
285
- "target_sha256": canonical_hash(target),
286
- "language": language,
287
- "purpose": purpose,
288
- "content_type": content_type,
289
- "short_text_reviewed": short_text_reviewed,
290
- "iat": now,
291
- "exp": now + max(60, min(ttl, 604800)),
292
- "nonce": _b64encode(os.urandom(12)),
293
- }
294
- encoded = _b64encode(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
295
- signature = _b64encode(hmac.new(key, encoded.encode(), hashlib.sha256).digest())
296
- return f"blg6.{encoded}.{signature}"
297
-
298
-
299
- def verify_receipt(
300
- token: str, source: str, target: str, language: str, key: bytes,
301
- content_type: str = "prose", short_text_reviewed: bool = False,
302
- purpose: str = "translation",
303
- ) -> dict[str, Any]:
304
- try:
305
- prefix, encoded, signature = token.split(".")
306
- expected = _b64encode(hmac.new(key, encoded.encode(), hashlib.sha256).digest())
307
- if prefix != "blg6" or not hmac.compare_digest(signature, expected):
308
- raise ValueError("invalid signature")
309
- payload = json.loads(_b64decode(encoded))
310
- checks = {
311
- "source": payload.get("source_sha256") == canonical_hash(source),
312
- "target": payload.get("target_sha256") == canonical_hash(target),
313
- "language": payload.get("language") == language,
314
- "purpose": payload.get("purpose") == purpose,
315
- "content_type": payload.get("content_type") == content_type,
316
- "short_text_reviewed": payload.get("short_text_reviewed") is short_text_reviewed,
317
- "version": payload.get("v") == VERSION,
318
- "not_expired": int(payload.get("exp", 0)) >= int(time.time()),
319
- }
320
- return {"valid": all(checks.values()), "checks": checks, "payload": payload}
321
- except (ValueError, TypeError, KeyError, json.JSONDecodeError, UnicodeDecodeError) as error:
322
- return {"valid": False, "error": str(error)}
323
-
324
-
325
- def bidi_findings(text: str) -> list[dict[str, str]]:
326
- findings: list[dict[str, str]] = []
327
- stack = 0
328
- for character in text:
329
- if character in DANGEROUS_BIDI:
330
- findings.append({"code": "dangerous-bidi-control", "character": f"U+{ord(character):04X}"})
331
- elif character in ISOLATE_OPENERS:
332
- stack += 1
333
- elif character == ISOLATE_CLOSER:
334
- if stack == 0:
335
- findings.append({"code": "unpaired-bidi-isolate", "character": "U+2069"})
336
- else:
337
- stack -= 1
338
- if stack:
339
- findings.append({"code": "unclosed-bidi-isolate", "count": str(stack)})
340
- return findings
341
-
342
-
343
- def _in_script(character: str, script: str) -> bool:
344
- point = ord(character)
345
- return any(start <= point <= end for start, end in SCRIPT_RANGES.get(script, ()))
346
-
347
-
348
- def script_report(text: str, language: str) -> dict[str, Any]:
349
- parts = language.replace("_", "-").split("-")
350
- base = parts[0].casefold()
351
- explicit = next((part.title() for part in parts[1:] if len(part) == 4), None)
352
- expected = explicit or LANGUAGE_SCRIPTS.get(base)
353
- if not expected:
354
- return {"status": "not-evaluated", "reason": "no deterministic script expectation"}
355
- letters = [c for c in text if unicodedata.category(c).startswith("L")]
356
- if not letters:
357
- return {"status": "fail", "expected_script": expected, "ratio": 0.0}
358
- matching = sum(_in_script(c, expected) for c in letters)
359
- ratio = matching / len(letters)
360
- threshold = 0.45 if expected in {"Hani", "Jpan"} else 0.70
361
- return {"status": "pass" if ratio >= threshold else "fail", "expected_script": expected, "ratio": round(ratio, 3)}
362
-
363
-
364
- def glossary_findings(target: str, glossary: dict[str, Any]) -> list[dict[str, str]]:
365
- findings: list[dict[str, str]] = []
366
- for source_term, rule in glossary.items():
367
- if isinstance(rule, str):
368
- pattern, flags = re.escape(rule), re.IGNORECASE
369
- elif isinstance(rule, dict):
370
- value = str(rule.get("target", ""))
371
- pattern = value if rule.get("regex") else re.escape(value)
372
- flags = 0 if rule.get("case_sensitive") else re.IGNORECASE
373
- else:
374
- continue
375
- if pattern and not re.search(pattern, target, flags):
376
- findings.append({"code": "missing-glossary-term", "source": source_term, "expected": pattern})
377
- return findings
1
+ #!/usr/bin/env python3
2
+ """Language identity, bidi, glossary, and signed release-receipt primitives."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import contextlib
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import os
12
+ import re
13
+ import stat
14
+ import time
15
+ import unicodedata
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ VERSION = "6.20.0"
21
+ MAX_SIGNING_KEY_BYTES = 64 * 1024
22
+ DANGEROUS_BIDI = {"\u202a", "\u202b", "\u202c", "\u202d", "\u202e"}
23
+ ISOLATE_OPENERS = {"\u2066", "\u2067", "\u2068"}
24
+ ISOLATE_CLOSER = "\u2069"
25
+ SCRIPT_RANGES = {
26
+ "Cyrl": ((0x0400, 0x052F),),
27
+ "Grek": ((0x0370, 0x03FF),),
28
+ "Arab": ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF)),
29
+ "Hebr": ((0x0590, 0x05FF),),
30
+ "Hang": ((0xAC00, 0xD7AF),),
31
+ "Hani": ((0x3400, 0x4DBF), (0x4E00, 0x9FFF)),
32
+ "Jpan": ((0x3040, 0x30FF), (0x3400, 0x9FFF)),
33
+ "Latn": ((0x0041, 0x007A), (0x00C0, 0x024F)),
34
+ }
35
+ LANGUAGE_SCRIPTS = {
36
+ "ar": "Arab", "fa": "Arab", "ur": "Arab", "he": "Hebr",
37
+ "el": "Grek", "ru": "Cyrl", "uk": "Cyrl", "bg": "Cyrl",
38
+ "mk": "Cyrl", "ko": "Hang", "ja": "Jpan", "zh": "Hani",
39
+ }
40
+
41
+
42
+ def canonical_text(text: str) -> str:
43
+ """Normalize transport-only differences without hiding character corruption."""
44
+ return unicodedata.normalize("NFC", text.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n"))
45
+
46
+
47
+ def canonical_hash(text: str) -> str:
48
+ return hashlib.sha256(canonical_text(text).encode("utf-8")).hexdigest()
49
+
50
+
51
+ def _b64encode(data: bytes) -> str:
52
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
53
+
54
+
55
+ def _b64decode(data: str) -> bytes:
56
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
57
+
58
+
59
+ def _validate_signing_key_stat(details: os.stat_result) -> None:
60
+ if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
61
+ raise ValueError("signing key must be a regular file")
62
+ if details.st_nlink != 1:
63
+ raise ValueError("signing key must have exactly one hard link")
64
+ if details.st_size < 32 or details.st_size > MAX_SIGNING_KEY_BYTES:
65
+ raise ValueError("signing key has an invalid size")
66
+ if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o077:
67
+ raise ValueError("signing key permissions must be owner-only")
68
+ if hasattr(os, "getuid") and details.st_uid != os.getuid():
69
+ raise ValueError("signing key has the wrong owner")
70
+
71
+
72
+ def _signing_key_identity(details: os.stat_result) -> tuple[int, int, int, int, int, int]:
73
+ return (
74
+ details.st_dev,
75
+ details.st_ino,
76
+ details.st_nlink,
77
+ details.st_size,
78
+ details.st_ctime_ns,
79
+ details.st_mtime_ns,
80
+ )
81
+
82
+
83
+ def _signing_key_directory_identity(
84
+ details: os.stat_result,
85
+ ) -> tuple[int, int, int, int, int]:
86
+ return (
87
+ details.st_dev,
88
+ details.st_ino,
89
+ details.st_mode,
90
+ details.st_uid,
91
+ details.st_gid,
92
+ )
93
+
94
+
95
+ def _validate_signing_key_directory(path: Path, details: os.stat_result) -> None:
96
+ if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
97
+ raise ValueError(f"signing-key directory is not a directory: {path}")
98
+ if stat.S_IMODE(details.st_mode) & 0o022:
99
+ raise ValueError(f"signing-key directory is writable outside its owner: {path}")
100
+ if hasattr(os, "getuid") and details.st_uid != os.getuid():
101
+ raise ValueError(f"signing-key directory has the wrong owner: {path}")
102
+
103
+
104
+ def _assert_signing_key_directory_unchanged(
105
+ path: Path, expected: tuple[int, int, int, int, int],
106
+ ) -> None:
107
+ try:
108
+ current = path.lstat()
109
+ except OSError as error:
110
+ raise ValueError(f"signing-key directory cannot be rechecked: {path}") from error
111
+ _validate_signing_key_directory(path, current)
112
+ if _signing_key_directory_identity(current) != expected:
113
+ raise ValueError(f"signing-key directory changed during operation: {path}")
114
+
115
+
116
+ def _existing_signing_key_directory_anchor(path: Path) -> Path:
117
+ candidate = path
118
+ while True:
119
+ try:
120
+ candidate.lstat()
121
+ return candidate
122
+ except FileNotFoundError:
123
+ parent = candidate.parent
124
+ if parent == candidate:
125
+ raise ValueError(f"signing-key directory has no existing anchor: {path}")
126
+ candidate = parent
127
+
128
+
129
+ @contextlib.contextmanager
130
+ def _open_signing_key_directory(path: Path, *, create: bool):
131
+ if os.name == "nt":
132
+ if create:
133
+ path.parent.mkdir(parents=True, exist_ok=True)
134
+ yield None
135
+ return
136
+ anchor = Path.home()
137
+ try:
138
+ relative = path.parent.relative_to(anchor)
139
+ except ValueError:
140
+ anchor = _existing_signing_key_directory_anchor(path.parent)
141
+ relative = path.parent.relative_to(anchor)
142
+ flags = (
143
+ os.O_RDONLY
144
+ | getattr(os, "O_BINARY", 0)
145
+ | getattr(os, "O_DIRECTORY", 0)
146
+ | getattr(os, "O_NOFOLLOW", 0)
147
+ )
148
+ descriptor = None
149
+ current_path = anchor
150
+ try:
151
+ try:
152
+ descriptor = os.open(anchor, flags)
153
+ _validate_signing_key_directory(
154
+ anchor, os.fstat(descriptor),
155
+ )
156
+ for component in relative.parts:
157
+ current_path = current_path / component
158
+ try:
159
+ child = os.open(component, flags, dir_fd=descriptor)
160
+ except FileNotFoundError:
161
+ if not create:
162
+ raise
163
+ try:
164
+ os.mkdir(component, mode=0o700, dir_fd=descriptor)
165
+ except FileExistsError:
166
+ pass
167
+ child = os.open(component, flags, dir_fd=descriptor)
168
+ try:
169
+ _validate_signing_key_directory(
170
+ current_path, os.fstat(child),
171
+ )
172
+ except Exception:
173
+ os.close(child)
174
+ raise
175
+ os.close(descriptor)
176
+ descriptor = child
177
+ expected = _signing_key_directory_identity(
178
+ os.fstat(descriptor),
179
+ )
180
+ except ValueError:
181
+ raise
182
+ except FileNotFoundError:
183
+ raise
184
+ except OSError as error:
185
+ raise ValueError(
186
+ f"cannot safely open signing-key directory: {current_path}"
187
+ ) from error
188
+ try:
189
+ yield descriptor
190
+ finally:
191
+ _assert_signing_key_directory_unchanged(path.parent, expected)
192
+ finally:
193
+ if descriptor is not None:
194
+ os.close(descriptor)
195
+
196
+
197
+ def _signing_key_lstat(path: Path, directory: int | None) -> os.stat_result:
198
+ if directory is None:
199
+ return os.lstat(path)
200
+ return os.stat(path.name, dir_fd=directory, follow_symlinks=False)
201
+
202
+
203
+ def _open_signing_key_file(
204
+ path: Path, directory: int | None, flags: int, mode: int = 0o600,
205
+ ) -> int:
206
+ if directory is None:
207
+ return os.open(path, flags, mode)
208
+ return os.open(path.name, flags, mode, dir_fd=directory)
209
+
210
+
211
+ def _signing_key_fstat(descriptor: int) -> os.stat_result:
212
+ return os.fstat(descriptor)
213
+
214
+
215
+ def _load_existing_key_at(path: Path, directory: int | None) -> bytes:
216
+ before = _signing_key_lstat(path, directory)
217
+ _validate_signing_key_stat(before)
218
+ descriptor = _open_signing_key_file(
219
+ path,
220
+ directory,
221
+ os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0),
222
+ )
223
+ try:
224
+ opened = _signing_key_fstat(descriptor)
225
+ _validate_signing_key_stat(opened)
226
+ if _signing_key_identity(opened) != _signing_key_identity(before):
227
+ raise ValueError("signing key changed while opening")
228
+ with os.fdopen(descriptor, "rb", closefd=False) as handle:
229
+ key = handle.read(MAX_SIGNING_KEY_BYTES + 1)
230
+ after = _signing_key_fstat(descriptor)
231
+ if _signing_key_identity(after) != _signing_key_identity(opened):
232
+ raise ValueError("signing key changed while reading")
233
+ finally:
234
+ os.close(descriptor)
235
+ if len(key) < 32 or len(key) > MAX_SIGNING_KEY_BYTES:
236
+ raise ValueError("signing key has an invalid size")
237
+ return key
238
+
239
+
240
+ def load_existing_key(path: Path) -> bytes:
241
+ with _open_signing_key_directory(path, create=False) as directory:
242
+ return _load_existing_key_at(path, directory)
243
+
244
+
245
+ def load_or_create_key(path: Path) -> bytes:
246
+ env_key = os.environ.get("BLUN_LANGUAGE_GUARD_KEY")
247
+ if env_key:
248
+ return hashlib.sha256(env_key.encode("utf-8")).digest()
249
+ with _open_signing_key_directory(path, create=True) as directory:
250
+ try:
251
+ return _load_existing_key_at(path, directory)
252
+ except FileNotFoundError:
253
+ pass
254
+ key = os.urandom(32)
255
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
256
+ try:
257
+ descriptor = _open_signing_key_file(path, directory, flags)
258
+ except FileExistsError:
259
+ return _load_existing_key_at(path, directory)
260
+ try:
261
+ with os.fdopen(descriptor, "wb", closefd=False) as handle:
262
+ handle.write(key)
263
+ handle.flush()
264
+ os.fsync(descriptor)
265
+ opened = _signing_key_fstat(descriptor)
266
+ _validate_signing_key_stat(opened)
267
+ installed = _signing_key_lstat(path, directory)
268
+ _validate_signing_key_stat(installed)
269
+ if _signing_key_identity(installed) != _signing_key_identity(opened):
270
+ raise ValueError("signing key changed while creating")
271
+ finally:
272
+ os.close(descriptor)
273
+ return key
274
+
275
+
276
+ def issue_receipt(
277
+ source: str, target: str, language: str, key: bytes, ttl: int = 86400,
278
+ content_type: str = "prose", short_text_reviewed: bool = False,
279
+ purpose: str = "translation",
280
+ ) -> str:
281
+ now = int(time.time())
282
+ payload = {
283
+ "v": VERSION,
284
+ "source_sha256": canonical_hash(source),
285
+ "target_sha256": canonical_hash(target),
286
+ "language": language,
287
+ "purpose": purpose,
288
+ "content_type": content_type,
289
+ "short_text_reviewed": short_text_reviewed,
290
+ "iat": now,
291
+ "exp": now + max(60, min(ttl, 604800)),
292
+ "nonce": _b64encode(os.urandom(12)),
293
+ }
294
+ encoded = _b64encode(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
295
+ signature = _b64encode(hmac.new(key, encoded.encode(), hashlib.sha256).digest())
296
+ return f"blg6.{encoded}.{signature}"
297
+
298
+
299
+ def verify_receipt(
300
+ token: str, source: str, target: str, language: str, key: bytes,
301
+ content_type: str = "prose", short_text_reviewed: bool = False,
302
+ purpose: str = "translation",
303
+ ) -> dict[str, Any]:
304
+ try:
305
+ prefix, encoded, signature = token.split(".")
306
+ expected = _b64encode(hmac.new(key, encoded.encode(), hashlib.sha256).digest())
307
+ if prefix != "blg6" or not hmac.compare_digest(signature, expected):
308
+ raise ValueError("invalid signature")
309
+ payload = json.loads(_b64decode(encoded))
310
+ checks = {
311
+ "source": payload.get("source_sha256") == canonical_hash(source),
312
+ "target": payload.get("target_sha256") == canonical_hash(target),
313
+ "language": payload.get("language") == language,
314
+ "purpose": payload.get("purpose") == purpose,
315
+ "content_type": payload.get("content_type") == content_type,
316
+ "short_text_reviewed": payload.get("short_text_reviewed") is short_text_reviewed,
317
+ "version": payload.get("v") == VERSION,
318
+ "not_expired": int(payload.get("exp", 0)) >= int(time.time()),
319
+ }
320
+ return {"valid": all(checks.values()), "checks": checks, "payload": payload}
321
+ except (ValueError, TypeError, KeyError, json.JSONDecodeError, UnicodeDecodeError) as error:
322
+ return {"valid": False, "error": str(error)}
323
+
324
+
325
+ def bidi_findings(text: str) -> list[dict[str, str]]:
326
+ findings: list[dict[str, str]] = []
327
+ stack = 0
328
+ for character in text:
329
+ if character in DANGEROUS_BIDI:
330
+ findings.append({"code": "dangerous-bidi-control", "character": f"U+{ord(character):04X}"})
331
+ elif character in ISOLATE_OPENERS:
332
+ stack += 1
333
+ elif character == ISOLATE_CLOSER:
334
+ if stack == 0:
335
+ findings.append({"code": "unpaired-bidi-isolate", "character": "U+2069"})
336
+ else:
337
+ stack -= 1
338
+ if stack:
339
+ findings.append({"code": "unclosed-bidi-isolate", "count": str(stack)})
340
+ return findings
341
+
342
+
343
+ def _in_script(character: str, script: str) -> bool:
344
+ point = ord(character)
345
+ return any(start <= point <= end for start, end in SCRIPT_RANGES.get(script, ()))
346
+
347
+
348
+ def script_report(text: str, language: str) -> dict[str, Any]:
349
+ parts = language.replace("_", "-").split("-")
350
+ base = parts[0].casefold()
351
+ explicit = next((part.title() for part in parts[1:] if len(part) == 4), None)
352
+ expected = explicit or LANGUAGE_SCRIPTS.get(base)
353
+ if not expected:
354
+ return {"status": "not-evaluated", "reason": "no deterministic script expectation"}
355
+ letters = [c for c in text if unicodedata.category(c).startswith("L")]
356
+ if not letters:
357
+ return {"status": "fail", "expected_script": expected, "ratio": 0.0}
358
+ matching = sum(_in_script(c, expected) for c in letters)
359
+ ratio = matching / len(letters)
360
+ threshold = 0.45 if expected in {"Hani", "Jpan"} else 0.70
361
+ return {"status": "pass" if ratio >= threshold else "fail", "expected_script": expected, "ratio": round(ratio, 3)}
362
+
363
+
364
+ def glossary_findings(target: str, glossary: dict[str, Any]) -> list[dict[str, str]]:
365
+ findings: list[dict[str, str]] = []
366
+ for source_term, rule in glossary.items():
367
+ if isinstance(rule, str):
368
+ pattern, flags = re.escape(rule), re.IGNORECASE
369
+ elif isinstance(rule, dict):
370
+ value = str(rule.get("target", ""))
371
+ pattern = value if rule.get("regex") else re.escape(value)
372
+ flags = 0 if rule.get("case_sensitive") else re.IGNORECASE
373
+ else:
374
+ continue
375
+ if pattern and not re.search(pattern, target, flags):
376
+ findings.append({"code": "missing-glossary-term", "source": source_term, "expected": pattern})
377
+ return findings