refactor(exec): centralize safe-bin policy checks

This commit is contained in:
Peter Steinberger
2026-02-22 13:18:17 +01:00
parent bcad4f67a2
commit 0d0f4c6992
15 changed files with 806 additions and 68 deletions

View File

@@ -0,0 +1,108 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
const { noteSpy } = vi.hoisted(() => ({
noteSpy: vi.fn(),
}));
vi.mock("../terminal/note.js", () => ({
note: noteSpy,
}));
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
async function runDoctorConfigWithInput(params: {
config: Record<string, unknown>;
repair?: boolean;
}) {
return withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(params.config, null, 2),
"utf-8",
);
return loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: params.repair },
confirm: async () => false,
});
});
}
describe("doctor config flow safe bins", () => {
beforeEach(() => {
noteSpy.mockClear();
});
it("scaffolds missing custom safe-bin profiles on repair but skips interpreter bins", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
tools: {
exec: {
safeBins: ["myfilter", "python3"],
},
},
agents: {
list: [
{
id: "ops",
tools: {
exec: {
safeBins: ["mytool", "node"],
},
},
},
],
},
},
});
const cfg = result.cfg as {
tools?: {
exec?: {
safeBinProfiles?: Record<string, object>;
};
};
agents?: {
list?: Array<{
id: string;
tools?: {
exec?: {
safeBinProfiles?: Record<string, object>;
};
};
}>;
};
};
expect(cfg.tools?.exec?.safeBinProfiles?.myfilter).toEqual({});
expect(cfg.tools?.exec?.safeBinProfiles?.python3).toBeUndefined();
const ops = cfg.agents?.list?.find((entry) => entry.id === "ops");
expect(ops?.tools?.exec?.safeBinProfiles?.mytool).toEqual({});
expect(ops?.tools?.exec?.safeBinProfiles?.node).toBeUndefined();
});
it("warns when interpreter/custom safeBins entries are missing profiles in non-repair mode", async () => {
await runDoctorConfigWithInput({
config: {
tools: {
exec: {
safeBins: ["python3", "myfilter"],
},
},
},
});
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("tools.exec.safeBins includes interpreter/runtime 'python3'"),
"Doctor warnings",
);
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("openclaw doctor --fix"),
"Doctor warnings",
);
});
});

View File

@@ -15,6 +15,10 @@ import {
readConfigFileSnapshot,
} from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
listInterpreterLikeSafeBins,
resolveMergedSafeBinProfileFixtures,
} from "../infra/exec-safe-bin-runtime-policy.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
import { note } from "../terminal/note.js";
import { isRecord, resolveHomeDir } from "../utils.js";
@@ -704,6 +708,134 @@ function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): {
return { config: next, changes };
}
type ExecSafeBinCoverageHit = {
scopePath: string;
bin: string;
isInterpreter: boolean;
};
type ExecSafeBinScopeRef = {
scopePath: string;
safeBins: string[];
exec: Record<string, unknown>;
mergedProfiles: Record<string, unknown>;
};
function normalizeConfiguredSafeBins(entries: unknown): string[] {
if (!Array.isArray(entries)) {
return [];
}
return Array.from(
new Set(
entries
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
.filter((entry) => entry.length > 0),
),
).toSorted();
}
function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] {
const scopes: ExecSafeBinScopeRef[] = [];
const globalExec = asObjectRecord(cfg.tools?.exec);
if (globalExec) {
const safeBins = normalizeConfiguredSafeBins(globalExec.safeBins);
if (safeBins.length > 0) {
scopes.push({
scopePath: "tools.exec",
safeBins,
exec: globalExec,
mergedProfiles:
resolveMergedSafeBinProfileFixtures({
global: globalExec,
}) ?? {},
});
}
}
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
for (const agent of agents) {
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
continue;
}
const agentExec = asObjectRecord(agent.tools?.exec);
if (!agentExec) {
continue;
}
const safeBins = normalizeConfiguredSafeBins(agentExec.safeBins);
if (safeBins.length === 0) {
continue;
}
scopes.push({
scopePath: `agents.list.${agent.id}.tools.exec`,
safeBins,
exec: agentExec,
mergedProfiles:
resolveMergedSafeBinProfileFixtures({
global: globalExec,
local: agentExec,
}) ?? {},
});
}
return scopes;
}
function scanExecSafeBinCoverage(cfg: OpenClawConfig): ExecSafeBinCoverageHit[] {
const hits: ExecSafeBinCoverageHit[] = [];
for (const scope of collectExecSafeBinScopes(cfg)) {
const interpreterBins = new Set(listInterpreterLikeSafeBins(scope.safeBins));
for (const bin of scope.safeBins) {
if (scope.mergedProfiles[bin]) {
continue;
}
hits.push({
scopePath: scope.scopePath,
bin,
isInterpreter: interpreterBins.has(bin),
});
}
}
return hits;
}
function maybeRepairExecSafeBinProfiles(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
warnings: string[];
} {
const next = structuredClone(cfg);
const changes: string[] = [];
const warnings: string[] = [];
for (const scope of collectExecSafeBinScopes(next)) {
const interpreterBins = new Set(listInterpreterLikeSafeBins(scope.safeBins));
const missingBins = scope.safeBins.filter((bin) => !scope.mergedProfiles[bin]);
if (missingBins.length === 0) {
continue;
}
const profileHolder =
asObjectRecord(scope.exec.safeBinProfiles) ?? (scope.exec.safeBinProfiles = {});
for (const bin of missingBins) {
if (interpreterBins.has(bin)) {
warnings.push(
`- ${scope.scopePath}.safeBins includes interpreter/runtime '${bin}' without profile; remove it from safeBins or use explicit allowlist entries.`,
);
continue;
}
if (profileHolder[bin] !== undefined) {
continue;
}
profileHolder[bin] = {};
changes.push(
`- ${scope.scopePath}.safeBinProfiles.${bin}: added scaffold profile {} (review and tighten flags/positionals).`,
);
}
}
if (changes.length === 0 && warnings.length === 0) {
return { config: cfg, changes: [], warnings: [] };
}
return { config: next, changes, warnings };
}
async function maybeMigrateLegacyConfig(): Promise<string[]> {
const changes: string[] = [];
const home = resolveHomeDir();
@@ -859,6 +991,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
pendingChanges = true;
cfg = allowFromRepair.config;
}
const safeBinProfileRepair = maybeRepairExecSafeBinProfiles(candidate);
if (safeBinProfileRepair.changes.length > 0) {
note(safeBinProfileRepair.changes.join("\n"), "Doctor changes");
candidate = safeBinProfileRepair.config;
pendingChanges = true;
cfg = safeBinProfileRepair.config;
}
if (safeBinProfileRepair.warnings.length > 0) {
note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings");
}
} else {
const hits = scanTelegramAllowFromUsernameEntries(candidate);
if (hits.length > 0) {
@@ -892,6 +1034,41 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
"Doctor warnings",
);
}
const safeBinCoverage = scanExecSafeBinCoverage(candidate);
if (safeBinCoverage.length > 0) {
const interpreterHits = safeBinCoverage.filter((hit) => hit.isInterpreter);
const customHits = safeBinCoverage.filter((hit) => !hit.isInterpreter);
const lines: string[] = [];
if (interpreterHits.length > 0) {
for (const hit of interpreterHits.slice(0, 5)) {
lines.push(
`- ${hit.scopePath}.safeBins includes interpreter/runtime '${hit.bin}' without profile.`,
);
}
if (interpreterHits.length > 5) {
lines.push(
`- ${interpreterHits.length - 5} more interpreter/runtime safeBins entries are missing profiles.`,
);
}
}
if (customHits.length > 0) {
for (const hit of customHits.slice(0, 5)) {
lines.push(
`- ${hit.scopePath}.safeBins entry '${hit.bin}' is missing safeBinProfiles.${hit.bin}.`,
);
}
if (customHits.length > 5) {
lines.push(
`- ${customHits.length - 5} more custom safeBins entries are missing profiles.`,
);
}
}
lines.push(
`- Run "${formatCliCommand("openclaw doctor --fix")}" to scaffold missing custom safeBinProfiles entries.`,
);
note(lines.join("\n"), "Doctor warnings");
}
}
const unknown = stripUnknownConfigKeys(candidate);