Skip to content

Two-tier component secret delivery + env declarations (#1550) - #1582

Merged
kriszyp merged 23 commits into
mainfrom
kris/component-secrets-1550
Jul 10, 2026
Merged

kriszyp merged 23 commits into
mainfrom
kris/component-secrets-1550

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 2, 2026 •

Copy link
Copy Markdown
Member

Closes #1550. Stacked on #1554 (kris/secrets-store-715) — the diff shows only the consumption side. Retarget to main after #1554 merges; do not merge #1554 via squash while this is stacked.

Summary

Consumption of the hdb_secret store, delivered in two tiers:

  • Global tier (rows with empty grants): decrypted at the start of each component-load cycle and materialized into the real process.env, before applications load. Precedence: a pre-existing real env var always wins (logged). Child processes inherit these — same semantics as .env today. No isolation promise.
  • Scoped tier (rows with non-empty grants): never in process.env — not inherited by child processes, invisible to env dumps. Exposed only through the per-component accessor: import { secrets } from 'harper' and scope.secrets. The accessor is a superset view: scoped rows granted to the component plus its declared global-tier names, so app code uses secrets.FOO uniformly and ops can later tighten a secret from global to granted without breaking the app.

Component configs declare env expectations in an env: block:

env:
  NODE_ENV: production                                        # string → inline literal into process.env (.env semantics, supports enc:v1:)
  STRIPE_SECRET_KEY: { required: true, description: Stripe }  # object → declaration, satisfied from the store
  SENTRY_DSN: { required: false }

An unsatisfied required: true declaration gates that component's load (reason: missing | ungranted | custody-unavailable); the instance keeps running and nothing of the gated component is registered. Literals are applied only after the whole block validates and the gate passes. get_components exposes the declared-but-unsatisfied set per component (unsatisfiedEnv: name/description/required/reason/tier — metadata only, never values) for Studio configure-me rendering.

Accessor binding (the investigated part)

  • vm, compartment, and the default vm-current-context loader: import { secrets } from 'harper' (and CJS require('harper') inside the app loader) resolves to the per-scope synthetic harper module (security/jsLoader.ts getHarperExports) — exact binding. This includes the default mode: the harper specifier is intercepted by resolveModule before Node resolution, so the symlinked package is not consulted.
  • native loader / bundler-loaded code / natively-loaded deps: those reach the process-wide singleton package, whose secrets export resolves the loading component via an AsyncLocalStorage context that scopedImport binds for the duration of each component load. Access from an ambiguous context (e.g. lazily at request time) fails loudly with an error naming the component-resolution problem — it never guesses.
  • Recommended idiom (documented in the module header): destructure at module top level — const { STRIPE_SECRET_KEY } = secrets; — where binding is exact in every mode.

JS-level scoping is documented as a slowdown layer, not a security boundary (shared process; containers/uids are the boundary; no SES hardening claimed).

Degraded modes

  • No custody registered (OSS core / material missing): required declarations report custody-unavailable; global rows log and stay out of env; node boots.
  • hdb_secret table missing (pre-upgrade): store treated as empty, missing reasons note the store is unavailable; node boots.
  • Late custody / changed secrets: heal on restart or reload cycle (each cycle re-reads the store; env-declaring component loads also refresh the snapshot so deploy validation in a long-lived worker gates against post-boot set_secret/grant_secret). No live re-materialization in v1.

Where to focus review

  • The secrets export is also an ambient global (via _assignPackageExport). Its loud-failure traps mean util.inspect/console.log of a container holding it (e.g. console.log(global)) outside a load context throws. Deliberate trade-off for "never silently hand back another component's secrets"; symbols and then are exempted so await/inspect probes don't explode.
  • Main-thread reload semantics: loadedPaths is never cleared in production, so already-loaded components early-return on main-thread reload; the declaration registries are therefore overwrite-on-reprocess (a cycle-level wipe would permanently empty get_components.unsatisfiedEnv — caught by the cross-model review leg).
  • Per-load store scan: each env-declaring component load re-scans hdb_secret (small system table, load-time only) to keep out-of-cycle gates fresh.
  • Gate containment: gate failure = status failed + log + early return; it deliberately does not install an ErrorResource at / (the pre-existing per-plugin catch does, which can clobber the root URL space — not repeated here).

Explicitly out of scope (follow-ups)

  • sensitive: false plaintext rows in hdb_secret (ops-managed non-secret vars with the same grants/declaration/UI machinery) — touches feat: hdb_secret store with grant-scoped secret operations #1554's operations while in review.
  • Live re-materialization on secret change (v1 = restart/reload heals).
  • Truly process-level vars (NODE_OPTIONS, UV_THREADPOOL_SIZE) — need container-level injection (the P3 surface), not the store.
  • env: in the root config is warn-and-ignore for now (root loads before custody registration/materialization; needs its own ordering story).
  • Docs: user-facing (env: block + accessor); companion documentation PR to follow when this retargets to main.

Tests

unitTests/components/componentSecrets.test.js (42: materialization/precedence/retraction, literal + enc:v1: semantics, declaration shapes, all three gate reasons, accessor visibility/superset/enumeration/collision, ALS binding incl. concurrent loads, loud ambiguous failure, proxy invariants) and unitTests/components/componentSecretsGate.test.js (2: loader-level gate containment — component fails, instance survives, literals land on the passing path). Existing loadEnv behavior untouched.

Cross-model review (adjudicated; all fixes in follow-up commits):

  • Codex leg — 6 findings, all confirmed and fixed: root-resource poisoning on gate failure, literal-before-gate env mutation, stale declaration registries, scope.secrets identity mismatch on RUN_HDB_APP paths, stale store snapshot on deploy-path loads, missing preventExtensions trap. Plus 1 valid residual on the first fix round (a cycle-level registry wipe vs. the never-cleared loadedPaths) — resolved with overwrite-on-reprocess semantics.
  • Gemini leg — 7 findings: 4 confirmed and fixed (concurrent-load scan/decrypt herd + out-of-order snapshot overwrite → single-flight materializeGlobalSecrets; gate rejecting an env fallback the accessor would serve for granted-but-undecryptable rows → gate now matches; Object.prototype names masquerading as secrets under dynamic access → null-prototype views). 1 documented-as-designed (env literals share process.env cross-component with load-order visibility — inherent to .env semantics, now stated in the module docs). 2 dismissed with evidence: the "Blocker" claim that AsyncLocalStorage context is lost during dynamic-import ESM top-level evaluation is empirically false on our supported Node range (guarded by a permanent regression test covering top-level read, destructure, and post-top-level-await); the catch (error) primitive-throw concern doesn't apply (the guarded block only throws Error instances and matches the loader's existing pattern).

Generated by Claude (Fable) with cross-model review by Codex and Gemini.

kriszyp and others added 8 commits June 7, 2026 18:20
Add an optional `registryAuth` array to deploy_component carrying private npm
registry tokens. The deploying node materializes a per-deploy 0600 `.npmrc`
(in a 0700 temp dir) that `npm pack`/`npm install` authenticate against, then
removes it; the token is held only in memory and that transient file. The token
is stripped from the request before replication and from the operations log, so
it never persists to config, hdb_deployment, the replication channel, or logs.
Peers reinstall the package via their own fabric-injected NPM_CONFIG_USERCONFIG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…d npmrc

Addresses two Codex review findings on the transient registry-auth path:

- A scope-less registryAuth entry now also emits a default `registry=` line so an
  unscoped package spec (npm:my-private-app) and its transitive deps resolve against
  the supplied private registry instead of silently falling back to npmjs (the token
  would otherwise never be used). Scoped entries still route only their @scope.
- writeTransientNpmrc now prepends any inherited npm_config_userconfig (e.g. a
  fabric-injected file with cluster registries, proxy, or cafile) and appends the
  transient auth last so it wins on conflict, instead of clobbering those settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address cross-model (gemini) review findings on the private-registry deploy
auth path, all defense-in-depth for the invariant that the token never
survives into a log/error path or replication:

- operations.js: strip req.registryAuth immediately after the Application ctor
  captures it, instead of after loadComponent. The prior strip ran only on the
  success path, leaking the token in req if prepareApplication/loadComponent
  threw. Removes the now-redundant later delete.
- Application.cleanupTransientNpmrc: wrap rm in try/catch so a failure (e.g. a
  Windows file lock) can't mask the original deploy error or skip
  broadcastDeployEnd; state is always cleared in finally.
- Application.writeTransientNpmrc: clean up a prior temp dir if called twice, so
  the earlier 0700 dir + token file isn't leaked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address claude[bot] review findings on the private-registry deploy auth path:

- operationsValidation.js: forbid CR/LF in registry and token. Both are written
  verbatim into the transient line-based .npmrc, so a super_user could otherwise
  inject arbitrary npm config lines (redirect scopes/registries, set other keys).
  Uses a newline guard rather than a strict URI validator because registry also
  accepts bare hosts and //host/ forms. Adds tests for both injection paths plus
  a bare-host case to pin that the guard doesn't over-restrict.
- Application.cleanupTransientNpmrc: also clear this.registryAuth so the
  plaintext token array can't surface in a later heap dump or error serialization
  of the Application instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…auth-core

# Conflicts:
#	components/operationsValidation.js
#	unitTests/server/fastifyRoutes/operationsValidation.test.js
…auth-core

# Conflicts:
#	components/Application.ts
#	components/operations.js

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a component-facing secrets management system with global-tier secrets materialized into process.env and scoped-tier secrets accessible via a per-component secrets accessor, alongside an env: configuration block to validate environment expectations during component loading. The review feedback identifies a critical issue where the process-wide secrets Proxy can cause crashes during global object inspection (e.g., by debuggers or logging tools) when accessed outside of a component-loading context, and suggests returning safe, empty values for the has, ownKeys, and getOwnPropertyDescriptor traps in such scenarios.

