From 6007c4001d36c08b0cd07787b612aa01efdf9812 Mon Sep 17 00:00:00 2001 From: DSE Builder Date: Fri, 4 Sep 2026 20:59:38 +0000 Subject: [PATCH 1/2] fix(lock-ts): normative 512 nesting cap + verify() error contract (DSE-1527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript verifier refused JSON nested past 512 levels while the Python reference accepted anything up to the interpreter's ~1000-frame recursion limit, so two conforming implementations disagreed on the same document (CSO re-verify of #99, N1). SPEC.md §4 now names 512 as the normative bound; canon() and read_lock() enforce it with an explicit iterative check raising DepthError (a ValueError); vectors gain canonical/depth-512-accepted and malformed/depth-513-rejected so both harnesses pin the exact bound. verify() in @mcp-warden/lock now throws only LockFormatError (N2): a canonicalizer or depth failure on the observed surface is wrapped rather than leaking as JcsError/DepthError. The Python vector harness routes every malformed lock through read_lock(), the public reader. --- CHANGELOG.md | 15 +- docs/SPEC.md | 11 +- packages/lock-ts/README.md | 2 +- packages/lock-ts/src/index.ts | 21 +- packages/lock-ts/test/vectors.test.ts | 36 + src/mcp_warden/hashing.py | 33 + src/mcp_warden/lockfile.py | 9 + tests/test_hashing.py | 41 + tests/test_lockfile.py | 41 + tests/test_spec_vectors.py | 19 +- vectors/README.md | 9 +- .../cases/canonical-depth-512-accepted.json | 1034 +++++++++++++++++ .../cases/malformed-depth-513-rejected.json | 9 + vectors/manifest.json | 12 +- vectors/tools/generate.py | 6 + 15 files changed, 1276 insertions(+), 22 deletions(-) create mode 100644 vectors/cases/canonical-depth-512-accepted.json create mode 100644 vectors/cases/malformed-depth-513-rejected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c379b6..a864485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,20 @@ Streamable HTTP; the v0.3 `guard` proxy adds deterministic runtime *result* insp ## [Unreleased] -_Nothing yet._ +### Fixed + +- **Normative nesting bound: 512 (DSE-1527).** The TypeScript verifier refused JSON nested + past 512 levels while the Python reference accepted anything up to the interpreter's + ~1000-frame recursion limit, so two conforming implementations disagreed on the same + document. `docs/SPEC.md` §4 now names 512 as the maximum depth (root = 0, each + enclosing array/object adds one); `canon()` and `read_lock()` enforce it with an + explicit, iterative check that raises a typed `DepthError` (a `ValueError`) before any + serialization; `vectors/` gains `canonical/depth-512-accepted` and + `malformed/depth-513-rejected` so both harnesses pin the exact bound. `@mcp-warden/lock`'s + `verify()` now throws only `LockFormatError` — a canonicalizer or depth failure on the + observed surface no longer leaks as `JcsError`/`DepthError` — and the Python vector + harness routes every `malformed` lock through `read_lock()`, the public reader, as + `vectors/README.md` asks of every implementation. ## [1.2.0] — 2026-09-04 diff --git a/docs/SPEC.md b/docs/SPEC.md index 0f62c9a..695b7cc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -85,6 +85,15 @@ is hashed MUST first be canonicalized. canonicalizer. Therefore an optional field that is simply not present contributes nothing to a digest, and a baseline that omits it is byte-identical to one written before the field existed. +- **Nesting bound (normative):** the *depth* of an element is the number of arrays and + objects enclosing it; the document root is depth 0. A conforming implementation MUST + accept any value whose deepest element is at depth **512** and MUST refuse — fail closed, + never hash — any value containing an element at depth greater than 512, both when + canonicalizing a surface value (§4–§5) and when reading a lock document (§3). An + implementation that merely inherits its host language's recursion limit is NOT + conformant: two such implementations disagree on the same document. The bound is + pinned by `vectors/cases/canonical-depth-512-accepted.json` and + `vectors/cases/malformed-depth-513-rejected.json`. --- @@ -419,7 +428,7 @@ byte-for-byte: - `drift` — the ordered `(drift_class, severity, target, detail)` set for a baseline lock and an observed surface (§8.2–§8.3); - `malformed` — lock documents a conforming reader MUST reject, and JSON values a - conforming canonicalizer MUST refuse (unpaired surrogates, excessive nesting). + conforming canonicalizer MUST refuse (unpaired surrogates, nesting past the §4 bound). The corpus is generated from the reference implementation (`vectors/tools/generate.py`) and is regenerated only on a deliberate format change (§14). Two implementations ship diff --git a/packages/lock-ts/README.md b/packages/lock-ts/README.md index 3901e91..f9f4ab6 100644 --- a/packages/lock-ts/README.md +++ b/packages/lock-ts/README.md @@ -37,7 +37,7 @@ console.log("surface matches baseline", digest(surface)); | export | purpose | |--------|---------| -| `verify(lock, surface) → { ok, findings, observed_digest }` | drift between a baseline lock document and an observed surface; always runs the strict reader first and throws `LockFormatError` on a malformed lock or one at a newer `schema_version` — it can never return `ok` for a document a conforming reader must reject | +| `verify(lock, surface) → { ok, findings, observed_digest }` | drift between a baseline lock document and an observed surface; always runs the strict reader first and throws `LockFormatError` — and only `LockFormatError` — on a malformed lock, one at a newer `schema_version`, or an observed surface it cannot canonicalize (unpaired surrogate, nesting past 512); it can never return `ok` for a document a conforming reader must reject | | `digest(surface) → "sha256:…"` | the `overall_digest` a conforming writer would store for that surface | | `parseLock(doc)` | strict structural reader; throws `LockFormatError` (fail closed) | | `buildFromSurface(surface)` | the hashed entries (`entry_digest`, `capabilities`, `schema_skeleton`) for a surface | diff --git a/packages/lock-ts/src/index.ts b/packages/lock-ts/src/index.ts index 49be4d3..626a1db 100644 --- a/packages/lock-ts/src/index.ts +++ b/packages/lock-ts/src/index.ts @@ -8,7 +8,7 @@ * what drifted — with the same rule ids, severities and ordering as the Python * reference. Conformance is defined by the corpus under `vectors/`. * - * Fail-closed contract: `verify()` ALWAYS runs the strict reader (`parseLock`) on + * Fail-closed contract: `verify()` throws `LockFormatError` and nothing else. It ALWAYS runs the strict reader (`parseLock`) on * the lock it is given — a document a conforming reader must reject (missing * `overall_digest`, a `schema_version` above the level this package implements, * malformed entries, excessive nesting) throws `LockFormatError` and can never @@ -16,7 +16,7 @@ */ import { computeDrift, type DriftItem } from "./drift.js"; -import { buildFromSurface, parseLock, type Surface } from "./lock.js"; +import { buildFromSurface, LockFormatError, parseLock, type Surface } from "./lock.js"; export { canonicalize, hasUnpairedSurrogate, JcsError } from "./jcs.js"; export { canon, hashArguments, hashDescription, hashInputSchema, hashValue, SHA256_PREFIX } from "./digest.js"; @@ -57,12 +57,19 @@ export function digest(surface: Surface): string { * Compare an observed surface against a baseline lock document. * * `lock` is the raw JSON document (or a `Lock` previously returned by `parseLock`, - * which re-validates identically). Throws `LockFormatError` if the document is not - * a structurally valid lock at a level this package implements — fail closed. + * which re-validates identically). Throws `LockFormatError` — and ONLY `LockFormatError` + * — if the document is not a structurally valid lock at a level this package implements, + * or if the observed surface cannot be canonicalized (unpaired surrogate, nesting past + * `MAX_JSON_DEPTH`). A `JcsError`/`DepthError` never escapes this entry point (DSE-1527). */ export function verify(lock: unknown, surface: Surface): VerifyResult { const baseline = parseLock(lock); - const current = buildFromSurface(surface); - const findings = computeDrift(baseline, current); - return { ok: findings.length === 0, findings, observed_digest: current.overall_digest }; + try { + const current = buildFromSurface(surface); + const findings = computeDrift(baseline, current); + return { ok: findings.length === 0, findings, observed_digest: current.overall_digest }; + } catch (e) { + if (e instanceof LockFormatError) throw e; + throw new LockFormatError(`observed surface is not verifiable: ${(e as Error).message}`); + } } diff --git a/packages/lock-ts/test/vectors.test.ts b/packages/lock-ts/test/vectors.test.ts index 71fde10..8a3f64c 100644 --- a/packages/lock-ts/test/vectors.test.ts +++ b/packages/lock-ts/test/vectors.test.ts @@ -221,3 +221,39 @@ test("depth: canonicalize, deepEqual and parseLock are bounded", () => { ((lock["tools"] as Doc[])[0] as Doc)["schema_skeleton"] = { props: { p: { constraints: { deep: deep(MAX_JSON_DEPTH + 2) } } } }; assert.throws(() => verify(lock, {}), LockFormatError); }); + +// --- DSE-1527: normative nesting bound + verify() error contract ----------------- + +const nestedArrays = (n: number): unknown => { + // n enclosing arrays around an empty array: the innermost `[]` sits at depth n. + let v: unknown = []; + for (let i = 0; i < n; i++) v = [v]; + return v; +}; + +test("DSE-1527: the canonicalizer bound is exactly 512 (root = 0)", () => { + assert.equal(MAX_JSON_DEPTH, 512); + assert.doesNotThrow(() => canonicalize(nestedArrays(MAX_JSON_DEPTH))); + assert.throws(() => canonicalize(nestedArrays(MAX_JSON_DEPTH + 1)), JcsError); + // A leaf counts, and objects count like arrays. + let obj: unknown = { k: "leaf" }; + for (let i = 0; i < MAX_JSON_DEPTH; i++) obj = { k: obj }; + assert.throws(() => canonicalize(obj), JcsError); +}); + +test("DSE-1527: verify() throws only LockFormatError, even when the observed surface is unverifiable", () => { + const lock = validLock(); + const surface = { tools: [{ name: "t", inputSchema: { deep: nestedArrays(MAX_JSON_DEPTH + 5) } }] } as unknown as Surface; + let caught: unknown; + try { + verify(lock, surface); + } catch (e) { + caught = e; + } + assert.ok(caught instanceof LockFormatError, `expected LockFormatError, got ${String(caught)}`); + assert.ok(!(caught instanceof JcsError) && !(caught instanceof DepthError), "underlying error type must not leak"); + assert.match((caught as Error).message, /observed surface is not verifiable/); + // Unpaired surrogate on the observed side takes the same path. + const bad = { tools: [{ name: "t", inputSchema: { d: "\ud800" } }] } as unknown as Surface; + assert.throws(() => verify(lock, bad), LockFormatError); +}); diff --git a/src/mcp_warden/hashing.py b/src/mcp_warden/hashing.py index dec5ea8..609bdef 100644 --- a/src/mcp_warden/hashing.py +++ b/src/mcp_warden/hashing.py @@ -26,6 +26,36 @@ #: Public prefix for every digest emitted by mcp-warden. SHA256_PREFIX = "sha256:" +#: Normative nesting bound (docs/SPEC.md §4): the document root is depth 0 and every +#: enclosing array/object adds one; an element at depth > MAX_JSON_DEPTH MUST be refused, +#: never hashed. ``@mcp-warden/lock`` enforces the same constant. Without an explicit +#: check the reference silently accepted 513–~990 levels (up to the interpreter's +#: recursion limit) while the TypeScript verifier refused them — two conforming +#: implementations disagreeing on one document (DSE-1527). +MAX_JSON_DEPTH = 512 + + +class DepthError(ValueError): + """A JSON value nests deeper than :data:`MAX_JSON_DEPTH` (fail closed).""" + + +def check_depth(value: Any, *, where: str = "value") -> None: + """Raise :class:`DepthError` if ``value`` nests deeper than :data:`MAX_JSON_DEPTH`. + + Iterative (explicit stack) so the check itself can never trip the interpreter's + recursion limit on the very input it exists to refuse. Leaves count: a scalar at + depth 513 is as much a violation as a container there. + """ + stack: list[tuple[Any, int]] = [(value, 0)] + while stack: + node, depth = stack.pop() + if depth > MAX_JSON_DEPTH: + raise DepthError(f"{where}: nesting deeper than {MAX_JSON_DEPTH} levels") + if isinstance(node, dict): + stack.extend((child, depth + 1) for child in node.values()) + elif isinstance(node, (list, tuple)): + stack.extend((child, depth + 1) for child in node) + def hash_bytes(payload: bytes, *, domain: DigestDomain) -> str: """Hash exact bytes under a closed content-envelope digest domain.""" @@ -48,8 +78,11 @@ def canon(value: Any) -> bytes: The canonical UTF-8 byte string. Raises: + DepthError: If ``value`` nests deeper than :data:`MAX_JSON_DEPTH` (SPEC.md §4); + a ``ValueError`` subclass, raised before any serialization is attempted. ValueError: If ``value`` is not JSON-serializable under JCS. """ + check_depth(value) try: return rfc8785.dumps(value) except Exception as exc: # rfc8785 raises a variety of types on bad input diff --git a/src/mcp_warden/lockfile.py b/src/mcp_warden/lockfile.py index 8c81707..a08acf7 100644 --- a/src/mcp_warden/lockfile.py +++ b/src/mcp_warden/lockfile.py @@ -17,6 +17,8 @@ from . import SCHEMA_VERSION, __version__ from .hashing import ( + DepthError, + check_depth, hash_arguments, hash_description, hash_input_schema, @@ -324,6 +326,13 @@ def read_lock(path: str | Path) -> WardenLock: raw = json.loads(p.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError(f"lock file {p} is not valid JSON: {exc}") from exc + try: + # SPEC.md §4 nesting bound — checked before validation so a hostile document is + # refused by an explicit, iterative check rather than by wherever the interpreter + # happens to run out of stack (DSE-1527). + check_depth(raw, where=f"lock file {p}") + except DepthError as exc: + raise ValueError(f"lock file {p} exceeds the nesting bound: {exc}") from exc try: return WardenLock.model_validate(raw) except Exception as exc: diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 5f3b48d..f7cec61 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -8,6 +8,8 @@ import hashlib +import pytest + from mcp_warden.hashing import ( canon, hash_arguments, @@ -80,3 +82,42 @@ def test_schema_change_changes_digest(): base = {"type": "object", "properties": {"path": {"type": "string"}}} changed = {"type": "object", "properties": {"path": {"type": "string"}, "enc": {"type": "string"}}} assert hash_input_schema(base) != hash_input_schema(changed) + + +# --- DSE-1527: normative nesting bound (SPEC.md §4) --------------------------- + + +def _nested_arrays(n: int) -> list: + """``n`` enclosing arrays around an empty array: the innermost ``[]`` sits at depth ``n``.""" + v: list = [] + for _ in range(n): + v = [v] + return v + + +def test_canon_accepts_depth_512_and_refuses_513(): + from mcp_warden.hashing import MAX_JSON_DEPTH, DepthError + + assert MAX_JSON_DEPTH == 512 + ok = _nested_arrays(MAX_JSON_DEPTH) + assert canon(ok) == b"[" * (MAX_JSON_DEPTH + 1) + b"]" * (MAX_JSON_DEPTH + 1) + with pytest.raises(DepthError): + canon(_nested_arrays(MAX_JSON_DEPTH + 1)) + # A leaf counts, and objects count like arrays: "leaf" ends up at depth 513. + deep_obj: dict = {"k": "leaf"} + for _ in range(MAX_JSON_DEPTH): + deep_obj = {"k": deep_obj} + with pytest.raises(DepthError): + canon(deep_obj) + + +def test_depth_error_is_a_value_error_raised_before_serialization(): + from mcp_warden.hashing import DepthError, check_depth + + assert issubclass(DepthError, ValueError) + with pytest.raises(DepthError, match="nesting deeper than 512"): + check_depth(_nested_arrays(600), where="probe") + # Iterative: far past any recursion limit, still a clean DepthError. + check = _nested_arrays(5000) + with pytest.raises(DepthError): + check_depth(check) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 65f9eff..78d4390 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -4,6 +4,8 @@ import json +import pytest + from mcp_warden.lockfile import build_lock, lock_to_pretty_json, read_lock, write_lock from mcp_warden.models import ( Attestation, @@ -171,3 +173,42 @@ def test_lock_stores_hashes_not_raw_text(): # Raw description text must NOT appear in the lock. assert "Read a file" not in text assert "description_hash" in text + + +# --- DSE-1527: read_lock enforces the SPEC.md §4 nesting bound ------------------ + + +def _deep(n: int) -> list: + v: list = [] + for _ in range(n): + v = [v] + return v + + +def test_read_lock_refuses_nesting_past_the_bound(tmp_path): + from mcp_warden.hashing import MAX_JSON_DEPTH + + lock = build_lock(_surface(), []) + doc = json.loads(lock_to_pretty_json(lock)) + path = tmp_path / "warden.lock" + + # Control: the document as written reads back. + path.write_text(json.dumps(doc), encoding="utf-8") + assert read_lock(path).overall_digest == lock.overall_digest + + # One past the bound anywhere in the document -> refused by the explicit check, + # before schema validation, with an intelligible message. + hostile = dict(doc) + hostile["x"] = _deep(MAX_JSON_DEPTH) # innermost [] at depth 512 + 1 (the "x" key) + path.write_text(json.dumps(hostile), encoding="utf-8") + with pytest.raises(ValueError, match="nesting deeper than 512"): + read_lock(path) + + # Exactly at the bound the depth check is silent (whatever schema validation says). + at_bound = dict(doc) + at_bound["x"] = _deep(MAX_JSON_DEPTH - 1) + path.write_text(json.dumps(at_bound), encoding="utf-8") + try: + read_lock(path) + except ValueError as exc: + assert "nesting deeper" not in str(exc) diff --git a/tests/test_spec_vectors.py b/tests/test_spec_vectors.py index c591bbf..34d1fa9 100644 --- a/tests/test_spec_vectors.py +++ b/tests/test_spec_vectors.py @@ -18,7 +18,7 @@ from mcp_warden.drift import compute_drift from mcp_warden.hashing import canon, hash_value -from mcp_warden.lockfile import build_lock +from mcp_warden.lockfile import build_lock, read_lock from mcp_warden.models import ( CapturedPrompt, CapturedResource, @@ -108,17 +108,20 @@ def test_drift(entry): @pytest.mark.parametrize("entry", _by_kind("malformed")) -def test_malformed_is_rejected(entry): +def test_malformed_is_rejected(entry, tmp_path): v = _load(entry) assert v["expect"] == {"error": True} if "input_json" in v: # A JSON value the canonicalizer MUST reject: it parses, but is not Unicode text - # (unpaired surrogate) or nests past the recursion bound. Either the parser or - # canon() refusing it is a rejection; a wrong digest is not. + # (unpaired surrogate) or nests past the §4 bound. Either the parser or canon() + # refusing it is a rejection; a wrong digest is not. with pytest.raises((ValueError, RecursionError)): canon(json.loads(v["input_json"])) return - # json.JSONDecodeError and pydantic's ValidationError are both ValueError subclasses. - with pytest.raises((ValueError, TypeError)): - doc = json.loads(v["lock_text"]) if "lock_text" in v else v["lock"] - WardenLock.model_validate(doc) + # Through the PUBLIC reader, exactly as a consumer hits it (vectors/README.md step 4): + # read_lock() must refuse. JSON errors, schema errors (pydantic ValidationError) and + # the nesting bound (DepthError) all surface as ValueError from read_lock. + path = tmp_path / "warden.lock" + path.write_text(v["lock_text"] if "lock_text" in v else json.dumps(v["lock"]), encoding="utf-8") + with pytest.raises(ValueError): + read_lock(path) diff --git a/vectors/README.md b/vectors/README.md index b58a827..4eaaee7 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -80,9 +80,12 @@ fails **both** implementations. cannot even represent them exactly (`2**63` parses to `9223372036854776000`). Vectors never contain such integers; a surface that does is not portable and the reference will not digest it. Integer-valued *floats* such as `1e21` are fine and are pinned. -- **Nesting is bounded.** The reference raises past its recursion limit; the TypeScript - verifier refuses anything deeper than 512 levels. `malformed/deep-nesting-2000` pins - that both reject, not the exact bound. +- **Nesting is bounded at 512 — normative (SPEC.md §4).** Depth counts enclosing + arrays/objects with the root at 0. `canonical/depth-512-accepted` MUST canonicalize and + `malformed/depth-513-rejected` MUST be refused; both the Python reference (`canon()`, + `read_lock()`) and the TypeScript verifier enforce the same explicit bound. + `malformed/deep-nesting-2000` remains as the coarse guard. An implementation that only + relies on its runtime's recursion limit is not conformant. - Enum ordering inside `schema_skeleton` keys on Python's `json.dumps` text of each value. JavaScript cannot distinguish `1.0` from `1`, so vectors never put integer-valued diff --git a/vectors/cases/canonical-depth-512-accepted.json b/vectors/cases/canonical-depth-512-accepted.json new file mode 100644 index 0000000..3112e2b --- /dev/null +++ b/vectors/cases/canonical-depth-512-accepted.json @@ -0,0 +1,1034 @@ +{ + "id": "canonical/depth-512-accepted", + "kind": "canonical", + "description": "The deepest element sits at depth 512 — the root is depth 0 and each enclosing array/object adds one — which is the normative maximum (SPEC.md §4). A conforming canonicalizer MUST accept it; one that inherits a smaller host recursion limit is not conformant.", + "input": [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [ + [] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ], + "expect": { + "jcs": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]", + "sha256": "sha256:a9ecae6c77e13ad7240afd2d6c8aa7603375874dd33cf982251d2d533db6d32b" + } +} diff --git a/vectors/cases/malformed-depth-513-rejected.json b/vectors/cases/malformed-depth-513-rejected.json new file mode 100644 index 0000000..7b4d181 --- /dev/null +++ b/vectors/cases/malformed-depth-513-rejected.json @@ -0,0 +1,9 @@ +{ + "id": "malformed/depth-513-rejected", + "kind": "malformed", + "description": "The deepest element sits at depth 513, one past the normative bound (SPEC.md §4). A conforming canonicalizer MUST refuse it — fail closed — rather than produce a digest; one that accepts it because its host recursion limit is larger is not conformant.", + "expect": { + "error": true + }, + "input_json": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]" +} diff --git a/vectors/manifest.json b/vectors/manifest.json index a5ef3ac..3c7ff7f 100644 --- a/vectors/manifest.json +++ b/vectors/manifest.json @@ -2,7 +2,7 @@ "format": "mcp-lock-v1", "schema_version": 3, "generator": "vectors/tools/generate.py", - "count": 84, + "count": 86, "vectors": [ { "id": "canonical/sorted-keys-utf16", @@ -59,6 +59,11 @@ "kind": "canonical", "file": "cases/canonical-deep-nesting.json" }, + { + "id": "canonical/depth-512-accepted", + "kind": "canonical", + "file": "cases/canonical-depth-512-accepted.json" + }, { "id": "canonical/key-escapes", "kind": "canonical", @@ -423,6 +428,11 @@ "id": "malformed/deep-nesting-2000", "kind": "malformed", "file": "cases/malformed-deep-nesting-2000.json" + }, + { + "id": "malformed/depth-513-rejected", + "kind": "malformed", + "file": "cases/malformed-depth-513-rejected.json" } ] } diff --git a/vectors/tools/generate.py b/vectors/tools/generate.py index 20c6ef1..c3bde2f 100644 --- a/vectors/tools/generate.py +++ b/vectors/tools/generate.py @@ -242,6 +242,11 @@ def schema_pair( ("empty-array", "The empty array — the §5.1 absent-arguments digest.", []), ("scalars", "Literals true/false/null and zero.", [True, False, None, "", 0]), ("deep-nesting", "Deeply nested empties.", {"a": {"b": {"c": {"d": [[[]]]}}}}), + ( + "depth-512-accepted", + "The deepest element sits at depth 512 — the root is depth 0 and each enclosing array/object adds one — which is the normative maximum (SPEC.md §4). A conforming canonicalizer MUST accept it; one that inherits a smaller host recursion limit is not conformant.", + json.loads("[" * 513 + "]" * 513), + ), ("key-escapes", "Keys are escaped like strings and sorted by their code units.", {'a"b': 1, "c\\d": 2, "e\nf": 3}), ( "numbers-boundaries", @@ -422,6 +427,7 @@ def schema_pair( ("unpaired-surrogate-low", "A lone low surrogate inside a string MUST be rejected.", {"input_json": '"a\\udc00b"'}), ("unpaired-surrogate-key", "An unpaired surrogate in an object key MUST be rejected.", {"input_json": '{"\\ud83d": 1}'}), ("deep-nesting-2000", "2000 nested arrays exceed any conforming implementation's recursion bound and MUST be rejected rather than hashed.", {"input_json": "[" * 2000 + "]" * 2000}), + ("depth-513-rejected", "The deepest element sits at depth 513, one past the normative bound (SPEC.md §4). A conforming canonicalizer MUST refuse it — fail closed — rather than produce a digest; one that accepts it because its host recursion limit is larger is not conformant.", {"input_json": "[" * 514 + "]" * 514}), ] From 4e2764eac1a81a9c10a451f3feb5c507f0354a08 Mon Sep 17 00:00:00 2001 From: DSE Builder Date: Fri, 4 Sep 2026 21:13:46 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(lock-ts):=20fold=20in=20CSO=20review=20?= =?UTF-8?q?of=20#102=20=E2=80=94=20RecursionError=E2=86=92ValueError,=20MA?= =?UTF-8?q?X=5FCANON=5FDEPTH,=20narrowed=20verify()=20catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_lock(): a >~1000-deep file raises RecursionError from json.loads on 3.11 before the depth check runs; map it to the documented ValueError. New vector malformed/lock-depth-1200-rejected (raw text) pins that BOTH readers refuse it. - hashing.MAX_JSON_DEPTH -> MAX_CANON_DEPTH, distinct from content_models. MAX_JSON_DEPTH (16, content-envelope profile); SPEC §4 scopes the 512 bound. - verify() folds only JcsError/DepthError into LockFormatError, with `cause`; anything else is a programming error and is not masked. --- docs/SPEC.md | 4 ++- packages/lock-ts/src/index.ts | 14 +++++++--- packages/lock-ts/test/vectors.test.ts | 1 + src/mcp_warden/hashing.py | 26 ++++++++++--------- src/mcp_warden/lockfile.py | 5 +++- tests/test_hashing.py | 12 ++++----- tests/test_lockfile.py | 6 ++--- .../malformed-lock-depth-1200-rejected.json | 9 +++++++ vectors/manifest.json | 7 ++++- vectors/tools/generate.py | 1 + 10 files changed, 58 insertions(+), 27 deletions(-) create mode 100644 vectors/cases/malformed-lock-depth-1200-rejected.json diff --git a/docs/SPEC.md b/docs/SPEC.md index 695b7cc..1242e41 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -93,7 +93,9 @@ is hashed MUST first be canonicalized. implementation that merely inherits its host language's recursion limit is NOT conformant: two such implementations disagree on the same document. The bound is pinned by `vectors/cases/canonical-depth-512-accepted.json` and - `vectors/cases/malformed-depth-513-rejected.json`. + `vectors/cases/malformed-depth-513-rejected.json`. This bound governs lock documents and + surface values only; the content-envelope profile (`docs/CONTENT_ENVELOPE.md`) keeps its + own, much smaller, nesting limit. --- diff --git a/packages/lock-ts/src/index.ts b/packages/lock-ts/src/index.ts index 626a1db..52db958 100644 --- a/packages/lock-ts/src/index.ts +++ b/packages/lock-ts/src/index.ts @@ -16,7 +16,9 @@ */ import { computeDrift, type DriftItem } from "./drift.js"; +import { JcsError } from "./jcs.js"; import { buildFromSurface, LockFormatError, parseLock, type Surface } from "./lock.js"; +import { DepthError } from "./py.js"; export { canonicalize, hasUnpairedSurrogate, JcsError } from "./jcs.js"; export { canon, hashArguments, hashDescription, hashInputSchema, hashValue, SHA256_PREFIX } from "./digest.js"; @@ -60,7 +62,8 @@ export function digest(surface: Surface): string { * which re-validates identically). Throws `LockFormatError` — and ONLY `LockFormatError` * — if the document is not a structurally valid lock at a level this package implements, * or if the observed surface cannot be canonicalized (unpaired surrogate, nesting past - * `MAX_JSON_DEPTH`). A `JcsError`/`DepthError` never escapes this entry point (DSE-1527). + * `MAX_JSON_DEPTH`). A `JcsError`/`DepthError` never escapes this entry point — it is + * wrapped with the original attached as `cause` (DSE-1527). */ export function verify(lock: unknown, surface: Surface): VerifyResult { const baseline = parseLock(lock); @@ -69,7 +72,12 @@ export function verify(lock: unknown, surface: Surface): VerifyResult { const findings = computeDrift(baseline, current); return { ok: findings.length === 0, findings, observed_digest: current.overall_digest }; } catch (e) { - if (e instanceof LockFormatError) throw e; - throw new LockFormatError(`observed surface is not verifiable: ${(e as Error).message}`); + // Only the two fail-closed refusals of the observed surface are folded into the + // contract error (with the original as `cause`); anything else is a programming + // error and must not be masked. + if (e instanceof JcsError || e instanceof DepthError) { + throw new LockFormatError(`observed surface is not verifiable: ${e.message}`, { cause: e }); + } + throw e; } } diff --git a/packages/lock-ts/test/vectors.test.ts b/packages/lock-ts/test/vectors.test.ts index 8a3f64c..ca143c9 100644 --- a/packages/lock-ts/test/vectors.test.ts +++ b/packages/lock-ts/test/vectors.test.ts @@ -253,6 +253,7 @@ test("DSE-1527: verify() throws only LockFormatError, even when the observed sur assert.ok(caught instanceof LockFormatError, `expected LockFormatError, got ${String(caught)}`); assert.ok(!(caught instanceof JcsError) && !(caught instanceof DepthError), "underlying error type must not leak"); assert.match((caught as Error).message, /observed surface is not verifiable/); + assert.ok((caught as Error).cause instanceof JcsError, "the original refusal travels as `cause`"); // Unpaired surrogate on the observed side takes the same path. const bad = { tools: [{ name: "t", inputSchema: { d: "\ud800" } }] } as unknown as Surface; assert.throws(() => verify(lock, bad), LockFormatError); diff --git a/src/mcp_warden/hashing.py b/src/mcp_warden/hashing.py index 609bdef..384d77f 100644 --- a/src/mcp_warden/hashing.py +++ b/src/mcp_warden/hashing.py @@ -26,21 +26,23 @@ #: Public prefix for every digest emitted by mcp-warden. SHA256_PREFIX = "sha256:" -#: Normative nesting bound (docs/SPEC.md §4): the document root is depth 0 and every -#: enclosing array/object adds one; an element at depth > MAX_JSON_DEPTH MUST be refused, -#: never hashed. ``@mcp-warden/lock`` enforces the same constant. Without an explicit -#: check the reference silently accepted 513–~990 levels (up to the interpreter's -#: recursion limit) while the TypeScript verifier refused them — two conforming -#: implementations disagreeing on one document (DSE-1527). -MAX_JSON_DEPTH = 512 +#: Normative nesting bound for LOCK/SURFACE canonicalization (docs/SPEC.md §4): the +#: document root is depth 0 and every enclosing array/object adds one; an element at +#: depth > MAX_CANON_DEPTH MUST be refused, never hashed. ``@mcp-warden/lock`` enforces +#: the same constant. Distinct from ``content_models.MAX_JSON_DEPTH`` (16), which bounds +#: the content-envelope profile. Without an explicit check the reference silently +#: accepted 513–~990 levels (up to the interpreter's recursion limit) while the +#: TypeScript verifier refused them — two conforming implementations disagreeing on one +#: document (DSE-1527). +MAX_CANON_DEPTH = 512 class DepthError(ValueError): - """A JSON value nests deeper than :data:`MAX_JSON_DEPTH` (fail closed).""" + """A JSON value nests deeper than :data:`MAX_CANON_DEPTH` (fail closed).""" def check_depth(value: Any, *, where: str = "value") -> None: - """Raise :class:`DepthError` if ``value`` nests deeper than :data:`MAX_JSON_DEPTH`. + """Raise :class:`DepthError` if ``value`` nests deeper than :data:`MAX_CANON_DEPTH`. Iterative (explicit stack) so the check itself can never trip the interpreter's recursion limit on the very input it exists to refuse. Leaves count: a scalar at @@ -49,8 +51,8 @@ def check_depth(value: Any, *, where: str = "value") -> None: stack: list[tuple[Any, int]] = [(value, 0)] while stack: node, depth = stack.pop() - if depth > MAX_JSON_DEPTH: - raise DepthError(f"{where}: nesting deeper than {MAX_JSON_DEPTH} levels") + if depth > MAX_CANON_DEPTH: + raise DepthError(f"{where}: nesting deeper than {MAX_CANON_DEPTH} levels") if isinstance(node, dict): stack.extend((child, depth + 1) for child in node.values()) elif isinstance(node, (list, tuple)): @@ -78,7 +80,7 @@ def canon(value: Any) -> bytes: The canonical UTF-8 byte string. Raises: - DepthError: If ``value`` nests deeper than :data:`MAX_JSON_DEPTH` (SPEC.md §4); + DepthError: If ``value`` nests deeper than :data:`MAX_CANON_DEPTH` (SPEC.md §4); a ``ValueError`` subclass, raised before any serialization is attempted. ValueError: If ``value`` is not JSON-serializable under JCS. """ diff --git a/src/mcp_warden/lockfile.py b/src/mcp_warden/lockfile.py index a08acf7..33db350 100644 --- a/src/mcp_warden/lockfile.py +++ b/src/mcp_warden/lockfile.py @@ -324,7 +324,10 @@ def read_lock(path: str | Path) -> WardenLock: raise FileNotFoundError(f"lock file not found: {p}") try: raw = json.loads(p.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: + except (json.JSONDecodeError, RecursionError) as exc: + # RecursionError: the C decoder refuses documents nested past the interpreter's + # limit (~1000 on 3.11) before check_depth() ever sees them — still the + # documented ValueError, never a bare RecursionError (CSO review of #102). raise ValueError(f"lock file {p} is not valid JSON: {exc}") from exc try: # SPEC.md §4 nesting bound — checked before validation so a hostile document is diff --git a/tests/test_hashing.py b/tests/test_hashing.py index f7cec61..f86579e 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -96,16 +96,16 @@ def _nested_arrays(n: int) -> list: def test_canon_accepts_depth_512_and_refuses_513(): - from mcp_warden.hashing import MAX_JSON_DEPTH, DepthError + from mcp_warden.hashing import MAX_CANON_DEPTH, DepthError - assert MAX_JSON_DEPTH == 512 - ok = _nested_arrays(MAX_JSON_DEPTH) - assert canon(ok) == b"[" * (MAX_JSON_DEPTH + 1) + b"]" * (MAX_JSON_DEPTH + 1) + assert MAX_CANON_DEPTH == 512 + ok = _nested_arrays(MAX_CANON_DEPTH) + assert canon(ok) == b"[" * (MAX_CANON_DEPTH + 1) + b"]" * (MAX_CANON_DEPTH + 1) with pytest.raises(DepthError): - canon(_nested_arrays(MAX_JSON_DEPTH + 1)) + canon(_nested_arrays(MAX_CANON_DEPTH + 1)) # A leaf counts, and objects count like arrays: "leaf" ends up at depth 513. deep_obj: dict = {"k": "leaf"} - for _ in range(MAX_JSON_DEPTH): + for _ in range(MAX_CANON_DEPTH): deep_obj = {"k": deep_obj} with pytest.raises(DepthError): canon(deep_obj) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 78d4390..e980788 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -186,7 +186,7 @@ def _deep(n: int) -> list: def test_read_lock_refuses_nesting_past_the_bound(tmp_path): - from mcp_warden.hashing import MAX_JSON_DEPTH + from mcp_warden.hashing import MAX_CANON_DEPTH lock = build_lock(_surface(), []) doc = json.loads(lock_to_pretty_json(lock)) @@ -199,14 +199,14 @@ def test_read_lock_refuses_nesting_past_the_bound(tmp_path): # One past the bound anywhere in the document -> refused by the explicit check, # before schema validation, with an intelligible message. hostile = dict(doc) - hostile["x"] = _deep(MAX_JSON_DEPTH) # innermost [] at depth 512 + 1 (the "x" key) + hostile["x"] = _deep(MAX_CANON_DEPTH) # innermost [] at depth 512 + 1 (the "x" key) path.write_text(json.dumps(hostile), encoding="utf-8") with pytest.raises(ValueError, match="nesting deeper than 512"): read_lock(path) # Exactly at the bound the depth check is silent (whatever schema validation says). at_bound = dict(doc) - at_bound["x"] = _deep(MAX_JSON_DEPTH - 1) + at_bound["x"] = _deep(MAX_CANON_DEPTH - 1) path.write_text(json.dumps(at_bound), encoding="utf-8") try: read_lock(path) diff --git a/vectors/cases/malformed-lock-depth-1200-rejected.json b/vectors/cases/malformed-lock-depth-1200-rejected.json new file mode 100644 index 0000000..b5dce12 --- /dev/null +++ b/vectors/cases/malformed-lock-depth-1200-rejected.json @@ -0,0 +1,9 @@ +{ + "id": "malformed/lock-depth-1200-rejected", + "kind": "malformed", + "description": "A lock document nested 1200 levels deep, presented as raw text. A conforming reader MUST refuse it (SPEC.md §4) — whether its JSON parser gives up first or its explicit depth check does — and MUST never surface it as anything but the reader's documented rejection.", + "expect": { + "error": true + }, + "lock_text": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]" +} diff --git a/vectors/manifest.json b/vectors/manifest.json index 3c7ff7f..76f0e1b 100644 --- a/vectors/manifest.json +++ b/vectors/manifest.json @@ -2,7 +2,7 @@ "format": "mcp-lock-v1", "schema_version": 3, "generator": "vectors/tools/generate.py", - "count": 86, + "count": 87, "vectors": [ { "id": "canonical/sorted-keys-utf16", @@ -429,6 +429,11 @@ "kind": "malformed", "file": "cases/malformed-deep-nesting-2000.json" }, + { + "id": "malformed/lock-depth-1200-rejected", + "kind": "malformed", + "file": "cases/malformed-lock-depth-1200-rejected.json" + }, { "id": "malformed/depth-513-rejected", "kind": "malformed", diff --git a/vectors/tools/generate.py b/vectors/tools/generate.py index c3bde2f..10b3909 100644 --- a/vectors/tools/generate.py +++ b/vectors/tools/generate.py @@ -427,6 +427,7 @@ def schema_pair( ("unpaired-surrogate-low", "A lone low surrogate inside a string MUST be rejected.", {"input_json": '"a\\udc00b"'}), ("unpaired-surrogate-key", "An unpaired surrogate in an object key MUST be rejected.", {"input_json": '{"\\ud83d": 1}'}), ("deep-nesting-2000", "2000 nested arrays exceed any conforming implementation's recursion bound and MUST be rejected rather than hashed.", {"input_json": "[" * 2000 + "]" * 2000}), + ("lock-depth-1200-rejected", "A lock document nested 1200 levels deep, presented as raw text. A conforming reader MUST refuse it (SPEC.md §4) — whether its JSON parser gives up first or its explicit depth check does — and MUST never surface it as anything but the reader's documented rejection.", "[" * 1200 + "]" * 1200), ("depth-513-rejected", "The deepest element sits at depth 513, one past the normative bound (SPEC.md §4). A conforming canonicalizer MUST refuse it — fail closed — rather than produce a digest; one that accepts it because its host recursion limit is larger is not conformant.", {"input_json": "[" * 514 + "]" * 514}), ]