Skip to content
Merged
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
4 changes: 4 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,10 @@ func (f *runExecFlags) createSessionSpawner(agentSource config.Source, sessStore
t.SetPermissions(permissions.Merge(t.Permissions(), f.globalPermissions))
}

if ignoreRules := permissions.FromAgentsIgnore(f.runConfig.WorkingDir); ignoreRules != nil {
t.SetPermissions(permissions.Merge(t.Permissions(), ignoreRules))
}

rtOpts, ctrl, err := f.snapshotRuntimeOpts()
if err != nil {
return nil, nil, nil, err
Expand Down
105 changes: 105 additions & 0 deletions docs/configuration/agentsignore/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
title: "Ignoring files"
description: "Hide files from the agent with a .agentsignore file, using the same syntax as .gitignore."
keywords: docker agent, ai agents, configuration, agentsignore, gitignore, ignore, secrets
weight: 75
canonical: https://docs.docker.com/ai/docker-agent/configuration/agentsignore/
---

_Hide files from the agent with a `.agentsignore` file, using the same syntax as `.gitignore`._

## Overview

Put a `.agentsignore` file in your project and the agent stops seeing the paths it lists. Matched files are absent from directory listings and searches, and reads, writes and edits targeting them are refused.

```text
# .agentsignore
secrets.env
*.key
.env.*
build/
docs/**/*.draft.md
!public.key
```

There is nothing to configure. The file's presence is the opt-in, and it is picked up automatically by the [filesystem toolset](../../tools/filesystem/index.md).

## Syntax

The syntax is `.gitignore` syntax — parsed with the same library git uses, so patterns behave identically to `.gitignore` and `.dockerignore`:

| Pattern | Matches |
| --- | --- |
| `secrets.env` | that name at any depth (`secrets.env`, `config/secrets.env`) |
| `/secrets.env` | that name at the project root only |
| `*.key` | any file with the extension |
| `build/` | the directory and everything under it |
| `docs/**/*.draft.md` | nested matches via `**` |
| `!public.key` | re-includes a path an earlier pattern excluded |
| `# comment` | ignored, as are blank lines |

## Where the file is found

The nearest `.agentsignore` at or above the working directory is used, so starting a run in a subdirectory still honours the project's file. Patterns are anchored to the directory containing the file, exactly as git anchors to the directory containing `.gitignore`.

Unlike `.gitignore`, **a git repository is not required** — `.agentsignore` works in any directory.

## What it affects

| Behaviour | Effect |
| --- | --- |
| `list_directory`, `directory_tree` | matched entries are omitted |
| `search_files_content` | matched files are skipped |
| `read_file`, `read_multiple_files` | refused |
| `write_file`, `edit_file` | refused, including for files that do not exist yet |
| `create_directory`, `remove_directory` | refused |
| `permissions` | matching deny rules are derived automatically, so `/permissions` shows them |

Paths are resolved before matching — symlinks, `./` prefixes and `..` segments are all normalised — so an ignored file cannot be reached by spelling it differently.

The `.agentsignore` file is itself always hidden: it names the very things being kept back, so handing it to the agent would be a map of what to look for.

> [!NOTE]
> `.agentsignore` is independent of the filesystem toolset's [`ignore_vcs`](../tools/index.md) option. Setting `ignore_vcs: false` turns off `.gitignore` filtering but does **not** un-hide `.agentsignore` entries.

## Relationship to `.gitignore`

They are separate, and they do different amounts of work.

`.gitignore` is respected by default (`ignore_vcs`), but only as a **display filter**: gitignored files are hidden from listings and searches while `read_file` still opens them. That is reasonable for build output, which is noise rather than secret.

`.agentsignore` is for content the agent should not have at all, so it blocks reads and writes as well. Use `.gitignore` for noise, `.agentsignore` for secrets.

## Limits

> [!WARNING]
> `.agentsignore` governs the filesystem toolset. It is **not** a sandbox.
>
> An agent with the [`shell`](../../tools/shell/index.md) toolset can still run `cat secrets.env`, because the shell runs commands the toolset never inspects. The same applies to any toolset that reaches the filesystem on its own, such as [`lsp`](../../tools/lsp/index.md).
>
> Treat `.agentsignore` as a strong default that keeps sensitive files out of the agent's view and context — not as a boundary against an agent actively trying to reach them. When you need a real boundary, combine it with [permissions](../permissions/index.md) that restrict `shell`, or run in [sandbox mode](../sandbox/index.md).

The derived permission rules are best-effort for the same reason: permission patterns match the argument string as the model wrote it, without resolving it first, so `./secrets.env` can slip past a rule written for `secrets.env`. The filesystem toolset's own check resolves paths first and is the part that actually enforces.

## Example

```text
# .agentsignore

# Secrets
.env
.env.*
secrets.env
*.pem
*.key
!public.key # this one is safe to read

# Credentials directories
.aws/
.ssh/

# Large build output the agent doesn't need
build/
dist/
node_modules/
```
2 changes: 2 additions & 0 deletions docs/data/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
url: /configuration/hooks/
- title: Permissions
url: /configuration/permissions/
- title: Ignoring Files
url: /configuration/agentsignore/
- title: Sandbox Mode
url: /configuration/sandbox/
- title: Structured Output
Expand Down
157 changes: 157 additions & 0 deletions pkg/fsx/agentsignore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package fsx

import (
"bufio"
"errors"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"

"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)

const AgentsIgnoreFile = ".agentsignore"

type AgentsIgnoreMatcher struct {
root string
matcher gitignore.Matcher
}

func FindAgentsIgnore(startDir string) string {
dir, err := filepath.Abs(startDir)
if err != nil {
return ""
}
for {
candidate := filepath.Join(dir, AgentsIgnoreFile)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

func NewAgentsIgnoreMatcher(startDir string) (*AgentsIgnoreMatcher, error) {
path := FindAgentsIgnore(startDir)
if path == "" {
return nil, nil
}
patterns, err := readAgentsIgnorePatterns(path)
if err != nil {
return nil, err
}
if len(patterns) == 0 {
return nil, nil
}
root := filepath.Dir(path)
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
slog.Debug("Loaded .agentsignore", "file", path, "patterns", len(patterns))
return &AgentsIgnoreMatcher{root: root, matcher: gitignore.NewMatcher(patterns)}, nil
}

func readAgentsIgnorePatterns(path string) ([]gitignore.Pattern, error) {
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer f.Close()

var patterns []gitignore.Pattern
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, gitignore.ParsePattern(line, nil))
}
if err := scanner.Err(); err != nil {
return nil, err
}
return patterns, nil
}

func ReadAgentsIgnoreGlobs(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer f.Close()

var out []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out, scanner.Err()
}

func (m *AgentsIgnoreMatcher) Match(path string) bool {
if m == nil {
return false
}

absPath, err := filepath.Abs(path)
if err != nil {
return false
}
absPath = resolveExisting(absPath)

relPath, err := filepath.Rel(m.root, absPath)
if err != nil {
return false
}
normalized := filepath.ToSlash(relPath)
if normalized == ".." || strings.HasPrefix(normalized, "../") {
return false
}
if normalized == "." {
return false
}
if normalized == AgentsIgnoreFile {
return true
}

info, statErr := os.Stat(absPath)
isDir := statErr == nil && info.IsDir()
return m.matcher.Match(strings.Split(normalized, "/"), isDir)
}

func resolveExisting(absPath string) string {
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
return resolved
}
dir, rest := filepath.Dir(absPath), filepath.Base(absPath)
for dir != filepath.Dir(dir) {
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
return filepath.Join(resolved, rest)
}
dir, rest = filepath.Dir(dir), filepath.Join(filepath.Base(dir), rest)
}
return absPath
}

func (m *AgentsIgnoreMatcher) Root() string {
if m == nil {
return ""
}
return m.root
}
Loading
Loading