feat: add readonly attribute for toolsets and agents

This commit is contained in:
David Gageot committed 2026-06-25 14:59:07 +02:00
1 parent efbb8359ea
commit 2cb905c647
8 files changed
+212

No files matched your search

+8
View File
@@ -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')."
+1
View File
@@ -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). |
---
+54
View File
@@ -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
+9
View File
@@ -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").
+48
View File
@@ -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
}
+78
View File
@@ -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))
}
+1
View File
@@ -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)
+13
View File
@@ -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