secrets: harden read-only SecretRef command paths and diagnostics (#47794)

* secrets: harden read-only SecretRef resolution for status and audit

* CLI: add SecretRef degrade-safe regression coverage

* Docs: align SecretRef status and daemon probe semantics

* Security audit: close SecretRef review gaps

* Security audit: preserve source auth SecretRef configuredness

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant 2026-03-15 21:55:24 -05:00 committed by GitHub
parent 3f12e90f3e
commit a2cb81199e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1368 additions and 103 deletions

View File

@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
### Fixes

View File

@ -34,13 +34,15 @@ openclaw daemon uninstall
## Common options
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json`
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
- lifecycle (`uninstall|start|stop|restart`): `--json`
Notes:
- `status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, `daemon status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
- On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed.

View File

@ -31,6 +31,7 @@ Notes:
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
## macOS: `launchctl` env overrides

View File

@ -111,7 +111,8 @@ Options:
Notes:
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first.
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy.
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).

View File

@ -783,6 +783,7 @@ Notes:
- `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting.
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra".
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
- If gateway auth SecretRefs are unresolved in the current command path, `gateway status --json` reports `rpc.authWarning` only when probe connectivity/auth fails (warnings are suppressed when probe succeeds).
- On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).

View File

@ -19,6 +19,8 @@ Related:
```bash
openclaw security audit
openclaw security audit --deep
openclaw security audit --deep --password <password>
openclaw security audit --deep --token <token>
openclaw security audit --fix
openclaw security audit --json
```
@ -40,6 +42,12 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security).
SecretRef behavior:
- `security audit` resolves supported SecretRefs in read-only mode for its targeted paths.
- If a SecretRef is unavailable in the current command path, audit continues and reports `secretDiagnostics` (instead of crashing).
- `--token` and `--password` only override deep-probe auth for that command invocation; they do not rewrite config or SecretRef mappings.
## JSON output
Use `--json` for CI/policy checks:

View File

