Files
openclaw/src/security/fix.ts
Peter Steinberger 82d1a03f25 refactor(agents): move implicit-main fallback into load-time roster injection (#112678)
* refactor(agents): require explicit roster defaults

* feat(onboard): create named first roster agent

* refactor(agents): remove runtime main fallbacks

* style(agents): apply roster refactor formatting

* refactor(agents): finish roster-only runtime sweep

* fix(doctor): migrate legacy main session sqlite

* fix(doctor): harden roster session migrations

* fix(onboard): commit first agent atomically

* fix(config): support empty-roster analysis

* fix(agents): preserve legacy main state during creation

* fix(setup): materialize baseline agent roster

* fix(agents): harden legacy default transfer recovery

* fix(agents): simplify roster-only legacy compatibility

* fix(agents): preserve staged first-agent entries

* fix(config): migrate persisted implicit-main rosters

* fix(config): preserve staged empty rosters

* fix(agents): finalize roster-only upgrade paths

* fix(sessions): close legacy main migration outcomes

* fix(config): migrate legacy roster markers at load

* fix(sessions): preserve roster upgrade history

* refactor(sessions): restore lean legacy main compatibility

* fix(setup): prepare first-agent credentials before publish

* fix(config): stabilize roster snapshot migration

* refactor(sessions): shrink legacy main compatibility

* fix(agents): restore roster compatibility fidelity

* fix(sessions): preserve divergent legacy history

* refactor(agents): narrow roster-only scope

* fix(config): isolate roster migration

* test(agents): align roster-only fixtures

* fix(agents): keep main agent undeletable

* fix(agents): harden roster migration invariants

* fix(agents): close setup and audit scope gaps

* fix(cron): scope session reaper throttles by agent

* fix(agents): preserve scoped owner precedence

* fix(config): preserve authored config ownership

* fix(setup): keep default workspace and roster in sync

* fix(setup): preserve default entry workspace on bare runs

* fix(agents): adapt roster rebase to keyed entries

* fix(agents): honor both roster representations

* fix(agents): route roster reads through shared helpers

* fix(config): preserve canonical roster writes

* fix(cron): resolve dynamic default for session reaper

* fix(agents): close dynamic default migration gaps

* fix(agents): align scoped session ownership

* fix(sessions): preserve legacy main directory casing

* fix(agents): align cron and legacy auth ownership

* fix(setup): provision the committed default workspace

* fix(cron): align scoped ownership and reaping

* fix(cron): treat blank agent ids as absent

* fix(cron): retain configured session-store owners

* fix(agents): repair roster-aware CI boundaries

* fix(cron): preserve scoped ownership resolution

* fix(agents): preserve rosterless maintenance paths

* fix(agents): propagate roster ownership through runtime boundaries

* fix(agents): preserve roster ownership across runtime paths

* fix(agents): harden roster diagnostics and legacy routing

* fix(agents): remove redundant diagnostic import

* test(agents): type CLI policy fixture explicitly

* fix(config): preserve canonical roster mutation identity

* fix(doctor): read canonical agent rosters consistently

* fix(config): resolve compound roster unsets safely

* fix(config): finalize main-session reconciliation

* fix(doctor): read canonical session state safely

* fix(sessions): preserve current visibility alias

* fix(config): track roster include provenance

* test(config): type roster provenance cases

* fix(config): refine roster include ownership

* fix(agents): preserve staged roster invariants

* test(config): align fixtures with explicit roster ownership

* test(node-host): preserve optional plan typing

* fix(config): preserve authored roster projections

* test(config): keep raw roster fixtures explicit

* test(config): normalize rosters at runtime fixtures

* fix(config): protect authored roster ownership

* fix(agents): require explicit session ownership

* fix(agents): enforce scoped roster ownership

* fix(sessions): merge fixed-store agent partitions

* fix(agents): harden roster ownership boundaries

* fix(config): reject ambiguous roster projections

* fix(sessions): preserve persisted store ownership

* fix(sessions): keep collision diagnostics additive

* fix(security): scan malformed roster workspaces

* test(config): align snapshot fixtures after rebase

* test(agents): use explicit roster fixtures

* fix(config): harden roster diagnostic boundaries

* fix(sessions): isolate fixed-store agent databases

* test(agents): type malformed default markers

* refactor(sessions): extract store collision resolution

* test(system-agent): split oversized setup coverage

* style(system-agent): format split setup suite

* fix(sessions): preserve promoted store ownership

* fix(sessions): derive scoped owner before target

* fix(sessions): preserve explicit sqlite ownership

* fix(agents): restore roster compatibility across CI

* fix(agents): enforce roster-owned runtime boundaries

* fix(agents): satisfy default lookup lint

* test(sessions): split known-owner coverage

* fix(state): satisfy path identity lint

* fix(agents): preserve malformed roster safety boundaries

* fix(agents): restore roster compatibility at runtime boundaries

* fix(config): satisfy roster boundary type checks

* fix(agents): preserve roster ownership across runtime probes

Setup inference probes now execute as the configured roster owner. Malformed agent-prefixed session rows are intentionally omitted by the fail-closed visibility contract rather than normalized by tests.

* fix(agents): satisfy session list owner lint

* fix(agents): preserve roster-owned runtime boundaries

Restore shared logical rows for exact SQLite session locators while keeping their physical database owner separate. The ownership regression test now constructs an explicit sole-owner database directly instead of relying on first-touch capture, matching the intentional shared-store contract.

* fix(sessions): preserve multiply owned exact stores

* fix(sessions): restore runtime owner boundaries

Keep incognito sentinels agent-owned, fold default-agent approvals into the global snapshot, and preserve the configless legacy-main CLI policy fallback. Also repair the existing CLI watchdog test lifecycle so the compact shard observes its timeout without an unawaited assertion or async timer stall; product behavior is unchanged by that test-only fix.

* test(ci): align owner-scoped fixtures

These assertions are unchanged. The fixtures now declare the intended non-default runner, expose the session-key constant imported by production status code, and select the main approvals bucket explicitly on Windows.

* fix(agents): close final roster ownership gaps
2026-07-24 22:38:09 -07:00

478 lines
13 KiB
TypeScript

// Applies safe automatic fixes for supported security audit findings.
import fs from "node:fs/promises";
import path from "node:path";
import { listAgentEntries, tryResolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAuthProfileDatabaseFilePaths } from "../agents/auth-profiles/sqlite.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import { createConfigIO, replaceConfigFile } from "../config/config.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runExec } from "../process/exec.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
type SecurityFixChmodAction = {
kind: "chmod";
path: string;
mode: number;
ok: boolean;
skipped?: string;
error?: string;
};
type SecurityFixIcaclsAction = {
kind: "icacls";
path: string;
command: string;
ok: boolean;
skipped?: string;
error?: string;
};
type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
type SecurityFixResult = {
ok: boolean;
stateDir: string;
configPath: string;
configWritten: boolean;
changes: string[];
actions: SecurityFixAction[];
errors: string[];
};
type SecurityPermissionTarget = {
path: string;
mode: number;
require: "dir" | "file";
};
async function safeChmod(params: {
path: string;
mode: number;
require: "dir" | "file";
}): Promise<SecurityFixChmodAction> {
try {
const st = await fs.lstat(params.path);
if (st.isSymbolicLink()) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "symlink",
};
}
if (params.require === "dir" && !st.isDirectory()) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "not-a-directory",
};
}
if (params.require === "file" && !st.isFile()) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "not-a-file",
};
}
const current = st.mode & 0o777;
if (current === params.mode) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "already",
};
}
await fs.chmod(params.path, params.mode);
return { kind: "chmod", path: params.path, mode: params.mode, ok: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "missing",
};
}
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
error: String(err),
};
}
}
async function safeAclReset(params: {
path: string;
require: "dir" | "file";
env: NodeJS.ProcessEnv;
exec?: ExecFn;
}): Promise<SecurityFixIcaclsAction> {
const display = formatIcaclsResetCommand(params.path, {
isDir: params.require === "dir",
env: params.env,
});
try {
const st = await fs.lstat(params.path);
if (st.isSymbolicLink()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "symlink",
};
}
if (params.require === "dir" && !st.isDirectory()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "not-a-directory",
};
}
if (params.require === "file" && !st.isFile()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "not-a-file",
};
}
const cmd = createIcaclsResetCommand(params.path, {
isDir: st.isDirectory(),
env: params.env,
});
if (!cmd) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "missing-user",
};
}
const exec = params.exec ?? runExec;
await exec(cmd.command, cmd.args);
return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "missing",
};
}
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
error: String(err),
};
}
}
function setGroupPolicyAllowlist(params: {
cfg: OpenClawConfig;
channel: string;
changes: string[];
}): void {
if (!params.cfg.channels) {
return;
}
const section = params.cfg.channels[params.channel as keyof OpenClawConfig["channels"]] as
| Record<string, unknown>
| undefined;
if (!section || typeof section !== "object") {
return;
}
const topPolicy = section.groupPolicy;
if (topPolicy === "open") {
section.groupPolicy = "allowlist";
params.changes.push(`channels.${params.channel}.groupPolicy=open -> allowlist`);
}
const accounts = section.accounts;
if (!accounts || typeof accounts !== "object") {
return;
}
for (const [accountId, accountValue] of Object.entries(accounts)) {
if (!accountId) {
continue;
}
if (!accountValue || typeof accountValue !== "object") {
continue;
}
const account = accountValue as Record<string, unknown>;
if (account.groupPolicy === "open") {
account.groupPolicy = "allowlist";
params.changes.push(
`channels.${params.channel}.accounts.${accountId}.groupPolicy=open -> allowlist`,
);
}
}
}
function applyConfigFixes(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv }): {
cfg: OpenClawConfig;
changes: string[];
} {
const next = structuredClone(params.cfg ?? {});
const changes: string[] = [];
for (const channel of Object.keys(next.channels ?? {})) {
setGroupPolicyAllowlist({ cfg: next, channel, changes });
}
return { cfg: next, changes };
}
async function applySecurityFixConfigMutations(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
channelPlugins?: ChannelPlugin[];
}): Promise<{
cfg: OpenClawConfig;
changes: string[];
}> {
const fixed = applyConfigFixes({ cfg: params.cfg, env: params.env });
const channelFixes = await collectChannelSecurityConfigFixMutation({
cfg: fixed.cfg,
env: params.env,
channelPlugins: params.channelPlugins,
});
return {
cfg: channelFixes.cfg,
changes: [...fixed.changes, ...channelFixes.changes],
};
}
async function collectChannelSecurityConfigFixMutation(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
channelPlugins?: ChannelPlugin[];
}) {
let nextCfg = params.cfg;
const changes: string[] = [];
const collectPlugins = async (): Promise<ChannelPlugin[]> => {
if (params.channelPlugins) {
return params.channelPlugins;
}
try {
const pluginIds = Object.keys(params.cfg.channels ?? {}).filter(Boolean);
if (pluginIds.length === 0) {
return [];
}
const wanted = new Set(pluginIds);
const { listBundledChannelPlugins } = await import("../channels/plugins/bundled.js");
return listBundledChannelPlugins().filter((plugin) => wanted.has(plugin.id));
} catch {
return [];
}
};
for (const plugin of await collectPlugins()) {
const mutation = await plugin.security?.applyConfigFixes?.({
cfg: nextCfg,
env: params.env,
});
if (!mutation || mutation.changes.length === 0) {
continue;
}
nextCfg = mutation.config;
changes.push(...mutation.changes);
}
return { cfg: nextCfg, changes };
}
async function collectSecurityPermissionTargets(params: {
env: NodeJS.ProcessEnv;
stateDir: string;
configPath: string;
cfg: OpenClawConfig;
includePaths?: readonly string[];
}): Promise<SecurityPermissionTarget[]> {
const targets: SecurityPermissionTarget[] = [
{ path: params.stateDir, mode: 0o700, require: "dir" },
{ path: params.configPath, mode: 0o600, require: "file" },
...(params.includePaths ?? []).map((targetPath) => ({
path: targetPath,
mode: 0o600,
require: "file" as const,
})),
];
const credsDir = resolveOAuthDir(params.env, params.stateDir);
targets.push({ path: credsDir, mode: 0o700, require: "dir" });
const credsEntries = await fs.readdir(credsDir, { withFileTypes: true }).catch(() => []);
for (const entry of credsEntries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith(".json")) {
continue;
}
const p = path.join(credsDir, entry.name);
targets.push({ path: p, mode: 0o600, require: "file" });
}
const ids = new Set<string>();
ids.add(LEGACY_IMPLICIT_AGENT_ID);
const defaultAgentId = tryResolveDefaultAgentId(params.cfg);
if (defaultAgentId) {
ids.add(defaultAgentId);
}
for (const agent of listAgentEntries(params.cfg)) {
if (!agent || typeof agent !== "object") {
continue;
}
const id =
typeof (agent as { id?: unknown }).id === "string" ? (agent as { id: string }).id.trim() : "";
if (id) {
ids.add(id);
}
}
for (const agentId of ids) {
const normalizedAgentId = normalizeAgentId(agentId);
const agentRoot = path.join(params.stateDir, "agents", normalizedAgentId);
const agentDir = path.join(agentRoot, "agent");
const sessionsDir = path.join(agentRoot, "sessions");
targets.push({ path: agentRoot, mode: 0o700, require: "dir" });
targets.push({ path: agentDir, mode: 0o700, require: "dir" });
for (const databasePath of resolveAuthProfileDatabaseFilePaths(agentDir)) {
targets.push({ path: databasePath, mode: 0o600, require: "file" });
}
const authPath = path.join(agentDir, "auth-profiles.json");
targets.push({ path: authPath, mode: 0o600, require: "file" });
targets.push({ path: sessionsDir, mode: 0o700, require: "dir" });
const storePath = path.join(sessionsDir, "sessions.json");
targets.push({ path: storePath, mode: 0o600, require: "file" });
// Fix permissions on session transcript files (*.jsonl)
const sessionEntries = await fs.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
for (const entry of sessionEntries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith(".jsonl")) {
continue;
}
const p = path.join(sessionsDir, entry.name);
targets.push({ path: p, mode: 0o600, require: "file" });
}
}
return targets;
}
export async function fixSecurityFootguns(opts?: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
configPath?: string;
platform?: NodeJS.Platform;
exec?: ExecFn;
channelPlugins?: ChannelPlugin[];
}): Promise<SecurityFixResult> {
const env = opts?.env ?? process.env;
const platform = opts?.platform ?? process.platform;
const exec = opts?.exec ?? runExec;
const isWindows = platform === "win32";
const stateDir = opts?.stateDir ?? resolveStateDir(env);
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
const actions: SecurityFixAction[] = [];
const errors: string[] = [];
const io = createConfigIO({ env, configPath });
const { snapshot: snap, writeOptions } = await io.readConfigFileSnapshotForWrite();
if (!snap.valid) {
errors.push(...snap.issues.map((i) => `${i.path}: ${i.message}`));
}
let configWritten = false;
let changes: string[] = [];
if (snap.valid) {
const fixed = await applySecurityFixConfigMutations({
cfg: snap.config,
env,
channelPlugins: opts?.channelPlugins,
});
changes = fixed.changes;
if (changes.length > 0) {
try {
await replaceConfigFile({
nextConfig: fixed.cfg,
snapshot: snap,
writeOptions,
io,
afterWrite: { mode: "auto" },
});
configWritten = true;
} catch (err) {
errors.push(`replaceConfigFile failed: ${String(err)}`);
}
}
}
const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
isWindows
? safeAclReset({ path: params.path, require: params.require, env, exec })
: safeChmod({ path: params.path, mode: params.mode, require: params.require });
let includePaths: string[] = [];
if (snap.exists) {
includePaths = await collectIncludePathsRecursive({
configPath: snap.path,
parsed: snap.parsed,
env,
}).catch(() => []);
}
const permissionTargets = await collectSecurityPermissionTargets({
env,
stateDir,
configPath,
cfg: snap.config ?? {},
includePaths,
}).catch((err: unknown) => {
errors.push(`collectSecurityPermissionTargets failed: ${String(err)}`);
return [] as SecurityPermissionTarget[];
});
for (const target of permissionTargets) {
actions.push(await applyPerms(target));
}
return {
ok: errors.length === 0,
stateDir,
configPath,
configWritten,
changes,
actions,
errors,
};
}