@tormentalabs/claude-code-wire-compat 0.6.0 → 0.7.0-rc.1

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/src/redaction.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
 
3
+ import { MAX_INPUT_SIZE } from "./limits.js";
4
+
3
5
  import type {
4
6
  ClaudeCodeCapabilityDecisions,
5
7
  ClaudeCodeModelFamily,
@@ -10,6 +12,11 @@ import type {
10
12
  } from "./contracts.js";
11
13
  import { ClaudeCodeWireError } from "./contracts.js";
12
14
  import { classifySurrogateAt } from "./unicode.js";
15
+ import {
16
+ isValidViolationCodeUnit,
17
+ isValidViolationPath,
18
+ isValidViolationReason,
19
+ } from "./violation.js";
13
20
 
14
21
  /** Excludes values used only while validating and assembling the request. */
15
22
  export type NormalizedRequestInput = Omit<
@@ -63,7 +70,6 @@ export interface BuildRedactedEvidenceInput {
63
70
  }
64
71
 
65
72
  const MAX_INPUT_DEPTH = 100;
66
- const MAX_INPUT_SIZE = 1_000_000;
67
73
  const ENDPOINT = "https://api.anthropic.com/v1/messages?beta=true";
68
74
  /**
69
75
  * The profile ids evidence may claim, spelled out here for the same reason
@@ -395,7 +401,7 @@ export function toSafeErrorDetails(
395
401
  "maximumDepth",
396
402
  "maximumSize",
397
403
  ] as const;
398
- const booleanKeys = ["hasSystem", "hasTools"] as const;
404
+ const booleanKeys = ["hasSystem", "hasTools", "violationInKey"] as const;
399
405
 
400
406
  for (const key of numericKeys) {
401
407
  const descriptor = Object.getOwnPropertyDescriptor(safeDetails, key);
@@ -415,6 +421,60 @@ export function toSafeErrorDetails(
415
421
  : undefined;
416
422
  if (typeof detail === "boolean") details[key] = detail;
417
423
  }
424
+
425
+ // Violation diagnostics (P1.T1, D4): strings pass only through closed-set
426
+ // or pattern validators, and numbers only through integer/range guards, so
427
+ // no caller text, key name or excerpt can ride these fields out of an error.
428
+ const stringValidators: Readonly<Record<string, (value: string) => boolean>> =
429
+ {
430
+ violationReason: isValidViolationReason,
431
+ violationPath: isValidViolationPath,
432
+ };
433
+ for (const [key, validator] of Object.entries(stringValidators)) {
434
+ const descriptor = Object.getOwnPropertyDescriptor(safeDetails, key);
435
+ const detail: unknown =
436
+ descriptor !== undefined && "value" in descriptor
437
+ ? descriptor.value
438
+ : undefined;
439
+ if (typeof detail === "string" && validator(detail)) {
440
+ details[key] = detail;
441
+ }
442
+ }
443
+ const integerKeys = ["violationOffset", "violationTextLength"] as const;
444
+ for (const key of integerKeys) {
445
+ const descriptor = Object.getOwnPropertyDescriptor(safeDetails, key);
446
+ const detail: unknown =
447
+ descriptor !== undefined && "value" in descriptor
448
+ ? descriptor.value
449
+ : undefined;
450
+ if (
451
+ typeof detail === "number" &&
452
+ Number.isSafeInteger(detail) &&
453
+ !Object.is(detail, -0) &&
454
+ detail >= 0 &&
455
+ detail <= MAX_INPUT_SIZE
456
+ ) {
457
+ details[key] = detail;
458
+ }
459
+ }
460
+ {
461
+ const descriptor = Object.getOwnPropertyDescriptor(
462
+ safeDetails,
463
+ "violationCodeUnit",
464
+ );
465
+ const detail: unknown =
466
+ descriptor !== undefined && "value" in descriptor
467
+ ? descriptor.value
468
+ : undefined;
469
+ if (
470
+ typeof detail === "number" &&
471
+ isValidViolationCodeUnit(detail) &&
472
+ ((details["violationReason"] === "lone-surrogate" && detail >= 0xd800) ||
473
+ (details["violationReason"] === "control-char" && detail <= 0x9f))
474
+ ) {
475
+ details["violationCodeUnit"] = detail;
476
+ }
477
+ }
418
478
  return Object.freeze(details);
419
479
  }
420
480
 
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
 
3
+ import { MAX_INPUT_SIZE as MAX_SIZE } from "./limits.js";
4
+
3
5
  import { CLAUDE_CODE_2_1_195_PROFILE } from "./profiles/claude-code-2.1.195.js";
4
6
  import { clampMaxTokens, resolveThinking } from "./thinking.js";
5
7
  import type { ThinkingDisplay, ThinkingRequest } from "./thinking.js";
@@ -29,11 +31,15 @@ import type {
29
31
  import { deriveCapabilities } from "./model-capabilities.js";
30
32
  import { stripModelMarkers } from "./model-identity.js";
31
33
  import { IDENTITY_TEXT } from "./system-prompt.js";
32
- import { classifySurrogateAt } from "./unicode.js";
34
+ import {
35
+ inspectText,
36
+ TEXT_POLICY_IDENTIFIER,
37
+ TEXT_POLICY_PROSE,
38
+ } from "./unicode.js";
39
+ import { violationDetails, type ViolationPathSegment } from "./violation.js";
33
40
 
34
41
  const MAX_DEPTH = 100;
35
42
  const MAX_ITEMS = 100_000;
36
- const MAX_SIZE = 1_000_000;
37
43
  const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
38
44
  const MESSAGE_KEYS = new Set(["role", "content"]);
39
45
  const CACHE_CONTROL_KEYS = new Set(["type", "ttl"]);
@@ -364,8 +370,9 @@ type ModelResolution = Readonly<{
364
370
 
365
371
  function fail(
366
372
  code: ConstructorParameters<typeof ClaudeCodeWireError>[0],
373
+ safeDetails: Readonly<Record<string, string | number | boolean>> = {},
367
374
  ): never {
368
- throw new ClaudeCodeWireError(code);
375
+ throw new ClaudeCodeWireError(code, safeDetails);
369
376
  }
370
377
 
371
378
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -380,25 +387,21 @@ function inspectString(
380
387
  value: string,
381
388
  state: InspectionState,
382
389
  validateString?: (value: string) => void,
390
+ path: readonly ViolationPathSegment[] = [],
391
+ inKey = false,
383
392
  ): void {
384
393
  state.size += value.length;
385
394
  if (state.size > MAX_SIZE) fail("INPUT_TOO_LARGE");
386
395
  validateString?.(value);
387
396
 
388
- for (let index = 0; index < value.length; index += 1) {
389
- const unit = value.charCodeAt(index);
390
- if (
391
- unit <= 0x08 ||
392
- unit === 0x0b ||
393
- unit === 0x0c ||
394
- (unit >= 0x0e && unit <= 0x1f) ||
395
- unit === 0x7f
396
- ) {
397
- fail("INVALID_INPUT");
398
- }
399
- const classification = classifySurrogateAt(value, index);
400
- if (classification === "loneSurrogate") fail("INVALID_UNICODE");
401
- if (classification === "surrogatePair") index += 1;
397
+ // Body prose policy (P1.T1): only a lone surrogate can fail here now, so
398
+ // the historical INVALID_INPUT-for-controls branch is gone by construction.
399
+ const violation = inspectText(value, TEXT_POLICY_PROSE);
400
+ if (violation !== null) {
401
+ fail(
402
+ "INVALID_UNICODE",
403
+ violationDetails(violation, path, value.length, inKey),
404
+ );
402
405
  }
403
406
  }
404
407
 
@@ -407,11 +410,12 @@ function inspect(
407
410
  depth: number,
408
411
  state: InspectionState,
409
412
  validateString?: (value: string) => void,
413
+ path: ViolationPathSegment[] = [],
410
414
  ): void {
411
415
  if (depth > MAX_DEPTH) fail("INPUT_TOO_DEEP");
412
416
  if (value === null || typeof value === "boolean") return;
413
417
  if (typeof value === "string") {
414
- inspectString(value, state, validateString);
418
+ inspectString(value, state, validateString, path, false);
415
419
  return;
416
420
  }
417
421
  if (typeof value === "number") {
@@ -430,7 +434,9 @@ function inspect(
430
434
  if (state.size > MAX_SIZE) fail("INPUT_TOO_LARGE");
431
435
  for (let index = 0; index < value.length; index += 1) {
432
436
  if (!hasOwn(value, String(index))) fail("INVALID_INPUT");
433
- inspect(value[index], depth + 1, state, validateString);
437
+ path.push(index);
438
+ inspect(value[index], depth + 1, state, validateString, path);
439
+ path.pop();
434
440
  }
435
441
  } else {
436
442
  const prototype = Reflect.getPrototypeOf(value);
@@ -441,27 +447,37 @@ function inspect(
441
447
  if (typeof key !== "string" || FORBIDDEN_KEYS.has(key)) {
442
448
  fail("INVALID_INPUT");
443
449
  }
444
- inspectString(key, state, validateString);
450
+ // Object keys stay strings: digits inside user keys must not become
451
+ // readable numeric path segments (QA F1).
452
+ path.push(key);
453
+ inspectString(key, state, validateString, path, true);
445
454
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
446
455
  if (descriptor === undefined || !("value" in descriptor)) {
447
456
  fail("INVALID_INPUT");
448
457
  }
449
- inspect(descriptor.value, depth + 1, state, validateString);
458
+ inspect(descriptor.value, depth + 1, state, validateString, path);
459
+ path.pop();
450
460
  }
451
461
  }
452
462
  state.active.delete(value);
453
463
  }
454
464
 
465
+ /** Paths without a caller hint are relative to the inspected value's root. */
455
466
  export function inspectJsonInputs(
456
467
  values: readonly unknown[],
457
468
  validateString?: (value: string) => void,
469
+ paths?: readonly (readonly ViolationPathSegment[])[],
458
470
  ): void {
459
471
  const state: InspectionState = {
460
472
  active: new WeakSet(),
461
473
  items: 0,
462
474
  size: 0,
463
475
  };
464
- for (const value of values) inspect(value, 0, state, validateString);
476
+ for (let index = 0; index < values.length; index += 1) {
477
+ inspect(values[index], 0, state, validateString, [
478
+ ...(paths?.[index] ?? []),
479
+ ]);
480
+ }
465
481
  }
466
482
 
467
483
  function requireRecord(value: unknown): Record<string, unknown> {
@@ -483,6 +499,13 @@ function requireString(value: unknown): string {
483
499
  return value;
484
500
  }
485
501
 
502
+ function requireIdentifier(value: unknown): string {
503
+ const text = requireString(value);
504
+ if (inspectText(text, TEXT_POLICY_IDENTIFIER) !== null)
505
+ fail("INVALID_UNICODE");
506
+ return text;
507
+ }
508
+
486
509
  function requirePositiveInteger(value: unknown): number {
487
510
  if (!Number.isSafeInteger(value) || typeof value !== "number" || value <= 0) {
488
511
  fail("INVALID_INPUT");
@@ -829,7 +852,7 @@ function imageSource(value: unknown): ImageBlock["source"] {
829
852
  )
830
853
  fail("INVALID_INPUT");
831
854
  entries.push([key, item]);
832
- } else entries.push([key, requireString(item)]);
855
+ } else entries.push([key, requireIdentifier(item)]);
833
856
  }
834
857
  return Object.fromEntries(entries) as unknown as ImageBlock["source"];
835
858
  }
@@ -890,7 +913,11 @@ function documentSource(value: unknown): DocumentBlock["source"] {
890
913
  }),
891
914
  ]);
892
915
  }
893
- } else entries.push([key, requireString(item)]);
916
+ } else
917
+ entries.push([
918
+ key,
919
+ type === "text" ? requireString(item) : requireIdentifier(item),
920
+ ]);
894
921
  }
895
922
  return Object.fromEntries(entries) as unknown as DocumentBlock["source"];
896
923
  }
@@ -925,7 +952,11 @@ function thinkingBlock(
925
952
  if (key === "type") entries.push([key, "thinking"]);
926
953
  else if (key === "cache_control")
927
954
  entries.push([key, nullable(item, cacheControl)]);
928
- else entries.push([key, requireString(item)]);
955
+ else
956
+ entries.push([
957
+ key,
958
+ key === "signature" ? requireIdentifier(item) : requireString(item),
959
+ ]);
929
960
  }
930
961
  return Object.fromEntries(entries) as unknown as ThinkingBlock;
931
962
  }
@@ -950,7 +981,7 @@ function redactedThinkingBlock(
950
981
  if (key === "type") entries.push([key, "redacted_thinking"]);
951
982
  else if (key === "cache_control")
952
983
  entries.push([key, nullable(item, cacheControl)]);
953
- else entries.push([key, requireString(item)]);
984
+ else entries.push([key, requireIdentifier(item)]);
954
985
  }
