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;
}