feat: secret scopes (--scope), membership-only subsets of a profile#163
feat: secret scopes (--scope), membership-only subsets of a profile#163ojhermann wants to merge 3 commits into
Conversation
|
@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 The open question is the derive macro. Which way would you like it pointed? That's the one answer I need to take this out of draft. |
46ffd79 to
9dfb677
Compare
|
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. |
|
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 Resolve A few related correctness points:
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 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. |
|
Agree with pausing here — better to get the model right once than ship a 1. How membership is represented (the real fork).
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 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. |
|
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, |
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>
|
Ooh, this looks like it'll really help keep things locked down nicely! 🔒✨😌 Maintainer? Turn off weaves from non-maintainers → |
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>
9dfb677 to
8851c06
Compare
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>
|
@domenkozar — rebased onto What changed
Two bugs found and fixed along the way
Three questions for you — didn't want to decide these unilaterally:
|
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:secretspec run --scope api -- ./api secretspec check --scope worker secretspec export --scope api --format dotenv--scope(envSECRETSPEC_SCOPE) is onrun,check, andexport.How it follows the design
resolve(profile, scope) = filter_by_scope(resolve_profile(profile))— the filter is applied atresolve_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.required/default/providers/references/generation/as_pathor the{project}/{profile}/{key}storage address. No per-scope overrides, so there's no profile × scope precedence.The env-isolation detail you flagged
You noted
run --scopemust remove declared-but-excluded secrets the parent already holds. It does — viaCommand::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 devenvsecretspec run(or a prioreval "$(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!readssecretspec.tomlat compile time, but--scopeis 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