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 | ~18 | PermissionID 타입 정의 |
src/permission/arity.ts | ~163 | BashArity 사전: Bash 명령의 인수 수 매핑 |
src/session/session.sql.ts | — | PermissionTable 정의, SQLite 저장을 위한 Ruleset |
src/util/wildcard.ts | — | Wildcard.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 — 규칙 컬렉션
Ruleset은 Rule[]의 타입 별칭입니다. 권한 평가 시 모든 규칙이 순차적으로 매칭되며, 마지막으로 매칭된 규칙이 적용됩니다(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: "*" }
}
핵심 설계 포인트:
- 다중 Ruleset 평탄화: 가변 인자
...rulesets를 받아, 일반적으로[configRules, approvedRules]를 단일 배열로 평탄화 - 이중 매칭: 각 규칙은
permission과pattern두 차원 모두 매칭되어야 함 - findLast 의미론: 마지막으로 매칭된 규칙이 승리. 이를 통해 “먼저 *을 거부한 후 특정 경로를 허용”하는 선언 패턴이 가능
- 기본값 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 매처를 작성하는 대신 코드를 간결하게 유지하면서 정확성을 보장합니다 |
| 순수 함수 evaluate | evaluate()는 부작용이 없는 순수 함수로 테스트와 이해가 쉽습니다. 모든 부작용(영속화, 이벤트 브로드캐스트)은 ask()/reply()에서 처리됩니다 |
| catchall 기본값 ask | Config의 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:
PermissionTable은session.sql.ts에 정의되어 Session과 SQLite 데이터베이스를 공유합니다.ask()의sessionID매개변수는 계단식 거부와 일괄 승인의 그룹화 기준으로 사용됩니다 - Bus:
Event.Asked와Event.Replied가 Bus를 통해 브로드캐스트되어 모든 UI 레이어(CLI, TUI, API)가 이러한 이벤트를 구독할 수 있습니다 - Wildcard (util):
evaluate()는 와일드카드 패턴 매칭을 위해Wildcard.match()에 의존하며, 이는 Permission 결정의 기초 기능입니다