955
986
  return Object.fromEntries(entries) as unknown as RedactedThinkingBlock;
956
987
  }
@@ -966,8 +997,8 @@ function searchResultBlock(value: unknown): SearchResultBlock {
966
997
  if (key === "content") {
967
998
  if (!Array.isArray(item)) fail("INVALID_INPUT");
968
999
  entries.push([key, item.map((block) => textBlock(block))]);
969
- } else if (key === "source" || key === "title")
970
- entries.push([key, requireString(item)]);
1000
+ } else if (key === "source") entries.push([key, requireIdentifier(item)]);
1001
+ else if (key === "title") entries.push([key, requireString(item)]);
971
1002
  else if (key === "type") entries.push([key, "search_result"]);
972
1003
  else if (key === "cache_control")
973
1004
  entries.push([key, nullable(item, cacheControl)]);
@@ -984,7 +1015,7 @@ function toolReferenceBlock(value: unknown): ToolReferenceBlock {
984
1015
  const entries: [string, unknown][] = [];
985
1016
  for (const key of Object.keys(record)) {
986
1017
  const item = record[key];
987
- if (key === "tool_name") entries.push([key, requireString(item)]);
1018
+ if (key === "tool_name") entries.push([key, requireIdentifier(item)]);
988
1019
  else if (key === "type") entries.push([key, "tool_reference"]);
989
1020
  else entries.push([key, nullable(item, cacheControl)]);
990
1021
  }
@@ -1000,7 +1031,7 @@ function toolUseBlock(value: unknown): ToolUseBlock {
1000
1031
  for (const key of Object.keys(record)) {
1001
1032
  const item = record[key];
1002
1033
  if (key === "id" || key === "name")
1003
- entries.push([key, requireString(item)]);
1034
+ entries.push([key, requireIdentifier(item)]);
1004
1035
  else if (key === "input") entries.push([key, validatedJson(item)]);
1005
1036
  else if (key === "type") entries.push([key, "tool_use"]);
1006
1037
  else if (key === "cache_control")
@@ -1025,7 +1056,7 @@ function toolCaller(value: unknown): ToolUseBlock["caller"] {
1025
1056
  return Object.fromEntries(
1026
1057
  Object.keys(record).map((key) => [
1027
1058
  key,
1028
- key === "type" ? type : requireString(record[key]),
1059
+ key === "type" ? type : requireIdentifier(record[key]),
1029
1060
  ]),
1030
1061
  ) as unknown as NonNullable<ToolUseBlock["caller"]>;
1031
1062
  }
@@ -1048,7 +1079,7 @@ function toolResultBlock(value: unknown): ToolResultBlock {
1048
1079
  const entries: [string, unknown][] = [];
1049
1080
  for (const key of Object.keys(record)) {
1050
1081
  const item = record[key];
1051
- if (key === "tool_use_id") entries.push([key, requireString(item)]);
1082
+ if (key === "tool_use_id") entries.push([key, requireIdentifier(item)]);
1052
1083
  else if (key === "type") entries.push([key, "tool_result"]);
1053
1084
  else if (key === "cache_control")
1054
1085
  entries.push([key, nullable(item, cacheControl)]);
@@ -1327,8 +1358,8 @@ function customToolDefinition(record: Record<string, unknown>): ToolDefinition {
1327
1358
  for (const key of Object.keys(record)) {
1328
1359
  const item = record[key];
1329
1360
  if (key === "input_schema") entries.push([key, toolInputSchema(item)]);
1330
- else if (key === "name" || key === "description")
1331
- entries.push([key, requireString(item)]);
1361
+ else if (key === "name") entries.push([key, requireIdentifier(item)]);
1362
+ else if (key === "description") entries.push([key, requireString(item)]);
1332
1363
  else if (key === "allowed_callers")
1333
1364
  entries.push([key, allowedCallers(item)]);
1334
1365
  else if (key === "cache_control")
@@ -1357,7 +1388,7 @@ function builtInToolDefinition(
1357
1388
  if (item !== spec.name) fail("INVALID_INPUT");
1358
1389
  entries.push([key, item]);
1359
1390
  } else if (key === "mcp_server_name" || key === "model")
1360
- entries.push([key, requireString(item)]);
1391
+ entries.push([key, requireIdentifier(item)]);
1361
1392
  else if (key === "allowed_callers")
1362
1393
  entries.push([key, allowedCallers(item)]);
1363
1394
  else if (key === "cache_control" || key === "caching")
@@ -1401,7 +1432,7 @@ function tools(value: unknown): readonly ToolDefinition[] {
1401
1432
  ? builtInToolDefinition(record)
1402
1433
  : customToolDefinition(record);
1403
1434
  if (hasOwn(record, "name")) {
1404
- const name = requireString(record["name"]);
1435
+ const name = requireIdentifier(record["name"]);
1405
1436
  if (names.has(name)) fail("INVALID_INPUT");
1406
1437
  names.add(name);
1407
1438
  }
@@ -1462,8 +1493,8 @@ function modelResolution(
1462
1493
  fail("INVALID_INPUT");
1463
1494
  }
1464
1495
  return {
1465
- id: requireString(record["id"]),
1466
- wireId: requireString(record["wireId"]),
1496
+ id: requireIdentifier(record["id"]),
1497
+ wireId: requireIdentifier(record["wireId"]),
1467
1498
  capabilities: {
1468
1499
  thinking: capabilityBoolean(capabilities.thinking, derived.thinking),
1469
1500
  adaptiveThinking: capabilityBoolean(
@@ -1647,7 +1678,8 @@ function toolChoice(value: unknown): Readonly<Record<string, unknown>> {
1647
1678
  const entries: [string, unknown][] = [];
1648
1679
  for (const key of Object.keys(record)) {
1649
1680
  if (key === "type") entries.push([key, type]);
1650
- else if (key === "name") entries.push([key, requireString(record[key])]);
1681
+ else if (key === "name")
1682
+ entries.push([key, requireIdentifier(record[key])]);
1651
1683
  else entries.push([key, requireBoolean(record[key])]);
1652
1684
  }
1653
1685
  return Object.fromEntries(entries);
@@ -1747,13 +1779,17 @@ export function buildCanonicalBody(
1747
1779
  profile?: ClaudeCodeProtocolProfile,
1748
1780
  thinkingDisplayOverride?: "updates",
1749
1781
  ): Readonly<Record<string, unknown>> {
1750
- inspectJsonInputs([
1751
- rawInput,
1752
- rawResolvedModel,
1753
- rawSystemBlocks,
1754
- rawMetadata,
1755
- ...(profile === undefined ? [] : [profile]),
1756
- ]);
1782
+ inspectJsonInputs(
1783
+ [
1784
+ rawInput,
1785
+ rawResolvedModel,
1786
+ rawSystemBlocks,
1787
+ rawMetadata,
1788
+ ...(profile === undefined ? [] : [profile]),
1789
+ ],
1790
+ undefined,
1791
+ [[], ["model"], ["system"], ["metadata"], ["profileOverride"]],
1792
+ );
1757
1793
 
1758
1794
  const input = requireRecord(rawInput);
1759
1795
  assertExactKeys(input, INPUT_KEY_SET);
@@ -1,12 +1,15 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
 
3
+ import { MAX_INPUT_SIZE } from "./limits.js";
4
+
3
5
  import type {
4
6
  ClaudeCodeRuntimeIdentity,
5
7
  SystemInput,
6
8
  TextBlock,
7
9
  } from "./contracts.js";
8
10
  import { ClaudeCodeWireError } from "./contracts.js";
9
- import { classifySurrogateAt } from "./unicode.js";
11
+ import { inspectText, TEXT_POLICY_PROSE } from "./unicode.js";
12
+ import { violationDetails, type ViolationPathSegment } from "./violation.js";
10
13
 
11
14
  /**
12
15
  * The pinned identity text, byte-exact.
@@ -26,7 +29,6 @@ import { classifySurrogateAt } from "./unicode.js";
26
29
  export const IDENTITY_TEXT =
27
30
  "You are Claude Code, Anthropic's official CLI for Claude.";
28
31
  const MAX_INPUT_DEPTH = 64;
29
- const MAX_INPUT_SIZE = 1_000_000;
30
32
  type UnknownRecord = Readonly<Record<PropertyKey, unknown>>;
31
33
 
32
34
  function fail(
@@ -36,38 +38,34 @@ function fail(
36
38
  | "INPUT_TOO_DEEP"
37
39
  | "INPUT_TOO_LARGE"
38
40
  | "CYCLIC_INPUT",
41
+ safeDetails: Readonly<Record<string, string | number | boolean>> = {},
39
42
  ): never {
40
- throw new ClaudeCodeWireError(code);
43
+ throw new ClaudeCodeWireError(code, safeDetails);
41
44
  }
42
45
 
43
- function validateText(text: string): void {
44
- for (let index = 0; index < text.length; index += 1) {
45
- const codeUnit = text.charCodeAt(index);
46
-
47
- if (
48
- codeUnit === 0 ||
49
- (codeUnit < 0x20 &&
50
- codeUnit !== 0x09 &&
51
- codeUnit !== 0x0a &&
52
- codeUnit !== 0x0d) ||
53
- (codeUnit >= 0x7f && codeUnit <= 0x9f)
54
- ) {
55
- fail("INVALID_UNICODE");
56
- }
57
-
58
- const classification = classifySurrogateAt(text, index);
59
- if (classification === "loneSurrogate") fail("INVALID_UNICODE");
60
- if (classification === "surrogatePair") index += 1;
46
+ function validateText(
47
+ text: string,
48
+ path: readonly ViolationPathSegment[],
49
+ inKey: boolean,
50
+ ): void {
51
+ // Body prose policy (P1.T1): the system field follows the same rule as
52
+ // every other body lane — only lone surrogates are rejected.
53
+ const violation = inspectText(text, TEXT_POLICY_PROSE);
54
+ if (violation !== null) {
55
+ fail(
56
+ "INVALID_UNICODE",
57
+ violationDetails(violation, path, text.length, inKey),
58
+ );
61
59
  }
62
60
  }
63
61
 
64
62
  function isUnknownRecord(value: unknown): value is UnknownRecord {
65
63
  return value !== null && typeof value === "object";
66
64
  }
67
-
68
65
  function validateStructure(value: unknown): void {
69
66
  const ancestors = new WeakSet();
70
67
  let size = 0;
68
+ const path: ViolationPathSegment[] = ["system"];
71
69
 
72
70
  function visit(current: unknown, depth: number): void {
73
71
  if (depth > MAX_INPUT_DEPTH) fail("INPUT_TOO_DEEP");
@@ -75,7 +73,7 @@ function validateStructure(value: unknown): void {
75
73
  if (typeof current === "string") {
76
74
  size += current.length;
77
75
  if (size > MAX_INPUT_SIZE) fail("INPUT_TOO_LARGE");
78
- validateText(current);
76
+ validateText(current, path, false);
79
77
  return;
80
78
  }
81
79
 
@@ -84,12 +82,22 @@ function validateStructure(value: unknown): void {
84
82
 
85
83
  ancestors.add(current);
86
84
  for (const key of Reflect.ownKeys(current)) {
85
+ // Only ARRAY indices become numeric segments (QA F1/F4); object keys
86
+ // stay strings and digits inside them render as `*`.
87
+ const segment: ViolationPathSegment =
88
+ typeof key !== "string"
89
+ ? "*"
90
+ : Array.isArray(current) && /^(?:0|[1-9]\d{0,5})$/u.test(key)
91
+ ? Number(key)
92
+ : key;
93
+ path.push(segment);
87
94
  if (typeof key === "string") {
88
95
  size += key.length;
89
96
  if (size > MAX_INPUT_SIZE) fail("INPUT_TOO_LARGE");
90
- validateText(key);
97
+ validateText(key, path, true);
91
98
  }
92
99
  visit(current[key], depth + 1);
100
+ path.pop();
93
101
  }
94
102
  ancestors.delete(current);
95
103
  }
package/src/unicode.ts CHANGED
@@ -22,3 +22,74 @@ export function classifySurrogateAt(
22
22
  if (unit >= 0xdc00 && unit <= 0xdfff) return "loneSurrogate";
23
23
  return "notSurrogate";
24
24
  }
25
+
26
+ export type TextViolationReason = "control-char" | "lone-surrogate";
27
+
28
+ /**
29
+ * One character-level policy violation found while screening a string.
30
+ *
31
+ * `offset` is the UTF-16 code-unit index of the offending unit and `codeUnit`
32
+ * is the unit itself. Both are safe diagnostics: offsets are numbers and the
33
+ * only code units ever reported are control characters or surrogate halves,
34
+ * never prose content.
35
+ */
36
+ export interface TextViolation {
37
+ readonly reason: TextViolationReason;
38
+ readonly offset: number;
39
+ readonly codeUnit: number;
40
+ }
41
+
42
+ /**
43
+ * Character-level acceptance policy for one string lane.
44
+ *
45
+ * `rejectControls` rejects every C0 control except TAB (0x09), LF (0x0A) and
46
+ * CR (0x0D), plus DEL (0x7F). Lone surrogates are always rejected, rather than
47
+ * depending on whether a caller serializes or directly encodes the string.
48
+ */
49
+ export interface TextPolicy {
50
+ readonly rejectControls: boolean;
51
+ }
52
+
53
+ /** Body prose: every well-formed UTF-16 string is accepted. */
54
+ export const TEXT_POLICY_PROSE: TextPolicy = Object.freeze({
55
+ rejectControls: false,
56
+ });
57
+
58
+ /**
59
+ * Identifier graph screening: C0 except TAB/LF/CR, plus DEL, plus lone
60
+ * surrogates. This is exactly the set the graph inspectors rejected before the
61
+ * shared validator existed.
62
+ */
63
+ export const TEXT_POLICY_IDENTIFIER: TextPolicy = Object.freeze({
64
+ rejectControls: true,
65
+ });
66
+
67
+ /**
68
+ * Screens one string under `policy`, returning the first violation or null.
69
+ *
70
+ * The walk order matches the historical per-module inspectors: at each index
71
+ * the control check runs before the surrogate check, and a well-formed pair
72
+ * consumes two code units. `classifySurrogateAt` is the single surrogate
73
+ * authority; this function never re-implements it.
74
+ */
75
+ export function inspectText(
76
+ value: string,
77
+ policy: TextPolicy,
78
+ ): TextViolation | null {
79
+ for (let index = 0; index < value.length; index += 1) {
80
+ const unit = value.charCodeAt(index);
81
+ if (
82
+ policy.rejectControls &&
83
+ ((unit <= 0x1f && unit !== 0x09 && unit !== 0x0a && unit !== 0x0d) ||
84
+ unit === 0x7f)
85
+ ) {
86
+ return { reason: "control-char", offset: index, codeUnit: unit };
87
+ }
88
+ const classification = classifySurrogateAt(value, index);
89
+ if (classification === "loneSurrogate") {
90
+ return { reason: "lone-surrogate", offset: index, codeUnit: unit };
91
+ }
92
+ if (classification === "surrogatePair") index += 1;
93
+ }
94
+ return null;
95
+ }