컨텐츠로 건너뛰기

Permission 소스 코드 분석

모듈 개요

Permission 모듈은 OpenCode의 보안 관문입니다. Agent가 도구를 호출하려 할 때 — 파일 읽기, 파일 쓰기, Bash 명령 실행 — Permission 모듈이 개입하여 3단계 결정을 내립니다: 자동 허용, 차단 및 거부, 또는 일시 중지하고 사용자에게 질문. “완전한 자율성”과 “안전하고 통제 가능” 사이의 균형을 찾는 것이 Permission 모듈의 핵심 설계 목표입니다.

핵심 설계 선택:

  • Effect Deferred 비동기 모델: ask/reply에 Effect.Deferred를 사용하여 일시 중지/재개, 취소 및 타임아웃을 네이티브로 지원
  • last-match-wins 규칙 평가: 구성에서 나중에 나오는 규칙이 이전 것을 오버라이드하여 “일반 규칙을 먼저 선언하고 예외를 나중에 선언”하는 직관에 더 부합
  • 와일드카드 패턴 매칭: *.*로, ?.로 매핑, 매칭 전 경로 구분자 정규화
  • Bus 이벤트 브로드캐스트: Event.Asked / Event.Replied가 Bus를 통해 브로드캐스트되어 CLI와 TUI 모두 구독 가능
  • 프로젝트 레벨 영속화: 사용자의 always 결정은 SQLite에 기록되어 project_id에 바인딩, 프로젝트 간 권한 누출 방지
  • 계단식 거부 및 일괄 승인: 하나의 요청을 거부하면 동일한 세션의 모든 대기 중인 요청도 거부; always 승인은 다른 만족된 대기 요청도 자동으로 확인하고 승인

Permission은 Effect.ts의 ServiceMap.Service 아키텍처 위에 구축되어, Bus 이벤트 시스템을 통해 비동기 “ask-reply” 플로우를 완성하고, SQLite를 통해 사용자 승인 결정을 영속화합니다.

주요 파일

파일 경로줄 수책임
src/permission/index.ts~326모듈 메인 진입점: Service 정의, ask/reply 플로우, fromConfig 파싱, expand 경로 확장, 계단식 로직
src/permission/evaluate.ts~15순수 함수: last-match-wins 규칙 평가, findLast 의미론
src/permission/schema.ts~18PermissionID 타입 정의
src/permission/arity.ts~163BashArity 사전: Bash 명령의 인수 수 매핑
src/session/session.sql.tsPermissionTable 정의, SQLite 저장을 위한 Ruleset
src/util/wildcard.tsWildcard.match / Wildcard.all, 와일드카드 패턴 매칭 엔진

타입 시스템

Action — 권한 동작 3상태

Permission 모듈은 Zod enum을 사용하여 세 가지 동작 수준을 정의합니다:

const Action = z.enum(["allow", "deny", "ask"]).meta({ ref: "PermissionAction" });
  • allow: 자동 허용, Agent가 대기하지 않고 도구가 직접 실행됨
  • ask: 실행을 일시 중지하고 Deferred를 통해 명시적인 사용자 승인을 비동기적으로 대기
  • deny: 직접 거부, DeniedError를 throw, Agent가 에러 신호를 수신

Reply — 사용자 응답 3상태

사용자가 질문에 응답할 때 Reply enum이 사용됩니다:

const Reply = z.enum(["once", "always", "reject"]);
  • once: 이번만 허용, 다음에도 동일한 작업은 질문이 필요함
  • always: 결정을 기억하고 approved: Ruleset에 추가하여 SQLite에 영속화, 이후 자동 허용
  • reject: 이번 거부, Agent가 RejectedError를 수신

Rule — 단일 권한 규칙

권한 규칙은 세 가지 차원으로 구성됩니다:

const Rule = z.object({
  permission: z.string(),  // 도구/작업 이름, 예: "read", "bash", "mcp.github.create_issue"
  pattern: z.string(),     // 파일 경로 또는 명령 패턴, 예: "src/**", ".env", "rm *"
  action: Action,          // allow / deny / ask
});

Ruleset — 규칙 컬렉션

RulesetRule[]의 타입 별칭입니다. 권한 평가 시 모든 규칙이 순차적으로 매칭되며, 마지막으로 매칭된 규칙이 적용됩니다(last-match-wins).

PendingEntry — 비동기 대기 요청

type PendingEntry = {
  deferred: Effect.Deferred<...>   // 비동기 일시 중지 지점
  request: PermissionRequest       // 원래 요청 정보
}

evaluate()ask를 반환하면 Permission이 PendingEntry를 생성하여 pending Map에 저장합니다. deferred는 Effect의 비동기 프리미티브로 — Promise와 유사하지만 외부 resolve/fail을 지원합니다.

코어 플로우

규칙 평가: last-match-wins (evaluate.ts)

evaluate()는 Permission 모듈 전체의 의사결정 핵심 — 부작용이 없는 순수 함수입니다:

import { Wildcard } from "@/util/wildcard"

export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
  const rules = rulesets.flat()
  const match = rules.findLast(
    (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
  )
  return match ?? { action: "ask", permission, pattern: "*" }
}

핵심 설계 포인트:

  1. 다중 Ruleset 평탄화: 가변 인자 ...rulesets를 받아, 일반적으로 [configRules, approvedRules]를 단일 배열로 평탄화
  2. 이중 매칭: 각 규칙은 permissionpattern 두 차원 모두 매칭되어야 함
  3. findLast 의미론: 마지막으로 매칭된 규칙이 승리. 이를 통해 “먼저 *을 거부한 후 특정 경로를 허용”하는 선언 패턴이 가능
  4. 기본값 ask: 규칙이 매칭되지 않으면 { action: "ask" } 반환 — 안전 우선, 한 번 더 묻는 것이 나음

와일드카드 매칭 엔진 (wildcard.ts)

Wildcard.match()는 glob 패턴을 정규 표현식으로 변환하여 매칭합니다:

export namespace Wildcard {
  export function match(str: string, pattern: string) {
    // 경로 구분자 정규화: Windows 백슬래시 → 슬래시
    if (str) str = str.replaceAll("\\", "/")
    if (pattern) pattern = pattern.replaceAll("\\", "/")
    // Glob → 정규식 변환
    let escaped = pattern
      .replace(/[.+^${}()|[\]\\]/g, "\\$&")
      .replace(/\*/g, ".*")    // * → .*
      .replace(/\?/g, ".")     // ? → .
    const flags = process.platform === "win32" ? "si" : "s"
    return new RegExp("^" + escaped + "$", flags).test(str)
  }
}

fromConfig — 설정 파싱

fromConfig()는 Config의 권한 설정을 Ruleset으로 파싱하며, 두 가지 형식을 지원합니다:

// 형식 1: 단축 형식 (동작만 선언)
const config1 = {
  permission: {
    bash: "allow",
    edit: "deny",
  }
}

// 형식 2: 객체 형식 (세밀한 제어)
const config2 = {
  permission: [
    { permission: "edit", pattern: "src/**", action: "allow" },
    { permission: "edit", pattern: ".env", action: "deny" },
    { permission: "bash", pattern: "git *", action: "allow" },
  ]
}

ask/reply 비동기 플로우

evaluate()ask를 반환하면 Permission은 비동기 상호작용에 진입합니다:

Agent 도구 호출 (예: edit("src/app.ts"))


Permission.ask({ permission: "edit", patterns: ["src/app.ts"] })

  ├─ disabled("edit") 확인
  ├─ evaluate(configRules + approvedRules, "edit", "src/app.ts")
  │   ├─ { action: "allow" } 반환 → 직접 허용, 반환
  │   ├─ { action: "deny" } 반환 → DeniedError throw
  │   └─ { action: "ask" } 반환 → 아래 비동기 플로우 계속

  ├─ PendingEntry 구성
  ├─ pending.set(id, entry)
  ├─ Bus.publish(Event.Asked, request)
  └─ Effect.await(deferred)  // 사용자 응답 대기, 일시 중지


사용자가 CLI에서 "once" / "always" / "reject" 선택


Permission.reply(id, reply)
  ├─ reply = "once": Deferred.succeed() → 도구 실행 계속
  ├─ reply = "always": approved.add(규칙) → SQLite 영속화 → 일괄 확인 → Deferred.succeed()
  ├─ reply = "reject": cascadeReject(sessionID) → Deferred.fail(RejectedError)
  └─ Bus.publish(Event.Replied, { id, reply })

계단식 거부 (cascadeReject)

사용자가 권한 요청을 거부하면 Permission은 동일한 세션의 모든 대기 중인 요청도 계단식으로 거부합니다:

function cascadeReject(sessionID: string) {
  for (const [id, entry] of pending) {
    if (entry.request.sessionID === sessionID) {
      Deferred.fail(entry.deferred, new RejectedError(entry.request))
      pending.delete(id)
      Bus.publish(Event.Replied, { id, reply: "reject" })
    }
  }
}

설계 의도: 사용자가 요청을 거부하면 일반적으로 Agent의 현재 작업 시퀀스를 더 이상 신뢰하지 않는다는 의미입니다. 계단식 거부는 의미 없는 확인 프롬프트의 연속을 피하고, 사용자가 빠르게 통제권을 되찾을 수 있게 합니다.

always 일괄 승인

사용자가 always를 선택하면 Permission은 현재 요청을 승인할 뿐만 아니라, 새 규칙에 의해 동일한 세션의 다른 대기 중인 요청도 자동으로 만족되는지 확인합니다:

function checkPendingRequests(sessionID: string, newRule: Rule) {
  for (const [id, entry] of pending) {
    if (entry.request.sessionID === sessionID) {
      const result = evaluate(entry.request.permission, entry.request.pattern, [newRule])
      if (result.action === "allow") {
        Deferred.succeed(entry.deferred)
        pending.delete(id)
        Bus.publish(Event.Replied, { id, reply: "always" })
      }
    }
  }
}

시나리오 예시: Agent가 연속으로 3번의 edit("src/xxx") 요청을 보내고, 첫 번째가 ask를 트리거합니다. 사용자가 always를 선택하면, 새 규칙 { permission: "edit", pattern: "src/**", action: "allow" }에 의해 나머지 두 요청도 자동으로 승인됩니다.

호출 체인 예제

체인 1: Agent가 파일 편집 (ask → always)

Agent가 도구 호출 edit(path="src/app.ts") 시작


Permission.ask({ permission: "edit", patterns: ["src/app.ts"], sessionID: "sess-abc" })
  ├─ disabled("edit") → false (전역 비활성화 아님)
  ├─ evaluate("edit", "src/app.ts", configRules, approvedRules)
  │   └─ 매칭 규칙 없음 → 기본값 { action: "ask" }
  ├─ action = "ask" → 비동기 플로우 진입
  │   ├─ Deferred.make() → 비동기 일시 중지 지점 생성
  │   ├─ pending.set("req-1", { deferred, request })
  │   ├─ Bus.publish(Event.Asked, { id: "req-1", permission: "edit", pattern: "src/app.ts" })
  │   └─ await(deferred) → Agent 도구 호출 일시 중지


사용자가 "항상 허용" 선택


Permission.reply("req-1", "always")
  ├─ approved.add({ permission: "edit", pattern: "src/app.ts", action: "allow" })
  ├─ PermissionTable.update(projectID, approved) → SQLite 쓰기
  ├─ checkPendingRequests("sess-abc", newRule) → 다른 대기 요청 확인
  ├─ Deferred.succeed(deferred) → Agent 도구 호출 재개
  └─ Bus.publish(Event.Replied, { id: "req-1", reply: "always" })

체인 2: Bash 명령이 deny 규칙에 의해 차단됨

Agent가 도구 호출 bash(command="rm -rf /") 시작


Permission.ask({ permission: "bash", patterns: ["rm -rf /"], sessionID: "sess-abc" })
  ├─ evaluate("bash", "rm -rf /", configRules, approvedRules)
  │   └─ 규칙 { permission: "bash", pattern: "rm -rf *", action: "deny" } 매칭
  ├─ action = "deny" → DeniedError 직접 throw


Agent가 DeniedError catch → 전략 조정, 명령 실행하지 않음

