블로그 • AI / Agent

Claude Agent 권한 제어는 어떻게 구현하나? Permission Policy, MCP Tool Call, JSON 승인 흐름

Claude Agent 권한 제어는 모델이 제안한 Tool Call과 실제 실행 사이에 두는 JSON 판단이다. 정책은 allow, deny, ask 중 하나를 낸다. ask는 승인 기록이 되고, 결정은 같은 tool_use_id 로 돌려준다. id를 빠뜨리거나 잘못 쓰면 세션이 멈추거나 같은 호출이 두 번 실행된다.

플랫폼이 스스로 판단하는 방식은 이전 글에 있다. 그곳의 auto 는 서버가 permission_policy 를 읽고 evaluated_permission 를 쓰는 과정이다. 그 판단 절차는 바꿀 수 없다. 이 글은 직접 붙이는 JSON 세 가지, 정책, 승인 대기열, 거부 회신을 다룬다. 사용자 정의 도구와 콜백에서야 보이는 호출은 여기를 통과한다.

이 글에서 다루는 내용:

먼저 기억할 점: 도구를 allow로 적으면 승인 대기열은 그 호출을 보지 못한다. canUseTool 은 이미 허용된 호출에서는 실행되지 않는다. 아래에서는 연결 지점, 정책 JSON, 대기열, 회신 순서로 직접 남길 문서를 나눈다.

공식 평가의 어디에 연결하는가

Agent SDK와 Claude Code의 평가 순서는 고정이다. 근거는 Agent SDK 권한 과 Claude Code 권한 이다. deny, ask, allow 순으로 본다. 더 구체적인 allow도 이미 맞은 deny를 덮지 못한다.

  1. 1
    deny가 먼저 막는다

    deny에 맞으면 호출이 막히며 bypassPermissions 모드도 같다. Bash처럼 도구 이름만 적은 deny는 이 단계 전에 컨텍스트에서 빠진다.

  2. 2
    ask는 콜백으로 간다

    ask에 맞으면 canUseTool 로 넘긴다. 우회 모드를 켜도 같다. dontAsk 에는 확인이 없고, 맞은 호출은 거부되며 콜백은 호출되지 않는다.

  3. 3
    allow는 다시 묻지 않는다

    allow에 맞으면 바로 실행되고 canUseTool 은 호출되지 않는다. 허용 목록에 도구 이름만 넣어도 같다. 다른 ask 규칙이 콜백으로 되돌리는 경우는 제외한다.

  4. 4
    아무것도 맞지 않을 때

    앞에서 아무것도 맞지 않으면 default 모드만 호출을 canUseTool 로 보낸다. dontAsk 는 거부하고 콜백을 호출하지 않는다.

그래서 승인 대기열은 아직 허용되지 않은 호출만 받는다. 매번 기록하려면 PreToolUse hook을 쓴다. 나머지 검사보다 먼저 실행되며 허용, 거부, 인자 변경이 가능하다. 이미 맞은 deny나 ask는 뒤집지 못한다.

세 연결점은 같은 대기열 기록을 쓰고, 마지막에 공식 회신 형태만 달라진다.

연결 저장할 JSON 모델에 돌려주는 형태
Managed Agents 호스트 도구, ask에서 정지 permission_policy user.tool_confirmation
Agent SDK 콜백 자신의 policy와 대기열 기록 { behavior, updatedInput / message }
사용자 정의 도구 자신의 policy와 대기열 기록 user.custom_tool_result

사용자 정의 도구 이벤트는 agent.custom_tool_use 이고 evaluated_permission 가 없다. 플랫폼 문서: Managed Agents 권한 정책. auto가 호스트 도구를 deny로 판단하면 클라이언트는 확인 이벤트로 뒤집을 수 없다. 대기열을 강제 허용의 두 번째 통로로 만들지 않는다.

Permission Policy JSON을 쓰는 방법

Claude Code만 쓴다면 공식 settings.json 의 allow / ask / deny 문자열로 충분하다. 제품 안의 Agent에는 버전 관리, diff, Schema 검증이 되는 문서가 더 필요하다. 아래 문서는 애플리케이션 계약이지 Anthropic 요청 본문이 아니다.

앱 자신의 정책. default는 ask. 뒤에 쓴 allow보다 deny가 우선
{
  "version": 1,
  "default": "ask",
  "rules": [
    {
      "id": "deny-env",
      "effect": "deny",
      "tool": "Read",
      "input": { "file_path": { "const": ".env" } },
      "reason_code": "secret_file"
    },
    {
      "id": "ask-pr-main",
      "effect": "ask",
      "tool": "mcp__github__create_pull_request",
      "input": { "base": { "enum": ["main", "master"] } },
      "reason_code": "protected_branch"
    },
    {
      "id": "allow-docs-search",
      "effect": "allow",
      "tool": "mcp__docs__search"
    }
  ]
}
필드 의미
version 정책 형식 버전. 대기열 기록에 복사해 옛 티켓을 새 규칙으로 해석하지 않게 한다.
default 어떤 규칙에도 맞지 않을 때의 효과. 제품에서는 ask 또는 deny 를 쓰고 allow는 쓰지 않는다.
effect deny, ask 또는 allow
tool 도구 전체 이름. MCP 도구는 mcp__ 다음에 서버 이름, 그다음 도구 이름이다.
input 선택. 있으면 인자도 맞아야 한다. 생략하면 도구 이름만 본다.
reason_code 안정적인 기계 코드. 회신과 감사 기록에 넣는다. 자연어 문장을 기본 키로 쓰지 않는다.

매칭 순서는 공식과 같다. deny를 모두 본 다음 ask, 그다음 allow. 같은 effect 안에서는 파일에서 앞에 있는 규칙이 먼저다.

default 를 ask 로 두면, 규칙에 없는 새 MCP 도구는 실행되지 않고 대기열에 들어간다. 호스트 MCP toolset의 기본값 always_ask 와 같은 생각이다. 문서와 대기열은 자신의 것이다.

MCP Tool Call 한 번을 규칙에 맞추는 방법

모델 호출에는 최소한 type, id, 도구 이름, input이 있다. 필드 이름은 Messages API의 tool_use를 따른다. MCP 도구 이름은 서버 이름과 도구 이름을 이은 전체 이름이다.

보호된 브랜치에 풀 리퀘스트를 여는 호출
{
  "type": "tool_use",
  "id": "toolu_01AbC",
  "name": "mcp__github__create_pull_request",
  "input": {
    "base": "main",
    "title": "Retry failed checks",
    "head": "fix/retry"
  }
}
  1. 1
    먼저 모양을 검증한다

    그 도구 자신의 JSON Schema로 input을 검증한다. 실패하면 대기열에 넣지 않고 MCP도 호출하지 않으며 바로 deny한다. reason_code는 schema_invalid 이다.

  2. 2
    그다음 deny, ask, allow 순으로 맞춘다

    위 호출은 ask-pr-main 에 맞는다. base 가 enum에 있기 때문이다. 파일 순서는 같은 effect 안에서만 본다.

  3. 3
    도구 이름만으로 allow하지 않는다

    input 제약이 없는 allow는 base가 main이어도 통과시킨다. 인자 제약은 권한의 일부이지 Schema의 복사본이 아니다.

동등과 enum만 보는 매처. 규칙 문자열을 shell에 붙이지 않는다.
function decide(policy, call) {
  const order = ["deny", "ask", "allow"];
  for (const effect of order) {
    const hit = policy.rules.find(
      (rule) => rule.effect === effect && matchRule(rule, call)
    );
    if (hit) {
      return {
        effect,
        rule_id: hit.id,
        reason_code: hit.reason_code || null
      };
    }
  }
  return { effect: policy.default, rule_id: null, reason_code: "default" };
}

function matchRule(rule, call) {
  if (rule.tool !== call.name) return false;
  if (!rule.input) return true;
  return Object.entries(rule.input).every(([key, cond]) => {
    const value = call.input?.[key];
    if (Object.prototype.hasOwnProperty.call(cond, "const")) return value === cond.const;
    if (Array.isArray(cond.enum)) return cond.enum.includes(value);
    return false;
  });
}

경로 접두사는 정규화한 경로만 비교하는 별도 검사로 둔다. 위 함수가 모르는 조건을 보면 false를 반환한다. 규칙은 맞지 않으며 허용되지도 않는다.

JSON Schema Permission Policy
답하는 질문 인자 모양이 맞는가 이번 호출을 실행해도 되는가
실패할 때 schema_invalid, 대기열에 넣지 않음 deny / ask
예 base 는 문자열이어야 한다 base 가 main

승인 대기열에 무엇을 저장하는가

effect가 ask 이면 기다리기 전에 저장한다. 기다림을 메모리에만 두지 않는다. 프로세스가 재시작되면 모델 쪽 호출에 결과가 없다.

승인 대기 한 건. tool_use_id는 유일하다.
{
  "id": "apr_01HZXK",
  "session_id": "sess_01",
  "tool_use_id": "toolu_01AbC",
  "tool_name": "mcp__github__create_pull_request",
  "input": {
    "base": "main",
    "title": "Retry failed checks",
    "head": "fix/retry"
  },
  "matched_rule": "ask-pr-main",
  "policy_version": 1,
  "status": "pending",
  "created_at": "2026-09-23T09:00:00Z",
  "expires_at": "2026-09-23T09:15:00Z"
}

사람의 결정은 별도 문서로 저장한다. 대기열 기록 자체를 덮어쓰지 않는다.

승인 결정. actor는 로그인 세션에서 온다.
{
  "approval_id": "apr_01HZXK",
  "tool_use_id": "toolu_01AbC",
  "decision": "deny",
  "actor": "user:alex",
  "reason_code": "protected_branch",
  "decided_at": "2026-09-23T09:04:12Z"
}

actor 는 로그인 세션에서 온다. input 안의 사용자 필드는 믿지 않는다.

거부와 허용을 어떻게 돌려주는가

결정이 종료 상태가 되면 같은 tool_use_id 로 반드시 돌려준다. 빠뜨리면 세션은 대기 상태로 남는다. 모델은 이어가지 못하고, 다음 턴에서 새 id로 다시 제안할 수 있다.

당신의 결정 Agent SDK 콜백 ask에서 멈춘 호스트 도구 사용자 정의 도구
allow { behavior: "allow", updatedInput } result: "allow" 실행에 성공한 뒤 사용자 정의 도구 결과를 반환
deny { behavior: "deny", message } result: "deny" + deny_message 오류로 표시한 사용자 정의 도구 결과. 다음을 포함한다: reason_code

공식 사용자 입력 문서 를 본다. TypeScript의 allow에는 updatedInput 가, Python에는 updated_input 가 필요하다. Claude Code v2.1.207 이전에는 이 필드를 빼면 검증이 실패해 거부되었다. 회신에는 항상 원래 input을 넣는다.

모델에 돌려주는 deny. 규칙 id와 reason_code를 넣고, 같은 인자로 재시도하지 말라고 적는다.
{
  "behavior": "deny",
  "message": "Denied by rule ask-pr-main (protected_branch). Do not retry this call."
}

message 와 deny_message 는 모델에게 보인다. 규칙 id와 reason_code를 적는다. 내부 호스트 이름, 토큰, 승인자의 개인 메모는 적지 않는다.

감사 행은 회신과 따로 저장한다. 회신이 성공한 뒤에만 wrote_back을 true로 둔다.
{
  "tool_use_id": "toolu_01AbC",
  "effect": "deny",
  "rule_id": "ask-pr-main",
  "reason_code": "protected_branch",
  "wrote_back": true
}

MCP는 allow 이후에만 호출한다. deny 와 expired 에서는 호출하지 않는다. 정책 엔진은 먼저 실행하고 나중에 사람에게 묻지 않는다.

settings, auto와 어떻게 나누는가

세 규칙 집합은 같은 질문에 답하지 않는다. 명단을 따로 쓰고 저절로 맞기를 기대하지 않는다.

Claude Code settings Managed Agents 자신의 policy와 대기열
규칙 형태 도구 이름에 지정자를 붙인 문자열 always_allow / always_ask / auto effect, 도구 이름, 선택적 input 제약
누가 평가하는가 Claude Code. 순서는 deny, ask, allow 플랫폼. auto는 이번 호출 한 번을 평가한다 자신의 코드. 순서는 공식과 맞춘다
사람 승인은 어디에 남는가 대화형 확인, 또는 canUseTool user.tool_confirmation 자신의 대기열. 그다음 왼쪽 두 회신으로 옮긴다
맞는 용도 로컬 코딩 호스트된 Agent 승인 티켓과 감사를 남겨야 하는 제품
Claude Code 권한 규칙. 공식 settings 형태이지 위의 앱 정책이 아니다.
{
  "permissions": {
    "allow": ["Bash(npm test *)"],
    "ask": ["Bash(git push *)"],
    "deny": ["Read(./.env)", "Read(./.env.*)"]
  }
}

사람이 반드시 봐야 하는 도구는 effect를 ask 로 유지한다. auto가 안전하다고 보면 사람이 보기 전에 실행한다. 자세한 내용은 이전 글 에 있다. 제품에서는 자신의 policy가 원본이다. Claude Code deny 문자열로 내보내는 것은 투영일 뿐이다. 승인 상태는 대기열이 가진다.

JSONNote에서 JSON 세 가지를 검사하기

배포 전에 정책, 대기열 기록, 회신을 작업 공간에서 각각 통과시킨다. 세 가지 모두 브라우저에서 포맷, 검증, 비교할 수 있고 라이브 Agent는 아직 필요 없다.

  1. 1
    정책 포맷

    JSON 포맷 으로 policy를 연다. Claude Code settings도 엄격한 JSON이다. 주석이나 끝의 쉼표는 다음 시작 때 오류가 된다.

  2. 2
    대기열 기록 검증

    아래 Schema를 JSON Schema 도구에 붙이고 승인 기록 하나를 붙인다. tool_use_id가 없거나 status가 열거 밖이면 실패한다.

  3. 3
    바꾼 규칙 비교

    규칙 변경 전후를 JSON Diff 로 봐서 ask 가 allow 로 바뀌었는지 확인한다. 이 변화는 문장보다 사람이 볼 가치가 있다.

  4. 4
    회신 검증

    JSON 검증 으로 회신이 아직 올바른 JSON이고 tool_use_id 가 남아 있는지 확인한다.

승인 기록의 JSON Schema. Schema 도구에 그대로 붙일 수 있다.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "id",
    "session_id",
    "tool_use_id",
    "tool_name",
    "input",
    "status",
    "created_at",
    "expires_at"
  ],
  "properties": {
    "id": { "type": "string", "minLength": 1 },
    "session_id": { "type": "string", "minLength": 1 },
    "tool_use_id": { "type": "string", "minLength": 1 },
    "tool_name": { "type": "string", "minLength": 1 },
    "input": { "type": "object" },
    "matched_rule": { "type": ["string", "null"] },
    "policy_version": { "type": "integer", "minimum": 1 },
    "status": { "enum": ["pending", "approved", "denied", "expired"] },
    "created_at": { "type": "string", "format": "date-time" },
    "expires_at": { "type": "string", "format": "date-time" }
  }
}

자주 묻는 질문

canUseTool이 호출되지 않는 이유는?

앞선 allow 규칙이나 acceptEdits, bypassPermissions가 이미 승인했기 때문이다. 콜백은 아직 승인되지 않은 호출만 본다. 매번 기록하려면 PreToolUse를 쓴다. 그래도 deny는 뒤집지 못한다.

JSON Schema 검증만으로 충분한가?

충분하지 않다. Schema는 모양만 증명한다. 예를 들어 base가 문자열인지만 본다. base가 main일 때 사람이 봐야 하는지는 policy의 일이다. 모양이 맞아도 deny나 ask가 될 수 있다.

거부한 뒤 모델이 같은 인자로 다시 시도해도 되나?

안 된다. 돌려주는 내용에 같은 인자로 재시도하지 말라고 적고, 결과를 지워 없던 일로 만들지 않는다. 새 시도는 새 tool_use_id로 대기열을 다시 통과해야 한다.

대기열이 만료되면 기본으로 허용되나?

아니다. 기록을 expired로 표시하고 reason_code를 approval_expired로 해서 deny를 돌려준다. 만료는 동의가 아니다.

Managed Agents의 auto를 써도 이 대기열이 필요한가?

필요하다. 다만 always_ask와 사용자 정의 도구만 해당한다. auto가 deny로 판단한 호출은 확인 이벤트로 뒤집을 수 없다. 대기열을 강제 allow 우회로 만들지 않는다.

정리

권한 제어를 구현한다는 것은 JSON 세 가지다. 직접 쓰는 policy, ask일 때 남기는 대기열 기록, 종료 상태일 때 돌려주는 결정이다. 매칭 순서는 공식과 같고 deny가 먼저다. Schema는 모양만 본다. 사람이 아직 결정하지 않았거나 만료되었으면 MCP를 호출하지 않는다.

결론: 기본은 ask 또는 deny다. allow에는 도구 이름을 적고, 필요할 때 인자도 적는다. 각 tool_use_id는 한 번만 실행하고 반드시 회신한다.

MCP 도구를 누가 선언하고 누가 실행하는지는 MCP 보안 을 본다. JSON을 실행 앞에 두는 이유는 보안 경계 에 있다.

다음: 승인 기록의 모양을 검증한 다음 정책을 Agent에 넘긴다.

블로그로 돌아가기