Skip to content

Commit 4d124e4

Browse files
committed
feat(security): warn on likely multi-user trust-model mismatch
1 parent 32d7756 commit 4d124e4

7 files changed

Lines changed: 236 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
77
### Changes
88

99
- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc.
10+
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
1011

1112
### Fixes
1213

docs/cli/security.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ openclaw security audit --json
2525

2626
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
2727
This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts).
28+
It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example configured group targets or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default.
29+
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
2830
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
2931
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
3032
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy.

docs/gateway/security/index.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ title: "Security"
77

88
# Security 🔒
99

10+
> [!WARNING]
11+
> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model).
12+
> OpenClaw is **not** a hostile multi-tenant security boundary for multiple adversarial users sharing one agent/gateway.
13+
> If you need mixed-trust or adversarial-user operation, split trust boundaries (separate gateway + credentials, ideally separate OS users/hosts).
14+
15+
## Scope first: personal assistant security model
16+
17+
OpenClaw security guidance assumes a **personal assistant** deployment: one trusted operator boundary, potentially many agents.
18+
19+
- Supported security posture: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary).
20+
- Not a supported security boundary: one shared gateway/agent used by mutually untrusted or adversarial users.
21+
- If adversarial-user isolation is required, split by trust boundary (separate gateway + credentials, and ideally separate OS users/hosts).
22+
- If multiple untrusted users can message one tool-enabled agent, treat them as sharing the same delegated tool authority for that agent.
23+
24+
This page explains hardening **within that model**. It does not claim hostile multi-tenant isolation on one shared gateway.
25+
1026
## Quick check: `openclaw security audit`
1127

1228
See also: [Formal Verification (Security Models)](/security/formal-verification/)

src/security/audit-extra.sync.ts

Lines changed: 167 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,137 @@ function listGroupPolicyOpen(cfg: OpenClawConfig): string[] {
338338
return out;
339339
}
340340

341+
function hasConfiguredGroupTargets(section: Record<string, unknown>): boolean {
342+
const groupKeys = ["groups", "guilds", "channels", "rooms"];
343+
return groupKeys.some((key) => {
344+
const value = section[key];
345+
return Boolean(value && typeof value === "object" && Object.keys(value).length > 0);
346+
});
347+
}
348+
349+
function listPotentialMultiUserSignals(cfg: OpenClawConfig): string[] {
350+
const out = new Set<string>();
351+
const channels = cfg.channels as Record<string, unknown> | undefined;
352+
if (!channels || typeof channels !== "object") {
353+
return [];
354+
}
355+
356+
const inspectSection = (section: Record<string, unknown>, basePath: string) => {
357+
const groupPolicy = typeof section.groupPolicy === "string" ? section.groupPolicy : null;
358+
if (groupPolicy === "open") {
359+
out.add(`${basePath}.groupPolicy="open"`);
360+
} else if (groupPolicy === "allowlist" && hasConfiguredGroupTargets(section)) {
361+
out.add(`${basePath}.groupPolicy="allowlist" with configured group targets`);
362+
}
363+
364+
const dmPolicy = typeof section.dmPolicy === "string" ? section.dmPolicy : null;
365+
if (dmPolicy === "open") {
366+
out.add(`${basePath}.dmPolicy="open"`);
367+
}
368+
369+
const allowFrom = Array.isArray(section.allowFrom) ? section.allowFrom : [];
370+
if (allowFrom.some((entry) => String(entry).trim() === "*")) {
371+
out.add(`${basePath}.allowFrom includes "*"`);
372+
}
373+
374+
const groupAllowFrom = Array.isArray(section.groupAllowFrom) ? section.groupAllowFrom : [];
375+
if (groupAllowFrom.some((entry) => String(entry).trim() === "*")) {
376+
out.add(`${basePath}.groupAllowFrom includes "*"`);
377+
}
378+
379+
const dm = section.dm;
380+
if (dm && typeof dm === "object") {
381+
const dmSection = dm as Record<string, unknown>;
382+
const dmLegacyPolicy = typeof dmSection.policy === "string" ? dmSection.policy : null;
383+
if (dmLegacyPolicy === "open") {
384+
out.add(`${basePath}.dm.policy="open"`);
385+
}
386+
const dmAllowFrom = Array.isArray(dmSection.allowFrom) ? dmSection.allowFrom : [];
387+
if (dmAllowFrom.some((entry) => String(entry).trim() === "*")) {
388+
out.add(`${basePath}.dm.allowFrom includes "*"`);
389+
}
390+
}
391+
};
392+
393+
for (const [channelId, value] of Object.entries(channels)) {
394+
if (!value || typeof value !== "object") {
395+
continue;
396+
}
397+
const section = value as Record<string, unknown>;
398+
inspectSection(section, `channels.${channelId}`);
399+
const accounts = section.accounts;
400+
if (!accounts || typeof accounts !== "object") {
401+
continue;
402+
}
403+
for (const [accountId, accountValue] of Object.entries(accounts)) {
404+
if (!accountValue || typeof accountValue !== "object") {
405+
continue;
406+
}
407+
inspectSection(
408+
accountValue as Record<string, unknown>,
409+
`channels.${channelId}.accounts.${accountId}`,
410+
);
411+
}
412+
}
413+
414+
return Array.from(out);
415+
}
416+
417+
function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
418+
riskyContexts: string[];
419+
hasRuntimeRisk: boolean;
420+
} {
421+
const contexts: Array<{
422+
label: string;
423+
agentId?: string;
424+
tools?: AgentToolsConfig;
425+
}> = [{ label: "agents.defaults" }];
426+
for (const agent of cfg.agents?.list ?? []) {
427+
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
428+
continue;
429+
}
430+
contexts.push({
431+
label: `agents.list.${agent.id}`,
432+
agentId: agent.id,
433+
tools: agent.tools,
434+
});
435+
}
436+
437+
const riskyContexts: string[] = [];
438+
let hasRuntimeRisk = false;
439+
for (const context of contexts) {
440+
const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode;
441+
const policies = resolveToolPolicies({
442+
cfg,
443+
agentTools: context.tools,
444+
sandboxMode,
445+
agentId: context.agentId ?? null,
446+
});
447+
const runtimeTools = ["exec", "process"].filter((tool) =>
448+
isToolAllowedByPolicies(tool, policies),
449+
);
450+
const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) =>
451+
isToolAllowedByPolicies(tool, policies),
452+
);
453+
const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly;
454+
const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all";
455+
const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true;
456+
if (!runtimeUnguarded && !fsUnguarded) {
457+
continue;
458+
}
459+
if (runtimeUnguarded) {
460+
hasRuntimeRisk = true;
461+
}
462+
riskyContexts.push(
463+
`${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${
464+
fsWorkspaceOnly === true ? "true" : "false"
465+
})`,
466+
);
467+
}
468+
469+
return { riskyContexts, hasRuntimeRisk };
470+
}
471+
341472
// --------------------------------------------------------------------------
342473
// Exported collectors
343474
// --------------------------------------------------------------------------
@@ -358,7 +489,9 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi
358489
`\n` +
359490
`hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` +
360491
`\n` +
361-
`browser control: ${browserEnabled ? "enabled" : "disabled"}`;
492+
`browser control: ${browserEnabled ? "enabled" : "disabled"}` +
493+
`\n` +
494+
"trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway";
362495

