Skip to content

Commit 2d10a2c

Browse files
authored
fix(doctor): case-insensitive safe-bin trusted dir matching on macOS/Windows
Summary: - Compare trusted safe-bin directories with path-local case folding so Windows and default macOS paths match without weakening case-sensitive mounts. - Keep the focused safe-bin regression coverage and current Unreleased changelog entry. Verification: - pnpm vitest run src/infra/exec-safe-bin-trust.test.ts src/auto-reply/reply/model-selection.test.ts - pnpm check:test-types - git diff --check - GitHub CI passed, including Real behavior proof, auto-response, ClawSweeper dispatch, CodeQL, Critical Quality, and full CI checks. Co-authored-by: Harman Kochar <[email protected]>
1 parent bc14aa7 commit 2d10a2c

3 files changed

Lines changed: 112 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
118118
- Discord/voice: keep default agent-proxy realtime sessions from auto-speaking filler before the forced OpenClaw consult answer, finish Discord playback on realtime response completion, and queue later exact-speech answers until playback idles to avoid mid-sentence replacement.
119119
- Gateway: return deterministic `400 invalid_request_error` responses for malformed encoded session-kill HTTP paths instead of letting route-shaped requests fall through to later Gateway handlers. (#72439) Thanks @rubencu.
120120
- Control UI: serve root PWA and favicon assets from `/__openclaw__/` SPA routes so tab icons, install metadata, and the service worker do not 404 after internal navigation. Fixes #80072. Thanks @CodeNovice2017.
121+
- Exec/safe bins: compare trusted safe-bin dirs with path-specific case folding on case-insensitive filesystems so Windows and default macOS paths match without weakening case-sensitive mounts. (#42131) Thanks @hkochar.
121122
- OpenAI/realtime voice: honor disabled input-audio interruption locally so server VAD speech-start events do not clear Discord playback after operators set `interruptResponseOnInputAudio: false`.
122123
- Telegram: keep no-response DM turns quiet instead of rewriting them into visible silent-reply chatter. Fixes #78188. (#78228) Thanks @Beandon13.
123124
- Telegram: handle managed select button callbacks before the raw callback fallback while preserving delimiter-containing option values such as `env|prod`. (#79816) Thanks @moeedahmed.

src/infra/exec-safe-bin-trust.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import {
1010
listWritableExplicitTrustedSafeBinDirs,
1111
} from "./exec-safe-bin-trust.js";
1212

13+
function swapAsciiCase(value: string): string {
14+
return value.replace(/[A-Za-z]/g, (char) => {
15+
const lower = char.toLowerCase();
16+
return char === lower ? char.toUpperCase() : lower;
17+
});
18+
}
19+
1320
describe("exec safe bin trust", () => {
1421
it("keeps default trusted dirs limited to immutable system paths", () => {
1522
const dirs = getTrustedSafeBinDirs({ refresh: true });
@@ -64,6 +71,62 @@ describe("exec safe bin trust", () => {
6471
).toBe(false);
6572
});
6673

74+
it("matches trusted dirs through path-local case folding on case-insensitive filesystems", async () => {
75+
await withTempDir({ prefix: "OpenClaw-Safe-Bin-" }, async (dir) => {
76+
const swapped = swapAsciiCase(dir);
77+
if (swapped === dir) {
78+
return;
79+
}
80+
const originalStat = await fs.stat(dir);
81+
let swappedStat: Awaited<ReturnType<typeof fs.stat>>;
82+
try {
83+
swappedStat = await fs.stat(swapped);
84+
} catch {
85+
return;
86+
}
87+
if (originalStat.dev !== swappedStat.dev || originalStat.ino !== swappedStat.ino) {
88+
return;
89+
}
90+
91+
const dirs = buildTrustedSafeBinDirs({
92+
baseDirs: [],
93+
extraDirs: [swapped],
94+
});
95+
96+
expect(
97+
isTrustedSafeBinPath({
98+
resolvedPath: path.join(dir, "jq"),
99+
trustedDirs: dirs,
100+
}),
101+
).toBe(true);
102+
});
103+
});
104+
105+
it("keeps case-distinct trusted dirs separate on case-sensitive filesystems", async () => {
106+
await withTempDir({ prefix: "openclaw-safe-bin-case-" }, async (parent) => {
107+
const trustedDir = path.join(parent, "ToolBin");
108+
const untrustedDir = path.join(parent, "toolbin");
109+
await fs.mkdir(trustedDir);
110+
try {
111+
await fs.mkdir(untrustedDir);
112+
} catch {
113+
return;
114+
}
115+
116+
const dirs = buildTrustedSafeBinDirs({
117+
baseDirs: [],
118+
extraDirs: [trustedDir],
119+
});
120+
121+
expect(
122+
isTrustedSafeBinPath({
123+
resolvedPath: path.join(untrustedDir, "jq"),
124+
trustedDirs: dirs,
125+
}),
126+
).toBe(false);
127+
});
128+
});
129+
67130
it("does not trust PATH entries by default", () => {
68131
const injected = `/tmp/openclaw-path-injected-${Date.now()}`;
69132

src/infra/exec-safe-bin-trust.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,54 @@ export type WritableTrustedSafeBinDir = {
2828

2929
let trustedSafeBinCache: TrustedSafeBinCache | null = null;
3030

31-
function normalizeTrustedDir(value: string): string | null {
31+
function swapAsciiCase(value: string): string {
32+
return value.replace(/[A-Za-z]/g, (char) => {
33+
const lower = char.toLowerCase();
34+
return char === lower ? char.toUpperCase() : lower;
35+
});
36+
}
37+
38+
function sameFsObject(a: fs.Stats, b: fs.Stats): boolean {
39+
return a.dev === b.dev && a.ino === b.ino;
40+
}
41+
42+
function pathCaseInsensitive(value: string): boolean {
43+
let candidate = value;
44+
for (;;) {
45+
const swapped = swapAsciiCase(candidate);
46+
if (swapped !== candidate) {
47+
try {
48+
const original = fs.statSync(candidate);
49+
try {
50+
const alternate = fs.statSync(swapped);
51+
return sameFsObject(original, alternate);
52+
} catch {
53+
return false;
54+
}
55+
} catch {
56+
// The compared path may not exist yet; probe the closest existing parent.
57+
}
58+
}
59+
60+
const parent = path.dirname(candidate);
61+
if (parent === candidate) {
62+
return process.platform === "win32";
63+
}
64+
candidate = parent;
65+
}
66+
}
67+
68+
function normalizeTrustComparisonPath(value: string): string {
69+
const resolved = path.resolve(value);
70+
return pathCaseInsensitive(resolved) ? resolved.toLowerCase() : resolved;
71+
}
72+
73+
function normalizeTrustedDir(value: string, forComparison = true): string | null {
3274
const trimmed = value.trim();
3375
if (!trimmed) {
3476
return null;
3577
}
36-
return path.resolve(trimmed);
78+
return forComparison ? normalizeTrustComparisonPath(trimmed) : path.resolve(trimmed);
3779
}
3880

3981
export function normalizeTrustedSafeBinDirs(entries?: readonly string[] | null): string[] {
@@ -44,9 +86,9 @@ export function normalizeTrustedSafeBinDirs(entries?: readonly string[] | null):
4486
return Array.from(new Set(normalized));
4587
}
4688

47-
function resolveTrustedSafeBinDirs(entries: readonly string[]): string[] {
89+
function resolveTrustedSafeBinDirs(entries: readonly string[], forComparison = true): string[] {
4890
const resolved = entries
49-
.map((entry) => normalizeTrustedDir(entry))
91+
.map((entry) => normalizeTrustedDir(entry, forComparison))
5092
.filter((entry): entry is string => Boolean(entry));
5193
return Array.from(new Set(resolved)).toSorted();
5294
}
@@ -92,7 +134,7 @@ export function getTrustedSafeBinDirs(
92134

93135
export function isTrustedSafeBinPath(params: TrustedSafeBinPathParams): boolean {
94136
const trustedDirs = params.trustedDirs ?? getTrustedSafeBinDirs();
95-
const resolvedDir = path.dirname(path.resolve(params.resolvedPath));
137+
const resolvedDir = normalizeTrustComparisonPath(path.dirname(path.resolve(params.resolvedPath)));
96138
return trustedDirs.has(resolvedDir);
97139
}
98140

@@ -102,7 +144,7 @@ export function listWritableExplicitTrustedSafeBinDirs(
102144
if (process.platform === "win32") {
103145
return [];
104146
}
105-
const resolved = resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries));
147+
const resolved = resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries), false);
106148
const hits: WritableTrustedSafeBinDir[] = [];
107149
for (const dir of resolved) {
108150
let stat: fs.Stats;

0 commit comments

Comments
 (0)