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
This commit is contained in:
Josh Avant
2026-03-17 23:24:34 -05:00
committed by GitHub
parent bf470b711b
commit 0ffcc308f2
13 changed files with 747 additions and 49 deletions

View File

@@ -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

View File

@@ -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 <plan.json>` — 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 <plan.json>` — apply a previously generated plan (`--dry-run` supported; use `--allow-exec` to permit exec providers in dry-run and exec-containing write plans).
## Plugins

View File

@@ -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 <id>`: 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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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,
}),
);
});

View File

@@ -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 <id>",
"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 <path>", "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>", "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(

View File

@@ -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,

View File

@@ -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<string, string>;
changedFiles: Set<string>;
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<ReturnType<typeof resolveValidatedPlanTarget>> {
@@ -179,6 +196,8 @@ function applyProviderPlanMutations(params: {
async function projectPlanState(params: {
plan: SecretsApplyPlan;
env: NodeJS.ProcessEnv;
write: boolean;
allowExecInDryRun: boolean;
}): Promise<ProjectedState> {
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<string, Record<string, unknown>>;
}): Promise<void> {
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<SecretsApplyResult> {
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,
};

View File

@@ -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");

View File

@@ -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<void> {
allowExec: boolean;
}): Promise<{ refsChecked: number; skippedExecRefs: number }> {
const cache: SecretRefResolveCache = {};
const refsByProvider = new Map<string, Map<string, SecretRef>>();
const skippedRefKeys = new Set<string>();
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<SecretsAuditReport> {
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,

View File

@@ -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<SecretsConfigureResult> {
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 };

View File

@@ -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;
}