From c6d362eb5d41c1ed97e41df52558dca97c716613 Mon Sep 17 00:00:00 2001 From: Cristian Sotomayor Date: Mon, 12 Oct 2026 11:55:42 -0500 Subject: [PATCH] docs(generate-hardening): add the NIW2-311 design spec and its ADRs Spec and ADR drafts for NIW2-311, from the 2026-10-12 security, quality and scalability audit (umbrella NIW2-302). Status: proposed, not implemented. Refs: NIW2-311 Co-Authored-By: Claude Opus 5.5 --- .../docs/decisions/0017-no-external-refs.md | 56 +++ .../content/docs/design/generate-hardening.md | 334 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 apps/documentation/src/content/docs/decisions/0017-no-external-refs.md create mode 100644 apps/documentation/src/content/docs/design/generate-hardening.md diff --git a/apps/documentation/src/content/docs/decisions/0017-no-external-refs.md b/apps/documentation/src/content/docs/decisions/0017-no-external-refs.md new file mode 100644 index 0000000..6b8358c --- /dev/null +++ b/apps/documentation/src/content/docs/decisions/0017-no-external-refs.md @@ -0,0 +1,56 @@ +--- +title: ADR-0017 — References never leave the document, and there is no switch +--- + +# ADR-0017 — References never leave the document, and there is no switch + +**Status:** Proposed +**Date:** 2026-10-12 +**Ticket:** [NIW2-311](https://linear.app/niway/issue/NIW2-311) +**Spec:** [Generate hardening — design spec](/design/generate-hardening/) +**Implementation:** none yet + +## Context + +laqi's compiler has always said it: "import, generation and export make no network requests" (`compile-schema.ts:623-630` on main). The code honoured that sentence only where the compiler walked. Two gaps were found and reproduced by the audit (NIW2-302, `generate.md` S1): + +- The compiler skips `$defs`, `definitions` and `examples` as annotations, and never compiles `additionalProperties` when `properties` is present. A `$ref` to `http://…`, `file://…` or `../secret.json` in those positions imports cleanly and is stored. +- Every non-TypeScript export goes through quicktype-core on its `FetchingJSONSchemaStore`, which reads `file://`, fetches anything `is-url` accepts, and otherwise reads a local path relative to the process cwd. A stored snapshot reaches export without the compiler at all. Clicking "Export → Python" on a mock file from a cloned repository is enough to make the developer's machine issue the request; the body shows up in the error text. + +The question this ADR settles is not whether to close the gap — it is whether laqi should keep any way to follow a reference outside the document. Three code generators faced the same question in 2026 and chose a switch: datamodel-code-generator (`--allow-remote-refs`, CVE-2026-54690: the default warned and fetched anyway), Kiota (CVE-2026-59867: fixed with default-deny and `--allowed-external-origins`), and Orval (CVE-2026-62680: SSRF plus local file read through `../`). OWASP's SSRF guidance is "do not accept complete URLs from the user" and "prefer allow-lists" — and notes SSRF "is not limited to the HTTP protocol". + +laqi's situation is narrower than a CLI generator's: the document that would carry the reference is committed to a repository and loaded by everyone who clones it, and the actions that would dereference it (export, `get_types`, `import_openapi`) run without a confirmation step on the panel and from agents. The person who would set a flag is not the person whose machine makes the request. + +## Decision + +1. **Every schema `$ref` laqi reads is a fragment into the same document** — exactly `#` or `#/…`. Anything else, in any position that holds a schema (`$defs` and `definitions` included), is refused by name at import (`unsupported.keyword`, with the pointer) and at export (`PrintError`, with the pointer). `$dynamicRef` and `$recursiveRef` are held to the same rule. Data positions (`examples`, `default`, `const`, `enum`) are never walked: a `$ref` key in example data is data. +2. **quicktype runs on a store that cannot fetch.** `printDocumentEffect` constructs `JSONSchemaInput` with a `JSONSchemaStore` whose `fetch()` returns `undefined` for every address. `FetchingJSONSchemaStore` is not imported anywhere in `packages/`, and a test asserts that. This holds even for a document that bypassed every other check, and regardless of which positions a future quicktype version dereferences. +3. **There is no opt-in.** No CLI flag, no config key, no allow-list of hosts or directories, no environment variable. A multi-file schema or specification is bundled by the user, with the tool of their choice, before laqi sees it. The refusal message says so. + +The two mechanisms are deliberately redundant. The walk gives a named, pointed refusal at the moment a document enters laqi; the store is the guarantee that holds when the walk is wrong, incomplete, or bypassed. + +## Alternatives rejected + +- **A flag (`--allow-remote-refs`) or config key.** Gives up the property that a cloned repository cannot cause a request. Every generator that shipped one shipped a CVE around its default or its bypass, and the flag would be set by the importer, not by the teammate whose machine later exports. +- **An allow-list of origins or a source-root directory for `file:` refs.** Gives up simplicity for a feature nobody has asked for; still requires DNS-rebinding and redirect handling (OWASP) for the HTTP case, and symlink/`..` confinement for the file case — a second copy of `resolveSourcePath`'s problems, for schema text instead of module code. +- **Resolve external refs at import and inline them, never at export.** Gives up the "no network at import" sentence the compiler already makes; moves the request from export time to import time without removing it. +- **The document walk alone, keeping the fetching store.** Gives up the guarantee: which positions quicktype dereferences is quicktype's traversal, and a guard that depends on a third party's traversal order is not a guard. +- **Prototype: keep the fetching store but strip non-`#` refs from the document before handing it to quicktype.** Gives up honesty: it would silently export a type that says less than the stored document, which the adapters spec's loss policy forbids. + +## Consequences + +- A document that reaches outside itself is refused once, early, with its pointer, and never silently stored. Already-stored documents of that kind stop exporting and say why. +- laqi takes on no URL parsing, host validation, redirect policy, DNS pinning or path confinement for schema text. The entire control is "is the string `#` or does it start with `#/`". +- Users with multi-file OpenAPI or JSON Schema sources must bundle before importing. This was already true for every position the compiler walked; it is now true for every position. +- Export of a stored snapshot that previously printed `unknown` (TypeScript) or fetched (other targets) for a non-local ref now fails for every target. Open question 3 in the spec offers the softer TypeScript-only `unknown` if the owner prefers it. +- quicktype's own "Could not fetch schema" error can still appear for a document that evaded the walk; it carries no fetched content, because nothing was fetched. + +## Non-goals + +- Resolving `$id`-based or anchor-based (`#name`) references. laqi never supported them; they are refused as non-local, not implemented. +- A sandbox or egress policy for the laqi process as a whole (the `--share` tunnel, MCP, and the control plane have their own tickets). +- Bundling. laqi does not become a `$ref` resolver. + +## Reopen when + +A user-facing need to import an unbundled multi-file specification is reported by more than one team **and** a bundling step before import is shown to be impractical for them. The answer then is a bundling step inside laqi that runs only on explicit, interactive user action from the terminal or panel (never from a stored document, never from an agent, never at export), with a file-only resolver confined to `schemaSources.root` — not a network resolver, and not a flag that a document can rely on. diff --git a/apps/documentation/src/content/docs/design/generate-hardening.md b/apps/documentation/src/content/docs/design/generate-hardening.md new file mode 100644 index 0000000..85a79f8 --- /dev/null +++ b/apps/documentation/src/content/docs/design/generate-hardening.md @@ -0,0 +1,334 @@ +--- +title: Generate hardening — design spec +--- + +# Generate hardening — design spec + +**Status:** proposed, not implemented. +**Date:** 2026-10-12 +**Ticket:** [NIW2-311 — Close SSRF, code injection and exponential blowups in @laqi/generate](https://linear.app/niway/issue/NIW2-311/close-ssrf-code-injection-and-exponential-blowups-in-laqigenerate) (parent audit NIW2-302) +**Amends:** [JSON Schema and adapters — design spec](/design/json-schema-adapters/) (three rows, listed under [Tension with existing documents](#tension-with-existing-documents)) +**Creates:** ADR-0017 — References never leave the document, and there is no switch (`adr-no-external-refs.md`) + +Normative statements use **must** / **must not**. Code blocks marked _illustrative_ show intent, not final declarations. Everything below was re-located in the read-only export of `origin/main`; file:line citations are to that snapshot. Reproductions marked _(reproduced)_ were run on 2026-10-12 against the working checkout, whose `packages/generate/src` differs from main only in `compile-schema.ts` (main additionally honours numeric `format`; the `$ref`, object and printer code is byte-identical). Nothing in this ticket is fixed on main. + +## In one minute + +`@laqi/generate` trusts its inputs in five places where it must not. A mock file in a cloned repository, an OpenAPI document someone shares, or a model pasted from a chat can today make the developer's machine issue HTTP requests and read local files (on export), put attacker-written lines into the TypeScript the panel offers to copy, block the single-threaded server for seconds with a 1.6 KB schema, multiply a spec's size by 200× on import, and read literal constants out of any `.ts` file reachable from the working directory. This spec closes all five without changing what laqi stores, what the Promise facades return, or the Effect boundary of [ADR-0012](/decisions/0012-effect-first-in-generate/). + +### Decisions + +| # | Decision | Why | ADR | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| D1 | quicktype runs on a store whose `fetch()` always returns `undefined`. `FetchingJSONSchemaStore` is never constructed anywhere in laqi. | It is the only control that holds for a stored snapshot that never passed the compiler, and it is independent of which positions quicktype happens to dereference in a given version. Verified: the deny-all store makes zero requests and still renders local `$ref`s, including under `$id`. | ADR-0017 (creates) | +| D2 | A non-local `$ref` (anything but `#` or `#/…`) in any **schema position** of a document — including `$defs`, `definitions`, and `additionalProperties` beside `properties` — is refused by name at import (`unsupported.keyword`) and at export (`PrintError`). | Early, pointed refusal beats quicktype's "Could not fetch schema" after a stored document has already been committed. Data positions (`examples`, `default`, `const`, `enum`) are not walked: a `$ref` key inside example data is data. | ADR-0017 (reuses) | +| D3 | There is no opt-in for remote or file references. No flag, no allow-list, no config key. | laqi's documented promise is "import, generation and export make no network requests". Other generators that shipped a flag shipped the vulnerability (datamodel-code-generator, Kiota, Orval, 2026). Bundling is the user's job before import. | ADR-0017 (creates) | +| D4 | Every name the TypeScript printers emit after `interface`/`type` goes through one `declarationName()`: valid ASCII identifier, digit-leading names prefixed, reserved words and the globals the output itself uses suffixed, length-capped, then uniqued per document. | `snapshot.name` is a display label (adapters spec) and is stored unconstrained; the printer is the boundary where a label becomes code. Shadowing `Date`/`Record` silently retargets the output's own references. | none | +| D5 | `compileSchema` memoises the plan per `$ref` string across the whole compilation, cycle cuts included, and counts compiled nodes against `MAX_COMPILE_NODES = 100_000`, refusing with `budget.exceeded`. | Plans are immutable, so a shared target is one plan referenced many times; work becomes linear in document size. The budget is the backstop for anything memoisation cannot share. Memoising only "clean" targets (the partial patch) leaves a diamond-with-a-cycle exponential. | none | +| D6 | `parseTypes` memoises `Type → Shape` the same way (cycle cuts memoised, depth cuts not) and counts conversions against `MAX_TYPE_VISITS = 50_000`, failing with `ParseError`. | Same shape of problem, same shape of fix. A 672-character paste takes 2.6 s today and grows as fan^depth. | none | +| D7 | `extractResponseSchema` carries only the components reachable from the response schema (worklist over `$ref`, visited set), and rewrites each component at most once per source document (`WeakMap` cache). | 400 responses × every component is 196× amplification on a 94 KB spec _(reproduced)_, 300× on the audit's 210 KB spec. Reachability over string `$ref`s needs no plan and no cycle policy; it is a graph walk. | none | +| D8 | `parseTypes` runs the compiler on a host that serves exactly two things: the pasted source and TypeScript's own `lib.*.d.ts`. `noResolve: true`, `types: []`, no directory listing, no real-path, no current directory, every module resolution unresolved. | Pasted text is untrusted. The documented contract already says an unresolvable import degrades to `unknown`; today it is only true when the file does not happen to exist under the cwd. Reading project files is what `project-module` is for, behind its approval flow. | none | +| D9 | None of this changes `packages/schema`: `SchemaSnapshot.name` stays `string.min(1)`, the diagnostics table is unchanged (`budget.exceeded` already exists), no new config key. | Keeps this ticket inside `packages/generate` (NIW2-315 owns `packages/schema`) and makes every stored mock file load exactly as before. | none | + +### Open questions for the owner + +1. **Prefix for digit-leading names.** `GET /2fa` → `T2fa` is proposed (common codegen convention). `_2fa` is the alternative; it reads as "private" to some people. Pick one; it is a one-line constant. +2. **`additionalProperties: ` beside `properties`.** Proposed: compile the sub-schema (so a hidden `$ref` or an unsupported keyword inside it is refused) but keep generation unchanged and emit **no** diagnostic, because laqi never invents keys and the generated object satisfies the document. The audit (Q5) wanted a `loss.index-signature` warning; under the strict default that would start refusing OpenAPI imports that succeed today. Confirm "no diagnostic". +3. **Export of an already-stored snapshot with a non-local `$ref`.** Proposed: fail for every target, TypeScript included (today TypeScript prints `unknown` for it). The softer option is to keep TypeScript printing `unknown` and fail only quicktype targets. Proposed is the stricter one because the panel then shows one message instead of two behaviours. +4. **Audit Q7 (`MAX_SHAPE_DEPTH` counts two levels per `$ref` hop, so ~250 chained components refuse).** Not required by the acceptance criteria. Fold into D5 (count one level per hop) or leave for a later ticket? +5. **Seeded output for cyclic documents changes under D5** (see [Migration](#migration-and-breaking-changes)). Accept, or keep today's per-path re-expansion for documents that contain a cycle and rely on the node budget alone for them? Proposed: accept; the body is still a valid instance of the document, and the evidence's `bodyHash` protects an existing body from a silent overwrite. + +### Out of scope + +- `load-module.ts` (child env, output caps, verdict framing): NIW2-310. +- MCP tool annotations, `import_openapi`'s dropped responses, output caps on tool results: NIW2-314. +- Constraints on `SchemaSnapshot.name` or any `packages/schema` change: NIW2-315. +- The panel/TUI rendering of errors: NIW2-316. +- String-length and numeric-bound resource limits in generation (audit S8), `exclusiveMinimum` epsilon bug (Q4), pointer-unescape in `print-document.ts` (Q3), duplicated helpers (Q7). Worth doing; not what the ticket's acceptance asks for. Q3/Q4 appear in the partial patch and may ride along in the same PRs if the reviewer agrees, but this spec does not depend on them. +- Any sandbox for pasted code. There is none to build: pasted TypeScript is never executed, only type-checked. + +## Threat model in one paragraph + +The attacker controls one of: a mock file in a repository the developer clones (`laqi/*.json`, with a `schema` block), an OpenAPI or JSON Schema document the developer imports, or a TypeScript model pasted into the panel or sent by an agent to `generate_data`. They do not control the laqi process, the config, or the terminal. Their goals: make the developer's machine reach a URL or read a file (cloud metadata, internal admin, `~/.ssh`), get attacker text into code the developer copies into the project, or stop the mock server. Every surface in this ticket is reachable from those inputs without any confirmation step: export runs on a click in the panel or on `get_types` from an agent, import runs on `import_openapi` or the panel's create flow, and `parseTypes` runs on every paste. + +## Findings + +### F1 — SSRF and local file read on export (High) + +**Current behaviour on main.** + +- `packages/generate/src/print-types.ts:51` destructures `FetchingJSONSchemaStore` from quicktype-core and `:77` constructs it: `new JSONSchemaInput(new FetchingJSONSchemaStore())`. Every non-TypeScript export target goes through this (`export-types.ts:45-48`); TypeScript is printed by laqi itself (`export-types.ts:37-43`). +- quicktype-core 26.0.0's store (`dist/input/io/NodeIO.js`, `readableFromFileOrURL`) handles three address shapes: `file://` is turned into a path and read; anything `is-url` accepts is passed to `globalThis.fetch`; anything else is treated as a local path, symlinks resolved, and read with `fs.createReadStream` if it exists. There is no scheme or host check anywhere. +- `compile-schema.ts:641-647` refuses a `$ref` that does not start with `#` — but only for the schemas it walks. It never walks: `$defs`/`definitions`/`examples`/`default` (annotations, `:18-31`), or `additionalProperties` when `properties` is present (`:487` lists it as consumed, `:490-494` compiles only `properties`). _(reproduced)_: all three hiding places compile `ok: true`. +- A stored snapshot reaches export **without** the compiler: `apps/cli/src/serve.ts:428-440` only runs `SchemaSnapshotSchema.safeParse` (which checks `$schema` and shape, `packages/schema/src/schema-snapshot.ts:50-60`), and `serve.ts:245-262` / `packages/mcp/src/server.ts:323-333` export `response.schema` straight from the loaded mock file. +- _(reproduced)_: `exportTypes(snapshot, 'python')` with `additionalProperties: { $ref: 'http://127.0.0.1:/latest/meta-data/' }` beside `properties` made **one GET** to the local server; `'typescript'` made none. A relative `$ref: 'other.json'` is looked up on disk relative to the process cwd (it failed only because the file did not exist). `$id` pointing at a URL did **not** trigger a fetch on its own. +- `print-types.ts:92` returns `String(e)` as the `PrintError` message, so quicktype's "Value of type … is not valid JSON Schema at " echoes part of the fetched body to the panel or agent. + +**Scenario.** A teammate's branch adds `laqi/users.json` with a `schema` whose `additionalProperties` references `http://169.254.169.254/latest/meta-data/iam/security-credentials/`. Anyone who opens the panel and clicks "Export → Python", or whose agent calls `get_types` with `lang: "go"`, has their machine issue that request, and part of the response comes back in the error text. + +**Designed behaviour.** + +- `printDocumentEffect` **must** construct `JSONSchemaInput` with a store whose `fetch()` returns `undefined` for every address. `FetchingJSONSchemaStore` **must not** be imported or constructed anywhere in `packages/`. A test asserts the symbol is absent from the package's compiled output. +- A new module `packages/generate/src/local-refs.ts` exports `findNonLocalRef(document): { pointer, ref } | undefined`. It **must** walk only schema positions — the same three keyword tables `normalize-dialect.ts:22-46` already uses (`SCHEMA_VALUED`, `SCHEMA_ARRAYS`, `SCHEMA_MAPS`, which include `$defs`, `definitions`, `properties`, `additionalProperties`, `items`, `prefixItems`, `allOf/anyOf/oneOf`, `not`, `if/then/else`, `patternProperties`, `dependentSchemas`, `propertyNames`, `contains`, `unevaluated*`) — plus the root. It **must not** descend into `examples`, `default`, `const`, `enum`, or unknown keywords. It **must** be iterative (explicit stack), so a document nested 100 000 levels deep cannot overflow the guard. It **must** treat `$ref`, `$dynamicRef` and `$recursiveRef` alike. A reference is local iff it is exactly `#` or starts with `#/`; `#anchor` forms are non-local for this purpose (laqi never supported anchors, and `compile-schema.ts:680` would mis-slice them — audit Q6). +- `compileSchema` **must** call `findNonLocalRef` on the whole document before compiling and refuse with `unsupported.keyword` at the offending pointer, using the existing message from `compile-schema.ts:644`. This closes the `$defs` and `additionalProperties` holes at import for every adapter (`import-schema.ts:206, 262, 355`). +- `object()` **must** compile `additionalProperties` when it is an object beside `properties`, discard the resulting plan, and keep generating exactly what it generates today (open question 2 for the diagnostic). +- `exportTypesEffect` **must** call `findNonLocalRef(snapshot.document)` first and fail with `PrintError` naming ref and pointer, for every target including TypeScript (open question 3). `printDocumentEffect` **must** repeat the check (it is public API and callable with any document). +- The `PrintError` message built from a quicktype failure **must** keep quicktype's text (it is the user's own document being described) — the body-echo concern disappears once nothing is fetched. + +**Interfaces** _(illustrative)_: + +```ts +// packages/generate/src/local-refs.ts +export type NonLocalRef = { pointer: string; ref: string } +export const isLocalRef = (ref: string): boolean => ref === '#' || ref.startsWith('#/') +export function findNonLocalRef(document: unknown): NonLocalRef | undefined +export const nonLocalRefMessage = (found: NonLocalRef): string + +// packages/generate/src/print-types.ts — inside printDocumentEffect, after the service yields +class LocalOnlyStore extends JSONSchemaStore { + async fetch(): Promise { return undefined } +} +const input = new JSONSchemaInput(new LocalOnlyStore()) +``` + +`JSONSchemaStore` is exported by quicktype-core 26.0.0 (`dist/input/JSONSchemaStore.d.ts`: `abstract fetch(address: string): Promise`). When `fetch` returns `undefined`, quicktype raises `SchemaFetchError: Could not fetch schema ${address}, referred to from ${base}` after one retry against an alternate base, with no I/O _(reproduced)_. + +**Tests** (`packages/generate/src/local-refs.test.ts`, `print-types.test.ts`, `export-types.test.ts`): + +| Test | Input | Expected | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `refuses a remote $ref hidden in additionalProperties beside properties, at import` | `{type:'object', properties:{a:{type:'string'}}, additionalProperties:{$ref:'http://127.0.0.1:/x'}}` via `compileSchema` and `importSchema({kind:'json-schema'})` | `ok:false`, diagnostic code `unsupported.keyword`, pointer `/additionalProperties/$ref`; `ImportError` from the facade | +| `refuses a file: $ref hidden in $defs, at import` | `$defs: { x: { $ref: 'file:///etc/hosts' } }` beside a valid root | as above, pointer `/$defs/x/$ref` | +| `refuses a relative file $ref anywhere a schema can sit` | one fixture per keyword in the three tables, `$ref: './secret.json'` | each refused with the pointer of that keyword | +| `does not mistake a $ref key inside example data for a reference` | `examples: [{ $ref: 'http://example.test' }]`, `const: { $ref: 'x' }`, `default: { $ref: 'x' }` | `findNonLocalRef` → `undefined`; `compileSchema` ok | +| `refuses a #anchor reference instead of mis-slicing it` | `{ $ref: '#node', $defs: {...} }` | `ok:false`, `unsupported.keyword` | +| `survives a document nested deeper than the stack` | 200 000 nested `items` objects, built iteratively | `findNonLocalRef` returns without throwing `RangeError` | +| `export makes no request and reads no file for a stored snapshot with a remote $ref, on every target` | a local `http.createServer` counting requests; snapshots hiding `http://127.0.0.1:/`, `file://`, and `` in `additionalProperties`, `$defs`, `items`; targets `python`, `go`, `typescript` | every `exportTypes` rejects with `PrintError` matching `/never fetches/`; request count unchanged; the temp file's `atime`-independent sentinel (a `fs.watch` or a counting `readFile` spy) never fires | +| `printDocumentEffect itself refuses before quicktype sees the document` | same documents through `printDocument(document, { typeName, lang: 'python' })` | rejects with `PrintError`; request count unchanged | +| `the deny-all store still renders local references` | `{ properties: { a: { $ref: '#/$defs/A' } }, $defs: { A: {...} } }` and the same with `$id: 'https://example.test/root.json'`, target `python` | code contains `class`; zero requests | +| `FetchingJSONSchemaStore is not referenced by the package` | grep over `packages/generate/src/**/*.ts` excluding tests | no match | + +The existing `compile-refs.test.ts:143-164` (`refuses an external reference without ever calling fetch`) stays and gains the hidden-position fixtures. + +### F2 — Code injection and invalid identifiers in printed TypeScript (High) + +**Current behaviour on main.** + +- `print-document.ts:26-32` `uniqueName` only de-duplicates; `:38` and `:40` interpolate the result into `export interface ${name} {` / `export type ${name} = `; `:136-137` feed `typeName` (= `snapshot.name` from `export-types.ts:39`) straight in. `print-shape.ts:26-32, 43, 51, 74` do the same with `typeName` from `typeNameFor(id)` (`serve.ts:467-470`). +- `snapshot.name` is `z.string().min(1)` (`schema-snapshot.ts:52`); a `SourceRequest.name` is `z.string().min(1).optional()` (`packages/schema/src/source-request.ts:26,35`). Both are honest labels by the adapters spec and nothing constrains their characters. +- `fieldTypeName` (`print-shape.ts:93-99`) rebuilds property-derived names from `[A-Za-z0-9]` and upper-cases the first letter, so a property named `date` becomes the interface `Date`, `record` becomes `Record`, and `1st` becomes `1st`. `typeNameFor('GET /2fa')` returns `2fa` (`type-name.ts:2-12`). +- _(reproduced)_ with `name = 'X {}\nimport * as cp from "child_process"; cp.execSync("touch /tmp/pwn"); export interface Y'`: both printers emit a second line that is that import and call. _(reproduced)_: properties `date`, `record`, `when: date-time`, `map: Record`, `1st` yield `export interface Date`, `export interface Record`, `when?: Date` now pointing at the user's `Date`, `map?: Record` now a non-generic, and `export interface 1st {` which is a syntax error. +- Property keys and literal values are safe: `property()` (`print-shape.ts:114-116`) quotes anything that is not a bare identifier, `literals()` uses `JSON.stringify`. Nested interface names come from `fieldTypeName` and so are alphanumeric. The only raw interpolation is the root name — and the reserved/global collision affects every name. + +**Scenario.** A mock file carries `"schema": { "name": "…" }` with the payload above. The panel's types view renders it, a developer (or an agent writing to `src/types/api.ts`) copies it, and the next `tsc`, test run or bundler evaluation with that file imported runs the attacker's import. The shadowing case needs no attacker: an ordinary API with a `date` object field produces types that compile against the wrong `Date`. + +**Designed behaviour.** + +- One function, `declarationName(raw: string): string` in `print-shape.ts`, is the only path to a name that follows `interface` or `type`: + 1. If `raw` matches `/^[A-Za-z_$][A-Za-z0-9_$]*$/` keep it; otherwise rebuild it with the same cleaning `fieldTypeName` applies (letters and digits only, camel-cased at the breaks). Non-ASCII letters are dropped, not transliterated; a result that is empty becomes `Type`. + 2. A leading digit gets the prefix from open question 1 (`T` proposed). + 3. The result is truncated to `MAX_NAME_LENGTH = 128` characters. + 4. If the result is in `RESERVED_NAMES` the suffix `Type` is appended (`Date` → `DateType`, `string` → `stringType`). `RESERVED_NAMES` **must** contain: every TypeScript reserved and strict-mode reserved word; the type keywords (`any`, `unknown`, `never`, `void`, `string`, `number`, `boolean`, `bigint`, `symbol`, `object`, `undefined`, `null`); the globals the printers themselves emit (`Date`, `Record`) and the built-in types a model commonly collides with (`Array`, `ReadonlyArray`, `Promise`, `PromiseLike`, `Map`, `Set`, `WeakMap`, `WeakSet`, `Object`, `String`, `Number`, `Boolean`, `Symbol`, `BigInt`, `Function`, `Error`, `RegExp`, `JSON`, `Math`, `ArrayBuffer`, `Iterable`, `Iterator`, `Generator`, `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Exclude`, `Extract`, `NonNullable`, `ReturnType`, `InstanceType`, `Parameters`, `Awaited`). The list is a constant in code with a test that every entry is rejected; it is not meant to be exhaustive over `lib.d.ts`, only over what generated code and typical models touch. +- `declarationNamer()` returns a per-document function that applies `declarationName` and then uniques with a per-base counter (`Item`, `Item2`, `Item3`) so 1 000 siblings called `Item` stay linear (audit P5). +- `printDocumentTypeScript` and `printShapeTypeScript` **must** obtain every declared name — root, nested, `$defs`-derived — through that namer, and `printShapeTypeScript` **must** reserve the root name before typing the fields so a nested interface cannot take it (today a non-object root declares `export type ${typeName}` unsanitised, `print-shape.ts:74`). +- `typeNameFor` **must** return `declarationName(...)` of what it builds today (so `GET /2fa` → `T2fa`, `GET /` → `Response`). +- `exportTypesEffect` **must** pass `declarationName(snapshot.name)` as quicktype's `typeName` too. quicktype has its own per-language namers, so this is belt-and-braces; whether quicktype would emit a raw name is **unverified** and not relied upon. +- The stored `snapshot.name` is **not** modified, and no `packages/schema` constraint is added (D9). The panel keeps showing the label it stored; only code generation sanitises. + +**Tests** (`print-shape.test.ts`, `print-document.test.ts`, `type-name.test.ts`): + +| Test | Input | Expected | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- | +| `keeps a hostile snapshot name out of the printed document` | the `child_process` payload as `typeName`, document `{type:'object', properties:{a:{type:'string'}}}` | output matches `/^export interface [A-Za-z_$][\w$]* \{/`, contains neither `child_process` nor a line starting with `import` | +| `keeps a hostile name out of a printed shape, object and non-object root` | same payload, shapes `{kind:'object',…}` and `{kind:'array',…}` | exactly one declaration line each; same assertions | +| `does not declare an interface named like a global the output uses` | properties `date`, `record`, `promise`, `map` (record), `when` (date-time) | no `interface (Date | Record | Promise)\b`; still contains `when?: Date;`and`Record`; `date?: DateType;` | +| `prefixes a digit-leading name and quotes the property` | property `1st` of object type, root `2fa` | `export interface T2fa {`, `"1st"?: T1st;`, `export interface T1st {` | +| `suffixes a reserved word used as a name` | root `string`, property `class` of object type | `export interface stringType`, `class?: ClassType` — and `tsc --noEmit` on the output passes | +| `numbers colliding names with a per-name counter` | 1 000 sibling object properties all named `item` | 1 000 distinct names `Item`, `Item2`…; completes in < 100 ms | +| `every printed fixture is valid TypeScript` | every fixture of the two printer test files | `ts.createSourceFile` + `getSyntacticDiagnostics` (via the `TypeScriptCompiler` service) reports zero errors | +| `typeNameFor never returns a digit-leading or reserved name` | `GET /2fa`, `GET /date`, `GET /`, `POST /users/:id/class` | `T2fa`, `DateType`, `Response`, `UsersClass` | +| `exporting a snapshot named with a payload sanitises the name for quicktype` | payload name, target `python` | code contains no line from the payload | + +Existing goldens under `packages/generate/goldens/` cover generation, not printing, and are unaffected. `print-document.test.ts:54` ("keeps a shared $defs component as one interface under its own name") and `characterization.test.ts` run unchanged for names that are already valid identifiers — the sanitiser is the identity on them. + +### F3 — Exponential `$ref` expansion in `compileSchema` (High) + +**Current behaviour on main.** + +- `compile-schema.ts:52-57` `Ctx` carries `following: string[]` and `depth`; `reference()` `:652` checks `ctx.following.includes(ref)` for cycles and `:668-672` compiles the target afresh on every use with a spread context. There is no memo and no node count; the only bound is `MAX_SHAPE_DEPTH = 500` (`shape.ts:27`, checked at `compile-schema.ts:112-114`). +- A document whose `$defs/Lᵢ` has three properties pointing at `Lᵢ₊₁` is compiled 3ⁱ times at level i. _(reproduced)_: 12 levels / 1 338 bytes = 129 ms, 13 levels = 349 ms, 14 levels / 1 578 bytes = 1 071 ms on an M-series laptop (the audit measured 13.7 s for 14 levels on its machine). Each level triples the time; 20 levels would not finish. +- The compilation is synchronous on the server's only thread and runs on import (`import-schema.ts:206, 262, 355`) and again on every `previewBody` (`:413`), including every `regenerate` and the `import_openapi` loop (`packages/mcp/src/openapi.ts:186-198`). + +**Scenario.** An OpenAPI document with a chain of components each referencing the next a few times — a realistic shape for nested resource envelopes — is imported through the panel or `import_openapi`. The mock server stops answering every request for seconds to minutes. Nothing malicious is required; a hostile document simply makes it certain. + +**Designed behaviour.** + +- `Ctx` gains two fields shared by every copy of the context: `resolved: Map` (by exact `$ref` string) and `work: { nodes: number }`. +- `reference()` **must** return `resolved.get(ref)` when present, before resolving the pointer. After compiling a target it **must** store the plan in `resolved` **unconditionally**, including targets that emitted `loss.circular` (cycle cuts) or `loss.unresolved-type` along the way. Rationale: a cut plan is a valid plan for that target; storing it keeps a diamond that contains a cycle linear instead of exponential. The diagnostics are pushed once, at the first encounter, with that encounter's pointer — this is what the existing tests in `compile-refs.test.ts:84-140` already assert, and both of them keep passing under unconditional memoisation (each `$defs` entry there is first compiled exactly once, in the context the test expects). +- A target refused with `RefusedError` is never stored (the whole compilation aborts anyway). +- `compile()` **must** increment `work.nodes` on every real node compilation (memo hits do not count) and refuse with `budget.exceeded` — error severity, not acknowledgeable, already in the table at `packages/schema/src/diagnostics.ts:117` — when `nodes > MAX_COMPILE_NODES` (`100_000`, exported, matching the `MAX_GENERATED_VALUES`/`MAX_INFERRED_VALUES` convention). Message: `this document expands to more than 100000 schema nodes once its references are followed`. +- `following` becomes a `Set` copied per hop only when a hop happens (audit P4). Depth accounting is unchanged unless open question 4 is answered "fold in". +- `previewBody`'s second compilation (audit P2) is not removed here: with memoisation it is linear in document size and a plan cache keyed by document would be new state in the facades, which [ADR-0012](/decisions/0012-effect-first-in-generate/)'s runtime note warns against adding casually. Leave for phase-6 measurement. + +**Tests** (`compile-refs.test.ts`, new `compile-budget.test.ts`): + +| Test | Input | Expected | +| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `compiles a $ref diamond fanned 25 levels deep in linear time` | `fanned(25)` (three properties per level → next level; 3²⁵ paths) | `ok: true`; wall time < 200 ms; `work.nodes` (exposed on the result for tests, or asserted via a counting spy) ≤ 3 × 25 + 25 | +| `compiles a diamond whose leaf closes a cycle without re-expanding paths` | `fanned(20)` with the last level `{ $ref: '#/$defs/L0' }` | `ok: true`; exactly one `loss.circular` diagnostic; wall time < 200 ms | +| `refuses a document past the node budget with budget.exceeded` | object with `MAX_COMPILE_NODES + 1` string properties (built with `Object.fromEntries`) | `ok: false`; `diagnostics[0].code === 'budget.exceeded'`; message contains `100000`; returns in < 2 s | +| `a shared definition is one plan object` | `properties: { a: {$ref:'#/$defs/X'}, b: {$ref:'#/$defs/X'} }` | `plan.fields[0].plan === plan.fields[1].plan` (reference equality) | +| `generation from a memoised plan matches the goldens` | every golden document under `packages/generate/goldens/` that uses `$ref` | byte-identical bodies at the recorded seeds (the goldens are the oracle; a difference means the memo changed semantics for an acyclic document) | +| existing `a cycle` block (`compile-refs.test.ts:84-140`) | unchanged | unchanged expectations | + +### F4 — Exponential type expansion and real filesystem access in `parseTypes` (High + Medium) + +**Current behaviour on main.** + +- `parse-types.ts:102-111` builds `ts.createCompilerHost(options)` and overrides `readFile`/`fileExists`/`getSourceFile` only for the virtual file; everything else — `import './x'`, `typeof import('/abs/path')`, `/// `, `node_modules/@types`, `package.json` lookups — goes to the real filesystem relative to `process.cwd()`. `options` (`:93-97`) do not set `noResolve` or `types`. +- _(reproduced)_: `export type X = { k: typeof import("/secret.ts").KEY }` returns `{ kind: 'literals', values: ['sk-live-12345'] }` with **no** warning. The `/// ` variant did **not** leak in my run when the referenced file is a module (its `KEY` is not global); a script-style `.d.ts` with `declare const KEY` would, which is **unverified**. +- `toShape` (`:173-321`) has `MAX_DEPTH = 10` (`:26`), a `seen` set that is removed after each object (`:295, :310`), and no memo or visit count. _(reproduced)_: fan 4 / depth 9 = 79 ms, fan 5 / depth 10 (600 chars) = 699 ms, fan 6 / depth 10 (672 chars) = 2 602 ms; growth is fan^depth, so fan 8 / depth 10 is in the tens of seconds and fan 12 in the hours. Reachable from `generate_data`'s `model` (capped at 200 000 characters, which does not help) and the panel's paste. + +**Scenario (read).** An agent, or a developer pasting a "model" from a blog or chat, includes `typeof import('../../.env.d')` or `import('/Users/me/project/src/config')`. Literal types — API keys written `as const`, feature flags, internal URLs — surface in the shape, then in the generated body, then in a committed mock file or in the agent's context. + +**Scenario (DoS).** A 700-byte paste pins the server for seconds; a 2 KB one for hours. + +**Designed behaviour.** + +- Compiler options **must** add `noResolve: true` (TypeScript: "If `noResolve` is set, [adding resolved `import`/`` all degrade to `any` → `loss.unresolved-type`, which is the documented contract (`parse-types.ts:46-49`). + - `useCaseSensitiveFileNames` and `getNewLine` from the stock host are kept. +- `toShape` **must** become a memoising wrapper around the existing conversion: `converted: Map`; a conversion that completed without a `loss.depth` cut is stored (cycle cuts are stored, as in D5); the wrapper increments `visits` and, past `MAX_TYPE_VISITS = 50_000`, throws a module-private `BudgetError`, which the program **must** turn into `ParseError` explicitly (a `catchIf`/`instanceof` branch placed before the existing `catchAllDefect` at `:341`), message `the model expands to more than 50000 nested types — simplify it or paste a smaller one`. The `ParseError` channel of `parseTypesEffect` is unchanged, which keeps the ADR-0012 invariant that error channels stay narrow. +- `MAX_DEPTH = 10` is unchanged. + +**Tests** (`parse-types.test.ts`, new `parse-types.isolation.test.ts`): + +| Test | Input | Expected | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `does not resolve a typeof import of a real file` | temp dir with `secret.ts` = `export const KEY = 'sk-live-12345' as const`; paste `export type X = { k: typeof import("").KEY }` | `ok: true`; `JSON.stringify(result)` does not contain `sk-live-12345`; `diagnostics` contains `loss.unresolved-type` at `/properties/k` | +| `does not resolve a relative import that exists under the cwd` | `process.chdir(tmp)` (or a cwd-relative path) with `./secret.ts`; paste `import { KEY } from './secret'; export type X = { k: typeof KEY }` | same | +| `does not follow a reference-path directive to a script file` | `secret.d.ts` = `declare const KEY: 'sk-live-12345'`; paste `/// \nexport type X = { k: typeof KEY }` | same | +| `does not read ambient @types from node_modules` | temp `node_modules/@types/leak/index.d.ts` declaring a global `LEAK: 'sk'`; cwd set there; paste `export type X = { k: typeof LEAK }` | same | +| `touches no file outside the TypeScript lib directory` | spy on `host.readFile` and `ts.sys.readFile` during a parse of a model with three imports | every read path's basename matches `/^lib\..*\.d\.ts$/` or is the virtual file | +| `still resolves the built-in lib types` | `export interface A { when: Date; ids: ReadonlyArray; m: Map }` | `when` → `primitive date`; `ids` → array of string; `m` → `unknown` with a warning as today (characterisation: must not regress to all-unknown) | +| `parses a fanned-out model in linear time` | 12 levels, four properties each pointing at the next | `ok: true`; < 1 s (4¹² ≈ 16 M paths today) | +| `a fanned-out model that ends in a cycle stays linear` | same, last level referencing `L0` | `ok: true`; one `loss.circular`; < 1 s | +| `refuses a model past the type budget as a ParseError, not a defect` | a generated model that fans faster than sharing can absorb (depth 11 so every path hits `loss.depth` and nothing is memoised, fan 6) | `{ ok: false, error: /50000 nested types/ }`; returns in < 5 s | +| existing `survives a self-referencing type` (`parse-types.test.ts:97`) | unchanged | unchanged | + +### F5 — OpenAPI import embeds every component in every response (High, scalability) + +**Current behaviour on main.** + +- `openapi-extract.ts:85-87`: `componentSchemas(source)` plus `rewriteRefs(components)` for the whole bag, on every call; `:93-97` attaches all of it as `$defs`. The doc comment at `:49-53` states the rationale: reachability "is the compiler's job and its cycle handling". +- `packages/mcp/src/openapi.ts:186-198` calls `importSchema({ kind: 'openapi', document, pointer })` once per response, then `previewBody` compiles the full document again, then `normalizeDialect` walks it again for each. +- _(reproduced)_: 400 paths × 1 shared component + 400 unrelated components, 93.9 KB spec → 400 extractions totalling 18.4 MB (196×) in 75 ms. The audit measured 210 KB → 63 MB on its fixture. Every byte is then written to the mock files, loaded on every reload, and sent to the panel. + +**Designed behaviour.** + +- `extractResponseSchema` **must** attach, as `$defs`, exactly the components reachable from the rewritten response node by following `$ref` strings transitively: a worklist of component names, a visited set, names taken as the first pointer segment after `#/$defs/` (RFC 6901 unescaped), so `#/components/schemas/A/properties/x` pulls in `A`. Cycles terminate through the visited set. A `$ref` to a name that is not in `components.schemas` is left alone and keeps failing at compile time with `resolves to nothing`, as today. +- Rewriting **must** happen at most once per component per source document: a module-level `WeakMap>` keyed by the `components.schemas` object. The `WeakMap` ties cache lifetime to the caller's document object; nothing is retained after the import loop drops it. +- `rewriteRefs` **must** build its copies with `Object.fromEntries` (audit S9: a JSON own key `__proto__` must stay an own key of the copy). +- `extractResponseSchema`'s existing contract — no `$schema`, OpenAPI-3.0-shaped output handed to `normalizeDialect({ assume: 'openapi-3.0' })`, `$defs` merged with any the node already carries — is unchanged. The doc comment at `:49-53` is rewritten to state the new rule and the measurement. + +**Tests** (`openapi-extract.test.ts`): + +| Test | Input | Expected | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `carries only the components the response can reach, transitively` | `Order → Customer → Customer (self)`, plus 50 `UnrelatedN` | `Object.keys($defs).sort()` equals `['Customer','Order']`; output contains no `components/schemas` | +| `reaches a component through a nested pointer` | response `$ref: '#/components/schemas/A/properties/x'` | `$defs` has `A` | +| `leaves an unknown component name alone` | response `$ref: '#/components/schemas/Missing'` | `$defs` empty; `compileSchema` on the result refuses with `invalid.document` naming the ref | +| `rewrites each component once per document` | spy on `rewriteRefs` (or count allocations) across 400 extractions of one document sharing one component | the shared component is rewritten once | +| `total stored bytes stay proportional to what is reachable` | the 400-path / 400-component fixture from the reproduction | sum of `JSON.stringify(document).length` over all extractions < 3 × spec size (today 196×) | +| `keeps the $defs the response node already carried` | response schema with its own `$defs: { Local: … }` | `$defs` has `Local` beside the reachable components | +| existing `carries the components it refers to, repointed at $defs` (`:61`) | unchanged fixture | unchanged (its components are reachable) | + +## Migration and breaking changes + +All of this ships inside the `laqi` npm package; nothing here is a wire or file-format change, and every stored mock file loads unchanged (D9). What a user can observe: + +| Change | Who notices | Mitigation | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Import now refuses a document with a non-local `$ref` in `$defs`/`definitions` or inside `additionalProperties` beside `properties`, or with `#anchor` refs. | Someone importing an unbundled multi-file schema or spec. Reachable positions were already refused; these were the positions laqi silently kept. | The diagnostic names the pointer and says "never fetches one". Bundle first (Redocly CLI, `swagger-cli bundle`, `json-schema-ref-parser` with `resolve.external` as the user chooses); laqi will not do it. | +| Export of an already-stored snapshot carrying a non-local `$ref` fails for every target with `PrintError`; previously TypeScript printed `unknown` there and other targets fetched. | A repository that already has such a snapshot on disk. | Re-import after bundling, or edit the stored document. The error names the pointer. | +| Exported TypeScript names change where a name was reserved, a global, or digit-leading: `date` object → `DateType`, `record` object → `RecordType`, `2fa` → `T2fa`. Names that were already valid, unreserved identifiers are unchanged. | Anyone who committed exported types for such endpoints and diffs them. | The previous output did not compile (`interface 1st`) or compiled against the wrong `Date`; this is a fix, documented in the changelog. | +| `typeNameFor` and therefore the panel's draft-model name for endpoints like `GET /2fa` changes to `T2fa`. The stored `snapshot.name` of a draft accepted after this change follows; existing stored names are untouched. | Panel users with digit-leading or reserved-word paths. | None needed. | +| A pasted model whose `import` happened to resolve against a real file under the cwd now degrades to `unknown` + `loss.unresolved-type`, and the strict default refuses it until `allowLoss` is passed. | Someone who relied on undocumented cwd resolution. | Use `project-module` (the sanctioned way to read project code, behind prepare/confirm) or paste the referenced types inline. The documented contract always said imports do not resolve. | +| Generated bodies for a document whose cycle is reachable through more than one path may differ at the same seed: the cut now happens where the target was first met and that plan is reused, instead of each path re-expanding to its own closing edge. Acyclic documents are byte-identical. | Anyone regenerating such a response with a recorded seed. | `generation.bodyHash` no longer matches the current body, so `applyGeneratedBody` requires `confirm: true` — the protection the adapters spec designed for exactly this. Open question 5. | +| OpenAPI imports store far smaller snapshots; existing snapshots are not rewritten. | Nobody adversely. `refresh_schema` re-extracts and shrinks on demand. | None. | +| Documents over 100 000 compiled nodes and models over 50 000 type conversions are refused. | Nobody realistic; the adapters spec already lists a 10 000-resolution budget that was never implemented. | The message names the constant. | + +## Tension with existing documents + +**[JSON Schema and adapters — design spec](/design/json-schema-adapters/).** Three rows change; this spec amends them in place when it lands. + +1. _Additive support › Annotations_: "`$defs` … never affect generation, never rejected." Amended: `$defs`/`definitions` entries are schemas; a non-local `$ref` inside them is refused exactly as anywhere else. `examples`, `default`, `$comment`, `title`, `description` remain data and are never rejected. +2. _Additive support › References_: "any `$ref` outside the document → `unsupported.keyword`; never fetched." Unchanged in wording; now true for every schema position and for export, and backed by a store that cannot fetch. +3. _Budgets_: "Reference resolutions — 10 000 per import" is replaced by "Compiled schema nodes — `MAX_COMPILE_NODES` 100 000; converted TypeScript types — `MAX_TYPE_VISITS` 50 000". The spec marks these as starting values left to evidence; this is the evidence. Resolutions are no longer the unit of work once targets are memoised. + +The spec's non-goals — "Remote `$ref` resolution. No network during import, generation or export" and "Deduplication and persistent caches wait for phase-6 measurements" — are respected: D1–D3 enforce the first; D7's `WeakMap` is a per-call working cache, not a persistent one, and D5/D6 add no cross-call state. + +**[ADR-0012 — Effect-first inside `@laqi/generate`](/decisions/0012-effect-first-in-generate/).** No tension. Every change is internal to the package; the Promise facades keep their contracts; `parseTypesEffect`/`exportTypesEffect`/`printDocumentEffect` keep their exact error channels (`ParseError`, `PrintError`) and `R` requirements; the deny-all store is created inside the program that already yields the `Quicktype` service, so the lazy-load invariant (`lazy-deps.test.ts`) is untouched. One rule from the ADR's operational page is applied deliberately: the type budget surfaces as a declared `ParseError`, not as a defect caught by `catchAllDefect`. + +**[ADR-0013](/decisions/0013-mocks-remember-their-model/)** (superseded): not affected; the stored form is unchanged. + +**`openapi-extract.ts:49-53`'s in-code rationale** ("every component is carried … reachability is the compiler's job") is reversed by D7. It was written before the amplification was measured and before `import_openapi` stored one snapshot per response. The new comment states the measurement. + +## Alternatives considered and rejected + +| Alternative | Strongest argument for it | Why rejected | +| --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Keep `FetchingJSONSchemaStore`, rely on the document walk alone. | One mechanism instead of two; the walk already refuses everything. | The walk covers the positions laqi knows are schemas; which positions quicktype dereferences is quicktype's business and changes between versions. A guard that depends on a third party's traversal order is not a guard. The store is one class and zero cost. | +| Allow remote refs behind a flag or allow-list (Kiota's `--allowed-external-origins`, datamodel-code-generator's `--allow-remote-refs`). | Multi-file specs are common; users would not have to bundle. | The three generators that shipped this in 2026 each shipped an SSRF/LFI CVE around the default or the bypass. laqi runs on every teammate's machine from a cloned repo, so the person who would set the flag is not the person whose machine makes the request. ADR-0017. | +| Constrain `SchemaSnapshot.name` in `packages/schema` to a valid identifier. | Fix at the source; the printer never sees a bad name. | Breaks loading every stored mock whose label has a space or a dot (`User (v2)`), contradicts "name is a label, never selects behaviour", and belongs to NIW2-315. Sanitising at the one boundary where a label becomes code is smaller and complete. | +| Validate printed TypeScript with the compiler at export time and refuse on syntax errors. | Catches anything the sanitiser misses. | Loads the 23 MB compiler on every export, violating the lazy-load invariant for the printing arrow; and a syntactically valid injection (the payload here is valid TypeScript) passes it. Kept as a **test**, not a runtime check. | +| Memoise `$ref` plans only when the target compiled cleanly (the partial patch). | Diagnostics keep the pointer of every path that met them. | A fan-out whose leaf closes a cycle marks every target dirty, nothing is memoised, and the blow-up is back. The node budget would catch it, but with a refusal of a document that should compile. First-encounter pointers are what the existing tests already expect. | +| Replace the plan tree with named plan nodes (`{ kind: 'ref', name }`) and cut cycles at generation time. | The right long-term shape: one plan per definition, cycles handled by a depth budget, no semantic change under sharing. | Touches `plan.ts`, `generate.ts` and every golden; it is the "whether `Shape` remains the compiled plan" question the adapters spec leaves to evidence. Out of a security ticket's scope; the memo gets linear time without it. | +| Move compilation off-thread (worker) instead of bounding it. | Keeps the server responsive whatever the document does. | Adds a real resource to the layer, which ADR-0012 says moves runtime ownership to the CLI/MCP (level 2). The work is still exponential; it is just somewhere else. Bounding it is the fix; a worker is a later nicety. | +| Use `@typescript/vfs` for the isolated host. | Purpose-built, battle-tested in the Playground. | A new dependency to serve one file plus `lib.*.d.ts` that the stock host already locates; the override is ~25 lines. Revisit if the host grows features. | +| Keep carrying all components and dedupe at the writer (one `$defs` block per file). | Zero change to extraction; shrinks files. | New file layout and a writer change (NIW2-313 territory); memory and panel payloads stay 200×; the adapters spec defers dedup to phase 6. Reachability is a 40-line graph walk. | +| Reject `$ref` keys in `examples`/`default` too (walk every position). | Simpler walk; no keyword tables to maintain. | False positives on APIs whose data legitimately has a `$ref` field (Google Discovery, JSON Reference-style payloads). The store makes the stricter walk unnecessary for safety. | + +## Cross-ticket dependencies and file boundaries + +This ticket **owns** (creates or edits): `packages/generate/src/{print-types,export-types,compile-schema,parse-types,openapi-extract,print-document,print-shape,type-name}.ts`, new `packages/generate/src/local-refs.ts`, their tests, `apps/documentation/src/content/docs/design/generate-hardening.md` (this spec), the amended rows of `design/json-schema-adapters.md`, and the new ADR. + +It **must not** touch: + +- `packages/generate/src/load-module.ts` — NIW2-310 (child env, output caps, `{"ok":` framing, process-group kill). +- `packages/mcp/**` — NIW2-314. `import_openapi` keeps calling `importSchema` per response; it benefits from D7 with no interface change. `generate_data`'s `model` cap already mirrors `MAX_SOURCE_LENGTH`; no new constant needs mirroring because `MAX_TYPE_VISITS` is not an input size. +- `packages/schema/**` — NIW2-315. D9 keeps `name`, `SourceRequestSchema`, and the diagnostics table unchanged; `budget.exceeded` is used as published. +- `apps/cli/src/serve.ts`, `packages/server/**` — NIW2-309. `exportSchema`/`getTypes` already surface a rejected `exportTypes` through `failedImport` / the `invalid` code (`serve.ts:446-448` and the `catch` at `:271-277`); the new `PrintError` text flows through unchanged. +- `apps/editor/**`, `packages/tui/**` — NIW2-316. The panel shows the error string it receives. + +Ordering: nothing here blocks or is blocked by another ticket. If NIW2-315 later tightens `SchemaSnapshot.name`, D4 remains correct (sanitiser is the identity on valid names). + +## Rollout + +Each step is one PR, independently shippable, in this order: + +1. **Stop the bleeding** — `local-refs.ts`, the deny-all store in `print-types.ts`, the export-time and compile-time walk, `object()` compiling `additionalProperties` beside `properties`. Tests: F1 table. Smallest diff, highest severity. +2. **Identifiers** — `declarationName`/`declarationNamer` in `print-shape.ts`, both printers, `typeNameFor`, quicktype `typeName`. Tests: F2 table, including the "every fixture is valid TypeScript" check. +3. **Compile budget** — memo + `MAX_COMPILE_NODES` in `compile-schema.ts`. Tests: F3 table, goldens re-run. +4. **Parse budget and isolation** — memo + `MAX_TYPE_VISITS`, the isolated host, in `parse-types.ts`. Tests: F4 table. Can be split in two if the host change needs its own review. +5. **Reachable `$defs`** — `openapi-extract.ts`. Tests: F5 table. +6. **Docs** — this spec into `design/`, the three amended rows in `json-schema-adapters.md`, the ADR into `decisions/`, a changelog note on the exported-name changes. + +Each PR's verification: `bun run verify` (lint, format, build, type-check, the full suite) plus the new tests. vitest has no "deny network" switch, so step 1's proof is the counting local server plus a counting `readFile` spy, not an environment flag. + +## References + +- Audit: `workspace-temp/audits/generate.md` (S1–S5, Q2, Q5, Q6, P1, P4, P5), `SUMMARY.md` items 3, 4, 15, 16. +- Linear: [NIW2-311](https://linear.app/niway/issue/NIW2-311), parent [NIW2-302]. +- Project docs: [JSON Schema and adapters — design spec](/design/json-schema-adapters/), [ADR-0012](/decisions/0012-effect-first-in-generate/), [Effect in laqi](/architecture/effect/). +- Reusable guidance: [general-knowledge › architecture › security-hardening](../../../general-knowledge/architecture/security-hardening.md) (validate at the boundary that acts, not in the caller), [general-knowledge › effect](../../../general-knowledge/effect/README.md) (honest error channels, lazy services). +- quicktype-core 26.0.0: `dist/input/JSONSchemaStore.d.ts` (abstract `fetch`), `dist/input/io/NodeIO.js` (`readableFromFileOrURL`: `file://`, `is-url` → `fetch`, else local path), `dist/Messages.js` (`SchemaFetchError`). Source: [JSONSchemaStore.ts](https://github.com/glideapps/quicktype/blob/master/packages/quicktype-core/src/input/JSONSchemaStore.ts), [FetchingJSONSchemaStore.ts](https://github.com/glideapps/quicktype/blob/master/packages/quicktype-core/src/input/FetchingJSONSchemaStore.ts). +- OWASP: [Server-Side Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) — "Do not accept complete URLs from the user"; allow-lists over deny-lists; SSRF "is not limited to the HTTP protocol" (`file://`, `gopher://`…). [OWASP Proactive Controls C10](https://owasp.org/www-project-proactive-controls/v4/en/c10-stop-server-side-request-forgery). +- Prior art, same bug class in 2026: [CVE-2026-54690 — datamodel-code-generator SSRF via JSON-Schema `$ref`](https://corgea.com/advisories/vulnerabilities/CVE-2026-54690) (flag defaulted to warn-and-fetch; fix blocks private ranges and requires opt-in), [CVE-2026-59867 — Kiota](https://releasealert.dev/cve/CVE-2026-59867) (fix: default-deny external references, `--allowed-external-origins`), [CVE-2026-62680 — Orval SSRF + local file inclusion via `$ref`](https://fieldguide.lutrasecurity.com/CVE-2026-62680/). +- Ajv: [Security considerations](https://ajv.js.org/security.html) — "limit the size of untrusted schemas and their maximum object depth"; schemas are "trusted as your application code". +- Redocly: [`remove-unused-components`](https://redocly.com/docs/cli/decorators/remove-unused-components) — a component is used only when something `$ref`s it; stated as a data-leak safeguard. (Prior art for D7's reachability rule, including its one-hop limitation reported by third parties and avoided here by the transitive walk.) +- TypeScript: [`noResolve`](https://www.typescriptlang.org/tsconfig/noResolve.html), [`types`](https://www.typescriptlang.org/tsconfig/types.html), [`@typescript/vfs`](https://www.typescriptlang.org/dev/typescript-vfs) (the alternative considered). +- json-schema-ref-parser ([options](https://apitools.dev/json-schema-ref-parser/docs/options.html): `resolve.external`, `resolve.file`, `resolve.http` — fetch of that page returned empty on 2026-10-12; semantics cited from the audit's general knowledge, **unverified**) as the bundling tool users may run before import.