Skip to content
Open
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
24 changes: 24 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,30 @@
"type": "boolean",
"description": "When true, the runtime auto-installs the redact_secrets builtin on all three of pre_tool_use (scrubs detected secrets from tool arguments), before_llm_call (scrubs the messages sent to the LLM), and tool_response_transform (scrubs tool output before it reaches event consumers, the persisted session, the post_tool_use hook input, or the next LLM call). The same hook entries can be authored directly in YAML for finer-grained control — see the hooks.tool_response_transform / hooks.before_llm_call / hooks.pre_tool_use sections. Detection uses the docker-agent secretsscan ruleset (GitHub PATs, AWS keys, Stripe / Slack / GitLab tokens, JWTs, private keys, etc.). Each detected span is replaced with the literal '[REDACTED]'."
},
"handle_large_tool_output": {
"type": "object",
"description": "When enabled, tool responses exceeding the threshold are saved to disk and replaced with a pointer that the agent can read back using shell tools (e.g. cat). This prevents large MCP tool responses from exhausting the model's context window while still allowing the agent to access the full data.",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether to enable large tool output handling. Default: false."
},
"threshold": {
"type": "integer",
"description": "Character count threshold above which tool output is saved to disk. Default: 5000.",
"minimum": 0
},
"output_dir": {
"type": "string",
"description": "Directory where large outputs are saved. Default: system temp directory."
},
"preview_size": {
"type": "integer",
"description": "Number of characters to include in the preview shown to the agent. Default: 3000.",
"minimum": 0
}
}
},
"max_iterations": {
"type": "integer",
"description": "Maximum number of iterations",
Expand Down
7 changes: 7 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Agent struct {
commands types.Commands
hooks *latest.HooksConfig
cache *cache.Cache
handleLargeToolOutput *latest.HandleLargeToolOutputConfig

// warningsMu guards pendingWarnings. AddToolWarning and DrainWarnings
// may be called concurrently from the runtime loop, the MCP server,
Expand Down Expand Up @@ -95,6 +96,12 @@ func (a *Agent) RedactSecrets() bool {
return a.redactSecrets
}

// HandleLargeToolOutput returns the configuration for large tool output
// handling, or nil if not configured.
func (a *Agent) HandleLargeToolOutput() *latest.HandleLargeToolOutputConfig {
return a.handleLargeToolOutput
}

func (a *Agent) MaxIterations() int {
return a.maxIterations
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/agent/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ func WithRedactSecrets(redactSecrets bool) Opt {
}
}

// WithHandleLargeToolOutput configures automatic handling of large tool
// responses. When enabled, tool responses exceeding the threshold are saved
// to disk and replaced with a pointer that the agent can read back using shell
// tools.
func WithHandleLargeToolOutput(cfg *latest.HandleLargeToolOutputConfig) Opt {
return func(a *Agent) {
a.handleLargeToolOutput = cfg
}
}

func WithAddDescriptionParameter(addDescriptionParameter bool) Opt {
return func(a *Agent) {
a.addDescriptionParameter = addDescriptionParameter
Expand Down
46 changes: 33 additions & 13 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,19 +391,39 @@ type AgentConfig struct {
// hook entries by hand — the runtime auto-injects them when this
// flag is true. See pkg/hooks/builtins/redact_secrets.go for the
// hook-side implementation.
RedactSecrets bool `json:"redact_secrets,omitempty"`
CodeModeTools bool `json:"code_mode_tools,omitempty"`
AddDescriptionParameter bool `json:"add_description_parameter,omitempty"`
MaxIterations int `json:"max_iterations,omitempty"`
MaxConsecutiveToolCalls int `json:"max_consecutive_tool_calls,omitempty"`
MaxOldToolCallTokens int `json:"max_old_tool_call_tokens,omitempty"`
NumHistoryItems int `json:"num_history_items,omitempty"`
AddPromptFiles []string `json:"add_prompt_files,omitempty" yaml:"add_prompt_files,omitempty"`
Commands types.Commands `json:"commands,omitempty"`
StructuredOutput *StructuredOutput `json:"structured_output,omitempty"`
Skills SkillsConfig `json:"skills,omitzero"`
Hooks *HooksConfig `json:"hooks,omitempty"`
Cache *CacheConfig `json:"cache,omitempty"`
RedactSecrets bool `json:"redact_secrets,omitempty"`
CodeModeTools bool `json:"code_mode_tools,omitempty"`
AddDescriptionParameter bool `json:"add_description_parameter,omitempty"`
MaxIterations int `json:"max_iterations,omitempty"`
MaxConsecutiveToolCalls int `json:"max_consecutive_tool_calls,omitempty"`
MaxOldToolCallTokens int `json:"max_old_tool_call_tokens,omitempty"`
NumHistoryItems int `json:"num_history_items,omitempty"`
AddPromptFiles []string `json:"add_prompt_files,omitempty" yaml:"add_prompt_files,omitempty"`
Commands types.Commands `json:"commands,omitempty"`
StructuredOutput *StructuredOutput `json:"structured_output,omitempty"`
Skills SkillsConfig `json:"skills,omitzero"`
Hooks *HooksConfig `json:"hooks,omitempty"`
Cache *CacheConfig `json:"cache,omitempty"`
HandleLargeToolOutput *HandleLargeToolOutputConfig `json:"handle_large_tool_output,omitempty"`
}

// HandleLargeToolOutputConfig configures automatic handling of large tool
// responses. When enabled, tool responses exceeding the threshold are saved
// to disk and replaced with a pointer that the agent can read back using shell
// tools. This prevents large MCP tool responses from exhausting the model's
// context window while still allowing the agent to access the full data.
type HandleLargeToolOutputConfig struct {
// Enabled controls whether large tool output handling is active.
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
// Threshold is the character count above which tool output is saved
// to disk. Default: 5000.
Threshold int `json:"threshold,omitempty" yaml:"threshold,omitempty"`
// OutputDir is the directory where large outputs are saved. Default:
// system temp directory.
OutputDir string `json:"output_dir,omitempty" yaml:"output_dir,omitempty"`
// PreviewSize is the number of characters to include in the preview
// shown to the agent. Default: 3000.
PreviewSize int `json:"preview_size,omitempty" yaml:"preview_size,omitempty"`
}

// CacheConfig configures the agent's response cache. When set and Enabled
Expand Down
13 changes: 13 additions & 0 deletions pkg/hooks/builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@
package builtins

import (
"encoding/json"
"errors"

"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/hooks"
)

Expand All @@ -71,6 +73,7 @@ func Register(r *hooks.Registry) error {
r.RegisterBuiltin(AddRecentCommits, addRecentCommits),
r.RegisterBuiltin(MaxIterations, maxIterations),
r.RegisterBuiltin(RedactSecrets, redactSecrets),
r.RegisterBuiltin(HandleLargeToolOutput, handleLargeToolOutput),
r.RegisterBuiltin(HTTPPost, httpPost),
)
}
Expand All @@ -88,6 +91,9 @@ type AgentDefaults struct {
// makes the auto-injection idempotent against an explicit YAML
// entry that already names the same builtin.
RedactSecrets bool
// HandleLargeToolOutput auto-injects the handle_large_tool_output
// builtin under tool_response_transform when configured.
HandleLargeToolOutput *latest.HandleLargeToolOutputConfig
}

// AutoInjector adds default hooks to an agent's hook configuration.
Expand Down Expand Up @@ -142,6 +148,13 @@ func ApplyAgentDefaults(cfg *hooks.Config, d AgentDefaults) *hooks.Config {
Hooks: []hooks.Hook{builtinHook(RedactSecrets)},
})
}
if d.HandleLargeToolOutput != nil && d.HandleLargeToolOutput.Enabled {
cfgBytes, _ := json.Marshal(d.HandleLargeToolOutput)
cfg.ToolResponseTransform = append(cfg.ToolResponseTransform, hooks.MatcherConfig{
Matcher: "*",
Hooks: []hooks.Hook{builtinHook(HandleLargeToolOutput, string(cfgBytes))},
})
}
if cfg.IsEmpty() {
return nil
}
Expand Down
101 changes: 101 additions & 0 deletions pkg/hooks/builtins/handle_large_tool_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package builtins

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/docker/docker-agent/pkg/hooks"
)

const HandleLargeToolOutput = "handle_large_tool_output"

func handleLargeToolOutput(ctx context.Context, in *hooks.Input, args []string) (*hooks.Output, error) {
if in == nil {
return nil, nil
}

if in.HookEventName != hooks.EventToolResponseTransform {
return nil, nil
}

response, ok := in.ToolResponse.(string)
if !ok || response == "" {
return nil, nil
}

cfg := parseArgs(args)
threshold := cfg.Threshold
if threshold == 0 {
threshold = 30000
}

if len(response) <= threshold {
return nil, nil
}

outputDir := cfg.OutputDir
if outputDir == "" {
outputDir = os.TempDir()
}

if err := os.MkdirAll(outputDir, 0o750); err != nil {
return nil, fmt.Errorf("create output directory: %w", err)
}

filename := fmt.Sprintf("%s_%s.txt",
sanitizeFilename(in.SessionID),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Filename collision risk when SessionID or ToolUseID is empty

The output filename is built as:

filename := fmt.Sprintf("%s_%s.txt",
    sanitizeFilename(in.SessionID),
    sanitizeFilename(in.ToolUseID))

If either SessionID or ToolUseID is empty (e.g. sanitizeFilename("") == ""), multiple tool calls can produce the same filename (e.g., _.txt or _<toolUseID>.txt). Each subsequent os.WriteFile call silently overwrites the previous content. When the agent later reads the file, it gets the last response only — the earlier ones are lost without any error.

Fix: Validate that both IDs are non-empty before constructing the filename, or incorporate a timestamp/random component to guarantee uniqueness:

if in.SessionID == "" || in.ToolUseID == "" {
    return nil, fmt.Errorf("handle_large_tool_output: missing session or tool-use ID")
}

sanitizeFilename(in.ToolUseID))
path := filepath.Join(outputDir, filename)

if err := os.WriteFile(path, []byte(response), 0o600); err != nil {
return nil, fmt.Errorf("write output file: %w", err)
}

previewSize := cfg.PreviewSize
if previewSize == 0 {
previewSize = 3000
}
preview := response
if len(preview) > previewSize {
preview = response[:previewSize]
}

pointer := fmt.Sprintf("[%s response: %d chars, full output saved to %s]\n\nFirst %d chars:\n%s\n\n[Use shell tool to read: cat %s]",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Shell metacharacters in SessionID/ToolUseID flow into the suggested cat command

The pointer message shown to the agent includes:

pointer := fmt.Sprintf("...[Use shell tool to read: cat %s]", path)

sanitizeFilename only strips .., /, and \ — it does not strip spaces, $, backticks, ;, |, &, or ()). If a SessionID or ToolUseID contains these characters, the generated cat <path> string will embed them literally. When the agent's shell tool executes the suggested command, those metacharacters are interpreted by the shell, potentially causing unintended execution.

Fix: Either quote the path in the suggested command, or expand sanitizeFilename to allowlist only safe characters:

// Option A: shell-quote the path in the message
pointer := fmt.Sprintf("...[Use shell tool to read: cat '%s']", path)

// Option B: allowlist in sanitizeFilename
func sanitizeFilename(name string) string {
    var b strings.Builder
    for _, r := range name {
        if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
           (r >= '0' && r <= '9') || r == '-' || r == '.' {
            b.WriteRune(r)
        } else {
            b.WriteRune('_')
        }
    }
    return b.String()
}

in.ToolName, len(response), path, previewSize, preview, path)