Comment thread components/componentSecrets.ts
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Comment thread index.ts Outdated
Comment thread unitTests/components/componentSecrets.test.js Outdated
kriszyp pushed a commit that referenced this pull request Jul 6, 2026
Replace the implicit 'empty grants => process.env' heuristic with an explicit
`processEnv: true` flag on the secret row, per review discussion (#1582): the
tier a secret is delivered on should be stated on the row, not inferred.

- schema: add `processEnv` attribute to system.hdb_secret
- set_secret: accept `processEnv` (defaults to the stored row so a value rotation
  preserves the tier); reject processEnv + non-empty grants (a processEnv secret
  is global, so scoping it is meaningless); force grants empty on processEnv rows
- grant_secret: reject scoping a processEnv secret (convert via set_secret first)
- list_secrets: surface the processEnv flag

Belongs to #1554 (the store PR); split out so it can move to that branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kris Zyp and others added 7 commits July 6, 2026 17:24
Consumption side of the hdb_secret store (#1554): rows with empty grants
are decrypted and materialized into the real process.env before
components load (pre-existing real env wins); rows with non-empty grants
never touch process.env and are exposed only through the per-component
secrets accessor (import { secrets } from 'harper' / scope.secrets),
which also carries the component's declared global-tier names.

Component configs declare env expectations in an `env:` block (string =
inline literal with .env semantics incl. enc:v1:; object = declaration
satisfied from the store). Unsatisfied required declarations gate that
component's load (missing | ungranted | custody-unavailable) while the
instance keeps running, and the declared-but-unsatisfied set is exposed
(metadata only) via get_components.

Under the vm/compartment loaders the accessor binds exactly via the
per-scope harper module; under the native loader the process-wide export
resolves through a component-load AsyncLocalStorage context and fails
loudly from ambiguous contexts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- gate failure no longer installs an ErrorResource at '/' (could clobber
  the root URL space of unrelated components); containment is status +
  log + absent URL space
- env literals apply only after the whole block validates and the gate
  passes (no partial process.env mutation from a gated component)
- load cycles reset per-component declaration state so removed
  components/env blocks don't leave stale accessor names or stale
  unsatisfiedEnv in get_components
- scope.secrets keys by ApplicationScope.name (grants identity), not
  Scope#appName, which diverges on RUN_HDB_APP paths
- env-declaring component loads refresh the store snapshot, so deploy
  validation in a long-lived worker gates against post-boot
  set_secret/grant_secret changes
- secrets proxy rejects preventExtensions/setPrototypeOf (freezing the
  shared proxy would break key-set invariants for all later consumers)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rocesses)

loadedPaths is never cleared in production, so already-loaded components
early-return on main-thread reload cycles — a cycle-level registry wipe
would permanently empty get_components' unsatisfiedEnv after the first
reload. Registries are overwrite-on-reprocess instead; deleted
components' state is unreachable from get_components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- single-flight materializeGlobalSecrets: concurrent component loads
  share one hdb_secret scan (no N-way scan/decrypt herd at boot) and
  two scans can never interleave (older scan can't overwrite newer
  state); sequential callers still get fresh reads
- gate accepts a real env var fallback for a granted-but-undecryptable
  row, matching what the accessor would serve at runtime
- accessor views are null-prototype so Object.prototype names can't
  masquerade as secret values under dynamic access
- document that env literals share process.env cross-component (load-
  order visibility, same as .env today)
- regression test: component binding propagates into native-loader ESM
  top-level evaluation (incl. destructure + post-top-level-await) —
  empirical guard for the ALS/dynamic-import claim (dismissed on
  evidence for our supported Node range)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing context

has/ownKeys/getOwnPropertyDescriptor now report an empty object when no
component-load binding is active, so inspectors/serializers (util.inspect,
spread, `in`) can never crash the process; direct property reads stay loud.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses Dawson's review comment: the per-component secrets view type was
defined inline in index.ts; export it as SecretsView from componentSecrets.ts
(next to the accessor it describes) and import it as a type in index.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ag (consumption)

Consumption side of the explicit-tier change (#1582): materialize a row into the
real process.env when `processEnv: true` rather than when `grants` is empty, and
key the declaration gate + accessor on the same flag. A row with neither
processEnv nor grants is now inert (visible to no component) until granted —
omission reads as restrictive, matching the review consensus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/component-secrets-1550 branch from 3c6c366 to d65a538 Compare July 6, 2026 23:24
@kriszyp
kriszyp requested a review from dawsontoth July 8, 2026 16:53
Base automatically changed from kris/secrets-store-715 to main July 8, 2026 17:55
Kris Zyp and others added 2 commits July 8, 2026 12:33
…o kris/registry-deploy-auth-secretref

# Conflicts:
#	server/serverHelpers/serverUtilities.ts
… (reference, not embed)

registryAuth entries may now name an hdb_secret row (`{ registry, secret }`)
instead of carrying a literal `{ registry, token }`. The token is resolved by
decrypting the referenced row on the deploying (main) thread, where the
operations API dispatches and the Pro secrets component registers custody —
so the credential lives in the replicated, audited secrets store (#1550/#1582)
rather than travelling in the operation body.

- operationsValidation: each entry is `token` XOR `secret` (secret uses the
  set_secret name grammar); the literal-token form is unchanged.
- secretOperations.resolveRegistryAuth(): decrypts secret-backed entries; literal
  tokens pass through untouched (no custody/store needed on that fast path).
  Authority mirrors the accessor model — the secret must be processEnv-global or
  granted to the component being deployed, else 403; missing row → 404; absent
  custody or a decrypt failure fails the deploy loudly.
- operations.deployComponent: resolves before constructing the Application; the
  resolved token gets the same transient handling as a literal token (transient
  .npmrc, stripped from req before replication, kept out of the ops log).

Peers still authenticate via fabric-injected NPM_CONFIG_USERCONFIG; origin-side
resolution keeps cluster behavior identical. Peer-side ref resolution (each peer
decrypting its replicated hdb_secret copy) is a possible follow-up.

Unit coverage: resolveRegistryAuth pass-through/resolve/global/mixed + all four
failure reasons; validator token-XOR-secret cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kris Zyp and others added 6 commits July 8, 2026 14:46
… every install

Completes the reference-not-embed story: a provided registry token is no longer a
transient, this-node-only credential — it is ingested into the encrypted, replicated
hdb_secret store and referenced everywhere, so package deploys survive rollback,
reboot, and new peers without the operator re-supplying the token.

- ingestRegistryAuth() (secretOperations): a literal `{ registry, token }` is sealed
  into hdb_secret (via set_secret, custody required) under a derived name
  (deploy.<component>.<registry>) granted to the component, and returned as a
  `{ registry, secret }` reference. Idempotent on rotation. Already-reference entries
  and (no-custody) literal tokens pass through untouched.
- deployComponent: ingests up front, then records references in TWO durable places —
  the component config (applicationConfig.registryAuth, read on every cold install) and
  the hdb_deployment row (registry_auth, the rollback source). req.registryAuth now
  replicates as references, never tokens; peers resolve from their own replicated
  hdb_secret copy (resolveRegistryAuth gains a bounded waitMs to cover the row arriving
  just behind the deploy op). No-custody core stays on the transient #1158 fallback.
- installApplications(): resolves applicationConfig.registryAuth at cold install so a
  fresh/wiped node or new peer authenticates from the store (best-effort — logs and
  installs without auth if custody isn't up yet, rather than blocking boot).
- assertApplicationConfig + deployment row + config type: registryAuth is references
  only; a literal token on disk is rejected.

Fabric NPM_CONFIG_USERCONFIG injection (harper-pro) becomes redundant for auth once
peers resolve from the store — a coordinated harper-pro follow-up removes the token
injection; core keeps userconfig *inheritance* for non-auth npm config (proxy/cafile).

Tests: ingest seal/round-trip/passthrough/no-custody/idempotent, derived-name
sanitization, resolve bounded-wait (times-out-404 + replicates-in-mid-wait),
assertApplicationConfig references-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esolve failures

Addresses PR #1717 review comments:

- buildNpmrcContent: enforce the no-CR/LF invariant at the .npmrc write
  boundary so it holds for tokens resolved from hdb_secret rows, not just
  validator-guarded literal tokens (cb1kenobi/Barber AI).
- resolveRegistryAuth: report no-custody as 503 and decrypt failure as 500
  instead of the ClientError default 400 — these are server-state, not
  client-fixable (cb1kenobi/Barber AI).
- resolveRegistryAuth: skip null entries for parity with the fast-path guard
  (gemini-code-assist; no 'SKIP' sentinel exists in this path).
- Tests: newline-guard rejection + status-code assertions on the two
  server-state failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hoist the throwaway InvalidRegistryAuthError classes (defined inline on
every assertApplicationConfig call) to named top-level classes,
InvalidRegistryAuthPropertyError and InvalidRegistryAuthEntryError,
matching the existing InvalidInstall*Error convention in the file. Also
fixes prettier formatting on secretOperations.test.js flagged by CI.
…cretref

feat: registry-auth via the hdb_secret store — persist provided tokens, resolve on every install
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread components/Application.ts
// hdb_secret row bypasses that guard; without this a `\n` in a secret value would inject
// arbitrary .npmrc lines (admin-only per the threat model, but the literal path already
// defends this class).
if (/[\r\n]/.test(token)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): the comment says "Enforce the no-newline invariant at the injection point so it holds for every source," but only token is guarded — registry is not. A manually-edited config or a hypothetical bypass path with an embedded \n in the registry string would survive normalizeRegistryUrl's .trim() and inject extra .npmrc lines (e.g. https://host/\nregistry=https://evil.com/ → an injected registry= line). Apply the same guard to registry:

Suggested change
if (/[\r\n]/.test(token)) {
if (/[\r\n]/.test(token)) {
throw new Error(`registry auth token for '${registry}' contains an illegal newline character`);
}
if (/[\r\n]/.test(registry)) {
throw new Error(`registry URL for token entry contains an illegal newline character`);
}

@kriszyp
kriszyp merged commit e8efacd into main Jul 10, 2026
49 checks passed
@kriszyp
kriszyp deleted the kris/component-secrets-1550 branch July 10, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-component process.env from granted secrets, with load-gating on declared secrets

2 participants