fix: tighten small-model audit guardrails
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||||
|
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||||
### Fixes
|
### Fixes
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ clawdbot security audit --fix
|
|||||||
```
|
```
|
||||||
|
|
||||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||||
|
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ Recommendations:
|
|||||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||||
|
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||||
|
|
||||||
## Reasoning & verbose output in groups
|
## Reasoning & verbose output in groups
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ import { resolveNativeSkillsEnabled } from "../config/commands.js";
|
|||||||
import { resolveOAuthDir } from "../config/paths.js";
|
import { resolveOAuthDir } from "../config/paths.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||||
|
import { resolveBrowserConfig } from "../browser/config.js";
|
||||||
|
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
|
||||||
|
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||||
|
import {
|
||||||
|
resolveSandboxConfigForAgent,
|
||||||
|
resolveSandboxToolPolicyForAgent,
|
||||||
|
} from "../agents/sandbox.js";
|
||||||
|
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +38,8 @@ export type SecurityAuditFinding = {
|
|||||||
remediation?: string;
|
remediation?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SMALL_MODEL_PARAM_B_MAX = 300;
|
||||||
|
|
||||||
function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null {
|
function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null {
|
||||||
if (!p.startsWith("~")) return p;
|
if (!p.startsWith("~")) return p;
|
||||||
const home = typeof env.HOME === "string" && env.HOME.trim() ? env.HOME.trim() : null;
|
const home = typeof env.HOME === "string" && env.HOME.trim() ? env.HOME.trim() : null;
|
||||||
@@ -266,6 +277,20 @@ const WEAK_TIER_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }>
|
|||||||
{ id: "anthropic.haiku", re: /\bhaiku\b/i, label: "Haiku tier (smaller model)" },
|
{ id: "anthropic.haiku", re: /\bhaiku\b/i, label: "Haiku tier (smaller model)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function inferParamBFromIdOrName(text: string): number | null {
|
||||||
|
const raw = text.toLowerCase();
|
||||||
|
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
|
||||||
|
let best: number | null = null;
|
||||||
|
for (const match of matches) {
|
||||||
|
const numRaw = match[1];
|
||||||
|
if (!numRaw) continue;
|
||||||
|
const value = Number(numRaw);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) continue;
|
||||||
|
if (best === null || value > best) best = value;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
function isGptModel(id: string): boolean {
|
function isGptModel(id: string): boolean {
|
||||||
return /\bgpt-/i.test(id);
|
return /\bgpt-/i.test(id);
|
||||||
}
|
}
|
||||||
@@ -363,6 +388,155 @@ export function collectModelHygieneFindings(cfg: ClawdbotConfig): SecurityAuditF
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractAgentIdFromSource(source: string): string | null {
|
||||||
|
const match = source.match(/^agents\.list\.([^.]*)\./);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): SandboxToolPolicy | null {
|
||||||
|
if (!config) return null;
|
||||||
|
const allow = Array.isArray(config.allow) ? config.allow : undefined;
|
||||||
|
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
||||||
|
if (!allow && !deny) return null;
|
||||||
|
return { allow, deny };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolPolicies(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
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 = pickToolPolicy(params.cfg.tools ?? undefined);
|
||||||
|
if (globalPolicy) policies.push(globalPolicy);
|
||||||
|
|
||||||
|
const agentPolicy = pickToolPolicy(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: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
|
||||||
|
const search = cfg.tools?.web?.search;
|
||||||
|
return Boolean(
|
||||||
|
search?.apiKey ||
|
||||||
|
search?.perplexity?.apiKey ||
|
||||||
|
env.BRAVE_API_KEY ||
|
||||||
|
env.PERPLEXITY_API_KEY ||
|
||||||
|
env.OPENROUTER_API_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebSearchEnabled(cfg: ClawdbotConfig, 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: ClawdbotConfig): boolean {
|
||||||
|
const enabled = cfg.tools?.web?.fetch?.enabled;
|
||||||
|
if (enabled === false) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBrowserEnabled(cfg: ClawdbotConfig): boolean {
|
||||||
|
try {
|
||||||
|
return resolveBrowserConfig(cfg.browser).enabled;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSmallModelRiskFindings(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
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 async function collectPluginsTrustFindings(params: {
|
export async function collectPluginsTrustFindings(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
|
|||||||
@@ -71,6 +71,56 @@ describe("security audit", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns when small models are paired with web/browser tools", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: { enabled: true },
|
||||||
|
fetch: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
browser: { enabled: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finding = res.findings.find((f) => f.checkId === "models.small_params");
|
||||||
|
expect(finding?.severity).toBe("critical");
|
||||||
|
expect(finding?.detail).toContain("mistral-8b");
|
||||||
|
expect(finding?.detail).toContain("web_search");
|
||||||
|
expect(finding?.detail).toContain("web_fetch");
|
||||||
|
expect(finding?.detail).toContain("browser");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats small models as safe when sandbox is on and web tools are disabled", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: { defaults: { model: { primary: "ollama/mistral-8b" }, sandbox: { mode: "all" } } },
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: { enabled: false },
|
||||||
|
fetch: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
browser: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finding = res.findings.find((f) => f.checkId === "models.small_params");
|
||||||
|
expect(finding?.severity).toBe("info");
|
||||||
|
expect(finding?.detail).toContain("mistral-8b");
|
||||||
|
expect(finding?.detail).toContain("sandbox=all");
|
||||||
|
});
|
||||||
|
|
||||||
it("flags tools.elevated allowFrom wildcard as critical", async () => {
|
it("flags tools.elevated allowFrom wildcard as critical", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
tools: {
|
tools: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
collectHooksHardeningFindings,
|
collectHooksHardeningFindings,
|
||||||
collectIncludeFilePermFindings,
|
collectIncludeFilePermFindings,
|
||||||
collectModelHygieneFindings,
|
collectModelHygieneFindings,
|
||||||
|
collectSmallModelRiskFindings,
|
||||||
collectPluginsTrustFindings,
|
collectPluginsTrustFindings,
|
||||||
collectSecretsInConfigFindings,
|
collectSecretsInConfigFindings,
|
||||||
collectStateDeepFilesystemFindings,
|
collectStateDeepFilesystemFindings,
|
||||||
@@ -805,6 +806,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||||||
findings.push(...collectHooksHardeningFindings(cfg));
|
findings.push(...collectHooksHardeningFindings(cfg));
|
||||||
findings.push(...collectSecretsInConfigFindings(cfg));
|
findings.push(...collectSecretsInConfigFindings(cfg));
|
||||||
findings.push(...collectModelHygieneFindings(cfg));
|
findings.push(...collectModelHygieneFindings(cfg));
|
||||||
|
findings.push(...collectSmallModelRiskFindings({ cfg, env }));
|
||||||
findings.push(...collectExposureMatrixFindings(cfg));
|
findings.push(...collectExposureMatrixFindings(cfg));
|
||||||
|
|
||||||
const configSnapshot =
|
const configSnapshot =
|
||||||
|
|||||||
Reference in New Issue
Block a user