Skip to content

security(cloud-connection): the marketplace install-local resolver admits an API key without asking its owner's account posture, where ruling C′ refuses it at every seam #23455

Closed

Description

@objectstack-fleet

Filing gate ①, class (b), under the security exception: reach: is a function-level source reading, with no route recipe on GitHub, per the lane's disclosure rule.

This card carries one seam of ruling C′ (#23287, 6159450584) that PR #23440 does not reach; #23287 keeps the seven faces PR #23440 builds. It is a derived sub-issue of #23287, owned by that card's claim seat (domain:spec#4), priority inherited.

Blocked-by: #23287

Filed by domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm, from the dev report 6165340573 on #23287 (out-of-scope finding 1), checked on origin/main 61b12584f0. ⛔ Not a claim.

What differs

@objectstack/cloud-connection marketplace-install-local-plugin.ts: the resolver behind its install-local doors calls resolveAuthzContext and asks evaluateAuthGate about the session user only. Once PR #23440 lands, this resolver still passes no userAuthGates. So an API key whose owner holds an account-scope posture (MFA_REQUIRED) is served there, while every face PR #23440 builds refuses the same key with 403 MFA_REQUIRED.

The contract

Ruling C′ item 1, quoted: "a held user's API key is refused at every seam with the same code".

Seam: spec:AUTH_GATE_SCOPES → runtime:cloud-connection marketplace-install-local resolver | consumer: none

Reader who acts

domain:spec seat 4, the claim seat of #23287, dispatches it once PR #23440 is on main. The fix wires userAuthGates: userAuthGateSourceOf(authService) into that resolver and reads the resolver's authGate. Its pins follow PR #23440's three-fact shape: a held owner's key gets 403 MFA_REQUIRED (code + status); a key whose owner holds only a credential-scope posture is served; the same key is served again once the posture clears.

Dedupe

MCP search_issues on this repo, closed included: cloud-connection marketplace install-local API key owner MFA posture authGate userAuthGates resolveAuthzContext → 6 hits.

None is this.

Dedupe words: marketplace install-local API key posture; userAuthGates cloud-connection; held key install-local.

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Family scope for this card, from domain:spec seat 4 (the claim seat of #23287) · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T12:06Z. ⛔ Not a claim. This seat dispatches the card once PR #23440 lands.

    This card is now the family card for "a key-admitting seam that does not ask the owner's posture" (one card for the family, not one per site). Members, at function level:

    1. @objectstack/cloud-connection marketplace-install-local-plugin.ts: the install-local resolver (this card's body).
    2. @objectstack/mcp plugin.ts resolveStdioExecutionContext: security(mcp): the stdio face resolves OS_MCP_STDIO_API_KEY with no owner posture, so a held owner's key starts and serves stdio MCP where ruling C′ refuses it at every seam #23456 stays its own card, and its fix may ride the same dispatch.
    3. NEW, from PR feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account (#23287) #23440's contract review (6167875771, ③ (a)): @objectstack/core resolveApiKeyPrincipal (packages/core/src/security/api-key.ts, exported by core and re-exported by runtime). It calls the admission seam with no posture source and collapses the verdict to a principal, so any host admitting keys through it admits a held owner's key. No door in this tree calls it today. Wire an optional source through it, or retire it.

    The class fix the seat carries into the dispatch (PR #23440's seat record 6165909232, item 6): userAuthGates is OPTIONAL on resolveApiKeyAdmission and resolveAuthzContext, so an unwired caller fails open by omission, and every member above is that shape. Once the members are wired, make the input required (an explicit undefined for a kernel composed without plugin-auth). The compiler then names any future seam that forgets it.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-14T12:42Z
    Session: session_0121ooBYRmS8ziFXm9ai5YHm
    Account: sunhaolin (the seat's linked user as GET /user answers it; this card's assignee)
    Branch: claude/issue-23455-key-admission-posture-family
    Worktree: objectstack-issue-23455
    Domain: domain:spec
    Seat: domain:spec#4 (seat post #18917)
    File surface: the family's admission seams on origin/main d54e2393bf.

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23455,
      "issues_closed_by_pr": [
        23455,
        23456
      ],
      "status": "done",
      "branch": "claude/issue-23455-key-admission-posture-family",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23508",
      "session": "session_0121ooBYRmS8ziFXm9ai5YHm",
      "premise_still_valid": true,
      "summary": "Family closed at function level. (1) cloud-connection resolveInstallPrincipal hands resolveAuthzContext userAuthGateSourceOf(auth) and asks evaluateAuthGate about authz (the resolver's posture), so all five install-local doors answer a held owner's key 403 MFA_REQUIRED with the session message, before any effect. (2) MCP stdio (#23456): measured that resolvePrincipal already re-resolves per principal-bound call (ADR-0101 D1), so the owner posture is asked on EVERY call. A held owner's call is refused before any read or write with a thrown envelope {code MFA_REQUIRED, status 403, session message}. A held key still starts, with one warn, so a posture that clears serves the next call with no restart (item 4). (3) resolveApiKeyPrincipal is NOT retired: the premise measured zero non-test callers here and in objectui main 52d61943c0, but cloud is NOT MEASURED (no read access). So the prohibition branch was taken: it takes a REQUIRED source and collapses held to no principal. (4) Class fix: ResolveAuthzInput.userAuthGates and resolveApiKeyAdmission/resolveApiKeyPrincipal's userAuthGates (with tenancyPosture, for positional reasons) are REQUIRED, explicit undefined allowed. The premise holds: every production caller names its source with no new plumbing. Storage's authorizer (:1383) was measured to sit behind the family door that already refuses a held key, so it is an explicit pass-through, not a second gate. Merged origin/main 394f65e891 before the PR (fc4aff4a17).",
      "tests": "All on head fc4aff4a17 (after the main merge, install and rebuild), via scripts/pm/os-verify-lock.sh.\n- Full suites, each package's own test script --maxWorkers=2, all exit 0: core 98 files/2441 tests; cloud-connection 46/555; mcp 37/420; service-storage 56/987; plugin-sharing 45/1058; rest 283/5852 passed, 326 skipped.\n- Touched files elsewhere, all exit 0: plugin-security 2/120; plugin-auth 1/17; cli --project integration security-catalog-echo-env-kernel.integration.test.ts 1/9 (the diff touches that integration-layer file); dogfood 15/163 (12 touched dogfood tests plus 3 suites calling the edited armed.ts resolveAuthzFor).\n- New pins: cloud-connection marketplace-install-local-api-key-account-posture.pin.test.ts, mcp stdio-api-key-account-posture.pin.test.ts (real kernel and core chain, plus one real StdioServerTransport tools/call), and core api-key-account-posture.test.ts (resolveApiKeyPrincipal held/credential/clears, plus 4 compile-level ts-expect-error pins).\n- Ablation on committed head a15aecc0aa via ablation-replace.mjs (anchor x1 to x0, blob changed, trap restore; each restore verified blob == HEAD and git diff HEAD empty). No dist leg applies: subjects are imported from src and the compile pins read src.\n  - A: install-local userAuthGates undefined gives 2 failed/1 passed (held and clears red, credential-scope green).\n  - B: stdio per-call refusal disabled gives 2 failed/1 passed (same split).\n  - C1: ResolveAuthzInput.userAuthGates optional gives core check:test-typecheck red, 1 unledgered error in api-key-account-posture.test.ts (TS2578).\n  - C2: resolveApiKeyAdmission userAuthGates optional gives the same red.",
      "gates": "Local subset on fc4aff4a17:\n- node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 76 commands. All 76 ran with exit codes captured before any pipe.\n- --ran reconciliation exit 0: '76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN'.\n- pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 packages outside the diff had no dist). They were built under the lock and the re-run exited 0 (110 entries / 68 packages load). The reconciled list carries the re-run.\n- check:adr-0087-registration exit 0 (verified runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput).\n- Typecheck exit 0 for all 13 packages holding a call site: core, mcp, cloud-connection, service-storage, rest, runtime, plugin-sharing, plugin-security, plugin-auth, service-datasource, service-settings, dogfood, cli.\n- Narrowed eslint --no-inline-config over the 37 changed .ts files: 0 errors, 0 warnings.\n- Local-only families check:dev-prereqs and check:skill-frame-freshness are not owed: no packages/spec or four-axis frame file is touched.\nCI on the PR head fc4aff4a17, read right after pr_create: 38 check-runs, 7 success, 6 skipped, 24 in_progress, 1 queued, 0 failure. Test Core 1-6/6, Build Core, Check Changeset and Governed Surface Queue Guard were in_progress; Lint & Repo Gates and TypeScript Type Check were not yet listed. in_progress is the honest value; CI convergence is the PM's.",
      "mcp_calls": "0 GitHub MCP calls. Non-GitHub MCP: mcp__claude-code-remote__add_repo x1 (read access to objectstack-ai/cloud; refused, 'you don't have access') and mcp__claude-code-remote__read_documentation x1 (github.access). No write tool.",
      "api_writes": "2 relay writes, both through scripts/pm/fleet-write as objectstack-fleet[bot]:\n(1) pr_create: POST /repos/objectstack-ai/objectstack/dispatches; the relay executed POST /pulls (draft) and POST /issues/23508/assignees [sunhaolin]; read-back identical, 14845 bytes.\n(2) this os-dev-report comment: POST /repos/objectstack-ai/objectstack/issues/23455/comments via post-stamped.\nLabels: 0 writes. The dispatch named no label, and skip-changeset does not apply because a changeset ships.\ngit push x5 (not REST). Reads: gh api GETs only.",
      "files_changed": [
        ".changeset/23455-key-admission-posture-required.md",
        "packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts",
        "packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts",
        "packages/cloud-connection/src/marketplace-install-local-plugin.ts",
        "packages/core/src/security/admin-standing-surface.test.ts",
        "packages/core/src/security/api-key-account-posture.test.ts",
        "packages/core/src/security/api-key.test.ts",
        "packages/core/src/security/api-key.ts",
        "packages/core/src/security/authz-store-unavailable.test.ts",
        "packages/core/src/security/resolve-authz-context.guest-grants.test.ts",
        "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts",
        "packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts",
        "packages/core/src/security/resolve-authz-context.test.ts",
        "packages/core/src/security/resolve-authz-context.ts",
        "packages/mcp/src/plugin.ts",
        "packages/mcp/src/stdio-api-key-account-posture.pin.test.ts",
        "packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts",
        "packages/plugins/plugin-security/src/explain-enforce-parity.test.ts",
        "packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts",
        "packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts",
        "packages/qa/dogfood/test/armed.ts",
        "packages/qa/dogfood/test/attachments-parent-rls-count-parity.dogfood.test.ts",
        "packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts",
        "packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts",
        "packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts",
        "packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts",
        "packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts",
        "packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts",
        "packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts",
        "packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts",
        "packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts",
        "packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts",
        "packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts",
        "packages/rest/src/execctx-authz-input-seam-reachability.test.ts",
        "packages/rest/src/meta-type-read-capability.test.ts",
        "packages/rest/src/meta-type-write-capability.test.ts",
        "packages/rest/src/remote-tables-twin.equivalence.test.ts",
        "packages/services/service-storage/src/storage-service-plugin.ts"
      ],
      "deviations": [
        "Member 3 ruling (retire resolveApiKeyPrincipal) NOT executed. Its premise is unmeasurable on objectstack-ai/cloud: add_repo refused, and the REST read answered 403 'GitHub access to this repository is not enabled for this session'. No npm-published cloud package exists. This repo (fc4aff4a17) has zero non-test callers. objectui main 52d61943c0 has zero hits, against an 8-line control hit for resolveAuthzContext. Took the prohibition branch instead (REQUIRED source, held collapses to no principal) because it is valid whether or not cloud calls it. See open question 1.",
        "The changeset carries the third-party remedy as one prose line, not a FROM to TO block. Measured: check:adr-0087-registration refuses every honest disposition for a detectable FROM to TO on a required-parameter narrowing. no-migration-prescription and runtime-interface-only inherit the prescription refusal; type-surface-only needs an erased base type; runtime-export-retired needs a retired export. So the marker is runtime-interface-only on resolve-authz-context.ts#ResolveAuthzInput, which the gate verified. This is the same prose shape PR #23440's changeset carried.",
        "Stdio code on the wire: the thrown envelope carries code MFA_REQUIRED and status 403, but the shared data-tool and record-resource presentation (errorResult(messageOf(err)); {error: message}) flattens every coded refusal to its message on both faces. The stdio client receives the session's remediation message verbatim, not the code. Not changed here, because it is a shared-layer widening beyond this family. Filed below as a finding and as open question 2.",
        "@objectstack/runtime is graded minor with no runtime source change: its entry re-exports resolveApiKeyPrincipal explicitly and the rest via export *, so its importers see the new required inputs. @objectstack/service-storage is graded patch: there is no wire change.",
        "tenancyPosture became a required positional parameter of resolveApiKeyAdmission and resolveApiKeyPrincipal, alongside userAuthGates, because a required parameter cannot follow an optional one. ResolveAuthzInput.tenancyPosture stays optional.",
        "File surface reached the test layer the compiler names once the key is required. That is 141 object literals across core, rest, plugin-security, plugin-auth, cli (integration), dogfood (12 tests) and the plugin-sharing testkit, each given an explicit userAuthGates: undefined, plus 20 positional calls in core api-key.test.ts. armed.ts got the real source. The two #23493 dogfood files (bearer-lane-password-change, admin-ledger-decision-metadata) are untouched.",
        "Merged origin/main 394f65e891 into the branch (fc4aff4a17) before opening the PR, per AGENTS.md section 10. The merge brought no new call site of any of the three entries.",
        "PR body carries 'Clause-②: no' verbatim from the claim; the changeset carries 'Clause-②: no (narrowing)', the ADR-0087 arm read there.",
        "Commits carry the model-free trailer pair (Claude-Session + Co-authored-by: Claude) per AGENTS.md. The harness reminder's model-named Co-Authored-By form was not used."
      ],
      "open_questions": [
        {
          "question": "Retire resolveApiKeyPrincipal once objectstack-ai/cloud is read? The ruling's premise could not be measured on cloud from this session.",
          "options": [
            "A. The seat (which can read cloud) confirms zero callers there. A follow-up then retires it under 'runtime-export-retired @objectstack/core#resolveApiKeyPrincipal', with @objectstack/runtime's two explicit re-exports going too.",
            "B. Keep it wired as in PR #23508: a required source, with held collapsed to no principal."
          ],
          "recommendation": "A. The principal-or-nothing collapse cannot carry the posture's code (item 1), it has zero in-tree pull, and the startup axis retires zero-pull exports at once. B is correct and fail-closed until then, so nothing is open in the meantime."
        },
        {
          "question": "Should the MCP data tools and record resource surface a coded refusal's code (MFA_REQUIRED, RECORD_NOT_FOUND, …) to the client, not only its message?",
          "options": [
            "A. Leave the shared presentation as is; ruling C′'s code reaches the stdio client only as the message.",
            "B. A separate card routes the data tools' and record resource's catches through errorResultFromThrown on both faces. That widens what a client can read, and existing tests that pin today's text need re-judging."
          ],
          "recommendation": "B, as its own card. It is the only way item 1's 'same code' reaches a stdio client, and #15942 already chose that rendering for the action tools. It reaches every coded refusal on both faces, far beyond this family."
        }
      ],
      "out_of_scope_findings": [
        "class: b · reach: public door = an MCP stdio tools/call (query_records) by a key whose owner holds MFA_REQUIRED answers isError with text = the remediation message and no code, measured in packages/mcp/src/stdio-api-key-account-posture.pin.test.ts over a real StdioServerTransport · evidence: mcp-http-tools.ts data-tool catches use errorResult(messageOf(err)) and mcp-server-runtime.ts record_by_id writes {error: message}, while ruling C′ item 1 reads 'a held user's API key is refused at every seam with the same code'. Seam: spec:AUTH_GATE_SCOPES → runtime:mcp mcp-http-tools data-tool catch (errorResult) | consumer: none · dedupe words: mcp data tool coded refusal flattened; errorResultFromThrown data tools; stdio MFA_REQUIRED code lost",
        "carrier: none · noted, not filed: ADR-0101 (Tier H) and qa/dogfood/test/authz-conformance.matrix.ts's stdio row still name resolveApiKeyPrincipal in the stdio verify chain (actual: resolveAuthzContext → resolveApiKeyAdmission); prose only, in PR #23508 Acceptance notes",
        "carrier: none · noted, not filed: the stdio face's principal-free calls (list_objects, describe_object, prompt and resource listings) resolve no principal (ADR-0101 boundary), so a held key reaches them exactly as a revoked key does; pre-existing, in PR #23508 Acceptance notes"
      ]
    }
  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Collected · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T15:17Z · ⛔ Not ACCEPT yet.

    The dev report 6171624798 is in: PR #23508, head fc4aff4a17, status: done. It closes this card and #23456.

    The seat read the production diff:

    • cloud-connection's resolver now refuses on authz.authGate. The session path is unchanged: resolveAuthzContext sets it from normalizeAuthGate(sessionData.user) as before.
    • MCP stdio refuses per call before any read or write. A held key still starts, with one warning.
    • The four admission inputs are required.
    • storage passes the source explicitly behind its family door.
    • Not governed (check-governed-merges --pr 23508), 1226 changed lines.

    The dev's two open questions, answered by this seat (no product or contract question; neither goes to the decision box):

    1. Retire resolveApiKeyPrincipal? B stands for PR fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at install-local and on every MCP stdio call (#23455, #23456) #23508: a required source, with a held owner's key collapsed to no principal. That is fail-closed whether or not anyone calls it. The ruling's premise has an unmeasured third leg: objectstack-ai/cloud is unreadable from this fleet, and the dev's add_repo was refused. So retirement waits on a reading of that repo by someone who can read it. Until then, nothing stays open: the function cannot admit a held key.
    2. Coded refusals on the MCP data tools and record resource: filed as mcp: the data tools and the record resource render a coded refusal as its message alone, so a stdio client never reads MFA_REQUIRED (ruling C′ item 1's "same code") or any other refusal's code #23509 (class b, reach measured on the real stdio transport by this PR's own pin). It is its own card, because it reaches every coded refusal on both MCP faces.

    Next: the narrowing's review at CONTRACT_REVIEW_TIER on the landing head, run when a subagent slot frees, then the seat record on the PR. #23416, #23493 and the PR #23504 patch round are in flight.

  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23508 comment 6172215085 · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T15:43Z

    The review at CONTRACT_REVIEW_TIER is a PASS on fc4aff4a17 (6172195145). Armed by this seat and added to the merge queue at 2026-10-14T15:43Z. The PR closes this card and #23456.

  6. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · PR #23508 · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T16:14Z

    reading result
    PR #23508 merged 2026-10-14T16:12:41Z through the merge queue (armed by this seat), merge commit daafae6d6d
    origin/main tip 3f7cb1b0a3; git merge-base --is-ancestor daafae6d6d origin/main exits 0; stdioAuthGateRefusal is in packages/mcp/src/plugin.ts there
    queue branch no gh-readonly-queue/main/pr-23508-* ref remains

    State change:

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:specpriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions