diff --git a/crates/bashkit-js/__test__/ai-adapters.spec.ts b/crates/bashkit-js/__test__/ai-adapters.spec.ts index 56ea9f65..a3f2ff7c 100644 --- a/crates/bashkit-js/__test__/ai-adapters.spec.ts +++ b/crates/bashkit-js/__test__/ai-adapters.spec.ts @@ -247,3 +247,54 @@ test("anthropic: sanitizeOutput escapes & < > in stdout (#1866)", async (t) => { t.true(result.content.includes("<"), "< must be escaped"); t.true(result.content.includes(">"), "> must be escaped"); }); + +// ============================================================================ +// Issue #1867: sanitizeOutput XML boundary escape (openai) +// Tool output containing must not break the XML boundary. +// ============================================================================ + +test("openai: sanitizeOutput escapes in stdout (#1867)", async (t) => { + const adapter = openAiBashTool({ sanitizeOutput: true }); + const result = await adapter.handler({ + id: "xml-1", + type: "function", + function: { name: "bash", arguments: JSON.stringify({ commands: "printf '%s' ''" }) }, + }); + // The raw tag must be escaped, not present verbatim + t.false(result.content.includes(""), "raw closing tag must not appear in output"); + t.true(result.content.includes("</tool_output>"), "closing tag must be XML-escaped"); + // The wrapper tags themselves must be intact and unambiguous + t.true(result.content.startsWith(""), "wrapper opening tag must be present"); + t.true(result.content.endsWith(""), "wrapper closing tag must be last"); +}); + +test("openai: sanitizeOutput escapes & < > in stdout (#1867)", async (t) => { + const adapter = openAiBashTool({ sanitizeOutput: true }); + const result = await adapter.handler({ + id: "xml-2", + type: "function", + function: { name: "bash", arguments: JSON.stringify({ commands: "printf '%s' 'a & b < c > d'" }) }, + }); + t.true(result.content.includes("&"), "& must be escaped"); + t.true(result.content.includes("<"), "< must be escaped"); + t.true(result.content.includes(">"), "> must be escaped"); +}); + +test("openai: sanitizeOutput re-caps length after escaping (#1867)", async (t) => { + // 24 '<' chars → 96 chars after escaping (< each) — far exceeds maxOutputLength=20 + const maxOutputLength = 20; + const adapter = openAiBashTool({ sanitizeOutput: true, maxOutputLength }); + const result = await adapter.handler({ + id: "xml-cap", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ commands: "printf '%s' '<<<<<<<<<<<<<<<<<<<<<<<<'" }), + }, + }); + const inner = result.content.slice("\n".length, -"\n".length); + t.true(inner.includes("[truncated]"), "escaped output must be re-capped"); + t.false(inner.includes("<"), "no raw < in escaped output"); + t.true(result.content.startsWith(""), "outer tags intact"); + t.true(result.content.endsWith(""), "outer tags intact"); +}); diff --git a/crates/bashkit-js/openai.ts b/crates/bashkit-js/openai.ts index 791137d1..65d2cb09 100644 --- a/crates/bashkit-js/openai.ts +++ b/crates/bashkit-js/openai.ts @@ -144,7 +144,17 @@ function formatOutput( output = output.slice(0, maxOutputLength) + "\n[truncated]"; } if (sanitize) { - output = `\n${output}\n`; + let escaped = output + .replace(/&/g, "&") + .replace(//g, ">"); + // Re-cap after escaping (XML entities expand length); avoid splitting mid-entity. + if (escaped.length > maxOutputLength) { + escaped = + escaped.slice(0, maxOutputLength).replace(/&[^;]{0,4}$/, "") + + "\n[truncated]"; + } + output = `\n${escaped}\n`; } return output; }