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.
- package/bin/glob-pattern-policy.cjs +13 -0
- package/bin/subagent-max-tokens-handoff-policy.cjs +14 -0
- package/bin/user-home-path-policy.cjs +13 -0
- package/blun.mjs +16 -1
- package/package.json +1 -1
- package/standard-skills/translate-native/SKILL.md +1 -1
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/provenance.json +2 -2
- package/standard-skills/translate-native/references/native-translation-standard.md +1 -1
- package/standard-skills/translate-native/scripts/blun_language_guard.py +16 -5
- package/standard-skills/translate-native/scripts/guard_service_client.py +183 -1
- package/standard-skills/translate-native/scripts/language_quality.py +214 -9
- package/standard-skills/translate-native/scripts/pre_output_guard.py +5 -8
- package/standard-tools/language-guard/blun_language_guard.py +33 -48
- package/standard-tools/language-guard/guard_service_client.py +183 -1
- package/standard-tools/language-guard/language_quality.py +214 -9
- package/standard-tools/language-guard/provenance.json +13 -0
- package/standard-tools/manifest.json +1 -1
|
@@ -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
|
@@ -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.
|
|
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.
|
|
1
|
+
6.42.13
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "translate-native",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"sourceCommit": "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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": {
|