Skip to content

feat: secret scopes (--scope), membership-only subsets of a profile#163

Draft
ojhermann wants to merge 3 commits into
cachix:mainfrom
ojhermann:feat/secret-scopes
Draft

feat: secret scopes (--scope), membership-only subsets of a profile#163
ojhermann wants to merge 3 commits into
cachix:mainfrom
ojhermann:feat/secret-scopes

Conversation

@ojhermann

Copy link
Copy Markdown
Contributor

Implements the membership-only scope design from #137. Opening as a draft: the config + CLI surface below is complete, tested, and matches the spec, but there's one open design question (the derive macro) I'd like your steer on before finalizing — details at the end.

Closes #137 once the macro question is settled.

What this adds

A [scopes] table names membership-only subsets of a profile's secrets, so a single service resolves only what it declares instead of the whole profile:

[profiles.default]
DATABASE_URL = { description = "Database", required = true }
API_KEY      = { description = "API key", required = true }
QUEUE_TOKEN  = { description = "Queue token", required = true }

[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]

[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
secretspec run   --scope api    -- ./api
secretspec check --scope worker
secretspec export --scope api --format dotenv

--scope (env SECRETSPEC_SCOPE) is on run, check, and export.

How it follows the design

  • resolve(profile, scope) = filter_by_scope(resolve_profile(profile)) — the filter is applied at resolve_profile_secret_names, the single worklist upstream of validation, planning, fetching, prompting, generation, and audit. Filtering there scopes all of them at once, and an unknown scope fails before any provider is touched.
  • Membership-only: a scope never changes required/default/providers/references/generation/as_path or the {project}/{profile}/{key} storage address. No per-scope overrides, so there's no profile × scope precedence.
  • No scope → the complete profile, unchanged.
  • Scope resolves the intersection of the merged profile and the scope's list, so a scope naming a secret a given profile doesn't declare just yields a smaller set rather than an error. A required secret the scope excludes does not block resolution.
  • Errors: selecting an undefined scope (lists the defined ones); a scope listing a secret no profile declares (static, at config-validation time).

The env-isolation detail you flagged

You noted run --scope must remove declared-but-excluded secrets the parent already holds. It does — via Command::env_remove, which overrides inheritance. Filtering only the injected map is not enough: the child inherits the real parent environment by default, so a value a devenv secretspec run (or a prior eval "$(secretspec export)") exported would otherwise pass straight through. There's an end-to-end test (run_scope_scrubs_an_excluded_inherited_secret_from_the_child) that exports an excluded secret into the parent, runs a child, and asserts the child sees an empty value while the scoped secret is still injected. As you said, this is secret minimization, not an authorization boundary — noted in the docs.

Tests

secretspec +12, all green (539 total): config parse/validate/overlay, resolution intersection, unknown-scope error, values-path resolution skipping an excluded required secret, and the parent-env leak test above.

Open question — the derive macro (why this is a draft)

declare_secrets! reads secretspec.toml at compile time, but --scope is a runtime selection, so the generated type can't be "the scope's fields." This PR takes option 1 from the issue thread: scope is a resolution-time filter only, the macro is left unchanged, and the typed SDK doesn't gain scope-awareness — which fully covers the CI/devenv motivating case. If you'd prefer the macro to emit scope-aware views (a union with membership, or one struct per scope), that's additive on top and I'll add it before this leaves draft. Your call on which way to point it.

🤖 Generated with Claude Code

@ojhermann

Copy link
Copy Markdown
Contributor Author

@domenkozar — draft, but there's one decision I'd like your steer on before I finalize, and it's the only thing blocking this from being review-ready.

The config + CLI surface is done and follows your #137 design exactly (membership-only, intersection semantics, no per-scope overrides, and the run --scope env scrub that removes excluded parent-exported secrets from the child). Tests are green.

The open question is the derive macro. declare_secrets! reads secretspec.toml at compile time, but --scope is a runtime selection, so the generated type can't just be "the scope's fields." I took option 1: scope is a resolution-time filter only, macro unchanged, typed SDK unaffected — which fully covers the CI/devenv motivating case. The alternatives (macro emits a union with scope membership, or one struct per scope) are additive and I'll build whichever you prefer before this leaves draft.

Which way would you like it pointed? That's the one answer I need to take this out of draft.

@ojhermann
ojhermann force-pushed the feat/secret-scopes branch from 46ffd79 to 9dfb677 Compare July 17, 2026 18:43
@domenkozar

Copy link
Copy Markdown
Member

I think we have to be careful here, there are many things at play and I'd like to spend a tiny bit more time exploring all the options we have before we commit to a solution.

Profiles are kind of mixing environments and scopes, while having only one level of inheritance.

It would be good to come up with ideal design and then see if we can fit it into the current one.

Sorry for dragging you into this one and then pulling the plug, but I'd rather do it once than twice.

@domenkozar

Copy link
Copy Markdown
Member

Thanks, this is pointed in the right direction. On the macro question: let’s take option 1 for now—keep the generated typed API unchanged and treat scopes as a runtime resolution/output feature. I don’t want per-scope structs or profile × scope generated types in this PR.

Before taking it out of draft, please rebase onto current main and account for composed secrets. We need to distinguish the consumer-visible set from the internal resolution set:

visible = effective_profile ∩ scope
accessed = transitive_dependency_closure(visible)

Resolve accessed, but inject/export only visible. For example, a scope containing a composed DATABASE_URL should fetch DB_USER and DB_PASSWORD without exposing those inputs to the child.

A few related correctness points:

  • run --scope should scrub every manifest-declared secret outside the visible set, not only excluded names from the selected profile. Otherwise a secret inherited from a parent environment under another profile can leak through.
  • “Macro unchanged” should also mean typed loaders are not accidentally affected by ambient SECRETSPEC_SCOPE; currently they call Secrets::load() and expect the full generated shape.
  • A valid empty scope/intersection should not initialize or contact a provider.
  • Please make the active scope explicit in untyped resolver/report output if those APIs support scoped resolution.
  • Scope replacement under project extends should remain whole-value replacement rather than implicit union; please document that, since replacement is safer for an allowlist.

Tests I’d especially like to see are: composed output without dependency exposure, nested composition, cross-profile inherited-env scrubbing, empty-scope/no-provider behavior, and typed-loader behavior with SECRETSPEC_SCOPE set.

Let’s keep multiple scopes, scope-specific overrides, per-secret tags, extendable profiles, and scope-aware generated types out of this PR. One membership-only scope per resolution is enough for the first version.

@ojhermann

Copy link
Copy Markdown
Contributor Author

Agree with pausing here — better to get the model right once than ship a --scope we have to walk back. And I think your diagnosis is the right frame: a profile today does two independent jobs — the environment axis (how each secret resolves: required/default/provider chain) and the membership axis (which secrets are in play) — and with one level of inheritance that forces a cross-product (prod, prod-api, prod-worker, …). Scopes as they stand are really just the extraction of that membership axis (resolved = intersection(profile, scope), never touching a secret's resolution rules), so I read this PR less as "a third concept" and more as "letting profiles go back to meaning only environment." But you're right that it leaves real questions open — laying them out:

1. How membership is represented (the real fork).

  • This PR: a separate [scopes] table listing member secrets — centralized and easy to audit ("what does api need?" reads in one block), but it can drift from the profile decls, which is why it needs the "scope references a secret not declared in any profile" validation.
  • Alternative: tag membership on each secret (API_KEY = { …, scopes = ["api", "worker"] }) — drift-proof by construction and composes naturally, but "what does api need" becomes a cross-secret grep, and untagged secrets need a default policy.

These two are duals of the same bipartite graph, so we can pick the ergonomics without changing the resolution model.

2. Composition / inheritance depth. Neither profiles (one level) nor scopes (none) compose today. I'd keep v1 flat, but pick syntax that leaves room for scope-includes-scope and/or multi-level profile inheritance so we don't have to break it later.

3. Intersection semantics. When a scope names a secret the active profile doesn't declare, the current behavior silently drops it — so --scope api can quietly resolve to fewer secrets than expected. Silent-drop vs. hard-error vs. warn is a real UX call worth making deliberately.

My lean: keep the axis this PR establishes (rather than deepening profile inheritance, which just makes the cross-product cheaper while keeping the overload), decide #1 explicitly, and consciously defer #2/#3 — but happy to go whichever direction you think fits best.

@ojhermann

Copy link
Copy Markdown
Contributor Author

Just saw your detailed reply landed while mine was in flight — crossed in the mail, and we're aligned. Taking option 1: single membership-only scope, [scopes] table, macro/generated API untouched, tags/multi-scope/extendable-profiles deferred. I'll rebase onto main and build the visible vs accessed (dependency-closure) split so composed secrets resolve their inputs without exposing them, scrub everything outside visible in run --scope, keep typed loaders free of SECRETSPEC_SCOPE, short-circuit empty scopes before any provider contact, and surface the active scope in untyped output — with the tests you listed.

Implements the design @domenkozar spelled out on cachix#137: a `[scopes]` table
names membership-only subsets of a profile's secrets, and `check`/`run`/`export`
take `--scope` (env `SECRETSPEC_SCOPE`). The resolved set is the intersection of
the selected profile and the scope's secret list; scopes never change a secret's
required/default/providers or its `{project}/{profile}/{key}` storage address.

Resolution filters at `resolve_profile_secret_names`, the single worklist
upstream of validation, planning, prompting, generation, and audit, so all of
them scope at once and an unknown scope fails before any provider is touched.

`run --scope` additionally strips scope-excluded secrets from the child via
`Command::env_remove`, which overrides inheritance — so a value the parent shell
already exported (a devenv `secretspec run`, a prior `eval "$(secretspec
export)"`) cannot leak into the launched process. This is the isolation point
@domenkozar flagged; filtering the injected map alone is insufficient because the
child inherits the real parent environment.

Static validation rejects a scope that lists a secret no profile declares;
selecting an undefined scope is a runtime error listing the defined ones.

The derive macro is intentionally unchanged (scope is a resolution-time filter,
option 1 from the cachix#137 discussion); the typed-SDK surface is deferred pending a
steer on the open macro question.

Tests: config parsing/validation/overlay, resolution intersection, unknown-scope
error, value resolution skipping excluded required secrets, and an end-to-end
run test asserting an excluded parent-exported secret does not reach the child.

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

Copy link
Copy Markdown

Ooh, this looks like it'll really help keep things locked down nicely! 🔒✨😌
Explore here →

Subweave map of cachix/secretspec#163

Maintainer? Turn off weaves from non-maintainers →
Carefully crafted by Subweave · 🧶 used ~1.8m LLM tokens

Resolve the transitive dependency closure of the visible set so an in-scope
composed secret can build its value from out-of-scope inputs, then restrict
every output surface — the value map, the temp files backing as_path values,
the per-secret report, and the missing/default lists — back to the visible set,
so those inputs resolve without ever being exposed to the scope. A secret that
is neither visible nor a dependency of one is never planned, so no provider is
contacted for it.

Broaden `run --scope` scrubbing to every manifest-declared secret outside the
visible set, across all profiles rather than only the selected one, so a value
inherited from another profile's environment cannot leak into the child.

Short-circuit an empty scope (or empty intersection) before any provider is
built. Generated typed loaders opt out of the ambient SECRETSPEC_SCOPE via
set_ignore_ambient_scope, since a generated struct always expects the full
profile. Surface the active scope in the resolve response and resolution report
(optional, omitted when unscoped). Document whole-value scope replacement under
project extends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ojhermann
ojhermann force-pushed the feat/secret-scopes branch from 9dfb677 to 8851c06 Compare July 17, 2026 20:30
Address an adversarial review of the composition-aware scope change:

- Interactive `check --scope` prompted for (and on entry overwrote) the
  out-of-scope dependencies of an in-scope composed secret: the visible-only
  resolution list made their already-resolved status look unresolved to the
  prompt planner, so it descended into the hidden leaves and solicited them by
  name. Restrict interactive prompting to the visible set (new
  scoped_promptable_missing), so a scope never names or writes a secret it
  hides.
- Correct the configuration reference: typed loaders cannot be scoped — the
  generated builder exposes no scope method — so they always resolve the full
  profile rather than "scope explicitly through the builder".
- Add coverage: scoped prompting excludes hidden dependencies; run --scope
  scrubs a composed secret's raw dependencies from the child environment
  (a separate code path from the resolution output filter); export --scope
  emits only the visible set; the active scope is surfaced in the resolve
  response and resolution report and omitted when unscoped.

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

ojhermann commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@domenkozar — rebased onto main (composed secrets included) and implemented the spec. Summary of what changed; still draft pending your answers on the three questions below.

What changed

  • Scoped composition: an in-scope composed secret (e.g. DATABASE_URL) resolves its out-of-scope inputs (DB_USER/DB_PASSWORD) to build its value, but they're filtered from every output — value map, as_path temp files, report, missing/default lists. A secret that's neither visible nor a dependency of one is never fetched.
  • run --scope scrubs every manifest-declared secret outside the visible set across all profiles, even ones the parent exported — so nothing inherited from another profile leaks into the child.
  • Empty scope contacts no provider.
  • Typed loaders ignore an ambient SECRETSPEC_SCOPE and always resolve the full profile; the untyped CLI/SDK path still honors it.
  • Active scope is surfaced in the resolve response and report (optional field, omitted when unscoped) and in check --explain.
  • extends: a child scope replaces the parent's of the same name outright (no union) — documented, with a test.
  • Tests for all of the above.

Two bugs found and fixed along the way

  • Interactive check --scope prompted for a composed secret's out-of-scope dependencies — printing their names and overwriting already-present values. Prompting is now limited to the visible set.
  • A doc claimed typed loads can be scoped via the builder; they can't (no such method). Corrected.

Three questions for you — didn't want to decide these unilaterally:

  1. Audit: a scoped run reads out-of-scope dependencies to build a composition, but the audit log records only the visible names. Record visible (fewer names disclosed) or accessed (faithful to what was read)? I lean accessed.
  2. as_path dependency of a visible composition: the composed value embeds the dependency's temp-file path, but under a scope that file is dropped, leaving a dangling path. Reject the config, keep the file, or leave as-is?
  3. Visible-but-optional-missing secrets aren't scrubbed from run (the scope admits them; they just didn't resolve) — deliberate, but flag if you'd rather scrub them.

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 / per-service secret scoping orthogonal to profiles

3 participants