return &hooks.Output{
HookSpecificOutput: &hooks.HookSpecificOutput{
HookEventName: hooks.EventToolResponseTransform,
UpdatedToolResponse: &pointer,
},
}, nil
}

type toolOutputConfig struct {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] output_dir and preview_size config values are silently ignored due to JSON key mismatch

HandleLargeToolOutputConfig (in pkg/config/latest/types.go) uses snake_case JSON tags:

Threshold   int    `json:"threshold,omitempty"`
OutputDir   string `json:"output_dir,omitempty"`
PreviewSize int    `json:"preview_size,omitempty"`

But toolOutputConfig (used to unmarshal the config at runtime) has no JSON tags, so Go uses the PascalCase field names as keys ("Threshold", "OutputDir", "PreviewSize"):

type toolOutputConfig struct {
    Threshold   int
    OutputDir   string
    PreviewSize int
}

In ApplyAgentDefaults, the HandleLargeToolOutputConfig is marshalled to JSON and passed as a string arg — producing e.g. {"threshold":5000,"output_dir":"/my/dir","preview_size":2000}. When parseArgs unmarshals this into toolOutputConfig:

  • "threshold" → matches Threshold ✅ (Go's case-insensitive match works here)
  • "output_dir"does NOT match OutputDir ❌ (underscore vs no-underscore; case-insensitivity can't bridge this)
  • "preview_size"does NOT match PreviewSize ❌ (same reason)

Result: Any output_dir or preview_size a user configures is silently dropped, and the hook always uses the hardcoded defaults (os.TempDir() and 3000 chars).

Fix: Add json struct tags to toolOutputConfig matching those on HandleLargeToolOutputConfig:

type toolOutputConfig struct {
    Threshold   int    `json:"threshold"`
    OutputDir   string `json:"output_dir"`
    PreviewSize int    `json:"preview_size"`
}

Threshold int
OutputDir string
PreviewSize int
}

func parseArgs(args []string) toolOutputConfig {
if len(args) == 0 {
return toolOutputConfig{}
}

var cfg toolOutputConfig
if err := json.Unmarshal([]byte(args[0]), &cfg); err != nil {
return toolOutputConfig{}
}
return cfg
}

func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "..", "__")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
return name
}
Loading
Loading