@sema-agent/settings-schema 6.2.0 → 6.3.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 (39) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +15 -6
  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/cli01-model-name-nfc-vs-nfd/expected.json +0 -8
  26. package/conformance/cases/cli01-model-name-nfc-vs-nfd/meta.json +2 -1
  27. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +0 -8
  28. package/conformance/cases/cli02-control-and-zero-width-chars/meta.json +2 -1
  29. package/dist/config-fns.d.ts +53 -22
  30. package/dist/config-fns.js +87 -33
  31. package/dist/cross-session.d.ts +8 -5
  32. package/dist/cross-session.js +8 -5
  33. package/dist/fleet.d.ts +3 -2
  34. package/dist/fleet.js +3 -2
  35. package/dist/secret-scan.d.ts +39 -0
  36. package/dist/secret-scan.js +110 -0
  37. package/dist/types.d.ts +13 -6
  38. package/dist/types.js +17 -31
  39. package/package.json +1 -1
@@ -17,6 +17,7 @@
17
17
  // its entry collections. zod is this package's one runtime dependency, so this costs nothing new.
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
+ import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private display-side normalization + the userinfo judgement (not in the barrel)
20
21
  import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, SkillsManifestSchema, containsSecretToken } from "./types.js";
21
22
  /** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
22
23
  * `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
@@ -24,20 +25,32 @@ import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX,
24
25
  * any secret-shaped run (`%` kept in the run so a percent-encoded secret stays whole for the percent-aware
25
26
  * containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
26
27
  *
27
- * **Output invariant** (S-712 verification pass N1 + codex review [high] ×2): a string holding a private-key PEM
28
- * header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) is withheld WHOLE, and no result trips `containsSecretToken`.
29
- * The PEM header is judged on the ORIGINAL text, before any replacement: the token pass can rewrite the header
30
- * itself away (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes
31
- * `[redacted-secret]` and the key body after it would ride out untouched — codex verification round). A PEM header
32
- * carries spaces, so the targeted passes can never isolate it, and the detector only sees it at the very start of
33
- * a string. Every warning face that goes through here (coordinates, error text, entry identity) inherits the same
34
- * guarantee. The validation-side detector `containsSecretToken` is deliberately NOT widened here: making it reject
35
- * a mid-string PEM would tighten every field it validates (a major). */
28
+ * **Output invariant** (S-712 verification pass N1 + codex review [high] ×2; 6.3.0): a string holding a private-key
29
+ * PEM header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) in ANY of its {@link secretScanForms} `forms` is withheld WHOLE, a
30
+ * string whose normalization did not finish (`unresolved`) is withheld WHOLE, and no result trips `containsSecretToken` on
31
+ * any of its forms.
32
+ * The whole-string judgement is ONE rule — both detectors run over the normalized forms: the PEM header on every form of
33
+ * the ORIGINAL text, `containsSecretToken` on every form of the targeted result; what could not be normalized does not go
34
+ * out (6.3.0, codex r1 F1: `-----BEGIN+PRIVATE+KEY` form-encoded headers and four-layer encodings both rode out).
35
+ * 6.3.0 (codex review of v6.1.0 [high]): `key=` + a percent-encoded PEM showed its header only on a decoded form
36
+ * (`-----BEGIN%20PRIVATE%20KEY` — `%20` is not in `[A-Z0-9 ]`), and the detector's anchored decode missed it behind
37
+ * the `key=` prefix, so the whole key rode out through coordinates, zod `received` / `message` and entry identity.
38
+ * The PEM header is judged BEFORE any replacement: the token pass can rewrite the header itself away
39
+ * (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes `[redacted-secret]` and the
40
+ * key body after it would ride out untouched — codex verification round). A PEM header carries spaces, so the
41
+ * targeted passes can never isolate it, and the detector only sees it at the very start of a string. Every
42
+ * free-text warning face that goes through here (coordinates, error text) inherits the same guarantee; the entry
43
+ * identity face additionally has to be an identifier ({@link isDisclosableIdentity}). The validation-side detector
44
+ * `containsSecretToken` is deliberately NOT widened here: making it reject a mid-string PEM would tighten every field
45
+ * it validates (a major). */
36
46
  export function redactSecrets(text) {
37
47
  const targeted = text
38
48
  .replace(/\b(bearer|basic)(\s+)\S+/gi, "$1$2[redacted-secret]")
39
49
  .replace(/[A-Za-z0-9_./:+=%-]{6,}/g, (tok) => (containsSecretToken(tok) ? "[redacted-secret]" : tok));
40
- return PRIVATE_KEY_PEM_HEADER.test(text) || containsSecretToken(targeted) ? "[redacted-secret]" : targeted;
50
+ const scan = secretScanForms(text);
51
+ const targetedForms = targeted === text ? scan.forms : secretScanForms(targeted).forms; // 没替换过(最常见)⇒ 同一组形,不再归一化一遍
52
+ const whole = scan.unresolved || scan.forms.some((form) => PRIVATE_KEY_PEM_HEADER.test(form)) || targetedForms.some((form) => containsSecretToken(form));
53
+ return whole ? "[redacted-secret]" : targeted;
41
54
  }
