mirror of https://github.com/openclaw/openclaw.git
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:
parent
bf470b711b
commit
0ffcc308f2
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue