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,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