Skip to content

(GH-1729) Make nonfunctional secret extension manifests invalid - #1745

Open
Gijs Reijn (Gijsreyn) wants to merge 1 commit into
PowerShell:mainfrom
Gijsreyn:gh-1729/main/feat-secret-extension-contract
Open

Gijs Reijn (Gijsreyn) wants to merge 1 commit into
PowerShell:mainfrom
Gijsreyn:gh-1729/main/feat-secret-extension-contract

Conversation

@Gijsreyn

Copy link
Copy Markdown
Collaborator

PR Summary

This change:

  • Requires extension manifests that define the secret command to also define its args, with exactly one secret name input argument (nameArg) and at most one vault input argument (vaultArg).
  • Validates these rules when DSC loads an extension manifest. A manifest that breaks them is skipped like any other invalid manifest, with an informational message that names the extension, the manifest path, and the specific problem.
  • Adds the same rules to the generated JSON schema for the secret command, so editors flag the problem while the manifest is being written. The VS Code form of the schema carries a specific error message for each rule.
  • Validates the arguments again right before DSC invokes the extension, as a safeguard for extensions that weren't loaded through discovery.
  • Teaches the no-op subcommand of dsctest to accept and ignore arguments, and fixes the deprecated test extension, which was itself a nonfunctional secret extension.
  • Adds unit tests for the validation, the argument processing, and the generated schema, and Pester tests that cover each invalid manifest shape and a valid one.
  • Updates the reference documentation and the changelog.
  • Fixes Make it impossible to accidentally define a nonfunctional secret extension #1729

PR Context

Prior to this change, the args field for the secret command was optional, and nothing checked whether it contained the secret name input argument. An extension could define a secret command that DSC was happy to invoke but could never tell which secret to retrieve: the name was silently dropped from the argument list, and whatever the extension wrote to stdout was treated as the secret. Nothing in the output of dsc extension list or in the trace messages told the author that something was wrong.

The hand-written YAML schema source under schemas/src already described the intended contract: args required, exactly one name argument, at most one vault argument. The published schemas are now generated from the Rust types, though, and those carried none of these constraints. The repository's own deprecated test extension shows how easy it was to get wrong: its secret command passed only no-op to dsctest and nothing else.

The working group chose the first option from the issue, making the contract strict and treating a missing name argument as an error, rather than the second option of appending the secret name as a trailing argument.

The check lives in the loader that turns an extension manifest into a discovered extension, next to the existing checks for the other commands. When it fails, the manifest is rejected the same way as a manifest that doesn't parse, so the behavior is consistent for extension authors: the extension doesn't appear in the list, and running dsc --trace-level info extension list shows why.

Remark on compatibility

This is a breaking change for extension manifests that omit args or the name argument. Those manifests loaded before, but the resulting extension couldn't retrieve a specific secret, so the change only excludes definitions that never worked as intended. The Azure CLI example and the test extensions in this repository were already compliant.

Remark on the message level

The skipped-manifest message is logged at the info level, matching every other invalid manifest. If this case should be louder, a warning from the loader is a one-line change.

Remark on the published schemas

The checked-in schemas under schemas/v3 aren't regenerated in this change. They don't yet contain the secret or import command schemas at all, so regenerating them appears to be a separate release-time step rather than something each change is expected to do.

@Gijsreyn
Gijs Reijn (Gijsreyn) force-pushed the gh-1729/main/feat-secret-extension-contract branch from 9604a96 to 773af9b Compare October 8, 2026 12:44
@Gijsreyn
Gijs Reijn (Gijsreyn) marked this pull request as ready for review October 8, 2026 12:44
Copilot AI balanced review requested due to automatic review settings October 8, 2026 12:44

Copilot AI 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.

🟡 Changes recommended

Validation currently permits an ambiguous argument object, can discard entire manifest lists, and has diagnostic, localization, and test-isolation issues.

5 open findings
What changed in this PR

Strengthens secret-extension manifests by enforcing required name/vault argument constraints during schema validation, discovery, and invocation.

Changes:

  • Adds secret argument validation and schema constraints.
  • Updates test tooling, manifests, and automated coverage.
  • Documents the stricter contract and compatibility impact.
