relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
249 lines (233 loc) • 10.4 kB
JavaScript
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
// A relay body is untrusted content; if a prompt-injected agent is talked into
// attaching a local file, relay_send must not become an exfiltration channel for
// secrets, nor a huge-file OOM/DoS. Cap the size and refuse the highest-signal
// secret locations. Deliberately conservative so ordinary work files still attach;
// RELAY_ATTACHMENT_MAX_BYTES tunes the cap.
const DEFAULT_MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024; // 100 MB
function maxAttachmentBytes() {
const raw = Number(process.env.RELAY_ATTACHMENT_MAX_BYTES);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_ATTACHMENT_BYTES;
}
// Path segments that overwhelmingly hold credentials. Matched case-insensitively
// against the resolved path's segments. Kept tight so ordinary work files still attach.
const SECRET_DIR_SEGMENTS = new Set([
".ssh", ".aws", ".gnupg", ".gpg", ".kube", ".docker",
".password-store", "keychains", ".relay",
]);
// Basenames that are almost always key material — NOT broad extensions. Deliberately
// excludes bare ".key" (Apple Keynote decks) and ".pem" (often public certs), and
// "credentials.<doc>" (documents ABOUT credentials). Only names that are specifically
// SSH/GPG private keys or clearly-secret credential stores.
const SECRET_BASENAME_RE = /(^\.env($|\.local|\.[a-z]+$)|^id_(rsa|dsa|ecdsa|ed25519)$|^id_(rsa|dsa|ecdsa|ed25519)\.pem$|\.p12$|\.pfx$|\.keystore$|^credentials\.(json|ya?ml|toml|ini)$|^\.?secrets?\.(json|ya?ml|toml|env)$|^\.netrc$|^\.pgpass$)/i;
// Returns null if the path is a refused (secret) location; otherwise the resolved path.
// Non-throwing so one bad path skips (with a logged warning) instead of failing the
// whole batch.
function safeAttachmentPathOrNull(filePath, trustedLocalRoot = "", baseDir = process.cwd()) {
const resolved = path.resolve(baseDir, filePath);
if (trustedLocalRoot) {
const root = path.resolve(trustedLocalRoot);
if (resolved === root || resolved.startsWith(root + path.sep)) return resolved;
}
const home = os.homedir();
const segments = resolved.split(path.sep).map((s) => s.toLowerCase());
for (const seg of segments) {
if (SECRET_DIR_SEGMENTS.has(seg)) return null;
}
// credential-bearing subtrees under ~/.config (but not the whole dir)
const configRoot = path.join(home, ".config");
if (resolved.startsWith(configRoot + path.sep)) {
const rest = resolved.slice(configRoot.length + 1).toLowerCase();
if (/^(gh|gcloud|op|1password)[\\/]|^git[\\/]credentials/.test(rest)) return null;
}
if (SECRET_BASENAME_RE.test(path.basename(resolved))) return null;
return resolved;
}
const CONTENT_TYPES = new Map([
[".csv", "text/csv"],
[".doc", "application/msword"],
[".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
[".gif", "image/gif"],
[".htm", "text/html"],
[".html", "text/html"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".json", "application/json"],
[".md", "text/markdown"],
[".m4v", "video/x-m4v"],
[".mov", "video/quicktime"],
[".mp4", "video/mp4"],
[".mpeg", "video/mpeg"],
[".mpg", "video/mpeg"],
[".pdf", "application/pdf"],
[".png", "image/png"],
[".ppt", "application/vnd.ms-powerpoint"],
[".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
[".rtf", "application/rtf"],
[".tex", "application/x-tex"],
[".text", "text/plain"],
[".txt", "text/plain"],
[".webp", "image/webp"],
[".webm", "video/webm"],
[".xls", "application/vnd.ms-excel"],
[".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
[".zip", "application/zip"],
]);
export function hasAttachmentPayload(value, depth = 0) {
if (!value || depth > 6) return false;
if (Array.isArray(value)) return value.some((item) => hasAttachmentPayload(item, depth + 1));
if (typeof value !== "object") return false;
if ((Array.isArray(value.files) && value.files.length) || (Array.isArray(value.attachments) && value.attachments.length)) return true;
return Object.values(value).some((item) => item && typeof item === "object" && hasAttachmentPayload(item, depth + 1));
}
export async function prepareOrdinaryRelayAttachments(args = {}, { baseDir = process.cwd() } = {}) {
const passthrough = passthroughAttachments(args);
const local = await readLocalAttachmentSpecs(localAttachmentSpecs(args), args.trustedLocalRoot, baseDir);
return [
...passthrough,
...local.map((file, index) => ({
id: file.id || ordinaryAttachmentId(file, index, args.idempotencyKey),
name: file.name,
contentType: file.contentType,
bytes: file.bytes,
sha256: file.sha256,
contentBase64: file.body.toString("base64"),
})),
];
}
export async function prepareTaskMessageAttachments(client, taskId, args = {}, { baseDir = process.cwd() } = {}) {
const passthrough = passthroughAttachments(args);
const local = await readLocalAttachmentSpecs(localAttachmentSpecs(args), "", baseDir);
const uploaded = await uploadLocalTaskFiles(client, taskId, local, args.idempotencyKey || "relay_mcp_task_file");
return [...passthrough, ...uploaded];
}
export async function prepareTaskResult(client, taskId, result = {}, baseIdempotencyKey = "relay_mcp_result_file", options = {}) {
return {
...result,
attachments: await prepareTaskMessageAttachments(client, taskId, {
attachments: result.attachments || [],
files: result.files || [],
idempotencyKey: baseIdempotencyKey,
}, options),
};
}
function localAttachmentSpecs(args = {}) {
const specs = [];
for (const item of arrayOf(args.files)) {
if (typeof item === "string") specs.push({ path: item });
else if (item && typeof item === "object") specs.push({ ...item, path: item.path || item.filePath });
}
for (const item of arrayOf(args.attachments)) {
if (item && typeof item === "object" && (item.path || item.filePath)) {
specs.push({ ...item, path: item.path || item.filePath });
}
}
return specs.filter((spec) => typeof spec.path === "string" && spec.path.trim().length > 0);
}
function passthroughAttachments(args = {}) {
return arrayOf(args.attachments).filter((item) => !(item && typeof item === "object" && (item.path || item.filePath)));
}
async function readLocalAttachmentSpecs(specs, trustedLocalRoot = "", baseDir = process.cwd()) {
const out = [];
const cap = maxAttachmentBytes();
const accepted = [];
let aggregateBytes = 0;
for (const [index, spec] of specs.entries()) {
const filePath = safeAttachmentPathOrNull(spec.path, trustedLocalRoot, baseDir);
if (!filePath) {
// Skip a clearly-secret file rather than fail the whole send; the agent can
// re-send without it, and a prompt-injected exfiltration attempt is quietly denied.
console.error(`[relay] refusing to attach a secret-looking file: ${path.resolve(baseDir, spec.path)}`);
continue;
}
const stat = await fs.stat(filePath);
if (!stat.isFile()) throw new Error(`Attachment path is not a file: ${filePath}`);
if (stat.size > cap) {
throw new Error(
`Attachment ${path.basename(filePath)} is ${(stat.size / 1024 / 1024).toFixed(1)} MB, over the ${(cap / 1024 / 1024).toFixed(0)} MB limit.`,
);
}
aggregateBytes += stat.size;
if (aggregateBytes > cap) {
throw new Error(
`Attachments total ${(aggregateBytes / 1024 / 1024).toFixed(1)} MB, over the ${(cap / 1024 / 1024).toFixed(0)} MB per-call limit.`,
);
}
accepted.push({ index, spec, filePath, stat });
}
for (const { index, spec, filePath } of accepted) {
const body = await fs.readFile(filePath);
const name = String(spec.name || spec.filename || path.basename(filePath)).trim() || `attachment-${index + 1}`;
const sha256 = createHash("sha256").update(body).digest("hex");
out.push({
id: spec.id,
path: filePath,
name,
filename: name,
contentType: spec.contentType || inferContentType(name),
bytes: body.length,
sha256,
body,
});
}
return out;
}
async function uploadLocalTaskFiles(client, taskId, files, baseIdempotencyKey) {
const uploaded = [];
for (const [index, file] of files.entries()) {
const result = await client.createFileUpload({
taskId,
filename: file.name,
contentType: file.contentType,
sizeBytes: file.bytes,
sha256: file.sha256,
metadataScanStatus: "pending",
idempotencyKey: fileUploadIdempotencyKey(baseIdempotencyKey, file, index),
});
await uploadBytes(result.upload, file);
uploaded.push(result.attachment);
}
return uploaded;
}
async function uploadBytes(upload, file) {
if (!upload?.url) throw new Error(`Relay did not return an upload URL for ${file.name}.`);
const method = upload.method || "PUT";
let res;
if (method === "POST") {
const form = new FormData();
for (const [key, value] of Object.entries(upload.fields || {})) form.append(key, value);
form.append("file", new Blob([file.body], { type: file.contentType }), file.name);
res = await fetch(upload.url, { method: "POST", body: form });
} else {
res = await fetch(upload.url, {
method: "PUT",
headers: { "Content-Type": file.contentType },
body: file.body,
});
}
if (!res.ok) {
const body = await res.text().catch(() => "");
const detail = body ? `: ${body.slice(0, 500)}` : "";
throw new Error(`Relay upload failed for ${file.name} with HTTP ${res.status}${detail}`);
}
}
function fileUploadIdempotencyKey(base, file, index) {
const safeBase = String(base || "relay_mcp_file").replace(/[^\w.-]+/g, "_").slice(0, 80);
return `${safeBase}_file_${index}_${file.sha256.slice(0, 16)}`;
}
function ordinaryAttachmentId(file, index, idempotencyKey) {
const sendHash = createHash("sha256")
.update(String(idempotencyKey || "relay_mcp_ordinary"))
.digest("hex")
.slice(0, 12);
return `att_${sendHash}_${index}_${file.sha256.slice(0, 16)}`;
}
function inferContentType(filename) {
return CONTENT_TYPES.get(path.extname(filename).toLowerCase()) || "application/octet-stream";
}
function arrayOf(value) {
return Array.isArray(value) ? value : [];
}