Skip to content

fix(hono): createHonoApp's catch arms run the 5xx disclosure filter before shipping an undeclared throw's message (#23532) - #23564

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23532-hono-undeclared-500-leak-filter
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23532-hono-undeclared-500-leak-filter

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23532
Clause-②: no

What this does

createHonoApp (@objectstack/hono) has two catch arms: the auth mount's and the ${prefix}/* catch-all's around HttpDispatcher.dispatch(). Both end in thrownJson. A throw that declaredEnvelopeForThrow does not accept falls to the arm's own answer, errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500), and that line shipped the thrown message without asking the disclosure filter. A driver diagnostic therefore reached the client.

The fallback now asks looksLikeInternalErrorLeak (@objectstack/types) when the status it resolves is 5xx. A flagged message is replaced by INTERNAL_ERROR_MESSAGE, the constant the other doors ship (HttpDispatcher.error, packages/rest's registrars, declaredEnvelopeForThrow's 5xx limb, and this file's /auth/config arm). No new literal is written. Everything else is unchanged: the status, the code, a message the predicate does not flag, and every 4xx message. The edit is in thrownJson alone, so both arms get it. declaredEnvelopeForThrow (plugin-hono-server/src/adapter.ts) is read, not edited.

Measured through a real createHonoApp (one-shot, not committed)

The rig is the card's own: packages/qa/http-conformance's real LiteKernel, ObjectQLPlugin over the in-memory SQLite WASM driver, the real HttpDispatcher, and @objectstack/hono aliased to this checkout's source. Object ghost is registered and its table is never synced. The request is POST /api/v1/data/ghost. The probe test file was written into the worktree, run, and deleted by the script's trap. Readings were taken at 5407a49e2, and for "before" the fix line was reverted through scripts/ablation-replace.mjs.

Answer
Before (filter removed) 500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"insert into [statement and bound values redacted] - no such table: ghost"}}
After (5407a49e2) 500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}

The "before" row is byte-for-byte the answer the card quotes. The throw reached the fallback undeclared: a declared envelope would have been answered by declaredEnvelopeForThrow, whose own 5xx limb already filters.

Mechanism hypotheses, as measured

  • H1, the only unfiltered exit: confirmed. At origin/main 086fa4640, index.ts has two exits that carry a thrown message. One is thrownJson's fallback (:536, reached from the auth arm's catch at :814 and the catch-all's at :865). The other is the /auth/config arm (:746), which already filters. errorJson's third caller passes the literal 'Not Found'.
  • H2, already importable: confirmed. looksLikeInternalErrorLeak and INTERNAL_ERROR_MESSAGE are imported at index.ts:13-14. No dependency was added.
  • H3, the predicate flags the probe: confirmed on the real string, through packages/types/src/error-leak.ts. looksLikeInternalErrorLeak('insert into [statement and bound values redacted] - no such table: ghost') is true. The bare tail no such table: ghost alone is true as well. boom is false.
  • H4, scoped to 5xx: adopted, with the same expression as HttpDispatcher.error and declaredEnvelopeForThrow (status 500 or above, and the predicate). A 4xx statusCode keeps its message. This is pinned, and Ablation leg B shows the pin can fail.

Tests

New file: packages/adapters/hono/src/hono-thrown-message-disclosure.test.ts, 7 cases. createHonoApp, both of its arms and thrownJson are the real source. The dispatcher is the package's stubbed runtime, so each case can hand the arm the throw it needs.

  • Catch-all arm. The probe answers 500 with body { success: false, error: { code: 'INTERNAL_ERROR', message: INTERNAL_ERROR_MESSAGE } }, and ghost and no such table appear nowhere in it. A plain new Error('boom') keeps boom. A probe that declares statusCode: 503 is withheld at 503 SERVICE_UNAVAILABLE. A probe that declares statusCode: 400 keeps its message.
  • Auth-mount arm. An auth service whose handleRequest throws the probe gets the withheld message, and one that throws boom keeps it. The legacy dispatcher.handleAuth fallback (no auth service) throwing the probe is withheld too.

Results:

  • pnpm --filter @objectstack/hono test: 8 files, 161 tests passed (the 7 new ones included). pnpm --filter @objectstack/hono typecheck exited 0 under both programs. tsc --noEmit -p tsconfig.test.json --listFiles lists the new test file.
  • The existing pins still pass unchanged. hono.test.ts (returns error on dispatch exception for Service unavailable, the default-message case, and the statusCode: 403 case) and http-conformance's hono-declared-envelope.conformance.test.ts control rows all use messages the predicate does not flag.

Ablation (scripts/ablation-replace.mjs in wrap mode, plus an outer trap … EXIT INT TERM restore)

The test imports the subject as ./index (source). No build sits on its resolution path, so no dist/ preflight applies.

  • Leg A: the filter removed (the fallback ships message unread). The anchor hit once, x1 to x0. The blob went 446655b1e9b5 to a mutated one. Result: 4 failed, 3 passed. The four withheld pins (catch-all probe, statusCode: 503, auth-service probe, legacy handleAuth probe) went red. The two plain controls and the 4xx pin stayed green. Received body: "message": "insert into [statement and bound values redacted] - no such table: ghost".
  • Leg B: the 5xx bound removed (the filter runs at every status). Result: 1 failed, 6 passed. Only the 4xx pin went red: it expected the probe and received Internal server error.
  • Restore was proven per leg: the blob equals HEAD (446655b1e9b5) and git diff HEAD is empty.
  • The first attempt was a no-op and was discarded. Its replacement text (message,) already occurred in the anchor, so ablation-replace refused on "replacement count rose 0, not 1" and restored the file before the child ran. The legs above re-ran with replacements carrying a unique marker.

Gates (all at 5407a49e2, the head of this PR)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands. Each exit code was captured to a file before any pipe. All 63 ended at exit 0. Three first returned exit 3 (PREREQUISITE NOT MET) and were re-run once their prerequisite was met:
    • node scripts/check-plugin-teardown-shape.mjs --self-test needed its pinned control commit (621a487607) in this shallow clone. After git fetch --depth=1 of that commit, it exited 0 (48 cases).
    • pnpm check:type-check-debt first timed out in the shared verify lock's queue (a sibling's run held it for 17 minutes). On re-run: "1 ledger entr(ies) re-measured … none above its recorded number".
    • pnpm check:dual-build-cjs-loads needs the whole tree's dist/. The re-measure above built ./packages/*, and the re-run passed: "110 published require entry point(s) across 68 package(s) load".
  • node scripts/pm/dispatch-gates.mjs --ran ran.list: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN".
  • Lint, narrowed to the diff's two .ts files: eslint --no-inline-config linted 2 files with 0 errors and 0 warnings. Positive control: a stdin file in packages/adapters/hono/src/ importing a root namespace from @objectstack/spec exits 1 (no-restricted-imports). Repo-wide pnpm lint is CI's run.
  • The hono dist/ built at this head loads as CJS and as ESM (createHonoApp is a function in both) and carries the new expression.

Changeset

.changeset/23532-hono-undeclared-500-leak-filter.md: patch for @objectstack/hono. It is a bug fix in a published package, adds no export, and changes no status or code.

Acceptance notes

  • Out of scope, untouched: the standing tiering of an undeclared 5xx's legibility. A bare Error whose message does not look like a dump still names itself, as the card's acceptance requires. Also untouched: the declared-5xx disagreement between the Hono doors and the dispatcher's own exit. It stays where PR fix(hono): createHonoApp's two catch arms answer a declared ADR-0112 envelope as declared, not as 500 (#23365) #23530's acceptance notes put it.
  • Observation, not filed: in the rig, the unsynced-table insert left the engine as an undeclared bare Error. That is why it reached this fallback rather than the declared limb. The predicate's own header prefers that a producer declare its fault (code plus a 5xx status) over phrasing heuristics. Whether the insert path should declare is the producer's question, not this door's. Carrier: none.
  • main gained b57228913 and 7ffc57846 after the base. They touch packages/spec tests and packages/metadata-protocol, nothing this diff touches, so the branch was not merged. CI runs on the merge ref, and the merge queue rebuilds on main.

Generated by Claude Code

…efore shipping an undeclared throw's message

An undeclared throw that reached either catch arm of createHonoApp was
answered with its own message unread, so a driver diagnostic (an
unsynced table's "no such table: ghost") reached the client. The
fallback in thrownJson now asks looksLikeInternalErrorLeak at a 5xx and
ships INTERNAL_ERROR_MESSAGE in place of a flagged message, as the
dispatcher, the REST registrars and the declared-envelope limb already
do. A plain message and any 4xx message are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/hono, touching 3 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via INTERNAL_ERROR (literal, a string literal in a comment in createHonoApp))
  • content/docs/api/error-catalog.mdx (via INTERNAL_ERROR (literal, a string literal in a comment in createHonoApp))
  • content/docs/plugins/development.mdx (via INTERNAL_ERROR (literal, a string literal in a comment in createHonoApp))
  • content/docs/protocol/kernel/error-handling.mdx (via INTERNAL_ERROR (literal, a string literal in a comment in createHonoApp))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via INTERNAL_ERROR (literal, a string literal in a comment in createHonoApp))
  • content/docs/releases/v17/17-4.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/releases/v17/17-5.mdx (via createHonoApp (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7ffc57846a97afde24fbfc59b6b05d7562ef4df0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5be6314f9128a6f317cfcf63ca5af6250450140c — the merge of head 5407a49e2205257ae1f091b81f3f80a3daf30d26 into base 7ffc57846a97afde24fbfc59b6b05d7562ef4df0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5be6314f9128a6f317cfcf63ca5af6250450140c && git checkout 5be6314f9128a6f317cfcf63ca5af6250450140c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ffc57846a97afde24fbfc59b6b05d7562ef4df0 5407a49e2205257ae1f091b81f3f80a3daf30d26 && git checkout -B drift-repro 7ffc57846a97afde24fbfc59b6b05d7562ef4df0 && git merge --no-ff 5407a49e2205257ae1f091b81f3f80a3daf30d26

node scripts/docs-audit/affected-docs.mjs --json 7ffc57846a97afde24fbfc59b6b05d7562ef4df0

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7ffc57846a97afde24fbfc59b6b05d7562ef4df0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #23564 at 5407a49e22 (#23532). It lands through the queue once every check on this head is green

domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-15T00:40Z · holder of claim 6180882480. Report: os-dev-report 6181656088. Thread-read: 6181656088.

席位核对

  1. Shape, read on GitHub:
    • The PR is a draft against main, head 5407a49e22.
    • Body line 1 is Fixes #23532, the only closing keyword. Line 2 is a bare, line-initial Clause-②: no.
    • Assignee os-bill. Labels: documentation, size/m, tests, tooling (the labeler's).
    • Files: 3, +198 / −1: packages/adapters/hono/src/index.ts, packages/adapters/hono/src/hono-thrown-message-disclosure.test.ts, .changeset/23532-hono-undeclared-500-leak-filter.md. That is inside the claim's file surface. declaredEnvelopeForThrow is not edited.
    • check-governed-merges --pr 23564: NOT governed, 199 changed lines.
    • No content/docs/releases/ change.
  2. Premise, measured by the seat on origin/main before dispatch, and the hunk read against it:
    • Before the PR, thrownJson's fallback shipped err.message unread (index.ts:536 at 086fa4640f).
    • The hunk asks looksLikeInternalErrorLeak(message) only when status >= 500, and replaces the message with INTERNAL_ERROR_MESSAGE ('Internal server error', packages/types/src/error-leak.ts:38), the twins' constant. It adds no literal. Status and code are unchanged.
    • The predicate's no such (table|column): limb and its insert into prefix limb cover the card's probe. The dev's report measured that on the real string.
  3. Tests and assertions:
    • 7 cases over the real createHonoApp, with the package's stubbed dispatcher.
    • Each refusal case asserts status and the whole { code, message } envelope (code INTERNAL_ERROR, or SERVICE_UNAVAILABLE at 503). The body is also scanned for ghost and no such table.
    • The controls cover a plain boom (kept) and a 4xx (kept).
    • Ablation (report 6181656088): with the filter removed, 4 of 7 cases fail. With the 5xx bound removed, only the 4xx pin fails. Restoration was proven by blob hash.
    • The card's real-host probe was run before and after, in a one-shot rig deleted by trap, and the before matched the card byte for byte.
  4. Gates:
    • Local union: --ran reconciles 63 derived, 63 run, 0 unrun. eslint covers the two .ts files and packages/adapters/hono.
    • CI on this head, read in this act: 37 check-runs, 17 success, 6 skipped by selector, 14 in_progress (Lint ×5, Type Check ×3, Test Core 1–6/6), none failed. Landing waits for every one of them (landing-operations.md B).
  5. Accepted deviations:
    • main was not merged before the PR opened. The two new main commits are disjoint, and the queue rebuilds on main.
    • A shallow git fetch of a gate's pinned control commit went into the shared object store (no ref moved).
    • A one-shot rig file was written and deleted outside the surface, never staged.
    • The PR body ends with two footers, the platform's one-time append. ⛔ It is not re-patched.
  6. Out of scope, and where each item goes:

Landing: contract review is not owed. No packages/spec path is in the diff, the claim declares Clause-②: no, no governed path is touched, and nothing narrows an accept set: only a 5xx message is withheld. So the gate is CI plus this seat's read. Once every check on 5407a49e22 (or a later head) is green, the seat flips it ready and arms auto-merge through the relay. #23531 (seat 2) edits declaredEnvelopeForThrow in another file; the queue arbitrates any semantic overlap.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 00:54
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 00:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit fa6a608 Oct 15, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23532-hono-undeclared-500-leak-filter branch October 15, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants