@samitouri / QOSAMI-WSL / commits / aa585a20

Implement WSLC container group policies (#40466)

* Implement WSLC container group policies Adds enforcement for two group policies whose ADMX templates landed in PR #40422: - AllowWSLContainer (DWORD): master switch for wslc.exe and the WSLCSessionManager COM API. Enforced inside WSLCSessionManagerFactory so CoCreateInstance returns WSL_E_CONTAINER_DISABLED directly when the policy is off; every caller (wslc.exe, WslcSDK, plugins) fails fast through one code path rather than getting a manager whose every method errors out individually. - WSLContainerRegistryAllowlist: ADMX `<list valuePrefix=\"AllowedRegistry\">` policy stored as a sub-key whose REG_SZ value data is each allowed registry hostname. Enforced in PullImage and PushImage via a shared helper. BuildImage is rejected outright when an allowlist is configured, since the in-VM docker daemon fetches FROM base images through its own pull mechanism and cannot be reliably gated per-registry. The policy is fail-open: an absent or empty sub-key is treated as no restriction, and registry I/O errors fall through to allow rather than break all container operations on a transient hiccup. Adds two new HRESULTs: - WSL_E_CONTAINER_DISABLED (0x33) - WSL_E_REGISTRY_BLOCKED_BY_POLICY (0x34) Adds PolicyTests covering disabled-state, CLI surfacing, allowlist denial, build rejection, and pure-function unit tests for the allowlist evaluator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on PolicyTests * SetRegistryAllowlist: delete the WSLContainerRegistryAllowlist sub-key before recreating it so stale AllowedRegistryN values from a previous (possibly interrupted) test run can't leak into the current test. * GetWslcExePath: avoid std::optional::value() throwing bad_optional_access; use THROW_HR_IF_MSG with a clear diagnostic when the MSI install location can't be read from the registry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Ben Hillis <benhill@ntdev.microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: benhillis <17727402+benhillis@users.noreply.github.com>

Ben Hillis committed May 13, 2026 at 12:43 UTC aa585a20838fc7f3f4a70d6eb728b6863e036b95
7 files changed +383 -3
localization/strings/en-US/Resources.resw
+10
@@ -987,6 +987,16 @@ Falling back to NAT networking.</value>
987 <data name="MessageWSL1Disabled" xml:space="preserve">
988 <value>WSL1 is disabled by the computer policy.</value>
989 </data>
990 + <data name="MessageWSLContainerDisabled" xml:space="preserve">
991 + <value>WSL container is disabled by the computer policy.</value>
992 + </data>
993 + <data name="MessageRegistryBlockedByPolicy" xml:space="preserve">
994 + <value>The container image registry '{}' is blocked by the computer policy.</value>
995 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
996 + </data>
997 + <data name="MessageImageBuildBlockedByPolicy" xml:space="preserve">
998 + <value>Building container images is blocked because a container registry allowlist is configured by the computer policy. Image builds may pull base images from arbitrary registries and cannot be reliably restricted to the allowlist.</value>
999 + </data>
1000 <data name="MessageUpgradeToWSL2" xml:space="preserve">
1001 <value>Please run 'wsl.exe --set-version {} 2' to upgrade to WSL2.</value>
1002 <comment>{FixedPlaceholder="{}"}{Locked="--set-version "}Command line arguments, file names and string inserts should not be translated</comment>
src/windows/common/wslutil.cpp
+5
@@ -88,6 +88,8 @@ static const std::map<HRESULT, LPCWSTR> g_commonErrors{
88 X(WSL_E_INVALID_JSON),
89 X(WSL_E_VM_CRASHED),
90 X(WSL_E_NOT_A_LINUX_DISTRO),
91 + X(WSL_E_CONTAINER_DISABLED),
92 + X(WSL_E_REGISTRY_BLOCKED_BY_POLICY),
93 X(WSLC_E_CONTAINER_PREFIX_AMBIGUOUS),
94 X(E_ACCESSDENIED),
95 X_WIN32(ERROR_NOT_FOUND),
@@ -783,6 +785,9 @@ std::wstring wsl::windows::common::wslutil::GetErrorString(HRESULT result)
785 case WSL_E_NOT_A_LINUX_DISTRO:
786 return Localization::MessageInvalidDistributionTar();
787
788 + case WSL_E_CONTAINER_DISABLED:
789 + return Localization::MessageWSLContainerDisabled();
790 +
791 case WSL_E_INVALID_USAGE:
792 {
793 const auto* context = wsl::windows::common::ExecutionContext::Current();
src/windows/inc/wslpolicies.h
+135
@@ -31,6 +31,8 @@ inline constexpr auto c_allowDiskMount = L"AllowDiskMount";
31 inline constexpr auto c_allowCustomNetworkingModeUserSetting = L"AllowNetworkingModeUserSetting";
32 inline constexpr auto c_allowCustomFirewallUserSetting = L"AllowFirewallUserSetting";
33 inline constexpr auto c_defaultNetworkingMode = L"DefaultNetworkingMode";
34 +inline constexpr auto c_allowWSLContainer = L"AllowWSLContainer";
35 +inline constexpr auto c_wslContainerRegistryAllowlist = L"WSLContainerRegistryAllowlist";
36
37 inline wil::unique_hkey CreatePoliciesKey(DWORD desiredAccess)
38 {
@@ -102,4 +104,137 @@ inline wil::unique_hkey OpenPoliciesKey()
104 return key;
105 }
106
107 +// Opens the WSLContainerRegistryAllowlist sub-key under the supplied policies key for
108 +// read-only enumeration. Returns an empty handle when the policy is not configured (sub-key
109 +// absent) or the parent key is null.
110 +inline wil::unique_hkey OpenRegistryAllowlistKey(HKEY policiesKey)
111 +{
112 + if (policiesKey == nullptr)
113 + {
114 + return {};
115 + }
116 +
117 + wil::unique_hkey subKey;
118 + const auto result = RegOpenKeyExW(policiesKey, c_wslContainerRegistryAllowlist, 0, KEY_READ, &subKey);
119 + if (result == ERROR_PATH_NOT_FOUND || result == ERROR_FILE_NOT_FOUND)
120 + {
121 + return {};
122 + }
123 +
124 + LOG_IF_WIN32_ERROR(result);
125 + return subKey;
126 +}
127 +
128 +// Returns the REG_SZ data of every value under the WSLContainerRegistryAllowlist sub-key
129 +// (one entry per configured registry hostname). The ADMX uses
130 +// `<list valuePrefix="AllowedRegistry"/>`, which the GP editor materialises by writing one
131 +// REG_SZ value per entry: each value is named `AllowedRegistry1`, `AllowedRegistry2`, ... and
132 +// the value's data is the actual hostname; the value names are therefore ignored here. Schema
133 +// reference: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/Policy/element-list
134 +//
135 +// Returns an empty list when the sub-key has no values or on any enumeration failure; either
136 +// case means no effective restriction is in place.
137 +inline std::vector<std::wstring> EnumerateRegistryAllowlist(HKEY subKey)
138 +try
139 +{
140 + std::vector<std::wstring> entries;
141 + if (subKey == nullptr)
142 + {
143 + return entries;
144 + }
145 +
146 + DWORD maxValueNameChars = 0;
147 + DWORD maxValueDataBytes = 0;
148 + THROW_IF_WIN32_ERROR(RegQueryInfoKeyW(
149 + subKey, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, &maxValueNameChars, &maxValueDataBytes, nullptr, nullptr));
150 +
151 + std::wstring nameBuffer(static_cast<size_t>(maxValueNameChars) + 1, L'\0');
152 + std::wstring dataBuffer(maxValueDataBytes / sizeof(wchar_t) + 1, L'\0');
153 + for (DWORD index = 0;; ++index)
154 + {
155 + DWORD nameSize = static_cast<DWORD>(nameBuffer.size());
156 + DWORD dataSize = static_cast<DWORD>(dataBuffer.size() * sizeof(wchar_t));
157 + DWORD type = 0;
158 + const auto status =
159 + RegEnumValueW(subKey, index, nameBuffer.data(), &nameSize, nullptr, &type, reinterpret_cast<BYTE*>(dataBuffer.data()), &dataSize);
160 + if (status == ERROR_NO_MORE_ITEMS)
161 + {
162 + break;
163 + }
164 +
165 + THROW_IF_WIN32_ERROR(status);
166 + if (type != REG_SZ && type != REG_EXPAND_SZ)
167 + {
168 + continue;
169 + }
170 +
171 + const size_t chars = dataSize / sizeof(wchar_t);
172 + std::wstring_view entry{dataBuffer.data(), chars};
173 + if (!entry.empty() && entry.back() == L'\0')
174 + {
175 + entry.remove_suffix(1);
176 + }
177 +
178 + // Skip empty entries so a stray blank list item in the GP editor doesn't make the
179 + // allowlist non-empty (which would otherwise deny every registry).
180 + if (entry.empty())
181 + {
182 + continue;
183 + }
184 +
185 + entries.emplace_back(entry);
186 + }
187 +
188 + return entries;
189 +}
190 +catch (...)
191 +{
192 + LOG_CAUGHT_EXCEPTION();
193 + return {};
194 +}
195 +
196 +// Evaluates the WSLContainerRegistryAllowlist policy for `server`. The policy only restricts
197 +// traffic when the sub-key exists and contains at least one entry. With the sub-key absent or
198 +// empty, every server is allowed; otherwise the server is allowed only when it case-insensitively
199 +// matches one of the allowlist entries.
200 +inline bool IsRegistryAllowed(HKEY policiesKey, std::wstring_view server)
201 +{
202 + auto subKey = OpenRegistryAllowlistKey(policiesKey);
203 + if (!subKey)
204 + {
205 + return true;
206 + }
207 +
208 + const auto entries = EnumerateRegistryAllowlist(subKey.get());
209 + if (entries.empty())
210 + {
211 + return true;
212 + }
213 +
214 + if (server.empty())
215 + {
216 + return true;
217 + }
218 +
219 + const std::wstring target{server};
220 + for (const auto& entry : entries)
221 + {
222 + if (_wcsicmp(entry.c_str(), target.c_str()) == 0)
223 + {
224 + return true;
225 + }
226 + }
227 + return false;
228 +}
229 +
230 +// Returns true when the WSLContainerRegistryAllowlist policy is in effect (sub-key present
231 +// with at least one entry). Used by callers (e.g., `wslc image build`) that cannot attribute
232 +// traffic to a specific registry and must therefore refuse the operation whenever any
233 +// allowlist restriction is active.
234 +inline bool HasRegistryAllowlist(HKEY policiesKey)
235 +{
236 + auto subKey = OpenRegistryAllowlistKey(policiesKey);
237 + return subKey && !EnumerateRegistryAllowlist(subKey.get()).empty();
238 +}
239 +
240 } // namespace wsl::windows::policies
\ No newline at end of file
src/windows/service/exe/WSLCSessionManagerFactory.cpp
+9
@@ -16,6 +16,7 @@ Abstract:
16
17 #include "WSLCSessionManagerFactory.h"
18 #include "WSLCSessionManager.h"
19 +#include "wslpolicies.h"
20
21 using wsl::windows::service::wslc::WSLCSessionManagerFactory;
22 using wsl::windows::service::wslc::WSLCSessionManagerImpl;
@@ -37,6 +38,14 @@ HRESULT WSLCSessionManagerFactory::CreateInstance(_In_ IUnknown* pUnkOuter, _In_
38
39 try
40 {
41 + wsl::windows::common::COMServiceExecutionContext context;
42 +
43 + namespace policies = wsl::windows::policies;
44 + THROW_HR_WITH_USER_ERROR_IF(
45 + WSL_E_CONTAINER_DISABLED,
46 + wsl::shared::Localization::MessageWSLContainerDisabled(),
47 + !policies::IsFeatureAllowed(policies::OpenPoliciesKey().get(), policies::c_allowWSLContainer));
48 +
49 std::lock_guard lock{g_mutex};
50
51 THROW_HR_IF(CO_E_SERVER_STOPPING, !g_sessionManagerImpl.has_value());
src/windows/service/inc/wslservice.idl
+2
@@ -395,3 +395,5 @@ cpp_quote("#define WSL_E_DISK_CORRUPTED MAKE_HRESULT(SEVERITY_ERROR, FACILITY_IT
395 cpp_quote("#define WSL_E_DISTRIBUTION_NAME_NEEDED MAKE_HRESULT(SEVERITY_ERROR, FACILITY_ITF, WSL_E_BASE + 0x30) /* 0x80040330 */")
396 cpp_quote("#define WSL_E_INVALID_JSON MAKE_HRESULT(SEVERITY_ERROR, FACILITY_ITF, WSL_E_BASE + 0x31) /* 0x80040331 */")
397 cpp_quote("#define WSL_E_VM_CRASHED MAKE_HRESULT(SEVERITY_ERROR, FACILITY_ITF, WSL_E_BASE + 0x32) /* 0x80040332 */")
398 +cpp_quote("#define WSL_E_CONTAINER_DISABLED MAKE_HRESULT(SEVERITY_ERROR, FACILITY_ITF, WSL_E_BASE + 0x33) /* 0x80040333 */")
399 +cpp_quote("#define WSL_E_REGISTRY_BLOCKED_BY_POLICY MAKE_HRESULT(SEVERITY_ERROR, FACILITY_ITF, WSL_E_BASE + 0x34) /* 0x80040334 */")
src/windows/wslcsession/WSLCSession.cpp
+35 -3
@@ -19,6 +19,7 @@ Abstract:
19 #include "ContainerNameGenerator.h"
20 #include "ServiceProcessLauncher.h"
21 #include "WslCoreFilesystem.h"
22 +#include "wslpolicies.h"
23
24 using namespace wsl::windows::common;
25 using relay::MultiHandleWait;
@@ -36,6 +37,25 @@ constexpr DWORD c_processKillTimeoutMs = 10 * 1000;
37
38 namespace {
39
40 +// Group policy: WSLContainerRegistryAllowlist restricts which container-image
41 +// registries can be pulled from or pushed to. The check is enforced here at the
42 +// service boundary so it covers ALL callers (wslc.exe CLI, the WslcSDK C API, and
43 +// any other COM client). The repo argument must be the parsed repo from
44 +// wslutil::ParseImage so callers don't pay the regex cost twice.
45 +void EnforceRegistryAllowlist(const std::string& Repo)
46 +{
47 + const auto policiesKey = wsl::windows::policies::OpenPoliciesKey();
48 + auto [server, path] = wsl::windows::common::wslutil::NormalizeRepo(Repo);
49 + const auto serverWide = wsl::shared::string::MultiByteToWide(server);
50 +
51 + if (wsl::windows::policies::IsRegistryAllowed(policiesKey.get(), serverWide))
52 + {
53 + return;
54 + }
55 +
56 + THROW_HR_WITH_USER_ERROR(WSL_E_REGISTRY_BLOCKED_BY_POLICY, Localization::MessageRegistryBlockedByPolicy(serverWide));
57 +}
58 +
59 std::string IndentLines(const std::string& input, const std::string& prefix)
60 {
61 if (input.empty())
@@ -633,11 +653,12 @@ try
653
654 RETURN_HR_IF_NULL(E_POINTER, Image);
655
656 + auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
657 + EnforceRegistryAllowlist(repo);
658 +
659 auto lock = m_lock.lock_shared();
660 THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
661
639 - auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
640 -
662 if (!tagOrDigest.has_value())
663 {
664 tagOrDigest = "latest";
@@ -673,6 +694,15 @@ try
694 "Invalid flags: 0x%x",
695 Options->Flags);
696
697 + // Image builds shell out to `docker build` inside the VM, which fetches FROM
698 + // base images directly through the in-VM docker daemon and bypasses the
699 + // per-pull registry policy gate. When an allowlist is configured, refuse the
700 + // build outright since we cannot reliably attribute its registry traffic.
701 + if (wsl::windows::policies::HasRegistryAllowlist(wsl::windows::policies::OpenPoliciesKey().get()))
702 + {
703 + THROW_HR_WITH_USER_ERROR(WSL_E_REGISTRY_BLOCKED_BY_POLICY, Localization::MessageImageBuildBlockedByPolicy());
704 + }
705 +
706 auto buildFileHandle = OpenUserHandle(Options->DockerfileHandle);
707
708 std::optional<UserCOMCallback> comCall;
@@ -1442,10 +1472,12 @@ try
1472 RETURN_HR_IF_NULL(E_POINTER, Image);
1473 RETURN_HR_IF_NULL(E_POINTER, RegistryAuthenticationInformation);
1474
1475 + auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
1476 + EnforceRegistryAllowlist(repo);
1477 +
1478 auto lock = m_lock.lock_shared();
1479 THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
1480
1448 - auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
1481 auto requestContext = m_dockerClient->PushImage(repo, tagOrDigest, RegistryAuthenticationInformation);
1482 StreamImageOperation(*requestContext, Image, "Push", ProgressCallback);
1483
test/windows/PolicyTests.cpp
+187
@@ -13,6 +13,7 @@ Abstract:
13 --*/
14
15 #include "precomp.h"
16 +#include <fstream>
17 #include "Common.h"
18 #include "registry.hpp"
19 #include "wslpolicies.h"
@@ -54,6 +55,36 @@ class PolicyTest
55 return RegistryKeyChange(HKEY_LOCAL_MACHINE, c_registryKey, Name, Value);
56 }
57
58 + // Writes the supplied entries under the WSLContainerRegistryAllowlist sub-key as REG_SZ
59 + // values named "AllowedRegistry1", "AllowedRegistry2", ... (matching what the GP editor
60 + // writes for the ADMX `<list valuePrefix="AllowedRegistry"/>` policy) and deletes the
61 + // sub-key when the returned scope exits.
62 + static auto SetRegistryAllowlist(std::initializer_list<std::wstring_view> entries)
63 + {
64 + const auto policies = OpenKey(HKEY_LOCAL_MACHINE, c_registryKey, KEY_ALL_ACCESS);
65 +
66 + // Drop any pre-existing sub-key so stale `AllowedRegistryN` values from a previous
67 + // (possibly interrupted) test run can't leak into this one.
68 + DeleteKey(policies.get(), c_wslContainerRegistryAllowlist);
69 +
70 + const auto subKey = CreateKey(policies.get(), c_wslContainerRegistryAllowlist);
71 + DWORD index = 1;
72 + for (const auto& entry : entries)
73 + {
74 + const auto name = std::format(L"AllowedRegistry{}", index++);
75 + const std::wstring data{entry};
76 + WriteString(subKey.get(), nullptr, name.c_str(), data.c_str());
77 + }
78 + return wil::scope_exit([] {
79 + try
80 + {
81 + const auto policies = OpenKey(HKEY_LOCAL_MACHINE, c_registryKey, KEY_ALL_ACCESS);
82 + DeleteKey(policies.get(), c_wslContainerRegistryAllowlist);
83 + }
84 + CATCH_LOG()
85 + });
86 + }
87 +
88 static void ValidateWarnings(const std::wstring& expectedWarnings, bool pattern = false)
89 {
90 auto [output, warnings] = LxsstuLaunchWslAndCaptureOutput(L"echo ok");
@@ -389,4 +420,160 @@ class PolicyTest
420 VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"wslinfo --networking-mode | grep -iF 'virtioproxy'"), 0u);
421 }
422 }
423 +
424 + // Build the absolute path to the installed wslc.exe.
425 + static std::wstring GetWslcExePath()
426 + {
427 + auto msiPath = wsl::windows::common::wslutil::GetMsiPackagePath();
428 + THROW_HR_IF_MSG(E_UNEXPECTED, !msiPath.has_value(), "MSI install location not found in registry; is WSL installed?");
429 + return (std::filesystem::path(*msiPath) / L"wslc.exe").wstring();
430 + }
431 +
432 + // Verifies AllowWSLContainer=0 gates the WSLCSessionManager COM factory itself, so that
433 + // every method (including GetVersion) is unreachable when the policy disables containers.
434 + WSLC_TEST_METHOD(WSLContainerDisabled)
435 + {
436 + auto revert = SetPolicy(c_allowWSLContainer, 0);
437 +
438 + wil::com_ptr<IWSLCSessionManager> sessionManager;
439 + HRESULT hr = CoCreateInstance(__uuidof(WSLCSessionManager), nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&sessionManager));
440 + VERIFY_ARE_EQUAL(WSL_E_CONTAINER_DISABLED, hr);
441 + VERIFY_IS_NULL(sessionManager.get());
442 + }
443 +
444 + // Verifies AllowWSLContainer=0 gates wslc.exe at startup with a friendly message.
445 + WSLC_TEST_METHOD(WSLContainerDisabledCli)
446 + {
447 + auto revert = SetPolicy(c_allowWSLContainer, 0);
448 +
449 + std::wstring cmd = L"\"" + GetWslcExePath() + L"\" container ls";
450 + auto [stdoutText, stderrText, exitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(cmd.data(), nullptr, nullptr);
451 +
452 + VERIFY_ARE_EQUAL(1, exitCode);
453 + if (stderrText.find(L"WSL container is disabled by the computer policy") == std::wstring::npos)
454 + {
455 + LogError("Expected stderr to contain disabled message, got: '%ls'", stderrText.c_str());
456 + VERIFY_FAIL();
457 + }
458 + }
459 +
460 + // Verifies the WSLContainerRegistryAllowlist denies image pulls from registries not in the
461 + // allowlist.
462 + WSLC_TEST_METHOD(RegistryAllowlistDenies)
463 + {
464 + // Allowlist contains ONLY mcr.microsoft.com -- pulling docker.io must be denied.
465 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
466 +
467 + std::wstring cmd = L"\"" + GetWslcExePath() + L"\" image pull alpine:latest";
468 + auto [stdoutText, stderrText, exitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(cmd.data(), nullptr, nullptr);
469 +
470 + VERIFY_ARE_NOT_EQUAL(0, exitCode);
471 + const std::wstring combined = stdoutText + stderrText;
472 + if (combined.find(L"docker.io") == std::wstring::npos || combined.find(L"blocked by the computer policy") == std::wstring::npos)
473 + {
474 + LogError(
475 + "Expected blocked-by-policy for docker.io when allowlist is mcr.microsoft.com, got stdout: '%ls' stderr: '%ls'",
476 + stdoutText.c_str(),
477 + stderrText.c_str());
478 + VERIFY_FAIL();
479 + }
480 + }
481 +
482 + // Verifies that `wslc image build` is rejected outright when an allowlist is configured,
483 + // since the in-VM docker daemon would fetch FROM base images directly and bypass the
484 + // per-pull registry gate.
485 + WSLC_TEST_METHOD(RegistryAllowlistRejectsImageBuild)
486 + {
487 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
488 +
489 + // Set up a minimal build context with a one-line Dockerfile in TEMP.
490 + const auto contextDir = std::filesystem::temp_directory_path() / L"wsl-policy-build-test";
491 + std::error_code ec;
492 + std::filesystem::remove_all(contextDir, ec);
493 + std::filesystem::create_directories(contextDir);
494 + auto cleanup = wil::scope_exit([&] { std::filesystem::remove_all(contextDir, ec); });
495 +
496 + {
497 + std::ofstream df(contextDir / L"Dockerfile");
498 + VERIFY_IS_TRUE(df.is_open());
499 + df << "FROM scratch\n";
500 + }
501 +
502 + std::wstring cmd = L"\"" + GetWslcExePath() + L"\" image build \"" + contextDir.wstring() + L"\"";
503 + auto [stdoutText, stderrText, exitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(cmd.data(), nullptr, nullptr);
504 +
505 + VERIFY_ARE_NOT_EQUAL(0, exitCode);
506 + const std::wstring combined = stdoutText + stderrText;
507 + if (combined.find(L"Building container images is blocked") == std::wstring::npos ||
508 + combined.find(L"computer policy") == std::wstring::npos)
509 + {
510 + LogError(
511 + "Expected image-build to be blocked by policy, got stdout: '%ls' stderr: '%ls'", stdoutText.c_str(), stderrText.c_str());
512 + VERIFY_FAIL();
513 + }
514 + }
515 +
516 + // Pure-function tests for the registry-allowlist policy evaluator. These don't talk to the
517 + // service, but do read/write the WSL policies registry key (created by TestClassSetup).
518 + TEST_METHOD(IsRegistryAllowed_Logic)
519 + {
520 + // No policy key configured -> always allowed.
521 + VERIFY_IS_TRUE(IsRegistryAllowed(nullptr, L"docker.io"));
522 +
523 + const auto policiesKey = OpenPoliciesKey();
524 + VERIFY_IS_TRUE(!!policiesKey);
525 +
526 + // No allowlist sub-key configured -> allowed.
527 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"docker.io"));
528 +
529 + // Allowlist with multiple entries; matching is case-insensitive.
530 + {
531 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com", L"Docker.IO"});
532 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"mcr.microsoft.com"));
533 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"docker.io"));
534 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"DOCKER.IO"));
535 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"MCR.Microsoft.COM"));
536 + VERIFY_IS_FALSE(IsRegistryAllowed(policiesKey.get(), L"ghcr.io"));
537 + }
538 +
539 + // Sub-key present with no entries -> no effective restriction, every server allowed.
540 + {
541 + auto revert = SetRegistryAllowlist({});
542 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"docker.io"));
543 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"mcr.microsoft.com"));
544 + }
545 +
546 + // Sub-key present but only contains empty entries -> treated as no restriction, not
547 + // as a deny-all (defensive against stray GP editor list items).
548 + {
549 + auto revert = SetRegistryAllowlist({L"", L""});
550 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"docker.io"));
551 + VERIFY_IS_TRUE(IsRegistryAllowed(policiesKey.get(), L"mcr.microsoft.com"));
552 + }
553 + }
554 +
555 + // Pure-function tests for HasRegistryAllowlist (used by `wslc image build` to decide whether
556 + // to refuse outright when the operation cannot be attributed to a single registry).
557 + TEST_METHOD(HasRegistryAllowlist_Logic)
558 + {
559 + VERIFY_IS_FALSE(HasRegistryAllowlist(nullptr));
560 +
561 + const auto policiesKey = OpenPoliciesKey();
562 + VERIFY_IS_TRUE(!!policiesKey);
563 +
564 + // No sub-key -> not configured.
565 + VERIFY_IS_FALSE(HasRegistryAllowlist(policiesKey.get()));
566 +
567 + // Sub-key present with no entries -> not effectively configured.
568 + {
569 + auto revert = SetRegistryAllowlist({});
570 + VERIFY_IS_FALSE(HasRegistryAllowlist(policiesKey.get()));
571 + }
572 +
573 + // Sub-key present with entries -> configured.
574 + {
575 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
576 + VERIFY_IS_TRUE(HasRegistryAllowlist(policiesKey.get()));
577 + }
578 + }
579 };
\ No newline at end of file