Skip to content

Support secret attribute on @Option across all language ports#7736

Open
jkschneider wants to merge 1 commit into
mainfrom
worktree-secret-options
Open

Support secret attribute on @Option across all language ports#7736
jkschneider wants to merge 1 commit into
mainfrom
worktree-secret-options

Conversation

@jkschneider
Copy link
Copy Markdown
Member

Summary

  • Adds a secret = true attribute on @Option (and equivalent across Python/JS/Go/C#) that flows through OptionDescriptor and the RPC bridge to all language ports.
  • Recipe.getInstanceName() now drops secret options from the composed name so single-required-secret recipes don't leak the value into user-visible strings.
  • RecipeMarketplaceWriter.optionsToJson redacts secret option values via the new OptionDescriptor.withRedactedSecretValue() helper.

Why redact at persistence boundaries, not at getDescriptor()

OptionDescriptor.value stays raw at the source. RpcRecipe.java:127-135 reads OptionDescriptor::getValue to forward option values across RPC to Python/JS/Go/C# peers, and Recipe.withOptions (the clone path) reads it during Jackson rebinding. Nulling at the source would silently break secret options for every RPC recipe. Redaction is opt-in via withRedactedSecretValue() and is called at each persistence/external boundary. The boundaries are few and well-defined — easier to audit than special-casing a source-level null.

The exception: Recipe.getInstanceName() reads option fields directly via reflection and composes them into a user-visible string. A required+secret-only recipe would leak the credential into dashboards, so the removeIf filter at Recipe.java:148 is extended to also drop secret options.

Per-port summary

Port Files Notes
rewrite-core Option.java, OptionDescriptor.java, Recipe.java, RecipeMarketplaceWriter.java, SecretOptionTest.java New secret() attribute, withRedactedSecretValue() helper, propagation through 3 reflection sites in getOptionDescriptors(), instance-name filter, marketplace-writer redaction.
rewrite-python recipe.py, rpc/server.py, tests/test_secret_option.py secret: bool = False on OptionDescriptor + option() factory; emitted in _recipe_descriptor_to_dict.
rewrite-javascript recipe.ts, test/secret-option.test.ts secret?: boolean on OptionDescriptor interface; spread carries it through descriptor().
rewrite-go pkg/recipe/recipe.go, cmd/rpc/main.go, pkg/recipe/secret_option_test.go Secret bool field + AsSecret() fluent builder; emitted in marketplaceOption wire struct.
rewrite-csharp OptionAttribute.cs, OptionDescriptor.cs, Recipe.cs, RewriteRpcServer.cs, RecipeTest.cs Secret on OptionAttribute, record param on OptionDescriptor + WithRedactedSecretValue() using with expression, propagated through reflection and the RPC OptionDescriptorDto.

Test plan

  • ./gradlew :rewrite-core:test — full suite green, 9 new SecretOptionTest tests pass including the regression test that asserts getInstanceName() does not leak a secret value
  • cd rewrite-python/rewrite && uv run pytest tests/test_secret_option.py tests/test_marketplace.py tests/test_data_table.py — 31 passed
  • cd rewrite-javascript/rewrite && npm run testhelper -- test/secret-option.test.ts test/recipe.test.ts && npm run typecheck — green, typecheck clean
  • cd rewrite-go && go test ./pkg/recipe/... && go build ./... — green, build clean
  • cd rewrite-csharp/csharp && dotnet test --filter RecipeTest && dotnet build — 7 passed, build clean
  • Downstream: moderne-cli and moderne-saas PRs (separate) consume the new flag

Follow-up PRs

This is the foundation PR. Two consumer PRs depend on it:

  1. moderne-cli: pre-resolve the recipe descriptor, prompt for missing secret options without echo, strip secret values from trace.json, pass secretKeys to the SaaS via the GraphQL mutation.
  2. moderne-saas: honor the CLI-supplied secretKeys at the event-log write boundary, expose Option.secret: Boolean! in the GraphQL marketplace schema, make RecipeOptionValue.value nullable in recipe-run history.

Recipes that accept credentials (API tokens, passwords, etc.) can now mark
those options with `secret = true`. The flag flows through `OptionDescriptor`
and the RPC bridge so every language port (Java, Python, JavaScript, Go, C#)
exposes it consistently. Downstream consumers (CLI traces, recipe-run history)
use it to redact values at their persistence boundaries.

A key invariant: `OptionDescriptor.value` is NOT redacted at the source.
`RpcRecipe` and the recipe `withOptions` clone path read it directly to forward
option values into recipe execution, and nulling at the source would silently
break secret options for RPC peers. Redaction is opt-in via
`OptionDescriptor.withRedactedSecretValue()` and is applied at each persistence
boundary (e.g. `RecipeMarketplaceWriter.optionsToJson`).

One source-level fix is needed regardless: `Recipe.getInstanceName()`
composed single-required-option values into a user-visible string, so a
secret-required option would have leaked the credential into dashboards.
The filter now also drops `secret` options.

Per port:

- rewrite-core: `Option.secret()`, `OptionDescriptor.secret` +
  `withRedactedSecretValue()`, propagation through the 3 reflection sites in
  `Recipe.getOptionDescriptors()`, `getInstanceName()` filter fix, marketplace
  writer redaction. 9 new tests in `SecretOptionTest`.
- rewrite-python: `secret: bool = False` on `OptionDescriptor` + `option()`
  factory; emitted in `_recipe_descriptor_to_dict`. 4 new tests.
- rewrite-javascript: `secret?: boolean` on `OptionDescriptor` interface;
  spread carries it through `descriptor()`. 2 new tests.
- rewrite-go: `Secret bool` on `OptionDescriptor` + `AsSecret()` fluent
  builder; emitted in `marketplaceOption` wire struct. 3 new tests.
- rewrite-csharp: `Secret` on `OptionAttribute`, positional record param on
  `OptionDescriptor` + `WithRedactedSecretValue()`, propagated through
  reflection in `Recipe.GetOptionDescriptors()` and through the RPC
  `OptionDescriptorDto`. 2 new tests.
@jkschneider jkschneider force-pushed the worktree-secret-options branch from 42c8c41 to 4df0469 Compare May 19, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant