본문 바로가기

개발/AI

Spring AI로 Pi 스타일 에이전트 하네스 만들기 (4) - 파일 읽기 전 안전 장치

M4. AI에게 파일을 읽게 하기 전에 만든 안전장치

 

아직 readFile 도구를 만들지 않습니다.

ls, read, find, grep도 붙이지 않습니다.

먼저 모든 파일 도구가 지나가야 할 공통 안전 계층을 만듭니다.

도구보다 먼저 경계
기능보다 먼저 정책

 

M4에서 만든 것은 다음 네 가지입니다.

  • WorkspaceProperties
  • SafePathResolver
  • SecretFileGuard
  • WorkspaceAccessDeniedException

그리고 이 클래스들이 정말 막아야 할 경로를 막는지 테스트로 고정했습니다.

왜 안전장치가 먼저인가

AI에게 파일을 읽게 한다는 것은 애플리케이션에 파일 시스템 접근 권한을 열어준다는 뜻입니다.

사용자가 직접 위험한 경로를 요청할 수도 있습니다.

.env 파일 읽어줘
../outside.txt 읽어줘
/home/user/.ssh/id_rsa 읽어줘

 

모델이 실수로 이런 경로를 도구 인자로 만들 수도 있습니다.

../../../../etc/passwd

 

이때 애플리케이션이 아무 검증 없이 파일을 읽으면 안 됩니다.

Spring Security를 떠올리면 이해하기 쉽습니다.

Controller를 만들기 전에 인증과 인가 기준을 정하듯이, Tool을 만들기 전에 workspace 접근 기준을 먼저 정해야 합니다.

M4의 목적은 파일을 읽는 기능이 아닙니다.

나중에 파일을 읽는 기능이 생겼을 때 반드시 통과해야 하는 경계를 만드는 것입니다.

Workspace라는 울타리

에이전트가 접근할 수 있는 루트 디렉터리를 이 프로젝트에서는 workspace라고 부릅니다.

workspace = /Users/me/projects/demo

 

에이전트는 이 안에서만 파일을 다룰 수 있어야 합니다.

허용되는 예는 다음과 같습니다.

/Users/me/projects/demo/README.md
/Users/me/projects/demo/src/main/java/App.java

반대로 다음 경로는 차단해야 합니다.

/Users/me/projects/secret.txt
/Users/me/.ssh/id_rsa
/etc/passwd

즉 workspace는 에이전트가 움직일 수 있는 프로젝트 경계입니다. 파일 도구가 아무리 편해도 이 경계를 넘으면 안 됩니다.

WorkspaceProperties

먼저 workspace 설정을 담는 WorkspaceProperties를 만들었습니다.

agent:
  workspace:
    root: .
    max-read-bytes: 200000
    max-directory-entries: 200
    max-grep-matches: 100

 

이번 PR에서 실제로 중요한 값은 root입니다.

기본값은 현재 디렉터리 .이고, 생성 시점에 절대경로로 정규화합니다.

max-read-bytes, max-directory-entries, max-grep-matches는 아직 도구와 연결하지 않았습니다.

M5에서 read, ls, grep 같은 읽기 전용 도구를 만들 때 사용할 제한값입니다.

지금은 설정 계약만 먼저 잡아둔 상태입니다.

테스트에서는 기본값이 들어가는지, 설정값이 Spring Boot @ConfigurationProperties로 바인딩되는지, 상대 경로가 절대 경로로 정리되는지 확인했습니다.

SafePathResolver

핵심 컴포넌트는 SafePathResolver입니다.

처음 떠올릴 수 있는 구현은 단순합니다.

Path resolved = workspaceRoot.resolve(userInput).normalize();

if (!resolved.startsWith(workspaceRoot)) {
    throw new WorkspaceAccessDeniedException("path is outside workspace");
}

 

normalize()만으로는 symlink escape를 막을 수 없기 때문입니다.

예를 들어 workspace 안에 있는 outside-link.txt가 workspace 밖의 파일을 가리킬 수 있습니다.

문자열 경로만 보면 workspace 안에 있는 파일처럼 보이지만, 실제 파일은 바깥에 있습니다.

그래서 존재하는 경로는 toRealPath()로 실제 경로를 확인한 뒤 workspace 내부인지 검사합니다.

이번 PR의 SafePathResolver는 두 가지 메서드를 제공합니다.

Path resolveExisting(String userPath)
Path resolveForFuturePath(String userPath)

resolveExisting은 나중에 read, ls, find, grep 같은 읽기 도구가 사용할 경로 검증입니다.

파일이나 디렉터리가 이미 존재해야 하고, 실제 경로 기준으로 workspace 안에 있어야 합니다.

resolveForFuturePath는 아직 쓰기 도구를 만든다는 뜻이 아닙니다.

M9에서 writeedit가 붙을 때를 대비해 새 파일 경로를 검증하는 공통 로직만 미리 분리한 것입니다.

parent 디렉터리는 이미 존재해야 하고, 그 parent의 실제 경로가 workspace 안이어야 합니다.

또한 대상 파일이 이미 있으면 실패합니다.

기본 overwrite를 허용하지 않겠다는 방향을 이 단계에서 미리 잡은 셈입니다.

이렇게 기존 경로와 새 경로를 나눈 이유는 읽기와 쓰기의 안전 조건이 다르기 때문입니다.

존재하는 파일은 파일 자체의 real path를 확인할 수 있지만, 아직 없는 파일은 parent의 real path를 확인해야 합니다.

