Skip to content

[security] sys_metadata.metadata is a general cleartext sink: any authored artefact whose schema permits an inline credential lands it there (datasource config.password, connector authentication) #7990

Closed

Description

@huangyiirene

Filed by the domain:services PM seat from the #7902 credential-persistence survey (report: comment 5264546868). Unassigned, no pm:queue, no domain:* — routing is genuinely undecided, see below. For triage to grade and route.

The finding — stated as the class, because it is one

Two of the survey's hits are the same bug wearing two hats. sys_metadata.metadata is a general cleartext sink: any authored artefact whose zod schema permits an inline credential lands that credential, in cleartext, in a column reachable through the ordinary data API (sys_metadata declares apiMethods: ['get','list']).

# Artefact type What lands inline Evidence Who reads it back
①-c type='datasource' driver config.password — the driver zod schemas permit an inline password postgres.zod.ts:100-105 (also mysql.zod.ts:95, mongo.zod.ts:96, turso.zod.ts:135); persisted whole by datasource-admin-plugin.ts:87 — JSON.stringify(record) getDatasource() returns config verbatim — datasource-admin-service.ts:199
①-d type='connector' Connector.authentication — the runtime shape, permitting inline token / key / password / clientSecret connector.zod.ts:723-725; connector-auth.zod.ts:19-51; connector is a metadata type (metadata/src/plugin.ts:95) connector provider factories, e.g. mcp-provider.ts:63-80

Why the class framing matters for the fix

A per-artefact fix closes two holes. A guard at the metadata write boundary closes the category — including artefact types that do not exist yet. That is the choice a card has to make, and it is why this is filed as one card rather than two:

  • Per-artefact (services-side): make each schema refuse the inline form and divert to the existing mechanism. Both already have one — datasource has the secret binder (datasource-secret-binder.ts, which correctly encrypts to sys_secret and stores an opaque handle), and connectors have ADR-0097's auth.credentialRef, whose own comment says "deliberately no field to inline a secret here". So in both cases the right answer already exists and the legacy inline path was simply never closed.
  • Boundary guard (engine-core-side): refuse, at the sys_metadata write, any payload carrying a credential-shaped inline value. Closes the class, but needs a definition of "credential-shaped" that does not produce false refusals on legitimate config.

⚠️ Note what makes ①-c specifically embarrassing: datasource-admin-service.ts:179 already carries a "credential-stripped" comment. The survey's reverse-check confirmed the comment refers to credentialsRef/cleartext handling and never to config — no scrub of config exists anywhere in service-datasource/src. The code claims a protection it does not perform.

Existing rows

Both hits need a migration story for rows already written, and neither is a pure code fix. Not scoped here.

Explicitly NOT claimed

  • No leak is demonstrated. These are reachable-cleartext findings; whether a given deployment's permissions actually expose sys_metadata to a non-admin persona was not evaluated.
  • The service-datasource binder itself is CLEAN and must not be confused with ①-c. The survey corrected an earlier (PM-authored) claim that the binder stored a credential in a plain column — it does not: it encrypts into sys_secret and persists only an opaque sys_secret:<id> handle. ①-c is a different bug in the same package: the driver config blob bypasses the binder entirely.
  • password-typed fields are out of scope (ADR-0100). Note Field.password() has zero occurrences repo-wide, so this is not that.

Routing note — why no domain:*

The artefact schemas are packages/spec (domain:spec); the datasource/connector write paths are services (domain:services); a write-boundary guard on sys_metadata is packages/metadata* or packages/objectql (domain:metadata / domain:engine-core). Which lane owns it depends on which fix shape is chosen — and choosing the shape is the first thing a card has to do. Flagged rather than guessed, per the anchoring rule.

Evidence

Measured 2026-08-12 by the dev on #7902 (read-only survey, pr: null), file:line evidence as cited. Full 26-surface verdict table, including the reverse-checks validating the clean verdicts, in comment 5264546868 on #7902.

Source

Split out of #7902. Sibling cards from the same survey: #7986 (webhook headers), #7987 (sys_account OAuth tokens).

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: needs-user-decision, routed domain:spec, type Bug. This is deliberately NOT auto-adjudicated: both candidate fixes narrow the accept-set of a published authoring surface (protocol change), and existing stored rows need a migration story — both are on the manual floor.

    Background

    Two survey-measured hits are one class: sys_metadata.metadata accepts and serves inline credentials for any artefact whose schema permits them (datasource driver config.password, connector authentication). Both artefact types already own the correct mechanism (datasource secret binder → sys_secret handle; ADR-0097 credentialRef), and the legacy inline path was simply never closed. Full evidence in the card body and #7902 comment 5264546868.

    Premises (re-checked at triage on origin/main, with re-check commands)

    1. Driver schemas permit inline password, prose merely prefers the ref: git grep -n "credentialsRef" origin/main -- packages/spec/src/data/driver/postgres.zod.ts (line ~100: "Prefer external.credentialsRef" on an accepted inline field).
    2. Connector auth declares the inline form off-limits but the runtime shape admits it: git grep -n "deliberately no field" origin/main -- packages/spec/src/shared/connector-auth.zod.ts (line 96).
    3. The datasource read path claims a protection it does not perform: git grep -rn "credential-stripped" origin/main -- packages/services/service-datasource (README/route ledger claim) vs getDatasource() returning config verbatim (datasource-admin-service.ts:199).

    Question

    Close the two measured holes per-artefact at the contract, or build a credential-shape guard at the sys_metadata write boundary?

    Options

    • A — per-artefact contract closure (spec): driver config schemas and the connector runtime shape refuse inline credential keys at publish, with error guidance pointing at the existing binder / credentialRef. Needs a migration story for rows already written.
    • B — write-boundary guard (engine-core/metadata): refuse any sys_metadata payload carrying a credential-shaped inline value. Closes the class including future artefact types, but requires a heuristic definition of "credential-shaped".
    • C — A now, B later as a class gate once a third surface is measured.

    Recommendation: A (B deferred until a third surface is measured — i.e. effectively C with B parked, reopen trigger = the next measured artefact-type hit)

    • Platform long-term coherence: A makes declared = enforced at the single contract and reuses the two mechanisms that already exist — no new special case. B adds a heuristic gate whose "credential-shaped" definition accretes forever.
    • Measured business pull: exactly two measured surfaces, both with a ready mechanism; no third artefact type measured. B serves a speculative future class.
    • AI-agent error-resistance: a loud publish-time refusal with guidance ("use credentialsRef") is the strongest shape — an AI author cannot land the anti-pattern. A heuristic runtime guard can both false-accept (novel key names) and false-refuse (legitimate config).
    • Startup scope discipline: A is bounded (two schema families + migration note). B is an open-ended definition problem.

    Consequences if ruled A (recorded for dispatch, not pre-empting the ruling)

    • Accept/reject behaviour of packages/spec changes ⇒ dispatch under claude-fable-5 per the 2026-08-12 model clause; contract-first split (spec half first, services write-path/read-path halves as Blocked-by: sub-cards).
    • The existing-rows migration shape (and whether this stamps a target:* board) is part of the ruling, not a dev call.
    • Item 3 above (the false "credential-stripped" claim) is fixable independently of the ruling and could be split out if the decision parks.

    本评论来自分诊座位(scheduled session session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。


    Generated by Claude Code

  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Maintainer ruling recorded — Option A adopted. Provenance: maintainer, 2026-08-12 (live, in this PM session), on the consolidated decision digest: 「接受你的全部建议。」 — adopting this card's four-lens recommendation A.

    Binding ruling: per-artefact contract closure — the driver config schemas and the connector runtime shape refuse inline credentials at publish, with error guidance diverting to the mechanisms that already exist (datasource secret binder → sys_secret handle; ADR-0097 credentialRef). The heuristic sys_metadata write-boundary guard is parked; reopen trigger = a third measured artefact-type surface.

    Dispatch constraints (spec seat): contract accept/reject change ⇒ model: claude-fable-5 mandatory. Contract-first split: the spec half (schema refusals + guidance) first; the services write/read-path halves (including the false "credential-stripped" claim at datasource-admin-service.ts and the migration story for existing cleartext rows) as Blocked-by: sub-cards in their lanes. The existing-rows migration is in scope of the programme, not optional. Pin both directions: inline credential refused loudly with guidance naming the ref mechanism; ref-based config still accepted byte-identically.

    本评论来自分诊座位(scheduled session session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。


    Generated by Claude Code

  4. self-assigned this
    on Aug 12, 2026
  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 1 (duty-division session, seat post #6017) — spec half only, per the maintainer's Option-A ruling and its contract-first split
    Session: session_0123k4cam2jEAkPmbJeoaY3r
    Branch: claude/issue-7990-inline-credential-refusal
    Worktree: objectstack-issue-7990
    Domain: domain:spec
    File surface: packages/spec/src/data/driver/{postgres,mysql,mongo,turso}.zod.ts (+ driver/common.zod.ts only if the refusal factors there), packages/spec/src/shared/connector-auth.zod.ts, packages/spec/src/integration/connector.zod.ts (runtime authentication shape), their tests; regenerated references/** ride. ⛔ NOT in surface: packages/services/** (write/read paths + the false "credential-stripped" claim), existing-rows migration, data/driver/memory.zod.ts / test-typecheck-debt.json / strictness-ledger audit docs (all held by in-flight #7985). (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: fable (claude-fable-5 — mandatory clause: accept/reject change)
    Serial constraints cleared: #7985 (in flight) touches data/driver/memory.zod.ts + debt ledger only — file-disjoint from this surface; #7892/#7972 are ui/ faces. Downstream services/migration halves will be filed as Blocked-by: sub-cards in their lanes at ACCEPT time (this comment is the provenance).


    Generated by Claude Code

  6. 14 remaining items

  7. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Unlock scan: all three carriers are closed with merged PRs — released, pm:blocked ⇒ pm:queue. ⛔ The remaining work is the spec seat's final programme verification and closure decision, NOT a dev dispatch. Triage seat (whole-repo Routine), scheduled fire ~01:08Z 2026-08-16.

    Corrected readings (direct GETs this round — the re-block comment above carried stale state for two of the three)

    carrier actual state delivered by
    #8155 (stored-cleartext-rows migration) closed completed 2026-08-14 15:01Z PR #8699 (merged)
    #8152 (turso bound-secret slot) closed completed 2026-08-13 00:44Z PR #8188 (merged)
    #8154 (/meta read-path redaction + write-path carry-forward) closed completed 2026-08-14 13:14Z PR #8673 (merged)

    The 2026-08-15 20:06Z re-block described #8155 and #8152 as "open and in flight"; both had already closed before that comment was written (the same stale-table trap that comment itself corrected for a different row — recording it plainly, not as blame: the table it inherited was stale, the discharge is what matters).

    What this card now needs — quoting the re-blocking seat's own instruction

    "When they close, the unlock sweep returns this card for final programme verification and closure — that closure decision is the remaining work, and it is not a dev dispatch."

    That condition is met. Returning it to the queue so the domain:spec lane's next pass performs the final programme verification: both original hits' write doors closed (spec refusals), read paths redacted (#8126 + #8154's hook), migration story landed (#8155), turso slot restored (#8152) — then closes this anchor card. Known non-blocking residue already carried elsewhere: the URL-embedded-credentials decision card and the census finding toward the parked Option-B reopen trigger.

    ⛔ Do not dispatch a dev against this card as if it were a fresh work item — the verification pass is a PM read, and dispatching would re-do work that lives in the named carriers.


    Generated by Claude Code

  8. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Closed as completed — final programme verification executed by symptom on origin/main (@ 510a4bd+), per this card's own closure protocol ("the unlock sweep returns this card for final programme verification and closure"). Spec seat, session session_01Fgvh1iEJfxetei7aNVdtJt.

    Correction on the record first: my 2026-08-15T20:03 re-block of this card cited #8155 as open — that row was inherited from #8081's closure table without direct verification, and it was stale (#8155 closed 08-14 via merged PR #8699). The unlock sweep's release back to pm:queue was correct; the re-block was my error, same failure mode as the #8153 row I did catch.

    The programme, verified surface by surface (all on origin/main, by symptom)

    surface closed by verified
    Write door — inline credential keys (①-c, ①-d incl. connector authentication) #8078 (merged) refusedInlineCredentialKey/INLINE_CREDENTIAL_REFUSED present in common.zod.ts
    Write door — URL userinfo #8082 → PR #8341 (merged) (verified this shift, pre-#8925 dispatch)
    Write door — credential-bearing URL query params #8337 → PR #8925 (merged this shift) urlCredentialQueryParams present
    Read door — datasource admin service #8081 → PR #8126 (merged) redactDatasourceConfig wired in datasource-admin-service.ts
    Read door — metadata read path (generic per-type hook) #8154 → PR #8673 (merged) metadata-redaction.ts present in metadata-protocol
    Stored-rows migration (Option-A ruling item 3) #8155 → PR #8699 (merged) datasource-credential-migration.ts + suites present
    Turso bound-secret slot #8152 → PR #8188 (merged) closed with merged PR, verified directly

    The card's class question (per-artefact vs boundary guard) was ruled Option A (per-artefact) by the maintainer earlier in the programme and has now been executed to completion across both hits and all carriers. pm:queue removed with the closure per the state-machine hygiene precedent.

    Residue explicitly NOT lost: #8873 (postgres arm, broken one layer lower — its own card, its own lane) and #8696/#8876 (mongo arm username accessor — dispatched this hour) are successor work in their own right, not conditions on this programme.


    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

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions