Skip to content

Commit ed21f35

Browse files
committed
fix(exec-approvals): allow darwin state-dir aliases
1 parent 6bd5213 commit ed21f35

2 files changed

Lines changed: 70 additions & 10 deletions

File tree

src/infra/exec-approvals-store.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,42 @@ describe("exec approvals store helpers", () => {
260260
expect(fs.existsSync(path.join(realStateRoot, "state"))).toBe(false);
261261
});
262262

263+
it("allows darwin system alias prefixes for OPENCLAW_STATE_DIR without masking user symlinks", () => {
264+
createHomeDir();
265+
const stateDir = createStateDir();
266+
const originalPlatform = process.platform;
267+
const realpathSync = fs.realpathSync.bind(fs);
268+
const lstatSync = fs.lstatSync.bind(fs);
269+
const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((targetPath: fs.PathLike) => {
270+
const resolved = path.resolve(String(targetPath));
271+
if (resolved === "/tmp") {
272+
return {
273+
isSymbolicLink: () => true,
274+
} as unknown as fs.Stats;
275+
}
276+
return lstatSync(targetPath);
277+
});
278+
const realpathSpy = vi.spyOn(fs, "realpathSync").mockImplementation((targetPath: fs.PathLike) => {
279+
const resolved = path.resolve(String(targetPath));
280+
if (resolved === path.resolve(stateDir)) {
281+
return `/private${resolved}`;
282+
}
283+
return realpathSync(targetPath);
284+
});
285+
286+
Object.defineProperty(process, "platform", { value: "darwin" });
287+
try {
288+
expect(() =>
289+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),
290+
).not.toThrow();
291+
expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(true);
292+
} finally {
293+
realpathSpy.mockRestore();
294+
lstatSpy.mockRestore();
295+
Object.defineProperty(process, "platform", { value: originalPlatform });
296+
}
297+
});
298+
263299
it("adds trimmed allowlist entries once and persists generated ids", () => {
264300
const dir = createHomeDir();
265301
vi.spyOn(Date, "now").mockReturnValue(123_456);

src/infra/exec-approvals.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -239,23 +239,47 @@ function ensureDir(filePath: string) {
239239

240240
function assertNoSymlinkPathComponents(targetPath: string): void {
241241
const resolvedTarget = path.resolve(targetPath);
242-
const parsedTarget = path.parse(resolvedTarget);
243-
const relative = path.relative(parsedTarget.root, resolvedTarget);
244-
const segments = relative && relative !== "." ? relative.split(path.sep).filter(Boolean) : [];
245-
let current = parsedTarget.root;
246-
for (const segment of segments) {
247-
current = path.join(current, segment);
242+
const existingAncestor = findNearestExistingAncestor(resolvedTarget);
243+
const relative = path.relative(existingAncestor, resolvedTarget);
244+
const realAncestor = fs.realpathSync(existingAncestor);
245+
const realTarget = path.resolve(realAncestor, relative);
246+
if (
247+
normalizeSymlinkComparisonPath(resolvedTarget) !== normalizeSymlinkComparisonPath(realTarget)
248+
) {
249+
throw new Error(`Refusing to traverse symlink in exec approvals path: ${existingAncestor}`);
250+
}
251+
}
252+
253+
function findNearestExistingAncestor(targetPath: string): string {
254+
let current = targetPath;
255+
while (true) {
248256
try {
249-
const stat = fs.lstatSync(current);
250-
if (stat.isSymbolicLink()) {
251-
throw new Error(`Refusing to traverse symlink in exec approvals path: ${current}`);
252-
}
257+
fs.lstatSync(current);
258+
return current;
253259
} catch (err) {
254260
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
255261
throw err;
256262
}
257263
}
264+
const parent = path.dirname(current);
265+
if (parent === current) {
266+
return current;
267+
}
268+
current = parent;
269+
}
270+
}
271+
272+
function normalizeSymlinkComparisonPath(targetPath: string): string {
273+
const resolvedTarget = path.resolve(targetPath);
274+
if (process.platform !== "darwin") {
275+
return resolvedTarget;
276+
}
277+
for (const prefix of ["/private/tmp", "/private/var", "/private/etc"]) {
278+
if (resolvedTarget === prefix || resolvedTarget.startsWith(`${prefix}${path.sep}`)) {
279+
return resolvedTarget.slice("/private".length);
280+
}
258281
}
282+
return resolvedTarget;
259283
}
260284

261285
function assertSafeExecApprovalsDestination(filePath: string): void {

0 commit comments

Comments
 (0)