↓ Skip to main content
  1. Posts/

Governance for GitHub, Copilot CLI and Agents: Keeping Control When the Code Writes Itself

Author
Gregor Suttie
Passionate about all things Azure. Microsoft Azure MVP, blogger, speaker and community enthusiast based in Scotland.

A couple of weeks ago I asked Copilot CLI to “check that the Bicep in this folder deploys cleanly”. I had --allow-all on, because I nearly always do when I’m in one of my own RateMy repos and want it to get on with things. It read the prompt the way I’d written it rather than the way I meant it, ran az deployment group create against my subscription, and deployed. Nothing bad happened. It was a dev resource group and the template was fine. But I sat there for a second looking at the deployment in the portal and thought: if a colleague had done that in a customer tenant, what would have stopped them? And the honest answer was nothing, because I’d never set anything up to stop me either.

That’s the post. Your team is already using agents, so the question is where the controls actually live now that the agent can run a shell, call MCP servers and open pull requests on its own. There are more of them than I expected, they’re spread across five different places, and some of the docs disagree with each other. Here’s the map I wish I’d had.

Think of it as five layers
#

I’ve found it easiest to split the problem by question rather than by product. Who is allowed to use what? What can the agent touch while it’s running? What is it told to do? What gets to reach main? And afterwards, how do I find out what it did? Each of those has a different control, owned by a different person, and if you only do the top one you’ll end up where I was.

Who gets what: policies at the top
#

Everything starts with the Copilot policies in your organisation or enterprise. If you’re on a plan with an enterprise, the place is the AI controls icon at the top of the enterprise page, then Copilot, then Configure features & clients. Copilot CLI has its own entry in the Clients section with three values: Enabled everywhere, Disabled everywhere and Let organizations decide. The enterprise setting wins over the organisation one, so if you’ve set it to disabled at the top there’s nothing an org owner can do about it, which is the point.

Cloud sessions, which is what GitHub now calls the thing the coding agent and /delegate run in, are governed by a separate Copilot in the cloud policy and it’s enabled by default. Both the CLI policy and that one need to be on before someone can hand work off from the terminal to a cloud session. MCP has its own MCP servers in Copilot policy, which is off by default, with an optional registry URL and a Registry only restriction.

Two things caught me out here. First, not every policy reaches the CLI. The docs are upfront that IDE-specific policies don’t apply to it, and the Copilot CLI concept page has a “known limitations” section saying the CLI can’t currently honour the org-level MCP servers in Copilot or MCP Registry URL policies. The enterprise administration page for the CLI says the opposite. I don’t know which is right as of today, so I’d treat the org MCP policy as a control on the IDEs and the web, and use managed settings (next section) for the CLI. Second, users can still point the CLI at their own model provider with their own API key, and the docs say plainly that this is not controlled by enterprise policies. Worth knowing before you tell your security team the model list is locked down.

What it can touch: permissions and managed settings
#

This is the layer I’d skipped. The CLI has a proper permission model and most people, me included, only ever meet it as the “allow this?” prompt and the flag that makes the prompt go away.

Per session it looks like this. Tool permissions use a kind(argument) pattern, and deny always beats allow, even when --allow-all is set:

copilot --allow-tool='shell(git:*)' --deny-tool='shell(git push)' --deny-tool='shell(az:*)'

That gives the agent every git command except push, and no Azure CLI at all. The kinds are shell, write, read, url, memory and the name of any MCP server, so --allow-tool='github(create_issue)' lets it use one tool from the GitHub MCP server and nothing else from it. Wildcards only work on shell subcommands and on URL hosts and path suffixes. There’s a separate --available-tools and --excluded-tools pair that removes tools from the model entirely rather than gating them, which is a stronger thing: an excluded tool isn’t denied, it doesn’t exist.

Anything you approve interactively is saved to ~/.copilot/permissions-config.json, scoped to the git repo you were in. Flags are not saved. And --allow-all, which also answers to --yolo, is shorthand for --allow-all-tools --allow-all-paths --allow-all-urls. The docs recommend only using it in an isolated environment. I was not in an isolated environment.

None of that is governance though, because it’s all set by the person running the CLI. The team-level control is a file called managed-settings.json and it’s the most useful thing I learned writing this post. It gets deployed to the machine, outside the user’s profile:

  • Windows: %ProgramFiles%\GitHubCopilot\managed-settings.json, or the same keys as REG_SZ values under HKEY_LOCAL_MACHINE\SOFTWARE\Policies\GitHubCopilot
  • macOS: /Library/Application Support/GitHubCopilot/managed-settings.json, or managed preferences for com.github.copilot
  • Linux: /etc/github-copilot/managed-settings.json

There’s also a server-managed option where the file lives at copilot/managed-settings.json in a .github-private repo that the enterprise has nominated, and clients pick changes up within about an hour. Precedence is MDM, then server, then file, then the user’s own settings, except that the permission rules are combined in the most restrictive direction regardless of where they came from. A minimal file that would have saved me from myself:

{
  "permissions": {
    "disableBypassPermissionsMode": "disable",
    "deny": [
      "Shell(az deployment *)",
      "Shell(rm -rf *)",
      "Read(~/.ssh/**)"
    ],
    "ask": [
      "Shell(git push *)"
    ]
  },
  "allowedMcpServers": [
    { "serverUrl": "https://api.githubcopilot.com/*" }
  ]
}

The first key turns off --allow-all and /yolo for everyone on that machine. The deny and ask lists use Shell(...), Read(...), Edit(...) and Domain(...) patterns. The MCP allowlist matches by server name, URL or command, and anything not on it is blocked, with a deniedMcpServers list that wins if both match. The same file applies to VS Code and the GitHub Copilot app, with a support table per key in the reference, so check it before assuming a key works everywhere. I’ve only got the shape of the full example from the reference page, not a run on a managed device, so test it on one laptop before you push it to a hundred.

The cloud side has its own equivalent: the agent firewall. By default a cloud session can only reach a recommended allowlist of package registries and the like, and an org owner can set Enable firewall to enforced, let repositories decide, or allow repository custom rules. It’s under the repository’s Copilot settings on the Internet access tab, and org entries can’t be removed at the repo level.

What it’s told: instructions and shared agents
#

Permissions say what the agent can do. Instructions say what it should do, and this is the layer most teams already have something in, even if it’s just a copilot-instructions.md someone wrote in March and never looked at again.

The bit worth doing properly is custom agents at organisation level. I wrote about building your first one a few weeks back, and the same .agent.md file in an agents folder at the root of your org’s .github or .github-private repo becomes available to every member, CLI included, without them needing access to that repo. You can stage a new one in .github/agents inside the same repo first, where only people with access can see it, then move it up when it’s ready. The precedence when names collide is user over repo over org, so an org agent is a default rather than a lock. Custom agents are still labelled public preview.

Why does this count as governance? Because a read-only Bicep reviewer with tools: ["read", "search"] in its header is a permission boundary written down in a file your team can review in a pull request. That is a lot easier to reason about than asking everyone to remember the right --deny-tool flags.

Content exclusion sits here too. Org owners and repo admins can list paths that Copilot never reads as context, and the CLI documentation says it respects those for Business and Enterprise seats. The how-to page for exclusions says the CLI doesn’t support it. The CLI reference looks newer, and I’d lean that way, but if you’re relying on exclusions to keep a secrets folder away from an agent, test it rather than trust either page.

What reaches main: rulesets and the PR
#

This is the layer everyone has and nobody has reviewed since agents arrived. The good news is that the cloud agent is designed to live inside your existing rules rather than around them. It can only push to a copilot/ branch it created, or to the branch of a PR you’ve explicitly pointed it at. It opens draft PRs and cannot mark them ready, approve them or merge them. GitHub Actions workflows on its PRs don’t run until a human with write access clicks Approve and run workflows, although there’s now a repository setting to let them run automatically if you want that. The person who asked Copilot to open the PR can’t count as the approver, and where the PR is opened under Copilot’s own app identity, rulesets add one extra required approval on top of whatever you already require. That last one is on by default and admins can turn it off.

So the check I’d do is simple: open your rulesets and make sure Require a pull request before merging with at least one approval is on for main. If it isn’t, none of the above protects you, because there’s nothing for the agent to be subject to. Copilot code review can be allowed to submit approvals that count towards that number, and that’s off by default, which is where I’d leave it. An agent approving another agent’s PR is the sort of thing that sounds efficient right up until you’re explaining it to an auditor.

Seeing what happened: audit log, hooks and AI controls
#

The afterwards layer is the thinnest and also the one improving fastest.

In the enterprise audit log, changes to Copilot policies land as events in the copilot category, and the cloud agent writes events for things like firewall and MCP configuration changes per repo, with every commit it makes signed and linked to its session log. Enterprise owners also get an Agent sessions view under AI controls showing recent sessions, with an Audit logs link at the bottom. Streaming agent activity to an external SIEM is in public preview, for enterprises using Enterprise Managed Users or data residency.

What’s missing is the CLI. There’s no server-side record of what someone ran locally. GitHub’s answer is hooks, which are shell commands the CLI runs at fixed points in a session, defined in .github/hooks/*.json in the repository. The events are sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, agentStop, subagentStop and errorOccurred. A preToolUse hook gets the tool name and arguments on stdin and can refuse the call by printing a decision:

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "type": "command",
        "bash": "./scripts/pre-tool-policy.sh",
        "powershell": "./scripts/pre-tool-policy.ps1",
        "cwd": ".github/hooks",
        "timeoutSec": 15
      }
    ]
  }
}

The script writes {"permissionDecision":"deny","permissionDecisionReason":"..."} to stdout to block, or nothing to allow, and can append whatever it likes to a log file on the way through. GitHub’s own tutorial for rolling these out recommends a logging-first approach: ship the hooks with no deny rules, look at the JSON lines they produce for a fortnight, then add denies for the things you actually saw. That matches my experience. I had no idea the agent was running az as often as it was until I logged it.

Hooks are repo-scoped and run on the user’s machine, so a determined person can delete the folder. They’re a seatbelt, not a lock. The lock is managed-settings.json. Use both.

Caveats
#

Several of these controls are preview or recently changed, and the docs are inconsistent in places. The two I’d flag are whether the org MCP policies apply to the CLI and whether content exclusion does, both covered above. I’ve read the pages rather than tested every path, and in particular I haven’t deployed managed settings through MDM myself yet.

Managed settings only restrict machines that receive the file. A personal laptop with a personal Copilot seat is outside all of this, and so is anyone using a bring-your-own-key provider in the CLI.

The hooks tutorial points out that GitHub doesn’t redact secrets from what a hook logs, so if you log tool arguments, redact them yourself before they reach a shared log.

And none of this replaces reading the diff. Every control above is about stopping the obviously bad thing. A plausible-looking change that quietly does the wrong thing still gets caught by a human reviewer or not at all.

Final thoughts
#

If you do one thing after reading this, get managed-settings.json onto the machines your team uses Copilot CLI on, with disableBypassPermissionsMode set and a deny list containing the three or four commands you’d never want run without a human watching. Mine now has az deployment * and az group delete * in it. It took fifteen minutes, it applies to the CLI, VS Code and the Copilot app at once, and it would have stopped the deployment at the top of this post. Then go and check that main actually requires a review. Everything else can wait until you’ve seen a fortnight of hook logs and know what your agents are really doing.

Share this:

Related