diff --git a/packages/opencode/src/altimate/telemetry/ansi.ts b/packages/opencode/src/altimate/telemetry/ansi.ts new file mode 100644 index 0000000000..241cadeb10 --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/ansi.ts @@ -0,0 +1,31 @@ +import stripAnsi from "strip-ansi" + +// JavaScript string lengths are UTF-16 code units, matching maskString's historical limits. +export const MAX_INPUT = 8192 +export const LOOKAHEAD = 2048 + +/** + * Shared bounded ANSI entry point with explicit compatibility profiles. + * `legacy` preserves maskString's 7-bit rules and existing lookahead window, so hashes + * do not change. `template` handles the broader strip-ansi grammar on the original first + * line. An oversized first line returns undefined: callers must redact the whole line, + * rather than emit a prefix whose ANSI sequence or credential marker may have been cut. + */ +export function normalize(raw: string, profile: "legacy"): string +export function normalize(raw: string, profile: "template"): string | undefined +export function normalize(raw: string, profile: "legacy" | "template"): string | undefined { + if (profile === "legacy") { + // maskString already supplies at most MAX_INPUT + LOOKAHEAD code units. Keep this + // historical regex frozen; adopting the template profile here would change hashes. + return raw + .slice(0, MAX_INPUT + LOOKAHEAD) + .replace(/\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g, "") + } + // One extra code unit distinguishes an exact-limit line followed by a newline from + // an oversized line. Never scan the unbounded input or strip across a line boundary. + const first = raw.slice(0, MAX_INPUT + 1).split(/[\r\n\u2028\u2029]/, 1)[0] + if (first.length > MAX_INPUT) return undefined + return stripAnsi(first) +} + +export * as Ansi from "./ansi" diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index a424f6c52a..863081f57a 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -1,7 +1,42 @@ import { Telemetry } from "./index" +import { Ansi } from "./ansi" const MAX_MESSAGE = 200 const PLACEHOLDER = "" +export const CREDENTIAL_WORDS: ReadonlySet = new Set([ + "accesstoken", + "apikey", + "appsecret", + "auth", + "authorization", + "authtoken", + "bearer", + "bearertoken", + "clientsecret", + "connectionstring", + "cookie", + "credential", + "credentials", + "jwtsecret", + "jwttoken", + "key", + "pass", + "passphrase", + "passwd", + "password", + "passwords", + "privatekey", + "pwd", + "refreshtoken", + "secret", + "secretkey", + "session", + "sessionid", + "sig", + "signature", + "token", +]) + /** * Fixed vocabulary of common error and diagnostic words. Credential words (password, * secret, token, key, auth, cookie, session, ...) are deliberately absent. Keep sorted. @@ -373,20 +408,29 @@ export const ERROR_VOCABULARY: ReadonlySet = new Set([ ]) /** - * Fixed-vocabulary template of an error's first line. Words are split on any non-letter - * character and lowercased; a word survives only if it is in ERROR_VOCABULARY, every other - * run becomes ``. The output holds only vocabulary words, `` and spaces, so user - * text, identifiers, paths, values and SQL cannot appear. + * Fixed-vocabulary template of an error's first line, with ANSI escapes removed. + * Words are split on any non-letter character and lowercased; a word survives only if + * it is in ERROR_VOCABULARY, every other run becomes ``. A credential word and the + * rest of the line become one `` too, because a credential value may itself consist + * of vocabulary words. This deliberately loses diagnostic text after ambiguous words + * such as key or session: privacy takes priority over template detail. The output holds + * only vocabulary words, `` and spaces. Raw first lines over 8192 UTF-16 code units + * become `` in full; partially normalized prefixes could cut ANSI or credential keys. */ export function templateErrorMessage(raw: string): string { - const first = raw.split(/[\r\n\u2028\u2029]/, 1)[0] + const line = Ansi.normalize(raw, "template") + if (line === undefined) return PLACEHOLDER + // Use the tokenizer's letter boundaries; spaced keys require an assignment separator. + const first = line + .replace(/(? pmSpan(sep) + "*" + pmChunks(sep) const PM_NOT_ROUTE = "(? + pieces + .map((_, j) => pieces[(i * (j + 1) + j * 7) % pieces.length]) + .join(i % 2 ? " " : "_") + .repeat(i % 5 === 0 ? 300 : 1), +) + +const hashes: Record = { + plain: "ae7e754cfb2d72a2", + csi: "ae7e754cfb2d72a2", + c1: "40678db384156848", + "osc-bel": "914eb30c45c8b094", + "osc-st": "914eb30c45c8b094", + "osc-c1-st": "bd974ba379a9b36f", + "incomplete-osc": "821db0d35dce78e7", + "incomplete-csi": "bb977ba58d3d2d21", + multiline: "7ee1b11bcf0c4db1", + "at-cap": "80b18d6fbcfa3063", + "beyond-cap": "80b18d6fbcfa3063", + "open-quote": "80b18d6fbcfa3063", + "path-cut": "80b18d6fbcfa3063", + lookahead: "bf3ce936232f73c1", + unbroken: "e3b0c44298fc1c14", +} + +describe("bounded ANSI profiles", () => { + test("shares the historical input and lookahead limits", () => { + expect(Ansi.MAX_INPUT).toBe(8192) + expect(Ansi.LOOKAHEAD).toBe(2048) + }) + + test.each([ + ["CSI", "\x1b[31mpassword\x1b[0m=wrong user"], + ["C1 CSI", "\x9b31mpassword\x9b0m=wrong user"], + ["OSC BEL", "\x1b]0;synthetic\x07password=wrong user"], + ["OSC ST", "\x1b]0;synthetic\x1b\\password=wrong user"], + ["OSC C1 ST", "\x1b]0;synthetic\x9cpassword=wrong user"], + ])("normalizes the template's %s sequences", (_, raw) => { + expect(Ansi.normalize(raw, "template")).toBe("password=wrong user") + }) + + test.each([ + ["text", "x".repeat(8193)], + ["ANSI-only text", "\x1b[0m".repeat(3000)], + ["unfinished CSI", "\x1b[" + "0;".repeat(5000)], + ["OSC closing past the cap", "\x1b]0;" + "x".repeat(8192) + "\x07"], + ["credential marker crossing the cap", " ".repeat(8190) + "password=wrong user"], + ["large unfinished OSC", "\x1b]0;" + "x".repeat(2_000_000)], + ])("rejects oversized %s before stripping", (_, raw) => { + expect(Ansi.normalize(raw, "template")).toBeUndefined() + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "preserves the original first-line boundary for %j", + (newline) => { + const first = "x".repeat(8192) + expect(Ansi.normalize(first + newline + "password=wrong user", "template")).toBe(first) + expect(Ansi.normalize(newline + "x".repeat(100000), "template")).toBe("") + }, + ) + + test("does not emit a surrogate split by the input cap", () => { + expect(Ansi.normalize(" ".repeat(8191) + "😀", "template")).toBeUndefined() + expect(Ansi.normalize(" ".repeat(8190) + "😀", "template")).toBe(" ".repeat(8190) + "😀") + }) + + test("bounds the legacy normalization window without dropping line separators", () => { + expect(Ansi.normalize("x".repeat(10240) + "wrong user", "legacy")).toBe("x".repeat(10240)) + expect(Ansi.normalize("connection\nrefused", "legacy")).toBe("connection\nrefused") + }) + + test("keeps the historical C1 hash profile explicitly separate", () => { + const raw = "request failed: \x9b31mpassword\x9b0m is connection refused" + const cleaned = Ansi.normalize(raw, "template")! + expect(cleaned).toBe("request failed: password is connection refused") + expect(ErrorDetail.describeToolError(raw).errorMessage).toBe("request failed ") + expect(ErrorDetail.describeToolError(raw).errorHash).toBe("40678db384156848") + expect(Telemetry.hashError(Telemetry.maskString(cleaned).slice(0, 500))).toBe("ae7e754cfb2d72a2") + }) +}) + +describe("pre-V4 maskString compatibility", () => { + test.each(cases)("pins the historical error hash for %s", (name, raw) => { + expect(ErrorDetail.describeToolError(raw).errorHash).toBe(hashes[name]) + }) + + test("preserves complete masked output over the frozen 96-input corpus", () => { + expect(corpus.length).toBe(96) + const digest = createHash("sha256") + .update(JSON.stringify(corpus.map((raw) => Telemetry.maskString(raw)))) + .digest("hex") + expect(digest).toBe("1fe645e8c04b5f30482bce034f9ddde64a36ac20beb475696a384edb39e1028c") + }) +}) diff --git a/packages/opencode/test/telemetry/error-detail.test.ts b/packages/opencode/test/telemetry/error-detail.test.ts index 58daf34d5c..060026f7a6 100644 --- a/packages/opencode/test/telemetry/error-detail.test.ts +++ b/packages/opencode/test/telemetry/error-detail.test.ts @@ -1,22 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Telemetry } from "../../src/altimate/telemetry" import { ErrorDetail } from "../../src/altimate/telemetry/error-detail" -const { buildErrorFingerprintEvent, describeToolError, templateErrorMessage, ERROR_VOCABULARY } = ErrorDetail - -const CREDENTIAL_WORDS = [ - "password", - "passwd", - "pwd", - "secret", - "token", - "key", - "auth", - "credential", - "bearer", - "cookie", - "session", -] +const { buildErrorFingerprintEvent, describeToolError, templateErrorMessage, ERROR_VOCABULARY, CREDENTIAL_WORDS } = + ErrorDetail describe("templateErrorMessage", () => { test.each([ @@ -28,6 +14,146 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage(raw)).toBe(expected) }) + test.each([...CREDENTIAL_WORDS])("masks vocabulary words after %s", (word) => { + expect(templateErrorMessage(`request failed with ${word}: connection refused`)).toBe("request failed with ") + }) + + test.each([ + ["password is connection refused", ""], + ["password=wrong user", ""], + ["connection failed: PaSsWoRd=wrong user", "connection failed "], + ['request failed: "password": "connection refused"', "request failed "], + ["request failed: api_key=wrong user", "request failed "], + ["request failed: access-token=connection refused", "request failed access "], + ["request failed: opaque password=wrong user", "request failed "], + ["password", ""], + ["password=1234 !!!", ""], + ])("masks the credential suffix: %s", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test("matches whole credential words rather than substrings", () => { + expect(templateErrorMessage("keychain keyboard monkey tokenizer failed connection refused")).toBe( + " failed connection refused", + ) + }) + + test.each([ + "apikey", + "apiKey", + "accessToken", + "authToken", + "refreshToken", + "bearerToken", + "jwtToken", + "jwtSecret", + "clientSecret", + "appSecret", + "signature", + "sig", + "pass", + "passwords", + "secretKey", + "sessionId", + "connectionString", + "privatekey", + "privateKey", + "PRIVATEKEY", + ])("masks the exact credential alias %s", (key) => { + expect(templateErrorMessage(`request failed: ${key}=wrong user`)).toBe("request failed ") + expect(templateErrorMessage(`${key}: connection refused`)).toBe("") + }) + + test.each([ + "request failed: connection_string=wrong user", + "request failed: CONNECTION_STRING: connection refused", + 'request failed: "connection_string": "wrong user"', + "request failed: connection-string=wrong user", + "request failed: connection_\x1b[31mstring=wrong user", + ])("masks the exact connection string key: %j", (raw) => { + expect(templateErrorMessage(raw)).toBe("request failed ") + }) + + test.each([ + ["DATABASE_CONNECTION_STRING=wrong user", "database "], + ["db_connection_string=wrong user", ""], + ["connection_string_value=wrong user", ""], + ["request failed: DB_Connection_String_Value=connection refused", "request failed "], + ["request failed: 123connection_string456=wrong user", "request failed "], + ["request failed: _connection-string_=wrong user", "request failed "], + ["request failed: éconnection_string中=wrong user", "request failed "], + ["request failed: \x1b[31mDATABASE_CONNECTION_STRING\x1b[0m=wrong user", "request failed database "], + ["request failed: db_connection_string_value=wrong user", "request failed "], + ])("uses tokenizer boundaries for connection string keys: %j", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test.each([ + ["connection string=wrong user", ""], + ["connection string:wrong user", ""], + ["connection string: invalid", ""], + ["request failed: connection string=wrong user", "request failed "], + ["request failed: Connection String : connection refused", "request failed "], + ["request failed: CONNECTION\tSTRING\t=\twrong user", "request failed "], + ["request failed: connection string = connection refused", "request failed "], + ['request failed: "connection string" = "wrong user"', "request failed "], + ["request failed: 'connection string': 'wrong user'", "request failed "], + ["request failed: `connection string` = wrong user", "request failed "], + ["request failed: \x1b[31mconnection string\x1b[0m=wrong user", "request failed "], + ["request failed: connec\x1b[31mtion str\x1b[0ming=wrong user", "request failed "], + ])("masks spaced connection string assignments: %j", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "keeps spaced key assignments on the first line: %j", + (newline) => { + expect(templateErrorMessage(`connection${newline}string=wrong user`)).toBe("connection") + expect(templateErrorMessage(`connection string${newline}=wrong user`)).toBe("connection string") + expect(templateErrorMessage(`connection string: wrong user${newline}connection refused`)).toBe("") + }, + ) + + test.each([ + ["connection string is invalid", "connection string is invalid"], + ["connection string", "connection string"], + ["connection string invalid", "connection string invalid"], + ['"connection string" is invalid', "connection string is invalid"], + ["disconnection string=wrong user", " string wrong user"], + ["connection stringify=wrong user", "connection wrong user"], + ["connection strings: invalid", "connection invalid"], + ["connection refused", "connection refused"], + ["signal received", "signal received"], + ["connection_stringify is invalid", "connection is invalid"], + ["disconnection_string is invalid", " string is invalid"], + ["connection_strings are invalid", "connection are invalid"], + ["db_disconnection_string is invalid", " string is invalid"], + ["db_connection_stringify is invalid", " connection is invalid"], + ["connection_strings_value is invalid", "connection value is invalid"], + ["privatekeyboard apikeyboard sigil signatured tokenizer failed", " failed"], + ])("keeps diagnostics without an exact credential marker: %s", (raw, expected) => { + expect(templateErrorMessage(raw)).toBe(expected) + }) + + test.each([ + "request failed: \x1b[31mpassword\x1b[0m=wrong user", + "request failed: pass\x1b[31mword=wrong user", + "request failed: api\x1b[31mKey=wrong user", + "request failed: \x1b[1;31mpassword\x1b[0m=connection refused", + "request failed: \x1b]8;;https://example.test\x07password\x1b]8;;\x07=wrong user", + "request failed: \x9b31mpassword\x9b0m=wrong user", + ])("ignores ANSI sequences when matching credentials: %j", (raw) => { + expect(templateErrorMessage(raw)).toBe("request failed ") + }) + + test("keeps diagnostic words inside ANSI styling", () => { + expect(templateErrorMessage("connection \x1b[31mrefused\x1b[0m")).toBe("connection refused") + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])("does not let an OSC sequence consume %j", (newline) => { + expect(templateErrorMessage(`first line \x1b]8;;${newline}\x07wrong user`)).toBe("first line") + }) + const adversarial = [ "password: hunter", "password: supersecret", @@ -59,7 +185,7 @@ describe("templateErrorMessage", () => { expect(tok === "" || ERROR_VOCABULARY.has(tok)).toBe(true) } expect(out).not.toContain(" ") - for (const w of CREDENTIAL_WORDS) expect(out).not.toContain(w) + for (const w of CREDENTIAL_WORDS) expect(out.split(" ")).not.toContain(w) }) test("user words never survive", () => { @@ -79,6 +205,38 @@ describe("templateErrorMessage", () => { expect(templateErrorMessage("first line\nsecond line")).toBe("first line") expect(templateErrorMessage("error ".repeat(200)).length).toBeLessThanOrEqual(200) }) + + test.each([8191, 8192, 8193])("bounds the raw first line at %i characters", (length) => { + const raw = "connection refused" + " ".repeat(length - 18) + expect(templateErrorMessage(raw)).toBe(length > 8192 ? "" : "connection refused") + }) + + test.each([ + ["diagnostic words", "error ".repeat(2000)], + ["closed CSI across the limit", "connection \x1b[" + "0;".repeat(5000) + "mpassword=wrong user"], + ["closed OSC across the limit", "connection \x1b]0;" + "x".repeat(8192) + "\x07password=wrong user"], + ["unfinished CSI", "connection \x1b[" + "0;".repeat(5000)], + ["unfinished OSC", "connection \x1b]0;" + "wrong user ".repeat(1000)], + ["credential word across the limit", " ".repeat(8190) + "privateKey=wrong user"], + ["spaced key across the limit", "connection " + " ".repeat(8180) + "connection string=wrong user"], + ["styled key across the limit", " ".repeat(8190) + "pass\x1b[31mword=wrong user"], + ])("fails closed for an oversized first line: %s", (_, raw) => { + expect(templateErrorMessage(raw)).toBe("") + }) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "keeps a complete line at the input limit before %j", + (newline) => { + const first = "connection refused" + " ".repeat(8192 - 18) + expect(templateErrorMessage(first + newline + "password=wrong user")).toBe("connection refused") + expect(templateErrorMessage("connection refused" + newline + "\x1b[31m".repeat(10000))).toBe("connection refused") + }, + ) + + test.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])("does not inspect later lines after %j", (newline) => { + expect(templateErrorMessage(`connection refused${newline}password is wrong user`)).toBe("connection refused") + expect(templateErrorMessage(`password is wrong user${newline}connection refused`)).toBe("") + }) }) describe("error_fingerprint wiring", () => { @@ -105,6 +263,25 @@ describe("error_fingerprint wiring", () => { expect("stack" in ev).toBe(false) }) + test.each([ + "request failed: password is connection refused", + "request failed: \x1b[31mpassword\x1b[0m is connection refused", + ])("credential suffix is hidden in the event without changing the hash: %j", (raw) => { + const detail = describeToolError(raw) + const event = buildErrorFingerprintEvent("s1", { + ...detail, + errorClass: "unknown", + toolName: "bash", + toolCategory: "shell", + recovered: false, + recoveryTool: "", + }) + expect(event).toMatchObject({ + error_message: "request failed ", + error_hash: "ae7e754cfb2d72a2", + }) + }) + test("prompt.ts emits through these helpers", async () => { const src = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url)).text() expect(src).toContain("...ErrorDetail.describeToolError(errorMsg)")