Step 05 — 백엔드와 권한

학습 목표

  • 백엔드(backend) 가 파일 도구의 실제 구현체라는 것을 이해하고, 도구는 그대로 둔 채 저장소만 바꾼다
  • StateBackend / FilesystemBackend / StoreBackend / CompositeBackend / 샌드박스 백엔드를 상황에 맞게 고른다
  • FilesystemBackendrootDirvirtualMode로컬 디스크 노출 범위를 잠근다
  • CompositeBackend/memories 는 영속 Store, /workspace 는 디스크로 경로 라우팅한다
  • permissions 규칙(operations/paths/mode)을 첫 매칭 승리 + 기본 allow 규칙에 맞게 쌓는다
  • execute 도구(셸)를 주는 것의 위험을 알고, 언제 필요하고 어떻게 가두는지 판단한다
  • BackendProtocolV2 를 직접 구현해 커스텀 백엔드를 만든다

선행 스텝: Step 04 — 가상 파일시스템 예상 소요: 80분

Step 04 에서 우리는 Deep Agent 에게 ls, read_file, write_file, edit_file, glob, grep 이라는 파일 도구가 기본으로 달려 있다는 것을 배웠습니다. 그때 그 파일들은 전부 메모리 안에 있었습니다. 프로그램이 끝나면 사라졌죠.

이번 스텝의 질문은 하나입니다. "그 파일들은 실제로 어디에 저장되는가?" 답은 백엔드(backend) 입니다. 그리고 이 질문이 중요한 이유는, 백엔드를 바꾸는 순간 write_file 이 "메모리에 쓰기"에서 "당신의 노트북 디스크에 쓰기"로 조용히 바뀌기 때문입니다. 도구 이름도, 모델이 보는 설명도, 코드 한 줄도 그대로인데 말입니다. 그래서 백엔드는 Deep Agents 에서 가장 조용하게 위험한 설정입니다. 이 스텝은 그 설정을 정확히 이해하고, permissions 로 잠그는 법까지 다룹니다.


5-1. 백엔드 = 파일 도구의 실제 구현체

Deep Agent 의 파일 도구는 인터페이스이고, 백엔드는 구현체입니다. 모델은 언제나 read_file("/notes.md") 라고 부릅니다. 그 호출이 메모리 Map 을 뒤질지, 디스크를 읽을지, S3 에 GET 을 날릴지는 백엔드가 정합니다.

import { createDeepAgent, StateBackend } from "deepagents";
import { FilesystemBackend } from "deepagents/node";

const MODEL = "anthropic:claude-sonnet-4-6";

// 두 에이전트는 도구 목록이 완전히 같습니다. 저장소만 다릅니다.
const stateAgent = createDeepAgent({ model: MODEL, backend: new StateBackend() });
const diskAgent = createDeepAgent({
  model: MODEL,
  backend: new FilesystemBackend({ rootDir: process.cwd(), virtualMode: true }),
});

출력

  createDeepAgent 가 Promise 인가?: false
  invoke 는 함수인가?: true
  diskAgent 도 동일한 형태인가?: true

두 에이전트에게 똑같이 "/notes.md 에 회의록을 정리해줘" 라고 시키면, 앞은 메모리에 쓰고 뒤는 당신의 현재 디렉터리에 진짜 파일을 만듭니다. 코드에서 다른 것은 backend: 한 줄뿐입니다.

⚠️ 함정: createDeepAgent동기 함수입니다. 위 출력의 Promise 인가?: false 가 그 증거입니다. const agent = await createDeepAgent({...}) 라고 써도 JavaScript 에서 await 는 Promise 가 아닌 값을 그냥 통과시키므로 에러 없이 잘 돌아갑니다. 그래서 틀린 줄도 모르고 씁니다. 동작에는 지장이 없지만, "이 함수는 비동기구나" 라고 잘못 배우게 되고 .then() 을 붙이는 순간 깨집니다. await 없이 쓰세요. 반면 agent.invoke()진짜 비동기이므로 반드시 await 가 필요합니다.


5-2. 백엔드 카탈로그 — 무엇을 언제 쓰나

먼저 전체 지도를 봅시다. 이 표가 이 스텝의 요약입니다.

백엔드저장 위치수명위험도언제 쓰나
StateBackendLangGraph 상태 (state.files)스레드 한정, 휘발성낮음기본값. 브라우저 데모, 임시 작업
FilesystemBackend로컬 디스크 (rootDir 기준)영구 (진짜 파일)높음코딩 에이전트, CLI 도구
StoreBackendLangGraph Store실행/스레드 간 영속중간장기 메모리, 사용자 선호
CompositeBackend경로 접두사별로 위임라우팅 대상에 따름조합에 따름실무 대부분
LocalShellBackend로컬 디스크 + 영구매우 높음로컬 개발, 격리된 컨테이너 안
LangSmithSandbox원격 샌드박스 + 셸샌드박스 수명중간(격리됨)프로덕션에서 셸이 필요할 때
ContextHubBackendLangSmith Hub 저장소영속중간스킬/프롬프트 공유

핵심은 이 전부가 같은 인터페이스(BackendProtocolV2)를 구현한다는 것입니다. 그래서 서로 갈아끼울 수 있습니다.

const methods = ["ls", "read", "readRaw", "write", "edit", "glob", "grep"] as const;

const candidates: Array<[string, object]> = [
  ["StateBackend", new StateBackend()],
  ["FilesystemBackend", new FilesystemBackend({ rootDir: tmp, virtualMode: true })],
  ["StoreBackend", new StoreBackend({ store: new InMemoryStore(), namespace: () => ["demo"] })],
  ["CompositeBackend", new CompositeBackend(new StateBackend(), {})],
];

for (const [name, backend] of candidates) {
  const has = methods.every((m) => typeof (backend as Record<string, unknown>)[m] === "function");
  console.log(`${name}: 전부 구현? ${has}`);
}

출력

  StateBackend: BackendProtocolV2 메서드 전부 구현? true
  FilesystemBackend: BackendProtocolV2 메서드 전부 구현? true
  StoreBackend: BackendProtocolV2 메서드 전부 구현? true
  CompositeBackend: BackendProtocolV2 메서드 전부 구현? true

💡 실무 팁 — 어디서 import 하나: deepagents 패키지는 엔트리포인트가 3개입니다.

  • deepagents — 기본. Node 환경이면 여기서 전부 나옵니다.
  • deepagents/browser브라우저 안전. StateBackend, StoreBackend, CompositeBackend 는 있지만 FilesystemBackendLocalShellBackend아예 없습니다. 브라우저 번들에 node:fs 가 딸려오는 사고를 막아줍니다.
  • deepagents/node — Node 전용임을 코드로 드러내고 싶을 때. FilesystemBackend, LocalShellBackend 가 여기 있습니다.

브라우저에서 도는 코드를 쓴다면 처음부터 deepagents/browser 만 import 하세요. 나중에 번들러 에러로 알게 되는 것보다 훨씬 낫습니다.


5-3. StateBackend — 기본값, 그리고 휘발성

StateBackend 는 파일을 LangGraph 상태에 넣습니다. 상태는 그래프 실행 중에만 존재하는 객체입니다. 그러니 파일도 그만큼만 삽니다.

// 브라우저 번들에서는 이렇게 가져옵니다:
//   import { StateBackend, createDeepAgent } from "deepagents/browser";
const agent = createDeepAgent({ model: MODEL, backend: new StateBackend() });

backend 를 아예 안 주면 기본이 StateBackend 입니다. Step 04 에서 우리가 쓴 게 바로 이것입니다.

장점: 디스크도 네트워크도 안 씁니다. 사고가 날 수 없습니다. 브라우저에서도 돕니다. 단점: 대화가 끝나면 다 사라집니다.

// ⚠️ StateBackend 를 직접 호출하면 터집니다.
const naked = new StateBackend();
try {
  naked.ls("/");
} catch (e) {
  console.log((e as Error).message);
}

출력

  직접 호출하면: Cannot read properties of undefined (reading 'configurable')

⚠️ 함정: new StateBackend() 를 만들어 놓고 테스트 삼아 backend.write("/a.txt", "hi") 를 직접 불러보면 위처럼 정체불명의 에러가 납니다. "백엔드가 고장났나?" 싶지만 정상입니다. StateBackend 는 자기 상태를 스스로 갖고 있지 않고, createDeepAgent런타임(state)을 주입해줘야 동작합니다. 단위 테스트에서 백엔드를 직접 두드리고 싶다면 StateBackend 말고 StoreBackend(store 를 직접 넘길 수 있음)나 FilesystemBackend 를 쓰세요. 이 둘은 독립적으로 동작합니다. 실제로 이 스텝의 practice.ts 도 그래서 대부분의 예제를 StoreBackend / FilesystemBackend 로 시연합니다.

💡 실무 팁: 상태에 들어간 파일은 체크포인터(Step 10 에서 다룬 MemorySaver 같은)를 붙이면 스레드 안에서는 살아남습니다. 즉 같은 thread_id 로 다시 부르면 파일이 아직 있습니다. 하지만 다른 스레드에서는 안 보이고, MemorySaver 를 썼다면 프로세스 재시작 시 다 날아갑니다. "스레드를 넘어 기억" 이 필요하면 5-5 의 StoreBackend 로 가야 합니다.


5-4. FilesystemBackend — 로컬 디스크를 LLM 에게 준다

이제 진짜 이야기입니다. FilesystemBackend 를 꽂는다는 것은 언어 모델에게 당신의 파일시스템 핸들을 넘긴다는 뜻입니다. 모델이 write_file 을 부르면 진짜 파일이 생기고, edit_file 을 부르면 진짜 파일이 바뀝니다. 되돌리기 버튼은 없습니다.

그래서 옵션이 두 개 있습니다.

옵션타입의미
rootDirstring에이전트가 볼 디렉터리 (절대경로 권장)
virtualModebooleantruerootDir 가 곧 / 가 되고, 밖으로 못 나감
maxFileSizeMbnumber읽을 파일 크기 상한

rootDir 는 격리가 아닙니다. 격리는 virtualMode 가 합니다. 이게 이 절의 핵심입니다.

const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "da-5-4-"));
await fs.writeFile(path.join(sandboxDir, "readme.txt"), "이 파일은 rootDir 안에 있습니다.\n");

// (A) virtualMode: true — rootDir 가 곧 "/" 가 된다
const guarded = new FilesystemBackend({ rootDir: sandboxDir, virtualMode: true });
console.log(await guarded.ls("/"));
console.log(await guarded.read("/readme.txt"));
console.log(await guarded.read("/../../etc/passwd"));  // 탈출 시도
console.log(await guarded.read("/etc/hosts"));         // 절대경로 시도

// (B) virtualMode: false — "/" 가 진짜 디스크 루트다
const open = new FilesystemBackend({ rootDir: sandboxDir, virtualMode: false });
console.log(await open.read("/etc/hosts"));

출력 (경로의 임시 디렉터리 이름은 실행마다 다릅니다)

  (A) ls('/'): {"files":[{"path":"/readme.txt","is_dir":false,"size":43,"modified_at":"…"}]}
  (A) read('/readme.txt'): {"content":"이 파일은 rootDir 안에 있습니다.\n","mimeType":"text/plain"}
  (A) read('/../../etc/passwd'): {"error":"Error reading file '/../../etc/passwd': Path traversal not allowed"}
  (A) read('/etc/hosts'): {"error":"Error reading file '/etc/hosts': ENOENT: no such file or directory, stat '/var/folders/…/da-5-4-WyZzvQ/etc/hosts'"}
  (B) read('/etc/hosts'): 실제 /etc/hosts 내용이 그대로 읽힘!

이 출력을 한 줄씩 읽어봅시다.

  • ls("/")/readme.txt 를 보여줍니다. 에이전트에게 /rootDir 입니다. 실제 경로(/var/folders/…)는 모델에게 아예 안 보입니다.
  • /../../etc/passwd차단. Path traversal not allowed 로 즉시 거부됩니다.
  • /etc/hosts차단이 아니라 재해석. 에러 메시지를 보세요. stat '/var/folders/…/da-5-4-WyZzvQ/etc/hosts'rootDir 안에서 /etc/hosts 를 찾다가 없어서 ENOENT 가 난 것입니다. 막은 게 아니라 "그 경로는 여기서는 이걸 뜻해" 라고 번역한 것입니다.
  • (B)진짜 /etc/hosts 가 읽혔습니다.

⚠️ 함정 (이 스텝에서 두 번째로 위험한 것): rootDir 를 지정했으니 그 안에 갇혔다고 생각하기 쉽습니다. 아닙니다. virtualMode 를 안 주면 기본값은 격리가 아니고, rootDir 는 그저 "상대경로의 기준점(cwd)" 일 뿐입니다. 위 (B)가 정확히 그 상황입니다 — rootDir 를 임시 디렉터리로 줬는데도 /etc/hosts 가 그대로 읽혔습니다. 에러도 경고도 없습니다. 실무에서 이 실수는 read_file("/Users/me/.ssh/id_rsa")read_file("/Users/me/.aws/credentials") 를 모델이 읽어서 그 내용이 프롬프트에 실려 API 로 나가는 것으로 이어집니다. 로컬 디스크를 붙일 거면 virtualMode: true 를 기본으로 삼으세요.

💡 실무 팁 — rootDir 를 좁게: virtualMode: true 를 켰더라도 rootDir: "/"rootDir: os.homedir() 로 주면 아무 의미가 없습니다. 감옥의 벽을 집 전체로 친 셈이니까요. 에이전트가 실제로 건드려야 하는 최소 디렉터리를 주세요. 코딩 에이전트라면 프로젝트 루트, 문서 정리 에이전트라면 그 문서 폴더. 그리고 그 안에서도 .env 같은 건 5-7 의 permissions 로 한 번 더 잠급니다. rootDir(어디까지 보이나) → permissions(그 안에서 뭘 하나) 2단 방어가 정석입니다.


5-5. StoreBackend — 실행 간 영속

StateBackend 는 스레드가 끝나면 사라집니다. 하지만 "이 사용자는 한국어를 선호한다" 같은 건 다음 대화에서도 기억해야 합니다. 그게 StoreBackend 입니다. LangGraph 의 Store(키-값 저장소)에 파일을 씁니다.

import { InMemoryStore } from "@langchain/langgraph";

const store = new InMemoryStore();

// namespace 는 "누구의 파일인가" 를 가르는 칸막이입니다.
const backend = new StoreBackend({ store, namespace: () => ["memories", "user-123"] });

await backend.write("/preference.md", "- 답변은 한국어로\n- 코드는 TypeScript 로\n");
console.log(await backend.ls("/"));
console.log(await backend.read("/preference.md"));

// 같은 store, 다른 namespace → 서로 안 보입니다.
const otherUser = new StoreBackend({ store, namespace: () => ["memories", "user-999"] });
console.log(await otherUser.ls("/"));

// 같은 namespace 로 새 백엔드를 만들면 → 그대로 남아 있습니다 (영속).
const reopened = new StoreBackend({ store, namespace: () => ["memories", "user-123"] });
console.log(await reopened.read("/preference.md"));

출력

  write 후 ls('/'): {"files":[{"path":"/preference.md","is_dir":false,"size":30,"modified_at":"…"}]}
  read('/preference.md'): {"content":"- 답변은 한국어로\n- 코드는 TypeScript 로\n","mimeType":"text/markdown"}
  다른 namespace 의 ls('/'): {"files":[]}
  같은 namespace 재접속 read: {"content":"- 답변은 한국어로\n- 코드는 TypeScript 로\n","mimeType":"text/markdown"}

user-999ls("/")빈 배열인 것에 주목하세요. 같은 store 인스턴스인데도 서로 안 보입니다. 이게 namespace 의 역할입니다.

실무에서는 namespace 를 하드코딩하지 않고 런타임에서 뽑습니다.

const agent = createDeepAgent({
  model: MODEL,
  store,
  backend: new StoreBackend({ namespace: () => ["memories"] }),
});

namespace 는 배열이나 팩토리 함수를 받습니다. 팩토리는 런타임 컨텍스트를 인자로 받으므로, 거기서 사용자 ID 를 꺼내 쓸 수 있습니다.

namespace 패턴의미
["memories"]전역 공유 (모든 사용자가 같은 칸)
(ctx) => ["memories", ctx.state.userId]사용자별 격리
(ctx) => [ctx.assistantId ?? "default"]어시스턴트별 격리

⚠️ 함정: namespace() => ["memories"] 처럼 고정해 두면 모든 사용자가 같은 칸을 씁니다. 개발할 때는 나 혼자 쓰니 아무 문제가 없다가, 배포하면 A 의 메모를 B 가 읽습니다. 에러가 안 나므로 사고가 나기 전까지 모릅니다. 멀티테넌트라면 namespace 팩토리에서 반드시 사용자 식별자를 꺼내 쓰세요. 그리고 그 식별자는 사용자가 조작할 수 없는 곳(인증된 세션)에서 와야 합니다 — 모델이 채우는 값이면 프롬프트 인젝션으로 남의 칸에 들어갑니다.

⚠️ 함정 2: InMemoryStore 는 이름 그대로 프로세스 메모리입니다. StoreBackend 를 썼으니 영속이라고 안심했는데 서버를 재시작하면 다 날아갑니다. "스레드 간 영속"(StateBackend 보다 오래 삶)과 "프로세스 간 영속"(재시작 후에도 삶)은 다른 얘기입니다. 진짜 영속이 필요하면 DB 기반 Store 를 쓰세요.


5-6. CompositeBackend — 경로 접두사로 라우팅

실무에서는 대개 한 종류로 안 끝납니다. 작업 파일은 디스크에, 장기 기억은 Store 에, 나머지 임시 파일은 상태에 두고 싶습니다. CompositeBackend 가 이걸 경로 접두사로 해결합니다.

const backend = new CompositeBackend(
  // 1번 인자 = 기본 백엔드 (라우팅에 안 걸리는 모든 경로)
  new StoreBackend({ store, namespace: () => ["scratch"] }),
  // 2번 인자 = 접두사 → 백엔드 라우팅 표
  {
    "/memories/": new StoreBackend({ store, namespace: () => ["memories"] }),
    "/workspace/": new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true }),
  },
);

await backend.write("/memories/user.md", "이름: 김개발");
await backend.write("/workspace/main.ts", "console.log('hi');");
await backend.write("/scratch.txt", "아무 접두사에도 안 걸림 → 기본 백엔드로");

출력

  routePrefixes: ["/memories/","/workspace/"]
  read /memories/user.md: {"content":"이름: 김개발","mimeType":"text/markdown"}
  read /workspace/main.ts: {"content":"console.log('hi');","mimeType":"text/plain"}
  read /scratch.txt: {"content":"아무 접두사에도 안 걸림 → 기본 백엔드로","mimeType":"text/plain"}
  실제 디스크 확인: ["main.ts"]
  라우팅 대상 백엔드가 보는 실제 키: {"files":[{"path":"/user.md","is_dir":false,"size":7,"modified_at":"…"}]}
  composite ls('/'): {"files":[{"path":"/memories/","is_dir":true,"size":0,"modified_at":""},{"path":"/scratch.txt",…},{"path":"/workspace/","is_dir":true,…}]}

세 가지를 확인했습니다.

  1. 디스크 확인: ["main.ts"]/workspace/main.ts 만 진짜 디스크에 떨어졌습니다. /memories/user.md 는 Store 에만 있습니다.
  2. ls("/") 는 합칩니다: 라우팅된 접두사를 디렉터리(is_dir: true)처럼 보여주고, 기본 백엔드 결과와 나란히 놓습니다. 모델 눈에는 그냥 하나의 파일시스템입니다.
  3. 접두사가 벗겨집니다: 아래 함정을 보세요.

⚠️ 함정 (커스텀 백엔드를 조합할 때 반드시 밟는 것): 라우팅된 백엔드는 접두사가 벗겨진 경로를 받습니다. 위 출력의 라우팅 대상 백엔드가 보는 실제 키 를 보세요. 에이전트는 /memories/user.md 라고 불렀지만, 그 StoreBackend 에는 /user.md 로 저장돼 있습니다. /memories/ 부분은 CompositeBackend 가 떼어내고 넘긴 것이고, 결과를 돌려줄 때 다시 붙입니다. 이걸 모르고 커스텀 백엔드 안에서 if (path.startsWith("/memories/")) 같은 걸 쓰면 영원히 false 입니다. 에러는 안 나고 그냥 조건이 안 걸립니다. 5-9 에서 커스텀 백엔드를 만들 때 이 점을 꼭 기억하세요.

💡 실무 팁 — 기본 백엔드는 신중하게: CompositeBackend 의 1번 인자(기본 백엔드)는 라우팅에 안 걸리는 모든 경로를 받습니다. 그런데 Deep Agent 는 내부적으로도 파일시스템을 씁니다 — 큰 도구 결과를 파일로 오프로딩하거나(컨텍스트 절약), 대화 기록을 저장할 때요. 그 내부 파일들이 전부 기본 백엔드로 갑니다. 그러니 기본 백엔드를 FilesystemBackend 로 두면 에이전트 내부 부산물이 당신의 디스크에 쌓입니다. 기본은 StateBackend(휘발성)로 두고, 영속이 필요한 경로만 명시적으로 라우팅하는 게 정석입니다.

💡 실무 팁 — 접두사는 긴 것이 이깁니다: {"/a/": X, "/a/b/": Y} 에서 /a/b/c.txtY 로 갑니다. 더 구체적인 규칙이 우선입니다. 그리고 접두사는 슬래시로 끝내세요("/memories/"). "/memories" 로 쓰면 /memories-backup.txt 같은 게 딸려 들어옵니다.


5-7. permissions — 그 안에서 뭘 할 수 있나

백엔드가 "어디까지 보이나" 를 정했다면, permissions 는 "그 안에서 뭘 할 수 있나" 를 정합니다. 규칙은 필드 3개짜리 객체입니다.

필드타입
operations("read" | "write")[]"read"ls/read_file/glob/grep 을, "write"write_file/edit_file 을 덮습니다
pathsstring[]절대 glob 패턴. / 로 시작해야 하고 ..~ 를 못 씁니다. **(임의 깊이), *(한 세그먼트), {a,b}(택일) 지원
mode"allow" | "deny"생략하면 "allow". 이 둘뿐입니다 — "ask" 는 없습니다

그리고 평가 규칙은 딱 두 줄입니다.

1. 첫 매칭 규칙이 이긴다 (first-match-wins). 2. 아무 규칙에도 안 걸리면 허용된다 (permissive default).

// (A) 읽기 전용 에이전트: 모든 쓰기 금지
const readOnly: FilesystemPermission[] = [
  { operations: ["write"], paths: ["/**"], mode: "deny" },
];

// (B) 작업공간 격리: /workspace 안에서만 뭐든 하고, 나머지는 전부 금지
const workspaceOnly: FilesystemPermission[] = [
  { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
  { operations: ["read", "write"], paths: ["/**"], mode: "deny" },
];

// (C) 특정 파일 보호: .env 는 못 건드리되 나머지 workspace 는 허용
const protectEnv: FilesystemPermission[] = [
  { operations: ["read", "write"], paths: ["/workspace/.env", "/workspace/secrets/**"], mode: "deny" },
  { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
  { operations: ["read", "write"], paths: ["/**"], mode: "deny" },
];

const agent = createDeepAgent({ model: MODEL, backend: new StateBackend(), permissions: protectEnv });

protectEnv 규칙을 경로별로 평가해 보면 구조가 눈에 들어옵니다.

출력

  --- protectEnv 규칙을 손으로 평가 ---
  /workspace/main.ts: allow (paths=["/workspace/**"])
  /workspace/.env: deny (paths=["/workspace/.env","/workspace/secrets/**"])
  /etc/passwd: deny (paths=["/**"])
  /workspace/secrets/key.pem: deny (paths=["/workspace/.env","/workspace/secrets/**"])

세 규칙이 샌드위치 구조를 이룹니다. 좁은 deny(구멍 막기) → 중간 allow(작업 영역) → 넓은 deny(나머지 전부 차단). 방화벽 규칙과 똑같은 사고방식입니다.

거부되면 모델은 이런 ToolMessage 를 받습니다.

Error: permission denied for write on /workspace/.env

⚠️ 함정 1 — 순서가 전부다: 아래 두 배열은 규칙이 같고 순서만 다릅니다.

const good = [
  { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
  { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
];
const bad = [
  { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },  // ← 먼저 매칭됨
  { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" }, // ← 평가조차 안 됨
];

bad/workspace/.env 에 쓰면 allow 가 나옵니다. .env 가 그대로 뚫립니다. 에러도, 경고도, 로그도 없습니다. 첫 매칭에서 끝나므로 뒤의 deny 는 죽은 코드입니다. 좁은 규칙을 위로.

⚠️ 함정 2 — 기본이 allow 다: 아래 규칙은 "workspace 만 허용" 처럼 읽힙니다.

const leaky = [{ operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" }];

하지만 /etc/passwd 쓰기는 허용됩니다. 아무 규칙에도 안 걸리니까요. allow 규칙을 쓴다고 나머지가 자동으로 막히지 않습니다. 화이트리스트를 원하면 맨 끝에 { paths: ["/**"], mode: "deny" } 를 반드시 깔아야 합니다.

⚠️ 함정 3 — read 거부는 에러가 아니라 "안 보임"이다: write 를 거부당하면 모델은 위의 permission denied 에러를 받습니다. 하지만 ls/glob/grep 에서 read 가 거부된 항목은 에러 없이 결과에서 조용히 빠집니다(필터링). 모델은 그 파일이 존재하는지조차 모릅니다. 보안상으로는 이게 옳지만(존재 자체를 숨김), 디버깅할 때는 "왜 내 파일이 ls 에 안 나오지?" 하고 백엔드를 의심하게 됩니다. permissions 를 먼저 확인하세요.

💡 실무 팁: permissions 는 백엔드가 아니라 에이전트/서브에이전트 수준의 설정입니다. 그래서 백엔드를 StateBackend 에서 FilesystemBackend 로 갈아끼워도 규칙이 그대로 따라옵니다. 또 서브에이전트마다 다른 규칙을 줄 수 있습니다(Step 06 에서 다룹니다) — 단, 서브에이전트의 permissions 는 부모 것을 병합이 아니라 완전 교체합니다.


5-8. 샌드박스와 execute — 셸을 준다는 것

지금까지의 도구는 전부 "파일 하나를 읽고 쓴다" 수준이었습니다. execute 는 다릅니다. 임의의 셸 명령을 실행합니다. npm test 도, git commit 도, rm -rf / 도요.

execute 도구는 샌드박스 백엔드를 꽂았을 때만 생깁니다. 가장 간단한 것이 LocalShellBackend 입니다.

import { LocalShellBackend } from "deepagents/node";

const shell = await LocalShellBackend.create({
  rootDir: dir,
  virtualMode: true,
  timeout: 10,          // 초
  maxOutputBytes: 100_000,
});

console.log(await shell.execute("echo hello"));
console.log(await shell.execute("exit 3"));
await shell.close();

execute 의 반환은 구조가 결정적입니다.

interface ExecuteResponse {
  output: string;         // stdout + stderr 합본 (+ 종료코드 안내)
  exitCode: number | null;
  truncated: boolean;     // 출력이 잘렸는가
}

출력

  execute('echo hello'): {"output":"hello\n","exitCode":0,"truncated":false}
  execute('exit 3'): {"output":"<no output>\n\nExit code: 3","exitCode":3,"truncated":false}
  execute('node -v'): {"output":"[stderr] /bin/sh: node: command not found\n\nExit code: 127","exitCode":127,"truncated":false}

출력이 없으면 <no output>, stderr 는 [stderr] 로 표시되고, 0 이 아닌 종료코드는 Exit code: N 이 붙습니다. 모델이 실패를 읽고 스스로 고칠 수 있게 하기 위한 형식입니다.

세 번째 줄을 보세요. node -vcommand not found 입니다. inheritEnv 기본값이 false 라서 PATH 가 최소한으로만 잡혀 있기 때문입니다.

그리고 진짜 함정

const shell = await LocalShellBackend.create({ rootDir: dir, virtualMode: true, timeout: 10 });

// (a) 파일 도구로 rootDir 밖 읽기
console.log(await shell.read("/etc/hosts"));

// (b) 셸로 rootDir 밖 읽기
console.log(await shell.execute("cat /etc/hosts | head -2"));

출력

  read('/etc/hosts'): {"error":"File '/etc/hosts' not found"}
  execute('cat /etc/hosts'): ## ⏎ # Host Database ⏎

⚠️ 함정 (이 스텝에서 가장 위험한 것): virtualMode: true 인데도 셸은 rootDir 밖으로 나갑니다. 같은 백엔드, 같은 설정, 같은 경로인데 파일 도구는 막히고 셸은 뚫립니다.

이유는 단순합니다. virtualMode백엔드의 파일 메서드가 경로를 어떻게 해석하는가만 바꾸는 설정입니다. execute 는 그 경로 해석기를 거치지 않고 문자열을 그대로 /bin/sh 에 넘깁니다. 셸에게 rootDir 는 그저 시작 cwd 일 뿐, 감옥이 아닙니다. cd / 한 줄이면 끝입니다.

그래서 LocalShellBackend 를 쓰면서 "virtualMode 켰으니 안전하지" 라고 믿으면, 에이전트는 ~/.ssh/id_rsa~/.aws/credentials 도 읽을 수 있습니다. 문서에도 이렇게 쓰여 있습니다 — "The command runs with your user's full permissions." LocalShellBackend 의 "Local" 은 "격리 없음" 으로 읽으세요.

그럼 어떻게 하나

셸이 필요하다고 느낄 때, 이 순서로 자문하세요.

  1. 셸이 정말 필요한가? 대개는 아닙니다. "숫자를 계산해줘", "JSON 을 변환해줘" 라면 인터프리터로 충분합니다. @langchain/quickjscreateCodeInterpreterMiddleware()eval 도구를 추가하는데, QuickJS WASM 안에서 돕니다. 파일시스템도, 네트워크도, 셸도, 시계조차 없습니다. 호스트 Node 프로세스가 아닙니다.

    import { createCodeInterpreterMiddleware } from "@langchain/quickjs";
    
    const agent = createDeepAgent({
      model: MODEL,
      middleware: [createCodeInterpreterMiddleware()],
    });
  2. 진짜 셸이 필요하다면, 프로세스 밖으로 내보내라. 원격 샌드박스를 쓰면 명령이 당신의 머신이 아니라 일회용 격리 환경에서 돕니다.

    import { createDeepAgent, LangSmithSandbox } from "deepagents";
    import { SandboxClient } from "langsmith/sandbox";
    
    const sandbox = await new SandboxClient().createSandbox();
    const agent = createDeepAgent({ model: MODEL, backend: new LangSmithSandbox({ sandbox }) });

    LangSmith 외에 Deno, Daytona, Modal 등도 지원됩니다.

  3. 그래도 LocalShellBackend 를 쓴다면, 그 프로세스 자체를 가둬라. 컨테이너 안에서 돌리세요. 백엔드 설정이 아니라 컨테이너 경계가 진짜 방어선입니다.

💡 실무 팁: execute 출력이 너무 크면 자동으로 파일에 저장되고, 모델은 read_file 로 조금씩 읽으라는 안내를 받습니다(truncated: true). 그래서 maxOutputBytes 를 너무 작게 잡으면 매 명령마다 파일 왕복이 생겨 느려지고 토큰도 더 씁니다. 기본값 근처(10만 바이트)에서 시작하세요. timeout 은 초 단위이며, 무한 루프에 빠진 명령을 끊어주는 유일한 안전장치이니 반드시 설정하세요.


5-9. 커스텀 백엔드 만들기 — BackendProtocol 구현

내장 백엔드로 안 되면 직접 만듭니다. 메서드 7개만 구현하면 됩니다.

interface BackendProtocolV2 {
  ls(path: string): MaybePromise<LsResult>;
  read(filePath: string, offset?: number, limit?: number): MaybePromise<ReadResult>;
  readRaw(filePath: string): MaybePromise<ReadRawResult>;
  grep(pattern: string, path?: string | null, glob?: string | null): MaybePromise<GrepResult>;
  glob(pattern: string, path?: string): MaybePromise<GlobResult>;
  write(filePath: string, content: string): MaybePromise<WriteResult>;
  edit(filePath: string, oldString: string, newString: string, replaceAll?: boolean): MaybePromise<EditResult>;
  delete?(filePath: string): MaybePromise<DeleteResult>;   // 선택
}

MaybePromise 이므로 동기든 비동기든 상관없습니다. 반환 타입도 단순합니다.

타입성공 필드실패
LsResultfiles?: FileInfo[]error?: string
ReadResultcontent?: string | Uint8Array, mimeType?: stringerror?: string
WriteResultpath?: stringerror?: string
EditResultpath?: string, occurrences?: numbererror?: string
GlobResultfiles?: FileInfo[]error?: string
GrepResultmatches?: GrepMatch[]error?: string

FileInfo{ path, is_dir?, size?, modified_at? }, GrepMatch{ path, line, text } 입니다.

S3 를 흉내낸 인메모리 목을 만들어 봅시다.

class MockS3Backend implements BackendProtocolV2 {
  private objects = new Map<string, string>();
  public readonly log: string[] = [];

  constructor(private bucket: string) {}

  read(filePath: string): ReadResult {
    this.log.push(`read ${filePath}`);
    const body = this.objects.get(filePath);
    // ⚠️ 없는 파일은 throw 가 아니라 { error } 로 돌려줍니다.
    if (body === undefined) return { error: `File '${filePath}' not found in s3://${this.bucket}` };
    return { content: body, mimeType: "text/plain" };
  }

  write(filePath: string, content: string): WriteResult {
    this.log.push(`write ${filePath}`);
    this.objects.set(filePath, content);
    return { path: filePath };
  }

  // ls / readRaw / edit / glob / grep 도 같은 식으로 구현 (practice.ts 참고)
}

출력

  read: {"content":"# 리포트\n매출: 100\n비용: 40\n","mimeType":"text/plain"}
  없는 파일 read: {"error":"File '/nope.md' not found in s3://my-agent-bucket"}
  edit: {"path":"/report.md","occurrences":1}
  edit 후 read: {"content":"# 리포트\n매출: 120\n비용: 40\n","mimeType":"text/plain"}
  grep '매출': {"matches":[{"path":"/report.md","line":2,"text":"매출: 120"}]}
  백엔드가 받은 호출 로그: ["write /report.md","read /report.md","read /nope.md","edit /report.md","read /report.md","grep 매출","ls /"]
  커스텀 백엔드 에이전트 생성: true

만들었으면 그냥 꽂습니다. 조합도 됩니다.

const agent = createDeepAgent({ model: MODEL, backend: new MockS3Backend("my-bucket") });

const composed = new CompositeBackend(new StateBackend(), {
  "/s3/": new MockS3Backend("bucket-2"),
});

⚠️ 함정 — throw 하지 말고 { error } 를 반환하라: 커스텀 백엔드에서 파일이 없을 때 throw new Error("not found") 를 하고 싶어집니다. 하지만 그러면 도구 호출 자체가 터지고 에이전트 루프가 죽습니다. { error: "..." } 로 돌려주면 그 문자열이 ToolMessage 로 모델에게 전달되고, 모델은 "아 없구나, ls 로 찾아보자" 하고 스스로 복구합니다. 이게 Deep Agent 가 견고하게 도는 이유입니다. 에러 문자열은 모델이 읽는 프롬프트라고 생각하고 쓰세요 — "error" 보다 "File '/nope.md' not found in s3://my-bucket" 이 훨씬 낫습니다.

⚠️ 함정 2 — CompositeBackend 안에서는 경로가 다르다: 5-6 에서 본 그것입니다. "/s3/" 로 라우팅하면 커스텀 백엔드는 /s3/report.md 가 아니라 /report.md 를 받습니다. 커스텀 백엔드는 자기가 어떤 접두사 아래 마운트됐는지 알 수도 없고 알 필요도 없습니다. 항상 / 부터 시작하는 자기만의 세계라고 생각하고 구현하세요.

💡 실무 팁 — 커스텀 백엔드 vs permissions: "쓰기를 막고 싶다" 정도라면 커스텀 백엔드를 만들지 말고 permissions 를 쓰세요. 선언적이고, 서브에이전트별로 갈아끼울 수 있고, 백엔드를 교체해도 규칙이 따라옵니다. 커스텀 백엔드가 필요한 건 경로 규칙으로 표현할 수 없는 정책일 때입니다 — "S3 에 저장", "쓰기 전에 감사 로그", "하루 100회 제한", "쓰기 시 자동 백업". 정리하면 permissions = 어디에(where), 커스텀 백엔드 = 어떻게(how) 입니다.


5-10. 종합 — 라우팅 + 권한을 함께

실무에서 쓰는 형태를 조립해 봅시다. /memories 는 영속, /workspace 는 디스크, 나머지는 상태. 그리고 .env 는 권한으로 잠급니다.

const agent = createDeepAgent({
  model: MODEL,
  store,
  backend: new CompositeBackend(new StateBackend(), {
    "/memories/": new StoreBackend({ store, namespace: () => ["memories"] }),
    "/workspace/": new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true }),
  }),
  permissions: [
    // deny 를 먼저 — 순서가 뒤집히면 .env 가 그대로 뚫립니다.
    { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
    { operations: ["read", "write"], paths: ["/workspace/**", "/memories/**"], mode: "allow" },
    { operations: ["write"], paths: ["/**"], mode: "deny" },
  ],
  systemPrompt:
    "너는 파일을 다루는 조수다. 작업 파일은 /workspace 에, 오래 기억할 것은 /memories 에 둔다.",
});

const result = await agent.invoke({
  messages: [{
    role: "user",
    content:
      "/workspace/notes.md 를 읽고 한 줄 요약해서 /memories/summary.md 에 저장해줘. " +
      "그리고 /workspace/.env 도 읽어보고, 읽히는지 안 읽히는지 알려줘.",
  }],
});

출력 예시 (모델 응답이므로 매번 다릅니다)

  --- 모델 최종 답변 ---
  /workspace/notes.md 를 읽고 요약해서 /memories/summary.md 에 저장했습니다.
  요약: "백엔드는 CompositeBackend 로 간다"

  /workspace/.env 는 읽을 수 없었습니다. 다음 에러가 반환되었습니다:
    Error: permission denied for read on /workspace/.env

이 한 번의 실행에서 네 가지가 동시에 일어났습니다.

  1. /workspace/notes.md 읽기 → FilesystemBackend 로 라우팅 → 진짜 디스크에서 읽음
  2. /memories/summary.md 쓰기 → StoreBackend 로 라우팅 → Store 에 영속
  3. /workspace/.env 읽기 → permissions 첫 규칙에 걸려 거부
  4. 모델은 거부를 에러 메시지로 받고, 죽지 않고 사용자에게 보고

그리고 /memories/summary.md 는 이 invoke 가 끝나도 store 에 남습니다.

출력

  store 에 남은 /memories: {"files":[{"path":"/summary.md","is_dir":false,"size":…,"modified_at":"…"}]}

여기서도 접두사가 벗겨져 /summary.md 로 저장된 것을 볼 수 있습니다(5-6 함정).

💡 실무 팁: 이 조합(CompositeBackend + permissions)이 프로덕션 Deep Agent 의 기본형이라고 생각하세요. 여기서 셸이 필요해지면 기본 백엔드를 원격 샌드박스로 바꾸고, 사용자별 분리가 필요해지면 StoreBackendnamespace 를 팩토리로 바꿉니다. 구조는 그대로입니다.


정리

백엔드저장 위치수명주의할 점
StateBackendLangGraph 상태스레드 한정직접 호출 불가 (런타임 주입 필요)
FilesystemBackend로컬 디스크영구virtualMode: true 없으면 격리 안 됨
StoreBackendLangGraph Store스레드 간 영속namespace 고정 시 사용자끼리 섞임
CompositeBackend접두사별 위임대상에 따름라우팅 대상은 접두사가 벗겨진 경로를 받음
LocalShellBackend디스크 + 셸영구executevirtualMode 를 무시함
원격 샌드박스격리 환경샌드박스 수명셸이 꼭 필요할 때의 정답
커스텀 (BackendProtocolV2)마음대로마음대로메서드 7개, throw 대신 { error }

permissions 요약

{ operations: ("read" | "write")[], paths: string[], mode?: "allow" | "deny" }
  • 첫 매칭 규칙이 이긴다 → 좁은 deny 를 위로, 넓은 allow 를 아래로
  • 기본은 allow → 화이트리스트를 원하면 맨 끝에 { paths: ["/**"], mode: "deny" }
  • "ask" 모드는 없다 → 승인을 받고 싶으면 interruptOn(Step 09)

핵심 함정 3가지

  1. rootDir 는 격리가 아니다. virtualMode: true 를 켜야 격리됩니다. 안 켜면 rootDir 를 줬어도 /etc/hosts 가 그대로 읽힙니다 — 에러도 경고도 없이.
  2. executevirtualMode 를 지키지 않는다. 파일 도구는 막히는데 셸은 뚫립니다. LocalShellBackend 의 격리는 없습니다. 셸이 필요하면 인터프리터나 원격 샌드박스로 가세요.
  3. 권한 규칙은 순서가 전부다. 넓은 allow 를 위에 두면 아래의 deny 는 평가조차 안 됩니다. .env 가 조용히 뚫립니다. 그리고 아무 규칙에도 안 걸리면 허용입니다.

연습문제

  1. 백엔드 고르기: 다음 4가지 상황에 어떤 백엔드를 쓸지 고르고 이유를 한 줄로 적으세요. (a) 새로고침하면 날아가도 되는 브라우저 데모 (b) 사용자 코드 저장소를 리팩터링하는 CLI (c) 대화가 끝나도 사용자 선호를 기억하는 챗봇 (d) (c)를 하면서 작업 파일은 디스크에 두기
  2. virtualMode 로 탈출 막기: 임시 디렉터리를 rootDir 로 하는 FilesystemBackendvirtualMode: true 로 만들고, "/../../etc/passwd", "/etc/hosts", "/~/.ssh/id_rsa" 세 시도가 전부 막히는지 확인하세요. 세 개가 서로 다른 이유로 막힙니다 — 각각 무엇이 다른지 설명하세요.
  3. namespace 격리: InMemoryStore 하나를 공유하되 namespace 가 다른 StoreBackend 2개를 만들어, user-a 가 쓴 파일이 user-b 에게 안 보이는 것을 확인하세요. 그다음 같은 namespace 로 다시 열면 남아 있는 것(영속)도 확인하세요.
  4. CompositeBackend 조립: /memories/ → Store, /workspace/ → 디스크, 나머지 → 기본 으로 라우팅하는 백엔드를 만들고 각 경로에 파일을 쓰세요. fs.readdir/workspace/ 것만 진짜 디스크에 있는지 검증하고, 라우팅된 Store 가 실제로 가진 키가 무엇인지도 출력해 보세요.
  5. 권한 규칙의 순서: checkWrite(rules, path) 를 구현해서, "같은 규칙 다른 순서"인 두 배열이 /workspace/.env 에 대해 각각 어떤 결정을 내는지 출력하세요. (첫 매칭 승리 + 기본 allow)
  6. 셸은 virtualMode 를 안 지킨다: LocalShellBackendvirtualMode: true 로 만들고, shell.read("/etc/hosts")shell.execute("cat /etc/hosts") 의 결과가 다른 것을 확인하세요. 왜 다른지 주석으로 설명하세요.
  7. 커스텀 백엔드 — 읽기 전용 래퍼: 다른 백엔드를 감싸 읽기는 위임하고 write/edit{ error } 로 거부하는 ReadOnlyBackend 를 만드세요. 보너스: 같은 일을 permissions 로도 할 수 있는데, 어느 쪽이 나을까요?

문제만 담긴 파일은 exercise.ts, 정답과 해설은 solution.ts 입니다. 두 파일 모두 아래 실습 파일 섹션에 전문이 실려 있습니다.


다음 단계

Step 06 — 서브에이전트

백엔드로 "에이전트가 무엇을 볼 수 있는가" 를 정했습니다. 다음은 컨텍스트입니다. 에이전트가 파일을 30개 읽으면 그 내용이 전부 대화에 쌓여 컨텍스트가 터집니다. 서브에이전트는 그 문제를 "일을 나눠서" 가 아니라 "부모의 컨텍스트를 보호해서" 푸는 도구입니다. 그리고 서브에이전트마다 다른 backendpermissions 를 줄 수 있으니, 이번 스텝의 내용이 그대로 이어집니다.


실습 파일

이 스텝은 TypeScript 파일 3개로 구성됩니다. 본문(5-1 ~ 5-10)의 예제를 순서대로 담은 practice.ts 를 먼저 실행해 결과를 눈으로 확인하고, 그다음 exercise.ts 의 7개 문제를 직접 풀어본 뒤, 마지막으로 solution.ts 로 채점하고 해설을 읽는 흐름입니다.

세 파일 모두 docs/reference/deepagent/ 에서 npx tsx step-05-backends/practice.ts 처럼 실행합니다. 대부분의 예제는 API 키 없이 그대로 돌아갑니다 — 백엔드 객체를 직접 두드려 보는 예제라 모델을 안 부르기 때문입니다. practice.ts[5-10] 만 실제 모델을 호출하며, ANTHROPIC_API_KEY 가 없으면 그 절만 조용히 건너뜁니다. OpenAI 로 바꾸려면 각 파일 상단의 MODEL 상수를 "openai:gpt-5.5" 로 고치고 OPENAI_API_KEY 를 넣으면 됩니다.

practice.ts

본문을 따라가며 손으로 쳐볼 예제를 [5-1] ~ [5-10] 주석 번호로 묶어 놓은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응하므로, 본문을 읽다가 막히면 같은 번호의 블록을 찾아 실행해 보면 됩니다.

  • [5-1]createDeepAgent(...) instanceof Promise 를 실제로 찍어서 await 가 필요 없다는 것을 눈으로 확인시킵니다. 문서마다 await 를 붙인 예제와 안 붙인 예제가 섞여 있어 헷갈리기 쉬운 지점이라, 코드로 못을 박아 둡니다.
  • [5-4] 가 이 파일의 심장입니다. 같은 rootDirvirtualModetrue/false 로 바꾼 백엔드 2개를 만들어 /etc/hosts 를 읽습니다. 앞은 ENOENT, 뒤는 진짜 시스템 파일 내용이 나옵니다. 이 두 줄이 본문 5-4 함정의 전부입니다.
  • [5-6] 의 마지막 두 줄은 CompositeBackend 에 라우팅된 StoreBackend직접 열어서 ls("/") 를 찍습니다. 에이전트가 /memories/user.md 로 쓴 파일이 거기서는 /user.md 로 보입니다 — 접두사가 벗겨지는 것을 증명하는 대목입니다.
  • [5-8]shell.read("/etc/hosts")(막힘)와 shell.execute("cat /etc/hosts")(뚫림)를 연달아 실행합니다. 같은 백엔드 인스턴스에서 두 결과가 갈리는 것을 보고 나면 LocalShellBackend 를 함부로 못 씁니다. execute("node -v")command not found 로 실패하는 것도 정상입니다 — inheritEnv 기본값이 falsePATH 가 최소입니다.
  • [5-9]MockS3Backendlog 배열에 모든 호출을 기록합니다. 마지막에 이 로그를 찍으므로 "백엔드가 실제로 어떤 순서로 불렸는가" 를 볼 수 있습니다. 커스텀 백엔드를 디버깅할 때 그대로 쓸 수 있는 패턴입니다.
  • [5-10] 만 모델을 부릅니다. 키가 없으면 안내 문구를 찍고 넘어가니, 키 없이 파일을 통째로 실행해도 끝까지 돕니다.
/**
 * Step 05 — 백엔드와 권한
 * 실행: npx tsx docs/reference/deepagent/step-05-backends/practice.ts
 *
 * 이 파일은 본문 5-1 ~ 5-10 의 예제를 순서대로 담고 있습니다.
 * [5-2] ~ [5-7], [5-9] 는 LLM 호출 없이 백엔드 객체를 직접 두드려 보는 예제라
 * ANTHROPIC_API_KEY 없이도 그대로 돌아갑니다.
 * [5-10] 만 실제 모델을 호출하므로 키가 필요합니다.
 */
import "dotenv/config";
import {
  createDeepAgent,
  StateBackend,
  StoreBackend,
  CompositeBackend,
  type BackendProtocolV2,
  type LsResult,
  type ReadResult,
  type ReadRawResult,
  type WriteResult,
  type EditResult,
  type GlobResult,
  type GrepResult,
  type FilesystemPermission,
} from "deepagents";
// FilesystemBackend / LocalShellBackend 는 Node 전용입니다.
// "deepagents" 에서도 나오지만, Node 전용임을 코드로 드러내려고 /node 에서 가져옵니다.
import { FilesystemBackend, LocalShellBackend } from "deepagents/node";
import { InMemoryStore } from "@langchain/langgraph";
import * as path from "node:path";
import * as os from "node:os";
import * as fs from "node:fs/promises";

const MODEL = "anthropic:claude-sonnet-4-6";
// OpenAI 로 바꾸려면: const MODEL = "openai:gpt-5.5";  (OPENAI_API_KEY 필요)

/** 결과를 짧게 잘라 출력하는 헬퍼 */
function show(label: string, value: unknown, max = 200) {
  const s = typeof value === "string" ? value : JSON.stringify(value);
  console.log(`  ${label}: ${s.length > max ? s.slice(0, max) + " …" : s}`);
}

/* ===== [5-1] 백엔드 = 파일 도구의 실제 구현체 ===== */
// 도구 이름(ls, read_file, write_file, edit_file, glob, grep)은 백엔드가 바뀌어도 그대로입니다.
// 바뀌는 것은 "그 도구가 어디에 쓰느냐" 뿐입니다.
// 아래 두 에이전트는 도구 목록이 완전히 같고, 저장소만 다릅니다.
async function section_5_1() {
  console.log("\n=== [5-1] 도구는 그대로, 저장소만 바뀐다 ===");

  const stateAgent = createDeepAgent({ model: MODEL, backend: new StateBackend() });
  const diskAgent = createDeepAgent({
    model: MODEL,
    backend: new FilesystemBackend({ rootDir: process.cwd(), virtualMode: true }),
  });

  // createDeepAgent 는 동기 함수입니다. Promise 가 아닙니다.
  show("createDeepAgent 가 Promise 인가?", stateAgent instanceof Promise);
  show("invoke 는 함수인가?", typeof stateAgent.invoke === "function");
  show("diskAgent 도 동일한 형태인가?", typeof diskAgent.invoke === "function");
}

/* ===== [5-2] 백엔드 카탈로그 — 전부 같은 인터페이스를 만족한다 ===== */
// StateBackend / FilesystemBackend / StoreBackend / CompositeBackend / LocalShellBackend 는
// 모두 BackendProtocolV2 를 구현합니다. 즉 서로 갈아끼울 수 있습니다.
async function section_5_2() {
  console.log("\n=== [5-2] 모든 백엔드는 같은 메서드 집합을 갖는다 ===");

  const methods = ["ls", "read", "readRaw", "write", "edit", "glob", "grep"] as const;
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "da-5-2-"));

  const candidates: Array<[string, object]> = [
    ["StateBackend", new StateBackend()],
    ["FilesystemBackend", new FilesystemBackend({ rootDir: tmp, virtualMode: true })],
    ["StoreBackend", new StoreBackend({ store: new InMemoryStore(), namespace: () => ["demo"] })],
    ["CompositeBackend", new CompositeBackend(new StateBackend(), {})],
  ];

  for (const [name, backend] of candidates) {
    const has = methods.every((m) => typeof (backend as Record<string, unknown>)[m] === "function");
    show(name, `BackendProtocolV2 메서드 전부 구현? ${has}`);
  }
}

/* ===== [5-3] StateBackend — 상태에 저장, 스레드가 끝나면 사라진다 ===== */
// StateBackend 는 LangGraph 상태(state.files)에 파일을 넣습니다.
// 디스크도 네트워크도 안 씁니다 → 브라우저에서도 안전합니다.
async function section_5_3() {
  console.log("\n=== [5-3] StateBackend ===");

  // 브라우저 번들에서는 이렇게 가져옵니다 (Node 전용 모듈이 딸려오지 않음):
  //   import { StateBackend, createDeepAgent } from "deepagents/browser";
  const agent = createDeepAgent({ model: MODEL, backend: new StateBackend() });
  show("StateBackend 에이전트 생성", typeof agent.invoke === "function");

  // ⚠️ StateBackend 를 직접 호출하면 터집니다. 런타임(state)이 주입돼야 동작합니다.
  const naked = new StateBackend();
  try {
    naked.ls("/");
    console.log("  (예상과 다름: 에러가 안 났습니다)");
  } catch (e) {
    show("직접 호출하면", (e as Error).message);
  }
}

/* ===== [5-4] FilesystemBackend — 로컬 디스크를 LLM 에게 준다 ===== */
// rootDir 로 "어디를" 주는지 정하고, virtualMode 로 "그 밖으로 못 나가게" 막습니다.
async function section_5_4() {
  console.log("\n=== [5-4] FilesystemBackend 와 virtualMode ===");

  const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "da-5-4-"));
  await fs.writeFile(path.join(sandboxDir, "readme.txt"), "이 파일은 rootDir 안에 있습니다.\n");

  // (A) virtualMode: true — rootDir 가 곧 "/" 가 된다
  const guarded = new FilesystemBackend({ rootDir: sandboxDir, virtualMode: true });
  show("(A) ls('/')", await guarded.ls("/"));
  show("(A) read('/readme.txt')", await guarded.read("/readme.txt"));

  // 상대경로 탈출 시도 → 차단
  show("(A) read('/../../etc/passwd')", await guarded.read("/../../etc/passwd"));
  // 절대경로 시도 → rootDir/etc/hosts 로 해석되어 없는 파일
  show("(A) read('/etc/hosts')", await guarded.read("/etc/hosts"));

  // (B) virtualMode: false — "/" 가 진짜 디스크 루트다
  const open = new FilesystemBackend({ rootDir: sandboxDir, virtualMode: false });
  const leaked = await open.read("/etc/hosts");
  show("(B) read('/etc/hosts')", leaked.content ? "실제 /etc/hosts 내용이 그대로 읽힘!" : leaked);
}

/* ===== [5-5] StoreBackend — 실행 간 영속 ===== */
// StoreBackend 는 LangGraph Store 에 씁니다. 스레드가 끝나도, 다른 스레드에서도 읽힙니다.
async function section_5_5() {
  console.log("\n=== [5-5] StoreBackend ===");

  const store = new InMemoryStore();

  // namespace 는 "누구의 파일인가" 를 가르는 칸막이입니다.
  const backend = new StoreBackend({ store, namespace: () => ["memories", "user-123"] });

  await backend.write("/preference.md", "- 답변은 한국어로\n- 코드는 TypeScript 로\n");
  show("write 후 ls('/')", await backend.ls("/"));
  show("read('/preference.md')", await backend.read("/preference.md"));

  // 같은 store, 다른 namespace → 서로 안 보입니다.
  const otherUser = new StoreBackend({ store, namespace: () => ["memories", "user-999"] });
  show("다른 namespace 의 ls('/')", await otherUser.ls("/"));

  // 같은 namespace 로 새 백엔드를 만들면 → 그대로 남아 있습니다 (영속).
  const reopened = new StoreBackend({ store, namespace: () => ["memories", "user-123"] });
  show("같은 namespace 재접속 read", await reopened.read("/preference.md"));

  // 실무에서는 namespace 를 런타임 컨텍스트에서 뽑습니다:
  //   new StoreBackend({ namespace: (ctx) => [ctx.state.userId as string] })
  // 그리고 store 는 createDeepAgent({ store }) 로 넘깁니다.
  const agent = createDeepAgent({ model: MODEL, backend: new StoreBackend({ namespace: () => ["memories"] }), store });
  show("Store 백엔드 에이전트 생성", typeof agent.invoke === "function");
}

/* ===== [5-6] CompositeBackend — 경로 접두사로 라우팅 ===== */
// "/memories/" 는 영속 Store 로, "/workspace/" 는 디스크로, 나머지는 상태로.
async function section_5_6() {
  console.log("\n=== [5-6] CompositeBackend ===");

  const store = new InMemoryStore();
  const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "da-5-6-"));

  const backend = new CompositeBackend(
    // 1번 인자 = 기본 백엔드 (라우팅에 안 걸리는 모든 경로)
    new StoreBackend({ store, namespace: () => ["scratch"] }),
    // 2번 인자 = 접두사 → 백엔드 라우팅 표
    {
      "/memories/": new StoreBackend({ store, namespace: () => ["memories"] }),
      "/workspace/": new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true }),
    },
  );

  show("routePrefixes", backend.routePrefixes);

  await backend.write("/memories/user.md", "이름: 김개발");
  await backend.write("/workspace/main.ts", "console.log('hi');");
  await backend.write("/scratch.txt", "아무 접두사에도 안 걸림 → 기본 백엔드로");

  show("read /memories/user.md", await backend.read("/memories/user.md"));
  show("read /workspace/main.ts", await backend.read("/workspace/main.ts"));
  show("read /scratch.txt", await backend.read("/scratch.txt"));

  // /workspace/ 는 진짜 디스크에 떨어졌습니다.
  show("실제 디스크 확인", await fs.readdir(workspaceDir));

  // ⚠️ 라우팅된 백엔드는 접두사가 "벗겨진" 경로를 받습니다.
  const routed = new StoreBackend({ store, namespace: () => ["memories"] });
  show("라우팅 대상 백엔드가 보는 실제 키", await routed.ls("/"));

  // ls("/") 는 모든 백엔드 결과를 접두사를 붙여 합칩니다.
  show("composite ls('/')", await backend.ls("/"));
}

/* ===== [5-7] permissions — 읽기/쓰기 접근 제어 ===== */
// 규칙은 { operations, paths, mode } 3개 필드. 첫 매칭 규칙이 이깁니다.
// 아무 규칙에도 안 걸리면 기본은 "allow" 입니다 (관대한 기본값).
async function section_5_7() {
  console.log("\n=== [5-7] permissions ===");

  // (A) 읽기 전용 에이전트: 모든 쓰기 금지
  const readOnly: FilesystemPermission[] = [
    { operations: ["write"], paths: ["/**"], mode: "deny" },
  ];

  // (B) 작업공간 격리: /workspace 안에서만 뭐든 하고, 나머지는 전부 금지
  const workspaceOnly: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
    { operations: ["read", "write"], paths: ["/**"], mode: "deny" },
  ];

  // (C) 특정 파일 보호: .env 는 못 건드리되 나머지 workspace 는 허용
  //     ⚠️ 순서가 전부입니다. deny 규칙이 allow 규칙보다 먼저 와야 합니다.
  const protectEnv: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/.env", "/workspace/secrets/**"], mode: "deny" },
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
    { operations: ["read", "write"], paths: ["/**"], mode: "deny" },
  ];

  for (const [label, rules] of [
    ["읽기 전용", readOnly],
    ["작업공간 격리", workspaceOnly],
    [".env 보호", protectEnv],
  ] as Array<[string, FilesystemPermission[]]>) {
    const agent = createDeepAgent({ model: MODEL, backend: new StateBackend(), permissions: rules });
    show(label, `규칙 ${rules.length}개로 에이전트 생성 완료 (${typeof agent.invoke === "function"})`);
  }

  // 규칙 평가를 손으로 흉내내 보면 "첫 매칭 승리 + 기본 allow" 가 눈에 들어옵니다.
  console.log("  --- protectEnv 규칙을 손으로 평가 ---");
  for (const p of ["/workspace/main.ts", "/workspace/.env", "/etc/passwd", "/workspace/secrets/key.pem"]) {
    const hit = protectEnv.find(
      (r) => r.operations.includes("write") && r.paths.some((pat) => simpleGlob(p, pat)),
    );
    show(p, hit ? `${hit.mode} (paths=${JSON.stringify(hit.paths)})` : "매칭 규칙 없음 → allow");
  }
}

/**
 * 본문 설명용 아주 단순화한 glob 매처 (실제 구현과 다릅니다 — 감을 잡기 위한 것).
 * "**" 로 먼저 쪼개고, 각 조각 안에서 "*" 로 다시 쪼갠 뒤 이어붙입니다.
 *   "**" -> ".*"      (슬래시를 넘어 임의 깊이)
 *   "*"  -> "[^/]*"   (한 세그먼트 안에서만)
 */
function simpleGlob(p: string, pattern: string): boolean {
  const escape = (s: string) => s.replace(/[.*+^${}()|[\]\\]/g, "\\$&");
  const source = pattern
    .split("**")
    .map((chunk) => chunk.split("*").map(escape).join("[^/]*"))
    .join(".*");
  return new RegExp(`^${source}$`).test(p);
}

/* ===== [5-8] 샌드박스와 execute — 셸을 준다는 것 ===== */
// LocalShellBackend 는 FilesystemBackend 를 상속하면서 execute 를 추가합니다.
// 이름이 "Local" 인 이유: 격리가 없습니다. 당신의 호스트에서 그대로 돕니다.
async function section_5_8() {
  console.log("\n=== [5-8] LocalShellBackend 와 execute ===");

  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "da-5-8-"));
  const shell = await LocalShellBackend.create({
    rootDir: dir,
    virtualMode: true,
    timeout: 10, // 초
    maxOutputBytes: 100_000,
  });

  // execute 의 반환은 결정적인 구조입니다: { output, exitCode, truncated }
  show("execute('echo hello')", await shell.execute("echo hello"));
  show("execute('exit 3')", await shell.execute("exit 3"));

  // ⚠️ 핵심: virtualMode 는 파일 도구만 막습니다. 셸은 못 막습니다.
  const escape = await shell.execute("cat /etc/hosts | head -2");
  show("execute('cat /etc/hosts')", escape.output);
  show("파일 도구로 같은 경로 읽기", await shell.read("/etc/hosts"));

  // inheritEnv 기본값이 false 라서 PATH 가 최소입니다 → node 조차 없습니다.
  show("execute('node -v')", await shell.execute("node -v"));

  await shell.close();

  // 진짜 격리가 필요하면 원격 샌드박스를 씁니다:
  //   import { LangSmithSandbox } from "deepagents";
  //   import { SandboxClient } from "langsmith/sandbox";
  //   const sandbox = await new SandboxClient().createSandbox();
  //   const agent = createDeepAgent({ model: MODEL, backend: new LangSmithSandbox({ sandbox }) });
  //
  // 셸이 아니라 "계산"만 필요하면 인터프리터가 훨씬 안전합니다 (WASM 격리):
  //   import { createCodeInterpreterMiddleware } from "@langchain/quickjs";
  //   const agent = createDeepAgent({ model: MODEL, middleware: [createCodeInterpreterMiddleware()] });
}

