@sema-agent/settings-schema 6.2.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +21 -9
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +166 -0
  5. package/conformance/cases/c41-s792-encoded-pem-identity/input.json +62 -0
  6. package/conformance/cases/c41-s792-encoded-pem-identity/meta.json +12 -0
  7. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +120 -0
  8. package/conformance/cases/c42-s792-unknown-key-secret-redacted/input.json +25 -0
  9. package/conformance/cases/c42-s792-unknown-key-secret-redacted/meta.json +11 -0
  10. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +137 -0
  11. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/input.json +44 -0
  12. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/meta.json +12 -0
  13. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +115 -0
  14. package/conformance/cases/c44-s792-utf8-escape-glued-token/input.json +30 -0
  15. package/conformance/cases/c44-s792-utf8-escape-glued-token/meta.json +10 -0
  16. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +185 -0
  17. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/input.json +78 -0
  18. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +11 -0
  19. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/expected.json +88 -0
  20. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/input.json +18 -0
  21. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +6 -0
  22. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/expected.json +98 -0
  23. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/input.json +26 -0
  24. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +6 -0
  25. package/conformance/cases/c48-s935-model-headers-transported/expected.json +115 -0
  26. package/conformance/cases/c48-s935-model-headers-transported/input.json +37 -0
  27. package/conformance/cases/c48-s935-model-headers-transported/meta.json +6 -0
  28. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +98 -0
  29. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/input.json +28 -0
  30. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/meta.json +9 -0
  31. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +0 -8
  32. package/conformance/cases/cli01-model-name-nfc-vs-nfd/meta.json +2 -1
  33. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +0 -8
  34. package/conformance/cases/cli02-control-and-zero-width-chars/meta.json +2 -1
  35. package/dist/config-fns.d.ts +53 -22
  36. package/dist/config-fns.js +87 -33
  37. package/dist/cross-session.d.ts +8 -5
  38. package/dist/cross-session.js +8 -5
  39. package/dist/fleet.d.ts +3 -2
  40. package/dist/fleet.js +3 -2
  41. package/dist/hash.d.ts +3 -3
  42. package/dist/hash.js +3 -3
  43. package/dist/hooks.d.ts +8 -8
  44. package/dist/secret-scan.d.ts +39 -0
  45. package/dist/secret-scan.js +110 -0
  46. package/dist/types.d.ts +65 -7
  47. package/dist/types.js +57 -44
  48. package/package.json +1 -1
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 秘密扫描读的**归一化**,外加「URL 的 userinfo 就是凭据」那一只判定({@link carriesUserinfo})—— 包内私有模块(6.3.0):不进主 barrel、
3
+ * 不进 `package.json` 的 `exports`,消费方拿不到。
4
+ * 校验侧探测器 `containsSecretToken`(`./types`)只读 {@link percentLayers};展示侧脱敏器 `redactSecrets`(`./config-fns`)
5
+ * 读 {@link secretScanForms}。展示侧的读法(边界读法、`+` 读法、「没走完」那一位)刻意不做公开契约:它们以后要随新发现的
6
+ * 编码形调整,调整不该是契约变更(opus 独立腿 L7)。
7
+ *
8
+ * **单遍、零异常**(opus 独立腿 M1,对 6.2.0 的读路径 CPU 回归):6.2.0 起的宽容解码对每个 `%HH` 各 `try decodeURIComponent`,
9
+ * 非 ASCII 的转义每个都抛一次异常(≈ 20–30 µs / 字节,同步阻塞);6.3.0 首稿又让探测器顺带算它不用的展示形。这里两处解码都
10
+ * 不靠异常:单个转义的判据写成字符集(只有 ASCII 的 `%00`–`%7F` 单独解得开),转义串的判据写成 UTF-8 良构判定 ——
11
+ * 结果与原来的 try/catch 逐字相同(`test/disclosable-identity-6.3.0.test.ts` 的两只冻结 oracle 钉着)。
12
+ *
13
+ * 本模块零 import(`./types` 引它,它不回引,不成环)。
14
+ */
15
+ /** How many percent-decoded layers a secret scan reads beyond the raw string (a multi-encoded `%2573%256B…` →
16
+ * `%73%6B…` → `sk-…`). Bounded → deterministic. */
17
+ export declare const SECRET_SCAN_DECODE_LAYERS = 3;
18
+ /** The raw string first, then up to {@link SECRET_SCAN_DECODE_LAYERS} tolerant percent-decoded layers, stopping at the first
19
+ * layer that decodes nothing — the forms the validation detector `containsSecretToken` reads, and nothing else. */
20
+ export declare function percentLayers(text: string): readonly string[];
21
+ /** What {@link secretScanForms} returns — plain data, no behavior. */
22
+ export interface SecretScanForms {
23
+ /** {@link percentLayers} of the text. */
24
+ readonly percentLayers: readonly string[];
25
+ /** The forms the display-side redactor judges a whole string on: `percentLayers`, each layer's {@link boundaryReading}, and
26
+ * each of those read the application/x-www-form-urlencoded way (`+` as a space); duplicates dropped, first seen first. */
27
+ readonly forms: readonly string[];
28
+ /** Normalization did not finish: the layer budget ran out while one more layer would still decode something. A caller that
29
+ * shows text withholds it whole — what could not be normalized does not go out. (A lone undecodable escape is a fixed
30
+ * point, not an unfinished one: `%E4%B8%AD` stays as it is and is not flagged.) */
31
+ readonly unresolved: boolean;
32
+ }
33
+ /** The display-side normalization — only `redactSecrets` (config-fns) calls it. */
34
+ export declare function secretScanForms(text: string): SecretScanForms;
35
+ /** 「URL 的 userinfo 就是凭据」的**唯一**判定(6.3.0,opus 腿 L1):username / password 任一在场即是。校验侧 `NON_SECRET_URL` 与 https
36
+ * 插件源据此拒写(`./types`),身份面据此否决(`./config-fns` 的 `isDisclosableIdentity` —— `https://admin:hunter2@db…` /
37
+ * `postgres://svc:pw@h` 的字符全在身份白名单里)。串解析不成 URL ⇒ false:这只判定只说「一个 URL 带没带 userinfo」,
38
+ * 不是 URL 的串不在它的射程里(无 scheme 的 `svc:pw@host` 解析成不透明路径,同样答 false —— 如实登记的边界)。 */
39
+ export declare function carriesUserinfo(u: URL | string): boolean;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 秘密扫描读的**归一化**,外加「URL 的 userinfo 就是凭据」那一只判定({@link carriesUserinfo})—— 包内私有模块(6.3.0):不进主 barrel、
3
+ * 不进 `package.json` 的 `exports`,消费方拿不到。
4
+ * 校验侧探测器 `containsSecretToken`(`./types`)只读 {@link percentLayers};展示侧脱敏器 `redactSecrets`(`./config-fns`)
5
+ * 读 {@link secretScanForms}。展示侧的读法(边界读法、`+` 读法、「没走完」那一位)刻意不做公开契约:它们以后要随新发现的
6
+ * 编码形调整,调整不该是契约变更(opus 独立腿 L7)。
7
+ *
8
+ * **单遍、零异常**(opus 独立腿 M1,对 6.2.0 的读路径 CPU 回归):6.2.0 起的宽容解码对每个 `%HH` 各 `try decodeURIComponent`,
9
+ * 非 ASCII 的转义每个都抛一次异常(≈ 20–30 µs / 字节,同步阻塞);6.3.0 首稿又让探测器顺带算它不用的展示形。这里两处解码都
10
+ * 不靠异常:单个转义的判据写成字符集(只有 ASCII 的 `%00`–`%7F` 单独解得开),转义串的判据写成 UTF-8 良构判定 ——
11
+ * 结果与原来的 try/catch 逐字相同(`test/disclosable-identity-6.3.0.test.ts` 的两只冻结 oracle 钉着)。
12
+ *
13
+ * 本模块零 import(`./types` 引它,它不回引,不成环)。
14
+ */
15
+ /** How many percent-decoded layers a secret scan reads beyond the raw string (a multi-encoded `%2573%256B…` →
16
+ * `%73%6B…` → `sk-…`). Bounded → deterministic. */
17
+ export const SECRET_SCAN_DECODE_LAYERS = 3;
18
+ /** One TOLERANT percent-decode layer: an escape decodes on its own iff `decodeURIComponent` would accept it alone — exactly
19
+ * the ASCII ones, `%00`–`%7F` — and every other escape (`%C2`, a lone `%E4`, `%ZZ` is not an escape at all) stays as it
20
+ * is, so one bad escape elsewhere can't abort the whole decode (R13's all-or-nothing decodeURIComponent did). Same result
21
+ * as the per-escape try/catch loop it replaces, with no exception per non-ASCII escape. */
22
+ function percentDecodeLayer(s) {
23
+ return s.indexOf("%") === -1 ? s : s.replace(/%([0-7][0-9A-Fa-f])/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
24
+ }
25
+ /** The raw string first, then up to {@link SECRET_SCAN_DECODE_LAYERS} tolerant percent-decoded layers, stopping at the first
26
+ * layer that decodes nothing — the forms the validation detector `containsSecretToken` reads, and nothing else. */
27
+ export function percentLayers(text) {
28
+ const layers = [text];
29
+ let cur = text;
30
+ for (let i = 0; i < SECRET_SCAN_DECODE_LAYERS; i++) {
31
+ const next = percentDecodeLayer(cur);
32
+ if (next === cur)
33
+ break;
34
+ layers.push(next);
35
+ cur = next;
36
+ }
37
+ return layers;
38
+ }
39
+ /** Is this byte sequence well-formed UTF-8 (Unicode Table 3-7: no overlongs, no surrogates, nothing above U+10FFFF)? That is
40
+ * exactly when `decodeURIComponent` accepts a run of escapes spelling these bytes — decided without throwing. */
41
+ function isWellFormedUtf8(bytes) {
42
+ for (let i = 0; i < bytes.length;) {
43
+ const lead = bytes[i];
44
+ if (lead < 0x80) {
45
+ i += 1;
46
+ continue;
47
+ }
48
+ // [续字节个数, 第一个续字节的下界, 上界];其余续字节恒为 80..BF。
49
+ const shape = lead >= 0xc2 && lead <= 0xdf ? [1, 0x80, 0xbf]
50
+ : lead === 0xe0 ? [2, 0xa0, 0xbf]
51
+ : (lead >= 0xe1 && lead <= 0xec) || lead === 0xee || lead === 0xef ? [2, 0x80, 0xbf]
52
+ : lead === 0xed ? [2, 0x80, 0x9f]
53
+ : lead === 0xf0 ? [3, 0x90, 0xbf]
54
+ : lead >= 0xf1 && lead <= 0xf3 ? [3, 0x80, 0xbf]
55
+ : lead === 0xf4 ? [3, 0x80, 0x8f]
56
+ : undefined;
57
+ if (shape === undefined)
58
+ return false;
59
+ const [need, lo, hi] = shape;
60
+ for (let k = 1; k <= need; k++) {
61
+ const c = bytes[i + k];
62
+ if (c === undefined || c < (k === 1 ? lo : 0x80) || c > (k === 1 ? hi : 0xbf))
63
+ return false;
64
+ }
65
+ i += need + 1;
66
+ }
67
+ return true;
68
+ }
69
+ /** The display-side BOUNDARY reading of one layer (codex r2): every run of %HH escapes is decoded as UTF-8 in one go when it
70
+ * is well-formed (an encoded NBSP / zero-width space becomes the delimiter it really is); otherwise its ASCII escapes decode
71
+ * on their own and every other escape reads as a space — an escape never glues its hex digits onto the token after it
72
+ * (`%C2%A0sk-live-…` read escape by escape is `%C2%A0sk-…`, whose tokens are `C2` / `A0sk-…`: the `sk-` boundary is gone,
73
+ * though any standard decoder restores it). Display-only: the validation detector never reads it. */
74
+ function boundaryReading(s) {
75
+ if (s.indexOf("%") === -1)
76
+ return s;
77
+ return s.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
78
+ const bytes = [];
79
+ for (let i = 0; i < run.length; i += 3)
80
+ bytes.push(parseInt(run.slice(i + 1, i + 3), 16));
81
+ return isWellFormedUtf8(bytes) ? decodeURIComponent(run) : bytes.map((b) => (b < 0x80 ? String.fromCharCode(b) : " ")).join("");
82
+ });
83
+ }
84
+ /** The display-side normalization — only `redactSecrets` (config-fns) calls it. */
85
+ export function secretScanForms(text) {
86
+ const layers = percentLayers(text);
87
+ const last = layers[layers.length - 1];
88
+ const unresolved = layers.length === SECRET_SCAN_DECODE_LAYERS + 1 && percentDecodeLayer(last) !== last;
89
+ const readings = [...layers, ...layers.map((layer) => boundaryReading(layer))];
90
+ const forms = [...new Set([...readings, ...readings.map((reading) => (reading.indexOf("+") === -1 ? reading : reading.replace(/\+/g, " ")))])];
91
+ return { percentLayers: layers, forms, unresolved };
92
+ }
93
+ /** 「URL 的 userinfo 就是凭据」的**唯一**判定(6.3.0,opus 腿 L1):username / password 任一在场即是。校验侧 `NON_SECRET_URL` 与 https
94
+ * 插件源据此拒写(`./types`),身份面据此否决(`./config-fns` 的 `isDisclosableIdentity` —— `https://admin:hunter2@db…` /
95
+ * `postgres://svc:pw@h` 的字符全在身份白名单里)。串解析不成 URL ⇒ false:这只判定只说「一个 URL 带没带 userinfo」,
96
+ * 不是 URL 的串不在它的射程里(无 scheme 的 `svc:pw@host` 解析成不透明路径,同样答 false —— 如实登记的边界)。 */
97
+ export function carriesUserinfo(u) {
98
+ let url;
99
+ if (typeof u === "string") {
100
+ try {
101
+ url = new URL(u);
102
+ }
103
+ catch {
104
+ return false; // 不是 URL:不在这只判定的射程里(见头注),不是回退
105
+ }
106
+ }
107
+ else
108
+ url = u;
109
+ return url.username !== "" || url.password !== "";
110
+ }
package/dist/types.d.ts CHANGED
@@ -18,9 +18,10 @@
18
18
  import { z } from "zod";
