mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 20:21:38 +00:00
* 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
478 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|