File Description
tools/​dsctest/​src/​main.rs Ignores no-op arguments.
tools/​dsctest/​src/​args.rs Allows trailing no-op arguments.
tools/​dsctest/​deprecated/​deprecated.dsc.manifests.json Fixes deprecated secret manifest.
lib/​dsc-lib/​src/​extensions/​secret.rs Implements validation, invocation safeguards, schema rules, and tests.
lib/​dsc-lib/​src/​discovery/​command_discovery.rs Rejects invalid secret extensions during discovery.
lib/​dsc-lib/​locales/​en-us.toml Adds validation diagnostics.
dsc/​tests/​dsc_extension_secret.tests.ps1 Tests valid and invalid manifests.
docs/​reference/​schemas/​extension/​manifest/​secret.md Documents strict secret arguments.
docs/​reference/​schemas/​extension/​manifest/​root.md Updates root manifest guidance.
CHANGELOG.md Records the breaking validation change.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment on lines +1000 to +1001
if let Err(err) = secret.validate_args() {
return Err(DscError::InvalidManifest(t!("discovery.commandDiscovery.invalidSecretExtensionManifest", extension = manifest.r#type, path = path.to_string_lossy(), err = err).to_string()));
/// The arguments to pass to the command to retrieve a secret. The arguments must define the
/// secret name input argument exactly once and may define the vault input argument at most
/// once.
pub args: Vec<SecretArgKind>,
Comment on lines +77 to +78
let name_arg_count = self.args.iter().filter(|arg| matches!(arg, SecretArgKind::Name { .. })).count();
let vault_arg_count = self.args.iter().filter(|arg| matches!(arg, SecretArgKind::Vault { .. })).count();
Comment on lines +222 to +232
try {
$env:DSC_RESTRICTED_PATH = $TestDrive
Set-Content -Path "$TestDrive/secretInvalid.dsc.extension.json" -Value $manifest
$out = dsc -l info extension list 2> $TestDrive/error.log | ConvertFrom-Json
$errorLog = Get-Content -Raw -Path $TestDrive/error.log
$LASTEXITCODE | Should -Be 0 -Because $errorLog
@($out).type | Should -Not -Contain 'Test/SecretInvalid'
$errorLog | Should -BeLike "*INFO Failed to load manifest: *$expectedError*" -Because $errorLog
} finally {
$env:DSC_RESTRICTED_PATH = $null
}
///
/// The subschemas are defined separately so that each failure reports a specific message.
/// The `errorMessage` keyword is only emitted in the VS Code form of the schema.
fn transform_args_constraints(schema: &mut Schema) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Added a few notes on addressing the schema change.

Another option to consider is to define a newtype wrapper around Vec<SecretArgKind>, like (not tested, just quickly scribbled out):

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "Vec<serde_json::Value>")]
#[schemars(inline, !try_from)]
pub struct SecretArgs(Vec<SecretArgKind>);

impl SecretArgs {
    pub fn from_values(values: Vec<serde_json::Value>) -> Result<Self, DscError> {
        Self::validate(&values)?;

        // Construction logic
    }
    fn validate(values: &Vec<serde_json::Value>) -> Result<(), DscError> {
        // Validation logic for an array of values
    }
}

impl TryFrom<Vec<serde_json::Value>> for SecretArgs {
    type Error = DscError;

    fn try_from(values: Vec<serde_json::Value>) -> Result<Self, Self::Error> {
        SecretArgs::from_values(values)
    }
}

Which lets us pull the validation into the deserialization process and provide meaningful messages. Generally, we want to raise an error when we can't deserialize data because it fails constraints - this way we can just use the deserialized data in the rest of the code without needing to validate it repeatedly.

Comment on lines +93 to +139
/// Adds the validation subschemas that require `args` to define the secret name input
/// argument exactly once and the vault input argument at most once.
///
/// The subschemas are defined separately so that each failure reports a specific message.
/// The `errorMessage` keyword is only emitted in the VS Code form of the schema.
fn transform_args_constraints(schema: &mut Schema) {
let docs_url = "https://learn.microsoft.com/powershell/dsc/reference/schemas/extension/manifest/secret";
schema.insert("allOf".to_string(), json!([
{
"title": "Missing secret name input argument",
"properties": {
"args": {
"errorMessage": format!(
"The `secret` command doesn't define the secret name input argument. If you don't define the secret name input argument, DSC can't pass the secret name to the extension for retrieval. You must define exactly one argument in `secret.args` as a JSON object with the `nameArg` property. For more information, see: {docs_url}"
),
"contains": { "type": "object", "required": ["nameArg"] },
"minContains": 1
}
}
},
{
"title": "Multiple secret name input arguments",
"properties": {
"args": {
"errorMessage": format!(
"The `secret` command defines the secret name input argument more than once. You must define exactly one argument in `secret.args` as a JSON object with the `nameArg` property and remove the additional secret name input arguments. For more information, see: {docs_url}"
),
"contains": { "type": "object", "required": ["nameArg"] },
"maxContains": 1
}
}
},
{
"title": "Multiple vault input arguments",
"properties": {
"args": {
"errorMessage": format!(
"The `secret` command defines the vault input argument more than once. You can define at most one argument in `secret.args` as a JSON object with the `vaultArg` property. For more information, see: {docs_url}"
),
"contains": { "type": "object", "required": ["vaultArg"] },
"minContains": 0,
"maxContains": 1
}
}
}
]));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Recommend removing this function in favor of defining the constraints directly on the struct with the schemars attribute.

Suggested change
/// Adds the validation subschemas that require `args` to define the secret name input
/// argument exactly once and the vault input argument at most once.
///
/// The subschemas are defined separately so that each failure reports a specific message.
/// The `errorMessage` keyword is only emitted in the VS Code form of the schema.
fn transform_args_constraints(schema: &mut Schema) {
let docs_url = "https://learn.microsoft.com/powershell/dsc/reference/schemas/extension/manifest/secret";
schema.insert("allOf".to_string(), json!([
{
"title": "Missing secret name input argument",
"properties": {
"args": {
"errorMessage": format!(
"The `secret` command doesn't define the secret name input argument. If you don't define the secret name input argument, DSC can't pass the secret name to the extension for retrieval. You must define exactly one argument in `secret.args` as a JSON object with the `nameArg` property. For more information, see: {docs_url}"
),
"contains": { "type": "object", "required": ["nameArg"] },
"minContains": 1
}
}
},
{
"title": "Multiple secret name input arguments",
"properties": {
"args": {
"errorMessage": format!(
"The `secret` command defines the secret name input argument more than once. You must define exactly one argument in `secret.args` as a JSON object with the `nameArg` property and remove the additional secret name input arguments. For more information, see: {docs_url}"
),
"contains": { "type": "object", "required": ["nameArg"] },
"maxContains": 1
}
}
},
{
"title": "Multiple vault input arguments",
"properties": {
"args": {
"errorMessage": format!(
"The `secret` command defines the vault input argument more than once. You can define at most one argument in `secret.args` as a JSON object with the `vaultArg` property. For more information, see: {docs_url}"
),
"contains": { "type": "object", "required": ["vaultArg"] },
"minContains": 0,
"maxContains": 1
}
}
}
]));
}

/// The arguments to pass to the command to retrieve a secret. The arguments must define the
/// secret name input argument exactly once and may define the vault input argument at most
/// once.
pub args: Vec<SecretArgKind>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Recommend defining the static constraints with the schemars attribute directly on the field:

Suggested change
pub args: Vec<SecretArgKind>,
#[schemars(extend("allOf" = [
{
"title": schema_i18n!("args.constraints.nameArg.title"),
"description": schema_i18n!("args.constraints.nameArg.description"),
"markdownDescription": schema_i18n!("args.constraints.nameArg.markdownDescription"),
"errorMessage": schema_i18n!("args.constraints.nameArg.errorMessage"),
"contains": { "type": "object", "required": ["nameArg"] },
"minContains": 1,
"maxContains": 1,
},
{
"title": schema_i18n!("args.constraints.vaultArg.title"),
"description": schema_i18n!("args.constraints.vaultArg.description"),
"markdownDescription": schema_i18n!("args.constraints.vaultArg.markdownDescription"),
"errorMessage": schema_i18n!("args.constraints.vaultArg.errorMessage"),
"contains": { "type": "object", "required": ["vaultArg"] },
"minContains": 0,
"maxContains": 1,
},
]))]
pub args: Vec<SecretArgKind>,

Prefer as few constraints as possible for validation - while we can get better error messages by splitting min/max for the name argument, this requires an additional validation pass (one per object in allOf) and requires the schema reader to check multiple constraints that are idiomatically represented as a single constraint.

@Gijsreyn
Gijs Reijn (Gijsreyn) force-pushed the gh-1729/main/feat-secret-extension-contract branch from 773af9b to 6fcd53d Compare October 10, 2026 14:19
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.

Make it impossible to accidentally define a nonfunctional secret extension

3 participants