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
23 changes: 23 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1372,6 +1372,29 @@
"description": "Whether to ignore VCS files (.git directories and .gitignore patterns) in filesystem operations. Default: true",
"default": true
},
"allow_list": {
"type": "array",
"description": "Allow-list of directories the filesystem tool is permitted to access (only valid for type 'filesystem'). Each entry may be '.' (the agent's working directory), '~' or '~/...' (the user's home directory), an absolute path, or a relative path (anchored at the working directory). Symlinks are followed before the containment check, so they cannot be used to escape the allow-list. An empty or omitted list preserves the default behaviour (any path the agent process can reach).",
"items": {
"type": "string"
},
"examples": [
["."],
[".", "~/projects"],
["/srv/data", "~/scratch"]
]
},
"deny_list": {
"type": "array",
"description": "Deny-list of directories the filesystem tool is forbidden to access (only valid for type 'filesystem'). Uses the same expansion rules as 'allow_list'. The deny-list takes precedence over the allow-list: a path that matches both is rejected.",
"items": {
"type": "string"
},
"examples": [
["~/.ssh", "~/.aws"],
["/etc", "/var/lib"]
]
},
"defer": {
"description": "Enable deferred loading for tools in this toolset. Set to true to defer all tools, or an array of tool names to defer only those tools. Deferred tools are not loaded into the agent's context immediately, but can be discovered and loaded on-demand using search_tool and add_tool.",
"oneOf": [
Expand Down
42 changes: 41 additions & 1 deletion docs/tools/filesystem/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,50 @@ toolsets:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `ignore_vcs` | boolean | `false` | When `true`, ignores `.gitignore` patterns and includes all files |
| `ignore_vcs` | boolean | `true` | When `true` (default), `.git` directories and `.gitignore` patterns are excluded from listings and searches. Set to `false` to include them. |
| `post_edit` | array | `[]` | Commands to run after editing files matching a path pattern |
| `post_edit[].path` | string | — | Glob pattern for files (e.g., `*.go`, `src/**/*.ts`) |
| `post_edit[].cmd` | string | — | Command to run (use `${file}` for the edited file path) |
| `allow_list` | array | `[]` | Directories the tools may access. Empty = unrestricted (default). |
| `deny_list` | array | `[]` | Directories the tools must not access. Takes precedence over `allow_list`. |

### Path access control

By default the filesystem tools are unrestricted: relative paths resolve
from the working directory, but absolute paths and `..` traversals can
reach anywhere the agent process can. Configure `allow_list` and/or
`deny_list` to sandbox the toolset.

Entries in either list are expanded as follows:

- `"."` — the agent's working directory
- `"~"` or `"~/..."` — the user's home directory
- `"$VAR"` / `"${VAR}"` — environment variable expansion
- absolute paths — used as-is
- relative paths — anchored at the working directory

Symlinks are resolved before the containment check, so a symlink inside an
allowed root cannot be used to escape it. When an `allow_list` is set,
each entry is opened as a Go [`*os.Root`](https://pkg.go.dev/os#Root) so
that the kernel's rooted-lookup semantics also reject `..` and symlink
escapes at I/O time, not just at resolve time.

```yaml
toolsets:
- type: filesystem
# Restrict every operation to the working directory and the user's
# home folder, then carve credentials out of the home folder.
allow_list:
- "."
- "~"
deny_list:
- "~/.ssh"
- "~/.aws"
```

When the path supplied by the agent is rejected, the tool returns a
structured error rather than performing any filesystem I/O. This makes the
restriction visible to the model so it can adjust its plan.

### Post-Edit Hooks

Expand Down
75 changes: 75 additions & 0 deletions examples/filesystem_allow_deny.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env docker agent run

# Demonstrates allow_list / deny_list for the filesystem tool.
#
# By default the filesystem toolset is unrestricted: relative paths resolve
# from the working directory, but the tools will happily read or write
# absolute paths (or `..` traversals) anywhere the agent process can reach.
# That is fine for local development but a footgun when running an agent on
# someone else's behalf.
#
# Configure `allow_list` to limit operations to a fixed set of roots, and/or
# `deny_list` to carve specific subtrees out. Tokens are expanded as follows:
#
# "." -> the agent's working directory
# "~" -> the user's home directory
# "~/foo" -> $HOME/foo
# "$VAR" -> environment variable
# absolute -> used as-is
# relative -> joined with the working directory
#
# Symlinks are resolved before the containment check, so a symlink inside an
# allowed root cannot be used to escape it.
#
# Switch agents with `-a <name>` to try each variant.

agents:
# Strict variant: only the working directory and the user's home are
# reachable, and the SSH/AWS credential directories are explicitly off-limits
# even though they live under the home directory.
root:
model: anthropic/claude-sonnet-4-5
description: A filesystem agent restricted to its working directory and home folder.
instruction: |
You may read and write files under the current working directory and
under the user's home directory, except for the credentials folders
(~/.ssh, ~/.aws). If asked to touch a path outside that scope, refuse
and explain why.
toolsets:
- type: filesystem
allow_list:
- "."
- "~"
deny_list:
- "~/.ssh"
- "~/.aws"
- "~/.docker"

# Workspace-only variant: the agent can only see its own working tree.
workspace_only:
model: anthropic/claude-sonnet-4-5
description: A filesystem agent that cannot leave its working directory.
instruction: |
You may only read and write files inside the current working directory.
Refuse any request that would require touching files elsewhere on the
filesystem.
toolsets:
- type: filesystem
allow_list:
- "."

# Deny-only variant: keeps the default permissive behaviour but explicitly
# forbids a few sensitive directories.
guarded:
model: anthropic/claude-sonnet-4-5
description: A filesystem agent with a small set of off-limits directories.
instruction: |
You have broad filesystem access, but a few directories are off-limits.
If a request is rejected, do not retry and explain the situation to the
user.
toolsets:
- type: filesystem
deny_list:
- "~/.ssh"
- "~/.aws"
- "/etc"
17 changes: 17 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,23 @@ type Toolset struct {
// For the `filesystem` tool - VCS integration
IgnoreVCS *bool `json:"ignore_vcs,omitempty"`

// For the `filesystem` tool - allow-list of directories the tools are
// permitted to access. Each entry may be "." (the agent's working
// directory), "~" or "~/..." (the user's home directory), an absolute
// path, or a relative path (anchored at the working directory). When
// non-empty, every read/write operation is rejected unless its target
// resolves under one of the listed roots. Symlinks are followed before
// the containment check so they cannot be used to escape the allow-list.
// An empty or omitted list preserves the default behaviour (any path
// reachable by the process is allowed).
AllowList []string `json:"allow_list,omitempty" yaml:"allow_list,omitempty"`

// For the `filesystem` tool - deny-list of directories the tools are
// forbidden to access. Same expansion and matching rules as `allow_list`.
// The deny-list takes precedence over `allow_list`: a path that matches
// both is rejected. An empty or omitted list disables the deny-list.
DenyList []string `json:"deny_list,omitempty" yaml:"deny_list,omitempty"`

// For the `lsp` tool
FileTypes []string `json:"file_types,omitempty"`

Expand Down
25 changes: 25 additions & 0 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ func (t *Toolset) validate() error {
if t.IgnoreVCS != nil && t.Type != "filesystem" {
return errors.New("ignore_vcs can only be used with type 'filesystem'")
}
if len(t.AllowList) > 0 && t.Type != "filesystem" {
return errors.New("allow_list can only be used with type 'filesystem'")
}
if len(t.DenyList) > 0 && t.Type != "filesystem" {
return errors.New("deny_list can only be used with type 'filesystem'")
}
if err := validatePathRootEntries("allow_list", t.AllowList); err != nil {
return err
}
if err := validatePathRootEntries("deny_list", t.DenyList); err != nil {
return err
}
if len(t.Env) > 0 && (t.Type != "shell" && t.Type != "script" && t.Type != "mcp" && t.Type != "lsp") {
return errors.New("env can only be used with type 'shell', 'script', 'mcp' or 'lsp'")
}
Expand Down Expand Up @@ -215,6 +227,19 @@ func (t *Toolset) validate() error {
return nil
}

// validatePathRootEntries rejects empty / whitespace-only entries in a
// filesystem allow- or deny-list. An empty entry would be a foot-gun: it
// would resolve to the working directory and silently widen (or close) the
// matched set in surprising ways.
func validatePathRootEntries(field string, entries []string) error {
for i, e := range entries {
if strings.TrimSpace(e) == "" {
return fmt.Errorf("%s[%d] must not be empty", field, i)
}
}
return nil
}

// validateDomainPatterns rejects empty / whitespace-only entries in a fetch
// allow- or block-list, since they silently match nothing and turn the list
// into a foot-gun (e.g. allowed_domains: [""] would reject every URL).
Expand Down
9 changes: 9 additions & 0 deletions pkg/teamloader/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ func createFilesystemTool(_ context.Context, toolset latest.Toolset, _ string, r
}
opts = append(opts, builtin.WithIgnoreVCS(ignoreVCS))

// Handle allow/deny lists for filesystem operations.
// An empty / nil list preserves the default behaviour (no restriction).
if len(toolset.AllowList) > 0 {
opts = append(opts, builtin.WithAllowList(toolset.AllowList))
}
if len(toolset.DenyList) > 0 {
opts = append(opts, builtin.WithDenyList(toolset.DenyList))
}

// Handle post-edit commands
if len(toolset.PostEdit) > 0 {
postEditConfigs := make([]builtin.PostEditConfig, len(toolset.PostEdit))
Expand Down
Loading
Loading