@ -26,3 +26,4 @@ Notes:
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)).
- Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible.
- If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`.
- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output.

View File

@ -348,7 +348,7 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R
There are two broad behaviors:
- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable.
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
Read-only behavior:

View File

@ -12,6 +12,8 @@ import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../../channels/plugins/types.js";
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
@ -709,7 +711,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}
}
const cfg = options?.config ?? loadConfig();
const cfg = options?.config
? options.config
: (
await resolveCommandSecretRefsViaGateway({
config: loadConfig(),
commandName: "tools.message",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "enforce_resolved",
})
).resolvedConfig;
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;

View File

@ -43,7 +43,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
async function resolveTalkApiKey(params: {
envKey: string;
commandName?: string;
mode?: "strict" | "summary";
mode?: "enforce_resolved" | "read_only_status";
}) {
return resolveCommandSecretRefsViaGateway({
config: makeTalkApiKeySecretRefConfig(params.envKey),
@ -447,7 +447,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
expect(result.diagnostics).toEqual(["memory search ref inactive"]);
});
it("degrades unresolved refs in summary mode instead of throwing", async () => {
it("degrades unresolved refs in read-only status mode instead of throwing", async () => {
const envKey = "TALK_API_KEY_SUMMARY_MISSING";
callGateway.mockResolvedValueOnce({
assignments: [],
@ -457,7 +457,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
const result = await resolveTalkApiKey({
envKey,
commandName: "status",
mode: "summary",
mode: "read_only_status",
});
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
expect(result.hadUnresolvedTargets).toBe(true);
@ -470,6 +470,25 @@ describe("resolveCommandSecretRefsViaGateway", () => {
});
});
it("accepts legacy summary mode as a read-only alias", async () => {
const envKey = "TALK_API_KEY_LEGACY_SUMMARY_MISSING";
callGateway.mockResolvedValueOnce({
assignments: [],
diagnostics: [],
});
await withEnvValue(envKey, undefined, async () => {
const result = await resolveCommandSecretRefsViaGateway({
config: makeTalkApiKeySecretRefConfig(envKey),
commandName: "status",
targetIds: new Set(["talk.apiKey"]),
mode: "summary",
});
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
expect(result.hadUnresolvedTargets).toBe(true);
expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved");
});
});
it("uses targeted local fallback after an incomplete gateway snapshot", async () => {
const envKey = "TALK_API_KEY_PARTIAL_GATEWAY";
callGateway.mockResolvedValueOnce({
@ -480,7 +499,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
const result = await resolveTalkApiKey({
envKey,
commandName: "status",
mode: "summary",
mode: "read_only_status",
});
expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally");
expect(result.hadUnresolvedTargets).toBe(false);
@ -571,7 +590,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
} as OpenClawConfig,
commandName: "status",
targetIds: new Set(["talk.apiKey"]),
mode: "summary",
mode: "read_only_status",
});
expect(result.resolvedConfig.talk?.apiKey).toBe("target-only");
@ -591,7 +610,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
}
});
it("degrades unresolved refs in operational read-only mode", async () => {
it("degrades unresolved refs in read-only operational mode", async () => {
const envKey = "TALK_API_KEY_OPERATIONAL_MISSING";
const priorValue = process.env[envKey];
delete process.env[envKey];
@ -606,7 +625,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
} as OpenClawConfig,
commandName: "channels resolve",
targetIds: new Set(["talk.apiKey"]),
mode: "operational_readonly",
mode: "read_only_operational",
});
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();

View File

@ -26,7 +26,16 @@ type ResolveCommandSecretsResult = {
hadUnresolvedTargets: boolean;
};
export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret
export type CommandSecretResolutionMode =
| "enforce_resolved"
| "read_only_status"
| "read_only_operational";
type LegacyCommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret
type CommandSecretResolutionModeInput =
| CommandSecretResolutionMode
| LegacyCommandSecretResolutionMode;
export type CommandSecretTargetState =
| "resolved_gateway"
@ -54,6 +63,22 @@ const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
"tools.web.fetch.firecrawl.",
] as const;
function normalizeCommandSecretResolutionMode(
mode?: CommandSecretResolutionModeInput,
): CommandSecretResolutionMode {
if (!mode || mode === "enforce_resolved" || mode === "strict") {
return "enforce_resolved";
}
if (mode === "read_only_status" || mode === "summary") {
return "read_only_status";
}
return "read_only_operational";
}
function enforcesResolvedSecrets(mode: CommandSecretResolutionMode): boolean {
return mode === "enforce_resolved";
}
function dedupeDiagnostics(entries: readonly string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
@ -242,7 +267,7 @@ async function resolveCommandSecretRefsLocally(params: {
context,
});
} catch (error) {
if (params.mode === "strict") {
if (enforcesResolvedSecrets(params.mode)) {
throw error;
}
localResolutionDiagnostics.push(
@ -289,7 +314,7 @@ async function resolveCommandSecretRefsLocally(params: {
analyzed,
resolvedState: "resolved_local",
});
if (params.mode !== "strict" && analyzed.unresolved.length > 0) {
if (!enforcesResolvedSecrets(params.mode) && analyzed.unresolved.length > 0) {
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
} else if (analyzed.unresolved.length > 0) {
throw new Error(
@ -336,7 +361,7 @@ function buildUnresolvedDiagnostics(
unresolved: UnresolvedCommandSecretAssignment[],
mode: CommandSecretResolutionMode,
): string[] {
if (mode === "strict") {
if (enforcesResolvedSecrets(mode)) {
return [];
}
return unresolved.map(
@ -411,7 +436,7 @@ async function resolveTargetSecretLocally(params: {
});
setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved);
} catch (error) {
if (params.mode !== "strict") {
if (!enforcesResolvedSecrets(params.mode)) {
params.localResolutionDiagnostics.push(
`${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`,
);
@ -423,9 +448,9 @@ export async function resolveCommandSecretRefsViaGateway(params: {
config: OpenClawConfig;
commandName: string;
targetIds: Set<string>;
mode?: CommandSecretResolutionMode;
mode?: CommandSecretResolutionModeInput;
}): Promise<ResolveCommandSecretsResult> {
const mode = params.mode ?? "strict";
const mode = normalizeCommandSecretResolutionMode(params.mode);
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
config: params.config,
targetIds: params.targetIds,
@ -567,7 +592,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
(entry) => !recoveredPaths.has(entry.path),
);
if (stillUnresolved.length > 0) {
if (mode === "strict") {
if (enforcesResolvedSecrets(mode)) {
throw new Error(
`${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`,
);
@ -590,7 +615,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
]);
}
} catch (error) {
if (mode === "strict") {
if (enforcesResolvedSecrets(mode)) {
throw error;
}
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getAgentRuntimeCommandSecretTargetIds,
getMemoryCommandSecretTargetIds,
getSecurityAuditCommandSecretTargetIds,
} from "./command-secret-targets.js";
describe("command secret target ids", () => {
@ -21,4 +22,13 @@ describe("command secret target ids", () => {
]),
);
});
it("includes gateway auth and channel targets for security audit", () => {
const ids = getSecurityAuditCommandSecretTargetIds();
expect(ids.has("channels.discord.token")).toBe(true);
expect(ids.has("gateway.auth.token")).toBe(true);
expect(ids.has("gateway.auth.password")).toBe(true);
expect(ids.has("gateway.remote.token")).toBe(true);
expect(ids.has("gateway.remote.password")).toBe(true);
});
});

View File

@ -30,6 +30,7 @@ const COMMAND_SECRET_TARGETS = {
"agents.defaults.memorySearch.remote.",
"agents.list[].memorySearch.remote.",
]),
securityAudit: idsByPrefix(["channels.", "gateway.auth.", "gateway.remote."]),
} as const;
function toTargetIdSet(values: readonly string[]): Set<string> {
@ -59,3 +60,7 @@ export function getAgentRuntimeCommandSecretTargetIds(): Set<string> {
export function getStatusCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.status);
}
export function getSecurityAuditCommandSecretTargetIds(): Set<string> {
return toTargetIdSet(COMMAND_SECRET_TARGETS.securityAudit);
}

View File

@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import type { GatewayRestartSnapshot } from "./restart-health.js";
const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const }));
const callGatewayStatusProbe = vi.fn<
(opts?: unknown) => Promise<{ ok: boolean; url?: string; error?: string | null }>
>(async (_opts?: unknown) => ({
ok: true,
url: "ws://127.0.0.1:19001",
error: null,
}));
const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({
enabled: true,
required: true,
@ -333,6 +339,71 @@ describe("gatherDaemonStatus", () => {
);
});
it("degrades safely when daemon probe auth SecretRef is unresolved", async () => {
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
const status = await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
});
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
password: undefined,
}),
);
expect(status.rpc?.authWarning).toBeUndefined();
});
it("surfaces authWarning when daemon probe auth SecretRef is unresolved and probe fails", async () => {
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
callGatewayStatusProbe.mockResolvedValueOnce({
ok: false,
error: "gateway closed",
url: "wss://127.0.0.1:19001",
});
const status = await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
});
expect(status.rpc?.ok).toBe(false);
expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable");
expect(status.rpc?.authWarning).toContain("probing without configured auth credentials");
});
it("keeps remote probe auth strict when remote token is missing", async () => {
daemonLoadedConfig = {
gateway: {

View File

@ -16,7 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { trimToUndefined } from "../../gateway/credentials.js";
import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js";
import { resolveGatewayBindHost } from "../../gateway/net.js";
import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
@ -112,6 +112,7 @@ export type DaemonStatus = {
ok: boolean;
error?: string;
url?: string;
authWarning?: string;
};
health?: {
healthy: boolean;
@ -130,6 +131,10 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool
return true;
}
function parseGatewaySecretRefPathFromError(error: unknown): string | null {
return isGatewaySecretRefUnavailableError(error) ? error.path : null;
}
async function loadDaemonConfigContext(
serviceEnv?: Record<string, string>,
): Promise<DaemonConfigContext> {
@ -310,8 +315,11 @@ export async function gatherDaemonStatus(
const tlsRuntime = shouldUseLocalTlsRuntime
? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls)
: undefined;
const daemonProbeAuth = opts.probe
? await resolveGatewayProbeAuthWithSecretInputs({
let daemonProbeAuth: { token?: string; password?: string } | undefined;
let rpcAuthWarning: string | undefined;
if (opts.probe) {
try {
daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({
cfg: daemonCfg,
mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local",
env: mergedDaemonEnv as NodeJS.ProcessEnv,
@ -319,8 +327,16 @@ export async function gatherDaemonStatus(
token: opts.rpc.token,
password: opts.rpc.password,
},
})
: undefined;
});
} catch (error) {
const refPath = parseGatewaySecretRefPathFromError(error);
if (!refPath) {
throw error;
}
daemonProbeAuth = undefined;
rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`;
}
}
const rpc = opts.probe
? await probeGatewayStatus({
@ -336,6 +352,9 @@ export async function gatherDaemonStatus(
configPath: daemonConfigSummary.path,
})
: undefined;
if (rpc?.ok) {
rpcAuthWarning = undefined;
}
const health =
opts.probe && loaded
? await inspectGatewayRestart({
@ -369,7 +388,15 @@ export async function gatherDaemonStatus(
port: portStatus,
...(portCliStatus ? { portCli: portCliStatus } : {}),
lastError,
...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}),
...(rpc
? {
rpc: {
...rpc,
url: gateway.probeUrl,
...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}),
},
}
: {}),
...(health
? {
health: {

View File

@ -181,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
} else {
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
if (rpc.authWarning) {
defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`);
}
if (rpc.url) {
defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
}

View File

@ -0,0 +1,245 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const loadConfig = vi.fn();
const runSecurityAudit = vi.fn();
const fixSecurityFootguns = vi.fn();
const resolveCommandSecretRefsViaGateway = vi.fn();
const getSecurityAuditCommandSecretTargetIds = vi.fn(
() => new Set(["gateway.auth.token", "gateway.auth.password"]),
);
const { defaultRuntime, runtimeLogs, resetRuntimeCapture } = createCliRuntimeCapture();
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfig(),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../security/audit.js", () => ({
runSecurityAudit: (opts: unknown) => runSecurityAudit(opts),
}));
vi.mock("../security/fix.js", () => ({
fixSecurityFootguns: () => fixSecurityFootguns(),
}));
vi.mock("./command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts),
}));
vi.mock("./command-secret-targets.js", () => ({
getSecurityAuditCommandSecretTargetIds: () => getSecurityAuditCommandSecretTargetIds(),
}));
const { registerSecurityCli } = await import("./security-cli.js");
function createProgram() {
const program = new Command();
program.exitOverride();
registerSecurityCli(program);
return program;
}
describe("security CLI", () => {
beforeEach(() => {
resetRuntimeCapture();
loadConfig.mockReset();
runSecurityAudit.mockReset();
fixSecurityFootguns.mockReset();
resolveCommandSecretRefsViaGateway.mockReset();
getSecurityAuditCommandSecretTargetIds.mockClear();
fixSecurityFootguns.mockResolvedValue({
changes: [],
actions: [],
errors: [],
});
});
it("runs audit with read-only SecretRef resolution and prints JSON diagnostics", async () => {
const sourceConfig = {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
const resolvedConfig = {
...sourceConfig,
gateway: {
...sourceConfig.gateway,
auth: {
...sourceConfig.gateway.auth,
token: "resolved-token",
},
},
};
loadConfig.mockReturnValue(sourceConfig);
resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig,
diagnostics: [
"security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.",
],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
runSecurityAudit.mockResolvedValue({
ts: 0,
summary: { critical: 0, warn: 1, info: 0 },
findings: [
{
checkId: "gateway.probe_failed",
severity: "warn",
title: "Gateway probe failed (deep)",
detail: "connect failed: connect ECONNREFUSED 127.0.0.1:18789",
},
],
});
await createProgram().parseAsync(["security", "audit", "--json"], { from: "user" });
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
config: sourceConfig,
commandName: "security audit",
mode: "read_only_status",
targetIds: expect.any(Set),
}),
);
expect(runSecurityAudit).toHaveBeenCalledWith(
expect.objectContaining({
config: resolvedConfig,
sourceConfig,
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
}),
);
const payload = JSON.parse(String(runtimeLogs.at(-1)));
expect(payload.secretDiagnostics).toEqual([
"security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.",
]);
});
it("forwards --token to deep probe auth without altering command-level resolver mode", async () => {
const sourceConfig = { gateway: { mode: "local" } };
loadConfig.mockReturnValue(sourceConfig);
resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: sourceConfig,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
runSecurityAudit.mockResolvedValue({
ts: 0,
summary: { critical: 0, warn: 0, info: 0 },
findings: [],
});
await createProgram().parseAsync(
["security", "audit", "--deep", "--token", "explicit-token", "--json"],
{
from: "user",
},
);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
mode: "read_only_status",
}),
);
expect(runSecurityAudit).toHaveBeenCalledWith(
expect.objectContaining({
deep: true,
deepProbeAuth: { token: "explicit-token" },
}),
);
});
it("forwards --password to deep probe auth without altering command-level resolver mode", async () => {
const sourceConfig = { gateway: { mode: "local" } };
loadConfig.mockReturnValue(sourceConfig);
resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: sourceConfig,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
runSecurityAudit.mockResolvedValue({
ts: 0,
summary: { critical: 0, warn: 0, info: 0 },
findings: [],
});
await createProgram().parseAsync(
["security", "audit", "--deep", "--password", "explicit-password", "--json"],
{
from: "user",
},
);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
mode: "read_only_status",
}),
);
expect(runSecurityAudit).toHaveBeenCalledWith(
expect.objectContaining({
deep: true,
deepProbeAuth: { password: "explicit-password" },
}),
);
});
it("forwards both --token and --password to deep probe auth", async () => {
const sourceConfig = { gateway: { mode: "local" } };
loadConfig.mockReturnValue(sourceConfig);
resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: sourceConfig,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
runSecurityAudit.mockResolvedValue({
ts: 0,
summary: { critical: 0, warn: 0, info: 0 },
findings: [],
});
await createProgram().parseAsync(
[
"security",
"audit",
"--deep",
"--token",
"explicit-token",
"--password",
"explicit-password",
"--json",
],
{
from: "user",
},
);
expect(runSecurityAudit).toHaveBeenCalledWith(
expect.objectContaining({
deep: true,
deepProbeAuth: {
token: "explicit-token",
password: "explicit-password",
},
}),
);
});
});

View File

@ -7,12 +7,16 @@ import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
import { getSecurityAuditCommandSecretTargetIds } from "./command-secret-targets.js";
import { formatHelpExamples } from "./help-format.js";
type SecurityAuditOptions = {
json?: boolean;
deep?: boolean;
fix?: boolean;
token?: string;
password?: string;
};
function formatSummary(summary: { critical: number; warn: number; info: number }): string {
@ -37,6 +41,11 @@ export function registerSecurityCli(program: Command) {
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw security audit", "Run a local security audit."],
["openclaw security audit --deep", "Include best-effort live Gateway probe checks."],
["openclaw security audit --deep --token <token>", "Use explicit token for deep probe."],
[
"openclaw security audit --deep --password <password>",
"Use explicit password for deep probe.",
],
["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."],
["openclaw security audit --json", "Output machine-readable JSON."],
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`,
@ -46,22 +55,45 @@ export function registerSecurityCli(program: Command) {
.command("audit")
.description("Audit config + local state for common security foot-guns")
.option("--deep", "Attempt live Gateway probe (best-effort)", false)
.option("--token <token>", "Use explicit gateway token for deep probe auth")
.option("--password <password>", "Use explicit gateway password for deep probe auth")
.option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false)
.option("--json", "Print JSON", false)
.action(async (opts: SecurityAuditOptions) => {
const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null;
const cfg = loadConfig();
const sourceConfig = loadConfig();
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
await resolveCommandSecretRefsViaGateway({
config: sourceConfig,
commandName: "security audit",
targetIds: getSecurityAuditCommandSecretTargetIds(),
mode: "read_only_status",
});
const report = await runSecurityAudit({
config: cfg,
sourceConfig,
deep: Boolean(opts.deep),
includeFilesystem: true,
includeChannelSecurity: true,
deepProbeAuth:
opts.token?.trim() || opts.password?.trim()
? {
...(opts.token?.trim() ? { token: opts.token } : {}),
...(opts.password?.trim() ? { password: opts.password } : {}),
}
: undefined,
});
if (opts.json) {
defaultRuntime.log(
JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2),
JSON.stringify(
fixResult
? { fix: fixResult, report, secretDiagnostics }
: { ...report, secretDiagnostics },
null,
2,
),
);
return;
}
@ -74,6 +106,9 @@ export function registerSecurityCli(program: Command) {
lines.push(heading("OpenClaw security audit"));
lines.push(muted(`Summary: ${formatSummary(report.summary)}`));
lines.push(muted(`Run deeper: ${formatCliCommand("openclaw security audit --deep")}`));
for (const diagnostic of secretDiagnostics) {
lines.push(muted(`[secrets] ${diagnostic}`));
}
if (opts.fix) {
lines.push(muted(`Fix: ${formatCliCommand("openclaw security audit --fix")}`));

View File

@ -21,6 +21,8 @@ describe("resolveDefaultChannelAccountContext", () => {
expect(result.account).toBe(account);
expect(result.enabled).toBe(true);
expect(result.configured).toBe(true);
expect(result.diagnostics).toEqual([]);
expect(result.degraded).toBe(false);
});
it("uses plugin enable/configure hooks", async () => {
@ -43,5 +45,70 @@ describe("resolveDefaultChannelAccountContext", () => {
expect(isConfigured).toHaveBeenCalledWith(account, {});
expect(result.enabled).toBe(false);
expect(result.configured).toBe(false);
expect(result.diagnostics).toEqual([]);
expect(result.degraded).toBe(false);
});
it("keeps strict mode fail-closed when resolveAccount throws", async () => {
const plugin = {
id: "demo",
config: {
listAccountIds: () => ["acc-err"],
resolveAccount: () => {
throw new Error("missing secret");
},
},
} as unknown as ChannelPlugin;
await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow(
/missing secret/i,
);
});
it("degrades safely in read_only mode when resolveAccount throws", async () => {
const plugin = {
id: "demo",
config: {
listAccountIds: () => ["acc-err"],
resolveAccount: () => {
throw new Error("missing secret");
},
},
} as unknown as ChannelPlugin;
const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, {
mode: "read_only",
commandName: "status",
});
expect(result.enabled).toBe(false);
expect(result.configured).toBe(false);
expect(result.degraded).toBe(true);
expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe(
true,
);
});
it("prefers inspectAccount in read_only mode", async () => {
const inspectAccount = vi.fn(() => ({ configured: true, enabled: true }));
const resolveAccount = vi.fn(() => ({ configured: false, enabled: false }));
const plugin = {
id: "demo",
config: {
listAccountIds: () => ["acc-1"],
inspectAccount,
resolveAccount,
},
} as unknown as ChannelPlugin;
const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, {
mode: "read_only",
});
expect(inspectAccount).toHaveBeenCalled();
expect(resolveAccount).not.toHaveBeenCalled();
expect(result.enabled).toBe(true);
expect(result.configured).toBe(true);
expect(result.degraded).toBe(true);
});
});

View File

@ -1,6 +1,8 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import type { OpenClawConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
export type ChannelDefaultAccountContext = {
accountIds: string[];
@ -8,22 +10,154 @@ export type ChannelDefaultAccountContext = {
account: unknown;
enabled: boolean;
configured: boolean;
diagnostics: string[];
/**
* Indicates read-only resolution was used instead of strict full-account resolution.
* This is expected for read_only mode and does not necessarily mean an error occurred.
*/
degraded: boolean;
};
export type ChannelAccountContextMode = "strict" | "read_only";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function getBooleanField(value: unknown, key: string): boolean | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
return typeof record[key] === "boolean" ? record[key] : undefined;
}
function formatContextDiagnostic(params: {
commandName?: string;
pluginId: string;
accountId: string;
message: string;
}): string {
const prefix = params.commandName ? `${params.commandName}: ` : "";
return `${prefix}channels.${params.pluginId}.accounts.${params.accountId}: ${params.message}`;
}
export async function resolveDefaultChannelAccountContext(
plugin: ChannelPlugin,
cfg: OpenClawConfig,
options?: { mode?: ChannelAccountContextMode; commandName?: string },
): Promise<ChannelDefaultAccountContext> {
const mode = options?.mode ?? "strict";
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
return { accountIds, defaultAccountId, account, enabled, configured };
if (mode === "strict") {
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true;
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
return {
accountIds,
defaultAccountId,
account,
enabled,
configured,
diagnostics: [],
degraded: false,
};
}
const diagnostics: string[] = [];
let degraded = false;
const inspected =
plugin.config.inspectAccount?.(cfg, defaultAccountId) ??
inspectReadOnlyChannelAccount({
channelId: plugin.id,
cfg,
accountId: defaultAccountId,
});
let account = inspected;
if (!account) {
try {
account = plugin.config.resolveAccount(cfg, defaultAccountId);
} catch (error) {
degraded = true;
diagnostics.push(
formatContextDiagnostic({
commandName: options?.commandName,
pluginId: plugin.id,
accountId: defaultAccountId,
message: `failed to resolve account (${formatErrorMessage(error)}); skipping read-only checks.`,
}),
);
return {
accountIds,
defaultAccountId,
account: {},
enabled: false,
configured: false,
diagnostics,
degraded,
};
}
} else {
degraded = true;
}
const inspectEnabled = getBooleanField(account, "enabled");
let enabled = inspectEnabled ?? true;
if (inspectEnabled === undefined && plugin.config.isEnabled) {
try {
enabled = plugin.config.isEnabled(account, cfg);
} catch (error) {
degraded = true;
enabled = false;
diagnostics.push(
formatContextDiagnostic({
commandName: options?.commandName,
pluginId: plugin.id,
accountId: defaultAccountId,
message: `failed to evaluate enabled state (${formatErrorMessage(error)}); treating as disabled.`,
}),
);
}
}
const inspectConfigured = getBooleanField(account, "configured");
let configured = inspectConfigured ?? true;
if (inspectConfigured === undefined && plugin.config.isConfigured) {
try {
configured = await plugin.config.isConfigured(account, cfg);
} catch (error) {
degraded = true;
configured = false;
diagnostics.push(
formatContextDiagnostic({
commandName: options?.commandName,
pluginId: plugin.id,
accountId: defaultAccountId,
message: `failed to evaluate configured state (${formatErrorMessage(error)}); treating as unconfigured.`,
}),
);
}
}
return {
accountIds,
defaultAccountId,
account,
enabled,
configured,
diagnostics,
degraded,
};
}

View File

@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGateway = vi.fn();
const resolveCommandSecretRefsViaGateway = vi.fn();
const requireValidConfigSnapshot = vi.fn();
const listChannelPlugins = vi.fn();
const withProgress = vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run());
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts),
}));
vi.mock("../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts),
}));
vi.mock("./shared.js", () => ({
requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime),
formatChannelAccountLabel: ({
channel,
accountId,
}: {
channel: string;
accountId: string;
name?: string;
}) => `${channel} ${accountId}`,
}));
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins: () => listChannelPlugins(),
getChannelPlugin: (channel: string) =>
(listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
}));
vi.mock("../cli/progress.js", () => ({
withProgress: (opts: unknown, run: () => Promise<unknown>) => withProgress(opts, run),
}));
const { channelsStatusCommand } = await import("./channels/status.js");
function createTokenOnlyPlugin() {
return {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
defaultAccountId: () => "default",
inspectAccount: (cfg: { secretResolved?: boolean }) =>
cfg.secretResolved
? {
name: "Primary",
enabled: true,
configured: true,
token: "resolved-discord-token",
tokenSource: "config",
tokenStatus: "available",
}
: {
name: "Primary",
enabled: true,
configured: true,
token: "",
tokenSource: "config",
tokenStatus: "configured_unavailable",
},
resolveAccount: (cfg: { secretResolved?: boolean }) =>
cfg.secretResolved
? {
name: "Primary",
enabled: true,
configured: true,
token: "resolved-discord-token",
tokenSource: "config",
tokenStatus: "available",
}
: {
name: "Primary",
enabled: true,
configured: true,
token: "",
tokenSource: "config",
tokenStatus: "configured_unavailable",
},
isConfigured: () => true,
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
},
};
}
function createRuntimeCapture() {
const logs: string[] = [];
const errors: string[] = [];
const runtime = {
log: (message: unknown) => logs.push(String(message)),
error: (message: unknown) => errors.push(String(message)),
exit: (_code?: number) => undefined,
};
return { runtime, logs, errors };
}
describe("channelsStatusCommand SecretRef fallback flow", () => {
beforeEach(() => {
callGateway.mockReset();
resolveCommandSecretRefsViaGateway.mockReset();
requireValidConfigSnapshot.mockReset();
listChannelPlugins.mockReset();
withProgress.mockClear();
listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
});
it("keeps read-only fallback output when SecretRefs are unresolved", async () => {
callGateway.mockRejectedValue(new Error("gateway closed"));
requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: { secretResolved: false, channels: {} },
diagnostics: [
"channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.",
],
targetStatesByPath: {},
hadUnresolvedTargets: true,
});
const { runtime, logs, errors } = createRuntimeCapture();
await channelsStatusCommand({ probe: false }, runtime as never);
expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
expect.objectContaining({
commandName: "channels status",
mode: "read_only_status",
}),
);
expect(
logs.some((line) =>
line.includes("[secrets] channels status: channels.discord.token is unavailable"),
),
).toBe(true);
const joined = logs.join("\n");
expect(joined).toContain("configured, secret unavailable in this command path");
expect(joined).toContain("token:config (unavailable)");
});
it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => {
callGateway.mockRejectedValue(new Error("gateway closed"));
requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} });
resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: { secretResolved: true, channels: {} },
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
});
const { runtime, logs } = createRuntimeCapture();
await channelsStatusCommand({ probe: false }, runtime as never);
const joined = logs.join("\n");
expect(joined).toContain("configured");
expect(joined).toContain("token:config");
expect(joined).not.toContain("secret unavailable in this command path");
expect(joined).not.toContain("token:config (unavailable)");
});
});

