Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/cli/src/utils/relay.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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");
}
Expand All @@ -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 {
Expand Down
48 changes: 47 additions & 1 deletion packages/cli/test/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Loading