diff --git a/src/auto-reply/usage-bar/translator.test.ts b/src/auto-reply/usage-bar/translator.test.ts index 42d0b72fcf4e..27b84f1d1224 100644 --- a/src/auto-reply/usage-bar/translator.test.ts +++ b/src/auto-reply/usage-bar/translator.test.ts @@ -65,6 +65,17 @@ describe("usage-bar verbs", () => { expect(render([{ text: "{m|alias:models}" }], { m: "some-new-model" })).toBe("some-new-model"); }); + it("alias — prototype keys (toString, constructor) do not match inherited properties", () => { + // When a model is named "toString" or "constructor", the `in` operator + // would match Object.prototype inherited properties and return + // Object.prototype.toString (a function) instead of the raw key. + // After the fix (Object.hasOwn), these should echo through unchanged. + expect(render([{ text: "{m|alias:models}" }], { m: "toString" })).toBe("toString"); + expect(render([{ text: "{m|alias:models}" }], { m: "constructor" })).toBe("constructor"); + expect(render([{ text: "{m|alias:models}" }], { m: "valueOf" })).toBe("valueOf"); + expect(render([{ text: "{m|alias:models}" }], { m: "__proto__" })).toBe("__proto__"); + }); + it("fallback when path is missing/empty", () => { expect(render([{ text: "{identity.emoji|🤖} hi" }], {})).toBe("🤖 hi"); expect(render([{ text: "{identity.emoji|🤖} hi" }], { identity: { emoji: "🩺" } })).toBe( @@ -87,6 +98,18 @@ describe("usage-bar segment forms", () => { expect(render(seg, { state: {} })).toBe(""); }); + it("map — prototype keys (toString, constructor) do not match inherited properties", () => { + // When the map key is "toString" or "constructor", the `in` operator + // would incorrectly match Object.prototype inherited properties and + // return undefined (Object.prototype.toString is a function, not a + // string case value) instead of falling through to _default. + const seg = [ + { map: "state.mode", cases: { toString: "should-not-match", _default: "fallback" } }, + ]; + expect(render(seg, { state: { mode: "toString" } })).toBe("should-not-match"); + expect(render(seg, { state: { mode: "constructor" } })).toBe("fallback"); + }); + it("each with item_scales picks a scale per window by position", () => { const seg = [ { diff --git a/src/auto-reply/usage-bar/translator.ts b/src/auto-reply/usage-bar/translator.ts index 2802e2187dc0..3846d75c96bf 100644 --- a/src/auto-reply/usage-bar/translator.ts +++ b/src/auto-reply/usage-bar/translator.ts @@ -130,11 +130,11 @@ function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): const table = args[0] && isObject(aliases[args[0]]) ? (aliases[args[0]] as Record) : {}; const key = String(value); - if (key in table) { + if (Object.hasOwn(table, key)) { return table[key]; } const lower = key.toLowerCase(); - return lower in table ? table[lower] : value; + return Object.hasOwn(table, lower) ? table[lower] : value; } case "meter": { const width = args[0] ? Number.parseInt(args[0], 10) || 5 : 5; @@ -200,7 +200,7 @@ function renderSegment(seg: Segment, ctx: unknown, vocab: Vocab): string | null const v = getPath(ctx, String(seg.map)); const key = typeof v === "boolean" ? String(v) : String(v); const cases = isObject(seg.cases) ? seg.cases : {}; - const hit = key in cases ? cases[key] : cases["_default"]; + const hit = Object.hasOwn(cases, key) ? cases[key] : cases["_default"]; return typeof hit === "string" ? hit : null; } if ("each" in seg) {