Skip to content

fix(relay): route branch→branch mail to the recipient's workspace inbox (ops-16)#313

Open
tps-flint wants to merge 1 commit into
mainfrom
fix-ops16-branch-mail-routing
Open

fix(relay): route branch→branch mail to the recipient's workspace inbox (ops-16)#313
tps-flint wants to merge 1 commit into
mainfrom
fix-ops16-branch-mail-routing

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

Fixes ops-16.

Problem

When a sandboxed/branch agent relays mail to another local branch agent, the outbox relay (processOutboxOnceresolveTransportFileSystemTransport.deliver) wrote to the flat host mail path (~/.tps/mail/<recipient>/new) regardless of recipient. But a branch agent reads its workspace inbox (~/.tps/branch-office/.../mail), so branch→branch messages landed somewhere the recipient never looks.

deliverToSandbox was already branch-aware (via resolveDeliveryPath), but the transport relay path that processOutboxOnce uses was not — FileSystemTransport.deliver hardcoded join(hostMailRoot(), envelope.to, "new").

Fix

Dependency injection — transport.ts never imports relay.ts, and relay.ts already owns the default transport (new TransportRegistry(new FileSystemTransport())), so no circular import:

  • FileSystemTransport takes an optional resolveRecipientDir(recipient) => string | null hook. deliver() routes to <hook(to)>/new when it returns a path, else the host path (unchanged default).
  • relay.ts injects resolveBranchRecipientDir: a local branch/team recipient (has a branch-office presence — own branch dir or team member) → resolveDeliveryPath(recipient) (its workspace inbox); a host agentnull → host path, behavior preserved.

Tests (packages/cli/test/relay.test.ts)

  • branch→branch → recipient's workspace inbox, not the host path
  • branch→team-member → team workspace inbox, not the host path
  • non-branch (host) recipient → host path (regression guard)

relay suite 11/11. Full cli suite 1016 pass; the 5 remaining failures (ssh-reachability, nono-on-PATH, external-command verify, type-coercion) are pre-existing/environmental — identical set on clean main, untouched by this change.

Follow-up note

The inbox-full check in processOutboxOnce (countInboxMessages(recipient)) still counts the host path for branch recipients, so it under-counts a branch recipient's real (workspace) inbox. Separate minor inconsistency — not fixed here to keep this focused; worth a follow-up.

🤖 Generated with Claude Code

…ace inbox (ops-16)

When a sandboxed/branch agent relays mail to another LOCAL branch agent, the outbox
relay (processOutboxOnce → resolveTransport → FileSystemTransport) wrote to the flat
host mail path (~/.tps/mail/<recipient>/new) regardless of recipient. But a branch
agent reads its WORKSPACE inbox (~/.tps/branch-office/.../mail), so branch→branch
messages landed somewhere the recipient never looks.

FileSystemTransport hardcoded join(hostMailRoot(), to, "new"). deliverToSandbox was
already branch-aware (via resolveDeliveryPath), but the transport relay path wasn't.

Fix (dependency injection — no circular import, relay.ts owns the default transport):
- FileSystemTransport takes an optional resolveRecipientDir hook; deliver() routes to
  <hook(to)>/new when it returns a path, else the host path (unchanged).
- relay.ts injects resolveBranchRecipientDir: a local branch/team recipient (has a
  branch-office presence) → resolveDeliveryPath(recipient) (its workspace inbox); a
  host agent → null → host path, behavior preserved.

Tests: branch→branch and branch→team-member land in the workspace inbox (not the host
path); a non-branch (host) recipient still uses the host path (regression guard).

Note: the inbox-full check in processOutboxOnce still counts the host path for branch
recipients — a separate minor inconsistency, flagged for follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tps-flint tps-flint requested a review from a team as a code owner June 15, 2026 06:11

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review — APPROVED ✅

Branch detection: cannot be tricked into cross-tenant delivery

isBranchRecipient(recipient) checks two things:

  1. existsSync(join(branchDir, recipient)) — a registered branch/team directory must exist
  2. The recipient is listed in a team's members array in team.json