42
55
  /** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
43
56
  * pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
@@ -185,23 +198,55 @@ const PLAIN_PATH_SEGMENT = /^(?!(?:0|[1-9]\d*)$)[^."\u0000-\u001f]+$/;
185
198
  export function formatConfigPath(p) {
186
199
  return p.map((seg) => (typeof seg === "number" ? String(seg) : PLAIN_PATH_SEGMENT.test(seg) ? seg : JSON.stringify(seg))).join(".");
187
200
  }
188
- /** 身份串 —— 「一个用户写的身份串(条目的 `name` / `id`)能不能**原样**出门」的**唯一**判据:
189
- * {@link redactSecrets} 原样放行。入参是 `string`、返回 `boolean`,**刻意不是**类型谓词(`v is string`):它对一些
190
- * 字符串答 false,谓词形会让调用方的否定分支把一个 `string` 收窄成 `never`;「是不是字符串」由调用点自己判
191
- * (运行期收到非字符串照答 false、不抛)。秘密形的串按**无名**处置(不带,而不是打码:打过码的串不再是身份)。
192
- * 与坐标段脱敏、`error` 文案脱敏({@link sanitizeZodError})**同一只**判据:告警面上任何一处用户写的串,要么过这一只
193
- * 脱敏器出门,要么(可缺的身份)不出门。两只判据会在同一条告警里给出两种答案(对抗轮 #2 真复现:`Bearer` 后接 NBSP
194
- * 或短令牌时,旧判据放行、坐标却打码)。
201
+ /** 本文件铸的、带用户写的键段的告警坐标出门前的**唯一**一道(6.3.0,codex r1 F2):字符串段过 {@link outgoingSegment} —— 与 `error` 文案
202
+ * ({@link sanitizeZodError})、身份否决({@link isDisclosableIdentity})**同一只**脱敏器,不另设判据;数字段(下标)原样。
203
+ * 键段是用户写的,完全可能就是一条粘错位置的凭据:被丢 Record 条目的键、未知键告警(`unknown-keys-dropped` /
204
+ * `-carried`)的键名都一样(后者 0.19.0 起从不过脱敏器,`sk-…` 形的未知键名原样进告警与投影)。schema 已知的字段名
205
+ * 脱敏器原样放行,所以坐标里「它是哪个字段」照旧可读。经它的坐标就是出门形 —— {@link droppedKeys} /
206
+ * {@link carriedUnknownKeys} 这两只导出函数给消费方的 `keys` 同样是它的产物。`legacy-governance-lifted.keys` 不经它:那些键只来自
207
+ * 闭集 {@link GOVERNANCE_MIRROR_KEYS},按构造没有用户写的串(opus 腿 I1)。 */
208
+ function outgoingPath(p) {
209
+ return p.map((seg) => (typeof seg === "string" ? outgoingSegment(seg) : seg));
210
+ }
211
+ /** 一段用户写的串(坐标键段、`hosts-grandfathered` 被夹紧的主机名)出门前的那一道:长过 {@link DISCLOSABLE_IDENTITY_MAX}(与身份白名单同一只上界)
212
+ * ⇒ 不扫描、整段 `[redacted-secret]`(标识符不会那么长;fail-closed 且 O(1) —— opus 独立腿 M1:6.3.0 首稿让每个未知键名
213
+ * 不论多长都过脱敏器,64 个 16 KB 的键名把一次读拖到 20 s 以上);否则过 {@link redactSecrets}。 */
214
+ function outgoingSegment(seg) {
215
+ return seg.length > DISCLOSABLE_IDENTITY_MAX ? "[redacted-secret]" : redactSecrets(seg);
216
+ }
217
+ /** 可披露身份与出门坐标段的**长度上界**(一只常量,两处用:{@link DISCLOSABLE_IDENTITY_RE} 的上限、{@link outgoingSegment}
218
+ * 的「超长不扫描」界)。与 web-admin 读侧名长上限 256 对齐。 */
219
+ const DISCLOSABLE_IDENTITY_MAX = 256;
220
+ /** 可披露身份的**闭形**(6.3.0):字符集 `A-Z a-z 0-9 . _ : / @ + -`、长度 1–256(上限与 web-admin 读侧名长上限 256 对齐;`ModelEntry.name`
221
+ * 本身不设上限,超 256 字的合法名按无名处置)。身份是**标识符**不是自由文本 ——
222
+ * 允许集才是它的语义:上游模型 id 的真实形(`accounts/fireworks/models/…`、`Qwen/Qwen2.5-72B-Instruct`、
223
+ * `claude-3-5-sonnet@20240620`、`deepseek-v4-flash:free`、`o3-mini+high`)都在集内;`%` / `=` / 空白 / 引号 / 反斜杠 /
224
+ * 控制与零宽字符 / 非 ASCII 一律在集外 —— 编码过的、带前缀的、多行的、会在日志里伪造行的串,不可能是一个标识符。
225
+ * 无旗标(无 `g` ⇒ `.test` 无状态,可放心共享这一只)。判据只有 {@link isDisclosableIdentity} 一只,消费方要
226
+ * 「这个名能不能写出去」问它,不要拿本常量自拼(集内仍有密钥形,见那一只的否决项)。 */
227
+ export const DISCLOSABLE_IDENTITY_RE = new RegExp(`^[A-Za-z0-9._:/@+-]{1,${DISCLOSABLE_IDENTITY_MAX}}$`);
228
+ /** 身份串 —— 「一个用户写的身份串(条目的 `name` / `id`)能不能**原样**出门」的**唯一**判据(6.3.0 起两项合取):
229
+ * ① **形**:落在 {@link DISCLOSABLE_IDENTITY_RE} 白名单闭形里(不再是「不像密钥就可披露」—— 6.1.0 / 6.2.0 的黑名单
230
+ * 判据放行过 `key=` + 百分号编码的整把 PEM 私钥,codex 对 v6.1.0 复审 [high]);
231
+ * ② **否决**(「无密钥形」):坐标脱敏器 {@link redactSecrets} 原样放行 —— 集内照样有密钥形(`sk-…` / `ghp_…` /
232
+ * `AKIA…` / 三段 JWT 的字符全在集内),而且判它的必须是坐标段、`error` 文案({@link sanitizeZodError})用的**同一只**
233
+ * 脱敏器:两只判据会在同一条告警里给出两种答案(对抗轮 #2 真复现;集内的残例 = `@` 之后的单点 `eyJ…` 串,裸探测器
234
+ * 判阴、脱敏器会改,语料 c41 钉着);∧ 不是带 userinfo 的 URL —— 校验侧 `NON_SECRET_URL` 拒写的同一只判定
235
+ * (`carriesUserinfo`,包内私有;opus 腿 L1:`postgres://svc:pw@h` 的字符全在集内)。
236
+ * 入参是 `string`、返回 `boolean`,**刻意不是**类型谓词(`v is string`):它对一些字符串答 false,谓词形会让调用方的
237
+ * 否定分支把一个 `string` 收窄成 `never`;「是不是字符串」由调用点自己判(运行期收到非字符串照答 false、不抛)。
238
+ * 不可披露的串按**无名**处置(不带,而不是打码:打过码的串不再是身份)。
195
239
  * 执法面(同一条律):被丢条目的身份({@link DroppedEntryIdentity},本文件铸);`ModelRefusalsWire` 的 `entries` 键
196
240
  * (经 {@link isReportableRefusalName},S-768);引擎自己的日志 / 拒因句里要不要写出一个名字,同样问这一只。 */
197
241
  export function isDisclosableIdentity(v) {
198
- return typeof v === "string" && redactSecrets(v) === v; // 非字符串(JS / any 调用方)⇒ false,不抛(秘密轴 fail-closed)
242
+ // 非字符串(JS / any 调用方)⇒ false,不抛(秘密轴 fail-closed)
243
+ return typeof v === "string" && DISCLOSABLE_IDENTITY_RE.test(v) && redactSecrets(v) === v && !carriesUserinfo(v);
199
244
  }
200
245
  /** S-768 —— 「一个目录条目名能不能当 {@link ModelRefusalsWire} `entries` 的键」的**唯一**判据;引擎铸键前只调这一只,
201
246
  * 不过 ⇒ 按无名处置(不进键、不计数)。两条律的合取,不手抄任何一条:
202
247
  * ① 这只 schema **自己载得住**这个键 —— 就地拿 schema 判(空串不过键规则;`__proto__` 这类名被 zod 的 record 静默丢掉,
203
248
  * 载不住),不写名单;
204
- * ② {@link isDisclosableIdentity}(秘密形 ⇒ 不出门)。
249
+ * ② {@link isDisclosableIdentity}(不是白名单闭形里的标识符,或是秘密形 ⇒ 不出门)。
205
250
  * 非字符串 ⇒ false。住在本文件而不是 `./fleet`:`fleet.ts` 被 `types.ts` 引,它再引本文件会成环。 */
