diff --git a/packages/cli/src/utils/relay.ts b/packages/cli/src/utils/relay.ts index 0c0b544..3aa8ce8 100644 --- a/packages/cli/src/utils/relay.ts +++ b/packages/cli/src/utils/relay.ts @@ -1,5 +1,5 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve, sep } from "node:path"; import { homedir } from "node:os"; import { randomUUID } from "node:crypto"; import { sanitizeIdentifier } from "../schema/sanitizer.js"; @@ -34,6 +34,18 @@ export function resolveAgentMailRoot(agentId: string): string { return resolveDeliveryPath(agentId); } +/** + * True if `p` resolves to within ~/.tps/branch-office (the only place a delivery + * path may point). Mirrors assertOfficeDir in wall.ts / internal-mail.ts so a + * team.json `workspaceMail` value can't redirect mail writes outside the office + * root via path traversal or an absolute path (ops-23). + */ +function isWithinBranchOffice(p: string): boolean { + const resolved = resolve(p); + const root = resolve(join(process.env.HOME || homedir(), ".tps", "branch-office")); + return resolved === root || resolved.startsWith(root + sep); +} + function resolveDeliveryPath(agentId: string): string { const branchDir = join(process.env.HOME || homedir(), ".tps", "branch-office"); if (!existsSync(branchDir)) return join(branchDir, agentId, "mail"); @@ -44,7 +56,10 @@ function resolveDeliveryPath(agentId: string): string { if (existsSync(teamSidecar)) { try { const sidecar = JSON.parse(readFileSync(teamSidecar, "utf-8")); - if (sidecar.workspaceMail) return sidecar.workspaceMail; + if (sidecar.workspaceMail) { + if (isWithinBranchOffice(sidecar.workspaceMail)) return sidecar.workspaceMail; + swarn(`team.json workspaceMail out of bounds, ignoring: ${sidecar.workspaceMail}`); + } } catch {} return join(teamPath, "workspace", "mail"); } @@ -59,7 +74,9 @@ function resolveDeliveryPath(agentId: string): string { const sidecarPath = join(branchDir, team, "team.json"); const sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8")); if (Array.isArray(sidecar.members) && sidecar.members.includes(agentId)) { - return sidecar.workspaceMail || join(branchDir, team, "workspace", "mail"); + if (sidecar.workspaceMail && isWithinBranchOffice(sidecar.workspaceMail)) return sidecar.workspaceMail; + if (sidecar.workspaceMail) swarn(`team.json workspaceMail out of bounds, ignoring: ${sidecar.workspaceMail}`); + return join(branchDir, team, "workspace", "mail"); } } } catch { diff --git a/packages/cli/test/relay.test.ts b/packages/cli/test/relay.test.ts index ef6ce30..84da230 100644 --- a/packages/cli/test/relay.test.ts +++ b/packages/cli/test/relay.test.ts @@ -2,7 +2,7 @@ import { beforeEach, afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { deliverToSandbox, processOutboxOnce } from "../src/utils/relay.js"; +import { deliverToSandbox, processOutboxOnce, resolveAgentMailRoot } from "../src/utils/relay.js"; function writeJson(path: string, obj: unknown) { writeFileSync(path, JSON.stringify(obj, null, 2), "utf-8"); @@ -170,6 +170,52 @@ describe("relay utils", () => { expect(msg.body).toBe("hello team member"); }); + test("ignores out-of-bounds workspaceMail on a team and falls back to safe default (ops-23)", () => { + const teamDir = join(root, ".tps", "branch-office", "team1"); + // Misconfigured/hostile team.json: workspaceMail escaping the office root. + const escape = join(root, "evil-mail"); + mkdirSync(teamDir, { recursive: true }); + writeJson(join(teamDir, "team.json"), { + teamId: "team1", + members: ["member1"], + workspaceMail: escape, + createdAt: new Date().toISOString(), + }); + + const resolved = resolveAgentMailRoot("team1"); + expect(resolved).toBe(join(teamDir, "workspace", "mail")); + expect(resolved).not.toBe(escape); + }); + + test("ignores out-of-bounds workspaceMail for a team member and falls back to safe default (ops-23)", () => { + const teamDir = join(root, ".tps", "branch-office", "team1"); + // Traversal that resolves outside ~/.tps/branch-office. + const escape = join(teamDir, "..", "..", "..", "escape", "mail"); + mkdirSync(teamDir, { recursive: true }); + writeJson(join(teamDir, "team.json"), { + teamId: "team1", + members: ["member1"], + workspaceMail: escape, + createdAt: new Date().toISOString(), + }); + + const resolved = resolveAgentMailRoot("member1"); + expect(resolved).toBe(join(teamDir, "workspace", "mail")); + }); + + test("returns an in-bounds workspaceMail unchanged (ops-23 regression guard)", () => { + const teamDir = join(root, ".tps", "branch-office", "team1"); + const legit = join(teamDir, "workspace", "mail"); + mkdirSync(teamDir, { recursive: true }); + writeJson(join(teamDir, "team.json"), { + teamId: "team1", + members: ["member1"], + workspaceMail: legit, + createdAt: new Date().toISOString(), + }); + expect(resolveAgentMailRoot("member1")).toBe(legit); + }); + test("relay pauses duplicate messages (loop detection)", async () => { const agentId = "brancha"; const out = outboxNew(agentId);