Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions crates/bashkit-js/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ export interface BashToolOptions extends Omit<BashOptions, "files"> {
* stops execution when the framework cancels a tool call.
*/
timeoutMs?: number;
/**
* Maximum output length in characters (default: 100000).
*
* Output exceeding this limit is truncated with a `[truncated]` marker.
* Prevents context window flooding when scripts produce large output.
*/
maxOutputLength?: number;
/**
* Wrap tool output in XML boundary markers (default: false).
*
* When enabled, output is wrapped in `<tool_output>...</tool_output>` tags
* to help LLMs distinguish tool output data from instructions, reducing
* prompt injection risk via tool output.
*
* **Security note:** This is a defense-in-depth measure. Tool output from
* untrusted sources (files, network) may contain text that attempts to
* manipulate LLM behavior. Boundary markers help but do not eliminate this risk.
*/
sanitizeOutput?: boolean;
}

/** Anthropic tool definition (matches the `tools` array in messages.create). */
Expand Down Expand Up @@ -107,15 +126,28 @@ export interface BashToolAdapter {
bash: BashTool;
}

function formatOutput(result: ExecResult): string {
const DEFAULT_MAX_OUTPUT_LENGTH = 100_000;

function formatOutput(
result: ExecResult,
maxOutputLength: number = DEFAULT_MAX_OUTPUT_LENGTH,
sanitize: boolean = false,
): string {
let output = result.stdout;
if (result.stderr) {
output += (output ? "\n" : "") + `STDERR: ${result.stderr}`;
}
if (result.exitCode !== 0) {
output += (output ? "\n" : "") + `[Exit code: ${result.exitCode}]`;
}
return output || "(no output)";
output = output || "(no output)";
if (output.length > maxOutputLength) {
output = output.slice(0, maxOutputLength) + "\n[truncated]";
}
if (sanitize) {
output = `<tool_output>\n${output}\n</tool_output>`;
}
return output;
}

/**
Expand Down Expand Up @@ -144,7 +176,8 @@ function formatOutput(result: ExecResult): string {
* ```
*/
export function bashTool(options?: BashToolOptions): BashToolAdapter {
const { files, ...bashOptions } = options ?? {};
const { files, maxOutputLength, sanitizeOutput, ...bashOptions } =
options ?? {};

const bash = new BashTool(bashOptions);

Expand Down Expand Up @@ -212,7 +245,7 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
return {
type: "tool_result",
tool_use_id: toolUse.id,
content: formatOutput(result),
content: formatOutput(result, maxOutputLength, sanitizeOutput),
is_error: result.exitCode !== 0,
};
} catch (err) {
Expand Down
37 changes: 33 additions & 4 deletions crates/bashkit-js/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ export interface BashToolOptions extends Omit<BashOptions, "files"> {
* stops execution when the framework cancels a tool call.
*/
timeoutMs?: number;
/**
* Maximum output length in characters (default: 100000).
*
* Output exceeding this limit is truncated with a `[truncated]` marker.
* Prevents context window flooding when scripts produce large output.
*/
maxOutputLength?: number;
/**
* Wrap tool output in XML boundary markers (default: false).
*
* When enabled, output is wrapped in `<tool_output>...</tool_output>` tags
* to help LLMs distinguish tool output data from instructions, reducing
* prompt injection risk via tool output.
*/
sanitizeOutput?: boolean;
}

/** OpenAI function tool definition (matches the `tools` array in chat.completions.create). */
Expand Down Expand Up @@ -110,15 +125,28 @@ export interface BashToolAdapter {
bash: BashTool;
}

function formatOutput(result: ExecResult): string {
const DEFAULT_MAX_OUTPUT_LENGTH = 100_000;

function formatOutput(
result: ExecResult,
maxOutputLength: number = DEFAULT_MAX_OUTPUT_LENGTH,
sanitize: boolean = false,
): string {
let output = result.stdout;
if (result.stderr) {
output += (output ? "\n" : "") + `STDERR: ${result.stderr}`;
}
if (result.exitCode !== 0) {
output += (output ? "\n" : "") + `[Exit code: ${result.exitCode}]`;
}
return output || "(no output)";
output = output || "(no output)";
if (output.length > maxOutputLength) {
output = output.slice(0, maxOutputLength) + "\n[truncated]";
}
if (sanitize) {
output = `<tool_output>\n${output}\n</tool_output>`;
}
return output;
}

/**
Expand Down Expand Up @@ -148,7 +176,8 @@ function formatOutput(result: ExecResult): string {
* ```
*/
export function bashTool(options?: BashToolOptions): BashToolAdapter {
const { files, ...bashOptions } = options ?? {};
const { files, maxOutputLength, sanitizeOutput, ...bashOptions } =
options ?? {};

const bash = new BashTool(bashOptions);

Expand Down Expand Up @@ -227,7 +256,7 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
return {
role: "tool",
tool_call_id: toolCall.id,
content: formatOutput(result),
content: formatOutput(result, maxOutputLength, sanitizeOutput),
};
} catch (err) {
return {
Expand Down
7 changes: 5 additions & 2 deletions crates/bashkit-python/bashkit/deepagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def _build_write_cmd(file_path: str, content: str) -> str:
return f"cat > {shlex.quote(file_path)} << '{delimiter}'\n{content}\n{delimiter}"


def _make_bash_tool(bash_instance: NativeBashTool):
def _make_bash_tool(bash_instance: NativeBashTool, max_output_length: int = 100_000):
"""Create a bash tool function from a BashTool instance."""
# Use name and description from bashkit lib
tool_name = bash_instance.name
Expand All @@ -77,7 +77,10 @@ def bashkit(command: str) -> str:
output += f"\n{result.stderr}"
if result.exit_code != 0:
output += f"\n[Exit code: {result.exit_code}]"
return output.strip() if output else "[No output]"
output = output.strip() if output else "[No output]"
if len(output) > max_output_length:
output = output[:max_output_length] + "\n[truncated]"
return output

return bashkit

Expand Down
30 changes: 16 additions & 14 deletions crates/bashkit-python/bashkit/langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ class BashkitTool(BaseTool):

_bash_tool: NativeBashTool = PrivateAttr()

_max_output_length: int = PrivateAttr(default=100_000)

def __init__(
self,
username: str | None = None,
hostname: str | None = None,
max_commands: int | None = None,
max_loop_iterations: int | None = None,
timeout_seconds: float | None = None,
max_output_length: int = 100_000,
**kwargs,
):
bash_tool = NativeBashTool(
Expand All @@ -86,6 +89,17 @@ def __init__(
kwargs["description"] = bash_tool.description()
super().__init__(**kwargs)
object.__setattr__(self, "_bash_tool", bash_tool)
object.__setattr__(self, "_max_output_length", max_output_length)

def _format_output(self, result) -> str:
output = result.stdout
if result.stderr:
output += f"\nSTDERR: {result.stderr}"
if result.exit_code != 0:
output += f"\n[Exit code: {result.exit_code}]"
if len(output) > self._max_output_length:
output = output[: self._max_output_length] + "\n[truncated]"
return output

def _run(self, commands: str) -> str:
"""Execute bash commands synchronously."""
Expand All @@ -94,13 +108,7 @@ def _run(self, commands: str) -> str:
if result.error:
raise ToolException(f"Execution error: {result.error}")

output = result.stdout
if result.stderr:
output += f"\nSTDERR: {result.stderr}"
if result.exit_code != 0:
output += f"\n[Exit code: {result.exit_code}]"

return output
return self._format_output(result)

async def _arun(self, commands: str) -> str:
"""Execute bash commands asynchronously."""
Expand All @@ -109,13 +117,7 @@ async def _arun(self, commands: str) -> str:
if result.error:
raise ToolException(f"Execution error: {result.error}")

output = result.stdout
if result.stderr:
output += f"\nSTDERR: {result.stderr}"
if result.exit_code != 0:
output += f"\n[Exit code: {result.exit_code}]"

return output
return self._format_output(result)

class ScriptedToolLangChain(BaseTool):
"""LangChain tool wrapper for Bashkit ScriptedTool.
Expand Down
6 changes: 5 additions & 1 deletion crates/bashkit-python/bashkit/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def create_bash_tool(
max_commands: int | None = None,
max_loop_iterations: int | None = None,
timeout_seconds: float | None = None,
max_output_length: int = 100_000,
) -> Tool:
"""Create a PydanticAI Tool wrapping Bashkit.

Expand Down Expand Up @@ -84,7 +85,10 @@ async def bash(commands: str) -> str:
if result.exit_code != 0:
output += f"\n[Exit code: {result.exit_code}]"

return output if output else "[No output]"
output = output if output else "[No output]"
if len(output) > max_output_length:
output = output[:max_output_length] + "\n[truncated]"
return output

return Tool(bash, takes_ctx=False, name="bash")

Expand Down
Loading