View File

@ -75,7 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
config: loadedRaw,
commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "operational_readonly",
mode: "read_only_operational",
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);

View File

@ -315,7 +315,7 @@ export async function channelsStatusCommand(
config: cfg,
commandName: "channels status",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "summary",
mode: "read_only_status",
});
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);

View File

@ -330,7 +330,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
config: cfg,
commandName: "doctor --fix",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "summary",
mode: "read_only_status",
});
const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => {
const inspected = inspectTelegramAccount({ cfg, accountId });

View File

@ -173,6 +173,32 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).toContain("direct/DM targets by default");
});
it("degrades safely when channel account resolution fails in read-only security checks", async () => {
pluginRegistry.list = [
{
id: "whatsapp",
meta: { label: "WhatsApp" },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => {
throw new Error("missing secret");
},
isEnabled: () => true,
isConfigured: () => true,
},
security: {
resolveDmPolicy: () => null,
},
},
];
await noteSecurityWarnings({} as OpenClawConfig);
const message = lastMessage();
expect(message).toContain("[secrets]");
expect(message).toContain("failed to resolve account");
expect(message).toContain("Run: openclaw security audit --deep");
});
it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => {
const cfg = {
agents: {

View File

@ -189,8 +189,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
if (!plugin.security) {
continue;
}
const { defaultAccountId, account, enabled, configured } =
await resolveDefaultChannelAccountContext(plugin, cfg);
const { defaultAccountId, account, enabled, configured, diagnostics } =
await resolveDefaultChannelAccountContext(plugin, cfg, {
mode: "read_only",
commandName: "doctor",
});
for (const diagnostic of diagnostics) {
warnings.push(`- [secrets] ${diagnostic}`);
}
if (!enabled) {
continue;
}

View File

@ -122,4 +122,52 @@ describe("doctor command", () => {
"openclaw config set gateway.auth.mode password",
);
});
it("keeps doctor read-only when gateway token is SecretRef-managed but unresolved", async () => {
mockDoctorConfigSnapshot({
config: {
gateway: {
mode: "local",
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
});
const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
note.mockClear();
try {
await doctorCommand(createDoctorRuntime(), {
nonInteractive: true,
workspaceSuggestions: false,
});
} finally {
if (previousToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
}
}
const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth");
expect(gatewayAuthNote).toBeTruthy();
expect(String(gatewayAuthNote?.[0])).toContain(
"Gateway token is managed via SecretRef and is currently unavailable.",
);
expect(String(gatewayAuthNote?.[0])).toContain(
"Doctor will not overwrite gateway.auth.token with a plaintext value.",
);
});
});

View File

@ -268,7 +268,7 @@ describe("gateway-status command", () => {
expect(scopeLimitedWarning?.targetIds).toContain("localLoopback");
});
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
it("suppresses unresolved SecretRef auth warnings when probe is reachable", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
mockLocalTokenEnvRefConfig();
@ -276,6 +276,38 @@ describe("gateway-status command", () => {
await runGatewayStatus(runtime, { timeout: "1000", json: true });
});
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.token SecretRef is unresolved"),
);
expect(unresolvedWarning).toBeUndefined();
});
it("surfaces unresolved SecretRef auth diagnostics when probe fails", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
mockLocalTokenEnvRefConfig();
probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "connection refused",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow(
"__exit__:1",
);
});
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;

