import { redactCommandText } from "@paperclipai/adapter-utils"; import { isPublicExecutorToolSelector, looksLikeCredentialJwt } from "@paperclipai/adapter-utils/command-redaction"; const SECRET_FIELD_NAME_PATTERN = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization(?:[-_]?code)?|bearer|secrets?|passwd|passwords?|credentials?|jwt|private[-_]?key|cookie|connectionstring|browser[-_]?code|login[-_]?url)(?:[-_]?(?:value|header|prod(?:uction)?|dev(?:elopment)?|test|staging|primary|secondary))*`; const SECRET_FIELD_NAME_START = String.raw`(? lower.includes(hint)) || input.includes(".") ); } function inlineWhitespaceEnd(input: string, start: number): number { let index = start; while ( index < input.length && (input[index] === " " || input[index] === "\t") ) { index += 1; } return index; } function quotedValueBoundary( input: string, index: number, ): "hard" | "line" | "provisional" | "unsafe" { const boundary = input[index]; if (boundary === undefined || /[,;&)}\]]/.test(boundary)) return "hard"; if (boundary === "\r" || boundary === "\n") return "line"; if (/\s/.test(boundary)) return "provisional"; return "unsafe"; } function isTrustedQuotedValueBoundary(input: string, index: number) { const boundary = quotedValueBoundary(input, index); return boundary === "hard" || boundary === "provisional"; } function startsIndependentCredentialLine(input: string, index: number) { if (input[index] !== "\r" && input[index] !== "\n") return false; let lineStart = index + 1; if (input[index] === "\r" && input[lineStart] === "\n") lineStart += 1; const lineEndCandidates = [ input.indexOf("\r", lineStart), input.indexOf("\n", lineStart), ].filter((candidate) => candidate >= 0); const lineEnd = lineEndCandidates.length > 0 ? Math.min(...lineEndCandidates) : input.length; const words = input .slice(lineStart, lineEnd) .trimStart() .toLowerCase() .split(/[ \t]+/); return ( (words[0] === "bearer" && words.length >= 2) || (words[0] === "request" && words[1] === "failed" && words[2] === "with" && words[3] === "bearer" && words.length >= 5) ); } function rawQuotedValueEnd( input: string, start: number, quote: '"' | "'", ): number { const end = input.length; let provisionalEnd: number | null = null; let unsafeAfterProvisional = false; for (let index = start + 1; index < end; index += 1) { if (input[index] === "\\") { index += 1; continue; } if (input[index] === quote) { const candidateEnd = index + 1; const boundary = quotedValueBoundary(input, candidateEnd); if (boundary === "hard") return candidateEnd; if (boundary === "provisional") { provisionalEnd = candidateEnd; unsafeAfterProvisional = false; } else if ( boundary === "line" && startsIndependentCredentialLine(input, candidateEnd) ) { return candidateEnd; } else { if (boundary === "line") provisionalEnd = null; unsafeAfterProvisional = true; } } } // Whitespace normally separates safe trailing context, so retain a final // provisional delimiter when no later quote contradicts it. Literal newlines // can occur inside provider-controlled credentials, so an unterminated // malformed value fails closed through the complete bounded diagnostic. return provisionalEnd !== null && !unsafeAfterProvisional ? provisionalEnd : end; } function escapedQuotedValueEnd( input: string, start: number, quote: '"' | "'", ): number { const end = input.length; let provisionalEnd: number | null = null; let unsafeAfterProvisional = false; for (let index = start + 2; index < end; index += 1) { if (input[index] !== "\\") continue; let afterSlashes = index + 1; while (afterSlashes < end && input[afterSlashes] === "\\") { afterSlashes += 1; } if (input[afterSlashes] !== quote) { index = afterSlashes - 1; continue; } // A serialized outer quote has one slash. Three or more slashes encode a // quote nested inside that value, so keep scanning for the outer delimiter. if (afterSlashes - index === 1) { const candidateEnd = afterSlashes + 1; const boundary = quotedValueBoundary(input, candidateEnd); if (boundary === "hard") return candidateEnd; if (boundary === "provisional") { provisionalEnd = candidateEnd; unsafeAfterProvisional = false; } else if ( boundary === "line" && startsIndependentCredentialLine(input, candidateEnd) ) { return candidateEnd; } else { if (boundary === "line") provisionalEnd = null; unsafeAfterProvisional = true; } } index = afterSlashes; } return provisionalEnd !== null && !unsafeAfterProvisional ? provisionalEnd : end; } function escapedQuoteAt(input: string, index: number): '"' | "'" | null { if (input[index] !== "\\") return null; const quote = input[index + 1]; return quote === '"' || quote === "'" ? quote : null; } function credentialEndAfterQuotedDelimiter(input: string, quotedEnd: number) { if (isTrustedQuotedValueBoundary(input, quotedEnd)) return quotedEnd; if (startsIndependentCredentialLine(input, quotedEnd)) return quotedEnd; // A closing delimiter followed immediately by more token bytes is not a // trustworthy credential boundary (for example `"abc"defg`). Once a // provider diagnostic is malformed this way, whitespace is not a safe // boundary either (`"a"b c"`). Fail closed through the rest of the // diagnostic so no later credential fragment survives. return input.length; } interface AuthorizationCredentialRange { start: number; end: number; replacement: string; } function authorizationCredentialRange( input: string, wordEnd: number, ): AuthorizationCredentialRange | null { let valueStart = wordEnd; // JSON and serialized JSON close the quoted key before the colon. Treat a // quote as part of the key only when a colon follows it; otherwise it is the // opening delimiter of a header value such as Authorization "Bearer ...". const rawKeyQuote = input[valueStart]; const escapedKeyQuote = escapedQuoteAt(input, valueStart); const keyQuoteWidth = rawKeyQuote === '"' || rawKeyQuote === "'" ? 1 : escapedKeyQuote ? 2 : 0; if (keyQuoteWidth > 0) { const possibleColon = inlineWhitespaceEnd( input, valueStart + keyQuoteWidth, ); if (input[possibleColon] === ":") valueStart = possibleColon; } valueStart = inlineWhitespaceEnd(input, valueStart); if (input[valueStart] === ":" || input[valueStart] === "=") { valueStart = inlineWhitespaceEnd(input, valueStart + 1); } const rawValueQuote = input[valueStart]; if (rawValueQuote === '"' || rawValueQuote === "'") { const quotedEnd = rawQuotedValueEnd(input, valueStart, rawValueQuote); const content = input.slice(valueStart + 1, quotedEnd - 1).trimStart(); if (!/^(?:Bearer|Basic)\b/i.test(content)) return null; return { start: valueStart, end: credentialEndAfterQuotedDelimiter(input, quotedEnd), replacement: `${rawValueQuote}${REDACTED_EVENT_VALUE}${rawValueQuote}`, }; } const escapedValueQuote = escapedQuoteAt(input, valueStart); if (escapedValueQuote) { const quotedEnd = escapedQuotedValueEnd( input, valueStart, escapedValueQuote, ); const content = input.slice(valueStart + 2, quotedEnd - 2).trimStart(); if (!/^(?:Bearer|Basic)\b/i.test(content)) return null; const delimiter = `\\${escapedValueQuote}`; return { start: valueStart, end: credentialEndAfterQuotedDelimiter(input, quotedEnd), replacement: `${delimiter}${REDACTED_EVENT_VALUE}${delimiter}`, }; } const scheme = /^(?:Bearer|Basic)\b/i.exec(input.slice(valueStart)); if (!scheme) return null; let credentialStart = inlineWhitespaceEnd( input, valueStart + scheme[0].length, ); if (credentialStart === valueStart + scheme[0].length) return null; if ( credentialStart >= input.length || /[\r\n]/.test(input[credentialStart]) ) { return null; } let end: number; const rawCredentialQuote = input[credentialStart]; if (rawCredentialQuote === '"' || rawCredentialQuote === "'") { end = credentialEndAfterQuotedDelimiter( input, rawQuotedValueEnd(input, credentialStart, rawCredentialQuote), ); } else { const escapedCredentialQuote = escapedQuoteAt(input, credentialStart); if (escapedCredentialQuote) { end = credentialEndAfterQuotedDelimiter( input, escapedQuotedValueEnd(input, credentialStart, escapedCredentialQuote), ); } else { end = credentialStart; // Embedded quote/backtick bytes do not make an unquoted credential safe; // consume them through the next structural or whitespace boundary. while (end < input.length && !/[\s,;}\]]/.test(input[end])) end += 1; } } return { start: valueStart, end, replacement: REDACTED_EVENT_VALUE }; } function redactAuthorizationCredentials(input: string): string { // Include compound diagnostic labels such as proxyAuthorization while the // value parser still requires a Basic/Bearer scheme before redacting. const authorizationWord = /(?= input.length || /[\r\n]/.test(input[credentialStart]) ) { continue; } let end: number; let replacement = REDACTED_EVENT_VALUE; const rawQuote = input[credentialStart]; if (rawQuote === '"' || rawQuote === "'") { end = credentialEndAfterQuotedDelimiter( input, rawQuotedValueEnd(input, credentialStart, rawQuote), ); replacement = `${rawQuote}${REDACTED_EVENT_VALUE}${rawQuote}`; } else { const escapedQuote = escapedQuoteAt(input, credentialStart); if (escapedQuote) { end = credentialEndAfterQuotedDelimiter( input, escapedQuotedValueEnd(input, credentialStart, escapedQuote), ); const delimiter = `\\${escapedQuote}`; replacement = `${delimiter}${REDACTED_EVENT_VALUE}${delimiter}`; } else { end = credentialStart; while (end < input.length && !/[\s,;}\]]/.test(input[end])) end += 1; } } const candidate = input.slice(credentialStart, end); // Preserve known authentication terms after bearer, unless explicitly quoted. // Named Authorization fields are already handled independently above. if (/^(?:tokens?|authentication|authorization|credentials?|schemes?|flows?)[.,;:!?]?$/i.test(candidate)) continue; if (credentialStart < copiedThrough) continue; parts.push(input.slice(copiedThrough, credentialStart), replacement); copiedThrough = end; bearerWord.lastIndex = end; } if (copiedThrough === 0) return input; parts.push(input.slice(copiedThrough)); return parts.join(""); } function isPlainObject(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } function sanitizeValue(value: unknown): unknown { if (value === null || value === undefined) return value; // Adapter diagnostics are provider-controlled text. Secret-bearing header or // command fragments can appear under otherwise innocuous keys such as // `message`, `reason`, or inside arrays, so apply the text scanner at every // string leaf after validated protocol discriminators have had a chance to // opt in above in sanitizeRecord. if (typeof value === "string") { return looksLikeCredentialJwt(value) && !isPublicExecutorToolSelector(value) ? REDACTED_EVENT_VALUE : redactSensitiveText(value); } if (Array.isArray(value)) return value.map(sanitizeValue); if (isSecretRefBinding(value)) { const version = safeSecretVersion(value.version); return { type: "secret_ref", secretId: value.secretId, ...(version === undefined ? {} : { version }), ...(value.projectionClass === "unclassified" || value.projectionClass === "class_3_static_lease" ? { projectionClass: value.projectionClass } : {}), ...(value.projectionAllowlistKey === null ? { projectionAllowlistKey: null } : typeof value.projectionAllowlistKey === "string" && PROJECTION_ALLOWLIST_KEY_RE.test(value.projectionAllowlistKey) && redactSensitiveText(value.projectionAllowlistKey) === value.projectionAllowlistKey ? { projectionAllowlistKey: value.projectionAllowlistKey } : {}), }; } if (isUserSecretRefBinding(value)) { const version = safeSecretVersion(value.version); return { type: "user_secret_ref", key: value.key, ...(version === undefined ? {} : { version }), ...(typeof value.required === "boolean" ? { required: value.required } : {}), ...(typeof value.allowMissingOverride === "boolean" ? { allowMissingOverride: value.allowMissingOverride } : {}), }; } if (isPlainBinding(value)) return { type: "plain", value: sanitizeValue(value.value) }; if (!isPlainObject(value)) return value; return sanitizeRecord(value); } const SECRET_REFERENCE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const USER_SECRET_KEY_RE = /^[A-Za-z0-9_.-]{1,120}$/; const PROJECTION_ALLOWLIST_KEY_RE = /^[A-Za-z0-9_-]{1,80}\.[A-Za-z0-9_-]{1,80}$/; function safeSecretVersion(value: unknown): number | "latest" | undefined { if (value === "latest") return value; return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } function isSecretRefBinding(value: unknown): value is { type: "secret_ref"; secretId: string; version?: unknown; projectionClass?: unknown; projectionAllowlistKey?: unknown; } { if (!isPlainObject(value)) return false; return ( value.type === "secret_ref" && typeof value.secretId === "string" && SECRET_REFERENCE_ID_RE.test(value.secretId) ); } function isUserSecretRefBinding(value: unknown): value is { type: "user_secret_ref"; key: string; version?: unknown; required?: unknown; allowMissingOverride?: unknown; } { if (!isPlainObject(value)) return false; return ( value.type === "user_secret_ref" && typeof value.key === "string" && USER_SECRET_KEY_RE.test(value.key) && redactSensitiveText(value.key) === value.key ); } function isPlainBinding( value: unknown, ): value is { type: "plain"; value: unknown } { if (!isPlainObject(value)) return false; return value.type === "plain" && "value" in value; } function sanitizeCommandArgs(args: unknown[]): unknown[] { let redactNext = false; return args.map((arg) => { if (redactNext) { redactNext = false; return REDACTED_EVENT_VALUE; } if (typeof arg !== "string") return sanitizeValue(arg); if (CLI_SECRET_FLAG_RE.test(arg.trim())) { redactNext = true; return arg; } return redactSensitiveText(arg); }); } function isKnownPrpEventDiscriminator( container: Record, key: string, value: unknown, ): value is string { return ( key === "eventType" && typeof value === "string" && ((container.schema === "paperclip.prp.event.v1" && container.schemaVersion === 1 && PRP_V1_EVENT_TYPES.has(value)) || (container.schema === "paperclip.prp.event.v2" && container.schemaVersion === 2 && (PRP_V1_EVENT_TYPES.has(value) || PRP_V2_EVENT_TYPES.has(value)))) ); } function isPaperclipSchemaDiscriminator( key: string, value: unknown, ): value is string { return ( PAPERCLIP_SCHEMA_FIELDS.has(key) && typeof value === "string" && PAPERCLIP_PUBLIC_SCHEMA_IDS.has(value) ); } export function sanitizeRecord( record: Record, ): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(record)) { if (COMMAND_ARGS_PAYLOAD_KEY_RE.test(key) && Array.isArray(value)) { redacted[key] = sanitizeCommandArgs(value); continue; } if (COMMAND_PAYLOAD_KEY_RE.test(key) && typeof value === "string") { redacted[key] = redactSensitiveText(value); continue; } if ( (SECRET_PAYLOAD_KEY_RE.test(key) || AUDIT_COUNT_PAYLOAD_KEYS.has(key)) && !AUDIT_REASON_PAYLOAD_KEY_RE.test(key) && !isAuditCountField(key, value) ) { if (isSecretRefBinding(value)) { redacted[key] = sanitizeValue(value); continue; } if (isUserSecretRefBinding(value)) { redacted[key] = sanitizeValue(value); continue; } if (isPlainBinding(value)) { redacted[key] = { type: "plain", value: REDACTED_EVENT_VALUE }; continue; } redacted[key] = REDACTED_EVENT_VALUE; continue; } // Preserve public protocol identities before scanning diagnostic values. if (isKnownPrpEventDiscriminator(record, key, value)) { redacted[key] = value; continue; } if (isPaperclipSchemaDiscriminator(key, value)) { redacted[key] = value; continue; } if (AUDIT_SURFACE_PAYLOAD_KEY_RE.test(key) && typeof value === "string") { redacted[key] = redactSensitiveText(value); continue; } if ( typeof value === "string" && looksLikeCredentialJwt(value) && !isPublicExecutorToolSelector(value) && !isPaperclipSchemaDiscriminator(key, value) ) { redacted[key] = REDACTED_EVENT_VALUE; continue; } redacted[key] = sanitizeValue(value); } return redacted; } export function redactEventPayload( payload: Record | null, ): Record | null { if (!payload) return null; if (!isPlainObject(payload)) return payload; const sanitized = sanitizeRecord(payload); if (payload.schema !== NATIVE_RUN_SPAN_SCHEMA) return sanitized; // Native run span identities are controlled diagnostics emitted by // createNativeRunTrace, not provider data. Their dotted names overlap the // broad JWT-value heuristic, so restore only these two fields on the exact // run-performance schema. Hostnames and JWT-shaped values on every other // field and schema still fail closed through sanitizeRecord above. for (const field of NATIVE_RUN_SPAN_FIELDS) { const value = payload[field]; if (typeof value === "string" && NATIVE_RUN_SPAN_NAMES.has(value)) { sanitized[field] = value; } } return sanitized; } function redactAgentEnvBinding(value: unknown): unknown { if (isSecretRefBinding(value) || isUserSecretRefBinding(value)) { return sanitizeValue(value); } if (typeof value === "string" || isPlainBinding(value)) { return { type: "plain", value: REDACTED_EVENT_VALUE }; } if (value === null || value === undefined) return value; return REDACTED_EVENT_VALUE; } export function redactAgentAdapterConfig( adapterConfig: Record, ): Record { if (!isPlainObject(adapterConfig)) return adapterConfig; if (!isPlainObject(adapterConfig.env)) return redactEventPayload(adapterConfig) ?? {}; // Redact `env` here and sanitize the remaining keys separately, so bindings // are never processed twice. `redactAgentEnvBinding` is authoritative for // `env`; keeping it out of `sanitizeRecord` means a future change there // cannot alter entries this function has already redacted. const { env, ...rest } = adapterConfig; const redactedEnv = Object.fromEntries( Object.entries(env).map(([key, value]) => [key, redactAgentEnvBinding(value)]), ); return { ...(redactEventPayload(rest) ?? {}), env: redactedEnv }; } export function redactSensitiveText(input: string): string { if (!maybeContainsSecretText(input)) return input; return redactCommandText( redactStandaloneBearerCredentials(redactAuthorizationCredentials(input)) .replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) .replace( ESCAPED_JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`, ), REDACTED_EVENT_VALUE, ); }