/* ===== [5-9] 커스텀 백엔드 — BackendProtocolV2 구현 ===== */
// 7개 메서드만 구현하면 어디든 백엔드가 됩니다. 여기선 S3 를 흉내낸 인메모리 목입니다.
class MockS3Backend implements BackendProtocolV2 {
  private objects = new Map<string, string>();
  public readonly log: string[] = [];

  constructor(private bucket: string) {}

  ls(dirPath: string): LsResult {
    this.log.push(`ls ${dirPath}`);
    const prefix = dirPath.endsWith("/") ? dirPath : `${dirPath}/`;
    const files = [...this.objects.entries()]
      .filter(([k]) => k.startsWith(prefix) || dirPath === "/")
      .map(([k, v]) => ({ path: k, is_dir: false, size: v.length }));
    return { files };
  }

  read(filePath: string, offset?: number, limit?: number): ReadResult {
    this.log.push(`read ${filePath}`);
    const body = this.objects.get(filePath);
    // ⚠️ 없는 파일은 throw 가 아니라 { error } 로 돌려줍니다. 그래야 모델이 읽고 대처합니다.
    if (body === undefined) return { error: `File '${filePath}' not found in s3://${this.bucket}` };
    if (offset === undefined && limit === undefined) return { content: body, mimeType: "text/plain" };
    const lines = body.split("\n").slice(offset ?? 0, (offset ?? 0) + (limit ?? Infinity));
    return { content: lines.join("\n"), mimeType: "text/plain" };
  }

  readRaw(filePath: string): ReadRawResult {
    const body = this.objects.get(filePath);
    if (body === undefined) return { error: `File '${filePath}' not found` };
    return { data: { content: body } as ReadRawResult["data"] };
  }

  write(filePath: string, content: string): WriteResult {
    this.log.push(`write ${filePath}`);
    this.objects.set(filePath, content);
    return { path: filePath };
  }

  edit(filePath: string, oldString: string, newString: string, replaceAll?: boolean): EditResult {
    this.log.push(`edit ${filePath}`);
    const body = this.objects.get(filePath);
    if (body === undefined) return { error: `File '${filePath}' not found` };
    if (!body.includes(oldString)) return { error: `String not found in '${filePath}'` };
    const occurrences = replaceAll ? body.split(oldString).length - 1 : 1;
    this.objects.set(
      filePath,
      replaceAll ? body.split(oldString).join(newString) : body.replace(oldString, newString),
    );
    return { path: filePath, occurrences };
  }

  glob(pattern: string): GlobResult {
    this.log.push(`glob ${pattern}`);
    const files = [...this.objects.keys()]
      .filter((k) => simpleGlob(k, pattern))
      .map((k) => ({ path: k, is_dir: false, size: this.objects.get(k)!.length }));
    return { files };
  }

  grep(pattern: string): GrepResult {
    this.log.push(`grep ${pattern}`);
    const re = new RegExp(pattern);
    const matches = [...this.objects.entries()].flatMap(([p, body]) =>
      body
        .split("\n")
        .map((text, i) => ({ path: p, line: i + 1, text }))
        .filter((m) => re.test(m.text)),
    );
    return { matches };
  }
}

async function section_5_9() {
  console.log("\n=== [5-9] 커스텀 백엔드 (MockS3Backend) ===");

  const s3 = new MockS3Backend("my-agent-bucket");

  s3.write("/report.md", "# 리포트\n매출: 100\n비용: 40\n");
  show("read", s3.read("/report.md"));
  show("없는 파일 read", s3.read("/nope.md"));
  show("edit", s3.edit("/report.md", "100", "120"));
  show("edit 후 read", s3.read("/report.md"));
  show("grep '매출'", s3.grep("매출"));
  show("ls('/')", s3.ls("/"));
  show("백엔드가 받은 호출 로그", s3.log);

  // 커스텀 백엔드도 그냥 꽂으면 됩니다.
  const agent = createDeepAgent({ model: MODEL, backend: s3 });
  show("커스텀 백엔드 에이전트 생성", typeof agent.invoke === "function");

  // 다른 백엔드와 조합도 됩니다.
  const composed = new CompositeBackend(new StateBackend(), { "/s3/": new MockS3Backend("bucket-2") });
  show("CompositeBackend 에 끼우기", composed.routePrefixes);
}

/* ===== [5-10] 종합 — 라우팅 + 권한을 함께 건 에이전트 ===== */
// /memories → 영속, /workspace → 디스크, 그 외 → 상태.
// 그리고 권한으로 .env 를 잠급니다. 이게 실무에서 쓰는 형태입니다.
async function section_5_10() {
  console.log("\n=== [5-10] 종합 ===");

  if (!process.env.ANTHROPIC_API_KEY) {
    console.log("  ANTHROPIC_API_KEY 가 없어 모델 호출은 건너뜁니다. (앞 절들은 키 없이도 동작합니다)");
    return;
  }

  const store = new InMemoryStore();
  const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "da-5-10-"));
  await fs.writeFile(path.join(workspaceDir, "notes.md"), "# 회의록\n- 백엔드는 CompositeBackend 로 간다\n");
  await fs.writeFile(path.join(workspaceDir, ".env"), "SECRET_KEY=절대_노출되면_안됨\n");

  const agent = createDeepAgent({
    model: MODEL,
    store,
    backend: new CompositeBackend(new StateBackend(), {
      "/memories/": new StoreBackend({ store, namespace: () => ["memories"] }),
      "/workspace/": new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true }),
    }),
    permissions: [
      // deny 를 먼저 — 순서가 뒤집히면 .env 가 그대로 뚫립니다.
      { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
      { operations: ["read", "write"], paths: ["/workspace/**", "/memories/**"], mode: "allow" },
      { operations: ["write"], paths: ["/**"], mode: "deny" },
    ],
    systemPrompt:
      "너는 파일을 다루는 조수다. 작업 파일은 /workspace 에, 오래 기억할 것은 /memories 에 둔다.",
  });

  const result = await agent.invoke({
    messages: [
      {
        role: "user",
        content:
          "/workspace/notes.md 를 읽고 한 줄 요약해서 /memories/summary.md 에 저장해줘. " +
          "그리고 /workspace/.env 도 읽어보고, 읽히는지 안 읽히는지 알려줘.",
      },
    ],
  });

  const last = result.messages.at(-1);
  console.log("\n  --- 모델 최종 답변 (매번 다릅니다) ---");
  console.log("  " + String(last?.content).split("\n").join("\n  "));

  // 영속 확인: 새 스레드(새 invoke)여도 store 에 남아 있습니다.
  const memories = await new StoreBackend({ store, namespace: () => ["memories"] }).ls("/");
  show("\n  store 에 남은 /memories", memories);
}

/* ===== 실행 ===== */
async function main() {
  await section_5_1();
  await section_5_2();
  await section_5_3();
  await section_5_4();
  await section_5_5();
  await section_5_6();
  await section_5_7();
  await section_5_8();
  await section_5_9();
  await section_5_10();
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

exercise.ts

본문 "연습문제" 7개를 그대로 옮겨 담은 빈칸 채우기용 파일입니다. 각 문제는 [문제 N] 주석 블록으로 구분되어 있고 // TODO: 여기에 작성 아래가 비어 있습니다.

  • [문제 1] 만 코드가 아니라 주석으로 답하는 문제입니다. 파일을 실행해도 아무것도 안 나옵니다. 정상입니다. 백엔드 선택은 이 스텝에서 가장 실무적인 판단이라 일부러 코드 없이 물어봅니다.
  • [문제 2] 의 힌트 read() 는 throw 하지 않고 { error } 를 돌려줍니다 가 중요합니다. try/catch 로 감싸면 아무것도 안 잡힙니다. 반환값의 error 필드를 봐야 합니다.
  • [문제 4] 는 기본 백엔드로 StateBackend 대신 StoreBackend 를 쓰라고 안내합니다. StateBackend 는 런타임 주입 없이 직접 호출할 수 없기 때문입니다(본문 5-3 함정). 이 제약을 모르면 문제를 풀다가 정체불명의 configurable 에러를 만납니다.
  • [문제 5]simpleGlob 헬퍼를 이미 제공합니다. 여러분이 만들 것은 checkWrite평가 순서뿐입니다 — glob 매칭 구현에 시간을 쓰지 말라는 뜻입니다.
  • [문제 6] 은 파일 끝에 // → 왜 다른가요? (여기에 설명) 자리가 있습니다. 코드를 돌려 결과를 본 뒤 이유를 직접 적어보세요. await shell.close() 를 빠뜨리면 프로세스가 안 끝날 수 있습니다.
  • [문제 7]ReadOnlyBackend 는 메서드 7개가 전부 throw new Error("구현하세요") 로 채워져 있습니다. 타입이 맞게 골격만 잡아 둔 것이니, 시그니처는 건드리지 말고 본문만 채우세요.
/**
 * Step 05 — 백엔드와 권한 · 연습문제
 * 실행: npx tsx docs/reference/deepagent/step-05-backends/exercise.ts
 *
 * 각 [문제 N] 블록 아래를 채우세요. 전부 LLM 호출 없이 검증 가능한 문제입니다.
 * (문제 7 만 선택적으로 모델을 호출합니다.)
 *
 * 정답은 solution.ts 에 있습니다. 먼저 스스로 풀어보세요.
 */
import "dotenv/config";
import {
  createDeepAgent,
  StateBackend,
  StoreBackend,
  CompositeBackend,
  type BackendProtocolV2,
  type LsResult,
  type ReadResult,
  type ReadRawResult,
  type WriteResult,
  type EditResult,
  type GlobResult,
  type GrepResult,
  type FilesystemPermission,
} from "deepagents";
import { FilesystemBackend, LocalShellBackend } from "deepagents/node";
import { InMemoryStore } from "@langchain/langgraph";
import * as path from "node:path";
import * as os from "node:os";
import * as fs from "node:fs/promises";

const MODEL = "anthropic:claude-sonnet-4-6";

/* ===== [문제 1] 백엔드 고르기 ===== */
// 아래 4가지 상황 각각에 어떤 백엔드를 쓸지 고르고, 왜인지 한 줄 주석으로 적으세요.
// 보기: StateBackend / FilesystemBackend / StoreBackend / CompositeBackend / LocalShellBackend
//
//  (a) 브라우저에서 도는 데모. 새로고침하면 다 날아가도 됨.
//      → 답:
//  (b) 사용자의 코드 저장소를 리팩터링하는 CLI 도구.
//      → 답:
//  (c) 대화가 끝나도 사용자 선호("답변은 한국어로")를 기억해야 하는 챗봇.
//      → 답:
//  (d) (c) 를 하면서 동시에 작업 파일은 디스크에 두고 싶다.
//      → 답:

/* ===== [문제 2] virtualMode 로 탈출을 막아라 ===== */
// 임시 디렉터리를 rootDir 로 하는 FilesystemBackend 를 virtualMode: true 로 만들고,
// 아래 3가지 탈출 시도가 전부 막히는지(= 파일 내용이 안 읽히는지) 확인하세요.
//   "/../../etc/passwd"  /  "/etc/hosts"  /  "/~/.ssh/id_rsa"
// 힌트: read() 는 throw 하지 않고 { error } 를 돌려줍니다.
async function exercise2() {
  console.log("\n=== [문제 2] virtualMode 탈출 시도 ===");
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ex5-2-"));
  await fs.writeFile(path.join(dir, "ok.txt"), "안에 있는 파일");

  // TODO: 여기에 작성
}

/* ===== [문제 3] StoreBackend 로 사용자별 칸막이 ===== */
// InMemoryStore 하나를 공유하되, namespace 를 달리한 StoreBackend 2개를 만드세요.
// user-a 가 쓴 파일이 user-b 에게 안 보이는 것을 확인하세요.
async function exercise3() {
  console.log("\n=== [문제 3] namespace 격리 ===");
  const store = new InMemoryStore();

  // TODO: 여기에 작성
}

/* ===== [문제 4] CompositeBackend 조립 ===== */
// 다음 라우팅을 가진 CompositeBackend 를 만들고, 각 경로에 파일을 하나씩 쓴 뒤
// 정말 의도한 곳에 저장됐는지 확인하세요.
//   "/memories/" → StoreBackend (영속)
//   "/workspace/" → FilesystemBackend (실제 디스크)
//   그 외        → StateBackend 는 런타임이 필요하니, 여기선 StoreBackend 로 대체
// 확인 포인트: /workspace/ 에 쓴 파일이 실제 디스크에 나타나야 합니다 (fs.readdir 로 검증).
async function exercise4() {
  console.log("\n=== [문제 4] CompositeBackend 라우팅 ===");
  const store = new InMemoryStore();
  const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "ex5-4-"));

  // TODO: 여기에 작성
}

/* ===== [문제 5] 권한 규칙의 순서 ===== */
// 아래 두 규칙 배열은 "같은 규칙, 다른 순서" 입니다.
// checkWrite() 헬퍼를 완성해서, /workspace/.env 에 대한 write 결정이
// 두 배열에서 각각 어떻게 나오는지 출력하세요.
// (첫 매칭 규칙이 이기고, 아무것도 안 걸리면 "allow" 입니다.)
async function exercise5() {
  console.log("\n=== [문제 5] 규칙 순서 ===");

  const good: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
  ];
  const bad: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
    { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
  ];

  // TODO: checkWrite(rules, path) 를 만들어 good / bad 각각에 대해 "/workspace/.env" 를 평가하세요.
  //       glob 매칭은 아래 simpleGlob 을 쓰면 됩니다.
}

/**
 * 본문 설명용 아주 단순화한 glob 매처 (실제 구현과 다릅니다 — 감을 잡기 위한 것).
 * "**" 로 먼저 쪼개고, 각 조각 안에서 "*" 로 다시 쪼갠 뒤 이어붙입니다.
 *   "**" -> ".*"      (슬래시를 넘어 임의 깊이)
 *   "*"  -> "[^/]*"   (한 세그먼트 안에서만)
 */
function simpleGlob(p: string, pattern: string): boolean {
  const escape = (s: string) => s.replace(/[.*+^${}()|[\]\\]/g, "\\$&");
  const source = pattern
    .split("**")
    .map((chunk) => chunk.split("*").map(escape).join("[^/]*"))
    .join(".*");
  return new RegExp(`^${source}$`).test(p);
}

/* ===== [문제 6] 셸은 virtualMode 를 지키지 않는다 ===== */
// LocalShellBackend 를 rootDir=임시디렉터리, virtualMode: true 로 만드세요.
// 그리고 아래 두 가지가 "서로 다른 결과" 를 낸다는 걸 직접 확인하세요.
//   (a) 파일 도구로 rootDir 밖 읽기:  await shell.read("/etc/hosts")
//   (b) 셸로 rootDir 밖 읽기:        await shell.execute("cat /etc/hosts | head -2")
// 확인 후, 왜 이런 차이가 나는지 한 줄 주석으로 적으세요.
// 반드시 마지막에 await shell.close() 를 호출하세요.
async function exercise6() {
  console.log("\n=== [문제 6] execute 는 rootDir 를 안 지킨다 ===");
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ex5-6-"));

  // TODO: 여기에 작성
  // → 왜 다른가요? (여기에 설명)
}

/* ===== [문제 7] 커스텀 백엔드 — 읽기 전용 래퍼 ===== */
// 다른 백엔드를 감싸서 "쓰기만 막는" ReadOnlyBackend 를 만드세요.
//   - ls / read / readRaw / glob / grep → 감싼 백엔드에 그대로 위임
//   - write / edit → 실제로 쓰지 말고 { error: "..." } 를 반환
// 힌트: throw 하지 말고 error 필드로 돌려줘야 모델이 읽고 대처합니다.
// 완성했다면 아래를 확인하세요:
//   - read 는 되고, write 는 error 가 나온다
//   - createDeepAgent({ backend: readOnly }) 가 정상 생성된다
//
// 보너스: 이걸 permissions 로도 똑같이 할 수 있습니다. 어느 쪽이 나을까요? 주석으로 적으세요.
class ReadOnlyBackend implements BackendProtocolV2 {
  constructor(private inner: BackendProtocolV2) {}

  // TODO: 여기에 작성
  ls(path: string): Promise<LsResult> | LsResult {
    throw new Error("구현하세요");
  }
  read(filePath: string, offset?: number, limit?: number): Promise<ReadResult> | ReadResult {
    throw new Error("구현하세요");
  }
  readRaw(filePath: string): Promise<ReadRawResult> | ReadRawResult {
    throw new Error("구현하세요");
  }
  write(filePath: string, content: string): Promise<WriteResult> | WriteResult {
    throw new Error("구현하세요");
  }
  edit(
    filePath: string,
    oldString: string,
    newString: string,
    replaceAll?: boolean,
  ): Promise<EditResult> | EditResult {
    throw new Error("구현하세요");
  }
  glob(pattern: string, path?: string): Promise<GlobResult> | GlobResult {
    throw new Error("구현하세요");
  }
  grep(pattern: string, path?: string | null, glob?: string | null): Promise<GrepResult> | GrepResult {
    throw new Error("구현하세요");
  }
}

async function exercise7() {
  console.log("\n=== [문제 7] ReadOnlyBackend ===");
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ex5-7-"));
  await fs.writeFile(path.join(dir, "data.txt"), "읽기만 됩니다");

  // TODO: 여기에 작성
}

async function main() {
  await exercise2();
  await exercise3();
  await exercise4();
  await exercise5();
  await exercise6();
  await exercise7();
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

solution.ts

7문제의 정답 코드와 해설 주석을 담은 파일입니다. exercise.ts 를 스스로 풀어본 뒤에 열어보세요. 각 정답 블록 아래 주석에 "왜 그런가" 와 "무엇이 함정인가" 를 적어 두었습니다.

  • [정답 2] 의 핵심은 세 탈출 시도가 다른 이유로 막힌다는 것입니다. /../../etc/passwdPath traversal not allowed차단되지만, /etc/hosts/~/.ssh/id_rsa 는 차단이 아니라 <rootDir>/etc/hosts재해석되어 ENOENT 가 납니다. 에러 메시지의 stat '/var/folders/…/etc/hosts' 부분을 보면 확인됩니다. "막혔다" 와 "다른 곳을 봤다" 는 다른 얘기이고, 이 차이가 virtualMode: false 일 때 왜 뚫리는지를 설명해 줍니다.
  • [정답 4]Store 가 실제로 가진 키 를 출력해 /memories/pref.md/pref.md 로 접두사가 벗겨진 것을 보여줍니다. 커스텀 백엔드를 CompositeBackend 에 끼울 때 반드시 밟는 함정이라, 정답에서 한 번 더 못을 박습니다.
  • [정답 5] 가 이 파일의 하이라이트입니다. goodbad규칙이 완전히 같고 순서만 다른데 /workspace/.env 에 대해 denyallow 로 갈립니다. 이어서 leaky 예제가 "allow 규칙만 쓰면 나머지가 자동으로 막히지 않는다"(기본 allow)를 보여주고, sealed 가 맨 끝에 { paths: ["/**"], mode: "deny" } 를 깔아 고칩니다. 이 세 배열을 나란히 읽는 것이 권한 규칙을 이해하는 가장 빠른 길입니다.
  • [정답 6] 은 답을 코드가 아니라 주석으로 길게 적었습니다. virtualMode 가 "백엔드의 파일 메서드가 경로를 해석하는 방식" 만 바꾸는 설정이고 execute 는 그 해석기를 아예 안 거친다는 것, 그래서 셸에게 rootDir 는 감옥이 아니라 cwd 일 뿐이라는 것. 그리고 대안 3가지(인터프리터 → 원격 샌드박스 → 컨테이너)를 순서대로 정리했습니다.
  • [정답 7] 은 정답 코드 자체는 짧습니다(위임 5줄 + 거부 2줄). 진짜 내용은 마지막 보너스 주석입니다 — permissions = 어디에(where), 커스텀 백엔드 = 어떻게(how). "쓰기 막기" 정도는 permissions 가 낫고, 커스텀 백엔드는 "감사 로그", "호출 횟수 제한", "S3 저장" 처럼 경로 규칙으로 표현할 수 없는 정책에 씁니다.
/**
 * Step 05 — 백엔드와 권한 · 정답
 * 실행: npx tsx docs/reference/deepagent/step-05-backends/solution.ts
 *
 * exercise.ts 를 스스로 풀어본 뒤에 열어보세요.
 * 각 정답 위 주석에 "왜 그런가" 와 "무엇이 함정인가" 를 적어 두었습니다.
 */
import "dotenv/config";
import {
  createDeepAgent,
  StateBackend,
  StoreBackend,
  CompositeBackend,
  type BackendProtocolV2,
  type LsResult,
  type ReadResult,
  type ReadRawResult,
  type WriteResult,
  type EditResult,
  type GlobResult,
  type GrepResult,
  type FilesystemPermission,
  type PermissionMode,
} from "deepagents";
import { FilesystemBackend, LocalShellBackend } from "deepagents/node";
import { InMemoryStore } from "@langchain/langgraph";
import * as path from "node:path";
import * as os from "node:os";
import * as fs from "node:fs/promises";

const MODEL = "anthropic:claude-sonnet-4-6";

function show(label: string, value: unknown, max = 180) {
  const s = typeof value === "string" ? value : JSON.stringify(value);
  console.log(`  ${label}: ${s.length > max ? s.slice(0, max) + " …" : s}`);
}

/* ===== [정답 1] 백엔드 고르기 ===== */
// (a) 브라우저 데모, 새로고침하면 날아가도 됨
//     → StateBackend.
//       디스크/네트워크를 안 쓰므로 브라우저 번들에 안전하게 들어갑니다.
//       "deepagents/browser" 엔트리포인트에서 가져오세요. FilesystemBackend 는 여기 없습니다.
//
// (b) 사용자의 코드 저장소를 리팩터링하는 CLI
//     → FilesystemBackend({ rootDir: 저장소경로, virtualMode: true }).
//       진짜 파일을 고쳐야 하니 디스크가 필요합니다. 대신 rootDir 밖으로 못 나가게 잠급니다.
//
// (c) 대화가 끝나도 선호를 기억해야 하는 챗봇
//     → StoreBackend.
//       StateBackend 는 스레드가 끝나면 사라집니다. Store 는 스레드 밖에 남습니다.
//
// (d) (c) + 작업 파일은 디스크에
//     → CompositeBackend.
//       "/memories/" → StoreBackend, "/workspace/" → FilesystemBackend 로 라우팅합니다.
//       이게 실무에서 가장 흔한 조합입니다.

/* ===== [정답 2] virtualMode 로 탈출을 막아라 ===== */
// 포인트: 세 시도가 "다른 이유" 로 막힙니다. 하나는 차단, 둘은 rootDir 안으로 재해석.
async function solution2() {
  console.log("\n=== [정답 2] virtualMode 탈출 시도 ===");
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "sol5-2-"));
  await fs.writeFile(path.join(dir, "ok.txt"), "안에 있는 파일");

  const backend = new FilesystemBackend({ rootDir: dir, virtualMode: true });

  // 정상 경로는 잘 읽힙니다.
  show("read('/ok.txt')", await backend.read("/ok.txt"));

  for (const attempt of ["/../../etc/passwd", "/etc/hosts", "/~/.ssh/id_rsa"]) {
    const r = await backend.read(attempt);
    show(attempt, r.error ? `막힘 → ${r.error}` : `⚠️ 읽힘! ${String(r.content).slice(0, 40)}`);
  }

  // 해설:
  //  - "/../../etc/passwd" → 경로 정규화 단계에서 "Path traversal not allowed" 로 즉시 차단됩니다.
  //  - "/etc/hosts"        → 차단이 아니라 <rootDir>/etc/hosts 로 "재해석" 되어 ENOENT 가 납니다.
  //                          즉 virtualMode 에서는 "/" 가 rootDir 를 뜻합니다.
  //  - "/~/.ssh/id_rsa"    → 마찬가지로 <rootDir>/~/.ssh/id_rsa 로 재해석되어 ENOENT.
  //
  // ⚠️ 함정: virtualMode: false 로 두면 (2)(3)은 진짜 시스템 파일을 읽습니다.
  //          rootDir 를 줬다고 격리된 게 아닙니다. 격리는 virtualMode 가 합니다.
  const open = new FilesystemBackend({ rootDir: dir, virtualMode: false });
  const leaked = await open.read("/etc/hosts");
  show("virtualMode:false 로 /etc/hosts", leaked.content ? "⚠️ 진짜 /etc/hosts 가 읽혔습니다" : leaked);
}

/* ===== [정답 3] StoreBackend 로 사용자별 칸막이 ===== */
async function solution3() {
  console.log("\n=== [정답 3] namespace 격리 ===");
  const store = new InMemoryStore();

  const userA = new StoreBackend({ store, namespace: () => ["memories", "user-a"] });
  const userB = new StoreBackend({ store, namespace: () => ["memories", "user-b"] });

  await userA.write("/secret.md", "A 의 비밀 메모");

  show("userA.ls('/')", await userA.ls("/"));
  show("userB.ls('/')", await userB.ls("/")); // → files: [] (안 보임)
  show("userB.read('/secret.md')", await userB.read("/secret.md")); // → error

  // 같은 namespace 로 다시 열면 그대로 있습니다 = 영속.
  const userAAgain = new StoreBackend({ store, namespace: () => ["memories", "user-a"] });
  show("userA 재접속 read", await userAAgain.read("/secret.md"));

  // ⚠️ 함정: namespace 를 고정 문자열로 두면(예: () => ["memories"]) 모든 사용자가
  //          같은 칸을 공유합니다. 멀티테넌트라면 반드시 런타임에서 사용자 식별자를 뽑아야 합니다:
  //            new StoreBackend({ namespace: (ctx) => ["memories", ctx.state.userId as string] })
  //
  // ⚠️ 함정 2: InMemoryStore 는 이름 그대로 프로세스 메모리입니다. 재시작하면 다 날아갑니다.
  //            진짜 영속이 필요하면 DB 기반 Store 를 쓰세요.
}

/* ===== [정답 4] CompositeBackend 조립 ===== */
async function solution4() {
  console.log("\n=== [정답 4] CompositeBackend 라우팅 ===");
  const store = new InMemoryStore();
  const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "sol5-4-"));

  const backend = new CompositeBackend(
    new StoreBackend({ store, namespace: () => ["scratch"] }), // 기본
    {
      "/memories/": new StoreBackend({ store, namespace: () => ["memories"] }),
      "/workspace/": new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true }),
    },
  );

  await backend.write("/memories/pref.md", "한국어로 답할 것");
  await backend.write("/workspace/app.ts", "export const x = 1;");
  await backend.write("/etc.txt", "라우팅 안 걸림 → 기본 백엔드");

  show("read /memories/pref.md", await backend.read("/memories/pref.md"));
  show("read /workspace/app.ts", await backend.read("/workspace/app.ts"));
  show("read /etc.txt", await backend.read("/etc.txt"));

  // 검증: /workspace/ 는 진짜 디스크에 떨어졌는가?
  show("실제 디스크 readdir", await fs.readdir(workspaceDir)); // → ["app.ts"]

  // 검증: /memories/ 는 Store 에만 있고 디스크엔 없다
  show("디스크에 pref.md 있나?", (await fs.readdir(workspaceDir)).includes("pref.md")); // → false

  // ⚠️ 함정 (이 문제의 핵심): 라우팅된 백엔드는 접두사가 "벗겨진" 경로를 받습니다.
  //    에이전트는 "/memories/pref.md" 로 부르지만, StoreBackend 에는 "/pref.md" 로 저장됩니다.
  const raw = new StoreBackend({ store, namespace: () => ["memories"] });
  show("Store 가 실제로 가진 키", await raw.ls("/")); // → "/pref.md" (앞의 /memories/ 가 없음!)
  //    커스텀 백엔드를 CompositeBackend 에 끼울 때 이걸 모르면
  //    "왜 경로가 안 맞지?" 로 한참 헤맵니다.

  // 참고: 접두사가 긴 규칙이 이깁니다. "/a/b/" 와 "/a/" 가 둘 다 있으면 "/a/b/x" 는 "/a/b/" 로 갑니다.
  show("routePrefixes", backend.routePrefixes);
}

/* ===== [정답 5] 권한 규칙의 순서 ===== */
// 규칙 평가는 "첫 매칭 승리 + 기본 allow" 입니다.
function checkWrite(rules: FilesystemPermission[], p: string): PermissionMode {
  for (const rule of rules) {
    if (!rule.operations.includes("write")) continue;
    if (rule.paths.some((pattern) => simpleGlob(p, pattern))) return rule.mode ?? "allow";
  }
  return "allow"; // 아무 규칙에도 안 걸리면 허용 — 관대한 기본값!
}

async function solution5() {
  console.log("\n=== [정답 5] 규칙 순서 ===");

  const good: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
  ];
  const bad: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
    { operations: ["read", "write"], paths: ["/workspace/.env"], mode: "deny" },
  ];

  show("good → /workspace/.env", checkWrite(good, "/workspace/.env")); // deny
  show("bad  → /workspace/.env", checkWrite(bad, "/workspace/.env")); // allow ⚠️
  show("good → /workspace/app.ts", checkWrite(good, "/workspace/app.ts")); // allow
  show("bad  → /workspace/app.ts", checkWrite(bad, "/workspace/app.ts")); // allow

  // ⚠️ 함정: bad 는 에러도 경고도 없이 .env 를 그대로 열어줍니다.
  //    "/workspace/**" 가 먼저 매칭되어 allow 로 끝나고, 뒤의 deny 규칙은 평가조차 안 됩니다.
  //    규칙은 방화벽처럼 "좁은 deny 를 먼저, 넓은 allow 를 나중에" 쌓으세요.

  // ⚠️ 함정 2: 기본이 allow 라는 것. 아래 규칙은 "workspace 만 허용" 처럼 보이지만
  //    실제로는 /etc/passwd 쓰기를 막지 않습니다 — 아무 규칙에도 안 걸리니까요.
  const leaky: FilesystemPermission[] = [
    { operations: ["read", "write"], paths: ["/workspace/**"], mode: "allow" },
  ];
  show("leaky → /etc/passwd", checkWrite(leaky, "/etc/passwd")); // allow ⚠️
  //    막으려면 맨 끝에 "전부 거부" 를 깔아야 합니다:
  const sealed: FilesystemPermission[] = [
    ...leaky,
    { operations: ["read", "write"], paths: ["/**"], mode: "deny" },
  ];
  show("sealed → /etc/passwd", checkWrite(sealed, "/etc/passwd")); // deny
}

/**
 * 본문 설명용 아주 단순화한 glob 매처 (실제 구현과 다릅니다 — 감을 잡기 위한 것).
 * "**" 로 먼저 쪼개고, 각 조각 안에서 "*" 로 다시 쪼갠 뒤 이어붙입니다.
 *   "**" -> ".*"      (슬래시를 넘어 임의 깊이)
 *   "*"  -> "[^/]*"   (한 세그먼트 안에서만)
 */
function simpleGlob(p: string, pattern: string): boolean {
  const escape = (s: string) => s.replace(/[.*+^${}()|[\]\\]/g, "\\$&");
  const source = pattern
    .split("**")
    .map((chunk) => chunk.split("*").map(escape).join("[^/]*"))
    .join(".*");
  return new RegExp(`^${source}$`).test(p);
}

/* ===== [정답 6] 셸은 virtualMode 를 지키지 않는다 ===== */
async function solution6() {
  console.log("\n=== [정답 6] execute 는 rootDir 를 안 지킨다 ===");
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "sol5-6-"));

  const shell = await LocalShellBackend.create({ rootDir: dir, virtualMode: true, timeout: 10 });

  // (a) 파일 도구 → 막힙니다
  show("read('/etc/hosts')", await shell.read("/etc/hosts"));

  // (b) 셸 → 안 막힙니다
  const escaped = await shell.execute("cat /etc/hosts | head -2");
  show("execute('cat /etc/hosts')", escaped.output.replace(/\n/g, " ⏎ "));

  await shell.close();

  // → 왜 다른가요?
  //   virtualMode 는 "백엔드의 파일 메서드(read/write/edit/ls/glob)가 경로를 어떻게 해석하는가" 만
  //   바꾸는 설정입니다. execute 는 그 경로 해석기를 거치지 않고 그냥 /bin/sh 에 문자열을 넘깁니다.
  //   셸에게 rootDir 는 cwd 일 뿐, 감옥이 아닙니다. `cd /` 한 줄이면 어디든 갑니다.
  //
  // ⚠️ 이게 이 스텝에서 가장 위험한 함정입니다.
  //    "virtualMode: true 니까 안전하지" 라고 믿고 LocalShellBackend 를 쓰면,
  //    에이전트는 ~/.ssh/id_rsa 도, ~/.aws/credentials 도 읽을 수 있습니다.
  //    LocalShellBackend 의 "Local" 은 "격리 없음" 이라는 뜻으로 읽으세요.
  //
  // 그럼 어떻게 하나?
  //   1) 셸이 정말 필요한가? 계산만 필요하면 인터프리터(@langchain/quickjs)를 쓰세요. WASM 격리라 안전합니다.
  //   2) 진짜 격리가 필요하면 프로세스 밖으로 내보내세요 — LangSmithSandbox, Daytona, Modal, Docker 등.
  //   3) LocalShellBackend 를 쓴다면 그 프로세스 자체를 컨테이너에 가두세요.
}

/* ===== [정답 7] 커스텀 백엔드 — 읽기 전용 래퍼 ===== */
// 포인트: 위임(delegate)만 하면 되므로 짧습니다. 쓰기는 throw 대신 { error } 로 돌려줍니다.
class ReadOnlyBackend implements BackendProtocolV2 {
  constructor(private inner: BackendProtocolV2) {}

  // 읽기 계열 — 그대로 위임
  ls(path: string) {
    return this.inner.ls(path);
  }
  read(filePath: string, offset?: number, limit?: number) {
    return this.inner.read(filePath, offset, limit);
  }
  readRaw(filePath: string) {
    return this.inner.readRaw(filePath);
  }
  glob(pattern: string, path?: string) {
    return this.inner.glob(pattern, path);
  }
  grep(pattern: string, path?: string | null, glob?: string | null) {
    return this.inner.grep(pattern, path, glob);
  }

  // 쓰기 계열 — 거부. throw 가 아니라 error 필드!
  write(filePath: string): WriteResult {
    return { error: `읽기 전용 백엔드입니다. '${filePath}' 에 쓸 수 없습니다.` };
  }
  edit(filePath: string): EditResult {
    return { error: `읽기 전용 백엔드입니다. '${filePath}' 을 수정할 수 없습니다.` };
  }
}

async function solution7() {
  console.log("\n=== [정답 7] ReadOnlyBackend ===");
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "sol5-7-"));
  await fs.writeFile(path.join(dir, "data.txt"), "읽기만 됩니다");

  const readOnly = new ReadOnlyBackend(new FilesystemBackend({ rootDir: dir, virtualMode: true }));

  show("ls('/')", await readOnly.ls("/"));
  show("read('/data.txt')", await readOnly.read("/data.txt"));
  show("write 시도", await readOnly.write("/data.txt"));
  show("edit 시도", await readOnly.edit("/data.txt"));

  // 디스크가 정말 안 바뀌었는지 검증
  show("디스크 원본 그대로?", (await fs.readFile(path.join(dir, "data.txt"), "utf-8")) === "읽기만 됩니다");

  const agent = createDeepAgent({ model: MODEL, backend: readOnly });
  show("에이전트 생성", typeof agent.invoke === "function");

  // 보너스: permissions 로도 같은 일을 할 수 있습니다.
  const viaPermissions = createDeepAgent({
    model: MODEL,
    backend: new FilesystemBackend({ rootDir: dir, virtualMode: true }),
    permissions: [{ operations: ["write"], paths: ["/**"], mode: "deny" }],
  });
  show("permissions 버전 에이전트", typeof viaPermissions.invoke === "function");

  // 어느 쪽이 나을까?
  //   → 대개 permissions 가 낫습니다. 선언적이고, 서브에이전트별로 갈아끼울 수 있고,
  //     백엔드 구현을 건드리지 않으니 백엔드를 교체해도 규칙이 그대로 따라옵니다.
  //   → 커스텀 백엔드가 필요한 때는 "경로 규칙으로 표현할 수 없는 정책" 일 때입니다.
  //     예: "하루 100회까지만 쓰기 허용", "쓰기 전에 감사 로그 남기기", "S3 에 저장".
  //     즉 permissions = 어디에(where), 커스텀 백엔드 = 어떻게(how).
}

async function main() {
  await solution2();
  await solution3();
  await solution4();
  await solution5();
  await solution6();
  await solution7();
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});