View File

@ -229,7 +229,7 @@ export async function gatewayStatusCommand(
});
}
for (const result of probed) {
if (result.authDiagnostics.length === 0) {
if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) {
continue;
}
for (const diagnostic of result.authDiagnostics) {

View File

@ -1,7 +1,8 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import { withProgress } from "../cli/progress.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, readBestEffortConfig } from "../config/config.js";
@ -161,17 +162,91 @@ const buildSessionSummary = (storePath: string) => {
} satisfies HealthSummary["sessions"];
};
const isAccountEnabled = (account: unknown): boolean => {
if (!account || typeof account !== "object") {
return true;
}
const enabled = (account as { enabled?: boolean }).enabled;
return enabled !== false;
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
function inspectHealthAccount(
plugin: ChannelPlugin,
cfg: OpenClawConfig,
accountId: string,
): unknown {
return (
plugin.config.inspectAccount?.(cfg, accountId) ??
inspectReadOnlyChannelAccount({
channelId: plugin.id,
cfg,
accountId,
})
);
}
function readBooleanField(value: unknown, key: string): boolean | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
return typeof record[key] === "boolean" ? record[key] : undefined;
}
async function resolveHealthAccountContext(params: {
plugin: ChannelPlugin;
cfg: OpenClawConfig;
accountId: string;
}): Promise<{
account: unknown;
enabled: boolean;
configured: boolean;
diagnostics: string[];
}> {
const diagnostics: string[] = [];
let account: unknown;
try {
account = params.plugin.config.resolveAccount(params.cfg, params.accountId);
} catch (error) {
diagnostics.push(
`${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`,
);
account = inspectHealthAccount(params.plugin, params.cfg, params.accountId);
}
if (!account) {
return {
account: {},
enabled: false,
configured: false,
diagnostics,
};
}
const enabledFallback = readBooleanField(account, "enabled") ?? true;
let enabled = enabledFallback;
if (params.plugin.config.isEnabled) {
try {
enabled = params.plugin.config.isEnabled(account, params.cfg);
} catch (error) {
enabled = enabledFallback;
diagnostics.push(
`${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`,
);
}
}
const configuredFallback = readBooleanField(account, "configured") ?? true;
let configured = configuredFallback;
if (params.plugin.config.isConfigured) {
try {
configured = await params.plugin.config.isConfigured(account, params.cfg);
} catch (error) {
configured = configuredFallback;
diagnostics.push(
`${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`,
);
}
}
return { account, enabled, configured, diagnostics };
}
const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => {
const record = asRecord(probe);
if (!record) {
@ -416,13 +491,14 @@ export async function getHealthSnapshot(params?: {
const accountSummaries: Record<string, ChannelAccountHealthSummary> = {};
for (const accountId of accountIdsToProbe) {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: isAccountEnabled(account);
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({
plugin,
cfg,
accountId,
});
if (diagnostics.length > 0) {
debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics });
}
let probe: unknown;
let lastProbeAt: number | null = null;
@ -588,16 +664,20 @@ export async function healthCommand(
` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`,
);
for (const accountId of accountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
const { account, configured, diagnostics } = await resolveHealthAccountContext({
plugin,
cfg,
accountId,
});
const record = asRecord(account);
const tokenSource =
record && typeof record.tokenSource === "string" ? record.tokenSource : undefined;
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, cfg)
: true;
runtime.log(
` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`,
);
for (const diagnostic of diagnostics) {
runtime.log(` ! ${diagnostic}`);
}
}
}
runtime.log(info("[debug] bindings map"));
@ -691,13 +771,31 @@ export async function healthCommand(
defaultAccountId,
boundAccounts,
});
const account = plugin.config.resolveAccount(cfg, accountId);
plugin.status.logSelfId({
account,
const accountContext = await resolveHealthAccountContext({
plugin,
cfg,
runtime,
includeChannelPrefix: true,
accountId,
});
if (!accountContext.enabled || !accountContext.configured) {
continue;
}
if (accountContext.diagnostics.length > 0) {
continue;
}
try {
plugin.status.logSelfId({
account: accountContext.account,
cfg,
runtime,
includeChannelPrefix: true,
});
} catch (error) {
debugHealth("logSelfId.failed", {
channel: plugin.id,
accountId,
error: formatErrorMessage(error),
});
}
}
if (resolvedAgents.length > 0) {

View File

@ -48,7 +48,7 @@ export async function statusAllCommand(
config: loadedRaw,
commandName: "status --all",
targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
mode: "read_only_status",
});
const osSummary = resolveOsSummary();
const snap = await readConfigFileSnapshot().catch(() => null);

View File

@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] }));
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins: () => pluginRegistry.list,
}));
import { resolveLinkChannelContext } from "./status.link-channel.js";
describe("resolveLinkChannelContext", () => {
it("returns linked context from read-only inspected account state", async () => {
const account = { configured: true, enabled: true };
pluginRegistry.list = [
{
id: "discord",
meta: { label: "Discord" },
config: {
listAccountIds: () => ["default"],
inspectAccount: () => account,
resolveAccount: () => {
throw new Error("should not be called in read-only mode");
},
},
status: {
buildChannelSummary: () => ({ linked: true, authAgeMs: 1234 }),
},
},
];
const result = await resolveLinkChannelContext({} as OpenClawConfig);
expect(result?.linked).toBe(true);
expect(result?.authAgeMs).toBe(1234);
expect(result?.account).toBe(account);
});
it("degrades safely when account resolution throws", async () => {
pluginRegistry.list = [
{
id: "discord",
meta: { label: "Discord" },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => {
throw new Error("missing secret");
},
},
},
];
const result = await resolveLinkChannelContext({} as OpenClawConfig);
expect(result).toBeNull();
});
});

