Skip to content

The runtime dispatcher serialises a PermissionDeniedError's details to the client, so positions / permissionSets reach the browser on the /data transport #7450

Description

@os-zhuang

Found while implementing #7414 (PR #7449). Filed separately because fixing it means changing what a 403 body carries, which #7414 explicitly ruled out of scope (that card is user-facing copy only, structured payload untouched).

What was measured

plugin-security's object gate attaches a structured payload to every PermissionDeniedError:

throw new PermissionDeniedError(
  message,
  { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
);

The two transports treat that payload differently, and only one of them was checked when #7414 was written.

@objectstack/rest — mapDataError, packages/rest/src/rest-server.ts:726-737:

return {
    status: 403,
    body: {
        error: error?.message ?? 'Permission denied',
        code: 'PERMISSION_DENIED',
        ...(object ? { object } : {}),
    },
};

error.details is never read. Nothing structured reaches the client, and the object that does ride is the object the route named, not error.details.object.

@objectstack/runtime — http-dispatcher.ts:1945-1951:

} catch (e) {
    if (isPermissionDeniedError(e)) {
        return {
            handled: true,
            response: this.error(e.message, 403, { code: 'PERMISSION_DENIED', ...(e.details ?? {}) }),
        };
    }
    throw e;
}

this.error hands that object to apiErrorResponse / buildApiError (packages/runtime/src/error-envelope.ts), which promotes the string code into error.code and puts everything else on the wire as error.details. /data is served by this dispatcher (the domain registry, packages/runtime/src/domains/data.ts), and that domain handler does not catch — so a permission denial on an ordinary CRUD request answers with error.details.positions, error.details.permissionSets, error.details.operation and error.details.object.

So on that transport a client is told the caller's position names, the names of the permission sets attached to their context, and — on a cascade delete, where cascadeDeleteRelations re-authorises each child independently — the API name of a child object the caller never addressed.

Why this matters beyond tidiness

#7414's "Expected" section asserted that the structured fields "are the right channel for the machine detail, and they are already there", and PR #7423 (the sibling DELETE_RESTRICTED card) justified shipping a developerMessage over the wire on the premise that the envelope already carried the same API names. Both readings assume one answer to "where do these fields land". There are two, and they disagree. PR #7449 declined to ship a developer sentence on either transport for exactly this reason, but it deliberately left the existing details disclosure standing because narrowing it is an envelope change, not a copy change.

What a fix would have to decide

  1. Is positions / permissionSets intended client-facing detail at all, or server-side diagnostics that leaked through a generic spread? (operation and object are far less sensitive — the caller supplied both, except on a cascade child.)
  2. If they are not: narrow the spread at the dispatcher (allowlist rather than ...e.details), which is a wire-visible change for any consumer already reading them.
  3. Either way the two transports should agree on what a PERMISSION_DENIED body carries. Today one carries nothing structured and the other carries everything.

Related: #7270 (runtime re-declares PermissionDeniedError with nothing enforcing the identity) touches the same class from the type side.

