블로그 • Claude Code / Gateway 정책
Claude Code 2.1.316으로 올리고 Claude apps gateway에서 managed.policy_mode를 layered로 두면, 여러 개 매칭된 permission policy JSON을 어떻게 병합해 감사 로그에 남기나?
2026년 10월 31일 Anthropic은 Claude Code 2.1.316을 냈다. Claude apps gateway托管 설정에 managed.policy_mode layered가 추가됐다. 예전처럼 첫 매칭에서 멈추지 않고 개발자에 맞는 policy가 matchedPolicies에 들어가 목록 순으로 아래에서 위로 병합되며 상위 tool effect가 하위 allow·deny·ask를 덮는다.
평가 context JSON에는 policyMode layered와 matchedPolicies 배열(id, order, tool·effect allow·deny·ask rules)을 쓴다. tool 호출 후 감사 JSON에는 appliedPolicyIds, decision, reason layered_merge, winningPolicyId. layered_policy_gate의 schema, not_layered, first_only_legacy, no_rule, allow, deny, ask 분기와 대조하고 첫 policy id만 보지 않는다.
allow·deny 큐 패턴은 Agent permission policy; Managed Agents auto 평가 맥락은 Managed Agents auto 평가; 세션 워크플로 JSON은 Claude Code workflow. 이 글은 2.1.316 layered 정책 병합, 평가 context·감사 로그 필드, layered_policy_gate 분기만 다룬다.
- 托管 settings에서 managed.policy_mode는 layered여야 한다. first거나 미설정인데 gateway가 구 경로면 layered_policy_gate는 first_only_legacy 또는 not_layered를 반환하고 layered_merge winningPolicyId를 쓰지 않는다.
- 평가 context matchedPolicies 항목마다 id, order, rules(tool·effect allow·deny·ask)가 필요하다. 병합 후 Write는 allow, Bash는 하위 deny·상위 ask면 deny가 아니라 ask.
- 감사 로그에 appliedPolicyIds 순서, decision allow·deny·ask, reason layered_merge, winningPolicyId(최종 층)를 쓴다. SIEM은 첫 id만이 아니라 winningPolicyId로 귀속한다.
- tool에 맞는 rule이 없으면 layered_policy_gate는 no_rule. context에 policyMode가 없거나 matchedPolicies 형태가 깨지면 병합 전 schema.
먼저 이것만: 2.1.316은 gateway에서 여러 policy layered 병합과 감사 필드를 바꾸며 로컬 permission policy 파일 형식을 다시 쓰지 않는다. 아래 changelog, managed settings JSON, 평가 context, Write·Bash 호출, 감사 로그·표, layered_policy_gate 소스, JSONNote 형태 검증.
2.1.316이 여러 매칭 policy를 layered 병합으로 바꾸나
변경 설명은 Claude Code changelog, 태그 페이지 v2.1.316 release. 이전 gateway 동작 대비 managed.policy_mode layered가 눈에 띈다. 첫 매칭에서 멈추지 않고 order로 전부 병합하며 상위 tool 규칙이 하위를 덮는다.
병합은 tool 호출 평가 단계에서 일어난다. 감사 JSON에 appliedPolicyIds 순서, decision, reason layered_merge, winningPolicyId를 남겨 first_match policy id만이 아니라 최종 층으로 SIEM 귀속한다.
托管 settings JSON에서 managed.policy_mode layered는 어떻게 쓰나
Claude apps gateway托管 settings의 managed에 policy_mode layered를 쓴다. gateway는 개발자에 맞는 policy를 모두 평가 pipeline에 보낸다. first이거나 layered 미선언이면 layered_merge 감사 대신 first_only_legacy 경로.
{
"managed": {
"policy_mode": "layered"
}
}
policy_mode 대소문자 혼용이나 managed 누락이면 schema가 먼저 실패해 schema를 반환하고 layered_policy_gate 병합 전에 막는다. 형태 오류를 not_layered로 보지 않는다.
평가 context의 policyMode와 matchedPolicies 작성법
gateway는 평가 context JSON에 policyMode layered와 matchedPolicies 배열을 쓴다. 항목마다 id, order, rules; rule은 tool과 effect allow·deny·ask. 목록 순서가 병합栈이며 뒤 policy가 같은 tool의 앞 rule을 덮는다.
{
"policyMode": "layered",
"matchedPolicies": [
{
"id": "org-baseline",
"order": 0,
"rules": [
{ "tool": "Bash", "effect": "deny" }
]
},
{
"id": "repo-contributors",
"order": 1,
"rules": [
{ "tool": "Write", "effect": "allow" },
{ "tool": "Bash", "effect": "ask" }
]
}
]
}
matchedPolicies는 최소 한 항목, id·order는 정수, rules는 비어 있지 않은 배열. policyMode 없거나 matchedPolicies 비면 layered_policy_gate는 schema. policyMode가 layered가 아니면 not_layered.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["policyMode", "matchedPolicies"],
"properties": {
"policyMode": { "type": "string", "enum": ["layered", "first"] },
"matchedPolicies": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "order", "rules"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"order": { "type": "integer" },
"rules": {
"type": "array",
"items": {
"type": "object",
"required": ["tool", "effect"],
"properties": {
"tool": { "type": "string" },
"effect": { "type": "string", "enum": ["allow", "deny", "ask"] }
}
}
}
}
}
}
}
}
layered 병합 후 Write·Bash tool 호출은 어떻게 판정되나
샘플1: 하위·상위 모두 Write allow → 병합 후 decision allow. 샘플2: Bash 하위 deny·상위 ask → decision ask, winningPolicyId는 상위 id. 감사 JSON에 tool명과 병합 전 각 층 effect를 남겨 layered_policy_gate와 대조.
{
"tool": "Write",
"path": "/srv/repo/src/app.ts"
}
{
"tool": "Bash",
"command": "npm test"
}
감사 로그의 appliedPolicyIds·winningPolicyId 작성법
layered 병합 후 permission·tool 감사 JSON에 appliedPolicyIds는 병합에 참여한 id 순서 배열, decision은 allow·deny·ask, reason은 layered_merge, winningPolicyId는 최종 층 id. Write allow는 보통 최고 order, Bash ask는 하위 deny가 아닌 상위 ask id.
{
"tool": "Bash",
"policyMode": "layered",
"appliedPolicyIds": ["org-baseline", "repo-contributors"],
"decision": "ask",
"reason": "layered_merge",
"winningPolicyId": "repo-contributors"
}
| 로그 또는 호출 신호 | layered_policy_gate 반환 | 운영 조치 |
|---|---|---|
| Write 모든 층 allow, reason layered_merge | allow | 로그 decision allow, winningPolicyId는 최고 order. |
| Bash 하위 deny·상위 ask, reason layered_merge | ask | 로그 decision ask, winningPolicyId는 상위 ask policy id. |
| 병합 후 최종 effect deny, ask 덮어쓰기 없음 | deny | 로그 decision deny, appliedPolicyIds는 전 순서 유지. |
| settings가 first이거나 layered 미启用 | first_only_legacy | reason layered_merge 쓰지 말고 구 first-match 보고와 대조. |
layered_policy_gate가 schema와 일곱 business 분기를 어떻게 고르나
순서: 평가 context JSON Schema로 policyMode·matchedPolicies 검증; policyMode가 layered 아니면 not_layered;托管 settings가 first면 first_only_legacy; order로 아래에서 위로 같은 tool effect 병합; rule 없음 no_rule; 결과 allow·deny·ask; 형태 오류 schema.
deny와 ask를 blocked 하나로 접지 않는다. 보고서에서 first_only_legacy, no_rule, 진짜 layered_merge allow·ask·deny를 구분한다.
def actual_type(data: object) -> str:
if data is None:
return "null"
if isinstance(data, bool):
return "boolean"
if isinstance(data, list):
return "array"
if isinstance(data, int):
return "integer"
if isinstance(data, float):
return "number"
if isinstance(data, str):
return "string"
if isinstance(data, dict):
return "object"
raise TypeError(type(data))
def validate(data: object, schema: dict) -> list[str]:
errors: list[str] = []
if not isinstance(schema, dict):
return errors
types = schema.get("type")
if isinstance(types, str):
types = [types]
actual = actual_type(data)
if types and not any(t == actual or (t == "number" and actual == "integer") for t in types):
errors.append("type")
if "enum" in schema and not any(value == data for value in schema["enum"]):
errors.append("enum")
if isinstance(data, str) and "minLength" in schema and len(data) < schema["minLength"]:
errors.append("minLength")
if isinstance(data, dict) and schema.get("properties"):
for key in schema.get("required") or []:
if key not in data:
errors.append(f"required:{key}")
if schema.get("additionalProperties") is False:
for key in data:
if key not in schema["properties"]:
errors.append(f"additional:{key}")
for key, sub in schema["properties"].items():
if key in data and data[key] is not None:
errors.extend(validate(data[key], sub))
return errors
def effect_for_tool(policies: list, tool: str, layered: bool) -> tuple[str, str | None]:
ordered = sorted(policies, key=lambda p: p.get("order", 0))
if not layered:
first = ordered[0]
for rule in first.get("rules") or []:
if rule.get("tool") == tool:
return str(rule.get("effect")), str(first.get("id"))
return "no_rule", None
effect: str | None = None
winner: str | None = None
for policy in ordered:
for rule in policy.get("rules") or []:
if rule.get("tool") == tool:
effect = str(rule.get("effect"))
winner = str(policy.get("id"))
if effect is None:
return "no_rule", None
return effect, winner
def layered_policy_gate(ctx: dict, call: dict, schema: dict) -> str:
if validate(ctx, schema):
return "schema"
mode = ctx.get("policyMode")
if mode != "layered":
return "not_layered"
policies = ctx.get("matchedPolicies") or []
if not policies:
return "schema"
tool = call.get("tool")
if not isinstance(tool, str):
return "schema"
effect, _ = effect_for_tool(policies, tool, layered=True)
if effect == "no_rule":
return "no_rule"
return effect
JSONNote에서 settings, context, 감사 로그를 어떻게 검증하나
- 1Schema로 context 형태 고정
열기 JSON Schema. 1칸에 평가 context schema, 2칸에 policyMode layered·matchedPolicies 샘플. required·minItems 오류를 먼저 표시하고 형태 깨지면 allow·deny 반환 금지.
- 2포맷으로 tool 호출 확인
열기 JSON 포맷, Write allow 또는 Bash ask 호출 JSON을 붙인다. tool 키를 확인한 뒤 layered_policy_gate가 ask·allow 중 무엇인지 본다.
- 3Diff로 allow·ask 감사 로그 비교
열기 JSON Diff. 왼쪽 Write allow 샘플, 오른쪽 Bash ask 샘플. Diff로 강조된 winningPolicyId가 SIEM 귀속 핵심 줄.
자주 묻는 질문
layered와 구 first-match-only 차이는?
구버전은 첫 매칭 policy에서 멈추고 감사는 policy id 하나만 남겼다. 2.1.316 layered는 모든 매칭을 appliedPolicyIds에 쓰고 order로 병합해 reason layered_merge·winningPolicyId를 남긴다. gateway가 layered가 아니면 first_only_legacy.
layered와 로컬 permission policy 파일 관계는?
gateway托管 settings의 layered 병합은 클라우드 평가 pipeline에서 일어나 matchedPolicies·감사 필드를 낸다. 로컬 permission policy 파일은 Agent 기본·오프라인 대조용이며 id·order가 자동 일치하지 않는다. managed settings, 평가 context, 로컬 policy JSON을 각각 검증한다.
하위 deny를 상위 ask가 덮을 수 있나?
가능하다. layered 병합은 order로 아래에서 위로 쌓으며 같은 tool의 최종 effect는 최상위 hit rule이다. Bash 하위 deny·상위 ask면 decision ask, winningPolicyId는 상위. 상위에 rule 없으면 하위 deny 유지.
SIEM은 winningPolicyId로 어떻게 귀속하나?
appliedPolicyIds는 병합 참여 전 순서, winningPolicyId는 최종 층이다. SIEM은 winningPolicyId로 owner·변경单을 연결하고 appliedPolicyIds 길이로 first-match-only 잔존을 탐지한다. reason layered_merge일 때 decision allow·deny·ask를 집계한다.
이 글이 공격·우회 튜토리얼인가?
아니다. 2.1.316 layered 병합 JSON 형태, 감사 로그, layered_policy_gate 분기만 설명해 운영·컴플라이언스 검증과 SIEM 귀속을 돕는다. 다층 ask·deny로 통제를 우회하는 절차는 제공하지 않는다.
정리
Claude Code 2.1.316은 Claude apps gateway에서 managed.policy_mode layered로 여러 policy를 병합하고 감사 JSON에 appliedPolicyIds, decision, reason layered_merge, winningPolicyId를 쓴다.托管 settings, 평가 context, Write·Bash 호출, 감사 로그 네 샘플로 layered_policy_gate를 돌리고 첫 policy id만 보지 않는다.
결론: context schema 실패 → schema. policyMode가 layered 아니면 not_layered. settings가 first면 first_only_legacy. tool rule 없음 no_rule. 병합 후 allow·deny·ask는 감사 reason layered_merge·winningPolicyId와 맞춘다.
다음: Schema로 context·settings 고정, Write·Bash 호출 포맷 확인, allow·ask 감사 로그 Diff, layered_policy_gate 반환값을 보고서에서 분리 집계.