363496
return [
364497
{
@@ -1096,53 +1229,7 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi
10961229
});
10971230
}
10981231

1099-
const contexts: Array<{
1100-
label: string;
1101-
agentId?: string;
1102-
tools?: AgentToolsConfig;
1103-
}> = [{ label: "agents.defaults" }];
1104-
for (const agent of cfg.agents?.list ?? []) {
1105-
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
1106-
continue;
1107-
}
1108-
contexts.push({
1109-
label: `agents.list.${agent.id}`,
1110-
agentId: agent.id,
1111-
tools: agent.tools,
1112-
});
1113-
}
1114-
1115-
const riskyContexts: string[] = [];
1116-
let hasRuntimeRisk = false;
1117-
for (const context of contexts) {
1118-
const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode;
1119-
const policies = resolveToolPolicies({
1120-
cfg,
1121-
agentTools: context.tools,
1122-
sandboxMode,
1123-
agentId: context.agentId ?? null,
1124-
});
1125-
const runtimeTools = ["exec", "process"].filter((tool) =>
1126-
isToolAllowedByPolicies(tool, policies),
1127-
);
1128-
const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) =>
1129-
isToolAllowedByPolicies(tool, policies),
1130-
);
1131-
const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly;
1132-
const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all";
1133-
const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true;
1134-
if (!runtimeUnguarded && !fsUnguarded) {
1135-
continue;
1136-
}
1137-
if (runtimeUnguarded) {
1138-
hasRuntimeRisk = true;
1139-
}
1140-
riskyContexts.push(
1141-
`${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${
1142-
fsWorkspaceOnly === true ? "true" : "false"
1143-
})`,
1144-
);
1145-
}
1232+
const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg);
11461233

11471234
if (riskyContexts.length > 0) {
11481235
findings.push({
@@ -1160,3 +1247,35 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi
11601247

11611248
return findings;
11621249
}
1250+
1251+
export function collectLikelyMultiUserSetupFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
1252+
const findings: SecurityAuditFinding[] = [];
1253+
const signals = listPotentialMultiUserSignals(cfg);
1254+
if (signals.length === 0) {
1255+
return findings;
1256+
}
1257+
1258+
const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg);
1259+
const impactLine = hasRuntimeRisk
1260+
? "Runtime/process tools are exposed without full sandboxing in at least one context."
1261+
: "No unguarded runtime/process tools were detected by this heuristic.";
1262+
const riskyContextsDetail =
1263+
riskyContexts.length > 0
1264+
? `Potential high-impact tool exposure contexts:\n${riskyContexts.map((line) => `- ${line}`).join("\n")}`
1265+
: "No unguarded runtime/filesystem contexts detected.";
1266+
1267+
findings.push({
1268+
checkId: "security.trust_model.multi_user_heuristic",
1269+
severity: "warn",
1270+
title: "Potential multi-user setup detected (personal-assistant model warning)",
1271+
detail:
1272+
"Heuristic signals indicate this gateway may be reachable by multiple users:\n" +
1273+
signals.map((signal) => `- ${signal}`).join("\n") +
1274+
`\n${impactLine}\n${riskyContextsDetail}\n` +
1275+
"OpenClaw's default security model is personal-assistant (one trusted operator boundary), not hostile multi-tenant isolation on one shared gateway.",
1276+
remediation:
1277+
'If users may be mutually untrusted, split trust boundaries (separate gateways + credentials, ideally separate OS users/hosts). If you intentionally run shared-user access, set agents.defaults.sandbox.mode="all", keep tools.fs.workspaceOnly=true, deny runtime/fs/web tools unless required, and keep personal/private identities + credentials off that runtime.',
1278+
});
1279+
1280+
return findings;
1281+
}

src/security/audit-extra.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
collectGatewayHttpNoAuthFindings,
1515
collectGatewayHttpSessionKeyOverrideFindings,
1616
collectHooksHardeningFindings,
17+
collectLikelyMultiUserSetupFindings,
1718
collectMinimalProfileOverrideFindings,
1819
collectModelHygieneFindings,
1920
collectNodeDangerousAllowCommandFindings,

src/security/audit.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,14 @@ describe("security audit", () => {
178178
};
179179

180180
const res = await audit(cfg);
181+
const summary = res.findings.find((f) => f.checkId === "summary.attack_surface");
181182

182183
expect(res.findings).toEqual(
183184
expect.arrayContaining([
184185
expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }),
185186
]),
186187
);
188+
expect(summary?.detail).toContain("trust model: personal assistant");
187189
});
188190

189191
it("flags non-loopback bind without auth as critical", async () => {
@@ -2696,6 +2698,51 @@ description: test skill
26962698
).toBe(false);
26972699
});
26982700

2701+
it("warns when config heuristics suggest a likely multi-user setup", async () => {
2702+
const cfg: OpenClawConfig = {
2703+
channels: {
2704+
discord: {
2705+
groupPolicy: "allowlist",
2706+
guilds: {
2707+
"1234567890": {
2708+
channels: {
2709+
"7777777777": { allow: true },
2710+
},
2711+
},
2712+
},
2713+
},
2714+
},
2715+
tools: { elevated: { enabled: false } },
2716+
};
2717+
2718+
const res = await audit(cfg);
2719+
const finding = res.findings.find(
2720+
(f) => f.checkId === "security.trust_model.multi_user_heuristic",
2721+
);
2722+
2723+
expect(finding?.severity).toBe("warn");
2724+
expect(finding?.detail).toContain(
2725+
'channels.discord.groupPolicy="allowlist" with configured group targets',
2726+
);
2727+
expect(finding?.detail).toContain("personal-assistant");
2728+
expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"');
2729+
});
2730+
2731+
it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => {
2732+
const cfg: OpenClawConfig = {
2733+
channels: {
2734+
discord: {
2735+
groupPolicy: "allowlist",
2736+
},
2737+
},
2738+
tools: { elevated: { enabled: false } },
2739+
};
2740+
2741+
const res = await audit(cfg);
2742+
2743+
expectNoFinding(res, "security.trust_model.multi_user_heuristic");
2744+
});
2745+
26992746
describe("maybeProbeGateway auth selection", () => {
27002747
const makeProbeCapture = () => {
27012748
let capturedAuth: { token?: string; password?: string } | undefined;

src/security/audit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
collectHooksHardeningFindings,
2525
collectIncludeFilePermFindings,
2626
collectInstalledSkillsCodeSafetyFindings,
27+
collectLikelyMultiUserSetupFindings,
2728
collectSandboxBrowserHashLabelFindings,
2829
collectMinimalProfileOverrideFindings,
2930
collectModelHygieneFindings,
@@ -866,6 +867,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
866867
findings.push(...collectModelHygieneFindings(cfg));
867868
findings.push(...collectSmallModelRiskFindings({ cfg, env }));
868869
findings.push(...collectExposureMatrixFindings(cfg));
870+
findings.push(...collectLikelyMultiUserSetupFindings(cfg));
869871

870872
const configSnapshot =
871873
opts.includeFilesystem !== false

0 commit comments

Comments
 (0)