From 0ffcc308f2f1372ee2fcff46b6a9f18512d7485b Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:24:34 -0500 Subject: [PATCH] Secrets: gate exec dry-run and preflight resolution behind --allow-exec (#49417) * Secrets: gate exec dry-run resolution behind --allow-exec * Secrets: fix dry-run completeness and skipped exec audit semantics * Secrets: require --allow-exec for exec-containing apply writes * Docs: align secrets exec consent behavior * Changelog: note secrets exec consent gating --- CHANGELOG.md | 1 + docs/cli/index.md | 6 +- docs/cli/secrets.md | 19 +- docs/gateway/secrets-plan-contract.md | 10 + docs/gateway/secrets.md | 17 ++ src/cli/secrets-cli.test.ts | 271 +++++++++++++++++++++++++- src/cli/secrets-cli.ts | 39 +++- src/secrets/apply.test.ts | 116 +++++++++++ src/secrets/apply.ts | 114 +++++++++-- src/secrets/audit.test.ts | 58 +++++- src/secrets/audit.ts | 66 ++++++- src/secrets/configure.ts | 38 ++-- src/secrets/exec-resolution-policy.ts | 41 ++++ 13 files changed, 747 insertions(+), 49 deletions(-) create mode 100644 src/secrets/exec-resolution-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 817d507b1bb..c8263b6da3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,7 @@ Docs: https://docs.openclaw.ai - Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. - Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. - Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc. +- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @joshavant. ### Breaking diff --git a/docs/cli/index.md b/docs/cli/index.md index a247a4085de..d9d50733632 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -276,9 +276,9 @@ Note: plugins can add additional top-level commands (for example `openclaw voice ## Secrets - `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot. -- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift. -- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply. -- `openclaw secrets apply --from ` — apply a previously generated plan (`--dry-run` supported). +- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift (`--allow-exec` to execute exec providers during audit). +- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply (`--allow-exec` to execute exec providers during preflight and exec-containing apply flows). +- `openclaw secrets apply --from ` — apply a previously generated plan (`--dry-run` supported; use `--allow-exec` to permit exec providers in dry-run and exec-containing write plans). ## Plugins diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index f90a5de8ec0..baefdc91886 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -14,9 +14,9 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot Command roles: - `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes). -- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift. +- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift (exec refs are skipped unless `--allow-exec` is set). - `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required). -- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues. +- `apply`: execute a saved plan (`--dry-run` for validation only; dry-run skips exec checks by default, and write mode rejects exec-containing plans unless `--allow-exec` is set), then scrub targeted plaintext residues. Recommended operator loop: @@ -29,6 +29,8 @@ openclaw secrets audit --check openclaw secrets reload ``` +If your plan includes `exec` SecretRefs/providers, pass `--allow-exec` on both dry-run and write apply commands. + Exit code note for CI/gates: - `audit --check` returns `1` on findings. @@ -73,6 +75,7 @@ Header residue note: openclaw secrets audit openclaw secrets audit --check openclaw secrets audit --json +openclaw secrets audit --allow-exec ``` Exit behavior: @@ -83,6 +86,7 @@ Exit behavior: Report shape highlights: - `status`: `clean | findings | unresolved` +- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete` - `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount` - finding codes: - `PLAINTEXT_FOUND` @@ -115,6 +119,7 @@ Flags: - `--providers-only`: configure `secrets.providers` only, skip credential mapping. - `--skip-provider-setup`: skip provider setup and map credentials to existing providers. - `--agent `: scope `auth-profiles.json` target discovery and writes to one agent store. +- `--allow-exec`: allow exec SecretRef checks during preflight/apply (may execute provider commands). Notes: @@ -124,6 +129,7 @@ Notes: - `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow. - Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface). - It performs preflight resolution before apply. +- If preflight/apply includes exec refs, keep `--allow-exec` set for both steps. - Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled). - Apply path is one-way for scrubbed plaintext values. - Without `--apply`, CLI still prompts `Apply this plan now?` after preflight. @@ -141,10 +147,19 @@ Apply or preflight a plan generated previously: ```bash openclaw secrets apply --from /tmp/openclaw-secrets-plan.json +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json ``` +Exec behavior: + +- `--dry-run` validates preflight without writing files. +- exec SecretRef checks are skipped by default in dry-run. +- write mode rejects plans that contain exec SecretRefs/providers unless `--allow-exec` is set. +- Use `--allow-exec` to opt in to exec provider checks/execution in either mode. + Plan contract details (allowed target paths, validation rules, and failure semantics): - [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) diff --git a/docs/gateway/secrets-plan-contract.md b/docs/gateway/secrets-plan-contract.md index 83ed10b06dd..b27518bdb1e 100644 --- a/docs/gateway/secrets-plan-contract.md +++ b/docs/gateway/secrets-plan-contract.md @@ -81,6 +81,12 @@ Invalid plan target path for models.providers.apiKey: models.providers.openai.ba No writes are committed for an invalid plan. +## Exec provider consent behavior + +- `--dry-run` skips exec SecretRef checks by default. +- Plans containing exec SecretRefs/providers are rejected in write mode unless `--allow-exec` is set. +- When validating/applying exec-containing plans, pass `--allow-exec` in both dry-run and write commands. + ## Runtime and audit scope notes - Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage. @@ -94,6 +100,10 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run # Then apply for real openclaw secrets apply --from /tmp/openclaw-secrets-plan.json + +# For exec-containing plans, opt in explicitly in both modes +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec ``` If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 1379d8e0202..d404399ac65 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -414,6 +414,11 @@ Findings include: - precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs) - legacy residues (`auth.json`, OAuth reminders) +Exec note: + +- By default, audit skips exec SecretRef resolvability checks to avoid command side effects. +- Use `openclaw secrets audit --allow-exec` to execute exec providers during audit. + Header residue note: - Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). @@ -429,6 +434,11 @@ Interactive helper that: - runs preflight resolution - can apply immediately +Exec note: + +- Preflight skips exec SecretRef checks unless `--allow-exec` is set. +- If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too. + Helpful modes: - `openclaw secrets configure --providers-only` @@ -447,9 +457,16 @@ Apply a saved plan: ```bash openclaw secrets apply --from /tmp/openclaw-secrets-plan.json +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec ``` +Exec note: + +- dry-run skips exec checks unless `--allow-exec` is set. +- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set. + For strict target/path contract details and exact rejection rules, see: - [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) diff --git a/src/cli/secrets-cli.test.ts b/src/cli/secrets-cli.test.ts index 90a7cb88d8b..86e7f52a6ce 100644 --- a/src/cli/secrets-cli.test.ts +++ b/src/cli/secrets-cli.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; @@ -23,7 +26,7 @@ vi.mock("../runtime.js", () => ({ })); vi.mock("../secrets/audit.js", () => ({ - runSecretsAudit: () => runSecretsAudit(), + runSecretsAudit: (options: unknown) => runSecretsAudit(options), resolveSecretsAuditExitCode: (report: unknown, check: boolean) => resolveSecretsAuditExitCode(report, check), })); @@ -90,6 +93,11 @@ describe("secrets CLI", () => { shadowedRefCount: 0, legacyResidueCount: 0, }, + resolution: { + refsChecked: 0, + skippedExecRefs: 0, + resolvabilityComplete: true, + }, findings: [], }); resolveSecretsAuditExitCode.mockReturnValue(1); @@ -97,10 +105,42 @@ describe("secrets CLI", () => { await expect( createProgram().parseAsync(["secrets", "audit", "--check"], { from: "user" }), ).rejects.toBeTruthy(); - expect(runSecretsAudit).toHaveBeenCalled(); + expect(runSecretsAudit).toHaveBeenCalledWith( + expect.objectContaining({ + allowExec: false, + }), + ); expect(resolveSecretsAuditExitCode).toHaveBeenCalledWith(expect.anything(), true); }); + it("forwards --allow-exec to secrets audit", async () => { + runSecretsAudit.mockResolvedValue({ + version: 1, + status: "clean", + filesScanned: [], + summary: { + plaintextCount: 0, + unresolvedRefCount: 0, + shadowedRefCount: 0, + legacyResidueCount: 0, + }, + resolution: { + refsChecked: 1, + skippedExecRefs: 0, + resolvabilityComplete: true, + }, + findings: [], + }); + resolveSecretsAuditExitCode.mockReturnValue(0); + + await createProgram().parseAsync(["secrets", "audit", "--allow-exec"], { from: "user" }); + expect(runSecretsAudit).toHaveBeenCalledWith( + expect.objectContaining({ + allowExec: true, + }), + ); + }); + it("runs secrets configure then apply when confirmed", async () => { runSecretsConfigureInteractive.mockResolvedValue({ plan: { @@ -125,6 +165,12 @@ describe("secrets CLI", () => { mode: "dry-run", changed: true, changedFiles: ["/tmp/openclaw.json"], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 1, + skippedExecRefs: 0, warningCount: 0, warnings: [], }, @@ -134,6 +180,12 @@ describe("secrets CLI", () => { mode: "write", changed: true, changedFiles: ["/tmp/openclaw.json"], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 1, + skippedExecRefs: 0, warningCount: 0, warnings: [], }); @@ -169,6 +221,12 @@ describe("secrets CLI", () => { mode: "dry-run", changed: false, changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 0, + skippedExecRefs: 0, warningCount: 0, warnings: [], }, @@ -179,6 +237,215 @@ describe("secrets CLI", () => { expect(runSecretsConfigureInteractive).toHaveBeenCalledWith( expect.objectContaining({ agentId: "ops", + allowExecInPreflight: false, + }), + ); + }); + + it("forwards --allow-exec to secrets apply dry-run", async () => { + const planPath = path.join( + os.tmpdir(), + `openclaw-secrets-cli-test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + await fs.writeFile( + planPath, + `${JSON.stringify({ + version: 1, + protocolVersion: 1, + generatedAt: new Date().toISOString(), + generatedBy: "manual", + targets: [], + })}\n`, + "utf8", + ); + runSecretsApply.mockResolvedValue({ + mode: "dry-run", + changed: false, + changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 0, + skippedExecRefs: 0, + warningCount: 0, + warnings: [], + }); + + await createProgram().parseAsync( + ["secrets", "apply", "--from", planPath, "--dry-run", "--allow-exec"], + { + from: "user", + }, + ); + expect(runSecretsApply).toHaveBeenCalledWith( + expect.objectContaining({ + write: false, + allowExec: true, + }), + ); + await fs.rm(planPath, { force: true }); + }); + + it("forwards --allow-exec to secrets apply write mode", async () => { + const planPath = path.join( + os.tmpdir(), + `openclaw-secrets-cli-test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + await fs.writeFile( + planPath, + `${JSON.stringify({ + version: 1, + protocolVersion: 1, + generatedAt: new Date().toISOString(), + generatedBy: "manual", + targets: [], + })}\n`, + "utf8", + ); + runSecretsApply.mockResolvedValue({ + mode: "write", + changed: false, + changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 0, + skippedExecRefs: 0, + warningCount: 0, + warnings: [], + }); + + await createProgram().parseAsync(["secrets", "apply", "--from", planPath, "--allow-exec"], { + from: "user", + }); + expect(runSecretsApply).toHaveBeenCalledWith( + expect.objectContaining({ + write: true, + allowExec: true, + }), + ); + await fs.rm(planPath, { force: true }); + }); + + it("does not print skipped-exec note when apply dry-run skippedExecRefs is zero", async () => { + const planPath = path.join( + os.tmpdir(), + `openclaw-secrets-cli-test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + await fs.writeFile( + planPath, + `${JSON.stringify({ + version: 1, + protocolVersion: 1, + generatedAt: new Date().toISOString(), + generatedBy: "manual", + targets: [], + })}\n`, + "utf8", + ); + runSecretsApply.mockResolvedValue({ + mode: "dry-run", + changed: false, + changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: false, + }, + refsChecked: 0, + skippedExecRefs: 0, + warningCount: 0, + warnings: [], + }); + + await createProgram().parseAsync(["secrets", "apply", "--from", planPath, "--dry-run"], { + from: "user", + }); + expect(runtimeLogs.some((line) => line.includes("Secrets apply dry-run note: skipped"))).toBe( + false, + ); + await fs.rm(planPath, { force: true }); + }); + + it("does not print skipped-exec note when configure preflight skippedExecRefs is zero", async () => { + runSecretsConfigureInteractive.mockResolvedValue({ + plan: { + version: 1, + protocolVersion: 1, + generatedAt: "2026-02-26T00:00:00.000Z", + generatedBy: "openclaw secrets configure", + targets: [], + }, + preflight: { + mode: "dry-run", + changed: false, + changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: false, + }, + refsChecked: 0, + skippedExecRefs: 0, + warningCount: 0, + warnings: [], + }, + }); + confirm.mockResolvedValue(false); + + await createProgram().parseAsync(["secrets", "configure"], { from: "user" }); + expect(runtimeLogs.some((line) => line.includes("Preflight note: skipped"))).toBe(false); + }); + + it("forwards --allow-exec to configure preflight and apply", async () => { + runSecretsConfigureInteractive.mockResolvedValue({ + plan: { + version: 1, + protocolVersion: 1, + generatedAt: "2026-02-26T00:00:00.000Z", + generatedBy: "openclaw secrets configure", + targets: [], + }, + preflight: { + mode: "dry-run", + changed: false, + changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 0, + skippedExecRefs: 0, + warningCount: 0, + warnings: [], + }, + }); + runSecretsApply.mockResolvedValue({ + mode: "write", + changed: false, + changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: 0, + skippedExecRefs: 0, + warningCount: 0, + warnings: [], + }); + + await createProgram().parseAsync(["secrets", "configure", "--apply", "--yes", "--allow-exec"], { + from: "user", + }); + expect(runSecretsConfigureInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + allowExecInPreflight: true, + }), + ); + expect(runSecretsApply).toHaveBeenCalledWith( + expect.objectContaining({ + write: true, + allowExec: true, }), ); }); diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index 463677a7904..4e0257b5dd9 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -15,6 +15,7 @@ type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean }; type SecretsAuditOptions = { check?: boolean; json?: boolean; + allowExec?: boolean; }; type SecretsConfigureOptions = { apply?: boolean; @@ -23,11 +24,13 @@ type SecretsConfigureOptions = { providersOnly?: boolean; skipProviderSetup?: boolean; agent?: string; + allowExec?: boolean; json?: boolean; }; type SecretsApplyOptions = { from: string; dryRun?: boolean; + allowExec?: boolean; json?: boolean; }; @@ -82,10 +85,17 @@ export function registerSecretsCli(program: Command) { .command("audit") .description("Audit plaintext secrets, unresolved refs, and precedence drift") .option("--check", "Exit non-zero when findings are present", false) + .option( + "--allow-exec", + "Allow exec SecretRef resolution during audit (may execute provider commands)", + false, + ) .option("--json", "Output JSON", false) .action(async (opts: SecretsAuditOptions) => { try { - const report = await runSecretsAudit(); + const report = await runSecretsAudit({ + allowExec: Boolean(opts.allowExec), + }); if (opts.json) { defaultRuntime.log(JSON.stringify(report, null, 2)); } else { @@ -102,6 +112,11 @@ export function registerSecretsCli(program: Command) { defaultRuntime.log(`... ${report.findings.length - 20} more finding(s).`); } } + if (report.resolution.skippedExecRefs > 0) { + defaultRuntime.log( + `Audit note: skipped ${report.resolution.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during audit.`, + ); + } } const exitCode = resolveSecretsAuditExitCode(report, Boolean(opts.check)); if (exitCode !== 0) { @@ -128,6 +143,11 @@ export function registerSecretsCli(program: Command) { "--agent ", "Agent id for auth-profiles targets (default: configured default agent)", ) + .option( + "--allow-exec", + "Allow exec SecretRef preflight checks (may execute provider commands)", + false, + ) .option("--plan-out ", "Write generated plan JSON to a file") .option("--json", "Output JSON", false) .action(async (opts: SecretsConfigureOptions) => { @@ -136,6 +156,7 @@ export function registerSecretsCli(program: Command) { providersOnly: Boolean(opts.providersOnly), skipProviderSetup: Boolean(opts.skipProviderSetup), agentId: typeof opts.agent === "string" ? opts.agent : undefined, + allowExecInPreflight: Boolean(opts.allowExec), }); if (opts.planOut) { fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8"); @@ -160,6 +181,14 @@ export function registerSecretsCli(program: Command) { defaultRuntime.log(`- warning: ${warning}`); } } + if ( + !configured.preflight.checks.resolvabilityComplete && + configured.preflight.skippedExecRefs > 0 + ) { + defaultRuntime.log( + `Preflight note: skipped ${configured.preflight.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during preflight.`, + ); + } const providerUpserts = Object.keys(configured.plan.providerUpserts ?? {}).length; const providerDeletes = configured.plan.providerDeletes?.length ?? 0; defaultRuntime.log( @@ -196,6 +225,7 @@ export function registerSecretsCli(program: Command) { const result = await runSecretsApply({ plan: configured.plan, write: true, + allowExec: Boolean(opts.allowExec), }); if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); @@ -218,6 +248,7 @@ export function registerSecretsCli(program: Command) { .description("Apply a previously generated secrets plan") .requiredOption("--from ", "Path to plan JSON") .option("--dry-run", "Validate/preflight only", false) + .option("--allow-exec", "Allow exec SecretRef checks (may execute provider commands)", false) .option("--json", "Output JSON", false) .action(async (opts: SecretsApplyOptions) => { try { @@ -225,6 +256,7 @@ export function registerSecretsCli(program: Command) { const result = await runSecretsApply({ plan, write: !opts.dryRun, + allowExec: Boolean(opts.allowExec), }); if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); @@ -236,6 +268,11 @@ export function registerSecretsCli(program: Command) { ? `Secrets apply dry run: ${result.changedFiles.length} file(s) would change.` : "Secrets apply dry run: no changes.", ); + if (!result.checks.resolvabilityComplete && result.skippedExecRefs > 0) { + defaultRuntime.log( + `Secrets apply dry-run note: skipped ${result.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ); + } return; } defaultRuntime.log( diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index d71c98ac389..bb5f230d839 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -193,6 +193,8 @@ describe("secrets apply", () => { const dryRun = await runSecretsApply({ plan, env: fixture.env, write: false }); expect(dryRun.mode).toBe("dry-run"); expect(dryRun.changed).toBe(true); + expect(dryRun.skippedExecRefs).toBe(0); + expect(dryRun.checks.resolvabilityComplete).toBe(true); const applied = await runSecretsApply({ plan, env: fixture.env, write: true }); expect(applied.mode).toBe("write"); @@ -220,6 +222,120 @@ describe("secrets apply", () => { expect(nextEnv).toContain("UNRELATED=value"); }); + it("skips exec SecretRef checks during dry-run unless explicitly allowed", async () => { + if (process.platform === "win32") { + return; + } + const execLogPath = path.join(fixture.rootDir, "exec-calls.log"); + const execScriptPath = path.join(fixture.rootDir, "resolver.sh"); + await fs.writeFile( + execScriptPath, + [ + "#!/bin/sh", + `printf 'x\\n' >> ${JSON.stringify(execLogPath)}`, + "cat >/dev/null", + 'printf \'{"protocolVersion":1,"values":{"providers/openai/apiKey":"sk-openai-exec"}}\'', // pragma: allowlist secret + ].join("\n"), + { encoding: "utf8", mode: 0o700 }, + ); + + await writeJsonFile(fixture.configPath, { + secrets: { + providers: { + execmain: { + source: "exec", + command: execScriptPath, + jsonOnly: true, + timeoutMs: 20_000, + noOutputTimeoutMs: 10_000, + }, + }, + }, + models: { + providers: { + openai: createOpenAiProviderConfig(), + }, + }, + }); + + const plan = createPlan({ + targets: [ + { + type: "models.providers.apiKey", + path: "models.providers.openai.apiKey", + providerId: "openai", + ref: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" }, + }, + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + const dryRunSkipped = await runSecretsApply({ plan, env: fixture.env, write: false }); + expect(dryRunSkipped.mode).toBe("dry-run"); + expect(dryRunSkipped.skippedExecRefs).toBe(1); + expect(dryRunSkipped.checks.resolvabilityComplete).toBe(false); + await expect(fs.stat(execLogPath)).rejects.toMatchObject({ code: "ENOENT" }); + + const dryRunAllowed = await runSecretsApply({ + plan, + env: fixture.env, + write: false, + allowExec: true, + }); + expect(dryRunAllowed.mode).toBe("dry-run"); + expect(dryRunAllowed.skippedExecRefs).toBe(0); + const callLog = await fs.readFile(execLogPath, "utf8"); + expect(callLog.split("\n").filter((line) => line.trim().length > 0).length).toBeGreaterThan(0); + }); + + it("rejects write mode for exec plans unless allowExec is set", async () => { + const plan = createPlan({ + targets: [ + { + type: "models.providers.apiKey", + path: "models.providers.openai.apiKey", + providerId: "openai", + ref: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" }, + }, + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + await expect(runSecretsApply({ plan, env: fixture.env, write: true })).rejects.toThrow( + "Plan contains exec SecretRefs/providers. Re-run with --allow-exec.", + ); + }); + + it("rejects write mode for plans with exec provider upserts unless allowExec is set", async () => { + const plan = createPlan({ + targets: [createOpenAiProviderTarget()], + providerUpserts: { + execmain: { + source: "exec", + command: "/bin/echo", + args: ["ok"], + }, + }, + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + await expect(runSecretsApply({ plan, env: fixture.env, write: true })).rejects.toThrow( + "Plan contains exec SecretRefs/providers. Re-run with --allow-exec.", + ); + }); + it("applies auth-profiles sibling ref targets to the scoped agent store", async () => { const plan: SecretsApplyPlan = { version: 1, diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 85408954239..0d02466530d 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -14,6 +14,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js"; import { createSecretsConfigIO } from "./config-io.js"; +import { getSkippedExecRefStaticError } from "./exec-resolution-policy.js"; import { deletePathStrict, getPath, setPathCreateStrict } from "./path-utils.js"; import { type SecretsApplyPlan, @@ -54,6 +55,9 @@ type ProjectedState = { envRawByPath: Map; changedFiles: Set; warnings: string[]; + refsChecked: number; + skippedExecRefs: number; + resolvabilityComplete: boolean; }; type ResolvedPlanTargetEntry = { @@ -77,10 +81,23 @@ export type SecretsApplyResult = { mode: "dry-run" | "write"; changed: boolean; changedFiles: string[]; + checks: { + resolvability: boolean; + resolvabilityComplete: boolean; + }; + refsChecked: number; + skippedExecRefs: number; warningCount: number; warnings: string[]; }; +function planContainsExecReferences(plan: SecretsApplyPlan): boolean { + if (plan.targets.some((target) => target.ref.source === "exec")) { + return true; + } + return Object.values(plan.providerUpserts ?? {}).some((provider) => provider.source === "exec"); +} + function resolveTarget( target: SecretsPlanTarget, ): NonNullable> { @@ -179,6 +196,8 @@ function applyProviderPlanMutations(params: { async function projectPlanState(params: { plan: SecretsApplyPlan; env: NodeJS.ProcessEnv; + write: boolean; + allowExecInDryRun: boolean; }): Promise { const io = createSecretsConfigIO({ env: params.env }); const { snapshot, writeOptions } = await io.readConfigFileSnapshotForWrite(); @@ -237,11 +256,13 @@ async function projectPlanState(params: { enabled: options.scrubEnv, }); - await validateProjectedSecretsState({ + const validation = await validateProjectedSecretsState({ env: params.env, nextConfig, resolvedTargets: targetMutations.resolvedTargets, authStoreByPath, + write: params.write, + allowExecInDryRun: params.allowExecInDryRun, }); return { @@ -253,6 +274,9 @@ async function projectPlanState(params: { envRawByPath, changedFiles, warnings, + refsChecked: validation.refsChecked, + skippedExecRefs: validation.skippedExecRefs, + resolvabilityComplete: validation.resolvabilityComplete, }; } @@ -629,14 +653,30 @@ async function validateProjectedSecretsState(params: { nextConfig: OpenClawConfig; resolvedTargets: ResolvedPlanTargetEntry[]; authStoreByPath: Map>; -}): Promise { + write: boolean; + allowExecInDryRun: boolean; +}): Promise<{ refsChecked: number; skippedExecRefs: number; resolvabilityComplete: boolean }> { const cache = {}; + let refsChecked = 0; + let skippedExecRefs = 0; for (const { target, resolved: resolvedTarget } of params.resolvedTargets) { + if (!params.write && target.ref.source === "exec" && !params.allowExecInDryRun) { + skippedExecRefs += 1; + const staticError = getSkippedExecRefStaticError({ + ref: target.ref, + config: params.nextConfig, + }); + if (staticError) { + throw new Error(staticError); + } + continue; + } const resolved = await resolveSecretRefValue(target.ref, { config: params.nextConfig, env: params.env, cache, }); + refsChecked += 1; assertExpectedResolvedSecretValue({ value: resolved, expected: resolvedTarget.entry.expectedResolvedValue, @@ -651,20 +691,28 @@ async function validateProjectedSecretsState(params: { for (const [authStorePath, store] of params.authStoreByPath.entries()) { authStoreLookup.set(resolveUserPath(authStorePath), store); } - await prepareSecretsRuntimeSnapshot({ - config: params.nextConfig, - env: params.env, - loadAuthStore: (agentDir?: string) => { - const storePath = resolveUserPath(resolveAuthStorePath(agentDir)); - const override = authStoreLookup.get(storePath); - if (override) { - return structuredClone(override) as unknown as ReturnType< - typeof loadAuthProfileStoreForSecretsRuntime - >; - } - return loadAuthProfileStoreForSecretsRuntime(agentDir); - }, - }); + if (params.write || params.allowExecInDryRun) { + await prepareSecretsRuntimeSnapshot({ + config: params.nextConfig, + env: params.env, + loadAuthStore: (agentDir?: string) => { + const storePath = resolveUserPath(resolveAuthStorePath(agentDir)); + const override = authStoreLookup.get(storePath); + if (override) { + return structuredClone(override) as unknown as ReturnType< + typeof loadAuthProfileStoreForSecretsRuntime + >; + } + return loadAuthProfileStoreForSecretsRuntime(agentDir); + }, + }); + } + return { + refsChecked, + skippedExecRefs, + // Dry-run without exec consent intentionally skips full runtime preflight. + resolvabilityComplete: params.write || params.allowExecInDryRun || skippedExecRefs === 0, + }; } function captureFileSnapshot(pathname: string): FileSnapshot { @@ -701,15 +749,33 @@ export async function runSecretsApply(params: { plan: SecretsApplyPlan; env?: NodeJS.ProcessEnv; write?: boolean; + allowExec?: boolean; }): Promise { const env = params.env ?? process.env; - const projected = await projectPlanState({ plan: params.plan, env }); + const write = params.write === true; + const allowExec = Boolean(params.allowExec); + if (write && planContainsExecReferences(params.plan) && !allowExec) { + throw new Error("Plan contains exec SecretRefs/providers. Re-run with --allow-exec."); + } + const allowExecInDryRun = write ? true : allowExec; + const projected = await projectPlanState({ + plan: params.plan, + env, + write, + allowExecInDryRun, + }); const changedFiles = [...projected.changedFiles].toSorted(); - if (!params.write) { + if (!write) { return { mode: "dry-run", changed: changedFiles.length > 0, changedFiles, + checks: { + resolvability: true, + resolvabilityComplete: projected.resolvabilityComplete, + }, + refsChecked: projected.refsChecked, + skippedExecRefs: projected.skippedExecRefs, warningCount: projected.warnings.length, warnings: projected.warnings, }; @@ -719,6 +785,12 @@ export async function runSecretsApply(params: { mode: "write", changed: false, changedFiles: [], + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: projected.refsChecked, + skippedExecRefs: 0, warningCount: projected.warnings.length, warnings: projected.warnings, }; @@ -771,6 +843,12 @@ export async function runSecretsApply(params: { mode: "write", changed: changedFiles.length > 0, changedFiles, + checks: { + resolvability: true, + resolvabilityComplete: true, + }, + refsChecked: projected.refsChecked, + skippedExecRefs: 0, warningCount: projected.warnings.length, warnings: projected.warnings, }; diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index b8a22cdcb43..cf49c2d9308 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -190,7 +190,57 @@ describe("secrets audit", () => { expect(hasFinding(report, (entry) => entry.code === "REF_UNRESOLVED")).toBe(true); }); - it("batches ref resolution per provider during audit", async () => { + it("skips exec ref resolution during audit unless explicitly allowed", async () => { + if (process.platform === "win32") { + return; + } + const execLogPath = path.join(fixture.rootDir, "exec-calls-skipped.log"); + const execScriptPath = path.join(fixture.rootDir, "resolver-skipped.sh"); + await fs.writeFile( + execScriptPath, + [ + "#!/bin/sh", + `printf 'x\\n' >> ${JSON.stringify(execLogPath)}`, + "cat >/dev/null", + 'printf \'{"protocolVersion":1,"values":{"providers/openai/apiKey":"value:providers/openai/apiKey"}}\'', // pragma: allowlist secret + ].join("\n"), + { encoding: "utf8", mode: 0o700 }, + ); + + await writeJsonFile(fixture.configPath, { + secrets: { + providers: { + execmain: { + source: "exec", + command: execScriptPath, + jsonOnly: true, + timeoutMs: 20_000, + noOutputTimeoutMs: 10_000, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + await fs.rm(fixture.authStorePath, { force: true }); + await fs.writeFile(fixture.envPath, "", "utf8"); + + const report = await runSecretsAudit({ env: fixture.env }); + expect(report.resolution.resolvabilityComplete).toBe(false); + expect(report.resolution.skippedExecRefs).toBe(1); + expect(report.summary.unresolvedRefCount).toBe(0); + await expect(fs.stat(execLogPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("batches ref resolution per provider during audit when --allow-exec is enabled", async () => { if (process.platform === "win32") { return; } @@ -239,7 +289,7 @@ describe("secrets audit", () => { await fs.rm(fixture.authStorePath, { force: true }); await fs.writeFile(fixture.envPath, "", "utf8"); - const report = await runSecretsAudit({ env: fixture.env }); + const report = await runSecretsAudit({ env: fixture.env, allowExec: true }); expect(report.summary.unresolvedRefCount).toBe(0); const callLog = await fs.readFile(execLogPath, "utf8"); @@ -247,7 +297,7 @@ describe("secrets audit", () => { expect(callCount).toBe(1); }); - it("short-circuits per-ref fallback for provider-wide batch failures", async () => { + it("short-circuits per-ref fallback for provider-wide batch failures when --allow-exec is enabled", async () => { if (process.platform === "win32") { return; } @@ -303,7 +353,7 @@ describe("secrets audit", () => { await fs.rm(fixture.authStorePath, { force: true }); await fs.writeFile(fixture.envPath, "", "utf8"); - const report = await runSecretsAudit({ env: fixture.env }); + const report = await runSecretsAudit({ env: fixture.env, allowExec: true }); expect(report.summary.unresolvedRefCount).toBeGreaterThanOrEqual(2); const callLog = await fs.readFile(execLogPath, "utf8"); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 15d7157acea..701a0cdd30e 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -13,6 +13,7 @@ import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js"; import { createSecretsConfigIO } from "./config-io.js"; +import { getSkippedExecRefStaticError, selectRefsForExecPolicy } from "./exec-resolution-policy.js"; import { listKnownSecretEnvVarNames } from "./provider-env-vars.js"; import { secretRefKey } from "./ref-contract.js"; import { @@ -59,6 +60,11 @@ export type SecretsAuditStatus = "clean" | "findings" | "unresolved"; // pragma: export type SecretsAuditReport = { version: 1; status: SecretsAuditStatus; + resolution: { + refsChecked: number; + skippedExecRefs: number; + resolvabilityComplete: boolean; + }; filesScanned: string[]; summary: { plaintextCount: number; @@ -456,9 +462,13 @@ async function collectUnresolvedRefFindings(params: { collector: AuditCollector; config: OpenClawConfig; env: NodeJS.ProcessEnv; -}): Promise { + allowExec: boolean; +}): Promise<{ refsChecked: number; skippedExecRefs: number }> { const cache: SecretRefResolveCache = {}; const refsByProvider = new Map>(); + const skippedRefKeys = new Set(); + let refsChecked = 0; + let skippedExecRefs = 0; for (const assignment of params.collector.refAssignments) { const providerKey = `${assignment.ref.source}:${assignment.ref.provider}`; let refsForProvider = refsByProvider.get(providerKey); @@ -474,9 +484,30 @@ async function collectUnresolvedRefFindings(params: { for (const refsForProvider of refsByProvider.values()) { const refs = [...refsForProvider.values()]; + const selectedRefs = selectRefsForExecPolicy({ + refs, + allowExec: params.allowExec, + }); + if (selectedRefs.skippedExecRefs.length > 0) { + skippedExecRefs += selectedRefs.skippedExecRefs.length; + for (const ref of selectedRefs.skippedExecRefs) { + skippedRefKeys.add(secretRefKey(ref)); + const staticError = getSkippedExecRefStaticError({ + ref, + config: params.config, + }); + if (staticError) { + errorsByRefKey.set(secretRefKey(ref), new Error(staticError)); + } + } + } + if (selectedRefs.refsToResolve.length === 0) { + continue; + } + refsChecked += selectedRefs.refsToResolve.length; const provider = refs[0]?.provider; try { - const resolved = await resolveSecretRefValues(refs, { + const resolved = await resolveSecretRefValues(selectedRefs.refsToResolve, { config: params.config, env: params.env, cache, @@ -487,7 +518,7 @@ async function collectUnresolvedRefFindings(params: { continue; } catch (err) { if (provider && isProviderScopedSecretResolutionError(err)) { - for (const ref of refs) { + for (const ref of selectedRefs.refsToResolve) { errorsByRefKey.set(secretRefKey(ref), err); } continue; @@ -495,7 +526,7 @@ async function collectUnresolvedRefFindings(params: { // Fall back to per-ref resolution for provider-specific pinpoint errors. } - const tasks = refs.map( + const tasks = selectedRefs.refsToResolve.map( (ref) => async (): Promise<{ key: string; resolved: unknown }> => ({ key: secretRefKey(ref), resolved: await resolveSecretRefValue(ref, { @@ -507,10 +538,10 @@ async function collectUnresolvedRefFindings(params: { ); const fallback = await runTasksWithConcurrency({ tasks, - limit: Math.min(REF_RESOLVE_FALLBACK_CONCURRENCY, refs.length), + limit: Math.min(REF_RESOLVE_FALLBACK_CONCURRENCY, selectedRefs.refsToResolve.length), errorMode: "continue", onTaskError: (error, index) => { - const ref = refs[index]; + const ref = selectedRefs.refsToResolve[index]; if (!ref) { return; } @@ -527,6 +558,9 @@ async function collectUnresolvedRefFindings(params: { for (const assignment of params.collector.refAssignments) { const key = secretRefKey(assignment.ref); + if (skippedRefKeys.has(key) && !errorsByRefKey.has(key)) { + continue; + } const resolveErr = errorsByRefKey.get(key); if (resolveErr) { addFinding(params.collector, { @@ -567,6 +601,10 @@ async function collectUnresolvedRefFindings(params: { }); } } + return { + refsChecked, + skippedExecRefs, + }; } function collectShadowingFindings(collector: AuditCollector): void { @@ -601,9 +639,11 @@ function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport[ export async function runSecretsAudit( params: { env?: NodeJS.ProcessEnv; + allowExec?: boolean; } = {}, ): Promise { const env = params.env ?? process.env; + const allowExec = Boolean(params.allowExec); const io = createSecretsConfigIO({ env }); const snapshot = await io.readConfigFileSnapshot(); const configPath = resolveUserPath(snapshot.path); @@ -620,6 +660,11 @@ export async function runSecretsAudit( const stateDir = resolveStateDir(env, os.homedir); const envPath = path.join(resolveConfigDir(env, os.homedir), ".env"); const config = snapshot.valid ? snapshot.config : ({} as OpenClawConfig); + let resolution = { + refsChecked: 0, + skippedExecRefs: 0, + resolvabilityComplete: true, + }; if (snapshot.valid) { collectConfigSecrets({ @@ -640,11 +685,17 @@ export async function runSecretsAudit( collector, }); } - await collectUnresolvedRefFindings({ + const unresolvedRefResult = await collectUnresolvedRefFindings({ collector, config, env, + allowExec, }); + resolution = { + refsChecked: unresolvedRefResult.refsChecked, + skippedExecRefs: unresolvedRefResult.skippedExecRefs, + resolvabilityComplete: unresolvedRefResult.skippedExecRefs === 0, + }; collectShadowingFindings(collector); } else { addFinding(collector, { @@ -676,6 +727,7 @@ export async function runSecretsAudit( return { version: 1, status, + resolution, filesScanned: [...collector.filesScanned].toSorted(), summary, findings: collector.findings, diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index a07d3b45903..9dfd4a92647 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -18,6 +18,7 @@ import { hasConfigurePlanChanges, type ConfigureCandidate, } from "./configure-plan.js"; +import { getSkippedExecRefStaticError } from "./exec-resolution-policy.js"; import type { SecretsApplyPlan } from "./plan.js"; import { PROVIDER_ENV_VARS } from "./provider-env-vars.js"; import { @@ -748,6 +749,7 @@ export async function runSecretsConfigureInteractive( providersOnly?: boolean; skipProviderSetup?: boolean; agentId?: string; + allowExecInPreflight?: boolean; } = {}, ): Promise { if (!process.stdin.isTTY) { @@ -758,6 +760,7 @@ export async function runSecretsConfigureInteractive( } const env = params.env ?? process.env; + const allowExecInPreflight = Boolean(params.allowExecInPreflight); const io = createSecretsConfigIO({ env }); const { snapshot } = await io.readConfigFileSnapshotForWrite(); if (!snapshot.valid) { @@ -940,18 +943,28 @@ export async function runSecretsConfigureInteractive( provider: providerAlias, id: String(id).trim(), }; - const resolved = await resolveSecretRefValue(ref, { - config: stagedConfig, - env, - }); - assertExpectedResolvedSecretValue({ - value: resolved, - expected: candidate.expectedResolvedValue, - errorMessage: - candidate.expectedResolvedValue === "string" - ? `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a non-empty string.` - : `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a supported value type.`, - }); + if (ref.source === "exec" && !allowExecInPreflight) { + const staticError = getSkippedExecRefStaticError({ + ref, + config: stagedConfig, + }); + if (staticError) { + throw new Error(staticError); + } + } else { + const resolved = await resolveSecretRefValue(ref, { + config: stagedConfig, + env, + }); + assertExpectedResolvedSecretValue({ + value: resolved, + expected: candidate.expectedResolvedValue, + errorMessage: + candidate.expectedResolvedValue === "string" + ? `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a non-empty string.` + : `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a supported value type.`, + }); + } const next = { ...candidate, @@ -985,6 +998,7 @@ export async function runSecretsConfigureInteractive( plan, env, write: false, + allowExec: allowExecInPreflight, }); return { plan, preflight }; diff --git a/src/secrets/exec-resolution-policy.ts b/src/secrets/exec-resolution-policy.ts new file mode 100644 index 00000000000..c29d4481218 --- /dev/null +++ b/src/secrets/exec-resolution-policy.ts @@ -0,0 +1,41 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { SecretRef } from "../config/types.secrets.js"; +import { formatExecSecretRefIdValidationMessage, isValidExecSecretRefId } from "./ref-contract.js"; + +export function selectRefsForExecPolicy(params: { refs: SecretRef[]; allowExec: boolean }): { + refsToResolve: SecretRef[]; + skippedExecRefs: SecretRef[]; +} { + const refsToResolve: SecretRef[] = []; + const skippedExecRefs: SecretRef[] = []; + for (const ref of params.refs) { + if (ref.source === "exec" && !params.allowExec) { + skippedExecRefs.push(ref); + continue; + } + refsToResolve.push(ref); + } + return { refsToResolve, skippedExecRefs }; +} + +export function getSkippedExecRefStaticError(params: { + ref: SecretRef; + config: OpenClawConfig; +}): string | null { + const id = params.ref.id.trim(); + const refLabel = `${params.ref.source}:${params.ref.provider}:${id}`; + if (!id) { + return "Error: Secret reference id is empty."; + } + if (!isValidExecSecretRefId(id)) { + return `Error: ${formatExecSecretRefIdValidationMessage()} (ref: ${refLabel}).`; + } + const providerConfig = params.config.secrets?.providers?.[params.ref.provider]; + if (!providerConfig) { + return `Error: Secret provider "${params.ref.provider}" is not configured (ref: ${refLabel}).`; + } + if (providerConfig.source !== params.ref.source) { + return `Error: Secret provider "${params.ref.provider}" has source "${providerConfig.source}" but ref requests "${params.ref.source}".`; + } + return null; +}