Windows 경로도 닫아두기

M4에서는 Unix 스타일 경로만 보지 않았습니다.

테스트에는 다음 사례도 들어갔습니다.

src/main/App.java
src\main\App.java
C:/outside/file.txt
C:relative.txt
\\server\share\file.txt

Windows 구분자 \는 현재 OS의 구분자에 맞춰 처리하되, 애매한 Windows drive-relative path나 UNC path는 보수적으로 차단했습니다.

이 프로젝트의 기본 원칙은 fail-closed입니다.

판단이 애매하면 허용하지 않습니다.

SecretFileGuard

workspace 내부라고 해서 모든 파일을 읽어도 되는 것은 아닙니다.

예를 들어 프로젝트 루트의 .env는 workspace 안에 있지만 읽으면 안 됩니다. private key나 production 설정 파일도 마찬가지입니다.

이번 PR에서는 secret deny pattern을 설정값으로 빼지 않았습니다. 아직 운영 중에 패턴을 바꿔야 할 요구가 없기 때문입니다. 대신 SecretFileGuard 안에 작은 기본 차단 목록을 두고, 테스트로 고정했습니다.

차단 목록은 다음과 같습니다.

.env
.env.*
*.pem
*.key
id_rsa
id_dsa
id_ecdsa
id_ed25519
.git
application-prod.yml
application-prod.yaml
application-prod.properties
secrets.*

 

검사는 파일명 하나만 보지 않습니다.

경로 segment 전체를 봅니다.

그래서 다음처럼 중첩된 경로도 차단됩니다.

config/.env.local
keys/id_rsa
.git/config
.git/hooks/pre-commit

 

반대로 비슷하게 보이지만 secret으로 보지 않는 파일은 허용합니다.

id_rsa.pub
.gitignore
secrets-guide.md
notes.key.md
pem-notes.txt

 

이 구분은 중요합니다.

너무 넓게 막으면 정상적인 문서까지 읽지 못하고, 너무 좁게 막으면 secret이 노출될 수 있습니다.

이번 단계에서는 작고 보수적인 기본값으로 시작했습니다.

예외는 하나로 모은다

차단될 때는 WorkspaceAccessDeniedException을 던집니다.

이 예외는 아직 API 응답이나 agent event와 연결되어 있지 않습니다.

그 연결은 M5 이후 도구와 M6 policy 단계에서 다룰 예정입니다.

하지만 지금부터 예외 타입을 분리해두면 나중에 다음 흐름을 만들기 쉽습니다.

tool call
→ SafePathResolver 또는 SecretFileGuard에서 차단
→ WorkspaceAccessDeniedException
→ ToolResult error
→ ToolCallDeniedEvent
→ 사용자에게 안전상 읽을 수 없다고 설명

 

테스트가 이번 단계의 핵심이다

M4는 기능보다 테스트가 더 중요합니다.

눈에 보이는 데모는 없지만, 나중에 붙을 모든 파일 도구가 이 테스트를 안전망으로 사용하게 됩니다.

SafePathResolverTest에서는 다음을 확인했습니다.

  • README.md 같은 workspace 내부 파일은 허용된다.
  • src/main/App.javasrc\main\App.java 모두 처리된다.
  • workspace 내부 절대 경로는 허용된다.
  • ../outside.txt는 차단된다.
  • workspace 밖 절대 경로는 차단된다.
  • /etc/passwd 같은 Unix root 경로 예시는 차단된다.
  • C:/outside/file.txt 같은 Windows drive absolute 예시는 차단된다.
  • workspace 밖을 가리키는 symlink는 차단된다.
  • future path는 parent가 workspace 안에 있을 때만 허용된다.
  • future path의 parent가 symlink로 workspace 밖을 가리키면 차단된다.
  • 이미 존재하는 future target은 차단된다.
  • parent가 디렉터리가 아니면 차단된다.
  • 빈 경로는 차단된다.

SecretFileGuardTest에서는 다음을 확인했습니다.

  • .env, .env.local, prod.pem, deploy.key는 차단된다.
  • id_rsa, id_dsa, id_ecdsa, id_ed25519는 차단된다.
  • application-prod.yml, application-prod.yaml, application-prod.properties는 차단된다.
  • .git 하위 경로는 차단된다.
  • secrets.json은 차단된다.
  • id_rsa.pub, .gitignore, secrets-guide.md 같은 파일은 허용된다.

WorkspacePropertiesTest에서는 기본값과 설정 바인딩을 확인했습니다.

이 테스트들이 통과해야 M4가 끝납니다.

마무리

AI Agent에서 파일 읽기 기능은 프로젝트를 볼 수 있게 해주는 일입니다.

하지만 무엇을 볼 수 있는지 정하지 않은 채 눈부터 달면 위험합니다.

.env, private key, workspace 밖 파일은 모델이 읽고 싶다고 해서 읽어도 되는 대상이 아닙니다.

M4는 화려한 단계가 아닙니다.

API도 없고, 데모 화면도 없습니다. 대신 앞으로 붙을 모든 파일 도구가 따라야 할 경계를 먼저 세웠습니다.

다음 단계인 M5에서는 이 경계 위에 ls, read, find, grep 같은 읽기 전용 도구를 작게 올릴 수 있습니다.

 

[codex] M4 workspace guard by dd3ok · Pull Request #7 · dd3ok/pi-spring-ai