Skip to content

Managed-settings interim fail-closed uses an empty allow list ([[]]) and permanently drops user MCP servers that register inside the window — reproduces via the desktop app, on an account with no managed policy #4419

Description

@devinj-msft

Describe the bug

While the CLI resolves managed settings, it installs an interim "deny everything" MCP policy
(managedAllowedMcpServerLists: [[]] — one list, containing nothing). Any user-configured MCP server
that happens to register during that window is rejected with:

[ERROR] Skipping MCP server "<name>": not permitted by enterprise managed allow list

Two things make this a real bug rather than a benign transient:

  1. It fires even when there is no managed policy at all. In my logs the resolution completes in
    5–65 ms with source=none, serverFetchFailed=false, and
    [managedSettings] applied: no bypass restriction in force (managed policy absent). Nothing failed,
    and there is no enterprise policy on this account. The deny is purely pre-emptive.
  2. Servers denied during that window are never restored. The "policy relaxed" restore loop replays
    only servers captured in policyRemovedConfigs, and the startup deny paths never write there. Those
    servers stay dead for the entire life of the session.

Net effect: opening a session while any session is resolving managed settings silently produces a
session with no user MCP servers, permanently. Built-in servers are exempt (the trusted-first-party
check keeps entries whose source === "builtin"), so it presents as "my servers are broken" rather than
"a policy was applied".

Important scope note

The defect is in the CLI, but it is only reachable when the CLI is driven by a client that sets
enableManagedSettings: true.
The GitHub Copilot desktop app sets it on every session.create and
session.resume. A CLI launched by hand does not, so this cannot be reproduced from a bare terminal
invocation
— please see the warning at the top of the repro steps before trying.

This is provable from both sides of the interface:

  • CLI side (app.js, 1.0.79-5): 21 occurrences of enableManagedSettings, and not one assigns it
    true
    . Both enableManagedSettings=!0 and enableManagedSettings:!0 are absent from the entire
    bundle. The field initialises to !1 and is thereafter only read from the client payload
    (this.enableManagedSettings = n.enableManagedSettings ?? !1). Every managed-settings entry point is
    gated on it, including refreshManagedSettings(), which opens with
    if(!this.enableManagedSettings)return;.
  • App side (copilot-sdk/index.js, app 1.1.6): passes
    enableManagedSettings: config.enableManagedSettings into both session.create and session.resume.

That is why the same binary produces 257 deny events under the app and 0 when launched directly.

