From 2cb905c647298968aea24a62ff2691935d136540 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 25 Jun 2026 14:21:32 +0200 Subject: [PATCH] feat: add readonly attribute for toolsets and agents --- agent-schema.json | 8 ++++ examples/README.md | 1 + examples/readonly.yaml | 54 +++++++++++++++++++++++ pkg/config/latest/types.go | 9 ++++ pkg/teamloader/readonly.go | 48 ++++++++++++++++++++ pkg/teamloader/readonly_test.go | 78 +++++++++++++++++++++++++++++++++ pkg/teamloader/teamloader.go | 1 + pkg/tools/description.go | 13 ++++++ 8 files changed, 212 insertions(+) create mode 100644 examples/readonly.yaml create mode 100644 pkg/teamloader/readonly.go create mode 100644 pkg/teamloader/readonly_test.go diff --git a/agent-schema.json b/agent-schema.json index 932677fdd..e788a5ed2 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -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')." diff --git a/examples/README.md b/examples/README.md index caf1efb81..ad045c18d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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). | --- diff --git a/examples/readonly.yaml b/examples/readonly.yaml new file mode 100644 index 000000000..d0a102965 --- /dev/null +++ b/examples/readonly.yaml @@ -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 ` 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 diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index 362c5d1ec..ac8f8ce0b 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -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"). diff --git a/pkg/teamloader/readonly.go b/pkg/teamloader/readonly.go new file mode 100644 index 000000000..78bbab941 --- /dev/null +++ b/pkg/teamloader/readonly.go @@ -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 +} diff --git a/pkg/teamloader/readonly_test.go b/pkg/teamloader/readonly_test.go new file mode 100644 index 000000000..c09ba6ff2 --- /dev/null +++ b/pkg/teamloader/readonly_test.go @@ -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)) +} diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index ca13c43e2..c9ad7f0aa 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -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) diff --git a/pkg/tools/description.go b/pkg/tools/description.go index 210387371..9c2cba0ad 100644 --- a/pkg/tools/description.go +++ b/pkg/tools/description.go @@ -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