체인 3: 계단식 거부 시나리오

Agent가 3개의 병렬 도구 호출 (동일한 세션):
  req-1: edit("src/a.ts")   → ask (대기 중)
  req-2: edit("src/b.ts")   → ask (대기 중)
  req-3: bash("npm test")   → ask (대기 중)

사용자가 req-1에 대해 "거부" 선택


Permission.reply("req-1", "reject")
  ├─ Deferred.fail(d1, RejectedError)  → req-1 실패
  ├─ cascadeReject("sess-abc")
  │   ├─ req-2: 동일한 세션 → Deferred.fail(d2, RejectedError)
  │   └─ req-3: 동일한 세션 → Deferred.fail(d3, RejectedError)
  └─ 3개 요청 모두 거부됨, Agent가 3개의 RejectedError 수신 → 현재 작업 시퀀스 중지

설계 트레이드오프

결정근거
first-match-wins 대신 last-match-wins구성 파일에서 나중에 나오는 규칙이 일반적으로 더 구체적(예: 먼저 *을 거부한 후 src/**을 허용)이며, “일반 규칙을 먼저 선언하고 예외를 나중에 선언”하는 직관에 더 부합
Effect Deferred 비동기 모델콜백이나 Promise와 비교하여 Effect Deferred는 취소(AbortController)와 타임아웃을 네이티브로 지원하여 장시간 실행 Agent 시나리오에 적합
프로젝트 레벨 영속화권한 결정은 project_id에 바인딩되어 전역이 아닌 프로젝트별로 관리됩니다. 프로젝트 A에서 always로 승인한 권한이 프로젝트 B에 자동 적용되지 않습니다
계단식 거부하나의 요청을 거부하면 동일한 세션의 모든 대기 중인 요청도 거부되어, 거부 후 Agent가 의미 없는 작업 시퀀스를 계속하는 것을 방지합니다
always 일괄 확인always 후 동일한 세션의 다른 대기 중인 요청이 새 규칙에 의해 만족되는지 자동으로 확인하여 반복적인 사용자 확인을 줄입니다
와일드카드 정규식 변환glob 패턴을 정규 표현식으로 변환하여 커스텀 glob 매처를 작성하는 대신 코드를 간결하게 유지하면서 정확성을 보장합니다
순수 함수 evaluateevaluate()는 부작용이 없는 순수 함수로 테스트와 이해가 쉽습니다. 모든 부작용(영속화, 이벤트 브로드캐스트)은 ask()/reply()에서 처리됩니다
catchall 기본값 askConfig의 Permission Schema catchall(PermissionAction.default("ask"))이 새로운 도구 타입이 실수로 허용되지 않도록 보장합니다

다른 모듈과의 관계

  • Agent: Agent는 각 도구 호출을 실행하기 전에 Permission.ask()를 호출하여 승인 또는 거부를 대기합니다. Agent는 RejectedError / DeniedError를 catch하여 전략을 조정할 수 있습니다. Doom Loop 감지도 Permission.ask()를 통해 사용자 확인을 요청합니다
  • Config: Permission 규칙은 Config의 permission 필드에서 Permission.fromConfig()를 통해 로딩됩니다
  • CLI / TUI: Event.Asked를 구독하여 확인 UI를 렌더링합니다; 사용자 액션이 Permission.reply()를 트리거합니다
  • MCP: MCP 도구 호출도 Permission 제어를 거치며, permission 필드는 server.tool 형식(예: github.create_issue)을 사용합니다
  • Session: PermissionTablesession.sql.ts에 정의되어 Session과 SQLite 데이터베이스를 공유합니다. ask()sessionID 매개변수는 계단식 거부와 일괄 승인의 그룹화 기준으로 사용됩니다
  • Bus: Event.AskedEvent.Replied가 Bus를 통해 브로드캐스트되어 모든 UI 레이어(CLI, TUI, API)가 이러한 이벤트를 구독할 수 있습니다
  • Wildcard (util): evaluate()는 와일드카드 패턴 매칭을 위해 Wildcard.match()에 의존하며, 이는 Permission 결정의 기초 기능입니다