19
19
  /** A private-key PEM header (`-----BEGIN [RSA |EC |OPENSSH |ENCRYPTED ]PRIVATE KEY`), unanchored — the ONE definition
20
20
  * of that shape: {@link SECRET_LITERAL_RE} is composed from it (anchored, as one of its branches), and
21
- * `redactSecrets` (config-fns) tests it ANYWHERE in a string. It carries spaces, so the token-splitting detector
22
- * {@link containsSecretToken} only sees it at the very start of a string; a PEM behind a newline or a `key: ` prefix
23
- * slipped past (S-712 codex review [high]). Public keys / certificates never match (see above). */
21
+ * `redactSecrets` (config-fns) tests it ANYWHERE in a string, on every display-side normalized form. It carries spaces,
22
+ * so the token-splitting detector {@link containsSecretToken} only sees it at the very start of a string; a PEM behind
23
+ * a newline or a `key: ` prefix slipped past (S-712 codex review [high]), and a `key=`-prefixed percent-encoded one
24
+ * hid it behind `%20` on the raw form, a form-urlencoded one behind `+` (6.3.0). Public keys / certificates never match. */
24
25
  export declare const PRIVATE_KEY_PEM_HEADER: RegExp;
25
26
  export declare const SECRET_LITERAL_RE: RegExp;
26
27
  export declare const ENV_NAME: z.ZodEffects<z.ZodString, string, string>;
@@ -29,9 +30,15 @@ export declare const ENV_NAME: z.ZodEffects<z.ZodString, string, string>;
29
30
  * fs path — so the worker dies at exec/boot, not at config edit. A NUL is never legitimate in a command / path
30
31
  * / host / image, so reject it on every exec-bound string field (mcp stdio + the whole remoteExec contract). R20. */
31
32
  export declare const hasNul: (s: string) => boolean;
32
- /** Does `s` contain a secret-shaped token? Scans the raw string AND a percent-decoded form (a secret can hide
33
- * as `%73%6B-…`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage. Tokenized
34
- * + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. */
33
+ /** Does `s` contain a secret-shaped token? Scans every percent layer of it — the raw string AND up to three tolerant
34
+ * percent-decoded layers (a secret can hide as `%73%6B-…`; the ONE definition of those layers is `percentLayers` in the
35
+ * package-private `./secret-scan`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage.
36
+ * Tokenized + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. (6.3.0 moved
37
+ * the decode loop out, unchanged in result — same layers, same bound, same verdict — and made it exception-free; the
38
+ * display-side readings the redactor also judges are not this detector's.) We do NOT fail-closed on a bare `%` — that
39
+ * would false-reject legit values like `100%` / `%s`. A secret glued to a malformed escape (`%ZZsk-`) is an accepted
40
+ * coarse-heuristic gap (no consumer reconstructs it; the real guarantee is the env-NAME boundary on structured fields).
41
+ * R11/R13/R14. */
35
42
  export declare function containsSecretToken(s: string): boolean;
36
43
  /** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
37
44
  * Userinfo is handled separately (NON_SECRET_URL bans it; dockerHost scans the ssh user). Round-4/5. */
@@ -110,7 +117,8 @@ export declare const SealedApiKey: z.ZodObject<{
110
117
  export type SealedApiKey = z.infer<typeof SealedApiKey>;
111
118
  /** 谁来校验一个**解析透明键**的形。闭集:新增一员要在这里加词,穷举消费方即编译红。
112
119
  * · `engine-ingest` —— 引擎摄入侧单点校验(不合形的那一条不挂并通告一次);
113
- * · `consumer` —— 消费方的投影/执法点(丢掉坏的那一个键、保留其宿主条目、记账)。 */
120
+ * · `consumer` —— 消费方的投影/执法点。坏值怎么处置(只丢那一个键 / 整条宿主条目不可用)是那个执法点自己的
121
+ * 契约,写在登记行的 `why` 里 —— 本包不替它定。 */
114
122
  export type ParseTransparentValidator = "engine-ingest" | "consumer";
115
123
  /** {@link PARSE_TRANSPARENT_KEYS} 的一行。 */
116
124
  export interface ParseTransparentKey {
@@ -240,6 +248,16 @@ export declare const ModelEntry: z.ZodEffects<z.ZodObject<{
240
248
  * deep to hash later. That is not shape validation — it is the boundary every free-form value must clear to
241
249
  * enter the persisted config and `stableHash` (R18). */
242
250
  compat: z.ZodEffects<z.ZodOptional<z.ZodUnknown>, unknown, unknown>;
251
+ /** Per-model extra request headers, deployment ↔ provider (e.g. `{"anthropic-beta": "context-1m-2025-08-07"}` on an
252
+ * `anthropic-messages` entry). Real need: a beta that a shell passes as `--betas` / `ANTHROPIC_BETAS` never reaches the
253
+ * request the engine sends; the catalog entry is the deployment-level seat for it — and without this key the entry's
254
+ * declaration is stripped at parse on every read leg, so it never reaches the consumer at all.
255
+ * ABSENT = key not minted = today's behaviour, zero migration.
256
+ *
257
+ * **Parse-transparent** — who validates the shape, and why not here: {@link PARSE_TRANSPARENT_KEYS}
258
+ * (`models.models[].headers`). The ONE rejection that remains is the free-form SAFETY judgment shared with `compat` /
259
+ * `extraBody` ({@link vetFreeFormJson}). */
260
+ headers: z.ZodEffects<z.ZodOptional<z.ZodUnknown>, unknown, unknown>;
243
261
  /** 配额倍率(EXPERT-REDESIGN §4-1):该模型烧 token 双窗额度的倍率,默认 1。折算时点=weight-at-burn
244
262
  * (记账时固化到 UsageEntry.quotaWeightAtUse/weightedTokens):改倍率只影响此后记账,不回算窗口内历史。 */
245
263
  quotaWeight: z.ZodDefault<z.ZodNumber>;
@@ -276,6 +294,7 @@ export declare const ModelEntry: z.ZodEffects<z.ZodObject<{
276
294
  } | undefined;
277
295
  extraBody?: Record<string, unknown> | undefined;
278
296
  compat?: unknown;
297
+ headers?: unknown;
279
298
  notes?: string | undefined;
280
299
  }, {
281
300
  id: string;
@@ -306,6 +325,7 @@ export declare const ModelEntry: z.ZodEffects<z.ZodObject<{
306
325
  } | undefined;
307
326
  extraBody?: unknown;
308
327
  compat?: unknown;
328
+ headers?: unknown;
309
329
  quotaWeight?: number | undefined;
310
330
  enabled?: boolean | undefined;
311
331
  notes?: string | undefined;
@@ -340,6 +360,7 @@ export declare const ModelEntry: z.ZodEffects<z.ZodObject<{
340
360
  } | undefined;
341
361
  extraBody?: Record<string, unknown> | undefined;
342
362
  compat?: unknown;
363
+ headers?: unknown;
343
364
  notes?: string | undefined;
344
365
  }, {
345
366
  id: string;
@@ -370,6 +391,7 @@ export declare const ModelEntry: z.ZodEffects<z.ZodObject<{
370
391
  } | undefined;
371
392
  extraBody?: unknown;
372
393
  compat?: unknown;
394
+ headers?: unknown;
373
395
  quotaWeight?: number | undefined;
374
396
  enabled?: boolean | undefined;
375
397
  notes?: string | undefined;
@@ -578,6 +600,16 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
578
600
  * deep to hash later. That is not shape validation — it is the boundary every free-form value must clear to
579
601
  * enter the persisted config and `stableHash` (R18). */
580
602
  compat: z.ZodEffects<z.ZodOptional<z.ZodUnknown>, unknown, unknown>;
603
+ /** Per-model extra request headers, deployment ↔ provider (e.g. `{"anthropic-beta": "context-1m-2025-08-07"}` on an
604
+ * `anthropic-messages` entry). Real need: a beta that a shell passes as `--betas` / `ANTHROPIC_BETAS` never reaches the
605
+ * request the engine sends; the catalog entry is the deployment-level seat for it — and without this key the entry's
606
+ * declaration is stripped at parse on every read leg, so it never reaches the consumer at all.
607
+ * ABSENT = key not minted = today's behaviour, zero migration.
608
+ *
609
+ * **Parse-transparent** — who validates the shape, and why not here: {@link PARSE_TRANSPARENT_KEYS}
610
+ * (`models.models[].headers`). The ONE rejection that remains is the free-form SAFETY judgment shared with `compat` /
611
+ * `extraBody` ({@link vetFreeFormJson}). */
612
+ headers: z.ZodEffects<z.ZodOptional<z.ZodUnknown>, unknown, unknown>;
581
613
  /** 配额倍率(EXPERT-REDESIGN §4-1):该模型烧 token 双窗额度的倍率,默认 1。折算时点=weight-at-burn
582
614
  * (记账时固化到 UsageEntry.quotaWeightAtUse/weightedTokens):改倍率只影响此后记账,不回算窗口内历史。 */
583
615
  quotaWeight: z.ZodDefault<z.ZodNumber>;
@@ -614,6 +646,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
614
646
  } | undefined;
615
647
  extraBody?: Record<string, unknown> | undefined;
616
648
  compat?: unknown;
649
+ headers?: unknown;
617
650
  notes?: string | undefined;
618
651
  }, {
619
652
  id: string;
@@ -644,6 +677,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
644
677
  } | undefined;
645
678
  extraBody?: unknown;
646
679
  compat?: unknown;
680
+ headers?: unknown;
647
681
  quotaWeight?: number | undefined;
648
682
  enabled?: boolean | undefined;
649
683
  notes?: string | undefined;
@@ -678,6 +712,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
678
712
  } | undefined;
679
713
  extraBody?: Record<string, unknown> | undefined;
680
714
  compat?: unknown;
715
+ headers?: unknown;
681
716
  notes?: string | undefined;
682
717
  }, {
683
718
  id: string;
@@ -708,6 +743,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
708
743
  } | undefined;
709
744
  extraBody?: unknown;
710
745
  compat?: unknown;
746
+ headers?: unknown;
711
747
  quotaWeight?: number | undefined;
712
748
  enabled?: boolean | undefined;
713
749
  notes?: string | undefined;
@@ -832,6 +868,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
832
868
  } | undefined;
833
869
  extraBody?: Record<string, unknown> | undefined;
834
870
  compat?: unknown;
871
+ headers?: unknown;
835
872
  notes?: string | undefined;
836
873
  }[];
837
874
  roles: Partial<Record<"default" | "summarize" | "subagent" | "team" | "synthesize" | "verifier" | "classifier" | "consolidate", {
@@ -887,6 +924,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
887
924
  } | undefined;
888
925
  extraBody?: unknown;
889
926
  compat?: unknown;
927
+ headers?: unknown;
890
928
  quotaWeight?: number | undefined;
891
929
  enabled?: boolean | undefined;
892
930
  notes?: string | undefined;
@@ -946,6 +984,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
946
984
  } | undefined;
947
985
  extraBody?: Record<string, unknown> | undefined;
948
986
  compat?: unknown;
987
+ headers?: unknown;
949
988
  notes?: string | undefined;
950
989
  }[];
951
990
  roles: Partial<Record<"default" | "summarize" | "subagent" | "team" | "synthesize" | "verifier" | "classifier" | "consolidate", {
@@ -1001,6 +1040,7 @@ export declare const ModelsConfig: z.ZodEffects<z.ZodObject<{
1001
1040
  } | undefined;
1002
1041
  extraBody?: unknown;
1003
1042
  compat?: unknown;
1043
+ headers?: unknown;
1004
1044
  quotaWeight?: number | undefined;
1005
1045
  enabled?: boolean | undefined;
1006
1046
  notes?: string | undefined;
@@ -9494,6 +9534,16 @@ export declare const DOMAIN_SCHEMAS: {
9494
9534
  * deep to hash later. That is not shape validation — it is the boundary every free-form value must clear to
9495
9535
  * enter the persisted config and `stableHash` (R18). */
9496
9536
  compat: z.ZodEffects<z.ZodOptional<z.ZodUnknown>, unknown, unknown>;
9537
+ /** Per-model extra request headers, deployment ↔ provider (e.g. `{"anthropic-beta": "context-1m-2025-08-07"}` on an
9538
+ * `anthropic-messages` entry). Real need: a beta that a shell passes as `--betas` / `ANTHROPIC_BETAS` never reaches the
9539
+ * request the engine sends; the catalog entry is the deployment-level seat for it — and without this key the entry's
9540
+ * declaration is stripped at parse on every read leg, so it never reaches the consumer at all.
9541
+ * ABSENT = key not minted = today's behaviour, zero migration.
9542
+ *
9543
+ * **Parse-transparent** — who validates the shape, and why not here: {@link PARSE_TRANSPARENT_KEYS}
9544
+ * (`models.models[].headers`). The ONE rejection that remains is the free-form SAFETY judgment shared with `compat` /
9545
+ * `extraBody` ({@link vetFreeFormJson}). */
9546
+ headers: z.ZodEffects<z.ZodOptional<z.ZodUnknown>, unknown, unknown>;
9497
9547
  /** 配额倍率(EXPERT-REDESIGN §4-1):该模型烧 token 双窗额度的倍率,默认 1。折算时点=weight-at-burn
9498
9548
  * (记账时固化到 UsageEntry.quotaWeightAtUse/weightedTokens):改倍率只影响此后记账,不回算窗口内历史。 */
9499
9549
  quotaWeight: z.ZodDefault<z.ZodNumber>;
@@ -9530,6 +9580,7 @@ export declare const DOMAIN_SCHEMAS: {
9530
9580
  } | undefined;
9531
9581
  extraBody?: Record<string, unknown> | undefined;
9532
9582
  compat?: unknown;
9583
+ headers?: unknown;
9533
9584
  notes?: string | undefined;
9534
9585
  }, {
9535
9586
  id: string;
@@ -9560,6 +9611,7 @@ export declare const DOMAIN_SCHEMAS: {
9560
9611
  } | undefined;
9561
9612
  extraBody?: unknown;
9562
9613
  compat?: unknown;
9614
+ headers?: unknown;
9563
9615
  quotaWeight?: number | undefined;
9564
9616
  enabled?: boolean | undefined;
9565
9617
  notes?: string | undefined;
@@ -9594,6 +9646,7 @@ export declare const DOMAIN_SCHEMAS: {
9594
9646
  } | undefined;
9595
9647
  extraBody?: Record<string, unknown> | undefined;
9596
9648
  compat?: unknown;
9649
+ headers?: unknown;
9597
9650
  notes?: string | undefined;
9598
9651
  }, {
9599
9652
  id: string;
@@ -9624,6 +9677,7 @@ export declare const DOMAIN_SCHEMAS: {
9624
9677
  } | undefined;
9625
9678
  extraBody?: unknown;
9626
9679
  compat?: unknown;
9680
+ headers?: unknown;
9627
9681
  quotaWeight?: number | undefined;
9628
9682
  enabled?: boolean | undefined;
9629
9683
  notes?: string | undefined;
@@ -9748,6 +9802,7 @@ export declare const DOMAIN_SCHEMAS: {
9748
9802
  } | undefined;
9749
9803
  extraBody?: Record<string, unknown> | undefined;
9750
9804
  compat?: unknown;
9805
+ headers?: unknown;
9751
9806
  notes?: string | undefined;
9752
9807
  }[];
9753
9808
  roles: Partial<Record<"default" | "summarize" | "subagent" | "team" | "synthesize" | "verifier" | "classifier" | "consolidate", {
@@ -9803,6 +9858,7 @@ export declare const DOMAIN_SCHEMAS: {
9803
9858
  } | undefined;
9804
9859
  extraBody?: unknown;
9805
9860
  compat?: unknown;
9861
+ headers?: unknown;
9806
9862
  quotaWeight?: number | undefined;
9807
9863
  enabled?: boolean | undefined;
9808
9864
  notes?: string | undefined;
@@ -9862,6 +9918,7 @@ export declare const DOMAIN_SCHEMAS: {
9862
9918
  } | undefined;
9863
9919
  extraBody?: Record<string, unknown> | undefined;
9864
9920
  compat?: unknown;
9921
+ headers?: unknown;
9865
9922
  notes?: string | undefined;
9866
9923
  }[];
9867
9924
  roles: Partial<Record<"default" | "summarize" | "subagent" | "team" | "synthesize" | "verifier" | "classifier" | "consolidate", {
@@ -9917,6 +9974,7 @@ export declare const DOMAIN_SCHEMAS: {
9917
9974
  } | undefined;
9918
9975
  extraBody?: unknown;
9919
9976
  compat?: unknown;
9977
+ headers?: unknown;
9920
9978
  quotaWeight?: number | undefined;
9921
9979
  enabled?: boolean | undefined;
9922
9980
  notes?: string | undefined;
package/dist/types.js CHANGED
@@ -18,6 +18,7 @@
18
18
  import { z } from "zod";
19
19
  import { MAX_EXTRA_BODY_DEPTH } from "./hash.js"; // reconcile the extraBody depth cap with the hash cap (R18)
20
20
  import { DispatchPolicy } from "./fleet.js"; // WorkerSpec.fleet.dispatch (scheduling framework D3)
21
+ import { carriesUserinfo, percentLayers } from "./secret-scan.js"; // package-private normalization + the userinfo judgement (not in the barrel)
21
22
  // COARSE, best-effort literal-secret SHAPE detector (NOT exhaustive — the real boundary is the env-NAME schema
22
23
  // on structured fields). Rejected in free-form value fields (worker `env`, model `extraBody`), URL components,
23
24
  // AND as an env-NAME value (an AWS key id is a valid env-NAME charset yet is a secret VALUE, not a NAME). Case-
@@ -28,9 +29,10 @@ import { DispatchPolicy } from "./fleet.js"; // WorkerSpec.fleet.dispatch (sched
28
29
  // which review flagged as a bypass (a private key misnamed `*_PUBKEY` skipped scanning entirely).
29
30
  /** A private-key PEM header (`-----BEGIN [RSA |EC |OPENSSH |ENCRYPTED ]PRIVATE KEY`), unanchored — the ONE definition
30
31
  * of that shape: {@link SECRET_LITERAL_RE} is composed from it (anchored, as one of its branches), and
31
- * `redactSecrets` (config-fns) tests it ANYWHERE in a string. It carries spaces, so the token-splitting detector
32
- * {@link containsSecretToken} only sees it at the very start of a string; a PEM behind a newline or a `key: ` prefix
33
- * slipped past (S-712 codex review [high]). Public keys / certificates never match (see above). */
32
+ * `redactSecrets` (config-fns) tests it ANYWHERE in a string, on every display-side normalized form. It carries spaces,
33
+ * so the token-splitting detector {@link containsSecretToken} only sees it at the very start of a string; a PEM behind
34
+ * a newline or a `key: ` prefix slipped past (S-712 codex review [high]), and a `key=`-prefixed percent-encoded one
35
+ * hid it behind `%20` on the raw form, a form-urlencoded one behind `+` (6.3.0). Public keys / certificates never match. */
34
36
  export const PRIVATE_KEY_PEM_HEADER = /-----BEGIN[A-Z0-9 ]*PRIVATE KEY/i;
35
37
  export const SECRET_LITERAL_RE = new RegExp(`^(sk-|gh[opus]_|github_pat_|xox[baprse]-|xapp-|bearer\\s|basic\\s|eyJ[A-Za-z0-9_-]{10,}\\.|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|${PRIVATE_KEY_PEM_HEADER.source})`, "i");
36
38
  // 所有 *Env / *tokenEnv 字段的值是宿主机 env 变量的 NAME(不是值)。合法 env 变量名 + 拒绝 secret 形状的值(AKIA…
@@ -71,33 +73,17 @@ function scanForSecret(s) {
71
73
  * fs path — so the worker dies at exec/boot, not at config edit. A NUL is never legitimate in a command / path
72
74
  * / host / image, so reject it on every exec-bound string field (mcp stdio + the whole remoteExec contract). R20. */
73
75
  export const hasNul = (s) => s.includes("\0");
74
- /** Does `s` contain a secret-shaped token? Scans the raw string AND a percent-decoded form (a secret can hide
75
- * as `%73%6B-…`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage. Tokenized
76
- * + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. */
76
+ /** Does `s` contain a secret-shaped token? Scans every percent layer of it — the raw string AND up to three tolerant
77
+ * percent-decoded layers (a secret can hide as `%73%6B-…`; the ONE definition of those layers is `percentLayers` in the
78
+ * package-private `./secret-scan`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage.
79
+ * Tokenized + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. (6.3.0 moved
80
+ * the decode loop out, unchanged in result — same layers, same bound, same verdict — and made it exception-free; the
81
+ * display-side readings the redactor also judges are not this detector's.) We do NOT fail-closed on a bare `%` — that
82
+ * would false-reject legit values like `100%` / `%s`. A secret glued to a malformed escape (`%ZZsk-`) is an accepted
83
+ * coarse-heuristic gap (no consumer reconstructs it; the real guarantee is the env-NAME boundary on structured fields).
84
+ * R11/R13/R14. */
77
85
  export function containsSecretToken(s) {
78
- // scan the raw string AND up to 3 percent-decoded layers (handles a multi-encoded `%2573%256B…` → `%73%6B…`
79
- // → `sk-…`). TOLERANT decode: each valid %HH is decoded, a malformed escape (`%ZZ`, `%` at end) is left as-is
80
- // so one bad escape elsewhere can't abort the whole decode (R13's all-or-nothing decodeURIComponent did). We
81
- // do NOT fail-closed on a bare `%` — that would false-reject legit values like `100%` / `%s`. A secret glued
82
- // to a malformed escape (`%ZZsk-`) is an accepted coarse-heuristic gap (no consumer reconstructs it; the real
83
- // guarantee is the env-NAME boundary on structured fields). Bounded → deterministic. R11/R13/R14. */
84
- let cur = s;
85
- for (let i = 0; i < 4; i++) {
86
- if (scanForSecret(cur))
87
- return true;
88
- const next = cur.replace(/%[0-9A-Fa-f]{2}/g, (m) => {
89
- try {
90
- return decodeURIComponent(m);
91
- }
92
- catch {
93
- return m;
94
- }
95
- });
96
- if (next === cur)
97
- break;
98
- cur = next;
99
- }
100
- return false;
86
+ return percentLayers(s).some((form) => scanForSecret(form));
101
87
  }
102
88
  /** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
103
89
  * Userinfo is handled separately (NON_SECRET_URL bans it; dockerHost scans the ssh user). Round-4/5. */
@@ -141,7 +127,7 @@ export const NON_SECRET_URL = z
141
127
  .refine((s) => {
142
128
  try {
143
129
  const u = new URL(s);
144
- if (!(u.protocol === "http:" || u.protocol === "https:") || !u.host || u.username || u.password)
130
+ if (!(u.protocol === "http:" || u.protocol === "https:") || !u.host || carriesUserinfo(u))
145
131
  return false;
146
132
  return !urlQueryFragmentHasSecret(u);
147
133
  }
@@ -215,9 +201,9 @@ export function extraBodyUnsafe(v) {
215
201
  }
216
202
  return false;
217
203
  }
218
- /** THE vetting pipeline for every FREE-FORM JSON value that lands in the persisted config — `ModelEntry.extraBody`
219
- * and `ModelEntry.compat` share this ONE function (1.10.0; before that it was inline in `extraBody` and a second
220
- * free-form field would have copied it). The judgment is identical for every such field, so it lives in one place:
204
+ /** THE vetting pipeline for every FREE-FORM JSON value that lands in the persisted config — `ModelEntry.extraBody`,
205
+ * `ModelEntry.compat` and `ModelEntry.headers` share this ONE function (1.10.0; before that it was inline in `extraBody`
206
+ * and a second free-form field would have copied it). The judgment is identical for every such field, so it lives in one place:
221
207
  *
222
208
  * 1. getter-free descriptor walk ({@link extraBodyUnsafe}) over the RAW value — never invokes a getter, and any
223
209
  * reflective throw (a hostile Proxy trap could throw a secret-bearing message) collapses into a FIXED message;
@@ -248,6 +234,22 @@ function vetFreeFormJson(v, field) {
248
234
  return { ok: false, message: `${field}: its serialized form carries a secret` };
249
235
  return { ok: true, value: clone };
250
236
  }
237
+ /** The transform of every **parse-transparent free-form** key (`z.unknown().optional().transform(createFreeFormTransform(field))`:
238
+ * `ModelEntry.compat` / `ModelEntry.headers`) — ONE closure, so those keys cannot drift apart: absent stays absent (key not
239
+ * minted); anything else is the {@link vetFreeFormJson} safety judgment and nothing more (no SHAPE rule — whose job that is,
240
+ * per key, is {@link PARSE_TRANSPARENT_KEYS}). `extraBody` is not one of these: it has its own shape gate first. */
241
+ function createFreeFormTransform(field) {
242
+ return (v, ctx) => {
243
+ if (v === undefined)
244
+ return undefined;
245
+ const vetted = vetFreeFormJson(v, field);
246
+ if (!vetted.ok) {
247
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: vetted.message });
248
+ return z.NEVER;
249
+ }
250
+ return vetted.value;
251
+ };
252
+ }
251
253
  /** A k8s resource quantity ("250m", "1", "0.5", "512Mi", "1Gi", "128M") — catches garbage at config-submit
252
254
  * rather than surfacing as a reconcile ApplyError at the apiserver (consistent with ENV_NAME/DURATION guards). */
253
255
  export const K8S_QUANTITY = z.string().regex(/^\d+(\.\d+)?(m|[kKMGTPE]i?)?$/, 'must be a k8s quantity like "250m", "512Mi", "1Gi"');
@@ -327,6 +329,13 @@ export const PARSE_TRANSPARENT_KEYS = [
327
329
  validatedBy: "engine-ingest",
328
330
  why: "镜像引擎的 per-model wire-compat 声明(thinkingFormat 等五键);那张词表随引擎走,本包复述一份必漂。",
329
331
  },
332
+ {
333
+ domain: "models",
334
+ path: "models[].headers",
335
+ since: "6.4.0",
336
+ validatedBy: "consumer",
337
+ why: "部署 ↔ 供应商的 per-model 附加请求头;判据(头名闭集白名单 × 读它的线路 × 值形)的唯一属主是 server 的目录条目判据(坏声明 ⇒ 该条目整条不可用,不是丢头照常),本包复述一份那张表必漂、两处各一份就是两个写者。",
338
+ },
330
339
  {
331
340
  domain: "mcp",
332
341
  path: "servers[].source",
@@ -438,16 +447,20 @@ export const ModelEntry = z.object({
438
447
  compat: z
439
448
  .unknown()
440
449
  .optional()
441
- .transform((v, ctx) => {
442
- if (v === undefined)
443
- return undefined;
444
- const vetted = vetFreeFormJson(v, "compat");
445
- if (!vetted.ok) {
446
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: vetted.message });
447
- return z.NEVER;
448
- }
449
- return vetted.value;
450
- }),
450
+ .transform(createFreeFormTransform("compat")),
451
+ /** Per-model extra request headers, deployment ↔ provider (e.g. `{"anthropic-beta": "context-1m-2025-08-07"}` on an
452
+ * `anthropic-messages` entry). Real need: a beta that a shell passes as `--betas` / `ANTHROPIC_BETAS` never reaches the
453
+ * request the engine sends; the catalog entry is the deployment-level seat for it — and without this key the entry's
454
+ * declaration is stripped at parse on every read leg, so it never reaches the consumer at all.
455
+ * ABSENT = key not minted = today's behaviour, zero migration.
456
+ *
457
+ * **Parse-transparent** — who validates the shape, and why not here: {@link PARSE_TRANSPARENT_KEYS}
458
+ * (`models.models[].headers`). The ONE rejection that remains is the free-form SAFETY judgment shared with `compat` /
459
+ * `extraBody` ({@link vetFreeFormJson}). */
460
+ headers: z
461
+ .unknown()
462
+ .optional()
463
+ .transform(createFreeFormTransform("headers")),
451
464
  /** 配额倍率(EXPERT-REDESIGN §4-1):该模型烧 token 双窗额度的倍率,默认 1。折算时点=weight-at-burn
452
465
  * (记账时固化到 UsageEntry.quotaWeightAtUse/weightedTokens):改倍率只影响此后记账,不回算窗口内历史。 */
453
466
  quotaWeight: z.number().finite().nonnegative().default(1),
@@ -2053,7 +2066,7 @@ export const PLUGIN_ORIGIN = z
2053
2066
  return true; // 已被上一条拒;避免对非 URL 再报一条噪声
2054
2067
  try {
2055
2068
  const u = new URL(s);
2056
- return !u.username && !u.password && !urlQueryFragmentHasSecret(u);
2069
+ return !carriesUserinfo(u) && !urlQueryFragmentHasSecret(u);
2057
2070
  }
2058
2071
  catch {
2059
2072
  return false; // claims to be https but does not parse → reject
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-registry, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",