FileSystem

FileSystem

FileSystem은 에이전트에게 읽기·쓰기·편집·목록·검색·찾기·생성·검사라는 고정된 파일 도구 세트를 주는데, 전부 단일 root_dir로 범위가 제한돼요. 모든 경로는 I/O 전에 해석되고 포함 검사(심링크 포함)를 거치며, allow/deny/protected glob 패턴으로 접근이 필터링돼요.

출처: 문서

Source

Pydantic AI Harness가 0.x 릴리스인 동안 API는 마이너 릴리스 사이에 바뀔 수 있어요. 그럴 때는 deprecation 경고와 릴리스 노트의 마이그레이션 안내가 정확히 어떻게 업그레이드할지 알려줘요. 버전 정책을 참고하세요.

본문

문제 (The problem)

에이전트가 파일시스템을 직접 건드리게 하는 건 위험해요. 경로 탐색(../../etc/passwd), 프로젝트를 벗어나는 심링크, .git 덮어쓰기, .env 비밀 유출 같은 일이 있죠. 모든 도구 호출마다 이런 방어를 손수 만드는 건 반복적이고, 아주 틀리기 쉬워요.

FileSystem은 그 방어를 한곳에 모아요. 하나로 묶인 샌드박스형 도구 세트를 노출해서, 경계를 한 번만 설정하고 여러 에이전트에서 재사용할 수 있게 해줘요.

사용법 (Usage)

root_dir을 지정해 에이전트의 capabilitiesFileSystem을 추가하세요. 에이전트가 읽거나 쓰는 모든 것은 그 디렉터리로 제한돼요.

from pydantic_ai import Agent
from pydantic_ai_harness import FileSystem

agent = Agent(
    'anthropic:claude-sonnet-4-6',
    capabilities=[FileSystem(root_dir='./workspace')],
)

result = agent.run_sync('Read config.toml and tell me the package name.')
print(result.output)

root_dir은 기본적으로 현재 디렉터리(.)지만, 명시적 워크스페이스 경로를 넘기는 걸 권장해요. 샌드박스는 주는 root만큼만 단단하니까요.

도구 (Tools)

FileSystem은 기본적으로 여덟 개 도구를 제공하고, 선택적으로 ripgrep 도구 두 개를 더 쓸 수 있어요. 모두 root_dir로 경로 범위가 제한돼요.

도구 용도
read_file 줄 번호와 콘텐츠 해시로 텍스트 파일 읽기. 바이너리 파일은 감지돼 덤프되지 않아요. offset/limit 페이징 지원. max_read_chars를 주면 전체 결과(헤더와 힌트 포함)가 한도 안에 들어오도록 하고, 마지막에 완전한 줄로 창이 끝나며 이어지는 힌트가 안 보인 첫 줄을 알려줘요.
write_file 파일 생성 또는 덮어쓰기. 선택적으로 expected_hash가 낡은 쓰기를 거부해요(낙관적 동시성).
edit_file 정확한 문자열 치환: old_text/new_text 한 쌍, 또는 순서대로 적용되는 replacements 배치. 각 old_text는 정확히 한 번 일치해야 해요. 배치는 메모리에서 검사되고 모든 치환이 일치할 때만 써져요. 선택적으로 expected_hash.
list_directory 디렉터리 항목을 타입 표시와 크기와 함께 나열해요.
search_files 파일 콘텐츠에서 정규식 검색. 선택적으로 include_glob으로 좁힘.
find_files 파일 이름 glob 검색(예: *.py, **/*.json). 패턴은 path 기준이며 절대 패턴은 거부돼요.
create_directory 디렉터리와 없는 부모를 생성해요.
file_info 파일/디렉터리 메타데이터(크기, 타입, 줄 수, 해시, 심링크 대상).
list_files 선택. ripgrep 기반: 디렉터리 아래 파일을 경로순으로 재귀 나열, 선택적 glob.
grep 선택. ripgrep 기반: glob, file_type, ignore_case, literal, context(0~20) 옵션으로 콘텐츠 검색. path는 파일 또는 디렉터리를 가리킬 수 있어요.

도구 선택과 ripgrep 도구

tools는 등록할 도구를 FILE_SYSTEM_TOOL_NAMES에서 골라 이름 짓게 해요. 기본 DEFAULT_TOOL_NAMES가 순수 파이썬 도구 여덟 개예요. list_filesgreprg 실행 파일을 실행하는데, PATH에 있어야 해요(coder extra가 설치해줘요). 그래서 이름으로 선택해 켜는 거죠:

from pydantic_ai_harness import FileSystem

FileSystem(root_dir='./workspace', tools=['read_file', 'edit_file', 'list_files', 'grep'])

둘 다 ripgrep 기본값을 존중해요. git 저장소 안의 .gitignore와 어디서든 .ignore 파일이요. ripgrep처럼 명시적 glob이 그 ignore 파일들보다 우선해요. 다른 walker들처럼 도트파일과 도트디렉터리는 여전히 숨겨져요. 출력은 경로순으로 정렬되므로 한도에 걸린 결과도 무작위 부분집합이 아니라 결정적 접두사예요. grep은 일치를 path:line:text로, 컨텍스트 줄은 path-line-text로 보고하고 경로는 cwd 기준이에요. literal이 설정되지 않으면 패턴은 ripgrep 정규식 문법을 써요. rg가 없거나 ripgrep이 거부하는 패턴이 오면 retry로 모델에 돌아가고, 호출을 고치거나 search_files/find_files를 쓰면 돼요. ripgrep이 출력하는 모든 경로는 보이기 전에 다른 walker들과 같은 포함·패턴 검사를 통과해요. read_only=Truetools가 무엇이든 READ_ONLY_TOOL_NAMES의 도구만 남겨요.

콘텐츠 해시

content_hashes=Falseread_file 헤더와 write_file/edit_file 결과에서 해시를 빼고, 그 두 도구에서 expected_hash 매개변수를 제거해요. 해시는 다른 무엇인가도 편집하고 있을 수 있는 워크스페이스에 모델에게 낙관적 동시성을 줘요. 단일 작성자 코딩 에이전트에게는 매 읽기·쓰기에 토큰만 추가할 뿐이죠. 어느 쪽이든 이벤트는 여전히 content_hash를 실어 나라요.

작업 디렉터리

cwd는 상대 경로가 해석되는 디렉터리예요. 기본값은 root_dir이고 반드시 그 안에 있어야 해요. 모델에게 프로젝트 디렉터리를 주고 root_dir은 더 많은 것(부모 디렉터리나 파일시스템 루트)에 접근을 주는 식으로, 모델이 절대 경로를 쓰지 않게 하려고 설정해요.

list_directory, find_files, search_files, list_files, grep은 하위 디렉터리를 검색해도 cwd 기준 경로를 반환해요. 이 경로들은 읽기/쓰기 도구에 그대로 넘길 수 있어요. cwd 밖이지만 root_dir 안인 파일은 .. 구성요소를 써요. 포함, 접근 패턴, 이벤트 경로는 root_dir 기준을 유지하고, search_filesinclude_glob 필터도 마찬가지예요.

이벤트 (Events)

FileSystemfile_system 네임스페이스에서 타입 있는 capability 이벤트를 내보내, 호스트가 에이전트가 워크스페이스에 뭘 했는지 보여주거나, 도구 인수를 파싱하지 않고 변경 전에 거부할 수 있게 해줘요.

이벤트 디스패치 작업 페이로드
FileChangeRequestEvent 즉시 write_file, edit_file, create_directory path, root_dir, operation, diff, truncated; cancel(reason)
FileReadEvent 스트림 read_file path, root_dir, content_hash
DirectoryListedEvent 스트림 list_directory path, root_dir, entry_count
FileWrittenEvent 스트림 write_file path, root_dir, content_hash
FileEditedEvent 스트림 edit_file FileWrittenEvent + diff, truncated
DirectoryCreatedEvent 스트림 create_directory path, root_dir
FilesSearchedEvent 스트림 search_files, find_files, list_files, grep path, root_dir, pattern, search, match_count, truncated

FileChangeRequestEvent는 결정이에요. 경로가 접근 검사를 통과한 뒤에, write_file/edit_file은 충돌 검사까지 거친 뒤에 발생해요. 그래서 리스너는 어차피 진행될 변경만 보게 되죠. 거부된 경로, write_file의 없는 부모, 디렉터리가 아닌 부모, 존재하는 파일의 낡은 expected_hash, 파일과 충돌하는 디렉터리는 요청을 내보내지 않아, 리스너는 정책이나 파일시스템이 거부하는 것을 승인할 수 없어요. 리스너는 시간이 걸릴 수 있어서(사람이 diff를 승인한다든지), 요청이 반환되면 경로를 다시 해석·검사하고, 쓰기/편집은 열린 디스크립터 아래에서 파일이 리스너가 본 것을 그대로 갖고 있는지 확인해요. 그 사이 경로나 파일이 바뀌면 발표 후 실패하긴 해도, 리다이렉트되거나 덮어쓰이진 않아요. 편집은 그 사이 삭제된 파일을 다시 만들지 않아요. 이렇게 해서 포함 검사와 I/O 사이의 창(보안 모델)을 리스너가 없을 때와 같은 수준으로 유지해요. 읽을 수 없는 대상의 경우 승인된 쓰기에는 콘텐츠 가드가 없고, 대신 expected_hash를 넘기면 발표 전에 쓰기를 거부해요. cancel(reason)을 부르는 리스너는 디스크에 닿기 전에 변경을 멈추고, 모델은 그 이유를 도구 결과로 받아요. 리스너가 예외를 던지면 다른 이벤트 리스너와 마찬가지로 실행이 중단되고 변경은 적용되지 않아요. diff는 현재 콘텐츠에서 제안 콘텐츠로의 통합 diff예요. 새 파일은 빈 상태에서 diff하고, 프로세스가 읽을 수 없는 파일은 파일 헤더만으로 발표되고 truncated가 설정돼요(뭐가 들었는지 보여줄 수 없으니까), create_directory는 diff가 없어요. 이미 존재하는 디렉터리의 create_directory는 아무것도 바꾸지 않고 아무것도 내보내지 않아요. 다른 이벤트는 알림이에요.

FileEditedEventFileWrittenEvent를 상속해서, 쓰기 리스너가 편집도 받고 diff가 있으면 읽을 수 있어요. 이 릴리스 전엔 edit_file이 평범한 FileWrittenEvent를 내보내 직렬화된 편집의 kind가 file_system.file_written이었는데, 이제 file_system.file_edited예요. FileWrittenEvent에 등록된 리스너는 여전히 받지만, 직렬화된 kind로 일치시키는 코드는 둘 다 받아야 해요. diff는 MAX_EVENT_DIFF_CHARS(8192)에서 truncated 플래그와 함께 잘려서, 영속·전달된 이벤트 스트림이 한 번의 큰 쓰기로 넘칠 수 없어요. 어느 한쪽이 MAX_DIFF_SOURCE_CHARS(32768)보다 긴 변경은 아예 diff하지 않고 diff는 두 파일 헤더이며 truncated가 설정돼요. (old.count("\n") + 1) * (new.count("\n") + 1)이 65536을 넘을 때도 같은 폴백이 적용돼, differ를 부르기 전에 줄 일치 작업을 제한해요. 마지막 줄이 줄바꿈 없이 끝나면 git diff처럼 표시돼 최종 줄바꿈만 바뀐 변경도 보여요. FilesSearchedEvent는 모델이 받은 일치 수를 세고, truncated는 검색이 max_search_resultsmax_find_results에서 멈췄다는 걸 말해요.

path는 정규화·심링크 해석된 위치로 root_dir 기준이며 결코 절대 호스트 경로가 아니에요. 그래서 모델이나 UI에 그대로 안전하게 되돌릴 수 있어요. root_dir은 내보내는 파일시스템의 해석된 root라, 다른 곳에 뿌리를 둔 구독자가 Path(root_dir) / path로 파일을 찾으면 되고 에미터와 root를 공유한다고 가정하지 않아요.

모든 이벤트 경로는 포함 검사와 거부 패턴을 통과했어요. DirectoryListedEventFilesSearchedEvent는 walk root를 가리키는데, 이 root는 allowed_patterns로 거르지 않아요(보안 모델 참고). 항목만 거르죠. 거부되거나 실패한 작업은 이벤트를 내보내지 않아요. offset이 파일 끝을 넘는 read_file도 마찬가지예요.

from pydantic_ai import Agent
from pydantic_ai_harness import FileSystem
from pydantic_ai_harness.filesystem import FileChangeRequestEvent

agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[FileSystem()])

@agent.on_event(FileChangeRequestEvent)
async def hold_migrations(ctx, event):
    if event.path.startswith('migrations/'):
        event.cancel('migrations need a human')

다른 capability는 RepoContextFileReadEventDirectoryListedEvent를 따르듯 메서드의 @on_event로 구독해요. 자체 파일 도구가 있는 호스트는 pydantic_ai_harness.filesystem에서 이벤트 타입을 임포트해 같은 타입을 내보낼 수 있어, 구독자가 도구 이름이나 raw 모델 인수에 의존하지 않고 반응할 수 있게 해줘요.

FileSystem은 자체 OpenTelemetry 스팬을 내보내지 않아요. 핵심 도구 호출 스팬이 이미 각 작업과 결과를 기록하고, 위 이벤트들이 트레이스가 안 담는 diff를 실어 나르니까요.

모델이 고칠 수 있는 도구 오류(없는 파일, 거부된 경로, 낡은 편집, 기존 파일과 충돌하는 디렉터리, 잘못된 glob 패턴, Windows가 거부하는 경로 이름, 파일시스템이 인코딩할 수 없는 경로 이름, 지나치게 긴 경로 이름, 심링크 루프)는 ModelRetry로 드러나요. 그래서 에이전트는 실행을 중단하는 대신 오류 메시지를 받고 조정할 수 있어요. 가득 찬 디스크나 읽기 전용 디스크처럼 모델이 어쩔 수 없는 실패는 여전히 중단돼요.

OS 오류가 파일 이름을 줄 때 FileSystemroot_dir 기준으로 보고하고, root_dir 밖 경로는 <outside-workspace>가 돼요. file_info는 절대 심링크 대상에 같은 규칙을 적용해요.

보안 모델 (Security model)

  • 포함 (Containment). 상대 경로는 cwd에서 해석돼요. .., 절대 경로, 심링크를 통해 root_dir 밖으로 해석되는 것은 전부 거부돼요. 심링크는 포함 검사 전에 os.path.realpath로 해석되고, I/O는 해석된 경로를 써요. 디렉터리 walk(list_directory, search_files, find_files, list_files, grep)는 각 항목을 같은 방식으로 해석하고 그 해석된 대상에 패턴을 일치시켜, 심링크가 트리 밖 파일을 이름붙이거나 허용된 이름 아래 거부된 파일을 보여줄 수 없게 해요. 이 검사는 경로명 기반이에요. 해석과 I/O 사이에 다른 프로세스가 트리를 바꾸면, 읽은 경로가 검사한 경로와 다를 수 있어요.
  • 바이너리 감지. read_file은 모델 컨텍스트에 바이너리 바이트를 덤프하는 대신 플레이스홀더를 반환해요.
  • 낙관적 동시성. write_file/edit_fileexpected_hash를 받아, 낡은 읽기로 동작하는 에이전트가 새 콘텐츠를 조용히 덮어쓰는 대신 다시 읽으라고 알림 받아요.
  • 정규 쓰기 대상. write_file은 정규 파일이 아닌 기존 대상을 거부해요. POSIX에서는 최종 대상 디스크립터를 비차단 모드로 열고 잘라내기 전에 그 디스크립터의 타입을 검사해, 쓰기 중에 FIFO가 자리바꿈돼도 도구가 멈추지 않아요.

커스텀 파일 스트림

FileSystem은 로컬 pathlib.Path 경로를 쓰고, 원격 UPathroot_dir로 넘기는 건 지원하지 않아요. 커스텀 FileSystemToolset에서는 open_read(resolved)open_write(resolved, *, read_back, create)를 오버라이드해 쓰기 스냅샷·write_file·edit_file이 쓰는 디스크립터 기반 I/O를 바꿀 수 있어요. 서브클래스를 커스텀 FileSystem.get_toolset() 구현에서 반환하고, 그 capability를 Agent(capabilities=[...])로 등록해 파일시스템 이벤트가 capability 소유권을 유지하게 하세요.

open_read는 변경 전 스냅샷과 편집 소스를 위한 바이너리 스트림을 반환해요. open_write(stream, created)를 반환해요. 위치 탐색 가능·비잘림 바이너리 스트림과, 이 오픈이 파일을 배타적으로 만들었는지 여부죠. read_back=True면 스트림이 0번 위치부터 읽을 수 있어야 해요. create=False면 없는 파일은 FileNotFoundError를 올려야 해요. 도구는 두 스트림을 모두 닫고, 잘라내기 전에 콘텐츠 해시를 비교하며, 줄바꿈 변환 없이 UTF-8 바이트를 써요. 스트림이 fileno()를 구현할 필요는 없어요.

오버라이드는 백엔드의 파일 타입 검사와 생성/레이스 시맨틱을 제공해야 해요. 이 메서드들은 원격 경로 해석, 포함, 디렉터리 작업, 발견을 구현하지 않아요. 원격 어댑터는 그 동작도 공급해야 해요. 로컬 구현은 디스크립터 검사와 POSIX 비차단/no-follow 플래그를 유지해요. 해시 검사는 동시 쓰기자에 대한 잠금이 아니라 낙관적 검사예요. 도구 스팬과 파일시스템 이벤트는 그대로이고, 열기 메서드가 텔레메트리를 추가하지 않아요.

패턴 필터링 (Pattern filtering)

세 개의 독립 glob 목록이 접근을 제어해요. 패턴은 fnmatch로 일치시키며, */를 가로지르므로 *.pysrc/main.py와 일치하고 **가 거의 필요 없어요.

필드 효과
allowed_patterns 비어 있지 않으면 일치하는 경로만 접근 가능(허용 목록).
denied_patterns 일치하는 경로는 항상 거부(거부 목록).
protected_patterns 일치하는 경로는 읽기 전용 — 읽기는 되고 쓰기는 거부.

protected_patterns 기본값은 .git/*, .env, .env.*, *.pem, *.key, **/secrets*예요. 빈 목록을 넘기면 보호가 꺼져요.

from pydantic_ai import Agent
from pydantic_ai_harness import FileSystem

agent = Agent(
    'anthropic:claude-sonnet-4-6',
    capabilities=[
        FileSystem(
            root_dir='./workspace',
            allowed_patterns=['*.py', '*.toml'],
            denied_patterns=['**/node_modules/*'],
        ),
    ],
)

직접 접근 vs walker

세 규칙은 두 가지 다른 세분도에서 적용돼요.

  • 직접 접근 (Direct access) (read_file, write_file, edit_file, file_info, create_directory)은 작업의 대상 경로를 거르죠. 패턴이 허용하는 경로를 이름붙여야 해요.
  • Walker (list_directory, search_files, find_files, list_files, grep)는 거부 패턴으로 자기 root를 거르지만, allowed_patterns로는 거르죠. . 같은 디렉터리 root는 src/*.py 같은 파일 패턴과 절대 일치하지 않으니까, 그것을 요구하면 모든 나열이 실패해요. 대신 root를 walk하며 각 항목allowed_patternsdenied_patterns에 대한 읽기 수준 접근으로 필터링해요. 디렉터리 나열은 에이전트가 달리 읽을 수 없는 경로를 드러낼 수 없어요.

그래서 allowed_patterns=['*.py']list_directory('.')은 성공하고 .py 항목만 보여주며, read_file('notes.md')는 거부돼요.

protected_patterns만 일치한다고 항목이 숨겨지진 않아요. allowed/denied/dotfile 필터를 통과하는 보호 경로는 walker에게 보이고 read_file/file_info로 직접 읽을 수 있지만, 쓰기 작업은 거부해요.

참고: 도트파일과 도트디렉터리(.git, .env, .github, ...)는 패턴과 무관하게 모든 walker(list_directory, search_files, find_files, list_files, grep)가 건너뛰어요.

구성 (Configuration)

from pydantic_ai_harness import FileSystem

FileSystem(
    root_dir='.',                  # str | Path -- sandbox root
    cwd=None,                      # where relative paths resolve from (defaults to root_dir)
    allowed_patterns=[],           # allowlist globs (empty = allow all)
    denied_patterns=[],            # denylist globs
    protected_patterns=[...],      # read-only globs (defaults to secrets/.git)
    max_read_lines=2000,           # cap for a single read_file
    max_read_chars=None,           # optional cap on a whole read_file result, ending on a complete line
    max_list_results=1000,         # cap for list_directory
    max_search_results=1000,       # cap for search_files and grep
    max_find_results=1000,         # cap for find_files and list_files
    read_only=False,               # keep only READ_ONLY_TOOL_NAMES
    content_hashes=True,           # report hashes and accept expected_hash
    tools=DEFAULT_TOOL_NAMES,      # which tools to register (add 'list_files', 'grep')
)

정수 한도는 양수여야 해요. 생성 시 검증되고 아니면 ValueError를 올려요. 한도에 걸린 walker는 출력을 [... truncated at N ...] 표시로 끝내고, 추가 항목이 실제로 버려졌을 때만 그래요.

에이전트 스펙 (YAML/JSON)

FileSystem은 Pydantic AI의 agent spec과 함께 작동해요.

model: anthropic:claude-sonnet-4-6
capabilities:
  - FileSystem:
      root_dir: ./workspace
      allowed_patterns: ['*.py', '*.toml']
from pydantic_ai import Agent
from pydantic_ai_harness import FileSystem

agent = Agent.from_file('agent.yaml', custom_capability_types=[FileSystem])

custom_capability_types를 넘겨 스펙 로더가 FileSystem을 인스턴스화하는 법을 알게 하세요.

API 참조 (API reference)

FileSystem

Bases: AbstractCapability[AgentDepsT]

root 디렉터리로 범위가 제한된 파일시스템 접근.

상대 경로는 cwd에서 해석돼요(기본 root_dir). root 위 탐색은 거부돼요. 심링크는 승인 전에 해석돼요.

속성
  • root_dir — 모든 파일 작업의 root 디렉터리. 기본값은 현재 디렉터리. Type: str | Path Default: '.'
  • cwd — 상대 경로가 해석되는 디렉터리; root_dir 안에 있어야 해요. 기본값은 root_dir. Type: str | Path | None Default: None
  • allowed_patterns — 비어 있지 않으면 하나 이상의 glob 패턴과 일치하는 경로만 접근 가능. Default: []
  • denied_patterns — 이 glob 패턴 중 하나와 일치하는 경로는 거부. Default: []
  • protected_patterns — 이 패턴과 일치하는 경로는 읽기 전용(쓰기 거부). 기본값은 .git/, .env, 키 파일, secrets 보호. 빈 목록이면 보호 해제. Default: _DEFAULT_PROTECTED
  • max_read_lines — 단일 read_file 호출이 반환하는 최대 줄 수. Default: 2000
  • max_read_chars — 단일 read_file 결과의 최대 문자 수(헤더·힌트 포함). 창은 마지막 완전한 줄에서 끝나고 이어지는 힌트가 안 보인 첫 줄을 이름붙여, offset으로 페이지할 때 콘텐츠를 건너뛸 수 없게 해요. Default: None
  • max_list_resultslist_directory가 반환하는 최대 항목 수. Default: 1000
  • max_search_resultssearch_files가 반환하는 최대 일치 수. Default: 1000
  • max_find_resultsfind_files가 반환하는 최대 일치 수. Default: 1000
  • read_onlyREAD_ONLY_TOOL_NAMES의 도구만 노출할지. Default: False
  • content_hashes — 도구 결과가 콘텐츠 해시를 보고하고 write_file/edit_fileexpected_hash를 받을지. Default: True
  • toolsFILE_SYSTEM_TOOL_NAMES에서 등록할 도구. 기본값은 모든 순수 파이썬 도구. list_filesgrep을 이름붙이면 ripgrep 기반 나열·검색 도구가 추가돼요. read_only는 선택을 더 좁혀요. Default: DEFAULT_TOOL_NAMES
메서드
  • get_toolsetdef get_toolset() -> FileSystemToolset[AgentDepsT] | FilteredToolset[AgentDepsT]. 파일시스템 toolset을 만들어 반환.

더 알아보기 (Learn more)