206
251
  export function isReportableRefusalName(name) {
207
252
  if (!isDisclosableIdentity(name))
@@ -274,7 +319,7 @@ function judgeEntry(schema, value) {
274
319
  * 造出一个空集合**。
275
320
  *
276
321
  * 🔴 **告警不许比它诊断的东西更危险**(复审 R1 两条,红先复现):① Record 的键是用户写的,完全可能就是
277
- * 一条粘错位置的凭据,所以路径里的键段过 {@link redactSecrets} 才出门(不变量①:secret 值永不随错误外流,
322
+ * 一条粘错位置的凭据,所以路径里的键段过 {@link redactSecrets} 才出门(6.3.0 起经 `outgoingPath` 这一道;不变量①:secret 值永不随错误外流,
278
323
  * 与 parseDomain 的 R13/R14 同一条);② 整段遍历包 try/catch —— 读字段、枚举 Record 都会触发访问器,
279
324
  * 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
280
325
  * 绝不把解析拖下水、更不回显它的错误(与同文件 {@link droppedKeys} 逐字同纪律)。
@@ -320,7 +365,7 @@ function salvageEntriesUnsafe(schema, raw) {
320
365
  keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
321
366
  return true;
322
367
  }
323
- bad.push(droppedEntry([field, i], error, entry));
368
+ bad.push(droppedEntry(outgoingPath([field, i]), error, entry));
324
369
  return false;
325
370
  });
326
371
  // 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
@@ -340,7 +385,7 @@ function salvageEntriesUnsafe(schema, raw) {
340
385
  if (error === undefined)
341
386
  kept[key] = entry;
342
387
  else
343
- bad.push(droppedEntry([field, redactSecrets(key)], error, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
388
+ bad.push(droppedEntry(outgoingPath([field, key]), error, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
344
389
  }
345
390
  if (bad.length > 0 && Object.keys(kept).length > 0) {
346
391
  dropped.push(...bad);
@@ -420,7 +465,7 @@ export const WARNING_KINDS = [
420
465
  export function projectWarning(w) {
421
466
  switch (w.kind) {
422
467
  case "hosts-grandfathered":
423
- return { kind: w.kind, domain: w.domain, droppedNames: w.droppedNames, ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
468
+ return { kind: w.kind, domain: w.domain, droppedNames: w.droppedNames, ...(w.droppedTotal === undefined ? {} : { droppedTotal: w.droppedTotal }), ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
424
469
  case "domain-defaulted":
425
470
  return { kind: w.kind, domain: w.domain };
426
471
  // 两臂字段相同但**不合并**:合并后 `kind` 塌成两员的联合,TS 就再也认不出它属于投影联合的哪一员
@@ -597,7 +642,7 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
597
642
  continue; // 扩展命名空间:按契约放的键,不是未知键
598
643
  acc.total += 1;
599
644
  if (acc.keys.length < DROP_SCAN_MAX_KEYS)
600
- acc.keys.push([...prefix, k]);
645
+ acc.keys.push(outgoingPath([...prefix, k]));
601
646
  continue;
602
647
  }
603
648
  collectDropped(domain, raw[k], parsed[k], [...prefix, k], depth + 1, spared, acc);
@@ -683,7 +728,7 @@ export function carriedUnknownKeys(domain, parsed) {
683
728
  continue;
684
729
  acc.total += 1;
685
730
  if (acc.keys.length < DROP_SCAN_MAX_KEYS)
686
- acc.keys.push([...at, k]);
731
+ acc.keys.push(outgoingPath([...at, k]));
687
732
  }
688
733
  }
689
734
  return acc;
@@ -767,15 +812,23 @@ export const DOMAIN_READ_FALLBACK = {
767
812
  * observable: a deployment that has not seen that warning across a full read cycle no longer needs the arm. */
768
813
  export function grandfatherHostsRead(raw) {
769
814
  if (raw === null || typeof raw !== "object" || !Array.isArray(raw.hosts)) {
770
- return { raw, droppedNames: [], keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
815
+ return { raw, droppedNames: [], droppedTotal: 0, keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
771
816
  }
772
817
  const original = raw.hosts;
773
818
  const droppedNames = [];
819
+ let droppedTotal = 0;
774
820
  const keptFrom = [];
775
821
  let hosts = original.filter((h, i) => {
776
822
  const name = h !== null && typeof h === "object" ? h.name : undefined;
777
823
  if (typeof name === "string" && name.length > HOST_NAME_MAX) {
778
- droppedNames.push(name.slice(0, HOST_NAME_MAX) + "…"); // audit label — clipped so the warning itself stays bounded
824
+ droppedTotal += 1;
825
+ // 先钳后扫(S-813):只给前 DROP_SCAN_MAX_KEYS 个铸标签,其余只计数 —— 出门那一道每个名都要归一化一遍,不能随文档条数放大。
826
+ if (droppedNames.length >= DROP_SCAN_MAX_KEYS)
827
+ return false;
828
+ // 出门前先过同一道(6.3.0,opus 腿 M2):用户写的主机名完全可能就是一条粘错位置的凭据,夹紧只管载荷上界、不管秘密 ——
829
+ // 此前 `sk-live-…` 形 / 编码 PEM 形以 63 字原文前缀出门。判在夹紧**之前**(截断会把秘密形截成探测器认不出的半截)。
830
+ const out = outgoingSegment(name);
831
+ droppedNames.push(out.length > HOST_NAME_MAX ? out.slice(0, HOST_NAME_MAX) + "…" : out); // audit label — clipped so the warning itself stays bounded
779
832
  return false;
780
833
  }
781
834
  keptFrom.push(i); // 夹紧后第 keptFrom.length-1 条 = 原文档第 i 条
@@ -786,11 +839,11 @@ export function grandfatherHostsRead(raw) {
786
839
  truncatedFrom = hosts.length;
787
840
  hosts = hosts.slice(0, HOSTS_ARRAY_MAX);
788
841
  }
789
- if (droppedNames.length === 0 && truncatedFrom === undefined)
790
- return { raw, droppedNames, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
842
+ if (droppedTotal === 0 && truncatedFrom === undefined)
843
+ return { raw, droppedNames, droppedTotal, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
791
844
  // 位移**只**来自按名剔条目;纯截断留下的是原数组前缀,幸存者下标不动 ⇒ 不登记(见 EntryKeptIndex)。
792
- const keptIndex = droppedNames.length > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
793
- return { raw: { ...raw, hosts }, droppedNames, truncatedFrom, keptIndex };
845
+ const keptIndex = droppedTotal > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
846
+ return { raw: { ...raw, hosts }, droppedNames, droppedTotal, truncatedFrom, keptIndex };
794
847
  }
795
848
  export function buildEffective(version, getRaw, updatedAt, onWarning) {
796
849
  // 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
@@ -867,8 +920,9 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
867
920
  // hosts: the PRECISE grandfather for the 0.8.0 tightening runs BEFORE parse (0.7-era legal data over the
868
921
  // new bounds reads on, clamped + warned); any residual corruption falls to the generic guardrail above.
869
922
  const hostsGf = grandfatherHostsRead(getRaw("hosts"));
870
- if (hostsGf.droppedNames.length > 0 || hostsGf.truncatedFrom !== undefined) {
871
- onWarning?.({ domain: "hosts", kind: "hosts-grandfathered", droppedNames: hostsGf.droppedNames, truncatedFrom: hostsGf.truncatedFrom });
923
+ if (hostsGf.droppedTotal > 0 || hostsGf.truncatedFrom !== undefined) {
924
+ const clipped = hostsGf.droppedTotal > hostsGf.droppedNames.length ? { droppedTotal: hostsGf.droppedTotal } : {};
925
+ onWarning?.({ domain: "hosts", kind: "hosts-grandfathered", droppedNames: hostsGf.droppedNames, ...clipped, truncatedFrom: hostsGf.truncatedFrom });
872
926
  }
873
927
  return {
874
928
  version,
@@ -15,11 +15,11 @@
15
15
  * HELD cross-session message awaits approval」。**注意它有两个消费面**:远端停靠的审批对话框,与
16
16
  * 停审的跨会话消息;本模块只声明键形,哪个宿主消费哪一面由宿主自陈。
17
17
  *
18
- * ## `"default"` 这第四个拼写
18
+ * ## `"default"` 这第五个拼写
19
19
  *
20
- * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第四种策略**:读取器
20
+ * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第五种策略**:读取器
21
21
  * (`readCrossSessionInbound` / `readDialogExpiry`)把它与 `null`/缺席一同折成 `undefined`。所以
22
- * `CrossSessionInboundSetting` 的受理面是四拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有三个。
22
+ * `CrossSessionInboundSetting` 的受理面是五拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有四个。
23
23
  *
24
24
  * ## 坏值方向 = 响亮,不静默
25
25
  *
@@ -29,8 +29,11 @@
29
29
  *
30
30
  * ## 与引擎词表的锁步
31
31
  *
32
- * 引擎另有一份同词表(判定纯函数的输入域)。两份**必须逐字相等**;等值由消费方(同时装了本包与引擎
33
- * 的那一侧)用一格锁步钉住 —— 任一侧加词/改序,那一格先红。本包不 import 引擎(纯度不变量)。
32
+ * 两张词表与 dialogExpiry 缺省的语义属主是引擎(判定纯函数的输入域;缺省也铸在引擎),本模块的三只常量是
33
+ * 它们的**登记镜像** —— 保留镜像是因为本包不能运行期依赖引擎,删掉它们两键的 schema 就只剩 `z.string()`。
34
+ * 两份**必须逐字同序相等**;等值由**本仓自证**:`test/upstream-drift.test.ts` ④ 在 dev 期读引擎的运行期导出,
35
+ * 登记表恒空(零豁免)、顺序另钉 —— 任一侧加词/改序/改缺省,那一格先红。消费方不再替本仓守这面镜像,只钉
36
+ * 「自己读的是引擎那份」。本包不 import 引擎(纯度不变量;引擎只在 devDependency)。
34
37
  */
35
38
  import { z } from "zod";
36
39
  /**
@@ -15,11 +15,11 @@
15
15
  * HELD cross-session message awaits approval」。**注意它有两个消费面**:远端停靠的审批对话框,与
16
16
  * 停审的跨会话消息;本模块只声明键形,哪个宿主消费哪一面由宿主自陈。
17
17
  *
18
- * ## `"default"` 这第四个拼写
18
+ * ## `"default"` 这第五个拼写
19
19
  *
20
- * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第四种策略**:读取器
20
+ * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第五种策略**:读取器
21
21
  * (`readCrossSessionInbound` / `readDialogExpiry`)把它与 `null`/缺席一同折成 `undefined`。所以
22
- * `CrossSessionInboundSetting` 的受理面是四拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有三个。
22
+ * `CrossSessionInboundSetting` 的受理面是五拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有四个。
23
23
  *
24
24
  * ## 坏值方向 = 响亮,不静默
25
25
  *
@@ -29,8 +29,11 @@
29
29
  *
30
30
  * ## 与引擎词表的锁步
31
31
  *
32
- * 引擎另有一份同词表(判定纯函数的输入域)。两份**必须逐字相等**;等值由消费方(同时装了本包与引擎
33
- * 的那一侧)用一格锁步钉住 —— 任一侧加词/改序,那一格先红。本包不 import 引擎(纯度不变量)。
32
+ * 两张词表与 dialogExpiry 缺省的语义属主是引擎(判定纯函数的输入域;缺省也铸在引擎),本模块的三只常量是
33
+ * 它们的**登记镜像** —— 保留镜像是因为本包不能运行期依赖引擎,删掉它们两键的 schema 就只剩 `z.string()`。
34
+ * 两份**必须逐字同序相等**;等值由**本仓自证**:`test/upstream-drift.test.ts` ④ 在 dev 期读引擎的运行期导出,
35
+ * 登记表恒空(零豁免)、顺序另钉 —— 任一侧加词/改序/改缺省,那一格先红。消费方不再替本仓守这面镜像,只钉
36
+ * 「自己读的是引擎那份」。本包不 import 引擎(纯度不变量;引擎只在 devDependency)。
34
37
  */
35
38
  import { z } from "zod";
36
39
  // ── crossSessionInbound ────────────────────────────────────────────────────────────────────────────
package/dist/fleet.d.ts CHANGED
@@ -132,8 +132,9 @@ export type ModelPlaneRefusalCode = z.infer<typeof ModelPlaneRefusalCode>;
132
132
  * broken row held, and the sentence is prose, not contract. "Which name, which reason" is the whole wire. A name that
133
133
  * cannot ride out is treated as NAMELESS — the engine leaves it out of this map entirely (not keyed, not counted). The
134
134
  * ONE predicate is `isReportableRefusalName(name)` (main barrel): this schema can carry the key (non-empty; `__proto__`
135
- * is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (not secret-shaped — the rule the
136
- * dropped-entry identity on read warnings already uses). A producer calls it before keying; this schema deliberately
135
+ * is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (an identifier in the closed allowlist
136
+ * `DISCLOSABLE_IDENTITY_RE` — `A-Z a-z 0-9 . _ : / @ + -`, 1–256 chars — AND not secret-shaped, a URL userinfo included; the
137
+ * rule the dropped-entry identity on read warnings already uses, 6.3.0). A producer calls it before keying; this schema deliberately
137
138
  * does not re-judge secrecy (see the 6.2.0 notes).
138
139
  * - `plane` — present ⇔ the model plane of the latest catalog the engine was handed was refused as a group (the engine
139
140
  * keeps serving the model plane it had); ONE code, never a list — one candidate has exactly one group verdict (the
package/dist/fleet.js CHANGED
@@ -139,8 +139,9 @@ export const ModelPlaneRefusalCode = z.enum(MODEL_PLANE_REFUSAL_CODES);
139
139
  * broken row held, and the sentence is prose, not contract. "Which name, which reason" is the whole wire. A name that
140
140
  * cannot ride out is treated as NAMELESS — the engine leaves it out of this map entirely (not keyed, not counted). The
141
141
  * ONE predicate is `isReportableRefusalName(name)` (main barrel): this schema can carry the key (non-empty; `__proto__`
142
- * is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (not secret-shaped — the rule the
143
- * dropped-entry identity on read warnings already uses). A producer calls it before keying; this schema deliberately
142
+ * is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (an identifier in the closed allowlist
143
+ * `DISCLOSABLE_IDENTITY_RE` — `A-Z a-z 0-9 . _ : / @ + -`, 1–256 chars — AND not secret-shaped, a URL userinfo included; the
144
+ * rule the dropped-entry identity on read warnings already uses, 6.3.0). A producer calls it before keying; this schema deliberately
144
145
  * does not re-judge secrecy (see the 6.2.0 notes).
145
146
  * - `plane` — present ⇔ the model plane of the latest catalog the engine was handed was refused as a group (the engine
146
147
  * keeps serving the model plane it had); ONE code, never a list — one candidate has exactly one group verdict (the
@@ -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. */
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
  }
@@ -2053,7 +2039,7 @@ export const PLUGIN_ORIGIN = z
2053
2039
  return true; // 已被上一条拒;避免对非 URL 再报一条噪声
2054
2040
  try {
2055
2041
  const u = new URL(s);
2056
- return !u.username && !u.password && !urlQueryFragmentHasSecret(u);
2042
+ return !carriesUserinfo(u) && !urlQueryFragmentHasSecret(u);
2057
2043
  }
2058
2044
  catch {
2059
2045
  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.3.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",