Impact

  • Sessions silently lose all user MCP tooling. There is no tools_changed notification, no banner, and
    no error surfaced in the UI — only an [ERROR] line in a process log the user has no reason to read.
  • The message names a cause (enterprise managed allow list) that does not exist on the account,
    sending users to hunt for a nonexistent policy. At least one upstream reporter (Post-authentication MCP client rebuild at startup leaves orphaned stdio MCP server processes #4392) read it as a
    genuine corporate whitelist.
  • Agents and skills that depend on those servers fail in ways that look like the servers are
    misconfigured, so users edit working configuration trying to fix it.
  • MCP child processes are spawned and then discarded, so the startup cost is paid for no benefit.

Affected Version

Reproduced on CLI payload 1.0.79-5, driven by desktop app 1.1.6 (commit=a9706b2); also observed
under app 1.1.5 (f459745). Windows 11 x64.

The mechanism is present in every payload from 1.0.72 onward. Marker counts taken directly from each
shipped app.js:

CLI payload managedAllowedMcpServerLists:[[]] host-wide fan-out to active sessions managedSettingsResolutionInFlight
1.0.71 absent 0 0
1.0.72 present 0 0
1.0.73 present 0 0
1.0.78-2 present 1 4
1.0.79-5 present 1 4
1.0.79-9 present 1 4
  • 1.0.72 introduced the empty-allow-list interim state, behind a narrow session-local guard.
  • 1.0.78-2 made it routine: it added fan-out to every active session and widened the trigger guard at
    the same time (diff in Additional context).
  • 1.0.71 does not contain the mechanism at all and is the basis of the workaround.

Install timeline on this machine, from ~/.copilot/logs/github-app.*.log:

2026-08-04T07:42:36Z  Detected Copilot CLI version at startup version=Copilot binary version: 1.0.78-2 source=Some(Bundled)
2026-08-07T15:03:37Z  Detected Copilot CLI version at startup version=Copilot binary version: 1.0.79-5 source=Some(Bundled)

The desktop app version is a precondition, not the cause. copilot-sdk/index.js is byte-identical
between app 1.1.5 and 1.1.6 (SHA-256 648CD26BA299…, 449,504 bytes in both), and the app has passed
enableManagedSettings since 1.1.0 — so nothing changed on the app side across the window in which the
symptom appeared.

Note on reading versions: copilot.exe --version self-resolves to the newest installed payload and
will misreport which payload is actually running. Use --no-auto-update --version, or inspect the
loaded module path of a live process.

Affected version

CLI payload 1.0.79-5+. See the bottom of description for details on affected version

Steps to reproduce the behavior

⚠️ A hand-launched CLI will not reproduce this, and the clean log it produces is not evidence the
build is fine.
Running copilot --server --stdio --no-auto-update directly produced 0
[managedSettings] lines and 0 deny events, while the app-spawned same binary in the same hour
produced 823 and 257 respectively. The reason is in the scope note above: such a process is
never sent enableManagedSettings: true, so it never enters the managed-settings path at all — and
that holds even if you authenticate it. Reproduce through the desktop app.

  1. Install the desktop app (1.1.5 or 1.1.6) with a CLI payload of 1.0.78-2 or later.
  2. Sign in with an account that has no enterprise managed policy. Confirm with the process log — a
    clean resolution on such an account looks like this:
2026-08-09T15:19:40.754Z [INFO] [managedSettings] self-fetch starting for account https://github.com/<user>
2026-08-09T15:19:40.755Z [INFO] [managedSettings] device MDM: no policy present on this device
2026-08-09T15:19:40.915Z [INFO] [managedSettings] server policy: none for this account (404/empty) from https://github.com
2026-08-09T15:19:40.916Z [INFO] [managedSettings] effective policy resolved: source=none, bypassDisabled=false, serverFetchFailed=false
2026-08-09T15:19:40.916Z [INFO] [managedSettings] applied: no bypass restriction in force (managed policy absent)
  1. Configure 2 or more user MCP servers in ~/.copilot/mcp-config.json. Transport does not matter —
    stdio, http and local all fail together. Do not rely on the built-in GitHub server to show the
    failure; it is exempt.
  2. Open several app sessions in quick succession, or open a new session while another is still
    starting. Opening a single session already issues session.create and then session.resume roughly
    2 s later, so one open produces two overlapping option updates; several opens make a collision
    near-certain.
  3. Search the CLI process logs for the denial:
Select-String -Path "$env:USERPROFILE\.copilot\logs\process-*.log" `
  -SimpleMatch 'not permitted by enterprise managed allow list' |
  Where-Object { $_.Line -match '^\d{4}-\d{2}-\d{2}T' -and $_.Line.Length -lt 300 }

The ^\d{4}-… anchor and the length filter are both necessary. Conversation transcripts and
fetched page text are written into these same logs, so an unanchored search matches quoted copies of
the message and overcounts badly. This produced false positives twice during the investigation.

Output — the denial, with the surrounding lifecycle. From
~/.copilot/logs/process-1786241789611-38652.log. Note the ordering and the millisecond deltas: the
skips land inside the fetch, and the fetch succeeds:

2026-08-09T02:16:45.131Z [INFO]  [managedSettings] self-fetch starting for account https://github.com/<user>
2026-08-09T02:16:45.136Z [ERROR] Skipping MCP server "ado-skype": not permitted by enterprise managed allow list
2026-08-09T02:16:45.136Z [ERROR] Skipping MCP server "ado-domoreexp": not permitted by enterprise managed allow list
2026-08-09T02:16:45.136Z [ERROR] Skipping MCP server "mcp_windbg": not permitted by enterprise managed allow list
2026-08-09T02:16:45.136Z [ERROR] Skipping MCP server "obsidian-vault-coding-brain": not permitted by enterprise managed allow list
2026-08-09T02:16:45.196Z [INFO]  [managedSettings] self-fetch complete for https://github.com/<user>: bypassDisabled=false, serverFetchFailed=false, serverCacheFallback=false, deviceLoadFailed=false
2026-08-09T02:16:45.634Z [WARNING] Failed to refresh persisted MCP tools for mcp_windbg; continuing with cached tools: Error: MCP server "mcp_windbg" is not connected
  • deny window here = 65 ms; the servers were denied at +5 ms
  • serverFetchFailed=false — the policy fetch worked
  • the MCP child processes had already been spawned
    (MCP server stderr {"server_name":"ado-domoreexp", ...} at 02:16:45.079), so the work is done and
    then thrown away
  1. To see the window itself and every trigger, pull the whole lifecycle:
Select-String -Path "$env:USERPROFILE\.copilot\logs\process-*.log" -SimpleMatch '[managedSettings]'

Output — a tighter variant, in which a server is denied 1 ms after the fetch already completed,
showing the interim policy is not cleared synchronously with completion:

2026-08-09T02:18:14.600Z [INFO]  [managedSettings] self-fetch starting for account https://github.com/<user>
2026-08-09T02:18:14.605Z [ERROR] Skipping MCP server "ado-skype": not permitted by enterprise managed allow list
   ... (ado-domoreexp, mcp_windbg, obsidian-vault-coding-brain, same millisecond)
2026-08-09T02:18:14.619Z [INFO]  [managedSettings] self-fetch complete for https://github.com/<user>: ... serverFetchFailed=false ...
2026-08-09T02:18:14.620Z [ERROR] Skipping MCP server "obsidian-vault-coding-brain": not permitted by enterprise managed allow list

Output — additional trigger sources, each of which opens its own deny window:

2026-08-09T02:18:18.068Z [INFO] [managedSettings] self-fetch starting for account device-only
2026-08-09T02:18:18.076Z [INFO] [managedSettings] self-fetch starting for account https://github.com/<user>
2026-08-09T02:18:18.079Z [INFO] [managedSettings] self-fetch result for device-only discarded: account changed while the fetch was in flight

Reproduction rate. Over the surviving log-retention window (2026-08-08T22:46Z – 2026-08-09T02:18Z),
during ordinary multi-session use: 42 skip records in 9 bursts across 2 CLI processes
obsidian-vault-coding-brain ×15, ado-skype ×9, ado-domoreexp ×9, mcp_windbg ×9. The built-in
GitHub server was never skipped. Not every session is affected; which sessions lose servers permanently
is decided by the timing rule described in Additional context.

Expected behavior

On an account with no enterprise managed policy, all servers configured in ~/.copilot/mcp-config.json
should start and remain available for the life of the session. Concretely:

  1. No deny while the policy is merely undetermined. "We have not resolved the policy yet" should not
    be encoded as "a policy exists and it permits nothing." With source=none, serverFetchFailed=false
    and managed policy absent, zero servers should be filtered.
  2. If a pre-emptive deny is genuinely required, it must be reversible. A server denied during the
    interim window should be restored when the window closes, exactly as a server stopped by a real
    policy tightening already is. Today the two cases behave differently and only one of them recovers.
  3. Registration should wait for the policy rather than race it. The gate that exists for this purpose
    should be in force during startup registration.
  4. A resolution that has not failed should not disturb sessions that are already running. Servers
    already connected should not be torn down for a routine re-resolution in another session.
  5. If servers are withheld, say so where the user will see it. The failure is currently silent in the
    UI, and the message that does exist blames an enterprise policy that is not present on the account.

Additional context

Where the code is

All references are to the shipped bundle:

%LOCALAPPDATA%\copilot\pkg\win32-x64\1.0.79-5\app.js      (9,317,686 chars, 6,135 lines)

The file is bundled one module per line, so the relevant lines are 100–150 KB wide (line 2345 is 147,313
chars; line 2398 is 114,363). A line number alone will not locate anything — use line:col, or the byte
offset.
Both are given; either can be re-derived by searching for the symbol name, which is unminified.

# What Symbol line:col byte offset
1 Producer emits the empty allow list composedManagedMcpHostOptions 2398:44941 2147203
1a The literal itself managedAllowedMcpServerLists:[[]] 2398:45016 2147278
2 Consumer's length > 0 test managedMcpPolicyPlan 2345:75295 1893804
3 Tighten + relax, both halves enforceManagedMcpPolicy 2345:76770 1895279
3a The only .set() in the bundle policyRemovedConfigs.set( 2345:77656 1896165
4 Deny path A — bulk start loop after managedMcpPolicyBlockReasonForBulk 2345:79639 1898148
5 Deny path B — first check startServerOnce 2345:93949 1912458
6 Deny path C — second check startServerOnce 2345:94664 1913173
7 The gate that opts itself out expectManagedMcpPolicy 2398:43337 2145599
8 Host-wide fan-out beginManagedSettingsResolution 2398:42842 2145104
9 The trigger updateOptions 2402:67610 2284349
10 Client-supplied flag default enableManagedSettings ?? !1 2402:40113 2256852

To re-derive line:col from an offset ([regex]::Matches over a 9 MB string hangs — use IndexOf):

$s = [IO.File]::ReadAllText("$env:LOCALAPPDATA\copilot\pkg\win32-x64\1.0.79-5\app.js")
$off = $s.IndexOf('policyRemovedConfigs.set(', [StringComparison]::Ordinal)
$line = 1; $i = $s.IndexOf("`n"); $last = 0
while ($i -ge 0 -and $i -lt $off) { $line++; $last = $i + 1; $i = $s.IndexOf("`n", $i + 1) }
"line $line col $($off - $last + 1)  offset $off"

There is a fourth mcpHostStateAddFiltered(...) call at 2345:93083 / offset 1911592, inside
applyConfigFilterToServer. It is not part of this defect — it is the separate config-filter path
and logs MCP server "X" filtered: <reason>, not the allow-list message. Noted so the count is not
mistaken for four managed-policy deny sites; there are three.

Root cause

1. The producer emits "a policy exists and it allows nothing"

composedManagedMcpHostOptions() {
  if (this.managedMcpInterimFailClosed)
    return { managedAllowedMcpServerLists: [[]] };          // <-- one list, containing nothing
  let e = this.composeManagedMcpPolicy();
  return {
    managedAllowedMcpServerLists: e.allowedLists.length > 0 ? e.allowedLists : void 0,
    managedDeniedMcpServers:      e.denied.length      > 0 ? e.denied      : void 0
  };
}

Note the asymmetry with the normal path directly below it: when there is genuinely no policy the normal
path returns void 0, but the interim path returns [[]].

2. The consumer's "is a policy configured?" test cannot tell [[]] from a real policy

async managedMcpPolicyPlan(e, n) {
  let r = this.managedAllowedMcpServerLists ?? [],
      o = r.length > 0,                                     // [[]].length === 1  -> true
      s = (this.managedDeniedMcpServers?.length ?? 0) > 0;
  return !o && !s
    ? { removeNames: [], logMessages: [] }
    : await h.mcpHostManagedMcpPolicyActions(/* ... */);
}

[[]] is truthy for length > 0, so the native policy engine runs against an empty allow list and
removes everything not exempt. The user-visible string comes from o.logMessages[0], produced by the
native addon — which is why grepping app.js for not permitted by enterprise managed allow list finds
nothing.

Exemption set — why built-ins survive and every user server dies together regardless of transport:

isRegisteredServerTrustedFirstParty(e) {
  return this.trustedDynamicStartServerNames.has(e)
      || this.trustedDefaultServerNames.includes(e)
      || this.trustedExternalServerNames.has(e);
}
// trustedDefaultServerNames keeps only entries whose source === "builtin"

3. The bug's teeth: the startup deny paths are not restorable

enforceManagedMcpPolicy contains both halves of the policy transition. On tighten it stops the server
and stashes its config; on relax it replays only what was stashed:

// tighten: stop + stash
try { await this.stopServer(l); } catch (d) { /* ... */ }
this.policyRemovedConfigs.set(l, c);            // <-- the ONLY .set() in the file (2345:77656)
delete this.config.mcpServers[l];

// relax: restore
for (let [l, c] of [...this.policyRemovedConfigs])
  if (!s.has(l)) {
    if (this.config.mcpServers[l]) { this.policyRemovedConfigs.delete(l); continue; }
    try { await this.startServer(l, c); } catch (d) { /* ... */ continue; }
    this.config.mcpServers[l] && (this.policyRemovedConfigs.delete(l), a = !0);
  }

But a server denied while starting never reaches that stash. startServerOnce checks the policy
twice and both times just marks the server filtered and returns:

async startServerOnce(e, n, r, o, s = !1) {
  let a = this.currentServerStopVersion(e);
  if (!this.validateServerConfig(e, n)) return;
  let l = await this.managedMcpPolicyBlockReason(e, n, s);
  if (l) { h.mcpHostStateAddFiltered(this.hostStateHandle, e); this.logger.log(l); return; }   // 2345:93949
  /* ... gate plan, config filtering ... */
  let g = await this.managedMcpPolicyBlockReason(e, d, s);
  if (g || this.currentServerStopVersion(e) !== a) {
    g && (h.mcpHostStateAddFiltered(this.hostStateHandle, e), this.logger.log(g)); return;      // 2345:94664
  }
  this.config.mcpServers[e] = d;      // only reached when NOT blocked
  /* ... connect ... */
}

and the bulk path additionally deletes the config outright:

let d = await this.managedMcpPolicyBlockReasonForBulk(l, c);
if (d) {
  this.logger.log(d);
  h.mcpHostStateAddFiltered(this.hostStateHandle, l);
  delete this.config.mcpServers[l];       // <-- dropped, never stashed for restore  (2345:79639)
  return;
}

There is exactly one policyRemovedConfigs.set(...) in the bundle, and none of the three
managed-policy deny paths is it.
Therefore:

when the window opens outcome
server already registered stopped, stashed, restored automatically when the policy clears
server registering right now filtered + config dropped, never restored — dead for the life of the session

A fresh session registers its MCP servers at session start, which is exactly when the window opens, so
the common case is the non-restorable one. This single asymmetry is why the failure looks
non-deterministic, and why "it fixed itself after a minute" and "it was dead for the whole session" are
both accurate reports of the same defect.

Servers in the second row can still come back if a later full MCP client rebuild happens to re-register
them from the original config — the teardown/rebuild described in #4392. That is incidental rather than
the restore path, and it can be denied again: I measured 4 consecutive bursts on the same 4 servers
within 90 s.

4. What opens the window, and why it opens so often

beginManagedSettingsResolution() {
  if (this.lastManagedSettingsLayers) {
    this.managedSettingsResolutionInFlight = !0;
    this.managedSettingsPolicyGeneration++;
    for (let n of this.activeSessions.values())
      n.beginManagedSettingsResolution({ permissions: !1 });   // fan-out to EVERY live session
  }
}

{permissions:false} skips the permissions half, but the session-level handler still always calls
beginInterimManagedMcpFailClosed(). A session that joins while a resolution is in flight is
immediately fail-closed too:

applyCurrentManagedSettingsPolicy(e, n, r) {
  /* ... */ this.managedSettingsResolutionInFlight
      ? e.beginManagedSettingsResolution({ permissions: !1 })
      : /* ... */
}

There is a gate intended to make MCP registration wait for the policy, but it opts itself out whenever
the host already exists, so registration proceeds against the interim [[]]:

expectManagedMcpPolicy() {
  if (this.managedMcpPolicyApplied || this.mcpHost || this.managedMcpPolicyGate) return;
  /* ...create gate... */
}
// consumer:
this.managedMcpPolicyGate && await this.managedMcpPolicyGate.promise;   // no gate -> awaits nothing

The 1.0.78-2 trigger-guard change

// 1.0.73 — narrow: authInfo present AND identity defined AND identity changed; session-local effect
if (e.authInfo !== void 0 && this.enableManagedSettings) {
  let l = YX(this.authInfo);
  l !== void 0 && l !== this.managedSettingsFetchIdentity && (
    this.managedSettingsFetchIdentity = l,
    this.beginInterimManagedMcpFailClosed(),          // this session only
    /* ... */);
}

// 1.0.78-2 and later — broad: key merely present, undefined identity no longer suppresses,
// plus a generation check; effect is now host-wide
if (this.enableManagedSettings && ("authInfo" in e || e.enableManagedSettings === !0)) {
  let c = ene(this.authInfo);
  (c !== this.managedSettingsFetchIdentity ||
   this.managedSettingsAuthGeneration !== this.managedSettingsFetchGeneration) && (
    this.managedSettingsFetchIdentity   = c,
    this.managedSettingsFetchGeneration = this.managedSettingsAuthGeneration,
    this.beginManagedSettingsResolution(),            // fan-out to ALL sessions
    /* ... */);
}

Three widenings at once: e.authInfo !== void 0 became "authInfo" in e; the l !== void 0 guard was
dropped; and a generation disjunct was added. managedSettingsAuthGeneration++ (new in 1.0.78-2, fired
on auth_info_updated) means each auth refresh arms the next options update to re-trigger the cycle.

The hourly refreshManagedSettings() timer does not call beginManagedSettingsResolution() — only
updateOptions does. So the window is tied to session churn, not to the timer.

Suggested fixes (any one breaks the chain)

  1. Do not use [[]] as a sentinel. Represent "policy undetermined" as a distinct flag, or make the
    consumer test r.some(list => list.length > 0) instead of r.length > 0.
  2. Make the deny paths restorable — have the startServerOnce and bulk deny paths record the config
    into policyRemovedConfigs (or an equivalent) so the "policy relaxed" loop can replay them. This is
    the smallest change that removes the permanent-loss case.
  3. Fix the gate — drop || this.mcpHost from expectManagedMcpPolicy() so registration waits for
    the resolution instead of racing it.
  4. Do not fan out to already-running sessions, or at minimum do not tear down servers that are
    already connected for a resolution that has not failed.

Workaround (verified)

Pin the CLI to 1.0.71, which does not contain the mechanism at all:

[Environment]::SetEnvironmentVariable(
  'COPILOT_CLI_PATH',
  "$env:LOCALAPPDATA\github-copilot-sdk\cli\1.0.71\copilot.exe",
  'User')
# then fully exit and restart the app

Confirmed applied — note that the reported version string is unreliable here; the path is ground truth:

2026-08-09T05:01:55Z  Detected Copilot CLI version at startup version=GitHub Copilot CLI 1.0.79-9
                      source=Some(Local) path="...\github-copilot-sdk\cli\1.0.71\copilot.exe"
                      bundled_version="1.0.79-5"

All 8 running copilot.exe processes load their payload from pkg\win32-x64\1.0.71\, and there have
been zero deny events in the ~11 h since, across continued multi-session use.

What we could not determine

  • Whether the interim state is ever legitimately needed. If the intent is to fail closed only when
    the policy genuinely cannot be determined, then the serverFetchFailed=false / managed policy absent
    case observed here should never have entered it at all.
  • Exact first occurrence. process-*.log files rotate; the oldest surviving one starts
    2026-08-08T22:24Z. Earlier deny events were observed during this investigation but those files are now
    gone. The version matrix is the reliable dating, not the logs.
  • Why the perceived onset was 2026-08-08 when 1.0.78-2 shipped on 08-04. Best explanation: the
    fan-out only bites when sessions overlap, and concurrent multi-session usage began on 08-08. Not
    proven
    — offered as a hypothesis, not a finding.

Related issues

Please do not close this as a duplicate of the fetch-failure family. The discriminator is one field
in the resolved line: every filed variant has serverFetchFailed=true; this one has
serverFetchFailed=false with source=none / managed policy absent. Relatedly, the v1.0.78 release
note
advertises a fail-closed → fail-open change, but that covers the fetch-failure path only. This
variant is untouched by it and in fact reproduces on 1.0.79-5, which postdates it.

A third-party report quoting Skipping MCP server "umacs-build-bridge" dated 2026-08-03 matches the
1.0.72/1.0.73 era, consistent with the mechanism landing in 1.0.72.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:enterpriseGitHub Enterprise (GHE/GHES) support, org policies, and enterprise settingsarea:mcpMCP server configuration, discovery, connectivity, OAuth, policy, and registry

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions