Tests: trim audit imports and fix reply typing

This commit is contained in:
Peter Steinberger
2026-04-07 13:22:27 +08:00
parent 80826bc000
commit 2aabe0e8fd
11 changed files with 456 additions and 257 deletions

View File

@@ -63,12 +63,12 @@ function resolveRawConfiguredAcpSessionKey(params: {
continue;
}
const bindingAccountId = normalizeText(binding.match.accountId);
const bindingAccountId = normalizeOptionalString(binding.match.accountId) ?? "";
if (bindingAccountId && bindingAccountId !== "*" && bindingAccountId !== params.accountId) {
continue;
}
const peerId = normalizeText(binding.match.peer?.id);
const peerId = normalizeOptionalString(binding.match.peer?.id) ?? "";
const matchedConversationId =
peerId === params.conversationId
? params.conversationId
@@ -107,7 +107,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
fallbackToActiveAcpWhenUnbound?: boolean;
}): string | undefined {
const activeSessionKey = normalizeText(params.activeSessionKey);
const activeSessionKey = normalizeOptionalString(params.activeSessionKey) ?? "";
const activeAcpSessionKey =
activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
@@ -122,7 +122,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
channel,
accountId: params.accountId,
});
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
const parentConversationId = normalizeOptionalString(params.parentConversationId) || undefined;
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
const serviceBinding = acpResetTargetDeps.getSessionBindingService().resolveByConversation({

View File

@@ -746,8 +746,9 @@ export async function dispatchReplyFromConfig(params: {
message?: string;
}) => {
if (payload.status === "pending") {
if (normalizeOptionalString(payload.command)) {
return normalizeWorkingLabel(`awaiting approval: ${payload.command}`);
const command = normalizeOptionalString(payload.command);
if (command) {
return normalizeWorkingLabel(`awaiting approval: ${command}`);
}
return "awaiting approval";
}

View File

@@ -77,8 +77,10 @@ function isApprovedElevatedSender(params: {
const senderIdTokens = new Set<string>();
const senderFromTokens = new Set<string>();
const senderE164Tokens = new Set<string>();
const senderId = normalizeOptionalString(params.ctx.SenderId);
const senderFrom = normalizeOptionalString(params.ctx.From);
const senderE164 = normalizeOptionalString(params.ctx.SenderE164);
if (senderId) {
addFormattedTokens({
formatAllowFrom: params.formatAllowFrom,
@@ -88,7 +90,6 @@ function isApprovedElevatedSender(params: {
tokens: senderIdTokens,
});
}
const senderFrom = normalizeOptionalString(params.ctx.From);
if (senderFrom) {
addFormattedTokens({
formatAllowFrom: params.formatAllowFrom,
@@ -98,7 +99,6 @@ function isApprovedElevatedSender(params: {
tokens: senderFromTokens,
});
}
const senderE164 = normalizeOptionalString(params.ctx.SenderE164);
if (senderE164) {
addFormattedTokens({
formatAllowFrom: params.formatAllowFrom,

View File

@@ -3,7 +3,7 @@ import { listSupportedMusicGenerationModes } from "../music-generation/capabilit
import {
musicGenerationProviderContractRegistry,
videoGenerationProviderContractRegistry,
} from "../plugins/contracts/registry.js";
} from "../plugins/contracts/media-provider-registry.js";
import { listSupportedVideoGenerationModes } from "../video-generation/capabilities.js";
describe("bundled media-generation provider capabilities", () => {

View File

@@ -0,0 +1,92 @@
import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js";
import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js";
import {
loadVitestMusicGenerationProviderContractRegistry,
loadVitestVideoGenerationProviderContractRegistry,
type MusicGenerationProviderContractEntry,
type VideoGenerationProviderContractEntry,
} from "./speech-vitest-registry.js";
function resolveBundledManifestPluginIdsForContract(
contract: "videoGenerationProviders" | "musicGenerationProviders",
): string[] {
return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) =>
contract === "videoGenerationProviders"
? entry.videoGenerationProviderIds.length > 0
: entry.musicGenerationProviderIds.length > 0,
)
.map((entry) => entry.pluginId)
.toSorted((left, right) => left.localeCompare(right));
}
function createLazyArrayView<T>(load: () => T[]): T[] {
return new Proxy([] as T[], {
get(_target, prop) {
const actual = load();
const value = Reflect.get(actual, prop, actual);
return typeof value === "function" ? value.bind(actual) : value;
},
has(_target, prop) {
return Reflect.has(load(), prop);
},
ownKeys() {
return Reflect.ownKeys(load());
},
getOwnPropertyDescriptor(_target, prop) {
const actual = load();
const descriptor = Reflect.getOwnPropertyDescriptor(actual, prop);
if (descriptor) {
return descriptor;
}
if (Reflect.has(actual, prop)) {
return {
configurable: true,
enumerable: true,
writable: false,
value: Reflect.get(actual, prop, actual),
};
}
return undefined;
},
});
}
let videoGenerationProviderContractRegistryCache: VideoGenerationProviderContractEntry[] | null =
null;
let musicGenerationProviderContractRegistryCache: MusicGenerationProviderContractEntry[] | null =
null;
function loadVideoGenerationProviderContractRegistry(): VideoGenerationProviderContractEntry[] {
if (!videoGenerationProviderContractRegistryCache) {
videoGenerationProviderContractRegistryCache = process.env.VITEST
? loadVitestVideoGenerationProviderContractRegistry()
: loadBundledCapabilityRuntimeRegistry({
pluginIds: resolveBundledManifestPluginIdsForContract("videoGenerationProviders"),
pluginSdkResolution: "dist",
}).videoGenerationProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
}
return videoGenerationProviderContractRegistryCache;
}
function loadMusicGenerationProviderContractRegistry(): MusicGenerationProviderContractEntry[] {
if (!musicGenerationProviderContractRegistryCache) {
musicGenerationProviderContractRegistryCache = process.env.VITEST
? loadVitestMusicGenerationProviderContractRegistry()
: loadBundledCapabilityRuntimeRegistry({
pluginIds: resolveBundledManifestPluginIdsForContract("musicGenerationProviders"),
pluginSdkResolution: "dist",
}).musicGenerationProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
}
return musicGenerationProviderContractRegistryCache;
}
export const videoGenerationProviderContractRegistry: VideoGenerationProviderContractEntry[] =
createLazyArrayView(loadVideoGenerationProviderContractRegistry);
export const musicGenerationProviderContractRegistry: MusicGenerationProviderContractEntry[] =
createLazyArrayView(loadMusicGenerationProviderContractRegistry);

View File

@@ -0,0 +1,297 @@
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../config/model-input.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js";
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
export type SecurityAuditFinding = {
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
};
const SMALL_MODEL_PARAM_B_MAX = 300;
type ModelRef = { id: string; source: string };
function summarizeGroupPolicy(cfg: OpenClawConfig): {
open: number;
allowlist: number;
other: number;
} {
const channels = cfg.channels as Record<string, unknown> | undefined;
if (!channels || typeof channels !== "object") {
return { open: 0, allowlist: 0, other: 0 };
}
let open = 0;
let allowlist = 0;
let other = 0;
for (const value of Object.values(channels)) {
if (!value || typeof value !== "object") {
continue;
}
const section = value as Record<string, unknown>;
const policy = section.groupPolicy;
if (policy === "open") {
open += 1;
} else if (policy === "allowlist") {
allowlist += 1;
} else {
other += 1;
}
}
return { open, allowlist, other };
}
function addModel(models: ModelRef[], raw: unknown, source: string) {
if (typeof raw !== "string") {
return;
}
const id = raw.trim();
if (!id) {
return;
}
models.push({ id, source });
}
function collectModels(cfg: OpenClawConfig): ModelRef[] {
const out: ModelRef[] = [];
addModel(
out,
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model),
"agents.defaults.model.primary",
);
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) {
addModel(out, fallback, "agents.defaults.model.fallbacks");
}
addModel(
out,
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel),
"agents.defaults.imageModel.primary",
);
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) {
addModel(out, fallback, "agents.defaults.imageModel.fallbacks");
}
const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
for (const agent of list ?? []) {
if (!agent || typeof agent !== "object") {
continue;
}
const id =
typeof (agent as { id?: unknown }).id === "string" ? (agent as { id: string }).id : "";
const model = (agent as { model?: unknown }).model;
if (typeof model === "string") {
addModel(out, model, `agents.list.${id}.model`);
} else if (model && typeof model === "object") {
addModel(out, (model as { primary?: unknown }).primary, `agents.list.${id}.model.primary`);
const fallbacks = (model as { fallbacks?: unknown }).fallbacks;
if (Array.isArray(fallbacks)) {
for (const fallback of fallbacks) {
addModel(out, fallback, `agents.list.${id}.model.fallbacks`);
}
}
}
}
return out;
}
function extractAgentIdFromSource(source: string): string | null {
const match = source.match(/^agents\.list\.([^.]*)\./);
return match?.[1] ?? null;
}
function resolveToolPolicies(params: {
cfg: OpenClawConfig;
agentTools?: AgentToolsConfig;
sandboxMode?: "off" | "non-main" | "all";
agentId?: string | null;
}): SandboxToolPolicy[] {
const policies: SandboxToolPolicy[] = [];
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
const profilePolicy = resolveToolProfilePolicy(profile);
if (profilePolicy) {
policies.push(profilePolicy);
}
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
if (globalPolicy) {
policies.push(globalPolicy);
}
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
if (agentPolicy) {
policies.push(agentPolicy);
}
if (params.sandboxMode === "all") {
policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined));
}
return policies;
}
function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return hasConfiguredWebSearchCredential({
config: cfg,
env,
origin: "bundled",
bundledAllowlistCompat: true,
});
}
function isWebSearchEnabled(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
const enabled = cfg.tools?.web?.search?.enabled;
if (enabled === false) {
return false;
}
if (enabled === true) {
return true;
}
return hasWebSearchKey(cfg, env);
}
function isWebFetchEnabled(cfg: OpenClawConfig): boolean {
const enabled = cfg.tools?.web?.fetch?.enabled;
if (enabled === false) {
return false;
}
return true;
}
function isBrowserEnabled(cfg: OpenClawConfig): boolean {
return cfg.browser?.enabled !== false;
}
export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const group = summarizeGroupPolicy(cfg);
const elevated = cfg.tools?.elevated?.enabled !== false;
const webhooksEnabled = cfg.hooks?.enabled === true;
const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false;
const browserEnabled = cfg.browser?.enabled ?? true;
const detail =
`groups: open=${group.open}, allowlist=${group.allowlist}` +
`\n` +
`tools.elevated: ${elevated ? "enabled" : "disabled"}` +
`\n` +
`hooks.webhooks: ${webhooksEnabled ? "enabled" : "disabled"}` +
`\n` +
`hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` +
`\n` +
`browser control: ${browserEnabled ? "enabled" : "disabled"}` +
`\n` +
"trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway";
return [
{
checkId: "summary.attack_surface",
severity: "info",
title: "Attack surface summary",
detail,
},
];
}
export function collectSmallModelRiskFindings(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel"));
if (models.length === 0) {
return findings;
}
const smallModels = models
.map((entry) => {
const paramB = inferParamBFromIdOrName(entry.id);
if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) {
return null;
}
return { ...entry, paramB };
})
.filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry));
if (smallModels.length === 0) {
return findings;
}
let hasUnsafe = false;
const modelLines: string[] = [];
const exposureSet = new Set<string>();
for (const entry of smallModels) {
const agentId = extractAgentIdFromSource(entry.source);
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode;
const agentTools =
agentId && params.cfg.agents?.list
? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools
: undefined;
const policies = resolveToolPolicies({
cfg: params.cfg,
agentTools,
sandboxMode,
agentId,
});
const exposed: string[] = [];
if (
isWebSearchEnabled(params.cfg, params.env) &&
isToolAllowedByPolicies("web_search", policies)
) {
exposed.push("web_search");
}
if (isWebFetchEnabled(params.cfg) && isToolAllowedByPolicies("web_fetch", policies)) {
exposed.push("web_fetch");
}
if (isBrowserEnabled(params.cfg) && isToolAllowedByPolicies("browser", policies)) {
exposed.push("browser");
}
for (const tool of exposed) {
exposureSet.add(tool);
}
const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`;
const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]";
const safe = sandboxMode === "all" && exposed.length === 0;
if (!safe) {
hasUnsafe = true;
}
const statusLabel = safe ? "ok" : "unsafe";
modelLines.push(
`- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`,
);
}
const exposureList = Array.from(exposureSet);
const exposureDetail =
exposureList.length > 0
? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.`
: "No web/browser tools detected for these models.";
findings.push({
checkId: "models.small_params",
severity: hasUnsafe ? "critical" : "info",
title: "Small models require sandboxing and web tools disabled",
detail:
`Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` +
modelLines.join("\n") +
`\n` +
exposureDetail +
`\n` +
"Small models are not recommended for untrusted inputs.",
remediation:
'If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode="all") and disable web_search/web_fetch/browser (tools.deny=["group:web","browser"]).',
});
return findings;
}

View File

@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
collectAttackSurfaceSummaryFindings,
collectSmallModelRiskFindings,
} from "./audit-extra.sync.js";
} from "./audit-extra.summary.js";
import { safeEqualSecret } from "./secret-equal.js";
describe("collectAttackSurfaceSummaryFindings", () => {

View File

@@ -5,6 +5,10 @@ import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/
*
* These functions analyze config-based security properties without I/O.
*/
export {
collectAttackSurfaceSummaryFindings,
collectSmallModelRiskFindings,
} from "./audit-extra.summary.js";
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js";
@@ -23,8 +27,6 @@ import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
resolveNodeCommandAllowlist,
} from "../gateway/node-command-policy.js";
import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js";
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
export type SecurityAuditFinding = {
@@ -35,41 +37,10 @@ export type SecurityAuditFinding = {
remediation?: string;
};
const SMALL_MODEL_PARAM_B_MAX = 300;
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
function summarizeGroupPolicy(cfg: OpenClawConfig): {
open: number;
allowlist: number;
other: number;
} {
const channels = cfg.channels as Record<string, unknown> | undefined;
if (!channels || typeof channels !== "object") {
return { open: 0, allowlist: 0, other: 0 };
}
let open = 0;
let allowlist = 0;
let other = 0;
for (const value of Object.values(channels)) {
if (!value || typeof value !== "object") {
continue;
}
const section = value as Record<string, unknown>;
const policy = section.groupPolicy;
if (policy === "open") {
open += 1;
} else if (policy === "allowlist") {
allowlist += 1;
} else {
other += 1;
}
}
return { open, allowlist, other };
}
function isProbablySyncedPath(p: string): boolean {
const s = p.toLowerCase();
return (
@@ -86,15 +57,6 @@ function looksLikeEnvRef(value: string): boolean {
return v.startsWith("${") && v.endsWith("}");
}
function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean {
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
if (bind !== "loopback") {
return true;
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
return tailscaleMode === "serve" || tailscaleMode === "funnel";
}
type ModelRef = { id: string; source: string };
function addModel(models: ModelRef[], raw: unknown, source: string) {
@@ -115,16 +77,16 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] {
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model),
"agents.defaults.model.primary",
);
for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) {
addModel(out, f, "agents.defaults.model.fallbacks");
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) {
addModel(out, fallback, "agents.defaults.model.fallbacks");
}
addModel(
out,
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel),
"agents.defaults.imageModel.primary",
);
for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) {
addModel(out, f, "agents.defaults.imageModel.fallbacks");
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) {
addModel(out, fallback, "agents.defaults.imageModel.fallbacks");
}
const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
@@ -141,8 +103,8 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] {
addModel(out, (model as { primary?: unknown }).primary, `agents.list.${id}.model.primary`);
const fallbacks = (model as { fallbacks?: unknown }).fallbacks;
if (Array.isArray(fallbacks)) {
for (const f of fallbacks) {
addModel(out, f, `agents.list.${id}.model.fallbacks`);
for (const fallback of fallbacks) {
addModel(out, fallback, `agents.list.${id}.model.fallbacks`);
}
}
}
@@ -150,6 +112,15 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] {
return out;
}
function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean {
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
if (bind !== "loopback") {
return true;
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
return tailscaleMode === "serve" || tailscaleMode === "funnel";
}
const LEGACY_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [
{ id: "openai.gpt35", re: /\bgpt-3\.5\b/i, label: "GPT-3.5 family" },
{ id: "anthropic.claude2", re: /\bclaude-(instant|2)\b/i, label: "Claude 2/Instant family" },
@@ -179,11 +150,6 @@ function isClaude45OrHigher(id: string): boolean {
);
}
function extractAgentIdFromSource(source: string): string | null {
const match = source.match(/^agents\.list\.([^.]*)\./);
return match?.[1] ?? null;
}
function hasConfiguredDockerConfig(
docker: Record<string, unknown> | undefined | null,
): docker is Record<string, unknown> {
@@ -221,6 +187,36 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
return out;
}
function resolveToolPolicies(params: {
cfg: OpenClawConfig;
agentTools?: AgentToolsConfig;
sandboxMode?: "off" | "non-main" | "all";
agentId?: string | null;
}): SandboxToolPolicy[] {
const policies: SandboxToolPolicy[] = [];
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
const profilePolicy = resolveToolProfilePolicy(profile);
if (profilePolicy) {
policies.push(profilePolicy);
}
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
if (globalPolicy) {
policies.push(globalPolicy);
}
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
if (agentPolicy) {
policies.push(agentPolicy);
}
if (params.sandboxMode === "all") {
policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined));
}
return policies;
}
function looksLikeNodeCommandPattern(value: string): boolean {
if (!value) {
return false;
@@ -294,71 +290,6 @@ function suggestKnownNodeCommands(unknown: string, known: Set<string>): string[]
.map((r) => r.cmd);
}
function resolveToolPolicies(params: {
cfg: OpenClawConfig;
agentTools?: AgentToolsConfig;
sandboxMode?: "off" | "non-main" | "all";
agentId?: string | null;
}): SandboxToolPolicy[] {
const policies: SandboxToolPolicy[] = [];
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
const profilePolicy = resolveToolProfilePolicy(profile);
if (profilePolicy) {
policies.push(profilePolicy);
}
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
if (globalPolicy) {
policies.push(globalPolicy);
}
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
if (agentPolicy) {
policies.push(agentPolicy);
}
if (params.sandboxMode === "all") {
const sandboxPolicy = resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined);
policies.push(sandboxPolicy);
}
return policies;
}
function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return hasConfiguredWebSearchCredential({
config: cfg,
env,
origin: "bundled",
bundledAllowlistCompat: true,
});
}
function isWebSearchEnabled(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
const enabled = cfg.tools?.web?.search?.enabled;
if (enabled === false) {
return false;
}
if (enabled === true) {
return true;
}
return hasWebSearchKey(cfg, env);
}
function isWebFetchEnabled(cfg: OpenClawConfig): boolean {
const enabled = cfg.tools?.web?.fetch?.enabled;
if (enabled === false) {
return false;
}
return true;
}
function isBrowserEnabled(cfg: OpenClawConfig): boolean {
// The audit only needs the enablement policy, not full browser runtime
// resolution. Browser defaults to enabled unless it is explicitly disabled.
return cfg.browser?.enabled !== false;
}
function listGroupPolicyOpen(cfg: OpenClawConfig): string[] {
const out: string[] = [];
const channels = cfg.channels as Record<string, unknown> | undefined;
@@ -524,36 +455,6 @@ function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
// Exported collectors
// --------------------------------------------------------------------------
export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const group = summarizeGroupPolicy(cfg);
const elevated = cfg.tools?.elevated?.enabled !== false;
const webhooksEnabled = cfg.hooks?.enabled === true;
const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false;
const browserEnabled = cfg.browser?.enabled ?? true;
const detail =
`groups: open=${group.open}, allowlist=${group.allowlist}` +
`\n` +
`tools.elevated: ${elevated ? "enabled" : "disabled"}` +
`\n` +
`hooks.webhooks: ${webhooksEnabled ? "enabled" : "disabled"}` +
`\n` +
`hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` +
`\n` +
`browser control: ${browserEnabled ? "enabled" : "disabled"}` +
`\n` +
"trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway";
return [
{
checkId: "summary.attack_surface",
severity: "info",
title: "Attack surface summary",
detail,
},
];
}
export function collectSyncedFolderFindings(params: {
stateDir: string;
configPath: string;
@@ -1194,101 +1095,6 @@ export function collectModelHygieneFindings(cfg: OpenClawConfig): SecurityAuditF
return findings;
}
export function collectSmallModelRiskFindings(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel"));
if (models.length === 0) {
return findings;
}
const smallModels = models
.map((entry) => {
const paramB = inferParamBFromIdOrName(entry.id);
if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) {
return null;
}
return { ...entry, paramB };
})
.filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry));
if (smallModels.length === 0) {
return findings;
}
let hasUnsafe = false;
const modelLines: string[] = [];
const exposureSet = new Set<string>();
for (const entry of smallModels) {
const agentId = extractAgentIdFromSource(entry.source);
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode;
const agentTools =
agentId && params.cfg.agents?.list
? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools
: undefined;
const policies = resolveToolPolicies({
cfg: params.cfg,
agentTools,
sandboxMode,
agentId,
});
const exposed: string[] = [];
if (isWebSearchEnabled(params.cfg, params.env)) {
if (isToolAllowedByPolicies("web_search", policies)) {
exposed.push("web_search");
}
}
if (isWebFetchEnabled(params.cfg)) {
if (isToolAllowedByPolicies("web_fetch", policies)) {
exposed.push("web_fetch");
}
}
if (isBrowserEnabled(params.cfg)) {
if (isToolAllowedByPolicies("browser", policies)) {
exposed.push("browser");
}
}
for (const tool of exposed) {
exposureSet.add(tool);
}
const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`;
const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]";
const safe = sandboxMode === "all" && exposed.length === 0;
if (!safe) {
hasUnsafe = true;
}
const statusLabel = safe ? "ok" : "unsafe";
modelLines.push(
`- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`,
);
}
const exposureList = Array.from(exposureSet);
const exposureDetail =
exposureList.length > 0
? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.`
: "No web/browser tools detected for these models.";
findings.push({
checkId: "models.small_params",
severity: hasUnsafe ? "critical" : "info",
title: "Small models require sandboxing and web tools disabled",
detail:
`Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` +
modelLines.join("\n") +
`\n` +
exposureDetail +
`\n` +
"Small models are not recommended for untrusted inputs.",
remediation:
'If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode="all") and disable web_search/web_fetch/browser (tools.deny=["group:web","browser"]).',
});
return findings;
}
export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const openGroups = listGroupPolicyOpen(cfg);

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectSmallModelRiskFindings } from "./audit-extra.sync.js";
import { collectSmallModelRiskFindings } from "./audit-extra.summary.js";
describe("security audit small-model risk findings", () => {
it("scores small-model risk by tool/sandbox exposure", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectAttackSurfaceSummaryFindings } from "./audit-extra.sync.js";
import { collectAttackSurfaceSummaryFindings } from "./audit-extra.summary.js";
describe("security audit attack surface summary", () => {
it("includes an attack surface summary (info)", () => {

View File

@@ -1,5 +1,9 @@
export {
collectAttackSurfaceSummaryFindings,
collectSmallModelRiskFindings,
} from "./audit-extra.summary.js";
export {
collectExposureMatrixFindings,
collectGatewayHttpNoAuthFindings,
collectGatewayHttpSessionKeyOverrideFindings,
@@ -12,7 +16,6 @@ export {
collectSandboxDangerousConfigFindings,
collectSandboxDockerNoopFindings,
collectSecretsInConfigFindings,
collectSmallModelRiskFindings,
collectSyncedFolderFindings,
} from "./audit-extra.sync.js";