Security Analysis

Microsoft lets an agent work production incidents alone. Four layers decide what it can touch

A Microsoft networking engineer explains why no single guardrail is enough for an agent working production incidents. Here are the four layers, and a 40-line gate to run in five minutes.

A blank interface split into stacked translucent layers with a single glowing element linking them.

At 2am a link flaps, alarms correlate, and an agent has already pulled device state, read telemetry and written up the likely cause by the time the on-call engineer logs in. Reading is the easy part. The hard question is whether that agent should be allowed to shut down a BGP session that’s carrying live customer traffic.

Arun Malik, a principal engineer in Azure Networking, takes that question on in a Deep Dive on Microsoft’s Command Line publication from 13 October 2026. His answer is a design for an agent that resolves production network incidents on its own, and the interesting part isn’t the networking. It’s the argument that safe autonomy comes from deterministic software wrapped around a non-deterministic model, in layers, with each layer failing closed.

If you run coding agents unattended, in CI, on a schedule or on a branch nobody watches, the same four layers apply to you.

The model supplies intelligence, the agent supplies the rules

The article draws a line most agent setups blur. The model reads and writes text. The agent is everything else: the code that decides what the model is asked, what it may do with the answer, which tools it can reach and what happens when a step fails. Malik’s phrase for it is a deterministic skeleton with probabilistic filling. The model parses a messy CLI dump into typed JSON, deterministic code checks the JSON against a schema and makes the decision, and the model never holds the execution credential.

His summary is that “the hard part of autonomy is authorization, not intelligence.” A smarter model doesn’t change who is allowed to do what.

The four layers

Identity comes first. The agent never has more access than the human who invoked it. If a service identity is unavoidable, it gets the narrower of the two, so the stricter policy always wins. Access policy for each tool belongs to that tool’s team and changes through pull requests with the tool team as a required reviewer. Merging grants access and reverting revokes it, which gives you an audit trail for free.

The tool layer is next. The agent never gets a bare shell, because an unbounded shell can’t be governed, scoped or audited. Every capability sits behind a typed interface where each tool is individually authorised and each call is structured and logged. The article treats “MCP or CLI” as a false choice: plain functions or sandboxed CLIs for low-risk local work, MCP where a call crosses a trust boundary and you want credential isolation, approval hooks and rate limits. It adds that a CLI can be secured, but only with per-user sandboxes, and even then without per-tool granularity.

The mutation layer separates reads from writes. Diagnostic reads run on their own. Anything that changes production pauses for a human, and the pause is enforced in code in the execution engine, not requested in a prompt. Skipping the gate is something a procedure earns for a specific action type, never something a model is granted for being capable.

The last layer is time. The first time an incident shape appears, the agent reasons through it end to end, which is slow, costly and non-deterministic. Once the same reasoning path has worked often enough, it’s crystallised into a deterministic playbook that runs with no model calls at all. That cuts cost, makes runs reproducible and shrinks the surface for hallucination and attack. The article doesn’t say how many successes count as “enough”, so that threshold is yours to pick.

Why you can’t keep just one

The article’s best section removes the layers one at a time, using a BGP config the model generates for a new device. Models write plausible configs: an invented ASN, a route policy that doesn’t exist, a miss on datacentre conventions.

  • Without identity, the config is pushed with credentials no human on that call held.
  • Without the tool layer, “push a BGP config” isn’t a distinct action, so there’s nothing for permissions or an approval gate to attach to.
  • Without the mutation gate, the invented ASN reaches the device unchecked.
  • Without time, every routine incident pays for full reasoning, including risky execution modes for problems that were solved last month.

Each removal breaks the system in a different way, which is the point. A stricter prompt doesn’t cover any of them.

What it doesn’t tell you

There are no numbers. The article reports no incident counts, latency, cost or accuracy, and its comparison chart is qualitative. It’s a design argument from a team that clearly runs this, not a measured result, so treat it as one.

The costs it does admit are real: deterministic gating adds friction and approval latency for writes, and per-user CLI sandboxes are expensive. For a coding agent the equivalents are a slower first run and a human who has to be reachable.

Mapping it to a coding agent

Opinion from here. Most developer setups have the identity layer by accident, because the agent runs as you, and then give away the rest. A shell tool is the default. Write approval is a prompt the model is told to respect. Everything runs on the full reasoning path every time.

The mutation layer is the cheapest one to move out of prose and into code. Claude Code’s hooks documentation describes a PreToolUse hook that can block a tool call, either by exiting with code 2 or by returning a permissionDecision of allow, deny, ask or defer. The docs’ own example is a deny:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

The docs also say that even a JSON allow doesn’t let an action through when the hook has blocked it with exit code 2. That’s the shape you want: the decision lives outside the model and fails closed.

The four layers in 40 lines

Here’s the whole structure as a runnable Python harness. The model call is a stand-in, a string of JSON, so the point stays on what surrounds it. The scenario follows the article’s interface-recovery example: parse a status, and if an interface is in the errdisabled state, recover it, but only with approval.

import json

HUMAN_SCOPES = {"iface.read", "iface.recover"}      # what the invoking engineer may do
AGENT_SCOPES = {"iface.read", "iface.recover", "bgp.push"}
SCOPES = HUMAN_SCOPES & AGENT_SCOPES                  # identity: the narrower of the two

TOOLS = {                                             # tool layer: typed, named, nothing else
    "show_interface": {"scope": "iface.read",    "writes": False},
    "recover_interface": {"scope": "iface.recover", "writes": True},
    "push_bgp_config": {"scope": "bgp.push",     "writes": True},
}
ALLOWED_STATUS = {"up", "down", "errdisabled"}

def call(tool, approve):
    spec = TOOLS.get(tool)
    if spec is None:
        return f"{tool}: refused, no such tool"
    if spec["scope"] not in SCOPES:
        return f"{tool}: refused, caller lacks {spec['scope']}"
    if spec["writes"] and not approve(tool):          # mutation layer: enforced here, not in a prompt
        return f"{tool}: refused, no human approval"
    return f"{tool}: executed"

def run(model_output, approve):
    try:
        parsed = json.loads(model_output)
        assert parsed["status"] in ALLOWED_STATUS
    except (ValueError, KeyError, AssertionError):
        return "model output rejected, nothing executed"
    if parsed["status"] == "errdisabled":             # the decision is plain code, not the model
        return call("recover_interface", approve)
    return "no action"

Drive it with a few cases, one with no approver, one with an approver, a malformed model answer, a healthy interface, a write the human never had, and a tool that was never registered:

never = lambda tool: False
yes = lambda tool: True
print(run('{"interface": "Eth1/7", "status": "errdisabled"}', never))
print(run('{"interface": "Eth1/7", "status": "errdisabled"}', yes))
print(run('{"interface": "Eth1/7", "status": "mostly fine"}', yes))
print(run('{"interface": "Eth1/7", "status": "up"}', yes))
print(call("push_bgp_config", yes))
print(call("bash", yes))

Run it and it prints:

recover_interface: refused, no human approval
recover_interface: executed
model output rejected, nothing executed
no action
push_bgp_config: refused, caller lacks bgp.push
bash: refused, no such tool

Line five is the identity layer working: the agent’s own scopes include bgp.push, the human’s don’t, so the intersection drops it even though an approver said yes. Line six is the tool layer: bash isn’t a thing here, so no approval can reach it. The first line is the one to check in your own setup. When nobody is around to approve, the answer has to be refusal, not a default of yes.

Where to start this week

Pick one unattended agent you already run and list every tool it can call. For each write-capable one, find where the approval is enforced, and if the honest answer is “the prompt asks nicely”, move that one decision into a hook or a wrapper that fails closed.