From b340eae81255c69c44e92e61c4847b4f565471f3 Mon Sep 17 00:00:00 2001 From: Paperclip CTO Date: Wed, 12 Aug 2026 14:15:18 +0000 Subject: [PATCH 1/3] fix(heartbeat): resolve repo-relative project workspace cwd inside its checkout (BLO-25415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A git_repo project workspace may declare a repo-relative cwd such as "packages/iwa". The workspace resolver consumed workspace.cwd raw and passed it straight to fs.stat(), which resolves a relative path against the API process's own working directory. That never matches, so the run failed `preferred_workspace_unrealizable` quoting a bare relative path that looks correct — while the real checkout sat on disk untouched. The managed-checkout branch was only taken when cwd was empty or the repo-only sentinel, so a workspace with a relative cwd never got its repo cloned at all. No amount of provisioning could satisfy it, and because the refusal names a path rather than a cause, the failure reads as a missing checkout. BLO-24751 hand-cloned the repo on that reading and closed done; the next run failed byte-identically 71 seconds later. Treat a non-absolute cwd as a subdirectory of the workspace's own repo: realize the managed checkout first, then join the subpath inside it. resolveContainedWorkspaceSubpath refuses traversal that escapes the checkout root, since cwd is operator-supplied and the checkouts share a PVC. Fleet-wide this shape is 2 workspace rows, both pim-multicast-gateway / packages/iwa; between them they had 6+ non-terminal issues stranded. Co-Authored-By: Claude --- .../heartbeat-workspace-session.test.ts | 43 +++++++++++++++++++ server/src/services/heartbeat.ts | 34 ++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 6524a27ee2a2..96e13bf505d3 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -59,6 +59,7 @@ import { normalizeSessionParams, shouldResetTaskSessionForWake, mergeModelProfileAdapterConfig, + resolveContainedWorkspaceSubpath, type ResolvedWorkspaceForRunSuccess, } from "../services/heartbeat.js"; import { applyRunScopeToBranchName } from "../services/workspace-runtime.js"; @@ -3938,6 +3939,48 @@ describe("isNonPrimaryWorkspaceTarget", () => { }); }); +describe("resolveContainedWorkspaceSubpath", () => { + // BLO-25415: a git_repo workspace may declare a repo-relative cwd such as + // "packages/iwa". Before the fix that raw string reached fs.stat() and was + // resolved against the API process's cwd, so the run failed + // `preferred_workspace_unrealizable` naming a path that looked correct while + // the real checkout sat on disk untouched. + it("joins a repo-relative subpath onto the realized checkout", () => { + expect(resolveContainedWorkspaceSubpath("/managed/pim-multicast-gateway", "packages/iwa")).toBe( + "/managed/pim-multicast-gateway/packages/iwa", + ); + }); + + it("normalizes redundant segments that stay inside the checkout", () => { + expect(resolveContainedWorkspaceSubpath("/managed/repo", "./packages/../packages/iwa")).toBe( + "/managed/repo/packages/iwa", + ); + }); + + it("allows the checkout root itself", () => { + expect(resolveContainedWorkspaceSubpath("/managed/repo", ".")).toBe("/managed/repo"); + }); + + it("refuses a subpath that escapes the checkout", () => { + // cwd is operator-supplied config, so traversal must not point a run at an + // unrelated repo on the shared PVC. + expect(() => resolveContainedWorkspaceSubpath("/managed/repo", "../other-repo")).toThrow( + /resolves outside its checkout/, + ); + expect(() => resolveContainedWorkspaceSubpath("/managed/repo", "../../../etc")).toThrow( + /resolves outside its checkout/, + ); + }); + + it("refuses a sibling directory sharing the checkout name prefix", () => { + // Guards the startsWith() containment check against "/managed/repo-evil" + // being treated as inside "/managed/repo". + expect(() => resolveContainedWorkspaceSubpath("/managed/repo", "../repo-evil")).toThrow( + /resolves outside its checkout/, + ); + }); +}); + describe("evaluatePreferredProjectWorkspaceRealization", () => { it("fails loud when an unrealized non-primary workspace is explicitly targeted", () => { // Mirrors BLO-8154: issue targets the trafficcontrol workspace but only the diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 7d51fb2cc8bc..ab30f75d1581 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2304,6 +2304,21 @@ function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { } } +// Join a repo-relative workspace subpath onto its realized checkout, refusing +// anything that escapes the checkout root. `cwd` is operator-supplied config, +// so a value like "../../../etc" must not be able to point a run outside the +// repo it declared. See BLO-25415. +export function resolveContainedWorkspaceSubpath(checkoutDir: string, subpath: string): string { + const root = path.resolve(checkoutDir); + const resolved = path.resolve(root, subpath); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error( + `Project workspace cwd "${subpath}" resolves outside its checkout "${root}".`, + ); + } + return resolved; +} + async function ensureManagedProjectWorkspace(input: { companyId: string; projectId: string; @@ -11418,14 +11433,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) for (const workspace of realizationCandidates) { let projectCwd = readNonEmptyString(workspace.cwd); let managedWorkspaceWarning: string | null = null; - if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) { + // A workspace cwd is either an absolute host path or — for a git_repo + // workspace — a repo-relative subdirectory such as "packages/iwa". The + // relative form only resolves once the repo is checked out, so realize + // the managed checkout first and join the subpath inside it. Without + // this the raw relative string reached the fs.stat() below and was + // resolved against the API process's own cwd, which never matches: the + // run then failed `preferred_workspace_unrealizable` quoting a path + // that looks correct, and no amount of cloning could satisfy it. See + // BLO-25415. + const repoRelativeCwd = + projectCwd && projectCwd !== REPO_ONLY_CWD_SENTINEL && !path.isAbsolute(projectCwd) + ? projectCwd + : null; + if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL || repoRelativeCwd) { try { const managedWorkspace = await ensureManagedProjectWorkspace({ companyId: agent.companyId, projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId, repoUrl: readNonEmptyString(workspace.repoUrl), }); - projectCwd = managedWorkspace.cwd; + projectCwd = repoRelativeCwd + ? resolveContainedWorkspaceSubpath(managedWorkspace.cwd, repoRelativeCwd) + : managedWorkspace.cwd; managedWorkspaceWarning = managedWorkspace.warning; } catch (error) { if (preferredWorkspace?.id === workspace.id) { From 4149920f0d61b23e713f4ff355ad328dca30dcfc Mon Sep 17 00:00:00 2001 From: Paperclip CTO Date: Wed, 12 Aug 2026 14:15:18 +0000 Subject: [PATCH 2/3] fix(heartbeat): only treat a relative workspace cwd as repo-relative when repo-backed (BLO-25415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #1302. The relative-cwd branch was selected on !path.isAbsolute(cwd) alone, so a local_path / non_git_path / remote_managed workspace with a relative cwd would have been redirected into a managed checkout dir instead of keeping its prior meaning. The redirect is not merely semantic: ensureManagedProjectWorkspace's repoUrl-less branch mkdirs the managed path, so such a workspace would have had an empty directory created on the shared PVC and still failed the subsequent stat. Gate the branch on the workspace being repo-backed, extracted into resolveRepoRelativeWorkspaceCwd so the decision is unit-testable rather than buried in the resolver loop. Repo-backed is tested as "not an explicitly non-repo source_type, and has a repoUrl" rather than source_type === "git_repo": source_type is an unconstrained text column and production carries a row typed "git", which an allowlist would silently skip. Requiring repoUrl also settles the repoUrl-null case raised in the review request — that workspace now keeps its cwd and fails loud without a stray directory. Audited fleet-wide: 1 workspace row still has a relative cwd (4dac485f, [P0] IWA Gateway Certificate Provisioning), and it is git_repo with a repoUrl, so it remains covered by the fix. heartbeat-workspace-session 211 passed (6 new), fail-loud 1 passed, tsc --noEmit exit 0. Co-Authored-By: Claude --- .../heartbeat-workspace-session.test.ts | 53 +++++++++++++++++++ server/src/services/heartbeat.ts | 37 +++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 96e13bf505d3..8c231d807f91 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -60,6 +60,7 @@ import { shouldResetTaskSessionForWake, mergeModelProfileAdapterConfig, resolveContainedWorkspaceSubpath, + resolveRepoRelativeWorkspaceCwd, type ResolvedWorkspaceForRunSuccess, } from "../services/heartbeat.js"; import { applyRunScopeToBranchName } from "../services/workspace-runtime.js"; @@ -3981,6 +3982,58 @@ describe("resolveContainedWorkspaceSubpath", () => { }); }); +describe("resolveRepoRelativeWorkspaceCwd", () => { + // BLO-25415: only a repo-backed workspace has a checkout for a relative cwd + // to be relative to. Redirecting other source types into a managed checkout + // dir would change their meaning — and, via ensureManagedProjectWorkspace's + // repoUrl-less branch, mkdir an empty directory on the shared PVC that the + // subsequent stat can never satisfy. + const repoUrl = "https://github.com/Blockcast/pim-multicast-gateway.git"; + + it("returns the subpath for a repo-backed workspace with a relative cwd", () => { + expect( + resolveRepoRelativeWorkspaceCwd({ cwd: "packages/iwa", sourceType: "git_repo", repoUrl }), + ).toBe("packages/iwa"); + }); + + it("accepts a repo-backed workspace whose source_type is not the canonical spelling", () => { + // source_type is an unconstrained text column; production carries a "git" + // row. An allowlist keyed on "git_repo" would silently skip it. + expect(resolveRepoRelativeWorkspaceCwd({ cwd: "packages/iwa", sourceType: "git", repoUrl })).toBe( + "packages/iwa", + ); + }); + + it("leaves an absolute cwd untouched", () => { + expect( + resolveRepoRelativeWorkspaceCwd({ cwd: "/managed/repo/packages/iwa", sourceType: "git_repo", repoUrl }), + ).toBeNull(); + }); + + it("leaves an empty cwd and the repo-only sentinel untouched", () => { + expect(resolveRepoRelativeWorkspaceCwd({ cwd: null, sourceType: "git_repo", repoUrl })).toBeNull(); + expect( + resolveRepoRelativeWorkspaceCwd({ cwd: "/__paperclip_repo_only__", sourceType: "git_repo", repoUrl }), + ).toBeNull(); + }); + + it("leaves a relative local_path / non_git_path / remote_managed cwd untouched", () => { + // These own their cwd outright — they must keep the prior semantics rather + // than being redirected into a managed checkout. + for (const sourceType of ["local_path", "non_git_path", "remote_managed"]) { + expect(resolveRepoRelativeWorkspaceCwd({ cwd: "packages/iwa", sourceType, repoUrl })).toBeNull(); + } + }); + + it("leaves a relative cwd untouched when the workspace has no repo", () => { + // Nothing for the path to be relative to; taking the managed-checkout + // branch would create an empty directory and still fail the stat. + expect( + resolveRepoRelativeWorkspaceCwd({ cwd: "packages/iwa", sourceType: "git_repo", repoUrl: null }), + ).toBeNull(); + }); +}); + describe("evaluatePreferredProjectWorkspaceRealization", () => { it("fails loud when an unrealized non-primary workspace is explicitly targeted", () => { // Mirrors BLO-8154: issue targets the trafficcontrol workspace but only the diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ab30f75d1581..bce04597cbe3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2319,6 +2319,36 @@ export function resolveContainedWorkspaceSubpath(checkoutDir: string, subpath: s return resolved; } +// Workspace source types that own their `cwd` outright: the path is a host +// location (or a remote provider's), not a subdirectory of a checkout we +// manage. A relative `cwd` on one of these keeps its prior meaning rather than +// being redirected into a managed checkout dir. +const NON_REPO_WORKSPACE_SOURCE_TYPES = new Set(["local_path", "non_git_path", "remote_managed"]); + +/** + * Decide whether a workspace's `cwd` is a repo-relative subdirectory that must + * be joined onto its managed checkout, returning that subpath (or null to use + * `cwd` as-is). + * + * Only repo-backed workspaces qualify. `repoUrl` is required rather than + * inferred from `sourceType` because `source_type` is an unconstrained text + * column — production carries at least one row typed `"git"` rather than + * `"git_repo"` — and because without a repo there is nothing for the path to + * be relative to: `ensureManagedProjectWorkspace` would mkdir an empty + * directory that the subsequent stat can never satisfy. See BLO-25415. + */ +export function resolveRepoRelativeWorkspaceCwd(workspace: { + cwd?: string | null; + sourceType?: string | null; + repoUrl?: string | null; +}): string | null { + const cwd = readNonEmptyString(workspace.cwd); + if (!cwd || cwd === REPO_ONLY_CWD_SENTINEL || path.isAbsolute(cwd)) return null; + if (NON_REPO_WORKSPACE_SOURCE_TYPES.has(readNonEmptyString(workspace.sourceType) ?? "")) return null; + if (!readNonEmptyString(workspace.repoUrl)) return null; + return cwd; +} + async function ensureManagedProjectWorkspace(input: { companyId: string; projectId: string; @@ -11433,7 +11463,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) for (const workspace of realizationCandidates) { let projectCwd = readNonEmptyString(workspace.cwd); let managedWorkspaceWarning: string | null = null; - // A workspace cwd is either an absolute host path or — for a git_repo + // A workspace cwd is either an absolute host path or — for a repo-backed // workspace — a repo-relative subdirectory such as "packages/iwa". The // relative form only resolves once the repo is checked out, so realize // the managed checkout first and join the subpath inside it. Without @@ -11442,10 +11472,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) // run then failed `preferred_workspace_unrealizable` quoting a path // that looks correct, and no amount of cloning could satisfy it. See // BLO-25415. - const repoRelativeCwd = - projectCwd && projectCwd !== REPO_ONLY_CWD_SENTINEL && !path.isAbsolute(projectCwd) - ? projectCwd - : null; + const repoRelativeCwd = resolveRepoRelativeWorkspaceCwd(workspace); if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL || repoRelativeCwd) { try { const managedWorkspace = await ensureManagedProjectWorkspace({ From b66c164cde45600c7a5d6a16ed97786fd95ac7ff Mon Sep 17 00:00:00 2001 From: Paperclip CTO Date: Wed, 12 Aug 2026 14:15:18 +0000 Subject: [PATCH 3/3] fix(heartbeat): enforce workspace-subpath containment after symlink resolution (BLO-25415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #1302. resolveContainedWorkspaceSubpath checked containment lexically only. `cwd` is operator config, but the checkout's *contents* are repo-controlled: a repo carrying `packages/iwa -> /etc` passes path.resolve(), and the caller's fs.stat() then follows the link, launching the run outside its checkout on a PVC shared with every other repo. Resolve both the checkout root and the target with fs.realpath and re-assert containment on the canonical paths. The realpath pass is skipped when either does not resolve — a missing target has no link to follow, and reporting it missing stays the caller's fs.stat()'s job, so the existing fail-loud path is unchanged. The root is resolved independently because the managed dir may itself sit behind a symlink, which would otherwise fail containment for a legitimate subpath. The escape test was verified to fail against the lexical-only implementation before being committed, so it pins the behavior rather than passing incidentally. heartbeat-workspace-session 214 passed (3 new: symlink escape refused, in-checkout symlink allowed, symlinked checkout root allowed), heartbeat-preferred-workspace-fail-loud 1 passed, tsc --noEmit exit 0. Co-Authored-By: Claude --- .../heartbeat-workspace-session.test.ts | 86 +++++++++++++++---- server/src/services/heartbeat.ts | 34 +++++++- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 8c231d807f91..dee8f76fc24f 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -3946,40 +3946,96 @@ describe("resolveContainedWorkspaceSubpath", () => { // resolved against the API process's cwd, so the run failed // `preferred_workspace_unrealizable` naming a path that looked correct while // the real checkout sat on disk untouched. - it("joins a repo-relative subpath onto the realized checkout", () => { - expect(resolveContainedWorkspaceSubpath("/managed/pim-multicast-gateway", "packages/iwa")).toBe( - "/managed/pim-multicast-gateway/packages/iwa", - ); + it("joins a repo-relative subpath onto the realized checkout", async () => { + await expect( + resolveContainedWorkspaceSubpath("/managed/pim-multicast-gateway", "packages/iwa"), + ).resolves.toBe("/managed/pim-multicast-gateway/packages/iwa"); }); - it("normalizes redundant segments that stay inside the checkout", () => { - expect(resolveContainedWorkspaceSubpath("/managed/repo", "./packages/../packages/iwa")).toBe( - "/managed/repo/packages/iwa", - ); + it("normalizes redundant segments that stay inside the checkout", async () => { + await expect( + resolveContainedWorkspaceSubpath("/managed/repo", "./packages/../packages/iwa"), + ).resolves.toBe("/managed/repo/packages/iwa"); }); - it("allows the checkout root itself", () => { - expect(resolveContainedWorkspaceSubpath("/managed/repo", ".")).toBe("/managed/repo"); + it("allows the checkout root itself", async () => { + await expect(resolveContainedWorkspaceSubpath("/managed/repo", ".")).resolves.toBe("/managed/repo"); }); - it("refuses a subpath that escapes the checkout", () => { + it("refuses a subpath that escapes the checkout", async () => { // cwd is operator-supplied config, so traversal must not point a run at an // unrelated repo on the shared PVC. - expect(() => resolveContainedWorkspaceSubpath("/managed/repo", "../other-repo")).toThrow( + await expect(resolveContainedWorkspaceSubpath("/managed/repo", "../other-repo")).rejects.toThrow( /resolves outside its checkout/, ); - expect(() => resolveContainedWorkspaceSubpath("/managed/repo", "../../../etc")).toThrow( + await expect(resolveContainedWorkspaceSubpath("/managed/repo", "../../../etc")).rejects.toThrow( /resolves outside its checkout/, ); }); - it("refuses a sibling directory sharing the checkout name prefix", () => { + it("refuses a sibling directory sharing the checkout name prefix", async () => { // Guards the startsWith() containment check against "/managed/repo-evil" // being treated as inside "/managed/repo". - expect(() => resolveContainedWorkspaceSubpath("/managed/repo", "../repo-evil")).toThrow( + await expect(resolveContainedWorkspaceSubpath("/managed/repo", "../repo-evil")).rejects.toThrow( /resolves outside its checkout/, ); }); + + it("refuses a subpath that escapes via a symlink inside the checkout", async () => { + // The lexical check alone is not enough: the checkout's *contents* are + // repo-controlled, so a repo carrying `packages/iwa -> /etc` passes + // path.resolve() and then fs.stat() follows the link, launching the run + // outside the checkout on a PVC shared with every other repo. + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "blo25415-symlink-")); + try { + const checkout = path.join(tmp, "repo"); + const outside = path.join(tmp, "outside"); + await fs.mkdir(path.join(checkout, "packages"), { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.symlink(outside, path.join(checkout, "packages", "iwa")); + + await expect(resolveContainedWorkspaceSubpath(checkout, "packages/iwa")).rejects.toThrow( + /resolves outside its checkout/, + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("allows a symlink that stays inside the checkout", async () => { + // Only escapes are refused — an in-repo symlink is legitimate. + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "blo25415-symlink-ok-")); + try { + const checkout = path.join(tmp, "repo"); + await fs.mkdir(path.join(checkout, "packages", "real"), { recursive: true }); + await fs.symlink(path.join(checkout, "packages", "real"), path.join(checkout, "iwa")); + + await expect(resolveContainedWorkspaceSubpath(checkout, "iwa")).resolves.toBe( + path.join(checkout, "iwa"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("allows a subpath when the checkout root itself sits behind a symlink", async () => { + // The managed dir may be reached through a symlink (e.g. a PVC mount + // indirection). Resolving root and target independently keeps that from + // failing containment for a perfectly legitimate subpath. + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "blo25415-symlink-root-")); + try { + const realCheckout = path.join(tmp, "real-repo"); + const linkedCheckout = path.join(tmp, "linked-repo"); + await fs.mkdir(path.join(realCheckout, "packages", "iwa"), { recursive: true }); + await fs.symlink(realCheckout, linkedCheckout); + + await expect(resolveContainedWorkspaceSubpath(linkedCheckout, "packages/iwa")).resolves.toBe( + path.join(linkedCheckout, "packages", "iwa"), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); describe("resolveRepoRelativeWorkspaceCwd", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bce04597cbe3..233637526941 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2308,15 +2308,41 @@ function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { // anything that escapes the checkout root. `cwd` is operator-supplied config, // so a value like "../../../etc" must not be able to point a run outside the // repo it declared. See BLO-25415. -export function resolveContainedWorkspaceSubpath(checkoutDir: string, subpath: string): string { +// +// Containment is enforced twice, because the two escapes are different: +// - lexically, against `..` traversal and sibling-prefix paths; and +// - after symlink resolution, because the checkout's *contents* are +// repo-controlled. A repo carrying `packages/iwa -> /etc` passes the +// lexical check and then fs.stat() follows the link, launching the run +// outside the checkout on a PVC shared with every other repo. +// +// The realpath pass is skipped when the target does not exist: there is +// nothing to follow, and the caller's own fs.stat() is what reports it +// missing. Resolving the root separately matters too — the managed dir may +// itself sit behind a symlink, which would otherwise fail containment for a +// perfectly legitimate subpath. +export async function resolveContainedWorkspaceSubpath( + checkoutDir: string, + subpath: string, +): Promise { const root = path.resolve(checkoutDir); const resolved = path.resolve(root, subpath); - if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + assertPathContained(root, resolved, subpath); + + const realRoot = await fs.realpath(root).catch(() => null); + const realResolved = await fs.realpath(resolved).catch(() => null); + if (realRoot && realResolved) { + assertPathContained(realRoot, realResolved, subpath); + } + return resolved; +} + +function assertPathContained(root: string, candidate: string, subpath: string): void { + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) { throw new Error( `Project workspace cwd "${subpath}" resolves outside its checkout "${root}".`, ); } - return resolved; } // Workspace source types that own their `cwd` outright: the path is a host @@ -11481,7 +11507,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoUrl: readNonEmptyString(workspace.repoUrl), }); projectCwd = repoRelativeCwd - ? resolveContainedWorkspaceSubpath(managedWorkspace.cwd, repoRelativeCwd) + ? await resolveContainedWorkspaceSubpath(managedWorkspace.cwd, repoRelativeCwd) : managedWorkspace.cwd; managedWorkspaceWarning = managedWorkspace.warning; } catch (error) {