View File

@ -16,7 +16,10 @@ export async function resolveLinkChannelContext(
): Promise<LinkChannelContext | null> {
for (const plugin of listChannelPlugins()) {
const { defaultAccountId, account, enabled, configured } =
await resolveDefaultChannelAccountContext(plugin, cfg);
await resolveDefaultChannelAccountContext(plugin, cfg, {
mode: "read_only",
commandName: "status",
});
const snapshot = plugin.config.describeAccount
? plugin.config.describeAccount(account, cfg)
: ({

View File

@ -197,7 +197,7 @@ async function scanStatusJsonFast(opts: {
config: loadedRaw,
commandName: "status --json",
targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
mode: "read_only_status",
});
if (hasPotentialConfiguredChannels(cfg)) {
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
@ -302,7 +302,7 @@ export async function scanStatus(
config: loadedRaw,
commandName: "status",
targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
mode: "read_only_status",
});
const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";

View File

@ -512,6 +512,11 @@ describe("statusCommand", () => {
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull();
if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) {
expect(
payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")),
).toBe(true);
}
expect(runtime.error).not.toHaveBeenCalled();
});

View File

@ -330,11 +330,8 @@ async function resolveGatewaySecretInputString(params: {
value: params.value,
env: params.env,
normalize: trimToUndefined,
onResolveRefError: (error) => {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, {
cause: error,
});
onResolveRefError: () => {
throw new GatewaySecretRefUnavailableError(params.path);
},
});
if (!value) {

View File

@ -54,10 +54,22 @@ export function resolveGatewayProbeAuthSafe(params: {
cfg: OpenClawConfig;
mode: "local" | "remote";
env?: NodeJS.ProcessEnv;
explicitAuth?: ExplicitGatewayAuth;
}): {
auth: { token?: string; password?: string };
warning?: string;
} {
const explicitToken = params.explicitAuth?.token?.trim();
const explicitPassword = params.explicitAuth?.password?.trim();
if (explicitToken || explicitPassword) {
return {
auth: {
...(explicitToken ? { token: explicitToken } : {}),
...(explicitPassword ? { password: explicitPassword } : {}),
},
};
}
try {
return { auth: resolveGatewayProbeAuth(params) };
} catch (error) {

View File

@ -14,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
import { formatErrorMessage } from "../infra/errors.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
@ -164,6 +165,7 @@ export async function collectChannelSecurityFindings(params: {
plugin: (typeof params.plugins)[number],
accountId: string,
) => {
const diagnostics: string[] = [];
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId);
const sourceInspection = sourceInspectedAccount as {
@ -174,8 +176,27 @@ export async function collectChannelSecurityFindings(params: {
enabled?: boolean;
configured?: boolean;
} | null;
const resolvedAccount =
resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId);
let resolvedAccount = resolvedInspectedAccount;
if (!resolvedAccount) {
try {
resolvedAccount = plugin.config.resolveAccount(params.cfg, accountId);
} catch (error) {
diagnostics.push(
`${plugin.id}:${accountId}: failed to resolve account (${formatErrorMessage(error)}).`,
);
}
}
if (!resolvedAccount && sourceInspectedAccount) {
resolvedAccount = sourceInspectedAccount;
}
if (!resolvedAccount) {
return {
account: {},
enabled: false,
configured: false,
diagnostics,
};
}
const useSourceUnavailableAccount = Boolean(
sourceInspectedAccount &&
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
@ -185,23 +206,49 @@ export async function collectChannelSecurityFindings(params: {
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
const accountRecord = asAccountRecord(account);
const enabled =
let enabled =
typeof selectedInspection?.enabled === "boolean"
? selectedInspection.enabled
: typeof accountRecord?.enabled === "boolean"
? accountRecord.enabled
: plugin.config.isEnabled
? plugin.config.isEnabled(account, params.cfg)
: true;
const configured =
: true;
if (
typeof selectedInspection?.enabled !== "boolean" &&
typeof accountRecord?.enabled !== "boolean" &&
plugin.config.isEnabled
) {
try {
enabled = plugin.config.isEnabled(account, params.cfg);
} catch (error) {
enabled = false;
diagnostics.push(
`${plugin.id}:${accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`,
);
}
}
let configured =
typeof selectedInspection?.configured === "boolean"
? selectedInspection.configured
: typeof accountRecord?.configured === "boolean"
? accountRecord.configured
: plugin.config.isConfigured
? await plugin.config.isConfigured(account, params.cfg)
: true;
return { account, enabled, configured };
: true;
if (
typeof selectedInspection?.configured !== "boolean" &&
typeof accountRecord?.configured !== "boolean" &&
plugin.config.isConfigured
) {
try {
configured = await plugin.config.isConfigured(account, params.cfg);
} catch (error) {
configured = false;
diagnostics.push(
`${plugin.id}:${accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`,
);
}
}
return { account, enabled, configured, diagnostics };
};
const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => {
@ -298,7 +345,20 @@ export async function collectChannelSecurityFindings(params: {
plugin.id,
accountId,
);
const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId);
const { account, enabled, configured, diagnostics } = await resolveChannelAuditAccount(
plugin,
accountId,
);
for (const diagnostic of diagnostics) {
findings.push({
checkId: `channels.${plugin.id}.account.read_only_resolution`,
severity: "warn",
title: `${plugin.meta.label ?? plugin.id} account could not be fully resolved`,
detail: diagnostic,
remediation:
"Ensure referenced secrets are available in this shell or run with a running gateway snapshot so security audit can inspect the full channel configuration.",
});
}
if (!enabled) {
continue;
}

View File

@ -346,6 +346,43 @@ description: test skill
expectNoFinding(res, "gateway.bind_no_auth");
});
it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => {
const sourceConfig: OpenClawConfig = {
gateway: {
bind: "lan",
auth: {
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
const resolvedConfig: OpenClawConfig = {
gateway: {
bind: "lan",
auth: {},
},
secrets: sourceConfig.secrets,
};
const res = await runSecurityAudit({
config: resolvedConfig,
sourceConfig,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
expectNoFinding(res, "gateway.bind_no_auth");
});
it("evaluates gateway auth rate-limit warning based on configuration", async () => {
const cases: Array<{
name: string;
@ -1805,11 +1842,7 @@ description: test skill
it("warns when multiple DM senders share the main session", async () => {
const cfg: OpenClawConfig = {
session: { dmScope: "main" },
channels: {
whatsapp: {
enabled: true,
},
},
channels: { whatsapp: { enabled: true } },
};
const plugins: ChannelPlugin[] = [
{
@ -1984,6 +2017,40 @@ description: test skill
});
});
it("adds a read-only resolution warning when channel account resolveAccount throws", async () => {
const plugin = stubChannelPlugin({
id: "zalouser",
label: "Zalo Personal",
listAccountIds: () => ["default"],
resolveAccount: () => {
throw new Error("missing SecretRef");
},
});
const cfg: OpenClawConfig = {
channels: {
zalouser: {
enabled: true,
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [plugin],
});
const finding = res.findings.find(
(entry) => entry.checkId === "channels.zalouser.account.read_only_resolution",
);
expect(finding?.severity).toBe("warn");
expect(finding?.title).toContain("could not be fully resolved");
expect(finding?.detail).toContain("zalouser:default: failed to resolve account");
expect(finding?.detail).toContain("missing SecretRef");
});
it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => {
await withChannelSecurityStateDir(async () => {
const sourceConfig: OpenClawConfig = {

View File

@ -113,6 +113,8 @@ export type SecurityAuditOptions = {
configSnapshot?: ConfigFileSnapshot | null;
/** Optional cache for code-safety summaries across repeated deep audits. */
codeSafetySummaryCache?: Map<string, Promise<unknown>>;
/** Optional explicit auth for deep gateway probe. */
deepProbeAuth?: { token?: string; password?: string };
};
type AuditExecutionContext = {
@ -132,6 +134,7 @@ type AuditExecutionContext = {
plugins?: ReturnType<typeof listChannelPlugins>;
configSnapshot: ConfigFileSnapshot | null;
codeSafetySummaryCache: Map<string, Promise<unknown>>;
deepProbeAuth?: { token?: string; password?: string };
};
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
@ -341,6 +344,7 @@ async function collectFilesystemFindings(params: {
function collectGatewayConfigFindings(
cfg: OpenClawConfig,
sourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
@ -365,18 +369,18 @@ function collectGatewayConfigFindings(
hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) ||
hasNonEmptyString(env.CLAWDBOT_GATEWAY_PASSWORD);
const tokenConfiguredFromConfig = hasConfiguredSecretInput(
cfg.gateway?.auth?.token,
cfg.secrets?.defaults,
sourceConfig.gateway?.auth?.token,
sourceConfig.secrets?.defaults,
);
const passwordConfiguredFromConfig = hasConfiguredSecretInput(
cfg.gateway?.auth?.password,
cfg.secrets?.defaults,
sourceConfig.gateway?.auth?.password,
sourceConfig.secrets?.defaults,
);
const remoteTokenConfigured = hasConfiguredSecretInput(
cfg.gateway?.remote?.token,
cfg.secrets?.defaults,
sourceConfig.gateway?.remote?.token,
sourceConfig.secrets?.defaults,
);
const explicitAuthMode = cfg.gateway?.auth?.mode;
const explicitAuthMode = sourceConfig.gateway?.auth?.mode;
const tokenCanWin =
hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured;
const passwordCanWin =
@ -1062,6 +1066,7 @@ async function maybeProbeGateway(params: {
env: NodeJS.ProcessEnv;
timeoutMs: number;
probe: typeof probeGateway;
explicitAuth?: { token?: string; password?: string };
}): Promise<{
deep: SecurityAuditReport["deep"];
authWarning?: string;
@ -1075,8 +1080,18 @@ async function maybeProbeGateway(params: {
const authResolution =
!isRemoteMode || remoteUrlMissing
? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" })
: resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" });
? resolveGatewayProbeAuthSafe({
cfg: params.cfg,
env: params.env,
mode: "local",
explicitAuth: params.explicitAuth,
})
: resolveGatewayProbeAuthSafe({
cfg: params.cfg,
env: params.env,
mode: "remote",
explicitAuth: params.explicitAuth,
});
const res = await params
.probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs })
.catch((err) => ({
@ -1144,6 +1159,7 @@ async function createAuditExecutionContext(
plugins: opts.plugins,
configSnapshot,
codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>(),
deepProbeAuth: opts.deepProbeAuth,
};
}
@ -1155,7 +1171,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
findings.push(...collectGatewayConfigFindings(cfg, env));
findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));
findings.push(...collectBrowserControlFindings(cfg, env));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));
@ -1244,6 +1260,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
env,
timeoutMs: context.deepTimeoutMs,
probe: context.probeGatewayFn ?? probeGateway,
explicitAuth: context.deepProbeAuth,
})
: undefined;
const deep = deepProbeResult?.deep;