refactor: harden safe-bin trusted dir diagnostics

This commit is contained in:
Peter Steinberger
2026-02-24 23:29:12 +00:00
parent 5c2a483375
commit 4355e08262
10 changed files with 391 additions and 7 deletions

View File

@@ -1,5 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
isInterpreterLikeSafeBin,
listInterpreterLikeSafeBins,
@@ -103,4 +105,34 @@ describe("exec safe-bin runtime policy", () => {
expect(optedIn.trustedSafeBinDirs.has(path.resolve("/opt/homebrew/bin"))).toBe(true);
expect(optedIn.trustedSafeBinDirs.has(path.resolve("/usr/local/bin"))).toBe(true);
});
it("emits runtime warning when explicitly trusted dir is writable", async () => {
if (process.platform === "win32") {
return;
}
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-safe-bin-runtime-"));
try {
await fs.chmod(dir, 0o777);
const onWarning = vi.fn();
const policy = resolveExecSafeBinRuntimePolicy({
global: {
safeBinTrustedDirs: [dir],
},
onWarning,
});
expect(policy.writableTrustedSafeBinDirs).toEqual([
{
dir: path.resolve(dir),
groupWritable: true,
worldWritable: true,
},
]);
expect(onWarning).toHaveBeenCalledWith(expect.stringContaining(path.resolve(dir)));
expect(onWarning).toHaveBeenCalledWith(expect.stringContaining("world-writable"));
} finally {
await fs.chmod(dir, 0o755).catch(() => undefined);
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
});

View File

@@ -6,7 +6,12 @@ import {
type SafeBinProfileFixture,
type SafeBinProfileFixtures,
} from "./exec-safe-bin-policy.js";
import { getTrustedSafeBinDirs, normalizeTrustedSafeBinDirs } from "./exec-safe-bin-trust.js";
import {
getTrustedSafeBinDirs,
listWritableExplicitTrustedSafeBinDirs,
normalizeTrustedSafeBinDirs,
type WritableTrustedSafeBinDir,
} from "./exec-safe-bin-trust.js";
export type ExecSafeBinConfigScope = {
safeBins?: string[] | null;
@@ -99,12 +104,14 @@ export function resolveMergedSafeBinProfileFixtures(params: {
export function resolveExecSafeBinRuntimePolicy(params: {
global?: ExecSafeBinConfigScope | null;
local?: ExecSafeBinConfigScope | null;
onWarning?: (message: string) => void;
}): {
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
trustedSafeBinDirs: ReadonlySet<string>;
unprofiledSafeBins: string[];
unprofiledInterpreterSafeBins: string[];
writableTrustedSafeBinDirs: ReadonlyArray<WritableTrustedSafeBinDir>;
} {
const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins);
const safeBinProfiles = resolveSafeBinProfiles(
@@ -116,17 +123,35 @@ export function resolveExecSafeBinRuntimePolicy(params: {
const unprofiledSafeBins = Array.from(safeBins)
.filter((entry) => !safeBinProfiles[entry])
.toSorted();
const explicitTrustedSafeBinDirs = [
...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs),
...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs),
];
const trustedSafeBinDirs = getTrustedSafeBinDirs({
extraDirs: [
...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs),
...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs),
],
extraDirs: explicitTrustedSafeBinDirs,
});
const writableTrustedSafeBinDirs = listWritableExplicitTrustedSafeBinDirs(
explicitTrustedSafeBinDirs,
);
if (params.onWarning) {
for (const hit of writableTrustedSafeBinDirs) {
const scope =
hit.worldWritable || hit.groupWritable
? hit.worldWritable
? "world-writable"
: "group-writable"
: "writable";
params.onWarning(
`exec: safeBinTrustedDirs includes ${scope} directory '${hit.dir}'; remove trust or tighten permissions (for example chmod 755).`,
);
}
}
return {
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
unprofiledSafeBins,
unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins),
writableTrustedSafeBinDirs,
};
}

View File

@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
@@ -5,6 +7,7 @@ import {
buildTrustedSafeBinDirs,
getTrustedSafeBinDirs,
isTrustedSafeBinPath,
listWritableExplicitTrustedSafeBinDirs,
} from "./exec-safe-bin-trust.js";
describe("exec safe bin trust", () => {
@@ -69,4 +72,25 @@ describe("exec safe bin trust", () => {
expect(refreshed.has(path.resolve(injected))).toBe(false);
});
});
it("flags explicitly trusted dirs that are group/world writable", async () => {
if (process.platform === "win32") {
return;
}
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-safe-bin-trust-"));
try {
await fs.chmod(dir, 0o777);
const hits = listWritableExplicitTrustedSafeBinDirs([dir]);
expect(hits).toEqual([
{
dir: path.resolve(dir),
groupWritable: true,
worldWritable: true,
},
]);
} finally {
await fs.chmod(dir, 0o755).catch(() => undefined);
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
});

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
// Keep defaults to OS-managed immutable bins only.
@@ -19,6 +20,12 @@ type TrustedSafeBinCache = {
dirs: Set<string>;
};
export type WritableTrustedSafeBinDir = {
dir: string;
groupWritable: boolean;
worldWritable: boolean;
};
let trustedSafeBinCache: TrustedSafeBinCache | null = null;
function normalizeTrustedDir(value: string): string | null {
@@ -88,3 +95,32 @@ export function isTrustedSafeBinPath(params: TrustedSafeBinPathParams): boolean
const resolvedDir = path.dirname(path.resolve(params.resolvedPath));
return trustedDirs.has(resolvedDir);
}
export function listWritableExplicitTrustedSafeBinDirs(
entries?: readonly string[] | null,
): WritableTrustedSafeBinDir[] {
if (process.platform === "win32") {
return [];
}
const resolved = resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries));
const hits: WritableTrustedSafeBinDir[] = [];
for (const dir of resolved) {
let stat: fs.Stats;
try {
stat = fs.statSync(dir);
} catch {
continue;
}
if (!stat.isDirectory()) {
continue;
}
const mode = stat.mode & 0o777;
const groupWritable = (mode & 0o020) !== 0;
const worldWritable = (mode & 0o002) !== 0;
if (!groupWritable && !worldWritable) {
continue;
}
hits.push({ dir, groupWritable, worldWritable });
}
return hits;
}