diff --git a/rewrite-core/src/main/java/org/openrewrite/Option.java b/rewrite-core/src/main/java/org/openrewrite/Option.java index 25598fce1c1..60977395b80 100644 --- a/rewrite-core/src/main/java/org/openrewrite/Option.java +++ b/rewrite-core/src/main/java/org/openrewrite/Option.java @@ -40,4 +40,19 @@ String[] valid() default ""; boolean required() default true; + + /** + * Indicates the value is sensitive (an API token, password, etc.). + *

+ * Consumers persisting or displaying option values are expected to redact secret values. + * The corresponding {@link org.openrewrite.config.OptionDescriptor#getValue()} is left raw + * at the source so the value can still be passed into recipe execution (including across + * RPC to non-JVM recipe peers); redaction happens at each persistence boundary. + *

+ * Recipe authors overriding {@link Recipe#getInstanceName()} or + * {@link Recipe#getInstanceNameSuffix()} must not include the value of a secret option + * in the returned string. The default {@code getInstanceName()} skips secret options + * automatically, but custom overrides bypass that filter. + */ + boolean secret() default false; } diff --git a/rewrite-core/src/main/java/org/openrewrite/Recipe.java b/rewrite-core/src/main/java/org/openrewrite/Recipe.java index 6c988939b20..cb671689cb4 100644 --- a/rewrite-core/src/main/java/org/openrewrite/Recipe.java +++ b/rewrite-core/src/main/java/org/openrewrite/Recipe.java @@ -145,7 +145,7 @@ public String getInstanceName() { } List options = new ArrayList<>(getOptionDescriptors()); - options.removeIf(opt -> !opt.isRequired()); + options.removeIf(opt -> !opt.isRequired() || opt.isSecret()); if (options.isEmpty()) { return getDisplayName(); } @@ -276,6 +276,7 @@ private List getOptionDescriptors() { option.example().isEmpty() ? null : option.example(), validValues(option, field.getType()), option.required(), + option.secret(), value)); } } @@ -291,6 +292,7 @@ private List getOptionDescriptors() { option.example().isEmpty() ? null : option.example(), validValues(option, method.getReturnType()), option.required(), + option.secret(), null)); } } @@ -307,6 +309,7 @@ private List getOptionDescriptors() { option.example().isEmpty() ? null : option.example(), validValues(option, parameter.getType()), option.required(), + option.secret(), null)); } } diff --git a/rewrite-core/src/main/java/org/openrewrite/config/OptionDescriptor.java b/rewrite-core/src/main/java/org/openrewrite/config/OptionDescriptor.java index 4eebc41cab0..8e8c9314b01 100644 --- a/rewrite-core/src/main/java/org/openrewrite/config/OptionDescriptor.java +++ b/rewrite-core/src/main/java/org/openrewrite/config/OptionDescriptor.java @@ -46,7 +46,19 @@ public class OptionDescriptor { boolean required; + boolean secret; + @Nullable @EqualsAndHashCode.Include Object value; + + /** + * If this option is marked {@link #secret}, returns a copy with {@link #value} nulled. + * Otherwise returns this instance unchanged. Use at persistence and external-transport + * boundaries to ensure secret values are not written to durable storage. + */ + public OptionDescriptor withRedactedSecretValue() { + return secret ? new OptionDescriptor(name, type, displayName, description, + example, valid, required, true, null) : this; + } } diff --git a/rewrite-core/src/main/java/org/openrewrite/marketplace/RecipeMarketplaceWriter.java b/rewrite-core/src/main/java/org/openrewrite/marketplace/RecipeMarketplaceWriter.java index fef168aad23..bf8fe232694 100644 --- a/rewrite-core/src/main/java/org/openrewrite/marketplace/RecipeMarketplaceWriter.java +++ b/rewrite-core/src/main/java/org/openrewrite/marketplace/RecipeMarketplaceWriter.java @@ -201,7 +201,10 @@ private String optionsToJson(List options) { return ""; } try { - return JSON_MAPPER.writeValueAsString(options); + List toSerialize = options.stream().anyMatch(OptionDescriptor::isSecret) + ? options.stream().map(OptionDescriptor::withRedactedSecretValue).collect(toList()) + : options; + return JSON_MAPPER.writeValueAsString(toSerialize); } catch (JsonProcessingException e) { throw new IllegalStateException("Failed to serialize options to JSON", e); } diff --git a/rewrite-core/src/test/java/org/openrewrite/SecretOptionTest.java b/rewrite-core/src/test/java/org/openrewrite/SecretOptionTest.java new file mode 100644 index 00000000000..a443cd7fc78 --- /dev/null +++ b/rewrite-core/src/test/java/org/openrewrite/SecretOptionTest.java @@ -0,0 +1,195 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite; + +import lombok.Getter; +import org.junit.jupiter.api.Test; +import org.openrewrite.config.OptionDescriptor; +import org.openrewrite.config.RecipeDescriptor; +import org.openrewrite.marketplace.RecipeMarketplace; +import org.openrewrite.marketplace.RecipeMarketplaceReader; +import org.openrewrite.marketplace.RecipeMarketplaceWriter; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecretOptionTest { + + @Getter + static class RecipeWithSecretField extends Recipe { + private final String displayName = "Secret field recipe"; + private final String description = "Secret field recipe."; + + @Option(displayName = "API token", description = "API token.", secret = true) + final String apiToken; + + public RecipeWithSecretField(String apiToken) { + this.apiToken = apiToken; + } + } + + @Test + void fieldOptionPropagatesSecret() { + RecipeDescriptor d = new RecipeWithSecretField("hunter2").createRecipeDescriptor(); + OptionDescriptor opt = d.getOptions().getFirst(); + assertThat(opt.isSecret()).isTrue(); + } + + @Test + void fieldOptionRawValuePreservedOnDescriptor() { + // Critical invariant: OptionDescriptor.value is NOT redacted at the source. RPC peers + // and the recipe clone/withOptions path rely on the raw value being available via + // OptionDescriptor#getValue(). Redaction happens only at persistence boundaries + // (e.g. RecipeMarketplaceWriter.optionsToJson) via withRedactedSecretValue(). + RecipeDescriptor d = new RecipeWithSecretField("hunter2").createRecipeDescriptor(); + OptionDescriptor opt = d.getOptions().getFirst(); + assertThat(opt.getValue()).isEqualTo("hunter2"); + } + + static class RecipeBase extends Recipe { + String apiToken; + + public RecipeBase(String apiToken) { + this.apiToken = apiToken; + } + + @Override + public String getDisplayName() { + return "Method secret recipe"; + } + + @Override + public String getDescription() { + return "Method secret recipe."; + } + } + + static class RecipeWithSecretMethod extends RecipeBase { + public RecipeWithSecretMethod(String apiToken) { + super(apiToken); + } + + @SuppressWarnings("unused") + @Option(displayName = "API token", description = "API token.", secret = true) + String getApiToken() { + return apiToken; + } + } + + @Test + void methodOptionPropagatesSecret() { + RecipeDescriptor d = new RecipeWithSecretMethod("hunter2").createRecipeDescriptor(); + OptionDescriptor opt = d.getOptions().stream() + .filter(o -> "apiToken".equals(o.getName())) + .findFirst().orElseThrow(); + assertThat(opt.isSecret()).isTrue(); + } + + @Getter + static class RecipeWithSecretConstructorParam extends Recipe { + private final String displayName = "Constructor secret recipe"; + private final String description = "Constructor secret recipe."; + final String apiToken; + + public RecipeWithSecretConstructorParam( + @Option(displayName = "API token", description = "API token.", secret = true) String apiToken) { + this.apiToken = apiToken; + } + } + + @Test + void constructorParamOptionPropagatesSecret() { + RecipeDescriptor d = new RecipeWithSecretConstructorParam("hunter2").createRecipeDescriptor(); + OptionDescriptor opt = d.getOptions().getFirst(); + assertThat(opt.isSecret()).isTrue(); + } + + @Test + void withRedactedSecretValueNullsValueButPreservesOtherFields() { + OptionDescriptor opt = new OptionDescriptor("apiToken", "String", + "API token", "API token.", null, null, true, true, "hunter2"); + + OptionDescriptor redacted = opt.withRedactedSecretValue(); + + assertThat(redacted.getName()).isEqualTo("apiToken"); + assertThat(redacted.getType()).isEqualTo("String"); + assertThat(redacted.getDisplayName()).isEqualTo("API token"); + assertThat(redacted.getDescription()).isEqualTo("API token."); + assertThat(redacted.isRequired()).isTrue(); + assertThat(redacted.isSecret()).isTrue(); + assertThat(redacted.getValue()).isNull(); + } + + @Test + void withRedactedSecretValueReturnsSameInstanceWhenNotSecret() { + OptionDescriptor opt = new OptionDescriptor("methodPattern", "String", + "Method pattern", "Method pattern.", null, null, true, false, "java.util.List add(..)"); + + OptionDescriptor result = opt.withRedactedSecretValue(); + + assertThat(result).isSameAs(opt); + assertThat(result.getValue()).isEqualTo("java.util.List add(..)"); + } + + /** + * Regression test: a recipe whose only required option is marked secret must NOT + * include the value of that option in {@link Recipe#getInstanceName()}. Without + * the filter in {@code Recipe#getInstanceName()}, the credential would be composed + * into a user-visible string that appears in dashboards, logs, and audit views. + */ + @Test + void instanceNameDoesNotLeakSecretOptionValue() { + RecipeWithSecretField recipe = new RecipeWithSecretField("hunter2"); + assertThat(recipe.getInstanceName()).isEqualTo(recipe.getDisplayName()); + assertThat(recipe.getInstanceName()).doesNotContain("hunter2"); + } + + @Getter + static class RecipeWithNonSecretRequiredOption extends Recipe { + private final String displayName = "Non-secret recipe"; + private final String description = "Non-secret recipe."; + + @Option(displayName = "Method pattern", description = "Method pattern.", example = "java.util.List add(..)") + final String methodPattern; + + public RecipeWithNonSecretRequiredOption(String methodPattern) { + this.methodPattern = methodPattern; + } + } + + @Test + void instanceNameStillComposesNonSecretRequiredOptionValue() { + // Sanity: instance-name composition still works for non-secret required options. + RecipeWithNonSecretRequiredOption recipe = new RecipeWithNonSecretRequiredOption("java.util.List add(..)"); + assertThat(recipe.getInstanceName()).isEqualTo("Non-secret recipe `java.util.List add(..)`"); + } + + @Test + void marketplaceWriterRedactsSecretOptionValueInJson() { + // Marketplace storage is durable and externally visible. Even when an option + // descriptor carries a runtime value (e.g. because the listing was loaded from + // a recipe instance), the secret value must not be serialized to CSV/JSON. + RecipeMarketplace marketplace = new RecipeMarketplaceReader().fromCsv(""" + name,displayName,options,category,ecosystem,packageName + org.example.SecretRecipe,Secret Recipe,"[{""name"":""apiToken"",""type"":""String"",""displayName"":""API token"",""description"":""API token."",""required"":true,""secret"":true,""value"":""hunter2""}]",Example,maven,org.example:example + """); + + String writtenCsv = new RecipeMarketplaceWriter().toCsv(marketplace); + + assertThat(writtenCsv).doesNotContain("hunter2"); + // The secret flag itself is preserved so downstream consumers know to treat the option as secret. + assertThat(writtenCsv).contains("\"\"secret\"\":true"); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs b/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs index e9c06fa3804..dec783210f3 100644 --- a/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs +++ b/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs @@ -90,11 +90,53 @@ public void RecipeDescriptorHasOptions() Assert.Equal("The class name to rename from.", descriptor.Options[0].Description); Assert.Equal("Foo", descriptor.Options[0].Example); Assert.True(descriptor.Options[0].Required); + Assert.False(descriptor.Options[0].Secret); Assert.Equal("Foo", descriptor.Options[0].Value); Assert.Equal("To", descriptor.Options[1].Name); Assert.Equal("Bar", descriptor.Options[1].Value); } + [Fact] + public void SecretOptionPropagatesAndCanBeRedacted() + { + var recipe = new RecipeWithSecret { ApiToken = "hunter2" }; + var descriptor = recipe.GetDescriptor(); + + Assert.Single(descriptor.Options); + var opt = descriptor.Options[0]; + Assert.Equal("ApiToken", opt.Name); + Assert.True(opt.Secret); + // Raw value is preserved on the source-level descriptor so RPC + recipe execution + // still see it. Persistence boundaries must call WithRedactedSecretValue(). + Assert.Equal("hunter2", opt.Value); + + var redacted = opt.WithRedactedSecretValue(); + Assert.True(redacted.Secret); + Assert.Null(redacted.Value); + Assert.Equal("ApiToken", redacted.Name); + Assert.Equal("API token", redacted.DisplayName); + } + + [Fact] + public void WithRedactedSecretValueIsNoOpForNonSecret() + { + var recipe = new RenameClassRecipe { From = "Foo", To = "Bar" }; + var opt = recipe.GetDescriptor().Options[0]; + Assert.Same(opt, opt.WithRedactedSecretValue()); + } + +} + +class RecipeWithSecret : OpenRewrite.Core.Recipe +{ + [Option(DisplayName = "API token", Description = "API token used by the recipe.", Secret = true)] + public required string ApiToken { get; init; } + + public override string DisplayName => "Recipe with secret"; + public override string Description => "Recipe with secret."; + + public override JavaVisitor GetVisitor() => + new CSharpVisitor(); } class RenameClassRecipe : OpenRewrite.Core.Recipe diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs index a870b0686d6..aa820dd81d5 100644 --- a/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs @@ -1569,6 +1569,7 @@ public class OptionDescriptorDto public string? Example { get; set; } public List? Valid { get; set; } public bool Required { get; set; } + public bool Secret { get; set; } public object? Value { get; set; } public static OptionDescriptorDto FromDescriptor(OptionDescriptor d) @@ -1582,6 +1583,7 @@ public static OptionDescriptorDto FromDescriptor(OptionDescriptor d) Example = d.Example, Valid = d.Valid?.ToList(), Required = d.Required, + Secret = d.Secret, Value = d.Value }; } diff --git a/rewrite-csharp/csharp/OpenRewrite/Core/OptionAttribute.cs b/rewrite-csharp/csharp/OpenRewrite/Core/OptionAttribute.cs index 47f3d138a76..d09c5bcf8a9 100644 --- a/rewrite-csharp/csharp/OpenRewrite/Core/OptionAttribute.cs +++ b/rewrite-csharp/csharp/OpenRewrite/Core/OptionAttribute.cs @@ -35,4 +35,15 @@ public sealed class OptionAttribute : Attribute public string[]? Valid { get; set; } public bool Required { get; set; } = true; + + ///

+ /// Indicates the value is sensitive (an API token, password, etc.). + /// + /// Consumers persisting or displaying option values are expected to redact secret values. + /// The corresponding is left raw at the source so the + /// value can still be passed into recipe execution (including across RPC); redaction happens + /// at each persistence boundary. + /// + /// + public bool Secret { get; set; } = false; } diff --git a/rewrite-csharp/csharp/OpenRewrite/Core/OptionDescriptor.cs b/rewrite-csharp/csharp/OpenRewrite/Core/OptionDescriptor.cs index f12c7748a32..5b91589acae 100644 --- a/rewrite-csharp/csharp/OpenRewrite/Core/OptionDescriptor.cs +++ b/rewrite-csharp/csharp/OpenRewrite/Core/OptionDescriptor.cs @@ -29,5 +29,15 @@ public record OptionDescriptor( string? Example, IReadOnlyList? Valid, bool Required, + bool Secret, object? Value -); +) +{ + /// + /// If this option is marked , returns a copy with + /// nulled. Otherwise returns this instance unchanged. Use at persistence and external- + /// transport boundaries to ensure secret values are not written to durable storage. + /// + public OptionDescriptor WithRedactedSecretValue() => + Secret ? this with { Value = null } : this; +} diff --git a/rewrite-csharp/csharp/OpenRewrite/Core/Recipe.cs b/rewrite-csharp/csharp/OpenRewrite/Core/Recipe.cs index 1a0e05a725e..a008ee888c0 100644 --- a/rewrite-csharp/csharp/OpenRewrite/Core/Recipe.cs +++ b/rewrite-csharp/csharp/OpenRewrite/Core/Recipe.cs @@ -86,6 +86,7 @@ private List GetOptionDescriptors() attr.Valid is { Length: > 0 } v && !(v.Length == 1 && v[0] == "") ? v.ToList() : null, attr.Required, + attr.Secret, value )); } diff --git a/rewrite-go/cmd/rpc/main.go b/rewrite-go/cmd/rpc/main.go index a30490f1ada..01f2682d02d 100644 --- a/rewrite-go/cmd/rpc/main.go +++ b/rewrite-go/cmd/rpc/main.go @@ -1039,6 +1039,7 @@ type marketplaceOption struct { Description string `json:"description"` Example *string `json:"example"` Required bool `json:"required"` + Secret bool `json:"secret"` Type string `json:"type"` Value any `json:"value"` Valid []any `json:"valid"` @@ -1105,6 +1106,7 @@ func marketplaceDescriptorFromRecipe(desc recipe.RecipeDescriptor) marketplaceDe Description: opt.Description, Example: example, Required: opt.Required, + Secret: opt.Secret, Type: opt.TypeName(), Value: opt.Value, Valid: valid, diff --git a/rewrite-go/pkg/recipe/recipe.go b/rewrite-go/pkg/recipe/recipe.go index 7703d1e2f8c..88f94e5dbef 100644 --- a/rewrite-go/pkg/recipe/recipe.go +++ b/rewrite-go/pkg/recipe/recipe.go @@ -130,6 +130,12 @@ type OptionDescriptor struct { // Value is the current value, if set. Value any + + // Secret indicates the value is sensitive (an API token, password, etc.). + // Consumers persisting or displaying option values are expected to redact + // secret values at persistence boundaries; the runtime value here stays raw + // so the recipe can still execute with it. + Secret bool } // Option creates a required OptionDescriptor with the given name, display name, and description. @@ -154,6 +160,10 @@ func (o OptionDescriptor) WithValid(v ...string) OptionDescriptor { o.Valid = v; // AsOptional marks the option as not required. func (o OptionDescriptor) AsOptional() OptionDescriptor { o.Required = false; return o } +// AsSecret marks the option's value as sensitive. Consumers persisting or displaying +// the value (CLI traces, marketplace JSON, SaaS event log) are expected to redact it. +func (o OptionDescriptor) AsSecret() OptionDescriptor { o.Secret = true; return o } + // TypeName returns a Java-style type name derived from the option's Value. // Used to populate the marketplace option `type` wire field. func (o OptionDescriptor) TypeName() string { diff --git a/rewrite-go/pkg/recipe/secret_option_test.go b/rewrite-go/pkg/recipe/secret_option_test.go new file mode 100644 index 00000000000..2232fe0d27f --- /dev/null +++ b/rewrite-go/pkg/recipe/secret_option_test.go @@ -0,0 +1,55 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://docs.moderne.io/licensing/moderne-source-available-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package recipe + +import "testing" + +func TestOptionDefaultsToNonSecret(t *testing.T) { + opt := Option("apiToken", "API token", "API token.") + if opt.Secret { + t.Fatalf("expected Secret to default to false") + } +} + +func TestAsSecretSetsFlag(t *testing.T) { + opt := Option("apiToken", "API token", "API token.").AsSecret() + if !opt.Secret { + t.Fatalf("expected AsSecret() to set Secret=true") + } + if !opt.Required { + t.Fatalf("AsSecret() must not clear Required (it remains required by default)") + } +} + +func TestAsSecretPreservesValueAndOtherFields(t *testing.T) { + // Critical invariant: AsSecret() does NOT redact the runtime Value. The Value + // stays raw so the recipe can still execute with it; redaction is a persistence- + // boundary concern handled by consumers (marketplace writer, CLI trace, SaaS). + opt := Option("apiToken", "API token", "API token."). + WithValue("hunter2"). + WithExample("***"). + AsSecret() + if opt.Value != "hunter2" { + t.Fatalf("expected raw Value to be preserved, got %v", opt.Value) + } + if opt.Example != "***" { + t.Fatalf("expected Example to be preserved, got %v", opt.Example) + } + if !opt.Secret { + t.Fatalf("expected Secret=true") + } +} diff --git a/rewrite-javascript/rewrite/src/recipe.ts b/rewrite-javascript/rewrite/src/recipe.ts index cb2cc059f65..c19c15ca64d 100644 --- a/rewrite-javascript/rewrite/src/recipe.ts +++ b/rewrite-javascript/rewrite/src/recipe.ts @@ -154,6 +154,13 @@ export interface OptionDescriptor { readonly required?: boolean readonly example?: string readonly valid?: string[] + /** + * If true, the option's value is sensitive (an API token, password, etc.). + * Recipe authors must not log or persist a secret value from within the recipe. + * The value is still made available to the recipe at runtime; redaction is the + * responsibility of consumers at persistence boundaries. + */ + readonly secret?: boolean } export abstract class ScanningRecipe

extends Recipe { diff --git a/rewrite-javascript/rewrite/test/secret-option.test.ts b/rewrite-javascript/rewrite/test/secret-option.test.ts new file mode 100644 index 00000000000..c74b8e06252 --- /dev/null +++ b/rewrite-javascript/rewrite/test/secret-option.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {ExecutionContext, noopVisitor, Option, Recipe, TreeVisitor} from "../src"; + +class RecipeWithSecret extends Recipe { + name = "org.example.RecipeWithSecret"; + displayName = "Recipe with secret"; + description = "Recipe with secret option."; + + @Option({ + displayName: "API token", + description: "API token used by the recipe.", + secret: true, + }) + apiToken!: string; + + constructor(options: { apiToken: string }) { + super(options); + } + + async editor(): Promise> { + return noopVisitor(); + } +} + +describe("secret option", () => { + test("propagates secret=true through the recipe descriptor", async () => { + const recipe = new RecipeWithSecret({apiToken: "hunter2"}); + const descriptor = await recipe.descriptor(); + + expect(descriptor.options).toHaveLength(1); + const opt = descriptor.options[0]; + expect(opt.name).toBe("apiToken"); + expect(opt.secret).toBe(true); + // Raw value is preserved on the descriptor; redaction is a persistence + // boundary concern, not a source-level concern. + expect(opt.value).toBe("hunter2"); + }); + + test("secret defaults to undefined (i.e. non-secret) when not specified", async () => { + class RecipeWithoutSecret extends Recipe { + name = "org.example.RecipeWithoutSecret"; + displayName = "Recipe"; + description = "Recipe."; + + @Option({ + displayName: "Pattern", + description: "A pattern.", + }) + pattern!: string; + + constructor(options: { pattern: string }) { + super(options); + } + + async editor(): Promise> { + return noopVisitor(); + } + } + + const recipe = new RecipeWithoutSecret({pattern: "foo"}); + const descriptor = await recipe.descriptor(); + const opt = descriptor.options[0]; + expect(opt.secret).toBeUndefined(); + }); +}); diff --git a/rewrite-python/rewrite/src/rewrite/recipe.py b/rewrite-python/rewrite/src/rewrite/recipe.py index 0d9f2adb36d..0aa02b4f951 100644 --- a/rewrite-python/rewrite/src/rewrite/recipe.py +++ b/rewrite-python/rewrite/src/rewrite/recipe.py @@ -40,6 +40,7 @@ def option( example: Optional[str] = None, required: bool = True, valid: Optional[List[str]] = None, + secret: bool = False, ) -> dict[str, Any]: """ Create option metadata for a recipe field. @@ -53,6 +54,10 @@ def option( example: Example value for the option required: Whether the option is required (default True) valid: List of valid values (for enum-like options) + secret: Whether the value is sensitive (API token, password, etc.). + Secret values must not be logged or persisted by the recipe. The + value still flows through normal recipe execution; consumers are + responsible for redacting it at any persistence boundary. Returns: Metadata dictionary to pass to dataclasses.field() @@ -73,6 +78,7 @@ class MyRecipe(Recipe): example=example, required=required, valid=valid, + secret=secret, ) } @@ -86,6 +92,7 @@ class OptionDescriptor: example: Optional[str] = None required: bool = True valid: Optional[List[str]] = None + secret: bool = False @dataclass(frozen=True) diff --git a/rewrite-python/rewrite/src/rewrite/rpc/server.py b/rewrite-python/rewrite/src/rewrite/rpc/server.py index 964b33a5ca1..6ddaa052a1e 100644 --- a/rewrite-python/rewrite/src/rewrite/rpc/server.py +++ b/rewrite-python/rewrite/src/rewrite/rpc/server.py @@ -1027,6 +1027,7 @@ def _recipe_descriptor_to_dict(descriptor) -> dict: 'description': opt.description, 'example': opt.example, 'required': opt.required, + 'secret': opt.secret, 'valid': opt.valid, } for name, value, opt in descriptor.options diff --git a/rewrite-python/rewrite/tests/test_secret_option.py b/rewrite-python/rewrite/tests/test_secret_option.py new file mode 100644 index 00000000000..135a858b9a2 --- /dev/null +++ b/rewrite-python/rewrite/tests/test_secret_option.py @@ -0,0 +1,87 @@ +# Copyright 2026 the original author or authors. +#

+# Licensed under the Moderne Source Available License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +#

+# https://docs.moderne.io/licensing/moderne-source-available-license +#

+# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the secret flag on recipe options.""" + +from dataclasses import dataclass, field +from typing import Any + +from rewrite import Recipe, OptionDescriptor, option +from rewrite.rpc.server import _recipe_descriptor_to_dict + + +@dataclass +class RecipeWithSecret(Recipe): + """A recipe with a secret option.""" + + api_token: str = field( + default="", + metadata=option( + display_name="API token", + description="API token used by the recipe.", + secret=True, + ), + ) + + @property + def name(self) -> str: + return "org.example.RecipeWithSecret" + + @property + def display_name(self) -> str: + return "Recipe with secret" + + @property + def description(self) -> str: + return "Recipe with secret." + + +class TestSecretOption: + """Tests for the secret flag on OptionDescriptor.""" + + def test_option_descriptor_defaults_to_non_secret(self): + desc = OptionDescriptor(display_name="x", description="x") + assert desc.secret is False + + def test_option_factory_can_mark_secret(self): + meta = option(display_name="API token", description="API token.", secret=True) + desc: OptionDescriptor = meta["option"] + assert desc.secret is True + + def test_recipe_field_with_secret_option_exposes_flag(self): + recipe = RecipeWithSecret(api_token="hunter2") + recipe_descriptor = recipe.descriptor() + # options is List[tuple[str, Any, OptionDescriptor]] + name, value, opt = recipe_descriptor.options[0] + assert name == "api_token" + assert value == "hunter2" + assert opt.secret is True + # Raw value is preserved on the Python-side descriptor; redaction is a + # persistence-boundary concern, not a source-level concern. + + def test_rpc_serialization_includes_secret_flag(self): + """The Java peer must learn that an option is secret over the wire. + + `_recipe_descriptor_to_dict` is the canonical Python -> JSON serializer + consumed by the Java-side RPC bridge.""" + recipe = RecipeWithSecret(api_token="hunter2") + d: dict[str, Any] = _recipe_descriptor_to_dict(recipe.descriptor()) + + opt = d["options"][0] + assert opt["name"] == "api_token" + assert opt["secret"] is True + # The Python serializer ships the raw value; the Java side is responsible + # for redaction at its persistence boundaries (RecipeMarketplaceWriter, + # moderne-cli trace.json, moderne-saas event log). + assert opt["value"] == "hunter2"