export const SERVER_JOB_OUTPUT_SCHEMA = 1; export const SERVER_JOB_OUTPUT_LIMITS = Object.freeze({ stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, totalBytes: 128 * 1024, }); const REDACTION = ""; const PATH_REDACTION = ""; const TRUNCATION = "\n"; const CREDENTIAL_KEY = "(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)"; const CREDENTIAL_ASSIGNMENT = /(\b(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*)(["']?)([^\s,;"']+)\2/giu; const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu; const BASIC = /\bBasic\s+[A-Za-z0-9+/=]+/giu; const SECRET_HEADER = /(\b(?:authorization|proxy-authorization)\s*:\s*)([^\r\n]+)/giu; const UNIX_PATH = /\/(?:[^\s/\\:*?"<>|]+\/)*[^\s/\\:*?"<>|]+/gu; const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\s\\/:*?"<>|]+\\)*[^\s\\/:*?"<>|]*/gu; const FILE_URL = /\bfile:\/\/[^\s"']+/giu; function invalid(message) { throw new Error(`SERVER_JOB_OUTPUT_INVALID: ${message}`); } function assertText(value, field) { if (typeof value !== "string") invalid(`${field} must be a string`); return value; } function assertLimit(value, field) { if (!Number.isSafeInteger(value) || value < 1 || value > SERVER_JOB_OUTPUT_LIMITS[field]) { invalid(`${field} is outside the fixed output budget`); } return value; } function redactCredentials(value) { let text = value; let count = 0; const replace = (pattern, replacement) => { text = text.replace(pattern, (...args) => { count += 1; return typeof replacement === "function" ? replacement(...args) : replacement; }); }; replace(CREDENTIAL_ASSIGNMENT, (_match, prefix, quote) => `${prefix}${quote}${REDACTION}${quote}`); replace(BEARER, `Bearer ${REDACTION}`); replace(BASIC, `Basic ${REDACTION}`); replace(SECRET_HEADER, (_match, prefix) => `${prefix}${REDACTION}`); return { text, count }; } function redactPaths(value) { let count = 0; let text = value.replace(FILE_URL, () => { count += 1; return PATH_REDACTION; }); text = text.replace(WINDOWS_PATH, () => { count += 1; return PATH_REDACTION; }); text = text.replace(UNIX_PATH, (match, offset, source) => { // Keep URL paths and protocol markers readable; only redact filesystem-looking paths. const before = source.slice(Math.max(0, offset - 8), offset); if (/https?:$|https?:\/\/$/iu.test(before) || match === "/") return match; count += 1; return PATH_REDACTION; }); return { text, count }; } function truncateUtf8(value, maxBytes) { const source = Buffer.from(value, "utf8"); if (source.byteLength <= maxBytes) return { text: value, truncated: false }; const marker = Buffer.from(TRUNCATION, "utf8"); const available = Math.max(0, maxBytes - marker.byteLength); let end = Math.min(available, source.byteLength); while (end > 0 && (source[end] & 0xc0) === 0x80) end -= 1; return { text: `${source.subarray(0, end).toString("utf8")}${TRUNCATION}`, truncated: true }; } export function sanitizeServerJobOutput(value, maxBytes) { assertText(value, "output"); assertLimit(maxBytes, "stdoutBytes"); const originalBytes = Buffer.byteLength(value, "utf8"); const credentials = redactCredentials(value); const paths = redactPaths(credentials.text); const truncated = truncateUtf8(paths.text, maxBytes); return Object.freeze({ text: truncated.text, originalBytes, emittedBytes: Buffer.byteLength(truncated.text, "utf8"), redactionCount: credentials.count + paths.count, truncated: truncated.truncated, }); } export function createServerJobOutputReceipt(value, limits = SERVER_JOB_OUTPUT_LIMITS) { if (!value || typeof value !== "object") invalid("receipt input is invalid"); if (!limits || typeof limits !== "object") invalid("limits are invalid"); const stdoutBytes = assertLimit(limits.stdoutBytes, "stdoutBytes"); const stderrBytes = assertLimit(limits.stderrBytes, "stderrBytes"); const totalBytes = assertLimit(limits.totalBytes, "totalBytes"); if (stdoutBytes + stderrBytes > totalBytes) invalid("stream budgets exceed total budget"); const stdout = sanitizeServerJobOutput(value.stdout ?? "", "stdoutBytes" in limits ? stdoutBytes : stdoutBytes); const stderr = sanitizeServerJobOutput(value.stderr ?? "", "stderrBytes" in limits ? stderrBytes : stderrBytes); const totalOriginalBytes = stdout.originalBytes + stderr.originalBytes; const totalEmittedBytes = stdout.emittedBytes + stderr.emittedBytes; const totalTruncated = stdout.truncated || stderr.truncated || totalOriginalBytes > totalBytes; return Object.freeze({ schemaVersion: SERVER_JOB_OUTPUT_SCHEMA, stdout, stderr, limits: Object.freeze({ stdoutBytes, stderrBytes, totalBytes }), totalOriginalBytes, totalEmittedBytes, totalRedactions: stdout.redactionCount + stderr.redactionCount, totalTruncated, execution: "DISABLED", }); } export function redactServerJobOutput(value, maxBytes) { return sanitizeServerJobOutput(value, maxBytes).text; }