Reproduction pointers

  • packages/runtime/src/http-dispatcher.ts — the catch at the end of dispatch()
  • packages/runtime/src/error-envelope.ts — splitSemanticCode / buildApiError
  • packages/runtime/src/domains/data.ts — no local catch, so the gate's throw reaches the dispatcher catch
  • packages/rest/src/rest-server.ts — mapDataError's PERMISSION_DENIED branch, for the contrast
  • packages/rest/src/rest.test.ts — the pin PR fix(plugin-security,spec): PERMISSION_DENIED stops showing developer copy to end users (#7414) #7449 added for the REST half

Activity

  1. claude commented on Aug 10, 2026

    @claude
    Contributor

    Triage: escalated to the maintainer's decision box (needs-user-decision) + routed domain:cli.

    Classification rationale — the card's own fix section is three decisions, and the first one ("are positions / permissionSets intended client-facing detail at all?") is a public-contract call: narrowing the dispatcher spread is a wire-visible change for any consumer already reading error.details, and the two transports currently disagree about what a PERMISSION_DENIED body carries. That is exactly the class this seat does not decide.

    Premise verified on origin/main @ 8a9c079 (all anchors read, not inferred):

    • packages/runtime/src/http-dispatcher.ts — the dispatch catch returns this.error(e.message, 403, { code: 'PERMISSION_DENIED', ...(e.details ?? {}) }), so the full details object (positions, permissionSets, operation, object) goes to buildApiError and onto the wire as error.details.
    • packages/rest/src/rest-server.ts (mapDataError 403 branch) — carries only error / code / route-derived object; error.details never read. The divergence is real.
    • packages/runtime/src/error-envelope.ts:100/:117 — splitSemanticCode / buildApiError promote code and pass the rest through as details.
    • packages/runtime/src/domains/data.ts — zero catch/PermissionDeniedError hits (falsified against a control grep on the same file), so the gate's throw reaches the dispatcher catch as described.

    Options for the ruling:

    • A (recommended): rule positions / permissionSets server-side diagnostics — allowlist the dispatcher spread (at most operation + object; note the cascade-child object is itself the sharper leak) and log the rest. Wire-visible narrowing; aligns the runtime transport down to what REST already ships.
    • B: rule them intended client-facing — document the shape in the contract and make REST carry the same fields, so the transports agree the other way.
    • C: accept the divergence and document it (no code change).

    Severity note: not graded target:v17 by this seat — the payload is mostly the caller's own context (their positions / permission sets), but the cascade-child object name is third-party information. If the maintainer reads A as a genuine disclosure, the grading question should be answered with the ruling.

    Routing — both fix shapes land in packages/runtime + packages/rest (⇒ domain:cli under the lane table); routing does not prejudge the ruling here, unlike #7401.

    Dedup — checked against the open 403 family: #7414 / PR #7449 (copy, in flight), #7451 (copy sweep remainder), #7366 (console renders vs discards the message — the human-readable half of this same decision surface, cross-link), #7270 (type-side re-declaration, pm:dispatched), #6736 (bulk-path pre-image gate). No duplicate: this is the only card about the structured details payload on the wire.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 11, 2026

    @claude
    Contributor

    Maintainer ruling — 2026-08-11. From the four-lens decision review (platform long-term coherence / measured business pull / AI-agent error-resistance / startup scope discipline); the maintainer accepted the recommendation set in full.

    Ruling: align the runtime dispatcher with REST — error.details (positions, permissionSets) must NOT be serialized to the wire; internal authorization topology stays server-side. REST's mapDataError shape (message + code + object) is the contract for both transports.

    This is the disclosure half of the 403 family and rides the release board: target:v17 applied (triage seat is the label producer; class ② declared-vs-enforced plus an information leak). State: needs-user-decision → pm:queue + target:v17.

    Recorded by the triage seat Routine (#5474 pilot) on the maintainer's direct instruction — ruling record, not a claim.


    Generated by Claude Code

  3. self-assigned this
    on Aug 11, 2026
  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim — PM loop domain:cli, session session_0158ZQo7LiHSxGWpYKuPq1wu (os-help seat, #6024), wave 5.

    Dispatching on the maintainer's 2026-08-11 ruling (comment 5248469105, option A): the runtime dispatcher aligns with REST — positions / permissionSets must not be serialized; REST's mapDataError shape (message + code + object) is the contract for both transports. The ruling settles what triage correctly refused to decide, so this is now an implementation card, not a decision.


    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    os-dev report — issue #7450 · branch claude/issue-7450-dispatcher-details-allowlist · draft PR #7520 · session session_01PLXNUARnwCaXaQ9xg4ZBhe

    {
      "marker": "os-dev-report",
      "issue": 7450,
      "pr": 7520,
      "branch": "claude/issue-7450-dispatcher-details-allowlist",
      "status": "implemented",
      "ruling_implemented": "A — runtime dispatcher aligned to REST (message + code + route-derived object)",
      "files_changed": [
        "packages/runtime/src/http-dispatcher.ts",
        "packages/runtime/src/security/permission-denied-envelope.ts (new)",
        "packages/runtime/src/security/permission-denied-envelope.test.ts (new)",
        "packages/runtime/src/domains/data-permission-denied-envelope.test.ts (new)",
        ".changeset/dispatcher-permission-denied-details-allowlist.md (new)"
      ],
      "packages_rest_touched": false,
      "gates": { "lint": "pass", "typecheck": "pass", "runtime": "1990 passed", "rest": "1341 passed", "adapters-hono": "73 passed", "plugin-security": "922 passed", "plugin-hono-server": "187 passed", "client": "279 passed" }
    }

    ⚠️ The dispatch was right to demand a measurement — and one card premise did not survive it

    The card (and triage's verification) says a /data denial "answers with error.details.positions, error.details.permissionSets…". Measured on today's main, it does not — and the reason changes what the fix had to be.

    dispatch()'s domain-registry branch returned its handler's promise without awaiting:

    if (domainRoute) {
        return domainRoute.handler({ path: cleanPath, method, body, query }, context);  // ← no await
    }

    In an async function a bare return <promise> settles outside the enclosing try. Probed both halves directly, green: isPermissionDeniedError matches the fixture (so the matcher was not the problem), and async () => { try { return rejectingPromise } catch {…} } rejects rather than catching. Every domain that can raise an object-gate denial resolves through that branch; the only awaited branch inside the try is /discovery. The dispatcher's PERMISSION_DENIED arm was unreachable in practice.

    The denial escaped to the Hono catch-all (packages/adapters/hono/src/index.ts:431) instead, which answers { success: false, error: { message, code: 403 } } — a numeric code, the #3842 shape error-envelope.ts exists to prevent, and no PERMISSION_DENIED string.

    Scope consequence: narrowing the spread alone would have been a no-op on the wire. /data would have kept answering the adapter's shape and the ruled contract still would not hold on this transport. So the PR does both, and they are inseparable — awaiting alone would have started shipping exactly the payload the ruling withholds. Triage's anchor reads were all correct; the reachability of that catch was the one link nobody measured.

    The leak was therefore latent, not live, on the Hono /data path. The ruling stands unchanged either way — this narrows what the now-reachable catch may say. Flagging for the PM in case it changes the target:v17 severity call.

    What I chose for the cascade child, and why

    cascadeDeleteRelations (objectql/src/engine.ts:8669) re-enters this.delete(childName, …) per child, so a cascade denial carries details.object === <child> — third-party information wearing the shape of the caller's own input.

    Chosen: the response's object is derived from the request path; error.details contributes nothing at all. Not a filter over details — an independent source, permissionDeniedErrorDetails(cleanPath), whose signature makes it impossible for any field of error.details to reach the body (pinned as a test). Same derivation REST uses (req.params?.object).

    This is exactly what the dispatch warned about: mutation M3 implements the "allowlist operation + object" reading, reaches the ruled field set, and still answers app_child_object. Two tests catch it.

    operation is dropped too — the ruling names REST's shape, and REST ships no operation; carrying it on one transport only rebuilds the divergence the card exists to close.

    Nothing is discarded: the full payload (incl. the cascade child) goes to a server console.warn line.

    Mutation table — each new test proven able to fail

    # Mutation Result
    M1 restore { code, ...(e.details ?? {}) } 4 failed
    M2 drop the await (= main's behaviour) 5 failed
    M3 forward e.details.object — the naive allowlist 2 failed (cascade + parity)
    M4 drop the diagnostics log 1 failed
    M5 unanchor the /data match 1 failed
    M6 describeDeniedDiagnostics returns a constant 1 failed
    M7 always emit object 1 failed

    Reverted after each; final state 14/14 green. Every fixture carries a fully populated details + developerMessage, so the unfixed code genuinely leaks under them (M1/M2 confirm).

    Constraints honoured

    Ending my turn here — not waiting on CI, not arming auto-merge, per dispatch. (Note: the harness auto-subscribed this session to PR #7520 activity.)


    Generated by Claude Code

  6. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    os-dev report — amendment (CI follow-up on PR #7520, commit ab1d0a7). One detail of the report above is now out of date.

    The TypeScript Type Check gate went red on the first push, and it was right. My cross-transport parity test imported mapDataError at its declaration (../../../rest/src/rest-server.js) so both real mappers ran over one error. tsc follows that relative path: it pulled eleven of @objectstack/rest's modules into @objectstack/runtime's program outside its rootDir, adding 13 raw errors (TS6059 ×12, TS7006 ×2) to the runtime TEST_DEBT ledger — recorded 227, measured 240. That ledger is a ratchet and may only shrink.

    Both escape hatches were wrong for this card: raising the ledger launders a self-inflicted regression, and re-exporting mapDataError from packages/rest's public entry widens the reference package's API surface to buy this card a test — which the dispatch's "rest is not an edit target" rule exists to prevent.

    Fix: parity is now pinned by two tests over one fixture rather than a live cross-import. packages/rest/src/rest.test.ts (PR #7449) asserts what mapDataError produces for that fixture; the runtime test asserts the dispatcher agrees, field by field, against a transcribed constant that names its source. Change either side's shape and the other side's pin fails. The rewritten case also checks more than the original: error.details must hold the route object alone, and the error member may carry no key beyond code / message / httpStatus / details.

    Re-measured: 226 raw errors in the runtime test layer — at or under the 227 ledger, zero attributable to these files. pnpm lint clean, runtime 1990 passed. Mutations M1 and M3 re-verified against the rewritten test: M1 still fails 4 cases, M3 (the naive e.details.object allowlist) still fails the cascade case and the parity case.

    Everything else in the report above stands unchanged — the ruling implementation, the route-derived object, the await finding, and the rest of the mutation table.

    packages/rest remains untouched.

    Still ending my turn without waiting on CI or arming auto-merge, per dispatch — the PM owns convergence. Noting only that the harness auto-subscribed this session to PR #7520, so a further CI failure would wake it.


    Generated by Claude Code

  7. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    ACCEPT — PR #7520, head ab1d0a77f. Implements the maintainer's 2026-08-11 ruling (option A) as written.

    The cascade-child question — the part I said would be easy to satisfy in letter and miss in substance — was answered correctly and for the right reason. The route object is derived from cleanPath, not from error.details: cascadeDeleteRelations re-enters delete() per child, so a cascade denial's details.object names a child the caller never addressed, and forwarding it would have landed inside the ruled field set while still disclosing a third party's API name. The catch now reads no field of error.details at all — which is a stronger invariant than an allowlist, because it cannot rot as fields are added upstream. The withheld payload goes to a server log line: diagnostics, not garbage, as required.

    The dev found a latent defect that made this card partly a no-op, and it is the most consequential thing in the PR. The domain-registry branch returned its handler's promise without awaiting, so a rejection settled outside the enclosing try and never reached that catch. Every domain that can raise an object-gate denial resolves through that branch — so the PERMISSION_DENIED arm was unreachable in practice, and denials escaped to the Hono catch-all, which answered a numeric code and no PERMISSION_DENIED string. The critical observation is the sequencing: awaiting alone would have started shipping the leak, so the two changes must land together. A narrower reading of this card would have "fixed" a branch nothing reached and left the real behaviour untouched.

    It also caught its own ratchet violation rather than raising the ceiling. The first parity test imported mapDataError across the package boundary; tsc followed it and pulled eleven @objectstack/rest modules into runtime's program outside rootDir, taking TEST_DEBT from 227 to 240. The TypeScript Type Check gate caught it, and the fix was to re-pin parity as two tests over one fixture — rest.test.ts asserts what mapDataError produces, this file asserts the dispatcher agrees field-by-field against a transcribed constant naming its source, so changing either side fails the other's pin. Measured back to 226, at or under the ledger. ⛔ No debt was ratcheted up to buy a test — exactly right, and it means a disclosure fix did not widen the reference package's surface.

    Ready flipped, auto-merge armed; queue membership verified by branch before any flip, per the conditional prescription.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions