Blog • Claude Code / Gateway policy
After upgrading to Claude Code 2.1.316 with Claude apps gateway managed.policy_mode set to layered, how do matching permission policy JSON objects merge and land in audit logs?
On October 31, 2026 Anthropic shipped Claude Code 2.1.316. Managed settings on Claude apps gateway add managed.policy_mode value layered: instead of stopping at the first matching policy, every policy that matches the developer enters matchedPolicies and merges bottom-up in list order, with upper-layer tool effects overriding lower allow, deny, or ask.
Evaluation context JSON should record policyMode layered and a matchedPolicies array with id, order, and rules by tool plus effect allow, deny, or ask. Post-call audit JSON should carry appliedPolicyIds, decision, reason layered_merge, and winningPolicyId. Operators can mirror layered_policy_gate branches schema, not_layered, first_only_legacy, no_rule, allow, deny, and ask instead of watching only the first policy id.
Allow and deny queue patterns are in Agent permission policy; Managed Agents auto evaluation context is in Managed Agents auto evaluation; session workflow JSON is in Claude Code workflow. This article covers only 2.1.316 layered policy merge, evaluation context and audit log fields, and how layered_policy_gate branches.
- Managed settings must set managed.policy_mode to layered; if first or unset while the gateway still uses the legacy path, layered_policy_gate should return first_only_legacy or not_layered instead of logging layered_merge winningPolicyId.
- Each matchedPolicies entry in evaluation context needs id, order, and rules with tool plus effect allow, deny, or ask; after merge Write may allow, while Bash with lower deny and upper ask should yield ask not deny.
- Audit logs should list appliedPolicyIds in order, decision allow deny or ask, reason layered_merge, and winningPolicyId for the effective layer; SIEM should attribute on winningPolicyId not only the first id.
- When no rule matches the tool, layered_policy_gate returns no_rule; if context lacks policyMode or matchedPolicies has a bad shape, return schema before merge logic.
Keep this in mind: 2.1.316 changes layered merge of multiple matching policies on the gateway and audit fields; it does not rewrite local permission policy file formats. Below: changelog, managed settings JSON, evaluation context, Write and Bash call samples, audit logs and table, layered_policy_gate source, and validating shapes in JSONNote.
How does 2.1.316 turn multiple matching policies into layered merge?
Release notes are in the Claude Code changelog, tag page at v2.1.316 release. Versus earlier gateway behavior, the visible change is managed.policy_mode layered: evaluation no longer stops at the first matching policy but merges every match by order with upper tool rules overriding lower ones.
Merge runs during tool-call evaluation. Audit JSON should keep appliedPolicyIds in order, decision, reason layered_merge, and winningPolicyId so SIEM attributes to the effective layer instead of only a first_match policy id.
How do you set managed.policy_mode layered in managed settings JSON?
In managed settings from Claude apps gateway, set policy_mode to layered under managed. The gateway sends every policy matching the developer into the evaluation pipeline; if still first or not layered, clients should use first_only_legacy instead of layered_merge audit fields.
{
"managed": {
"policy_mode": "layered"
}
}
If operators store policy_mode with wrong casing or omit managed, local schema validation should fail with schema before layered_policy_gate merge branches. Do not treat shape errors as not_layered.
How do policyMode and matchedPolicies appear in evaluation context?
The gateway writes policyMode layered and a matchedPolicies array in evaluation context JSON. Each entry has id, order, and rules; each rule binds tool with effect allow, deny, or ask. List order is the merge stack: later policies override earlier rules for the same tool.
{
"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 needs at least one entry with integer id and order and non-empty rules; missing policyMode or empty matchedPolicies makes layered_policy_gate return schema first. When policyMode is not layered, return not_layered to avoid layered_merge log templates.
{
"$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"] }
}
}
}
}
}
}
}
}
How are Write and Bash tool calls decided after layered merge?
Sample one: lower and upper layers both allow Write → merged decision allow. Sample two: lower deny and upper ask on Bash → merged decision ask with winningPolicyId pointing to the upper id. Audit JSON should keep the tool name and per-layer effects before merge for layered_policy_gate checks.
{
"tool": "Write",
"path": "/srv/repo/src/app.ts"
}
{
"tool": "Bash",
"command": "npm test"
}
How should appliedPolicyIds and winningPolicyId appear in audit logs?
After layered merge, permission or tool audit JSON should set appliedPolicyIds to the ordered ids that merged, decision to allow deny or ask, reason to layered_merge, and winningPolicyId to the effective layer id. Write allow samples usually pick the highest order id; Bash ask samples should point winningPolicyId to the upper ask layer not the lower deny.
{
"tool": "Bash",
"policyMode": "layered",
"appliedPolicyIds": ["org-baseline", "repo-contributors"],
"decision": "ask",
"reason": "layered_merge",
"winningPolicyId": "repo-contributors"
}
| Log or call signal | layered_policy_gate returns | Operator action |
|---|---|---|
| Write all layers allow, reason layered_merge | allow | Log decision allow; winningPolicyId points to highest order. |
| Bash lower deny upper ask, reason layered_merge | ask | Log decision ask; winningPolicyId is the upper ask policy id. |
| Merged final effect deny with no ask override | deny | Log decision deny; appliedPolicyIds stays fully ordered. |
| Settings still first or layered not enabled | first_only_legacy | Do not write reason layered_merge; compare to legacy first-match reports. |
How does layered_policy_gate choose among schema and seven business branches?
Order: validate evaluation context JSON Schema for policyMode and matchedPolicies; policyMode not layered → not_layered; managed settings still first → first_only_legacy; merge same-tool effects bottom-up by order; no matching rule → no_rule; merged result allow deny or ask; bad shape → schema.
Do not collapse deny and ask into one blocked bit. Count separately in reports to distinguish first_only_legacy, no_rule, and true layered_merge allow ask deny outcomes.
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
How do you validate settings, context, and audit logs in JSONNote?
- 1Pin context shape with Schema
Open JSON Schema. Paste evaluation context schema in the first field and a policyMode layered plus matchedPolicies sample in the second. required or minItems errors should flag first; layered_policy_gate must not return allow or deny on a bad shape.
- 2Use Format to inspect tool calls
Open JSON Format, paste Write allow or Bash ask call JSON. Confirm the tool key exists before checking whether layered_policy_gate should return ask or allow.
- 3Diff allow versus ask audit logs
Open JSON Diff. Put a Write allow sample on the left and a Bash ask sample on the right. The highlighted winningPolicyId is the line your SIEM rule should key on.
Common questions
How is layered different from old first-match-only?
Legacy evaluation stopped at the first matching policy and audits often kept one policy id. 2.1.316 layered lists every match in appliedPolicyIds, merges by order, and logs reason layered_merge plus winningPolicyId. If the gateway is not on layered, use first_only_legacy instead of the new fields.
How does layered relate to a local permission policy file?
Layered merge in managed gateway settings runs in the cloud evaluation pipeline and produces matchedPolicies and audit fields. Local permission policy files still hold Agent defaults or offline checks, but ids and order are not automatically identical. Validate managed settings, evaluation context, and local policy JSON separately.
Can lower deny be overridden by upper ask?
Yes. Layered merge stacks bottom-up by order; the final effect for the same tool comes from the highest matching layer. In the Bash sample with lower deny and upper ask, decision is ask and winningPolicyId points upward. If the upper layer has no rule, lower deny remains.
How should SIEM use winningPolicyId for attribution?
appliedPolicyIds holds the full merge order; winningPolicyId marks the effective layer. SIEM alerts and reports should join policy owners and change tickets on winningPolicyId and use appliedPolicyIds length to detect lingering first-match-only behavior. Aggregate decision allow deny ask when reason is layered_merge.
Does this article provide attack or bypass tutorials?
No. This article only explains 2.1.316 layered merge JSON shapes, audit log fields, and layered_policy_gate branches for operator and compliance validation and SIEM attribution. It does not provide steps to exploit multi-layer ask or deny to bypass controls.
Summary
Claude Code 2.1.316 uses managed.policy_mode layered on Claude apps gateway to merge multiple matching policies and writes appliedPolicyIds, decision, reason layered_merge, and winningPolicyId in audit JSON. Drive layered_policy_gate with managed settings, evaluation context, Write and Bash calls, and audit log samples instead of watching only the first policy id.
Decision: context failing schema → schema; policyMode not layered → not_layered; settings still first → first_only_legacy; no tool rule → no_rule; merged allow deny or ask aligns with audit reason layered_merge and winningPolicyId.
Next: pin context and settings with Schema, use Format to inspect Write and Bash calls, Diff allow versus ask audit logs, and report layered_policy_gate return values separately.