Both are host-fs-controlled. An attacker can't inject themselves as a member without writing to team.json (host-side). The recipient ID is the to field from the envelope, which comes from the sender's outbox — the sender chooses the recipient, and the path resolves based on that recipient's identity, not the sender's.

function resolveBranchRecipientDir(recipient: string): string | null {
  return isBranchRecipient(recipient) ? resolveDeliveryPath(recipient) : null;
}

resolveDeliveryPath(recipient) uses the recipient's own team membership to resolve the path. A sender addressing mail to branchb cannot make it land in branchc's inbox — the path is derived from branchb's identity alone.

Workspace inbox path: stays within branch-office root

resolveDeliveryPath is the same function hardened in PR 312 — it now includes isWithinBranchOffice which resolve()-normalizes and checks against ~/.tps/branch-office with the root + sep prefix guard. Even if team.json has a malicious workspaceMail value, it's rejected and falls back to the safe default.

The recipient ID itself is sanitized via sanitizeIdentifier in assertAgent before delivery reaches this code path.

No path-traversal or cross-tenant leak

  • Recipient ID → sanitized by assertAgent (alphanumeric + hyphens only)
  • Delivery path → resolveDeliveryPathisWithinBranchOffice boundary check
  • Host agents → resolveBranchRecipientDir returns null → flat host path unchanged
  • The to field determines the inbox; the sender cannot redirect

Composition with PR 312

The boundary check in resolveDeliveryPath (PR 312) applies to all paths through this code. The two PRs compose correctly: PR 312 prevents team.json from redirecting the delivery root outside the office, and PR 313 ensures the correct root is selected for branch recipients.

Test coverage

  • Branch→branch: lands in recipient's workspace inbox, not host path
  • Branch→team member: lands in team workspace inbox, not host path
  • Branch→host agent: lands in host path unchanged (regression guard)
  • Relay suite: 11/11 pass

Verdict

APPROVED. Branch detection is host-fs-gated and identity-correct. Workspace inbox path stays within the branch-office root. No cross-tenant leak. Composes correctly with PR 312's boundary check.

@tps-flint

Copy link
Copy Markdown
Contributor Author

Note: the failing Dependency Audit check is the same pre-existing, repo-wide transitive advisory flagged on #312shell-quote (≤1.8.3) via react-devtools-core (GHSA-w7jw-789q-3m8p). Lockfile is unchanged from main; tracked in ops-oylg. Not introduced by this PR — it touches only relay.ts / transport.ts + tests.

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture Review — ✅ Approved

Sherlock already covered the security surface. My architecture notes:

Dependency injection pattern

FileSystemTransport takes an optional resolveRecipientDir hook — transport.ts never imports relay.ts, and relay.ts already owns the default transport construction. No circular import. Clean separation: the transport is a generic delivery mechanism, the resolver is relay-specific routing logic. This is the right layering.

Branch detection

isBranchRecipient checks two things: (1) a registered branch/team directory exists, (2) the recipient is in a team's members array. Both are host-fs-gated. The recipient ID is the to field from the envelope — the sender chooses the recipient, and the path resolves based on that recipient's identity. A sender addressing mail to branchb cannot make it land in branchc's inbox.

Composition with PR #312

resolveDeliveryPath (the path resolver used here) now includes the isWithinBranchOffice boundary check from PR #312. The two PRs compose correctly: #312 prevents team.json from redirecting the delivery root outside the office, and #313 ensures the correct root is selected for branch recipients.

Follow-up note acknowledged

The inbox-full check in processOutboxOnce still counts the host path for branch recipients — under-counts a branch recipient's real (workspace) inbox. Flagged in the PR body, not fixed here to keep scope focused. Worth a follow-up but not a blocker.

Test coverage

Three cases: branch→branch (workspace inbox), branch→team-member (team workspace inbox), branch→host agent (host path unchanged). All correct.

Verdict: Approved.

No architectural concerns. Merge when ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants