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.

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 settings: managed.policy_mode layered.
{
  "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.

Evaluation context: policyMode layered and matchedPolicies.
{
  "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.

Evaluation context JSON Schema: shape before layered_policy_gate.
{
  "$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.

After merge allow: Write tool call.
{
  "tool": "Write",
  "path": "/srv/repo/src/app.ts"
}
After merge ask: Bash upper ask overrides lower deny.
{
  "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.

Audit log: appliedPolicyIds, decision, reason layered_merge, winningPolicyId.
{
  "tool": "Bash",
  "policyMode": "layered",
  "appliedPolicyIds": ["org-baseline", "repo-contributors"],
  "decision": "ask",
  "reason": "layered_merge",
  "winningPolicyId": "repo-contributors"
}
Log or call signallayered_policy_gate returnsOperator action
Write all layers allow, reason layered_mergeallowLog decision allow; winningPolicyId points to highest order.
Bash lower deny upper ask, reason layered_mergeaskLog decision ask; winningPolicyId is the upper ask policy id.
Merged final effect deny with no ask overridedenyLog decision deny; appliedPolicyIds stays fully ordered.
Settings still first or layered not enabledfirst_only_legacyDo 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.

layered_policy_gate: schema, not_layered, first_only_legacy, no_rule, allow, deny, ask.
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?

  1. 1
    Pin 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.

  2. 2
    Use 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.

  3. 3
    Diff 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.

Back to the blog