blun-king-cli 9.1.563 → 9.1.565
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,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
|