mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
feat: add readonly attribute for toolsets and agents
This commit is contained in:
8 files changed
+212
No files matched your search
@@ -586,6 +586,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether to add environment information"
|
||||
},
|
||||
"readonly": {
|
||||
"type": "boolean",
|
||||
"description": "When true, makes every one of this agent's toolsets read-only: only tools whose annotations carry a read-only hint are listed and callable, and all other (mutating) tools are filtered out. Equivalent to setting 'readonly: true' on each toolset. A sub-agent or handoff agent is made read-only by setting this flag on that agent's own definition."
|
||||
},
|
||||
"redact_secrets": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
@@ -1852,6 +1856,10 @@
|
||||
"type": "string",
|
||||
"description": "A comma-delimited list of regular expressions of tools to toonify"
|
||||
},
|
||||
"readonly": {
|
||||
"type": "boolean",
|
||||
"description": "When true, restricts this toolset to tools whose annotations carry a read-only hint. Every other (mutating) tool is filtered out, so the agent can only list and call the non-mutating subset of the toolset."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model to use for the LLM turn that processes tool results from this toolset. Enables per-tool model routing: cheaper/faster models handle simple tool results (e.g. knowledge-base lookups, file reads) while the agent's primary model handles complex reasoning. Value can be a model name from the models section or an inline provider/model format (e.g. 'openai/gpt-4o-mini')."
|
||||
|
||||
@@ -215,6 +215,7 @@ remote MCP endpoints.
|
||||
| [`redact_secrets_hooks.yaml`](redact_secrets_hooks.yaml) | The same scrubbing wired manually as three hooks. |
|
||||
| [`sandbox_agent.yaml`](sandbox_agent.yaml) | Running tools inside a Docker sandbox via `docker agent run --sandbox`. |
|
||||
| [`filesystem_allow_deny.yaml`](filesystem_allow_deny.yaml) | Path-level allow/deny for the filesystem toolset. |
|
||||
| [`readonly.yaml`](readonly.yaml) | `readonly: true` on a toolset or agent keeps only read-only tools (mutating tools are filtered out). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Demonstrates the `readonly` attribute on toolsets and agents.
|
||||
#
|
||||
# Every tool carries a read-only hint in its annotations: tools that only
|
||||
# observe state (list files, read a file, search) are marked read-only, while
|
||||
# tools that mutate state (write a file, run a shell command) are not.
|
||||
#
|
||||
# Setting `readonly: true` on a toolset keeps only the tools whose hint says
|
||||
# they are read-only; every other tool is filtered out so it can neither be
|
||||
# listed nor called. Setting `readonly: true` on an agent applies that filter
|
||||
# to all of its toolsets at once.
|
||||
#
|
||||
# A sub-agent or handoff agent is made read-only the same way: set the flag on
|
||||
# that agent's own definition and it stays read-only wherever it is referenced.
|
||||
#
|
||||
# Switch agents with `-a <name>` to try each variant.
|
||||
|
||||
agents:
|
||||
# Agent-level readonly: every toolset is restricted to its read-only tools.
|
||||
root:
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
description: A read-only agent that can inspect but never modify the project.
|
||||
instruction: |
|
||||
You can explore the filesystem and run read-only shell commands to
|
||||
understand the project, but you cannot modify anything. If asked to make
|
||||
a change, explain what you would do and hand off to the `editor` agent.
|
||||
readonly: true
|
||||
toolsets:
|
||||
- type: filesystem
|
||||
- type: shell
|
||||
handoffs:
|
||||
- editor
|
||||
|
||||
# Toolset-level readonly: only the filesystem toolset is restricted; the
|
||||
# shell toolset keeps all of its tools.
|
||||
inspector:
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
description: An agent whose filesystem access is read-only but can still run shell commands.
|
||||
instruction: |
|
||||
Use the filesystem tools to read and search files (you cannot write
|
||||
them), and the shell to run any command you need.
|
||||
toolsets:
|
||||
- type: filesystem
|
||||
readonly: true
|
||||
- type: shell
|
||||
|
||||
# A regular read-write agent used as a read-only handoff target would stay
|
||||
# read-write; mark the agent itself readonly to constrain it everywhere.
|
||||
editor:
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
description: An agent that can modify files.
|
||||
instruction: |
|
||||
You can read and write files. Make the requested changes carefully.
|
||||
toolsets:
|
||||
- type: filesystem
|
||||
@@ -441,6 +441,10 @@ type AgentConfig struct {
|
||||
|
||||
AddDate bool `json:"add_date,omitempty"`
|
||||
AddEnvironmentInfo bool `json:"add_environment_info,omitempty"`
|
||||
// ReadOnly makes every one of the agent's toolsets read-only: only
|
||||
// tools whose annotations carry a read-only hint are listed and
|
||||
// callable. Equivalent to setting `readonly: true` on each toolset.
|
||||
ReadOnly bool `json:"readonly,omitempty" yaml:"readonly,omitempty"`
|
||||
// RedactSecrets enables every leg of the redact_secrets feature:
|
||||
// the pre_tool_use builtin (scrubs tool arguments), the
|
||||
// before_llm_call hook (scrubs outgoing chat content), and the
|
||||
@@ -998,6 +1002,11 @@ type Toolset struct {
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
Toon string `json:"toon,omitempty"`
|
||||
|
||||
// ReadOnly restricts the toolset to tools whose annotations carry a
|
||||
// read-only hint. Every other tool is filtered out, so the agent can
|
||||
// list and call only the non-mutating subset of the toolset.
|
||||
ReadOnly bool `json:"readonly,omitempty" yaml:"readonly,omitempty"`
|
||||
|
||||
// Model overrides the LLM used for the turn that processes tool results
|
||||
// from this toolset, enabling per-toolset model routing. Value can be a
|
||||
// model name from the models section or "provider/model" (e.g. "openai/gpt-4o-mini").
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package teamloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
// WithReadOnlyFilter wraps a toolset so it only lists and exposes tools whose
|
||||
// annotations carry a read-only hint. Every other tool is filtered out, so the
|
||||
// agent can never call a mutating tool from this toolset. When readOnly is
|
||||
// false the inner toolset is returned unchanged.
|
||||
func WithReadOnlyFilter(inner tools.ToolSet, readOnly bool) tools.ToolSet {
|
||||
if !readOnly {
|
||||
return inner
|
||||
}
|
||||
|
||||
return &readOnlyTools{ToolSet: inner}
|
||||
}
|
||||
|
||||
type readOnlyTools struct {
|
||||
tools.ToolSet
|
||||
}
|
||||
|
||||
// Verify interface compliance
|
||||
var (
|
||||
_ tools.Instructable = (*readOnlyTools)(nil)
|
||||
_ tools.Unwrapper = (*readOnlyTools)(nil)
|
||||
)
|
||||
|
||||
// Unwrap implements tools.Unwrapper.
|
||||
func (f *readOnlyTools) Unwrap() tools.ToolSet {
|
||||
return f.ToolSet
|
||||
}
|
||||
|
||||
// Instructions implements tools.Instructable by delegating to the inner toolset.
|
||||
func (f *readOnlyTools) Instructions() string {
|
||||
return tools.GetInstructions(f.ToolSet)
|
||||
}
|
||||
|
||||
func (f *readOnlyTools) Tools(ctx context.Context) ([]tools.Tool, error) {
|
||||
allTools, err := f.ToolSet.Tools(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tools.FilterReadOnly(allTools), nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package teamloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
func readOnlyTool(name string, readOnly bool) tools.Tool {
|
||||
return tools.Tool{
|
||||
Name: name,
|
||||
Annotations: tools.ToolAnnotations{ReadOnlyHint: readOnly},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithReadOnlyFilter_Disabled(t *testing.T) {
|
||||
inner := &mockToolSet{}
|
||||
|
||||
wrapped := WithReadOnlyFilter(inner, false)
|
||||
|
||||
assert.Same(t, inner, wrapped)
|
||||
}
|
||||
|
||||
func TestWithReadOnlyFilter_KeepsOnlyReadOnly(t *testing.T) {
|
||||
inner := &mockToolSet{
|
||||
toolsFunc: func(context.Context) ([]tools.Tool, error) {
|
||||
return []tools.Tool{
|
||||
readOnlyTool("read_file", true),
|
||||
readOnlyTool("write_file", false),
|
||||
readOnlyTool("list_files", true),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
wrapped := WithReadOnlyFilter(inner, true)
|
||||
|
||||
result, err := wrapped.Tools(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "read_file", result[0].Name)
|
||||
assert.Equal(t, "list_files", result[1].Name)
|
||||
}
|
||||
|
||||
func TestWithReadOnlyFilter_NoReadOnlyTools(t *testing.T) {
|
||||
inner := &mockToolSet{
|
||||
toolsFunc: func(context.Context) ([]tools.Tool, error) {
|
||||
return []tools.Tool{
|
||||
readOnlyTool("write_file", false),
|
||||
readOnlyTool("shell", false),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
wrapped := WithReadOnlyFilter(inner, true)
|
||||
|
||||
result, err := wrapped.Tools(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestWithReadOnlyFilter_InstructablePassthrough(t *testing.T) {
|
||||
inner := &instructableToolSet{
|
||||
mockToolSet: mockToolSet{
|
||||
toolsFunc: func(context.Context) ([]tools.Tool, error) {
|
||||
return []tools.Tool{readOnlyTool("read_file", true)}, nil
|
||||
},
|
||||
},
|
||||
instructions: "Read-only instructions",
|
||||
}
|
||||
|
||||
wrapped := WithReadOnlyFilter(inner, true)
|
||||
|
||||
assert.Equal(t, "Read-only instructions", tools.GetInstructions(wrapped))
|
||||
}
|
||||
@@ -573,6 +573,7 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri
|
||||
}
|
||||
|
||||
wrapped := WithToolsFilter(tool, toolset.Tools...)
|
||||
wrapped = WithReadOnlyFilter(wrapped, toolset.ReadOnly || a.ReadOnly)
|
||||
wrapped = WithInstructions(wrapped, expander.Expand(ctx, toolset.Instruction, nil))
|
||||
wrapped = WithToon(wrapped, toolset.Toon)
|
||||
wrapped = WithModelOverride(wrapped, toolset.Model)
|
||||
|
||||
@@ -45,6 +45,19 @@ func addDescriptionParam(tool Tool) Tool {
|
||||
return tool
|
||||
}
|
||||
|
||||
// FilterReadOnly returns only the tools whose annotations carry a read-only
|
||||
// hint. It is used to enforce read-only toolsets and read-only agents: every
|
||||
// mutating tool is dropped so it can neither be listed nor called.
|
||||
func FilterReadOnly(toolList []Tool) []Tool {
|
||||
var filtered []Tool
|
||||
for _, tool := range toolList {
|
||||
if tool.Annotations.ReadOnlyHint {
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ExtractDescription extracts the description from tool call arguments.
|
||||
func ExtractDescription(arguments string) string {
|
||||
var args map[string]any
|
||||
|
||||
Reference in new issue
Block a user