blun-king-cli 9.1.479 → 9.1.481

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.
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ function quotedPattern(pattern) {
4
+ return `'${pattern.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
5
+ }
6
+
7
+ function globPatternError(pattern) {
8
+ if (typeof pattern !== "string") return "Invalid glob pattern";
9
+ if (!pattern.replaceAll("\\", "/").split("/").includes("..")) return undefined;
10
+ return `Path traversal not allowed in glob pattern ${quotedPattern(pattern)}`;
11
+ }
12
+
13
+ module.exports = { globPatternError };
@@ -23,6 +23,19 @@ function shouldRequestSubagentHandoff(stopReason, attempts) {
23
23
  && attempts < MAX_SUBAGENT_HANDOFF_ATTEMPTS;
24
24
  }
25
25
 
26
+ function isSubagentMaxTokensTurnError(error) {
27
+ if (typeof error !== "object" || error === null) return false;
28
+ const details = typeof error.details === "object" && error.details !== null
29
+ ? error.details
30
+ : {};
31
+ if (details.emptyResponseKind === "length") return true;
32
+ if (details.finishReason === "truncated" || details.rawFinishReason === "length") return true;
33
+
34
+ const message = typeof error.message === "string" ? error.message : "";
35
+ return /\bmax[_ -]?tokens?\b/iu.test(message)
36
+ && /(?:\bno\b|\bwithout\b|did not produce)[^\n.]*\btool(?: call)?\b/iu.test(message);
37
+ }
38
+
26
39
  function boundedPartialHandoff(partialSummary) {
27
40
  const normalized = typeof partialSummary === "string" ? partialSummary.trim() : "";
28
41
  if (normalized.length <= MAX_PARTIAL_HANDOFF_CHARS) return normalized;
@@ -51,5 +64,6 @@ module.exports = {
51
64
  SUBAGENT_MAX_TOKENS_ERROR,
52
65
  SUBAGENT_MAX_TOKENS_HANDOFF_PROMPT,
53
66
  buildSubagentMaxTokensFailure,
67
+ isSubagentMaxTokensTurnError,
54
68
  shouldRequestSubagentHandoff,
55
69
  };
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ function quotedPath(path) {
4
+ return `'${path.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
5
+ }
6
+
7
+ function unsupportedUserHomePathError(path) {
8
+ if (typeof path !== "string" || !path.startsWith("~")) return undefined;
9
+ if (path === "~" || path.startsWith("~/") || path.startsWith("~\\")) return undefined;
10
+ return `Named home-directory paths are not supported: ${quotedPath(path)}. Use '~/' for the current user or './' for a literal path.`;
11
+ }
12
+
13
+ module.exports = { unsupportedUserHomePathError };
package/blun.mjs CHANGED
@@ -14,6 +14,8 @@ import agentResumeSnapshot from "./bin/agent-resume-snapshot.cjs";
14
14
  import sessionResumeCheckpoint from "./bin/session-resume-checkpoint.cjs";
15
15
  import sessionScrollbackArchive from "./bin/session-scrollback-archive.cjs";
16
16
  import sessionReplayWindowPolicy from "./bin/session-replay-window-policy.cjs";
17
+ import globPatternPolicy from "./bin/glob-pattern-policy.cjs";
18
+ import userHomePathPolicy from "./bin/user-home-path-policy.cjs";
17
19
  import crypto$1, { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
18
20
  import * as fs$16 from "node:fs";
19
21
  import Kt, { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, promises, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
@@ -52,6 +54,8 @@ const { createAgentResumeSnapshot, restoreAgentResumeSnapshot } = agentResumeSna
52
54
  const { loadResumeCheckpoint, writeResumeCheckpoint } = sessionResumeCheckpoint;
53
55
  const { SessionScrollbackArchive, sessionScrollbackArchiveDirectory } = sessionScrollbackArchive;
54
56
  const { replayWindowStartIndex } = sessionReplayWindowPolicy;
57
+ const { globPatternError } = globPatternPolicy;
58
+ const { unsupportedUserHomePathError } = userHomePathPolicy;
55
59
  import { EventEmitter as EventEmitter$1 } from "node:events";
56
60
  import { StringDecoder } from "node:string_decoder";
57
61
  import co from "node:assert";
@@ -77727,6 +77731,8 @@ function relativeOutsideMessage(path, operation) {
77727
77731
  return `"${path}" is not an absolute path. You must provide an absolute path to ${operation === "write" ? "write or edit a file" : operation === "search" ? "search" : "read a file"} outside the working directory.`;
77728
77732
  }
77729
77733
  function resolvePathAccess(path, cwd, config, options) {
77734
+ const homePathError = unsupportedUserHomePathError(path);
77735
+ if (homePathError !== void 0) throw new PathSecurityError("PATH_INVALID", path, path, homePathError);
77730
77736
  const pathClass = options.pathClass ?? DEFAULT_PATH_CLASS;
77731
77737
  const expandedPath = expandUserPath$1(normalizeUserPath(path, pathClass), options.homeDir, pathClass);
77732
77738
  const rawIsAbsolute = isAbsolute$2(expandedPath);
@@ -252363,6 +252369,7 @@ async function runChildTurnToCompletion(child, signal) {
252363
252369
  const turnEnded = completion.event;
252364
252370
  if (turnEnded.reason !== "completed") {
252365
252371
  if (turnEnded.reason === "filtered") throw new Error("Subagent turn blocked by provider safety policy");
252372
+ if (isSubagentMaxTokensTurnError(turnEnded.error)) return "max_tokens";
252366
252373
  if (turnEnded.error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) throw providerRateLimitErrorFromPayload(turnEnded.error);
252367
252374
  if (turnEnded.error === void 0) throw new Error(`Subagent turn ${turnEnded.reason}`);
252368
252375
  const error = new Error(`[${turnEnded.error.code}] ${turnEnded.error.message}`);
@@ -252406,7 +252413,7 @@ function shouldSuppressQueuedAttemptFailureEvent(options, error) {
252406
252413
  if (isProviderRateLimitError(error)) return true;
252407
252414
  return isAbortError$4(error) || options.signal.aborted;
252408
252415
  }
252409
- var DEFAULT_SUBAGENT_TIMEOUT_MS, resolveSubagentTimeoutMs, runAgentWithTimeoutContinuation, buildSubagentUsageDelta, inheritSubagentAllowedTools, inheritSubagentLifecycleTools, SUBAGENT_MAX_TOKENS_ERROR, SUBAGENT_MAX_TOKENS_HANDOFF_PROMPT, buildSubagentMaxTokensFailure, shouldRequestSubagentHandoff, SUMMARY_MIN_LENGTH, SUMMARY_CONTINUATION_ATTEMPTS, HOOK_TEXT_PREVIEW_LENGTH, TOOL_CALL_DISABLED_MESSAGE, SUBAGENT_PROMPT_ORIGIN, SIDE_QUESTION_SYSTEM_REMINDER, SessionSubagentHost;
252416
+ var DEFAULT_SUBAGENT_TIMEOUT_MS, resolveSubagentTimeoutMs, runAgentWithTimeoutContinuation, buildSubagentUsageDelta, inheritSubagentAllowedTools, inheritSubagentLifecycleTools, SUBAGENT_MAX_TOKENS_ERROR, SUBAGENT_MAX_TOKENS_HANDOFF_PROMPT, buildSubagentMaxTokensFailure, isSubagentMaxTokensTurnError, shouldRequestSubagentHandoff, SUMMARY_MIN_LENGTH, SUMMARY_CONTINUATION_ATTEMPTS, HOOK_TEXT_PREVIEW_LENGTH, TOOL_CALL_DISABLED_MESSAGE, SUBAGENT_PROMPT_ORIGIN, SIDE_QUESTION_SYSTEM_REMINDER, SessionSubagentHost;
252410
252417
  var init_subagent_host = __esmMin((() => {
252411
252418
  init_src$4();
252412
252419
  init_errors$8();
@@ -252425,6 +252432,7 @@ var init_subagent_host = __esmMin((() => {
252425
252432
  SUBAGENT_MAX_TOKENS_ERROR,
252426
252433
  SUBAGENT_MAX_TOKENS_HANDOFF_PROMPT,
252427
252434
  buildSubagentMaxTokensFailure,
252435
+ isSubagentMaxTokensTurnError,
252428
252436
  shouldRequestSubagentHandoff
252429
252437
  } = createRequire(import.meta.url)("./bin/subagent-max-tokens-handoff-policy.cjs"));
252430
252438
  SUMMARY_MIN_LENGTH = 200;
@@ -258611,6 +258619,11 @@ glob_default = "Find files by glob pattern, sorted by modification time with the
258611
258619
  }
258612
258620
  async execution(args, signal, searchRoots) {
258613
258621
  const searchRoot = searchRoots[0] ?? this.workspace.workspaceDir;
258622
+ const patternError = globPatternError(args.pattern);
258623
+ if (patternError !== void 0) return {
258624
+ isError: true,
258625
+ output: patternError
258626
+ };
258614
258627
  try {
258615
258628
  if (((await this.kaos.stat(searchRoot)).stMode & S_IFMT$2) !== S_IFDIR$1) return {
258616
258629
  isError: true,
@@ -517648,6 +517661,7 @@ var BlunTUI = class {
517648
517661
  const items = this.state.queuedMessages.slice(0, batchEnd < 0 ? this.state.queuedMessages.length : batchEnd);
517649
517662
  if (items.length === 0) return false;
517650
517663
  this.state.queuedMessages = this.state.queuedMessages.slice(items.length);
517664
+ this.updateQueueDisplay();
517651
517665
  const transcriptRendered = items.map((queued) => queued.channelTranscriptRendered === true);
517652
517666
  const renderTranscripts = () => {
517653
517667
  items.forEach((queued, index) => {
@@ -517915,6 +517929,7 @@ var BlunTUI = class {
517915
517929
  }
517916
517930
  const items = this.state.queuedMessages.slice(0, prefixLength);
517917
517931
  this.state.queuedMessages = this.state.queuedMessages.slice(prefixLength);
517932
+ this.updateQueueDisplay();
517918
517933
  const turnId = this.streamingUI.getTurnContext().turnId;
517919
517934
  const inFlight = {
517920
517935
  items,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.479",
3
+ "version": "9.1.481",
4
4
  "description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -7,7 +7,7 @@ description: Translate, localize, rewrite, and review user-visible language with
7
7
 
8
8
  Use this skill for every translation, localization, translated rewrite, translation review, or user-visible internationalization edit.
9
9
 
10
- This bundled adaptation is based on translate-native 6.41.1. Read [references/native-translation-standard.md](references/native-translation-standard.md) and [references/evaluation-protocol.md](references/evaluation-protocol.md) for the full quality contract. For structured data, also read [references/structured-content.md](references/structured-content.md).
10
+ This bundled adaptation is based on translate-native 6.42.13. Read [references/native-translation-standard.md](references/native-translation-standard.md) and [references/evaluation-protocol.md](references/evaluation-protocol.md) for the full quality contract. For structured data, also read [references/structured-content.md](references/structured-content.md).
11
11
 
12
12
  ## Seven-pass review
13
13
 
@@ -1 +1 @@
1
- 6.41.1
1
+ 6.42.13
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "translate-native",
3
- "version": "6.41.1",
4
- "sourceCommit": "047aa2b4ba4c6a7b4797be8ed8ea6b7fd60728f0",
3
+ "version": "6.42.13",
4
+ "sourceCommit": "6430ce30e235c1a9d1ebcd1b646d62edce510605",
5
5
  "sourceRepository": "https://github.com/Maykbiletti/translate-native",
6
6
  "adaptation": "BLUN host policy replaces client-specific deployment text"
7
7
  }
@@ -68,7 +68,7 @@ A large dataset is not automatically authoritative. It may contain machine trans
68
68
 
69
69
  Useful starting points:
70
70
 
71
- - FLORES+ multilingual benchmark dataset
71
+ - FLORES+: <https://huggingface.co/datasets/openlanguagedata/flores_plus>
72
72
  - OPUS: <https://opus.nlpl.eu/>
73
73
  - Leipzig Corpora Collection: <https://cls.corpora.uni-leipzig.de/>
74
74
  - Unicode CLDR: <https://cldr.unicode.org/>
@@ -95,7 +95,21 @@ LANGUAGE_CHARACTER_PROFILES = {
95
95
  ASCII_FOLDING_PROFILES = {
96
96
  # Conventional transliterations whose density is measurable without a dictionary.
97
97
  # Thresholds deliberately avoid treating one ordinary letter sequence as proof.
98
- "de": {"patterns": (r"ae", r"oe", r"ue"), "native": "äöüÄÖÜ", "minimum": 3},
98
+ # German ``ue`` is also a native vowel sequence in words such as ``neue``
99
+ # and ``Abenteuer`` and in the productive loan suffix ``-uell``. Count it
100
+ # only where the surrounding letters still make an ASCII-folded umlaut
101
+ # plausible. The short-stem exception deliberately keeps ``Muell`` and
102
+ # similar folded forms measurable while accepting ``aktuell`` and
103
+ # ``individuell``.
104
+ "de": {
105
+ "patterns": (
106
+ r"ae",
107
+ r"oe",
108
+ r"(?<![aeiouyäöüq])(?:(?<![A-Za-zÄÖÜäöüß]{3})ue(?=ll)|ue(?!ll))",
109
+ ),
110
+ "native": "äöüÄÖÜ",
111
+ "minimum": 3,
112
+ },
99
113
  "sv": {"patterns": (r"aa", r"ae", r"oe"), "native": "åäöÅÄÖ", "minimum": 1},
100
114
  "da": {"patterns": (r"aa", r"ae", r"oe"), "native": "åæøÅÆØ", "minimum": 1},
101
115
  "no": {"patterns": (r"aa", r"ae", r"oe"), "native": "åæøÅÆØ", "minimum": 1},
@@ -117,10 +131,7 @@ def _service_token() -> str:
117
131
  return direct
118
132
  if not SERVICE_TOKEN_FILE:
119
133
  return ""
120
- token = Path(SERVICE_TOKEN_FILE).read_text(encoding="utf-8-sig").strip()
121
- if len(token) < 32:
122
- raise SERVICE_CLIENT.GuardServiceError("guard service token is invalid")
123
- return token
134
+ return SERVICE_CLIENT.load_service_token(Path(SERVICE_TOKEN_FILE))
124
135
 
125
136
 
126
137
  def _isolated_release(task_kind: str, arguments: dict[str, Any]) -> dict[str, Any] | None:
@@ -4,17 +4,199 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
+ import os
7
8
  import socket
8
- from typing import Any
9
+ import stat
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from typing import Any, Iterator
9
13
 
10
14
 
11
15
  MAX_RESPONSE_BYTES = 8 * 1024 * 1024
16
+ MAX_SERVICE_TOKEN_BYTES = 64 * 1024
12
17
 
13
18
 
14
19
  class GuardServiceError(RuntimeError):
15
20
  """Raised when the isolated guard cannot evaluate a request safely."""
16
21
 
17
22
 
23
+ def _token_file_identity(details: os.stat_result) -> tuple[int, int, int, int, int, int]:
24
+ return (
25
+ details.st_dev,
26
+ details.st_ino,
27
+ details.st_nlink,
28
+ details.st_size,
29
+ details.st_ctime_ns,
30
+ details.st_mtime_ns,
31
+ )
32
+
33
+
34
+ def _token_directory_identity(
35
+ details: os.stat_result,
36
+ ) -> tuple[int, int, int, int, int]:
37
+ return (
38
+ details.st_dev,
39
+ details.st_ino,
40
+ details.st_mode,
41
+ details.st_uid,
42
+ details.st_gid,
43
+ )
44
+
45
+
46
+ def _validate_token_directory(path: Path, details: os.stat_result) -> None:
47
+ if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
48
+ raise GuardServiceError(f"guard service token directory is invalid: {path}")
49
+ if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o022:
50
+ raise GuardServiceError(
51
+ f"guard service token directory is writable outside its owner: {path}"
52
+ )
53
+ if hasattr(os, "getuid") and details.st_uid != os.getuid():
54
+ raise GuardServiceError(f"guard service token directory has the wrong owner: {path}")
55
+
56
+
57
+ def _existing_token_directory_anchor(path: Path) -> Path:
58
+ candidate = path
59
+ while True:
60
+ try:
61
+ candidate.lstat()
62
+ return candidate
63
+ except FileNotFoundError:
64
+ parent = candidate.parent
65
+ if parent == candidate:
66
+ raise GuardServiceError(
67
+ f"guard service token directory has no existing anchor: {path}"
68
+ )
69
+ candidate = parent
70
+
71
+
72
+ def _assert_token_directory_unchanged(
73
+ path: Path, expected: tuple[int, int, int, int, int],
74
+ ) -> None:
75
+ try:
76
+ current = path.lstat()
77
+ except OSError as error:
78
+ raise GuardServiceError(
79
+ f"guard service token directory cannot be rechecked: {path}"
80
+ ) from error
81
+ _validate_token_directory(path, current)
82
+ if _token_directory_identity(current) != expected:
83
+ raise GuardServiceError(
84
+ f"guard service token directory changed during read: {path}"
85
+ )
86
+
87
+
88
+ @contextmanager
89
+ def _open_token_directory(path: Path) -> Iterator[int | None]:
90
+ if os.name == "nt":
91
+ yield None
92
+ return
93
+ anchor = Path.home()
94
+ try:
95
+ relative = path.parent.relative_to(anchor)
96
+ except ValueError:
97
+ anchor = _existing_token_directory_anchor(path.parent)
98
+ relative = path.parent.relative_to(anchor)
99
+ flags = (
100
+ os.O_RDONLY
101
+ | getattr(os, "O_BINARY", 0)
102
+ | getattr(os, "O_DIRECTORY", 0)
103
+ | getattr(os, "O_NOFOLLOW", 0)
104
+ )
105
+ descriptor = None
106
+ current_path = anchor
107
+ try:
108
+ try:
109
+ descriptor = os.open(anchor, flags)
110
+ _validate_token_directory(anchor, os.fstat(descriptor))
111
+ for component in relative.parts:
112
+ current_path = current_path / component
113
+ child = os.open(component, flags, dir_fd=descriptor)
114
+ try:
115
+ _validate_token_directory(current_path, os.fstat(child))
116
+ except Exception:
117
+ os.close(child)
118
+ raise
119
+ os.close(descriptor)
120
+ descriptor = child
121
+ expected = _token_directory_identity(os.fstat(descriptor))
122
+ except (FileNotFoundError, GuardServiceError):
123
+ raise
124
+ except OSError as error:
125
+ raise GuardServiceError(
126
+ f"guard service token directory cannot be opened safely: {current_path}"
127
+ ) from error
128
+ try:
129
+ yield descriptor
130
+ finally:
131
+ _assert_token_directory_unchanged(path.parent, expected)
132
+ finally:
133
+ if descriptor is not None:
134
+ os.close(descriptor)
135
+
136
+
137
+ def _validate_token_file(details: os.stat_result) -> None:
138
+ if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
139
+ raise GuardServiceError("guard service token must be a regular file")
140
+ if details.st_nlink != 1:
141
+ raise GuardServiceError("guard service token must not have additional hard links")
142
+ if details.st_size < 32 or details.st_size > MAX_SERVICE_TOKEN_BYTES:
143
+ raise GuardServiceError("guard service token has an invalid size")
144
+ if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o077:
145
+ raise GuardServiceError("guard service token permissions must be owner-only")
146
+ if hasattr(os, "getuid") and details.st_uid != os.getuid():
147
+ raise GuardServiceError("guard service token has the wrong owner")
148
+
149
+
150
+ def _token_lstat(path: Path, directory: int | None) -> os.stat_result:
151
+ if directory is None:
152
+ return os.lstat(path)
153
+ return os.stat(path.name, dir_fd=directory, follow_symlinks=False)
154
+
155
+
156
+ def _open_token_file(path: Path, directory: int | None) -> int:
157
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
158
+ if directory is None:
159
+ return os.open(path, flags)
160
+ return os.open(path.name, flags, dir_fd=directory)
161
+
162
+
163
+ def _token_fstat(descriptor: int) -> os.stat_result:
164
+ return os.fstat(descriptor)
165
+
166
+
167
+ def load_service_token(path: Path) -> str:
168
+ """Read a bounded owner-only service token without following a replaced file."""
169
+ with _open_token_directory(path) as directory:
170
+ before = _token_lstat(path, directory)
171
+ _validate_token_file(before)
172
+ descriptor = _open_token_file(path, directory)
173
+ try:
174
+ opened = _token_fstat(descriptor)
175
+ _validate_token_file(opened)
176
+ if _token_file_identity(opened) != _token_file_identity(before):
177
+ raise GuardServiceError("guard service token changed while opening")
178
+ with os.fdopen(descriptor, "rb", closefd=False) as handle:
179
+ raw = handle.read(MAX_SERVICE_TOKEN_BYTES + 1)
180
+ after = _token_fstat(descriptor)
181
+ after_path = _token_lstat(path, directory)
182
+ if (
183
+ _token_file_identity(after) != _token_file_identity(opened)
184
+ or _token_file_identity(after_path) != _token_file_identity(opened)
185
+ ):
186
+ raise GuardServiceError("guard service token changed while reading")
187
+ finally:
188
+ os.close(descriptor)
189
+ if len(raw) > MAX_SERVICE_TOKEN_BYTES:
190
+ raise GuardServiceError("guard service token has an invalid size")
191
+ try:
192
+ token = raw.decode("utf-8-sig").strip()
193
+ except UnicodeDecodeError as error:
194
+ raise GuardServiceError("guard service token is not valid UTF-8") from error
195
+ if len(token) < 32:
196
+ raise GuardServiceError("guard service token is invalid")
197
+ return token
198
+
199
+
18
200
  def parse_endpoint(endpoint: str) -> tuple[str, str | tuple[str, int]]:
19
201
  value = str(endpoint or "").strip()
20
202
  if value.startswith("unix:") and value[5:]:
@@ -4,11 +4,13 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import base64
7
+ import contextlib
7
8
  import hashlib
8
9
  import hmac
9
10
  import json
10
11
  import os
11
12
  import re
13
+ import stat
12
14
  import time
13
15
  import unicodedata
14
16
  from pathlib import Path
@@ -16,6 +18,7 @@ from typing import Any
16
18
 
17
19
 
18
20
  VERSION = "6.20.0"
21
+ MAX_SIGNING_KEY_BYTES = 64 * 1024
19
22
  DANGEROUS_BIDI = {"\u202a", "\u202b", "\u202c", "\u202d", "\u202e"}
20
23
  ISOLATE_OPENERS = {"\u2066", "\u2067", "\u2068"}
21
24
  ISOLATE_CLOSER = "\u2069"
@@ -53,19 +56,221 @@ def _b64decode(data: str) -> bytes:
53
56
  return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
54
57
 
55
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
+
56
245
  def load_or_create_key(path: Path) -> bytes:
57
246
  env_key = os.environ.get("BLUN_LANGUAGE_GUARD_KEY")
58
247
  if env_key:
59
248
  return hashlib.sha256(env_key.encode("utf-8")).digest()
60
- path.parent.mkdir(parents=True, exist_ok=True)
61
- if path.exists():
62
- return path.read_bytes()
63
- key = os.urandom(32)
64
- temporary = path.with_suffix(".tmp")
65
- temporary.write_bytes(key)
66
- os.chmod(temporary, 0o600)
67
- temporary.replace(path)
68
- return key
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
69
274
 
70
275
 
71
276
  def issue_receipt(
@@ -7,7 +7,6 @@ import hashlib
7
7
  import importlib.util
8
8
  import json
9
9
  import os
10
- import stat
11
10
  import sys
12
11
  from pathlib import Path
13
12
 
@@ -25,17 +24,15 @@ def load_verification_key(path: Path) -> bytes:
25
24
  if environment_key:
26
25
  return hashlib.sha256(environment_key.encode("utf-8")).digest()
27
26
  try:
28
- mode = stat.S_IMODE(path.stat().st_mode)
29
- if os.name != "nt" and mode & 0o077:
30
- raise ValueError("signing key permissions are broader than owner-only")
31
- key = path.read_bytes()
27
+ return QUALITY.load_existing_key(path)
32
28
  except FileNotFoundError as error:
33
29
  raise ValueError("signing key is missing; verification fails closed") from error
34
30
  except OSError as error:
35
31
  raise ValueError("signing key cannot be read") from error
36
- if len(key) < 32:
37
- raise ValueError("signing key is invalid")
38
- return key
32
+ except ValueError as error:
33
+ if "permissions" in str(error):
34
+ raise ValueError("signing key permissions are broader than owner-only") from error
35
+ raise ValueError("signing key is invalid") from error
39
36
 
40
37
 
41
38
  def main() -> int:
@@ -95,21 +95,21 @@ LANGUAGE_CHARACTER_PROFILES = {
95
95
  ASCII_FOLDING_PROFILES = {
96
96
  # Conventional transliterations whose density is measurable without a dictionary.
97
97
  # Thresholds deliberately avoid treating one ordinary letter sequence as proof.
98
- # German ``ue`` is also a native vowel sequence in words such as ``neue``
99
- # and ``Abenteuer`` and in the productive loan suffix ``-uell``. Count it
100
- # only where the surrounding letters still make an ASCII-folded umlaut
101
- # plausible. The short-stem exception deliberately keeps ``Muell`` and
102
- # similar folded forms measurable while accepting ``aktuell`` and
103
- # ``individuell``.
104
- "de": {
105
- "patterns": (
106
- r"ae",
107
- r"oe",
108
- r"(?<![aeiouyäöüq])(?:(?<![A-Za-zÄÖÜäöüß]{3})ue(?=ll)|ue(?!ll))",
109
- ),
110
- "native": "äöüÄÖÜ",
111
- "minimum": 3,
112
- },
98
+ # German ``ue`` is also a native vowel sequence in words such as ``neue``
99
+ # and ``Abenteuer`` and in the productive loan suffix ``-uell``. Count it
100
+ # only where the surrounding letters still make an ASCII-folded umlaut
101
+ # plausible. The short-stem exception deliberately keeps ``Muell`` and
102
+ # similar folded forms measurable while accepting ``aktuell`` and
103
+ # ``individuell``.
104
+ "de": {
105
+ "patterns": (
106
+ r"ae",
107
+ r"oe",
108
+ r"(?<![aeiouyäöüq])(?:(?<![A-Za-zÄÖÜäöüß]{3})ue(?=ll)|ue(?!ll))",
109
+ ),
110
+ "native": "äöüÄÖÜ",
111
+ "minimum": 3,
112
+ },
113
113
  "sv": {"patterns": (r"aa", r"ae", r"oe"), "native": "åäöÅÄÖ", "minimum": 1},
114
114
  "da": {"patterns": (r"aa", r"ae", r"oe"), "native": "åæøÅÆØ", "minimum": 1},
115
115
  "no": {"patterns": (r"aa", r"ae", r"oe"), "native": "åæøÅÆØ", "minimum": 1},
@@ -131,10 +131,7 @@ def _service_token() -> str:
131
131
  return direct
132
132
  if not SERVICE_TOKEN_FILE:
133
133
  return ""
134
- token = Path(SERVICE_TOKEN_FILE).read_text(encoding="utf-8-sig").strip()
135
- if len(token) < 32:
136
- raise SERVICE_CLIENT.GuardServiceError("guard service token is invalid")
137
- return token
134
+ return SERVICE_CLIENT.load_service_token(Path(SERVICE_TOKEN_FILE))
138
135
 
139
136
 
140
137
  def _isolated_release(task_kind: str, arguments: dict[str, Any]) -> dict[str, Any] | None:
@@ -173,14 +170,13 @@ def _languages_for(text: str, language: str) -> tuple[str, ...]:
173
170
  return (base,) if base in DIACRITICS.RULES else ()
174
171
 
175
172
 
176
- def validate_text(
177
- text: str,
178
- language: str = "auto",
179
- glossary: dict[str, Any] | None = None,
180
- content_type: str = "prose",
181
- short_text_reviewed: bool = False,
182
- require_character_profile: bool = True,
183
- ) -> dict[str, Any]:
173
+ def validate_text(
174
+ text: str,
175
+ language: str = "auto",
176
+ glossary: dict[str, Any] | None = None,
177
+ content_type: str = "prose",
178
+ short_text_reviewed: bool = False,
179
+ ) -> dict[str, Any]:
184
180
  findings: list[Finding] = []
185
181
  if not text.strip():
186
182
  findings.append(Finding("empty-target", "Target text is empty."))
@@ -200,12 +196,7 @@ def validate_text(
200
196
  base_language = language.casefold().replace("_", "-").split("-", 1)[0]
201
197
  profile = LANGUAGE_CHARACTER_PROFILES.get(base_language)
202
198
  profile_prose = DIACRITICS.mask_technical_text(text)
203
- if (
204
- require_character_profile
205
- and profile
206
- and len(profile_prose) >= 200
207
- and not any(character in profile for character in profile_prose)
208
- ):
199
+ if profile and len(profile_prose) >= 200 and not any(character in profile for character in profile_prose):
209
200
  findings.append(Finding(
210
201
  "missing-language-character-profile",
211
202
  f"Long {base_language} text contains none of the language's characteristic native characters; possible wholesale ASCII folding.",
@@ -392,14 +383,13 @@ def release_response(arguments: dict[str, Any]) -> dict[str, Any]:
392
383
  and language.casefold() not in {"auto", "all"}
393
384
  and EXACT_LANGUAGE_TAG.fullmatch(language) is not None
394
385
  )
395
- report = validate_text(
396
- target if target_is_text else "",
397
- language if isinstance(language, str) else "",
398
- arguments.get("glossary"),
399
- arguments.get("content_type", "prose"),
400
- arguments.get("short_text_reviewed") is True,
401
- False,
402
- )
386
+ report = validate_text(
387
+ target if target_is_text else "",
388
+ language if isinstance(language, str) else "",
389
+ arguments.get("glossary"),
390
+ arguments.get("content_type", "prose"),
391
+ arguments.get("short_text_reviewed") is True,
392
+ )
403
393
  report["checks"].append("agent-response-native-orthography")
404
394
  if not target_is_text:
405
395
  report["findings"].append(
@@ -670,13 +660,8 @@ def handle_message(message: dict[str, Any]) -> dict[str, Any] | None:
670
660
  }
671
661
 
672
662
 
673
- def serve() -> int:
674
- # MCP stdio is UTF-8; Windows otherwise decodes it using the system code page.
675
- for stream in (sys.stdin, sys.stdout):
676
- reconfigure = getattr(stream, "reconfigure", None)
677
- if reconfigure is not None:
678
- reconfigure(encoding="utf-8")
679
- for line in sys.stdin:
663
+ def serve() -> int:
664
+ for line in sys.stdin:
680
665
  if not line.strip():
681
666
  continue
682
667
  try:
@@ -4,17 +4,199 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
+ import os
7
8
  import socket
8
- from typing import Any
9
+ import stat
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from typing import Any, Iterator
9
13
 
10
14
 
11
15
  MAX_RESPONSE_BYTES = 8 * 1024 * 1024
16
+ MAX_SERVICE_TOKEN_BYTES = 64 * 1024
12
17
 
13
18
 
14
19
  class GuardServiceError(RuntimeError):
15
20
  """Raised when the isolated guard cannot evaluate a request safely."""
16
21
 
17
22
 
23
+ def _token_file_identity(details: os.stat_result) -> tuple[int, int, int, int, int, int]:
24
+ return (
25
+ details.st_dev,
26
+ details.st_ino,
27
+ details.st_nlink,
28
+ details.st_size,
29
+ details.st_ctime_ns,
30
+ details.st_mtime_ns,
31
+ )
32
+
33
+
34
+ def _token_directory_identity(
35
+ details: os.stat_result,
36
+ ) -> tuple[int, int, int, int, int]:
37
+ return (
38
+ details.st_dev,
39
+ details.st_ino,
40
+ details.st_mode,
41
+ details.st_uid,
42
+ details.st_gid,
43
+ )
44
+
45
+
46
+ def _validate_token_directory(path: Path, details: os.stat_result) -> None:
47
+ if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
48
+ raise GuardServiceError(f"guard service token directory is invalid: {path}")
49
+ if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o022:
50
+ raise GuardServiceError(
51
+ f"guard service token directory is writable outside its owner: {path}"
52
+ )
53
+ if hasattr(os, "getuid") and details.st_uid != os.getuid():
54
+ raise GuardServiceError(f"guard service token directory has the wrong owner: {path}")
55
+
56
+
57
+ def _existing_token_directory_anchor(path: Path) -> Path:
58
+ candidate = path
59
+ while True:
60
+ try:
61
+ candidate.lstat()
62
+ return candidate
63
+ except FileNotFoundError:
64
+ parent = candidate.parent
65
+ if parent == candidate:
66
+ raise GuardServiceError(
67
+ f"guard service token directory has no existing anchor: {path}"
68
+ )
69
+ candidate = parent
70
+
71
+
72
+ def _assert_token_directory_unchanged(
73
+ path: Path, expected: tuple[int, int, int, int, int],
74
+ ) -> None:
75
+ try:
76
+ current = path.lstat()
77
+ except OSError as error:
78
+ raise GuardServiceError(
79
+ f"guard service token directory cannot be rechecked: {path}"
80
+ ) from error
81
+ _validate_token_directory(path, current)
82
+ if _token_directory_identity(current) != expected:
83
+ raise GuardServiceError(
84
+ f"guard service token directory changed during read: {path}"
85
+ )
86
+
87
+
88
+ @contextmanager
89
+ def _open_token_directory(path: Path) -> Iterator[int | None]:
90
+ if os.name == "nt":
91
+ yield None
92
+ return
93
+ anchor = Path.home()
94
+ try:
95
+ relative = path.parent.relative_to(anchor)
96
+ except ValueError:
97
+ anchor = _existing_token_directory_anchor(path.parent)
98
+ relative = path.parent.relative_to(anchor)
99
+ flags = (
100
+ os.O_RDONLY
101
+ | getattr(os, "O_BINARY", 0)
102
+ | getattr(os, "O_DIRECTORY", 0)
103
+ | getattr(os, "O_NOFOLLOW", 0)
104
+ )
105
+ descriptor = None
106
+ current_path = anchor
107
+ try:
108
+ try:
109
+ descriptor = os.open(anchor, flags)
110
+ _validate_token_directory(anchor, os.fstat(descriptor))
111
+ for component in relative.parts:
112
+ current_path = current_path / component
113
+ child = os.open(component, flags, dir_fd=descriptor)
114
+ try:
115
+ _validate_token_directory(current_path, os.fstat(child))
116
+ except Exception:
117
+ os.close(child)
118
+ raise
119
+ os.close(descriptor)
120
+ descriptor = child
121
+ expected = _token_directory_identity(os.fstat(descriptor))
122
+ except (FileNotFoundError, GuardServiceError):
123
+ raise
124
+ except OSError as error:
125
+ raise GuardServiceError(
126
+ f"guard service token directory cannot be opened safely: {current_path}"
127
+ ) from error
128
+ try:
129
+ yield descriptor
130
+ finally:
131
+ _assert_token_directory_unchanged(path.parent, expected)
132
+ finally:
133
+ if descriptor is not None:
134
+ os.close(descriptor)
135
+
136
+
137
+ def _validate_token_file(details: os.stat_result) -> None:
138
+ if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
139
+ raise GuardServiceError("guard service token must be a regular file")
140
+ if details.st_nlink != 1:
141
+ raise GuardServiceError("guard service token must not have additional hard links")
142
+ if details.st_size < 32 or details.st_size > MAX_SERVICE_TOKEN_BYTES:
143
+ raise GuardServiceError("guard service token has an invalid size")
144
+ if os.name != "nt" and stat.S_IMODE(details.st_mode) & 0o077:
145
+ raise GuardServiceError("guard service token permissions must be owner-only")
146
+ if hasattr(os, "getuid") and details.st_uid != os.getuid():
147
+ raise GuardServiceError("guard service token has the wrong owner")
148
+
149
+
150
+ def _token_lstat(path: Path, directory: int | None) -> os.stat_result:
151
+ if directory is None:
152
+ return os.lstat(path)
153
+ return os.stat(path.name, dir_fd=directory, follow_symlinks=False)
154
+
155
+
156
+ def _open_token_file(path: Path, directory: int | None) -> int:
157
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
158
+ if directory is None:
159
+ return os.open(path, flags)
160
+ return os.open(path.name, flags, dir_fd=directory)
161
+
162
+
163
+ def _token_fstat(descriptor: int) -> os.stat_result:
164
+ return os.fstat(descriptor)
165
+
166
+
167
+ def load_service_token(path: Path) -> str:
168
+ """Read a bounded owner-only service token without following a replaced file."""
169
+ with _open_token_directory(path) as directory:
170
+ before = _token_lstat(path, directory)
171
+ _validate_token_file(before)
172
+ descriptor = _open_token_file(path, directory)
173
+ try:
174
+ opened = _token_fstat(descriptor)
175
+ _validate_token_file(opened)
176
+ if _token_file_identity(opened) != _token_file_identity(before):
177
+ raise GuardServiceError("guard service token changed while opening")
178
+ with os.fdopen(descriptor, "rb", closefd=False) as handle:
179
+ raw = handle.read(MAX_SERVICE_TOKEN_BYTES + 1)
180
+ after = _token_fstat(descriptor)
181
+ after_path = _token_lstat(path, directory)
182
+ if (
183
+ _token_file_identity(after) != _token_file_identity(opened)
184
+ or _token_file_identity(after_path) != _token_file_identity(opened)
185
+ ):
186
+ raise GuardServiceError("guard service token changed while reading")
187
+ finally:
188
+ os.close(descriptor)
189
+ if len(raw) > MAX_SERVICE_TOKEN_BYTES:
190
+ raise GuardServiceError("guard service token has an invalid size")
191
+ try:
192
+ token = raw.decode("utf-8-sig").strip()
193
+ except UnicodeDecodeError as error:
194
+ raise GuardServiceError("guard service token is not valid UTF-8") from error
195
+ if len(token) < 32:
196
+ raise GuardServiceError("guard service token is invalid")
197
+ return token
198
+
199
+
18
200
  def parse_endpoint(endpoint: str) -> tuple[str, str | tuple[str, int]]:
19
201
  value = str(endpoint or "").strip()
20
202
  if value.startswith("unix:") and value[5:]:
@@ -4,11 +4,13 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import base64
7
+ import contextlib
7
8
  import hashlib
8
9
  import hmac
9
10
  import json
10
11
  import os
11
12
  import re
13
+ import stat
12
14
  import time
13
15
  import unicodedata
14
16
  from pathlib import Path
@@ -16,6 +18,7 @@ from typing import Any
16
18
 
17
19
 
18
20
  VERSION = "6.20.0"
21
+ MAX_SIGNING_KEY_BYTES = 64 * 1024
19
22
  DANGEROUS_BIDI = {"\u202a", "\u202b", "\u202c", "\u202d", "\u202e"}
20
23
  ISOLATE_OPENERS = {"\u2066", "\u2067", "\u2068"}
21
24
  ISOLATE_CLOSER = "\u2069"
@@ -53,19 +56,221 @@ def _b64decode(data: str) -> bytes:
53
56
  return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
54
57
 
55
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
+
56
245
  def load_or_create_key(path: Path) -> bytes:
57
246
  env_key = os.environ.get("BLUN_LANGUAGE_GUARD_KEY")
58
247
  if env_key:
59
248
  return hashlib.sha256(env_key.encode("utf-8")).digest()
60
- path.parent.mkdir(parents=True, exist_ok=True)
61
- if path.exists():
62
- return path.read_bytes()
63
- key = os.urandom(32)
64
- temporary = path.with_suffix(".tmp")
65
- temporary.write_bytes(key)
66
- os.chmod(temporary, 0o600)
67
- temporary.replace(path)
68
- return key
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
69
274
 
70
275
 
71
276
  def issue_receipt(
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "blun-language-guard",
3
+ "version": "6.42.13",
4
+ "sourceCommit": "6430ce30e235c1a9d1ebcd1b646d62edce510605",
5
+ "sourceRepository": "https://github.com/Maykbiletti/translate-native",
6
+ "files": {
7
+ "blun_language_guard.py": "37ec76a20ac0656397e7923cd5b40800054cc95119de73e2fe6f437d760dda01",
8
+ "check_diacritics.py": "96d7a0faa4f6a8c827bcf6fe065975fc803389fa4a5363108383da4b9bbc995b",
9
+ "guard_service_client.py": "e3c000f2e5994738cb219cc82d14466d558206e7c7dd6a0e7d184071d8791f48",
10
+ "language_quality.py": "46fe4f2b22205d6d5514bc1d0d7354ae862395f53134c7d82d892440b98e8529",
11
+ "translation_guard.py": "56e74c2432aeeaecabfd58ba30ec507aab1d42c3e32fa1b39307c1043c8c7ac2"
12
+ }
13
+ }
@@ -18,7 +18,7 @@
18
18
  "blun-language-guard": {
19
19
  "owner": "user-mcp",
20
20
  "version": "host-provisioned",
21
- "skillVersion": "6.41.1",
21
+ "skillVersion": "6.42.13",
22
22
  "detectTokens": ["blun-language-guard", "blun_language_guard.py"],
23
23
  "provisioning": "The npm package provides the zero-dependency Python runtime files.",
24
24
  "config": {