From 364d49889e675f720f908fd00cd5fb917e78ae13 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 14:57:01 -0700 Subject: [PATCH] fix: allow trusted exec approvals home symlinks (#72377) --- CHANGELOG.md | 1 + src/infra/exec-approvals-store.test.ts | 25 +++++++++++++++++++++---- src/infra/exec-approvals.ts | 6 ++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d9f658efa..2684c042d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim. - ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924. - Logging: propagate internal request trace scopes through Gateway HTTP requests and WebSocket frames so file logs, diagnostic events, agent run traces, model-call traces, OTEL spans, and trusted provider `traceparent` headers share a correlatable `traceId` without logging raw request or model content. Fixes #40353. Thanks @liangruochong44-ui. - Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830. diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 3e2881ac6b1..1dac65a2bc2 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -187,17 +187,34 @@ describe("exec approvals store helpers", () => { expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n'); }); - it("refuses to traverse a symlinked parent component in the approvals path", () => { + it("accepts a symlinked OPENCLAW_HOME as the trusted approvals root", () => { const realHome = makeTempDir(); const linkedHome = `${realHome}-link`; - tempDirs.push(realHome); - fs.symlinkSync(realHome, linkedHome); + tempDirs.push(realHome, linkedHome); + fs.symlinkSync(realHome, linkedHome, "dir"); + process.env.OPENCLAW_HOME = linkedHome; + + saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); + + expect( + fs.readFileSync(path.join(realHome, ".openclaw", "exec-approvals.json"), "utf8"), + ).toContain('"security": "full"'); + }); + + it("refuses to traverse symlinked approvals components below a symlinked home", () => { + const realHome = makeTempDir(); + const linkedHome = `${realHome}-link`; + const linkedStateTarget = path.join(realHome, "state-target"); + tempDirs.push(realHome, linkedHome); + fs.mkdirSync(linkedStateTarget, { recursive: true }); + fs.symlinkSync(realHome, linkedHome, "dir"); + fs.symlinkSync(linkedStateTarget, path.join(realHome, ".openclaw"), "dir"); process.env.OPENCLAW_HOME = linkedHome; expect(() => saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), ).toThrow(/Refusing to traverse symlink in exec approvals path/); - expect(fs.existsSync(path.join(realHome, ".openclaw"))).toBe(false); + expect(fs.existsSync(path.join(linkedStateTarget, "exec-approvals.json"))).toBe(false); }); it("adds trimmed allowlist entries once and persists generated ids", () => { diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 0eff54f557b..30fc5a8e78d 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -248,10 +248,8 @@ function assertNoSymlinkPathComponents(targetPath: string, trustedRoot: string): const relative = path.relative(resolvedRoot, resolvedTarget); const segments = relative && relative !== "." ? relative.split(path.sep) : []; let current = resolvedRoot; - for (const segment of [".", ...segments]) { - if (segment !== ".") { - current = path.join(current, segment); - } + for (const segment of segments) { + current = path.join(current, segment); try { const stat = fs.lstatSync(current); if (stat.isSymbolicLink()) {