diff --git a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc index 915c11e31..29f34086b 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -14,6 +14,7 @@ #include "computer_use_native_interlock.h" #include "computer_use_native_interlock_capture.h" #include "computer_use_windows_interlock_capture.h" +#include "computer_use_windows_shell_name_match.h" #include "computer_use_native_approved_risk.h" #include "computer_use_native_approval_offer.h" #include "computer_use_native_refusal_table.h" @@ -3090,7 +3091,8 @@ struct FakeExplorer { FakeForm form; int item = 0; int tab = -1; - explicit FakeExplorer(const std::string &name, bool with_tab = false) { + explicit FakeExplorer(const std::string &name, bool with_tab = false, + const std::string &list_class = "UIItemsView") { form.Node(form.window).class_name = "CabinetWClass"; form.source.process_path = "C:\\Windows\\explorer.exe"; int parent = form.window; @@ -3098,7 +3100,7 @@ struct FakeExplorer { tab = form.source.Add(form.window, "Pane", "Tab", "ShellTabWindowClass"); parent = tab; } - const int list = form.source.Add(parent, "List", "Items", "UIItemsView"); + const int list = form.source.Add(parent, "List", "Items", list_class); item = form.source.Add(list, "ListItem", name, "UIItem"); form.source.point = item; } @@ -3453,6 +3455,27 @@ bool CheckWindowsShadowReader() { if (!Check(plain.item_kind == ShadowItemKind::kNone && ordinary.source.item_calls == 0, "winreader-no-item-outside-a-file-browser")) return false; + // A ListItem outside the shell's item view (a preview pane list, a search suggestion) is not an + // item of the folder: the shell is not asked about it, and its name stays a display name. + FakeExplorer preview("invoice.pdf", true, "PreviewPaneList"); + preview.GiveRealPath("C:\\Users\\a\\invoice.pdf.exe"); + WindowsShadowReader preview_reader(preview.form.source); + const ShadowUiaCapture in_preview = preview_reader.Capture("click", ""); + if (!Check(in_preview.item_kind == ShadowItemKind::kFile && !in_preview.item_name_is_real_path && + preview.form.source.item_calls == 0, + "winreader-list-item-outside-the-item-view-is-not-asked")) return false; + // The item view is a SHELLDLL_DefView too, with or without the UIItemsView below it. + FakeExplorer defview("invoice.pdf", true, "SHELLDLL_DefView"); + defview.GiveRealPath("C:\\Users\\a\\invoice.pdf.exe"); + WindowsShadowReader defview_reader(defview.form.source); + if (!Check(defview_reader.Capture("click", "").item_name_is_real_path && defview.form.source.item_calls == 1, + "winreader-list-item-in-a-defview-is-asked")) return false; + // With a tab and the ordinary item view the path is read (the case used throughout). + FakeExplorer tabbed("invoice.pdf", true); + tabbed.GiveRealPath("C:\\Users\\a\\invoice.pdf.exe"); + WindowsShadowReader tabbed_reader(tabbed.form.source); + if (!Check(tabbed_reader.Capture("click", "").item_name_is_real_path, "winreader-list-item-in-the-item-view-is-asked")) return false; + // A path longer than the core accepts is a gap, and still only a display name. FakeExplorer lost("a.txt"); lost.GiveRealPath(std::string(2048, 'p')); @@ -6763,6 +6786,384 @@ bool CheckWindowsInterlockCapture() { return true; } +// ---- Classifier v2, S5-c-2 PR-3: the display name of a file browser item -> the real entry --------- + +bool CheckWindowsShellNameMatch() { + using namespace sprint_coder::computer_use; + // A candidate: the file system's long name and the name the SHELL shows for it. + const auto shown = [](const wchar_t *name, const wchar_t *shell, bool directory = false) { + ShellNameCandidate candidate; + candidate.name = name; + candidate.is_directory = directory; + candidate.shell_name = shell; + candidate.shell_name_known = true; + return candidate; + }; + const auto unreadable = [](const wchar_t *name) { + ShellNameCandidate candidate; + candidate.name = name; + return candidate; + }; + using List = std::vector; + const auto match = [](std::wstring_view display, const List &list) { + return MatchShellDisplayName(display, list); + }; + // The one candidate whose shell name is the display name is the entry. + if (!Check(match(L"report", List{shown(L"report.pdf", L"report")}) == 0, "shellname-one-shell-name-matches")) return false; + if (!Check(match(L"run.bat", List{shown(L"run.bat", L"run.bat"), shown(L"run.bat.old", L"run.bat.old")}) == 0, + "shellname-extension-shown-exact")) return false; + // Which part of the name the shell hides is the shell's business, not a rule here: an extension + // that is never shown, a shortcut, a hidden extension of any kind. + if (!Check(match(L"x", List{shown(L"x.website", L"x")}) == 0, "shellname-never-shown-extension-is-the-shells-answer")) return false; + if (!Check(match(L"x", List{shown(L"x", L"x"), shown(L"x.website", L"x")}) == kNoShellMatch, + "shellname-x-and-x-website-collide")) return false; + if (!Check(match(L"x", List{shown(L"x", L"x"), shown(L"x.lnk", L"x")}) == kNoShellMatch, + "shellname-lnk-collides-with-same-name")) return false; + // x.pdf and x.pdf.exe, extensions hidden: the shell shows "x" and "x.pdf"; shown: "x.pdf", "x.pdf.exe". + if (!Check(match(L"x.pdf", List{shown(L"x.pdf", L"x"), shown(L"x.pdf.exe", L"x.pdf")}) == 1, + "shellname-pdf-exe-hidden-is-the-second")) return false; + if (!Check(match(L"x.pdf", List{shown(L"x.pdf", L"x.pdf"), shown(L"x.pdf.exe", L"x.pdf.exe")}) == 0, + "shellname-pdf-exe-shown-is-the-first")) return false; + // A folder whose name has a dot keeps it: the shell shows "data.xxx", and "data" is not that folder. + if (!Check(match(L"data", List{shown(L"data.xxx", L"data.xxx", true)}) == kNoShellMatch, + "shellname-folder-with-a-dot-is-not-a-stem")) return false; + if (!Check(match(L"data.xxx", List{shown(L"data.xxx", L"data.xxx", true)}) == 0, "shellname-folder-with-a-dot-is-itself")) return false; + { + const List list{shown(L"data", L"data", true), shown(L"data.txt", L"data")}; + if (!Check(match(L"data", list) == kNoShellMatch, "shellname-folder-and-file-stem-collide")) return false; + } + // A localized folder: "Documents" is shown as the Japanese word. It is found by its SHELL name + // (no file name starts like it), and a file whose name starts the same is a second entry. + { + const wchar_t *word = L"\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8"; + const std::wstring file = std::wstring(word) + L".txt"; + if (!Check(match(word, List{shown(L"Documents", word, true)}) == 0, "shellname-localized-folder-is-found-by-its-shell-name")) return false; + if (!Check(match(word, List{shown(L"Documents", word, true), shown(file.c_str(), word)}) == kNoShellMatch, + "shellname-localized-folder-and-same-stem-file-collide")) return false; + } + // Two folders data and data.xxx: both keep their names, "data" is the first. + if (!Check(match(L"data", List{shown(L"data", L"data", true), shown(L"data.xxx", L"data.xxx", true)}) == 0, + "shellname-two-folders-data-and-data-xxx")) return false; + // LICENSE and LICENSE.md with .md not a registered extension: the shell shows both extensions. + if (!Check(match(L"LICENSE", List{shown(L"LICENSE", L"LICENSE"), shown(L"LICENSE.md", L"LICENSE.md")}) == 0, + "shellname-unregistered-extension-is-not-hidden")) return false; + if (!Check(match(L"LICENSE.md", List{shown(L"LICENSE", L"LICENSE"), shown(L"LICENSE.md", L"LICENSE.md")}) == 1, + "shellname-unregistered-extension-is-the-second")) return false; + // The names have nothing to do with each other (a localized name): only the shell name counts. + if (!Check(match(L"Documents", List{shown(L"Dokumente", L"Documents")}) == 0, "shellname-localized-name-is-the-shell-name")) return false; + if (!Check(match(L"Dokumente", List{shown(L"Dokumente", L"Documents")}) == kNoShellMatch, + "shellname-file-name-is-not-the-display-name")) return false; + // No candidate fits, and none at all. + if (!Check(match(L"a", List{shown(L"ab.txt", L"ab")}) == kNoShellMatch, "shellname-prefix-is-not-a-match")) return false; + if (!Check(match(L"a", List{}) == kNoShellMatch, "shellname-no-candidate")) return false; + // One candidate whose shell name cannot be read makes the whole answer unknown, wherever it is. + if (!Check(match(L"a", List{shown(L"a", L"a"), unreadable(L"a2")}) == kNoShellMatch, "shellname-unreadable-after-the-match")) return false; + if (!Check(match(L"a", List{unreadable(L"a2"), shown(L"a", L"a")}) == kNoShellMatch, "shellname-unreadable-before-the-match")) return false; + if (!Check(match(L"a", List{unreadable(L"a")}) == kNoShellMatch, "shellname-only-candidate-unreadable")) return false; + // Case is not significant. + if (!Check(match(L"README", List{shown(L"readme.MD", L"readme")}) == 0, "shellname-case-insensitive")) return false; + // A display name that cannot be a file name matches nothing. + for (const wchar_t *bad : {L"", L"a*", L"a?b", L"a\\b", L"a/b", L"a:b", L"a|b"}) + if (!Check(match(bad, List{shown(L"a", bad), shown(L"b", L"b")}) == kNoShellMatch, "shellname-unusable-display-name")) return false; + // More candidates than the cap are not known; exactly the cap is fine. + { + List list; + for (std::size_t i = 0; i < kShellMaxCandidates; ++i) { + const std::wstring name = L"n" + std::to_wstring(i); + list.push_back(shown(name.c_str(), name.c_str())); + } + list.push_back(shown(L"target", L"target")); + if (!Check(list.size() == kShellMaxCandidates + 1 && match(L"target", list) == kNoShellMatch, "shellname-over-the-cap-is-unknown")) return false; + list.erase(list.begin()); + if (!Check(list.size() == kShellMaxCandidates && match(L"target", list) == kShellMaxCandidates - 1, "shellname-exactly-the-cap-is-fine")) return false; + } + // The comparison is the caller's. + { + const auto exact_case = [](std::wstring_view left, std::wstring_view right) { return left == right; }; + if (!Check(MatchShellDisplayName(L"Run", List{shown(L"run", L"run")}, exact_case) == kNoShellMatch, "shellname-uses-the-given-comparison")) return false; + if (!Check(MatchShellDisplayName(L"run", List{shown(L"run", L"run")}, exact_case) == 0, "shellname-given-comparison-matches")) return false; + if (!Check(MatchShellDisplayName(L"run", List{shown(L"run", L"run")}, nullptr) == kNoShellMatch, "shellname-null-comparison-is-unknown")) return false; + } + // Which entries need the shell's name (the pure rule): a file name that starts like the display + // name (case-blind), and any read-only / system DIRECTORY; nothing else, never "." or "..". + { + const auto needs = [](const wchar_t *name, bool directory, bool read_only_or_system, const wchar_t *display) { + return ShellEntryNeedsShellName(name, directory, read_only_or_system, display); + }; + if (!Check(needs(L"report", false, false, L"report"), "shellneeds-exact")) return false; + if (!Check(needs(L"report.pdf", false, false, L"report"), "shellneeds-prefix")) return false; + if (!Check(needs(L"REPORT.pdf", false, false, L"report"), "shellneeds-case-differs")) return false; + if (!Check(!needs(L"other.txt", false, false, L"report"), "shellneeds-unrelated-file-is-not-a-candidate")) return false; + if (!Check(!needs(L"other", true, false, L"report"), "shellneeds-ordinary-directory-is-not-a-candidate")) return false; + if (!Check(needs(L"other", true, true, L"report"), "shellneeds-read-only-or-system-directory-is-a-candidate")) return false; + if (!Check(!needs(L"other.txt", false, true, L"report"), "shellneeds-read-only-file-is-not-a-candidate")) return false; + if (!Check(!needs(L"re", false, false, L"report"), "shellneeds-shorter-name-is-not-a-prefix")) return false; + if (!Check(!needs(L".", true, true, L"."), "shellneeds-dot-is-never-a-candidate")) return false; + if (!Check(!needs(L"..", true, true, L"report"), "shellneeds-dotdot-is-never-a-candidate")) return false; + if (!Check(!ShellEntryNeedsShellName(L"report", false, false, L"report", nullptr), "shellneeds-null-comparison-is-no")) return false; + } + // A desktop.ini that localizes file names. The shell honours it only in a read-only / system folder, so + // other folders are not read; a desktop.ini that is a link (of any kind) is a yes without being read; + // what cannot be known is a yes. + { + constexpr unsigned long kAccessDenied = 5; + constexpr unsigned long kDirectory = 0x10; + const auto ini = [](bool folder_known, unsigned long folder_attributes, bool opened, unsigned long open_error, + bool reparse, bool content_says_localized) { + return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, opened, open_error, reparse, + content_says_localized); + }; + const unsigned long read_only = kDirectory | kShellAttributeReadOnly; + const unsigned long system_dir = kDirectory | kShellAttributeSystem; + if (!Check(!ini(true, kDirectory, true, 0, false, true), "desktopini-plain-folder-is-not-read")) return false; + if (!Check(!ini(true, kDirectory, true, 0, true, true), "desktopini-plain-folder-link-is-not-read-either")) return false; + if (!Check(ini(false, 0, true, 0, false, false), "desktopini-unknown-folder-attributes-is-a-yes")) return false; + if (!Check(!ini(true, read_only, false, kShellErrorFileNotFound, false, true), "desktopini-none-file-not-found")) return false; + if (!Check(!ini(true, system_dir, false, kShellErrorPathNotFound, false, true), "desktopini-none-path-not-found")) return false; + if (!Check(ini(true, read_only, false, kAccessDenied, false, false), "desktopini-unknown-open-error-is-a-yes")) return false; + if (!Check(ini(true, read_only, true, 0, true, false), "desktopini-link-is-a-yes-without-reading")) return false; + if (!Check(ini(true, system_dir, true, 0, true, false), "desktopini-link-in-a-system-folder-is-a-yes")) return false; + if (!Check(!ini(true, read_only, true, 0, false, false), "desktopini-without-the-section-is-normal")) return false; + if (!Check(ini(true, read_only, true, 0, false, true), "desktopini-with-the-section-is-a-yes")) return false; + if (!Check(ini(true, system_dir, true, 0, false, true), "desktopini-with-the-section-in-a-system-folder-is-a-yes")) return false; + } + // The text of a desktop.ini: a non-empty [LocalizedFileNames] section is a yes; anything not known is too. + { + const auto ansi = [](const char *text) { + std::vector bytes; + for (const char *c = text; *c != 0; ++c) bytes.push_back(static_cast(*c)); + return bytes; + }; + const auto utf16 = [](const wchar_t *text, bool big_endian = false) { + std::vector bytes; + if (big_endian) { bytes.push_back(0xFE); bytes.push_back(0xFF); } else { bytes.push_back(0xFF); bytes.push_back(0xFE); } + for (const wchar_t *c = text; *c != 0; ++c) { + const unsigned char lo = static_cast(*c & 0xFF); + const unsigned char hi = static_cast((*c >> 8) & 0xFF); + if (big_endian) { bytes.push_back(hi); bytes.push_back(lo); } else { bytes.push_back(lo); bytes.push_back(hi); } + } + return bytes; + }; + const auto says = [](const std::vector &bytes) { + return ShellDesktopIniTextHasLocalizedFileNames(bytes.data(), bytes.size()); + }; + if (!Check(says(utf16(L"[LocalizedFileNames]\r\nalpha.txt=Beta\r\n")), "initext-utf16le-with-a-entry")) return false; + if (!Check(says(utf16(L"[LocalizedFileNames]\r\nalpha.txt=Beta\r\n", true)), "initext-utf16be-with-an-entry")) return false; + if (!Check(says(ansi("[LocalizedFileNames]\r\nalpha.txt=Beta\r\n")), "initext-ansi-with-an-entry")) return false; + if (!Check(says(ansi("\xEF\xBB\xBF[LocalizedFileNames]\nalpha.txt=Beta")), "initext-utf8-with-its-mark-and-no-final-newline")) return false; + if (!Check(says(ansi("[localizedfilenames]\nalpha.txt=Beta\n")), "initext-section-name-is-case-blind")) return false; + if (!Check(says(ansi("[ LOCALIZEDFILENAMES ]\nalpha.txt=Beta\n")), "initext-spaces-around-the-name-still-count")) return false; + if (!Check(says(ansi("[.ShellClassInfo]\nInfoTip=x\n[LocalizedFileNames]\n\n; note\n \nalpha.txt=Beta\n")), "initext-entry-after-blank-and-comment-lines")) return false; + if (!Check(says(ansi("[LocalizedFileNames]\nalpha.txt=Beta\n[Other]\nx=y\n")), "initext-entry-before-another-section")) return false; + if (!Check(says(ansi("[Other]\n[LocalizedFileNames]\nx=1\n[LocalizedFileNames]\n")), "initext-the-same-section-twice-one-with-an-entry")) return false; + if (!Check(!says(ansi("")), "initext-empty-file-is-no")) return false; + if (!Check(!says(ansi("[LocalizedFileNames]\n")), "initext-empty-section")) return false; + if (!Check(!says(ansi("[LocalizedFileNames]\r\n\r\n \r\n")), "initext-blank-lines-only-section")) return false; + if (!Check(!says(ansi("[LocalizedFileNames]\n; just a comment\n;another\n[Other]\nx=y\n")), "initext-comments-only-section")) return false; + if (!Check(!says(utf16(L"[LocalizedFileNames]\r\n; c\r\n[.ShellClassInfo]\r\nInfoTip=x\r\n")), "initext-utf16-empty-section-then-another")) return false; + if (!Check(!says(ansi("[.ShellClassInfo]\nLocalizedResourceName=@x,-1\n")), "initext-another-section-only")) return false; + if (!Check(!says(ansi("alpha.txt=Beta\n")), "initext-lines-before-any-section-do-not-count")) return false; + if (!Check(!says(ansi("[Other]\nLocalizedFileNames=x\n")), "initext-the-name-as-a-key-is-not-the-section")) return false; + // Not known is a yes. + if (!Check(says(ansi("[LocalizedFileNames\nalpha.txt=Beta\n")), "initext-header-that-never-closes")) return false; + if (!Check(says(std::vector{0xFF, 0xFE, 0x5B}), "initext-utf16-with-an-odd-number-of-bytes")) return false; + if (!Check(says(std::vector{'[', 0, 'L', 0, ']', 0}), "initext-nul-in-single-byte-text-is-a-utf16-file-without-its-mark")) return false; + { + const std::vector large(kShellDesktopIniMaxBytes + 1, static_cast(';')); + if (!Check(says(large), "initext-larger-than-the-cap-is-a-yes")) return false; + const std::vector at_cap(kShellDesktopIniMaxBytes, static_cast(';')); + if (!Check(!says(at_cap), "initext-exactly-the-cap-is-judged")) return false; + } + if (!Check(ShellDesktopIniTextHasLocalizedFileNames(nullptr, 3), "initext-null-bytes-with-a-size-is-a-yes")) return false; + if (!Check(!ShellDesktopIniTextHasLocalizedFileNames(nullptr, 0), "initext-null-and-empty-is-no")) return false; + } + // A link is a reparse point that NAMES another object (bit 29 of the tag): a symbolic link, a mount + // point, any other such tag. A cloud file's tag does not have the bit and is not a link. + { + if (!Check(ShellReparseIsLink(true, kShellReparseTagSymlink), "link-symlink")) return false; + if (!Check(ShellReparseIsLink(true, kShellReparseTagMountPoint), "link-mount-point-or-junction")) return false; + if (!Check(ShellReparseIsLink(true, 0xA000001D), "link-another-name-surrogate-tag")) return false; + if (!Check(!ShellReparseIsLink(true, 0x9000001A), "link-cloud-tag-is-not-a-link")) return false; + if (!Check(!ShellReparseIsLink(true, 0x80000015), "link-a-tag-without-the-surrogate-bit-is-not-a-link")) return false; + if (!Check(!ShellReparseIsLink(true, 0), "link-other-reparse-point-is-not-a-link")) return false; + if (!Check(!ShellReparseIsLink(false, kShellReparseTagSymlink), "link-tag-of-a-non-reparse-entry-is-garbage")) return false; + if (!Check(ShellReparseIsMountPoint(true, kShellReparseTagMountPoint) && !ShellReparseIsMountPoint(true, kShellReparseTagSymlink) && + !ShellReparseIsMountPoint(false, kShellReparseTagMountPoint), + "link-only-a-mount-point-is-a-mount-point")) return false; + } + // A candidate that is a link is never matched, even with a shell name that fits. + { + ShellNameCandidate linked; + linked.name = L"jn"; + linked.shell_name = L"jn"; + linked.shell_name_known = true; + linked.is_link = true; + if (!Check(MatchShellDisplayName(L"jn", std::vector{linked}) == kNoShellMatch, "link-candidate-is-never-matched")) return false; + ShellNameCandidate plain; + plain.name = L"a.txt"; + plain.shell_name = L"a.txt"; + plain.shell_name_known = true; + if (!Check(MatchShellDisplayName(L"a.txt", std::vector{plain, linked}) == kNoShellMatch, + "link-candidate-among-the-others-makes-the-answer-unknown")) return false; + linked.is_link = false; + if (!Check(MatchShellDisplayName(L"jn", std::vector{linked}) == 0, "link-candidate-let-through-is-an-ordinary-one")) return false; + } + // The target of a mount point, from the bytes FSCTL_GET_REPARSE_POINT returns. + { + const auto buffer = [](const std::wstring &substitute, unsigned long tag = kShellReparseTagMountPoint) { + std::vector bytes(16, 0); + for (int i = 0; i < 4; ++i) bytes[static_cast(i)] = static_cast((tag >> (8 * i)) & 0xFF); + const std::size_t length = substitute.size() * 2; + bytes[8] = 0; bytes[9] = 0; // substitute offset + bytes[10] = static_cast(length & 0xFF); bytes[11] = static_cast(length >> 8); + for (const wchar_t c : substitute) { bytes.push_back(static_cast(c & 0xFF)); bytes.push_back(static_cast((c >> 8) & 0xFF)); } + return bytes; + }; + std::wstring name; + const auto made = buffer(L"\\??\\C:\\Users\\a"); + if (!Check(ShellMountPointSubstituteName(made.data(), made.size(), &name) && name == L"\\??\\C:\\Users\\a", "mount-substitute-name-is-read")) return false; + if (!Check(ShellLocalDriveLetterOfSubstituteName(name) == L'C', "mount-drive-letter-path")) return false; + if (!Check(ShellLocalDriveLetterOfSubstituteName(L"\\??\\d:") == L'd', "mount-bare-drive")) return false; + if (!Check(ShellLocalDriveLetterOfSubstituteName(L"\\??\\Volume{12345678-1234-1234-1234-123456789abc}\\") == 0, "mount-volume-guid-is-not-a-drive-letter")) return false; + if (!Check(ShellLocalDriveLetterOfSubstituteName(L"\\??\\UNC\\server\\share") == 0, "mount-unc-is-not-a-drive-letter")) return false; + if (!Check(ShellLocalDriveLetterOfSubstituteName(L"\\??\\C:x") == 0, "mount-a-colon-without-a-separator")) return false; + if (!Check(ShellLocalDriveLetterOfSubstituteName(L"C:\\x") == 0, "mount-without-the-nt-prefix")) return false; + const auto symlink_tag = buffer(L"\\??\\C:\\x", kShellReparseTagSymlink); + if (!Check(!ShellMountPointSubstituteName(symlink_tag.data(), symlink_tag.size(), &name), "mount-a-symlink-buffer-is-not-read")) return false; + auto cut = made; + cut.resize(cut.size() - 4); + if (!Check(!ShellMountPointSubstituteName(cut.data(), cut.size(), &name), "mount-a-cut-buffer-is-not-read")) return false; + if (!Check(!ShellMountPointSubstituteName(made.data(), 8, &name), "mount-a-short-buffer-is-not-read")) return false; + auto odd = made; + odd[10] = 5; + if (!Check(!ShellMountPointSubstituteName(odd.data(), odd.size(), &name), "mount-an-odd-length-is-not-read")) return false; + } + // The target of a mount point as an ordinary path, whose parts are then looked at one by one. + { + if (!Check(ShellMountTargetPath(L"\\??\\C:\\redirect\\subdir") == L"C:\\redirect\\subdir", "mounttarget-path-of-a-drive-letter-target")) return false; + if (!Check(ShellMountTargetPath(L"\\??\\D:") == L"D:\\", "mounttarget-bare-drive-gets-its-root")) return false; + if (!Check(ShellMountTargetPath(L"\\??\\Volume{12345678-1234-1234-1234-123456789abc}\\").empty(), "mounttarget-volume-guid-has-no-path")) return false; + if (!Check(ShellMountTargetPath(L"\\??\\UNC\\server\\share").empty(), "mounttarget-unc-has-no-path")) return false; + if (!Check(ShellMountTargetPath(L"C:\\x").empty(), "mounttarget-without-the-nt-prefix-has-no-path")) return false; + } + // A directory ready to have a name appended: a drive root must stay "C:\\" (not "C:\\\\"). + { + if (!Check(ShellDirectoryWithSeparator(L"C:\\") == L"C:\\", "dirsep-drive-root-stays-a-root")) return false; + if (!Check(ShellDirectoryWithSeparator(L"e:\\") == L"e:\\", "dirsep-another-drive-root")) return false; + if (!Check(ShellDirectoryWithSeparator(L"C:\\Windows") == L"C:\\Windows\\", "dirsep-a-separator-is-added")) return false; + if (!Check(ShellDirectoryWithSeparator(L"C:\\Windows\\") == L"C:\\Windows\\", "dirsep-one-is-kept")) return false; + if (!Check(ShellDirectoryWithSeparator(L"C:\\Windows\\\\\\") == L"C:\\Windows\\", "dirsep-several-become-one")) return false; + if (!Check(ShellDirectoryWithSeparator(L"C:\\\\") == L"C:\\", "dirsep-doubled-root-becomes-one")) return false; + if (!Check((ShellDirectoryWithSeparator(L"C:\\") + L"Windows") == L"C:\\Windows", "dirsep-appending-to-a-root-makes-a-valid-path")) return false; + } + // The allow-list for a value in a candidate folder's desktop.ini. Only plain text, and an absolute path + // under a known system variable (with an optional '@' and ",-N" / ",N"), are safe; a drive-letter path + // is not. + { + const auto safe = [&](const wchar_t *value) { return ShellIniValueIsSafe(value); }; + // Safe. + if (!Check(safe(L"Documents"), "inival-plain-text")) return false; + if (!Check(safe(L""), "inival-empty")) return false; + if (!Check(safe(L"A tip for this folder"), "inival-plain-text-with-spaces")) return false; + if (!Check(safe(L"'abc"), "inival-a-lone-quote-in-plain-text")) return false; + if (!Check(safe(L"@%SystemRoot%\\system32\\shell32.dll,-21770"), "inival-system-root-resource")) return false; + if (!Check(safe(L"%SystemRoot%\\system32\\imageres.dll,-112"), "inival-system-root-icon-without-the-at")) return false; + if (!Check(safe(L"%windir%\\system32\\x.dll,3"), "inival-windir-with-a-positive-index")) return false; + if (!Check(safe(L"@%ProgramFiles%\\App\\a.dll,-1"), "inival-program-files")) return false; + if (!Check(safe(L"@%ProgramFiles(x86)%\\App\\a.dll,-1"), "inival-program-files-x86")) return false; + if (!Check(safe(L"%CommonProgramFiles%\\a.dll"), "inival-common-program-files")) return false; + if (!Check(safe(L"%SYSTEMROOT%\\system32\\x.dll"), "inival-variable-name-is-case-blind")) return false; + if (!Check(!safe(L"@C:\\Windows\\system32\\shell32.dll,-1"), "inival-local-drive-path-is-not-safe-a-part-can-be-a-link")) return false; + if (!Check(!safe(L"D:\\res\\x.dll"), "inival-another-local-drive-is-not-safe-either")) return false; + if (!Check(safe(L"'@%SystemRoot%\\system32\\shell32.dll,-21770'"), "inival-single-quotes-are-removed")) return false; + if (!Check(safe(L"\"@%SystemRoot%\\system32\\shell32.dll,-21770\""), "inival-double-quotes-are-removed")) return false; + if (!Check(safe(L" %windir%\\x.dll "), "inival-blanks-are-removed")) return false; + // Not safe. + if (!Check(!safe(L"'@\\\\server\\share\\x.dll,-1'"), "inival-single-quoted-unc")) return false; + if (!Check(!safe(L"\"@\\\\server\\share\\x.dll,-1\""), "inival-double-quoted-unc")) return false; + if (!Check(!safe(L"\"'@\\\\server\\share\\x.dll,-1'\""), "inival-one-level-of-quotes-only")) return false; + if (!Check(!safe(L"//server/share/icon.ico"), "inival-slash-unc")) return false; + if (!Check(!safe(L"@%SystemRoot%\\system32/x.dll"), "inival-slash-later-in-the-path")) return false; + if (!Check(!safe(L"@C:\\a/b.dll"), "inival-slash-in-a-drive-path")) return false; + if (!Check(!safe(L"/x"), "inival-leading-slash")) return false; + if (!Check(!safe(L"@/x.dll"), "inival-at-and-slash")) return false; + if (!Check(!safe(L"%SystemRoot%/system32/x.dll"), "inival-forward-slashes-after-a-variable")) return false; + if (!Check(!safe(L"\\\\server\\share\\icon.ico"), "inival-backslash-unc")) return false; + if (!Check(!safe(L"@\\\\server\\share\\x.dll,-1"), "inival-resource-unc")) return false; + if (!Check(!safe(L"\\\\?\\C:\\x.dll"), "inival-extended-length-prefix")) return false; + if (!Check(!safe(L"\\\\.\\pipe\\x"), "inival-device-prefix")) return false; + if (!Check(!safe(L"@C:\\Windows\\..\\x.dll"), "inival-dot-dot")) return false; + if (!Check(!safe(L"@%SystemRoot%\\..\\x.dll"), "inival-dot-dot-after-a-variable")) return false; + if (!Check(!safe(L"@%Unknown%\\x.dll,-1"), "inival-unknown-variable")) return false; + if (!Check(!safe(L"@%USERPROFILE%\\x.dll,-1"), "inival-a-variable-that-is-not-on-the-list")) return false; + if (!Check(!safe(L"@%SystemRoot%x.dll"), "inival-variable-without-a-separator")) return false; + if (!Check(!safe(L"@%SystemRoot%\\a%b.dll"), "inival-another-percent")) return false; + if (!Check(!safe(L"@shell32.dll,-21770"), "inival-relative-resource")) return false; + if (!Check(!safe(L"sub\\path.dll"), "inival-relative-path")) return false; + if (!Check(!safe(L"http://example.invalid/x"), "inival-url")) return false; + if (!Check(!safe(L"@Z:\\x.dll,-1"), "inival-non-local-drive")) return false; + if (!Check(!safe(L"@C:\\\\x.dll"), "inival-doubled-backslash")) return false; + if (!Check(!safe(L"@C:x.dll"), "inival-drive-without-a-separator")) return false; + if (!Check(!safe(L"@%SystemRoot%\\x.dll,abc"), "inival-bad-resource-suffix")) return false; + if (!Check(!safe(L"@%SystemRoot%\\x.dll,-"), "inival-empty-resource-index")) return false; + if (!Check(!safe(L"@%SystemRoot%\\x.dll,1,2"), "inival-two-commas")) return false; + if (!Check(!safe(L"Hello, world"), "inival-a-comma-in-plain-text")) return false; + if (!Check(!safe(L"a:b"), "inival-a-colon-in-plain-text")) return false; + if (!Check(!safe(L"user@host"), "inival-an-at-in-plain-text")) return false; + if (!Check(!safe(L"@"), "inival-a-bare-at")) return false; + const auto ansi = [](const char *text) { + std::vector bytes; + for (const char *c = text; *c != 0; ++c) bytes.push_back(static_cast(*c)); + return bytes; + }; + const auto text_risky = [&](const std::vector &bytes) { + return ShellDesktopIniTextIsRiskyForShellName(bytes.data(), bytes.size()); + }; + if (!Check(!text_risky(ansi("[.ShellClassInfo]\r\nLocalizedResourceName=@%SystemRoot%\\system32\\shell32.dll,-21770\r\nIconResource=%SystemRoot%\\system32\\imageres.dll,-112\r\n")), + "inirisk-a-typical-known-folder-ini-is-safe")) return false; + if (!Check(!text_risky(ansi("")), "inirisk-empty-is-safe")) return false; + if (!Check(!text_risky(ansi("[Other]\r\nIconFile=\\\\server\\share\\x.ico\r\n")), "inirisk-another-section-is-not-looked-at")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nLocalizedResourceName='@\\\\server\\share\\x.dll,-1'\r\n")), "inirisk-single-quoted-unc-resource-name")) return false; + if (!Check(text_risky(ansi("[.shellclassinfo]\r\nlocalizedresourcename=@\\\\server\\share\\x.dll,-1\r\n")), "inirisk-names-are-case-blind")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nIconResource=//server/share/x.dll,0\r\n")), "inirisk-slash-unc-icon-resource")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nIconFile=Z:\\x.ico\r\n")), "inirisk-network-icon-file")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nInfoTip=@\\\\server\\share\\x.dll,-3\r\n")), "inirisk-unc-info-tip")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nLogo=sub\\logo.png\r\n")), "inirisk-relative-logo")) return false; + if (!Check(!text_risky(ansi("[.ShellClassInfo]\r\nInfoTip=A tip with words\r\n")), "inirisk-a-plain-info-tip-is-safe")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nCLSID={11111111-2222-3333-4444-555555555555}\r\n")), "inirisk-clsid")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nCLSID2={11111111-2222-3333-4444-555555555555}\r\n")), "inirisk-clsid2")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo]\r\nUICLSID={11111111-2222-3333-4444-555555555555}\r\n")), "inirisk-uiclsid")) return false; + if (!Check(text_risky(ansi("[.ShellClassInfo\r\nx=y\r\n")), "inirisk-text-that-cannot-be-judged")) return false; + if (!Check(text_risky(std::vector(kShellDesktopIniMaxBytes + 1, ';')), "inirisk-larger-than-the-cap")) return false; + { + std::vector wide{0xFF, 0xFE}; + for (const wchar_t c : std::wstring(L"[.ShellClassInfo]\r\nCLSID=x\r\n")) { wide.push_back(static_cast(c)); wide.push_back(0); } + if (!Check(text_risky(wide), "inirisk-utf16-clsid")) return false; + } + } + // The leaf of a file system path identifies a resolved item (a known folder's parsing name is a GUID). + { + if (!Check(ShellLeafName(L"C:\\Users\\a\\Documents") == L"Documents", "leaf-of-a-path")) return false; + if (!Check(ShellLeafName(L"C:\\Users\\a\\Documents\\") == L"Documents", "leaf-ignores-a-trailing-separator")) return false; + if (!Check(ShellLeafName(L"name") == L"name", "leaf-of-a-bare-name")) return false; + if (!Check(ShellLeafName(L"C:\\") == L"C:", "leaf-of-a-root-is-the-drive-part")) return false; + } + // A path that resolved to a shell item is that candidate only if the name comes back as it went in; + // a name that ends in a dot or a space is normalized to ANOTHER entry and is never taken. + { + if (!Check(ShellResolvedNameIsTheCandidate(L"a.txt", L"a.txt"), "resolved-same-name")) return false; + if (!Check(ShellResolvedNameIsTheCandidate(L"a.txt", L"A.TXT"), "resolved-case-differs")) return false; + if (!Check(!ShellResolvedNameIsTheCandidate(L"a.", L"a"), "resolved-trailing-dot-resolves-to-another-entry")) return false; + if (!Check(!ShellResolvedNameIsTheCandidate(L"a.", L"a."), "resolved-trailing-dot-is-never-taken")) return false; + if (!Check(!ShellResolvedNameIsTheCandidate(L"a ", L"a"), "resolved-trailing-space-resolves-to-another-entry")) return false; + if (!Check(!ShellResolvedNameIsTheCandidate(L"a", L"b"), "resolved-another-name")) return false; + if (!Check(!ShellResolvedNameIsTheCandidate(L"", L""), "resolved-empty-is-never-taken")) return false; + if (!Check(!ShellResolvedNameIsTheCandidate(L"a", L"a", nullptr), "resolved-null-comparison-is-no")) return false; + } + std::cout << "Computer Use native Windows shell name match: PASS\n"; + return true; +} + // ---- Classifier v2, C4-2c-1: the split high-impact vocabulary and the approved-effect judgement ---- // The v1 vocabulary as it was before the split, frozen here byte for byte (the lists, and the two // matching rules). The table in computer_use_native_approved_risk.h must say exactly the same when @@ -7650,6 +8051,7 @@ int main() { if (!CheckArtifactReadJudgement()) return 1; if (!CheckNativeInterlockCapture()) return 1; if (!CheckWindowsInterlockCapture()) return 1; + if (!CheckWindowsShellNameMatch()) return 1; if (!CheckClickRule()) return 1; if (!CheckNativeApprovedRisk()) return 1; if (!CheckNativeApprovalOffer()) return 1; diff --git a/apps/desktop/computer-use-native/computer_use_windows_shadow_reader.h b/apps/desktop/computer-use-native/computer_use_windows_shadow_reader.h index 0c8088fba..d375eed26 100644 --- a/apps/desktop/computer-use-native/computer_use_windows_shadow_reader.h +++ b/apps/desktop/computer-use-native/computer_use_windows_shadow_reader.h @@ -396,17 +396,24 @@ class WindowsShadowReader { capture->item_kind = ShadowItemKind::kFile; // The shell tab that shows the item is its nearest ShellTabWindowClass ancestor in the walk. Element tab = ShadowUiaSource::kNoElement; - for (std::size_t at = index; at < capture->chain.size() && at < chain_elements_.size(); ++at) - if (windows_shadow_detail::Lowercase(shadow_detail::CompatFold(capture->chain[at].class_name)) == - "shelltabwindowclass") { + // Whether the ListItem sits in the shell's ITEM VIEW (a SHELLDLL_DefView or its UIItemsView) between + // itself and that tab: a list in the preview pane or a search suggestion is a ListItem too, but its + // name is not an item of the folder the view shows, so the shell is not asked about it. + bool in_item_view = false; + for (std::size_t at = index; at < capture->chain.size() && at < chain_elements_.size(); ++at) { + const std::string class_name = + windows_shadow_detail::Lowercase(shadow_detail::CompatFold(capture->chain[at].class_name)); + if (class_name == "shelltabwindowclass") { tab = chain_elements_[at]; break; } + if (class_name == "shelldll_defview" || class_name == "uiitemsview") in_item_view = true; + } std::string path; bool is_folder = false; // The shell is asked only about an item of the bound window: a target the walk could not trace // back to it (another window on top, the focus elsewhere) is not looked up. - if (capture->chain_reached_root && + if (capture->chain_reached_root && in_item_view && ShadowSettle(ItemPath(chain_elements_[index], tab, kPathBytes, &path, &is_folder), &capture->read_failed)) { capture->item_name_read = true; diff --git a/apps/desktop/computer-use-native/computer_use_windows_shell_name_match.h b/apps/desktop/computer-use-native/computer_use_windows_shell_name_match.h new file mode 100644 index 000000000..33c1d233b --- /dev/null +++ b/apps/desktop/computer-use-native/computer_use_windows_shell_name_match.h @@ -0,0 +1,408 @@ +#pragma once + +// The match of a file browser's DISPLAY name to the real directory entry (S5-c-2 PR-3). Portable and +// pure: no platform header, no clock, no I/O, no thread. +// +// UI Automation publishes only what Explorer SHOWS: "report" for "report.pdf" when known extensions +// are hidden. The real file is found in two steps: +// +// 1. the caller lists the folder ONCE and keeps, only to narrow the field, the entries that can carry +// the display name (ShellEntryNeedsShellName): those whose file name STARTS WITH it, and the +// directories that are read-only or system (a desktop.ini can give only those another name, as a +// localized "Documents"). Both kinds count together toward kShellMaxCandidates. The CALLER +// must not cut the list at the cap and match what is left: the moment it finds one more entry +// than the cap it gives up and the whole lookup is "not known" (ShadowReadResult::kAbsent in +// the caller's terms; this header defines no such result, and kNoShellMatch below is its +// counterpart for a match that is not made). MatchShellDisplayName +// repeats that rule for its own input: a list longer than the cap (the caller's one-past-the-cap +// case, were it ever passed in) is "not known" too, never matched; and +// 2. for EVERY candidate the caller asks the SHELL for the name it shows (the same name Explorer +// shows, whatever hides an extension: the user's setting, an extension that is never shown, +// a folder with a dot in its name), and this function decides which candidate the display name +// stands for. No rule about extensions lives here: the shell's own answer is compared. +// +// The answer is an entry only when it is the ONLY candidate whose shell name equals the display name. +// It is "not known" (kNoShellMatch), never a guess, when no candidate does, when two or more do, when +// there are more candidates than the cap, or when the shell name of ANY candidate could not be read +// (a candidate that could not be read might be the one, or a second one). +// +// Names are compared as the file system does, case-insensitively and ordinally, by the function the +// caller passes (the host passes one built on CompareStringOrdinal); the default folds ASCII only. + +#include +#include +#include +#include + +namespace sprint_coder::computer_use { + +struct ShellNameCandidate { + std::wstring name; // the entry's long name, as the file system has it + bool is_directory = false; + bool renamable_directory = false; // a read-only / system directory: its desktop.ini can name it + std::wstring shell_name; // the name the shell shows for it (valid when shell_name_known) + bool shell_name_known = false; + // A link (a name surrogate: symbolic link or mount point) whose target is not known to be a local disk: + // its shell name is never asked for. A match is never made while one is among the candidates. + bool is_link = false; +}; + +constexpr std::size_t kShellMaxCandidates = 64; +constexpr std::size_t kNoShellMatch = static_cast(-1); + +using ShellNameEquals = bool (*)(std::wstring_view, std::wstring_view); + +[[nodiscard]] inline bool ShellNameEqualsAsciiFold(std::wstring_view left, std::wstring_view right) { + if (left.size() != right.size()) return false; + for (std::size_t i = 0; i < left.size(); ++i) { + wchar_t a = left[i]; + wchar_t b = right[i]; + if (a >= L'A' && a <= L'Z') a = static_cast(a - L'A' + L'a'); + if (b >= L'A' && b <= L'Z') b = static_cast(b - L'A' + L'a'); + if (a != b) return false; + } + return true; +} + +// Whether a directory entry can carry the display name and so needs the name the shell shows for it +// (the pure rule of step 1). "." and ".." never do. An entry whose file name starts with the display +// name does (compared with equals: case-blind), and so does a directory that is read-only or system +// whatever its name, because a desktop.ini inside it can show it under another name. +[[nodiscard]] inline bool ShellEntryNeedsShellName(std::wstring_view name, bool is_directory, + bool readonly_or_system, + std::wstring_view display, + ShellNameEquals equals = ShellNameEqualsAsciiFold) { + if (name == L"." || name == L".." || equals == nullptr) return false; + if (name.size() >= display.size() && equals(name.substr(0, display.size()), display)) return true; + return is_directory && readonly_or_system; +} + +// The Win32 values the folder checks below read (kept here so the rules stay pure). +constexpr unsigned long kShellErrorFileNotFound = 2; +constexpr unsigned long kShellErrorPathNotFound = 3; +constexpr unsigned long kShellAttributeReadOnly = 0x1; +constexpr unsigned long kShellAttributeSystem = 0x4; +constexpr unsigned long kShellReparseTagMountPoint = 0xA0000003; +constexpr unsigned long kShellReparseTagSymlink = 0xA000000C; +// Bit 29 of a reparse tag says the entry NAMES ANOTHER FILE SYSTEM OBJECT (IsReparseTagNameSurrogate): +// a symbolic link, a mount point, and any other tag of that kind. A cloud file's tag does not have it. +constexpr unsigned long kShellReparseNameSurrogate = 0x20000000; + +// Whether a directory entry is a link: a reparse point that names another object. Reading the shell's +// name for it would touch what it points to, which can be a network or optical location. Any other +// reparse point (a cloud file's tag, for one) is not a link in this sense. The tag is only meaningful +// when the entry IS a reparse point (the listing leaves it undefined otherwise). +[[nodiscard]] inline bool ShellReparseIsLink(bool is_reparse_point, unsigned long tag) { + return is_reparse_point && (tag & kShellReparseNameSurrogate) != 0; +} + +[[nodiscard]] inline bool ShellReparseIsMountPoint(bool is_reparse_point, unsigned long tag) { + return is_reparse_point && tag == kShellReparseTagMountPoint; +} + +// The substitute name (the target) in the bytes FSCTL_GET_REPARSE_POINT returned for a mount point +// (a junction): tag, data length, reserved, then the offsets and lengths of the substitute and the print +// name, then the path buffer. False for anything that is not a well-formed mount point buffer. +[[nodiscard]] inline bool ShellMountPointSubstituteName(const unsigned char *bytes, std::size_t size, + std::wstring *name) { + if (bytes == nullptr || name == nullptr || size < 16) return false; + const auto word = [&](std::size_t at) { return static_cast(bytes[at]) | (static_cast(bytes[at + 1]) << 8); }; + const unsigned long tag = static_cast(word(0)) | (static_cast(word(2)) << 16); + if (tag != kShellReparseTagMountPoint) return false; + const std::size_t offset = word(8); + const std::size_t length = word(10); + const std::size_t begin = 16 + offset; + if ((length & 1u) != 0 || begin > size || length > size - begin) return false; + name->clear(); + for (std::size_t at = begin; at < begin + length; at += 2) + name->push_back(static_cast(word(at))); + return true; +} + +// The drive letter of a mount point target of the form "\??\X:" or "\??\X:\...", else 0. A target such as +// "\??\Volume{guid}\" names a volume and not a drive letter: it is not a local drive for our purpose. +[[nodiscard]] inline wchar_t ShellLocalDriveLetterOfSubstituteName(std::wstring_view substitute) { + constexpr std::wstring_view kPrefix = L"\\??\\"; + if (substitute.size() < kPrefix.size() + 2 || substitute.substr(0, kPrefix.size()) != kPrefix) return 0; + const wchar_t letter = substitute[kPrefix.size()]; + const bool alpha = (letter >= L'A' && letter <= L'Z') || (letter >= L'a' && letter <= L'z'); + if (!alpha || substitute[kPrefix.size() + 1] != L':') return 0; + if (substitute.size() > kPrefix.size() + 2 && substitute[kPrefix.size() + 2] != L'\\') return 0; + return letter; +} + +// A directory path ready to have a name appended: trailing separators are dropped (except the one of a +// drive root) and exactly one is put at the end. "C:\" stays "C:\" (it must not become "C:\\", which no +// shell call accepts: every candidate of a drive root would be unknown), "C:\Windows" and +// "C:\Windows\" both become "C:\Windows\". +[[nodiscard]] inline std::wstring ShellDirectoryWithSeparator(std::wstring path) { + while (path.size() > 3 && path.back() == L'\\') path.pop_back(); + if (path.empty() || path.back() != L'\\') path.push_back(L'\\'); + return path; +} + +// The target of a mount point as an ordinary path ("C:\redirect\subdir"), from a substitute name of the form +// "\??\C:\redirect\subdir" (or "\??\C:"); empty for any other form. The parts of this path are looked at +// one by one, from the root of the drive, before the mount point is let through. +[[nodiscard]] inline std::wstring ShellMountTargetPath(std::wstring_view substitute) { + if (ShellLocalDriveLetterOfSubstituteName(substitute) == 0) return std::wstring(); + std::wstring path(substitute.substr(4)); + if (path.size() == 2) path.push_back(L'\\'); + return path; +} + +// The most of a desktop.ini that is read (a real one is a few hundred bytes); a bigger file is a yes. +constexpr std::size_t kShellDesktopIniMaxBytes = 64 * 1024; + +namespace shell_desktop_ini_detail { + +[[nodiscard]] inline bool IsBlank(wchar_t c) { return c == L' ' || c == L'\t'; } + +[[nodiscard]] inline std::wstring_view Trim(std::wstring_view text) { + while (!text.empty() && IsBlank(text.front())) text.remove_prefix(1); + while (!text.empty() && IsBlank(text.back())) text.remove_suffix(1); + return text; +} + +struct IniEntry { + std::wstring section; // the section the line is in (empty before any header) + std::wstring key; // the text before '=' (the whole line when there is none) + std::wstring value; // the text after '=' +}; + +// The lines of a desktop.ini that are not blank, not a comment and not a header, each with its section. +// The bytes are UTF-16 LE or BE with a byte order mark, or else single-byte text (ANSI / UTF-8 with or +// without its mark; only ASCII matters to the rules, and a character above 127 is just a character). +// False when the text cannot be judged (fail-closed for every caller): longer than +// kShellDesktopIniMaxBytes, UTF-16 with an odd number of bytes, a NUL in single-byte text (a UTF-16 +// file without its mark), and a line that opens a header but does not close it. +[[nodiscard]] inline bool ParseIni(const unsigned char *bytes, std::size_t size, std::vector *entries) { + if (size > kShellDesktopIniMaxBytes) return false; + if (size != 0 && bytes == nullptr) return false; + std::wstring text; + text.reserve(size); + std::size_t at = 0; + bool wide = false; + bool big_endian = false; + if (size >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) { + wide = true; + at = 2; + } else if (size >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) { + wide = true; + big_endian = true; + at = 2; + } else if (size >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { + at = 3; + } + if (wide) { + if (((size - at) & 1u) != 0) return false; + for (; at + 1 < size; at += 2) { + const unsigned lo = big_endian ? bytes[at + 1] : bytes[at]; + const unsigned hi = big_endian ? bytes[at] : bytes[at + 1]; + text.push_back(static_cast((hi << 8) | lo)); + } + } else { + for (; at < size; ++at) { + if (bytes[at] == 0) return false; + text.push_back(static_cast(bytes[at])); + } + } + std::wstring section; + std::size_t line_begin = 0; + while (line_begin <= text.size()) { + std::size_t line_end = text.find_first_of(L"\r\n", line_begin); + if (line_end == std::wstring::npos) line_end = text.size(); + const std::wstring_view line = Trim(std::wstring_view(text).substr(line_begin, line_end - line_begin)); + line_begin = line_end + 1; + if (line.empty() || line.front() == L';') continue; + if (line.front() == L'[') { + const std::size_t close = line.find(L']'); + if (close == std::wstring_view::npos) return false; // a header that never closes: not known + section = std::wstring(Trim(line.substr(1, close - 1))); + continue; + } + IniEntry entry; + entry.section = section; + const std::size_t equals = line.find(L'='); + if (equals == std::wstring_view::npos) { + entry.key = std::wstring(line); + } else { + entry.key = std::wstring(Trim(line.substr(0, equals))); + entry.value = std::wstring(Trim(line.substr(equals + 1))); + } + entries->push_back(std::move(entry)); + } + return true; +} + +[[nodiscard]] inline bool SameName(std::wstring_view left, std::wstring_view right) { + return ShellNameEqualsAsciiFold(left, right); +} + +} // namespace shell_desktop_ini_detail + +// Whether the TEXT of a desktop.ini has a [LocalizedFileNames] section that is not empty (a section is +// empty when no line between its header and the next header is anything but blank or a comment, a line +// that starts with ';'). The section name is compared without regard to case. Text that cannot be +// judged (see ParseIni) is a YES. +[[nodiscard]] inline bool ShellDesktopIniTextHasLocalizedFileNames(const unsigned char *bytes, + std::size_t size) { + std::vector entries; + if (!shell_desktop_ini_detail::ParseIni(bytes, size, &entries)) return true; + for (const auto &entry : entries) + if (shell_desktop_ini_detail::SameName(entry.section, L"LocalizedFileNames")) return true; + return false; +} + +using ShellDriveIsLocal = bool (*)(wchar_t); + +// Whether a value from a desktop.ini is SAFE for the shell to be given: an ALLOW-list. The shell reads +// these values itself (on a device a single-quoted value was shown with its quotes kept). Here one +// pair of surrounding quotes (' or ", the same at both ends) and the blanks are removed first anyway, +// which can only make the verdict stricter. What is left is safe only if it is +// (a) plain text: none of the characters \ / : % @ , (a title, a tip); or +// (b) a path under a system variable, optionally with a leading '@' (a resource reference) and +// optionally a resource suffix ",-N" or ",N": it starts with %SystemRoot%, %windir%, +// %ProgramFiles%, %ProgramFiles(x86)% or %CommonProgramFiles% (followed by '\'); and the rest has no +// '/', no doubled '\' (so no UNC and no "\\?\" or "\\.\" form), no "..", and no other '%'. +// Everything else is NOT safe: a path that starts with a drive letter (even a local one: a part of it +// can be a symbolic link to the network), a relative path, a UNC or device path, a URL, an unknown +// variable, a malformed suffix. A KNOWN LIMIT: the folder a system variable expands to could itself +// hold a link; that is not looked at. +[[nodiscard]] inline bool ShellIniValueIsSafe(std::wstring_view value) { + using shell_desktop_ini_detail::SameName; + using shell_desktop_ini_detail::Trim; + value = Trim(value); + if (value.size() >= 2 && (value.front() == L'"' || value.front() == L'\'') && value.back() == value.front()) + value = Trim(value.substr(1, value.size() - 2)); + if (value.find_first_of(L"\\/:%@,") == std::wstring_view::npos) return true; // (a) plain text + if (!value.empty() && value.front() == L'@') value.remove_prefix(1); + // The resource suffix: ",-N" or ",N". A comma that is not that is not safe. + const std::size_t comma = value.find(L','); + if (comma != std::wstring_view::npos) { + std::wstring_view tail = value.substr(comma + 1); + if (!tail.empty() && tail.front() == L'-') tail.remove_prefix(1); + if (tail.empty() || tail.find_first_not_of(L"0123456789") != std::wstring_view::npos) return false; + value = value.substr(0, comma); + } + if (value.find(L'/') != std::wstring_view::npos || value.find(L"\\\\") != std::wstring_view::npos || + value.find(L"..") != std::wstring_view::npos || value.find(L',') != std::wstring_view::npos || + value.find(L'@') != std::wstring_view::npos) + return false; + // The start: a known system variable followed by '\'. A drive-letter path is not allowed (see above). + for (const std::wstring_view variable : + {std::wstring_view(L"%SystemRoot%"), std::wstring_view(L"%windir%"), std::wstring_view(L"%ProgramFiles%"), + std::wstring_view(L"%ProgramFiles(x86)%"), std::wstring_view(L"%CommonProgramFiles%")}) { + if (value.size() > variable.size() && SameName(value.substr(0, variable.size()), variable)) { + if (value[variable.size()] != L'\\') return false; + return value.substr(variable.size()).find(L'%') == std::wstring_view::npos; + } + } + return false; // a drive letter, an unknown variable, a relative path: not safe +} + +[[nodiscard]] inline bool ShellIniValueIsRisky(std::wstring_view value) { + return !ShellIniValueIsSafe(value); +} + +// Whether a candidate FOLDER's desktop.ini could make the shell's name for it reach somewhere that is not +// a local disk. The shell reads that file to name a read-only / system folder: the keys +// LocalizedResourceName, IconResource, IconFile, InfoTip (and the logo / icon-area keys) of +// [.ShellClassInfo] can refer to a module or a file ("@\\server\share\x.dll,-1" loads a library from the +// network), and CLSID / CLSID2 / UICLSID can bind a namespace extension: a value that is not SAFE +// (ShellIniValueIsSafe: an allow-list), and any CLSID key at all, is a YES. Text that cannot be judged +// is a YES. +[[nodiscard]] inline bool ShellDesktopIniTextIsRiskyForShellName(const unsigned char *bytes, + std::size_t size) { + using shell_desktop_ini_detail::SameName; + std::vector entries; + if (!shell_desktop_ini_detail::ParseIni(bytes, size, &entries)) return true; + for (const auto &entry : entries) { + if (!SameName(entry.section, L".ShellClassInfo")) continue; + if (SameName(entry.key, L"CLSID") || SameName(entry.key, L"CLSID2") || SameName(entry.key, L"UICLSID")) + return true; + for (const std::wstring_view key : + {std::wstring_view(L"LocalizedResourceName"), std::wstring_view(L"IconResource"), + std::wstring_view(L"IconFile"), std::wstring_view(L"InfoTip"), std::wstring_view(L"Logo"), + std::wstring_view(L"WideLogo"), std::wstring_view(L"IconArea_Image"), + std::wstring_view(L"PersonalizedName")}) + if (SameName(entry.key, key) && ShellIniValueIsRisky(entry.value)) return true; + } + return false; +} + +// Whether a folder's desktop.ini means that its files can be shown under names with no relation to +// their file names (a non-empty [LocalizedFileNames] section). The shell honours that section only +// for a folder that is read-only or system (checked on this machine: the same desktop.ini changes no +// name in a plain folder), so a folder without those attributes is not read at all. A desktop.ini +// is normal (a user's home has one): its existence is not a yes. What cannot be known is a yes +// (fail-closed): the folder's attributes, a lookup that failed for another reason than "not there", +// a desktop.ini that is itself a reparse point of ANY kind (it is opened as the link itself, never +// followed, to a place that can be on the network), and content that cannot be read or judged +// (content_says_localized is the verdict of ShellDesktopIniTextHasLocalizedFileNames, or true when +// the bytes could not be had). +[[nodiscard]] inline bool ShellDesktopIniLocalizesFileNames(bool folder_attributes_known, + unsigned long folder_attributes, + bool file_opened, + unsigned long open_error, + bool file_is_reparse_point, + bool content_says_localized) { + if (!folder_attributes_known) return true; + if ((folder_attributes & (kShellAttributeReadOnly | kShellAttributeSystem)) == 0) return false; + if (!file_opened) + return !(open_error == kShellErrorFileNotFound || open_error == kShellErrorPathNotFound); + if (file_is_reparse_point) return true; + return content_says_localized; +} + +// The last part of a file system path (what follows the last separator; a trailing separator is +// ignored). The shell item a candidate's path resolved to is identified by the leaf of its FILE SYSTEM +// path: its relative parsing name would be a GUID for a known folder ("Documents" of a user's home). +[[nodiscard]] inline std::wstring_view ShellLeafName(std::wstring_view path) { + while (!path.empty() && path.back() == L'\\') path.remove_suffix(1); + const std::size_t at = path.find_last_of(L'\\'); + return at == std::wstring_view::npos ? path : path.substr(at + 1); +} + +// Whether the shell item that a candidate's path resolved to IS that candidate. Win32 normalizes a +// path: a name that ends in a dot or a space resolves to the entry without them, which is another +// entry. Such a name is never taken as resolved; any other must come back with exactly its own name. +[[nodiscard]] inline bool ShellResolvedNameIsTheCandidate(std::wstring_view candidate_name, + std::wstring_view resolved_name, + ShellNameEquals equals = ShellNameEqualsAsciiFold) { + if (candidate_name.empty() || equals == nullptr) return false; + if (candidate_name.back() == L'.' || candidate_name.back() == L' ') return false; + return equals(candidate_name, resolved_name); +} + +// A display name that cannot be a file name (it holds a wildcard or a separator) matches nothing, and +// is never used to build a search pattern. +[[nodiscard]] inline bool ShellDisplayNameUsable(std::wstring_view display) { + if (display.empty()) return false; + for (const wchar_t c : display) + if (c == L'*' || c == L'?' || c == L'<' || c == L'>' || c == L'"' || c == L'|' || c == L'/' || + c == L'\\' || c == L':' || c < 0x20) + return false; + return true; +} + +// The index of the one candidate the display name stands for, or kNoShellMatch. +[[nodiscard]] inline std::size_t MatchShellDisplayName( + std::wstring_view display, const std::vector &candidates, + ShellNameEquals equals = ShellNameEqualsAsciiFold) { + if (!ShellDisplayNameUsable(display) || candidates.empty() || + candidates.size() > kShellMaxCandidates || equals == nullptr) + return kNoShellMatch; + std::size_t found = kNoShellMatch; + for (std::size_t i = 0; i < candidates.size(); ++i) { + // A candidate whose shell name is not known could be the one, or a second one. + if (candidates[i].is_link) return kNoShellMatch; + if (!candidates[i].shell_name_known) return kNoShellMatch; + if (!equals(candidates[i].shell_name, display)) continue; + if (found != kNoShellMatch) return kNoShellMatch; // two entries: which one was meant is not known + found = i; + } + return found; +} + +} // namespace sprint_coder::computer_use diff --git a/apps/desktop/computer-use-native/computer_use_windows_uia_source.h b/apps/desktop/computer-use-native/computer_use_windows_uia_source.h index b3c2ee387..188e533cb 100644 --- a/apps/desktop/computer-use-native/computer_use_windows_uia_source.h +++ b/apps/desktop/computer-use-native/computer_use_windows_uia_source.h @@ -20,6 +20,7 @@ // has initialized COM). The thread that calls it must have COM initialized. #include "computer_use_windows_shadow_reader.h" +#include "computer_use_windows_shell_name_match.h" #include @@ -30,10 +31,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -167,23 +170,361 @@ struct ShellItemAnswer { bool is_folder = false; }; -// How long, and for how many items, the shell lookup may go on. It is ONE gated call of the reader, -// so it keeps its own limits: the action still being valid, a deadline, and a cap on the items it -// goes through. A folder bigger than the cap, or a lookup past the deadline, is "not known". +// How long the shell lookup may go on. It is ONE gated call of the reader, so it keeps its own +// limits: the action still being valid, a deadline, and (in the matcher) a cap on the candidate +// entries it collects. A lookup past the deadline, or with more candidates than the cap, is "not +// known". The size of the folder does not matter: only entries whose names start with the display +// name are listed. struct ShellLookupLimits { std::function still_valid; std::function now_ns; std::uint64_t deadline_ns = 0; }; -constexpr int kShellLookupMaxItems = 512; constexpr int kShellLookupMaxWindows = 64; constexpr std::uint64_t kShellLookupBudgetNs = 60'000'000; -// The path of the item a file browser shows under `display_name`, found in the shell view of the -// tab (or of the window) by its display name. Read-only: windows and views are asked for, never -// navigated, selected or invoked. kAbsent when there is no such view, the name is not unique in the -// view (a hidden extension can make two items look alike), the item has no file-system path -// (a search, This PC, a library), the folder is too big to look through, or the deadline passed. +// Whether the file system compares two names as equal, the way Explorer does (ordinal, case-blind). +[[nodiscard]] inline bool ShellNameEqualsOrdinal(std::wstring_view left, std::wstring_view right) { + if (left.size() != right.size()) return false; + if (left.empty()) return true; + return CompareStringOrdinal(left.data(), static_cast(left.size()), right.data(), + static_cast(right.size()), TRUE) == CSTR_EQUAL; +} + +struct FindHandle { + HANDLE handle = INVALID_HANDLE_VALUE; + FindHandle() = default; + FindHandle(const FindHandle &) = delete; + FindHandle &operator=(const FindHandle &) = delete; + ~FindHandle() { + if (handle != INVALID_HANDLE_VALUE) FindClose(handle); + } +}; + +struct FileHandle { + HANDLE handle = INVALID_HANDLE_VALUE; + FileHandle() = default; + FileHandle(const FileHandle &) = delete; + FileHandle &operator=(const FileHandle &) = delete; + ~FileHandle() { + if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + } +}; + +struct AbsoluteIdList { + PIDLIST_ABSOLUTE id = nullptr; + AbsoluteIdList() = default; + AbsoluteIdList(const AbsoluteIdList &) = delete; + AbsoluteIdList &operator=(const AbsoluteIdList &) = delete; + ~AbsoluteIdList() { + if (id != nullptr) ILFree(id); + } +}; + +[[nodiscard]] inline std::wstring ShellPathWithoutTrailingSeparator(std::wstring path) { + while (path.size() > 3 && path.back() == L'\\') path.pop_back(); + return path; +} + +// Whether a lookup must stop now, before it starts one more call that could block on the file +// system: the action is no longer valid or the caller gave up (kStopped; the caller then also cancels +// a call that is already blocked, but cannot cancel one that is started after), or the deadline passed +// (kAbsent). Asked before EVERY new I/O step of the lookup. +[[nodiscard]] inline bool ShellLookupMustStop(const ShellLookupLimits &limits, ShadowReadResult *result) { + if (!limits.still_valid()) { + *result = ShadowReadResult::kStopped; + return true; + } + if (limits.now_ns() > limits.deadline_ns) { + *result = ShadowReadResult::kAbsent; + return true; + } + return false; +} + +// Whether a drive type is one whose files are read from a local disk: not a network drive, an +// optical drive, an unknown one or a root that does not exist. +[[nodiscard]] inline bool ShellDriveIsLocalDisk(UINT drive) { + if (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || + drive == DRIVE_NO_ROOT_DIR) + return false; + return true; +} + +[[nodiscard]] inline bool ShellDriveLetterIsLocal(wchar_t letter) { + const wchar_t root[4] = {letter, L':', L'\\', L'\0'}; + return ShellDriveIsLocalDisk(GetDriveTypeW(root)); // no I/O +} + +enum class ShellMountTarget { kLocalDisk, kNotLocal, kStopped }; + +[[nodiscard]] inline bool ShellPathHasLink(const std::wstring &root, const std::wstring &path, + const ShellLookupLimits &limits, ShadowReadResult *result, + bool allow_local_mount_points = true); + +// Where a mount point (a junction) at `path` leads: read with FSCTL_GET_REPARSE_POINT on the +// mount point itself (opened as the link, never followed). It is a local disk only when the target is a +// drive-letter path whose drive is a local disk (a junction cannot point to a network share; a target +// such as "\??\Volume{guid}\" names no drive letter and is not accepted). Anything unreadable is "not +// local" (fail-closed). ShellLookupMustStop is asked before each of the two calls. +[[nodiscard]] inline ShellMountTarget ShellMountPointTarget(const std::wstring &path, + const ShellLookupLimits &limits, + ShadowReadResult *result) { + if (ShellLookupMustStop(limits, result)) return ShellMountTarget::kStopped; + FileHandle link; + link.handle = CreateFileW(path.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr); + if (link.handle == INVALID_HANDLE_VALUE) return ShellMountTarget::kNotLocal; + if (ShellLookupMustStop(limits, result)) return ShellMountTarget::kStopped; + std::vector buffer(16 * 1024); + DWORD returned = 0; + if (!DeviceIoControl(link.handle, FSCTL_GET_REPARSE_POINT, nullptr, 0, buffer.data(), + static_cast(buffer.size()), &returned, nullptr)) + return ShellMountTarget::kNotLocal; + std::wstring substitute; + if (!ShellMountPointSubstituteName(buffer.data(), returned, &substitute)) + return ShellMountTarget::kNotLocal; + const wchar_t letter = ShellLocalDriveLetterOfSubstituteName(substitute); + if (letter == 0 || !ShellDriveLetterIsLocal(letter)) return ShellMountTarget::kNotLocal; + // A local drive letter is not enough: a part of the target's own path can be a symbolic link to the + // network ("C:\redirect\subdir" with C:\redirect a link to a share). So every part of the target is + // looked at, from the drive's root, and the target is accepted only if NONE of them names another object. + // No recursion: a junction (or any link) inside the target is refused (depth one). + const std::wstring target = ShellMountTargetPath(substitute); + const std::wstring root = std::wstring(1, letter) + L":\\"; + if (target.empty()) return ShellMountTarget::kNotLocal; + if (ShellPathHasLink(root, target, limits, result, false)) + return *result == ShadowReadResult::kStopped ? ShellMountTarget::kStopped : ShellMountTarget::kNotLocal; + return ShellMountTarget::kLocalDisk; +} + +enum class ShellIniStatus { kStopped, kNotThere, kOpenFailed, kLink, kUnreadable, kText }; + +// A desktop.ini opened ONCE, as the file itself (FILE_FLAG_OPEN_REPARSE_POINT: a link is opened, never +// followed, so nothing is reached through it), checked on that handle for a reparse point of any kind, +// and read from the SAME handle up to kShellDesktopIniMaxBytes: there is no second open by path between +// the check and the read (which a swap for a link could exploit). The profile API is not used. +// ShellLookupMustStop is asked before EVERY call (open, handle information, read); on a stop the status +// is kStopped and *result says why. kNotThere: the file does not exist. kOpenFailed: it could not be +// opened for another reason (*open_error). kLink: it is a reparse point of any kind. kUnreadable: the +// information or the content could not be had, or the file is too big. kText: *bytes holds the content. +[[nodiscard]] inline ShellIniStatus ShellReadDesktopIni(const std::wstring &settings, + const ShellLookupLimits &limits, + ShadowReadResult *result, DWORD *open_error, + std::vector *bytes) { + if (ShellLookupMustStop(limits, result)) return ShellIniStatus::kStopped; + FileHandle file; + file.handle = CreateFileW(settings.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (file.handle == INVALID_HANDLE_VALUE) { + *open_error = GetLastError(); + return *open_error == ERROR_FILE_NOT_FOUND || *open_error == ERROR_PATH_NOT_FOUND + ? ShellIniStatus::kNotThere + : ShellIniStatus::kOpenFailed; + } + if (ShellLookupMustStop(limits, result)) return ShellIniStatus::kStopped; + BY_HANDLE_FILE_INFORMATION info{}; + if (!GetFileInformationByHandle(file.handle, &info)) return ShellIniStatus::kUnreadable; + if ((info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) return ShellIniStatus::kLink; + const unsigned long long size = (static_cast(info.nFileSizeHigh) << 32) | info.nFileSizeLow; + if (size > kShellDesktopIniMaxBytes) return ShellIniStatus::kUnreadable; + if (ShellLookupMustStop(limits, result)) return ShellIniStatus::kStopped; + std::vector content(static_cast(size)); + DWORD got = 0; + const bool read_ok = content.empty() || + (ReadFile(file.handle, content.data(), static_cast(content.size()), &got, nullptr) && + got == content.size()); + if (!read_ok) return ShellIniStatus::kUnreadable; + *bytes = std::move(content); + return ShellIniStatus::kText; +} + +enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; + +// Whether the shell can be asked for the name of a read-only / system candidate DIRECTORY without it +// reaching somewhere that is not a local disk. To name such a folder the shell reads its desktop.ini +// (LocalizedResourceName and the like), may follow a link there, load the module a resource reference +// names ("@\\server\share\x.dll,-1") and bind a CLSID. So the file is read here first, by +// ShellReadDesktopIni, and judged by ShellDesktopIniTextIsRiskyForShellName. No file: safe. A link, a +// file that cannot be opened or read or judged, or a risky one: unknown. +[[nodiscard]] inline ShellCandidateIni ShellCandidateDirectoryIni(const std::wstring &directory, + const ShellLookupLimits &limits, + ShadowReadResult *result) { + DWORD open_error = 0; + std::vector bytes; + switch (ShellReadDesktopIni(ShellDirectoryWithSeparator(directory) + L"desktop.ini", limits, + result, &open_error, &bytes)) { + case ShellIniStatus::kStopped: + return ShellCandidateIni::kStopped; + case ShellIniStatus::kNotThere: + return ShellCandidateIni::kSafe; + case ShellIniStatus::kText: + return ShellDesktopIniTextIsRiskyForShellName(bytes.data(), bytes.size()) + ? ShellCandidateIni::kUnknown + : ShellCandidateIni::kSafe; + default: + return ShellCandidateIni::kUnknown; + } +} + +// Whether any part of `path` (below its volume root) is a symbolic link or a volume mount point: +// those can lead to a network or optical location that the drive letter does not show. Cloud file +// tags and other reparse points are not links in this sense. A part that cannot be looked at counts +// as a link (fail-closed), and so does a stop (see ShellLookupMustStop; *result then says why). Only a +// mount point that leads to a local disk is let through (ShellMountPointTarget). +[[nodiscard]] inline bool ShellPathHasLink(const std::wstring &root, const std::wstring &path, + const ShellLookupLimits &limits, ShadowReadResult *result, + bool allow_local_mount_points) { + const std::wstring trimmed = ShellPathWithoutTrailingSeparator(path); + std::size_t at = root.size(); + for (;;) { + const std::size_t next = trimmed.find(L'\\', at); + const std::wstring part = trimmed.substr(0, next == std::wstring::npos ? trimmed.size() : next); + if (part.size() > root.size()) { + // Each part is one more file system call: not after the caller gave up or the deadline passed (a + // stop counts as "a link": fail-closed, and the caller returns the stop result). + if (ShellLookupMustStop(limits, result)) return true; + WIN32_FIND_DATAW data{}; + FindHandle find; + find.handle = FindFirstFileExW(part.c_str(), FindExInfoBasic, &data, FindExSearchNameMatch, + nullptr, 0); + if (find.handle == INVALID_HANDLE_VALUE) return true; + const bool is_reparse = (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + if (ShellReparseIsLink(is_reparse, data.dwReserved0)) { + // A symbolic link is refused. A mount point (a junction) whose target is a local drive-letter + // path is allowed: it cannot lead to the network, and the folders of a user's profile are often + // reached through one. Any other target, and a stop, is refused. + if (!allow_local_mount_points || !ShellReparseIsMountPoint(is_reparse, data.dwReserved0)) return true; + if (ShellMountPointTarget(part, limits, result) != ShellMountTarget::kLocalDisk) return true; + } + } + if (next == std::wstring::npos) return false; + at = next + 1; + } +} + +// Whether the folder at `path` is one whose entries may be listed here: a plain directory on a local +// disk, whose view shows exactly its own entries under the names the shell gives them. Decided +// BEFORE any listing, and fail-closed (a doubt is "no"): +// - a network location (a UNC path, a remote, optical or unknown drive) can block for long. It is +// excluded in THREE steps, each before any call that could reach it: first by the type of the drive +// letter (GetDriveTypeW on the first three characters: no I/O, so a drive mapped to an SMB share that +// does not answer is never touched); then every part of the path is looked at from that drive's +// root (a symbolic link is refused, and a mount point only when it leads to a local drive-letter +// path: ShellPathHasLink), so a link in the middle that leads to the network is refused BEFORE the +// volume is asked about; and only then GetVolumePathNameW finds the VOLUME the path is on, whose type +// is checked again. The root of a mounted volume itself (C:\\mnt\\vol, listed as the folder) is not +// listed either: its volume root is longer than its path, which is "no" (fail-closed); +// - the desktop folders (the user's and the public one) are shown MERGED in the desktop view, so an +// entry of the other folder looks like one of this folder. Their paths are asked WITHOUT +// verifying them (KF_FLAG_DONT_VERIFY): a redirected desktop would otherwise be probed over the +// network. +// +// Every file system call here is preceded by ShellLookupMustStop: on a stop the answer is "no" and +// *result is kStopped or kAbsent (the caller returns it); on any other "no" *result is untouched. +// +// A known limit: the folder could be swapped for a link between this check and the listing (TOCTOU). +// It is accepted, because only a local operation of the same user on this machine can do it, and a +// swap does not make the answer wrong by itself: every candidate must still resolve (by its file +// system path) to the very entry that was listed, and the action is re-evaluated just before it is +// sent (the dispatch guard of the later step), so a folder that changed in between is seen again. +[[nodiscard]] inline bool ShellFolderMayBeListed(const std::wstring &path, const ShellLookupLimits &limits, + ShadowReadResult *result) { + if (path.size() < 3 || path[0] == L'\\') return false; + if (path[1] != L':' || path[2] != L'\\') return false; + // Step 1: the drive letter's type, with no I/O (a share that does not answer is not touched). + if (!ShellDriveIsLocalDisk(GetDriveTypeW(path.substr(0, 3).c_str()))) return false; + // Step 2: any link on the way, from the drive letter's root, BEFORE the volume is asked about: a link + // in the middle of the path that leads to the network must be refused before any call reaches it. + if (ShellPathHasLink(path.substr(0, 3), path, limits, result)) return false; + // Step 3: the volume the path is really on, and its type. + std::wstring root(MAX_PATH * 2, L'\0'); + if (ShellLookupMustStop(limits, result)) return false; + if (!GetVolumePathNameW(path.c_str(), root.data(), static_cast(root.size()))) return false; + root.resize(wcslen(root.c_str())); + if (root.size() < 3 || root[0] == L'\\' || root.size() > path.size() || + !ShellNameEqualsOrdinal(std::wstring_view(path).substr(0, root.size()), root)) + return false; + if (!ShellDriveIsLocalDisk(GetDriveTypeW(root.c_str()))) return false; + const std::wstring trimmed = ShellPathWithoutTrailingSeparator(path); + for (const KNOWNFOLDERID &known : {FOLDERID_Desktop, FOLDERID_PublicDesktop}) { + PWSTR folder = nullptr; + if (ShellLookupMustStop(limits, result)) return false; + if (FAILED(SHGetKnownFolderPath(known, KF_FLAG_DONT_VERIFY, nullptr, &folder)) || folder == nullptr) { + if (folder != nullptr) CoTaskMemFree(folder); + return false; + } + const bool same = ShellNameEqualsOrdinal(ShellPathWithoutTrailingSeparator(folder), trimmed); + CoTaskMemFree(folder); + if (same) return false; + } + return true; +} + +// Whether the files of the folder can be shown under names that have nothing to do with their file +// names (a desktop.ini with a non-empty [LocalizedFileNames] section; the rule, including what counts +// as a yes for lack of knowledge, is ShellDesktopIniLocalizesFileNames). Then an entry that is not a +// candidate by its file name could still carry the display name. The shell honours a desktop.ini only +// in a read-only or system folder, so no other folder is read. +// +// The file is opened ONCE, as the file itself (FILE_FLAG_OPEN_REPARSE_POINT: a link is opened, never +// followed, so nothing is reached through it), checked on that handle for a reparse point of any +// kind, and read from the SAME handle up to kShellDesktopIniMaxBytes: there is no second open by path +// between the check and the read (which a swap for a link could exploit). The text is judged by the +// pure ShellDesktopIniTextHasLocalizedFileNames; the profile API is not used. A stop counts as "yes" +// (fail-closed); *result then says why. ShellLookupMustStop is asked before EVERY call below. +[[nodiscard]] inline bool ShellFolderHasLocalizedFileNames(const std::wstring &path, + const ShellLookupLimits &limits, + ShadowReadResult *result) { + const std::wstring folder = ShellPathWithoutTrailingSeparator(path); + const std::wstring settings = ShellDirectoryWithSeparator(path) + L"desktop.ini"; + if (ShellLookupMustStop(limits, result)) return true; + const DWORD folder_attributes = GetFileAttributesW(folder.c_str()); + const bool folder_known = folder_attributes != INVALID_FILE_ATTRIBUTES; + bool opened = false; + DWORD open_error = 0; + bool is_reparse_point = false; + bool content_says_localized = true; + if (folder_known && (folder_attributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0) { + std::vector bytes; + switch (ShellReadDesktopIni(settings, limits, result, &open_error, &bytes)) { + case ShellIniStatus::kStopped: + return true; + case ShellIniStatus::kNotThere: + case ShellIniStatus::kOpenFailed: + break; // opened = false: the open error says which + case ShellIniStatus::kLink: + opened = true; + is_reparse_point = true; + break; + case ShellIniStatus::kUnreadable: + opened = true; // content_says_localized stays true: not known + break; + case ShellIniStatus::kText: + opened = true; + content_says_localized = ShellDesktopIniTextHasLocalizedFileNames(bytes.data(), bytes.size()); + break; + } + } + return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, opened, open_error, + is_reparse_point, content_says_localized); +} + +// The path of the item a file browser shows under `display_name`. The folder the view shows is asked +// for its file-system path ONCE, and only a plain local directory is looked into (see +// ShellFolderMayBeListed; the root of the desktop namespace has an empty id list and is not; a folder +// whose desktop.ini localizes file names is not either). The folder is listed ONCE in THIS process +// (FindFirstFileExW: file system metadata only), and the SHELL is asked for the name it shows (the +// very name Explorer shows: a hidden extension of any kind, a folder with a dot in its name) only for +// the entries that can carry the display name: those whose file name STARTS WITH it, and the +// directories that are read-only or system (the only ones a desktop.ini can give another name, as +// "Documents" shown as the localized word). MatchShellDisplayName takes the one whose shell name is +// the display name, if there is exactly one and every such entry could be read. Read-only: windows +// and views are asked for, never navigated, selected or invoked, and nothing is opened. kAbsent when +// there is no such view, the folder is not a plain local directory, no or more than one entry fits, a +// name cannot be read, more than the cap of entries need their shell name, or the deadline passed. [[nodiscard]] inline ShadowReadResult ShellItemPathByName(HWND tab, HWND top, std::wstring_view display_name, const ShellLookupLimits &limits, @@ -244,46 +585,134 @@ constexpr std::uint64_t kShellLookupBudgetNs = 60'000'000; folder_view.Get() == nullptr || FAILED(folder_view.Get()->GetFolder(IID_PPV_ARGS(folder.Receive()))) || folder.Get() == nullptr) return ShadowReadResult::kAbsent; - int items = 0; - if (FAILED(folder_view.Get()->ItemCount(SVGIO_ALLVIEW, &items))) return ShadowReadResult::kFailed; - if (items <= 0 || items > kShellLookupMaxItems) return ShadowReadResult::kAbsent; + // Every call below that asks the shell about the FOLDER (it can reach the disk) is preceded by the + // stop check, like every file system call after it. + ShadowReadResult stopped = ShadowReadResult::kAbsent; + // The folder's id list. The root of the desktop namespace has an EMPTY one (its path would be the + // user's desktop, while the view also merges the public desktop): not looked into. + AbsoluteIdList folder_id; + if (ShellLookupMustStop(limits, &stopped)) return stopped; + if (FAILED(SHGetIDListFromObject(folder.Get(), &folder_id.id)) || folder_id.id == nullptr || + ILIsEmpty(folder_id.id)) + return ShadowReadResult::kAbsent; + // A slow location is not touched (its folder says so before any I/O of ours). + ComRef folder_item; + SFGAOF folder_attributes = 0; + if (ShellLookupMustStop(limits, &stopped)) return stopped; + if (FAILED(SHCreateItemFromIDList(folder_id.id, IID_PPV_ARGS(folder_item.Receive()))) || + folder_item.Get() == nullptr || + FAILED(folder_item.Get()->GetAttributes(SFGAO_ISSLOW, &folder_attributes)) || + (folder_attributes & SFGAO_ISSLOW) != 0) + return ShadowReadResult::kAbsent; + // The folder's path, once. A folder that is no directory of the file system has none. + std::wstring folder_path(32768, L'\0'); + if (ShellLookupMustStop(limits, &stopped)) return stopped; + const BOOL has_path = SHGetPathFromIDListEx(folder_id.id, folder_path.data(), + static_cast(folder_path.size()), GPFIDL_DEFAULT); + if (!has_path) return ShadowReadResult::kAbsent; + folder_path.resize(wcslen(folder_path.c_str())); + if (folder_path.empty() || !ShellDisplayNameUsable(display_name)) return ShadowReadResult::kAbsent; + // Only a plain local directory: decided before the first listing, and never after the caller gave + // up or the deadline passed (a call started after the cancel could not be canceled). + if (ShellLookupMustStop(limits, &stopped)) return stopped; + stopped = ShadowReadResult::kAbsent; + if (!ShellFolderMayBeListed(folder_path, limits, &stopped)) return stopped; + const std::wstring directory = ShellDirectoryWithSeparator(folder_path); + // The folder itself: every entry (a name that is only the display name's prefix does not decide). + std::wstring pattern = directory; + pattern.push_back(L'*'); + // A path past MAX_PATH would need the extended prefix: it is not looked up. + if (directory.size() >= MAX_PATH - 14) return ShadowReadResult::kAbsent; + // A desktop.ini that renames files: an entry outside the candidates could carry the display name. + if (ShellLookupMustStop(limits, &stopped)) return stopped; + stopped = ShadowReadResult::kAbsent; + if (ShellFolderHasLocalizedFileNames(folder_path, limits, &stopped)) return stopped; - ComRef matched; - for (int index = 0; index < items; ++index) { - if (!limits.still_valid()) return ShadowReadResult::kStopped; - if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent; - PITEMID_CHILD child = nullptr; - // An item that cannot be read leaves the uniqueness of the name unknown: the whole lookup is - // not obtained (a readable file beside an unreadable .lnk of the same display name would - // otherwise be taken for the target). - if (FAILED(folder_view.Get()->Item(index, &child)) || child == nullptr) + std::vector candidates; + FindHandle find; + WIN32_FIND_DATAW entry{}; + if (ShellLookupMustStop(limits, &stopped)) return stopped; + find.handle = FindFirstFileExW(pattern.c_str(), FindExInfoBasic, &entry, FindExSearchNameMatch, + nullptr, FIND_FIRST_EX_LARGE_FETCH); + if (find.handle == INVALID_HANDLE_VALUE) { + const DWORD error = GetLastError(); + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? ShadowReadResult::kAbsent + : ShadowReadResult::kFailed; + } + for (;;) { + if (ShellLookupMustStop(limits, &stopped)) return stopped; + const std::wstring_view found(entry.cFileName); + const bool is_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + // An entry needs its shell name only when it can carry the display name (the pure rule in the + // matcher header: a file name that starts with it, or a read-only / system directory, which a + // desktop.ini can rename). Hidden entries are not left out (a doubt keeps an entry, which can only + // make the answer "not known"). + if (ShellEntryNeedsShellName(found, is_directory, + (entry.dwFileAttributes & + (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0, + display_name, ShellNameEqualsOrdinal)) { + // One past the cap is enough to know there are too many. + if (candidates.size() >= kShellMaxCandidates) return ShadowReadResult::kAbsent; + ShellNameCandidate candidate; + candidate.name = std::wstring(found); + candidate.is_directory = is_directory; + candidate.renamable_directory = + is_directory && (entry.dwFileAttributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0; + // A link is never followed to get a name (its target can be on the network). The moment one is + // found the whole answer is "not known", and nothing else is read. The one exception: a mount + // point (a junction) whose target is a local drive-letter path is an ordinary candidate (it cannot + // lead to the network; a symbolic link and a volume mount point still end the lookup). + const bool is_reparse = (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + candidate.is_link = ShellReparseIsLink(is_reparse, entry.dwReserved0); + if (candidate.is_link) { + if (!ShellReparseIsMountPoint(is_reparse, entry.dwReserved0)) return ShadowReadResult::kAbsent; + const ShellMountTarget target = ShellMountPointTarget(directory + candidate.name, limits, &stopped); + if (target == ShellMountTarget::kStopped) return stopped; + if (target != ShellMountTarget::kLocalDisk) return ShadowReadResult::kAbsent; + candidate.is_link = false; + } + candidates.push_back(std::move(candidate)); + } + if (!FindNextFileW(find.handle, &entry)) { + if (GetLastError() == ERROR_NO_MORE_FILES) break; return ShadowReadResult::kFailed; + } + } + // The name the shell shows for each candidate. One that cannot be read, or whose path resolved to + // another entry (Win32 drops a trailing dot or space), is left unknown, which makes the whole + // answer "not known" (it might be the one, or a second one). + for (ShellNameCandidate &candidate : candidates) { + if (ShellLookupMustStop(limits, &stopped)) return stopped; + // To name a read-only / system directory the shell reads ITS desktop.ini (and may follow a link there, + // load a module a resource reference names, bind a CLSID): read it here first, as a handle, and + // judge it; a risky or unreadable one leaves the name unknown, and the shell is never asked. + if (candidate.renamable_directory) { + const ShellCandidateIni ini = ShellCandidateDirectoryIni(directory + candidate.name, limits, &stopped); + if (ini == ShellCandidateIni::kStopped) return stopped; + if (ini == ShellCandidateIni::kUnknown) continue; + } + if (ShellLookupMustStop(limits, &stopped)) return stopped; ComRef item; - const HRESULT made = - SHCreateItemWithParent(nullptr, folder.Get(), child, IID_PPV_ARGS(item.Receive())); - ILFree(child); - if (FAILED(made) || item.Get() == nullptr) return ShadowReadResult::kFailed; PWSTR shown = nullptr; - if (FAILED(item.Get()->GetDisplayName(SIGDN_NORMALDISPLAY, &shown)) || shown == nullptr) - return ShadowReadResult::kFailed; - const bool same = std::wstring_view(shown) == display_name; - CoTaskMemFree(shown); - if (!same) continue; - // Two items with the same display name (a hidden extension): which one was meant is not known. - if (matched.Get() != nullptr) return ShadowReadResult::kAbsent; - matched = std::move(item); - } - if (matched.Get() == nullptr) return ShadowReadResult::kAbsent; - PWSTR file_path = nullptr; - if (FAILED(matched.Get()->GetDisplayName(SIGDN_FILESYSPATH, &file_path)) || file_path == nullptr) - return ShadowReadResult::kAbsent; - answer->path = file_path; - CoTaskMemFree(file_path); - SFGAOF attributes = 0; - if (FAILED(matched.Get()->GetAttributes(SFGAO_FOLDER | SFGAO_STREAM, &attributes))) - return ShadowReadResult::kFailed; - // An archive is both a folder and a stream: it is a file with an extension. - answer->is_folder = (attributes & SFGAO_FOLDER) != 0 && (attributes & SFGAO_STREAM) == 0; + PWSTR resolved = nullptr; + if (SUCCEEDED(SHCreateItemFromParsingName((directory + candidate.name).c_str(), nullptr, + IID_PPV_ARGS(item.Receive()))) && + item.Get() != nullptr && + SUCCEEDED(item.Get()->GetDisplayName(SIGDN_FILESYSPATH, &resolved)) && + resolved != nullptr && + ShellResolvedNameIsTheCandidate(candidate.name, ShellLeafName(resolved), ShellNameEqualsOrdinal) && + SUCCEEDED(item.Get()->GetDisplayName(SIGDN_NORMALDISPLAY, &shown)) && shown != nullptr) { + candidate.shell_name = shown; + candidate.shell_name_known = true; + } + if (shown != nullptr) CoTaskMemFree(shown); + if (resolved != nullptr) CoTaskMemFree(resolved); + } + const std::size_t index = MatchShellDisplayName(display_name, candidates, ShellNameEqualsOrdinal); + if (index == kNoShellMatch) return ShadowReadResult::kAbsent; + answer->path = directory + candidates[index].name; + // A directory of the file system is a folder; an archive is a file. + answer->is_folder = candidates[index].is_directory; return ShadowReadResult::kOk; } @@ -295,6 +724,17 @@ struct ShellLookupPending { std::mutex mutex; std::condition_variable ready; bool done = false; + // Set by the caller when it stops waiting: the lookup stops at its next step. + std::atomic abandoned{false}; + // A handle of the lookup's thread (under `mutex`), so a caller that gives up can cancel the file + // system call the thread is blocked in. + HANDLE thread = nullptr; + ShellLookupPending() = default; + ShellLookupPending(const ShellLookupPending &) = delete; + ShellLookupPending &operator=(const ShellLookupPending &) = delete; + ~ShellLookupPending() { + if (thread != nullptr) CloseHandle(thread); + } ShadowReadResult result = ShadowReadResult::kAbsent; ShellItemAnswer answer; }; @@ -531,28 +971,57 @@ class WindowsUiaShadowSource final : public ShadowUiaSource { const std::wstring display_name(name.View()); try { std::thread([pending, tab, top, display_name, valid, &running]() { - const HRESULT initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED); - const auto now = [] { - return static_cast(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count()); - }; - windows_uia_source_detail::ShellLookupLimits limits; - limits.still_valid = [valid] { return valid == nullptr || valid(); }; - limits.now_ns = now; - limits.deadline_ns = now() + windows_uia_source_detail::kShellLookupBudgetNs; + ShadowReadResult result = ShadowReadResult::kFailed; windows_uia_source_detail::ShellItemAnswer answer; - const ShadowReadResult result = windows_uia_source_detail::ShellItemPathByName( - tab, top, display_name, limits, &answer); - if (SUCCEEDED(initialized)) CoUninitialize(); - { - std::lock_guard lock(pending->mutex); - pending->result = result; - pending->answer = std::move(answer); - pending->done = true; + try { + // Released after the lookup, also when something throws. + struct ComApartment { + HRESULT initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + ~ComApartment() { + if (SUCCEEDED(initialized)) CoUninitialize(); + } + } apartment; + const auto now = [] { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); + }; + windows_uia_source_detail::ShellLookupLimits limits; + // The caller giving up ends the lookup at its next step, like the action being canceled. + limits.still_valid = [valid, pending] { + return !pending->abandoned.load(std::memory_order_acquire) && + (valid == nullptr || valid()); + }; + { + HANDLE self = nullptr; + if (DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), &self, 0, + FALSE, DUPLICATE_SAME_ACCESS)) { + std::lock_guard lock(pending->mutex); + pending->thread = self; + } + } + limits.now_ns = now; + limits.deadline_ns = now() + windows_uia_source_detail::kShellLookupBudgetNs; + result = windows_uia_source_detail::ShellItemPathByName(tab, top, display_name, limits, + &answer); + } catch (...) { + // An exception out of a thread would end the process: the lookup just failed. + result = ShadowReadResult::kFailed; + answer = windows_uia_source_detail::ShellItemAnswer{}; } - running.store(false); - pending->ready.notify_one(); + // The slot is released and the waiting caller told, whatever happened above. + struct Finish { + std::atomic &running; + std::shared_ptr pending; + ~Finish() { + running.store(false); + pending->ready.notify_one(); + } + } finish{running, pending}; + std::lock_guard lock(pending->mutex); + pending->result = result; + pending->answer = std::move(answer); + pending->done = true; }).detach(); } catch (...) { running.store(false); @@ -562,8 +1031,14 @@ class WindowsUiaShadowSource final : public ShadowUiaSource { { std::unique_lock lock(pending->mutex); if (!pending->ready.wait_for(lock, windows_uia_source_detail::kShellLookupWait, - [&pending] { return pending->done; })) + [&pending] { return pending->done; })) { + // Given up: the lookup stops at its next step, and a file system call it is blocked in (a + // disk that does not answer) is canceled, so the single-flight slot is not held for good. A + // call that cannot be canceled (a driver that never returns) is a known limit. + pending->abandoned.store(true, std::memory_order_release); + if (pending->thread != nullptr) CancelSynchronousIo(pending->thread); return ShadowReadResult::kAbsent; + } if (pending->result != ShadowReadResult::kOk) return pending->result; answer = std::move(pending->answer); } diff --git a/apps/desktop/src/main/computer-use-windows-shadow-reader.test.ts b/apps/desktop/src/main/computer-use-windows-shadow-reader.test.ts index 31b38e811..ae0c36c17 100644 --- a/apps/desktop/src/main/computer-use-windows-shadow-reader.test.ts +++ b/apps/desktop/src/main/computer-use-windows-shadow-reader.test.ts @@ -226,13 +226,13 @@ describe('Windows UI Automation source only reads', () => { // The value of a pattern is read in exactly one place, and nothing is set, invoked or moved. expect(source.match(/get_CurrentValue/gu)?.length).toBe(1); expect(code(source)).not.toMatch( - /SetValue|Invoke\(|->Select|Toggle|Expand|Collapse|SetFocus|ScrollIntoView|->Move|Resize|->Close|SendInput|SendMessage|PostMessage|keybd_event|mouse_event|SetCursorPos|SetForegroundWindow|SetWindowPos|ShellExecute|CreateProcess|WriteProcessMemory|OpenClipboard|RegisterHotKey/u, + /SetValue|Invoke\(|->Select|Toggle|->Expand|->Collapse|SetFocus|ScrollIntoView|->Move|Resize|->Close|SendInput|SendMessage|PostMessage|keybd_event|mouse_event|SetCursorPos|SetForegroundWindow|SetWindowPos|ShellExecute|CreateProcess|WriteProcessMemory|OpenClipboard|RegisterHotKey/u, ); // The only Win32 calls: strings, the process that owns a window, and COM creation. const win32 = new Set( [ ...source.matchAll( - /(?:])(CoCreateInstance|NormalizeString|WideCharToMultiByte|MultiByteToWideChar|GetLastError|Sys\w+|Variant\w+|OpenProcess|QueryFullProcessImageNameW|CloseHandle|GetWindowThreadProcessId|FindWindow\w*|EnumWindows|GetForegroundWindow|SHCreateItemWithParent|ILFree|CoTaskMemFree|GetAncestor|SH\w+|Shell\w+)\(/gu, + /(?:])(CoCreateInstance|NormalizeString|WideCharToMultiByte|MultiByteToWideChar|GetLastError|Sys\w+|Variant\w+|OpenProcess|QueryFullProcessImageNameW|CloseHandle|GetWindowThreadProcessId|FindWindow\w*|EnumWindows|GetForegroundWindow|SHCreateItemWithParent|ILFree|CoTaskMemFree|GetAncestor|FindFirstFileExW|FindNextFileW|FindClose|CompareStringOrdinal|GetDriveTypeW|GetFileAttributesW|CancelSynchronousIo|DuplicateHandle|SH\w+|Shell\w+)\(/gu, ), ].map((match) => match[1]!), ); @@ -253,8 +253,51 @@ describe('Windows UI Automation source only reads', () => { 'ILFree', 'CoTaskMemFree', 'GetAncestor', + // the folder's path and a listing of its entries in this process (read-only) + 'SHGetIDListFromObject', + 'SHGetPathFromIDListEx', + 'SHGetKnownFolderPath', + 'SHCreateItemFromIDList', + 'SHCreateItemFromParsingName', + 'FindFirstFileExW', + 'FindNextFileW', + 'FindClose', + 'CompareStringOrdinal', + 'GetDriveTypeW', + 'GetFileAttributesW', + 'CancelSynchronousIo', + 'DuplicateHandle', // the shell lookup of the item path (functions of this file, not Win32 calls) 'ShellItemPathByName', + 'ShellLookupPending', + 'ShellNameEqualsOrdinal', + 'ShellDisplayNameUsable', + 'MatchShellDisplayName', + 'ShellFolderMayBeListed', + 'ShellDriveIsLocalDisk', + 'ShellReparseIsLink', + 'ShellMountTargetPath', + 'ShellDirectoryWithSeparator', + 'ShellIniValueIsSafe', + 'ShellDriveLetterIsLocal', + 'ShellMountPointTarget', + 'ShellReadDesktopIni', + 'ShellCandidateDirectoryIni', + 'ShellReparseIsMountPoint', + 'ShellMountPointSubstituteName', + 'ShellLocalDriveLetterOfSubstituteName', + 'ShellIniValueIsRisky', + 'ShellDesktopIniTextIsRiskyForShellName', + 'ShellDesktopIniTextHasLocalizedFileNames', + 'FileHandle', + 'ShellLookupMustStop', + 'ShellPathHasLink', + 'ShellEntryNeedsShellName', + 'ShellDesktopIniLocalizesFileNames', + 'ShellResolvedNameIsTheCandidate', + 'ShellLeafName', + 'ShellFolderHasLocalizedFileNames', + 'ShellPathWithoutTrailingSeparator', 'ShellLookupRunning', 'InterlockShellLookupRunning', ]); @@ -309,6 +352,28 @@ describe('Windows UI Automation source only reads', () => { 'GetLastError', 'IID_PPV_ARGS', 'ILFree', + 'CompareStringOrdinal', + 'FindClose', + 'FindFirstFileExW', + 'FindNextFileW', + 'SHGetIDListFromObject', + 'SHGetPathFromIDListEx', + 'SHGetKnownFolderPath', + 'SHCreateItemFromIDList', + 'SHCreateItemFromParsingName', + 'GetDriveTypeW', + 'GetFileAttributesW', + 'CancelSynchronousIo', + 'DuplicateHandle', + 'GetCurrentProcess', + 'GetCurrentThread', + 'GetVolumePathNameW', + 'CreateFileW', + 'GetFileInformationByHandle', + 'ReadFile', + 'DeviceIoControl', + 'SetLastError', + 'ILIsEmpty', 'NormalizeString', 'OpenProcess', 'QueryFullProcessImageNameW', @@ -332,6 +397,7 @@ describe('Windows UI Automation source only reads', () => { 'CoreWindowProcessPath', 'Create', 'DocumentUrl', + 'FindHandle', 'FirstChild', 'Focused', 'Get', @@ -350,6 +416,38 @@ describe('Windows UI Automation source only reads', () => { 'ShadowTextFromUtf16', 'ShadowUnitsTooLong', 'ShellItemPathByName', + 'ShellNameEqualsOrdinal', + 'ShellDisplayNameUsable', + 'MatchShellDisplayName', + 'ShellFolderMayBeListed', + 'ShellDriveIsLocalDisk', + 'ShellReparseIsLink', + 'ShellMountTargetPath', + 'ShellDirectoryWithSeparator', + 'ShellIniValueIsSafe', + 'ShellDriveLetterIsLocal', + 'ShellMountPointTarget', + 'ShellReadDesktopIni', + 'ShellCandidateDirectoryIni', + 'ShellReparseIsMountPoint', + 'ShellMountPointSubstituteName', + 'ShellLocalDriveLetterOfSubstituteName', + 'ShellIniValueIsRisky', + 'ShellDesktopIniTextIsRiskyForShellName', + 'ShellDesktopIniTextHasLocalizedFileNames', + 'FileHandle', + 'ShellLookupMustStop', + 'ShellPathHasLink', + 'ShellEntryNeedsShellName', + 'ShellDesktopIniLocalizesFileNames', + 'ShellResolvedNameIsTheCandidate', + 'ShellLeafName', + 'ShellFolderHasLocalizedFileNames', + 'ShellPathWithoutTrailingSeparator', + 'AbsoluteIdList', + 'ComApartment', + 'Finish', + 'ShellLookupPending', 'ShellLookupRunning', 'InterlockShellLookupRunning', 'Text', @@ -461,29 +559,201 @@ describe('Windows UI Automation source only reads', () => { 'ShellItemPathByName(HWND tab', '} // namespace windows_uia_source_detail', ); - // A failed read of any item (the item, the shell item, its display name) is a failed lookup: it - // leaves the uniqueness of the name unknown. None of them is skipped. - const items = between( + const compact = body.replace(/\s+/gu, ' '); + // The folder is listed in THIS process: no walk over the view's items (IFolderView2::Item / + // ItemCount) and no per-item shell object are left. + expect(code(body)).not.toMatch( + /ItemCount|folder_view\.Get\(\)->Item\(|SHCreateItemWithParent|kShellLookupMaxItems/u, + ); + const listing = between(body, 'FindFirstFileExW(', 'MatchShellDisplayName('); + expect(listing).toContain('FindExInfoBasic'); + expect(listing).toContain('FindExSearchNameMatch'); + expect(listing).toContain('FIND_FIRST_EX_LARGE_FETCH'); + // A failed read of the listing is a failed lookup (the uniqueness is unknown), never a skipped entry. + expect(listing).toContain('return ShadowReadResult::kFailed;'); + // The whole folder is listed once (the display name does not narrow the listing). + expect(code(body)).not.toContain('pattern.append(display_name)'); + expect(body).toContain("pattern.push_back(L'*');"); + // The folder's path is asked for once; a folder with no path of the file system is not known. + expect((code(body).match(/SHGetPathFromIDListEx\(/gu) ?? []).length).toBe(1); + expect(body).toMatch(/if \(!has_path\) return ShadowReadResult::kAbsent;/u); + // No rule about extensions: the SHELL is asked, for every candidate, for the name it shows. + expect(code(body)).not.toMatch(/SHGetSetSettings|SSF_SHOWEXTENSIONS|extensions_shown/u); + const names = between( body, - 'for (int index = 0; index < items; ++index) {', - 'if (matched.Get() == nullptr)', + 'for (ShellNameCandidate &candidate : candidates) {', + 'MatchShellDisplayName(', + ); + expect(names).toContain('SHCreateItemFromParsingName('); + expect(names).toContain('GetDisplayName(SIGDN_FILESYSPATH, &resolved)'); + expect(names).toContain('GetDisplayName(SIGDN_NORMALDISPLAY, &shown)'); + expect(body).toContain( + 'MatchShellDisplayName(display_name, candidates, ShellNameEqualsOrdinal)', + ); + // A name is known ONLY when the path resolved, resolved to the candidate itself (not to the entry + // Win32 normalizes a trailing dot or space to), and the shell gave its name: one assignment, in + // that branch and nowhere else. + expect((code(source).match(/shell_name_known = true/gu) ?? []).length).toBe(1); + expect(code(names).replace(/\s+/gu, ' ')).toMatch( + /if \(SUCCEEDED\(SHCreateItemFromParsingName\([^{}]*ShellResolvedNameIsTheCandidate\(candidate\.name, ShellLeafName\(resolved\), ShellNameEqualsOrdinal\) && SUCCEEDED\(item\.Get\(\)->GetDisplayName\(SIGDN_NORMALDISPLAY, &shown\)\) && shown != nullptr\) \{ candidate\.shell_name = shown; candidate\.shell_name_known = true; \}/u, + ); + // Where it may look is decided BEFORE the first listing (and its file system call): the root of the + // desktop namespace (an empty id list), a slow folder, and then a plain local directory only. + const find = body.indexOf('FindFirstFileExW('); + for (const guard of [ + 'ILIsEmpty(folder_id.id)', + 'SFGAO_ISSLOW', + 'ShellFolderMayBeListed(folder_path', + ]) { + expect(body.indexOf(guard), guard).toBeGreaterThanOrEqual(0); + expect(body.indexOf(guard), guard).toBeLessThan(find); + } + // A slow folder is refused on the shell's own attribute, before any listing. + expect(body).toContain('(folder_attributes & SFGAO_ISSLOW) != 0)'); + expect(body).toContain( + 'FAILED(folder_item.Get()->GetAttributes(SFGAO_ISSLOW, &folder_attributes))', + ); + // Every shell call on the FOLDER (id list, slow attribute, path) is preceded by the stop check. + for (const call of [ + 'SHGetIDListFromObject(', + 'SHCreateItemFromIDList(', + 'SHGetPathFromIDListEx(', + ]) { + const at = compact.indexOf(call); + expect(at, call).toBeGreaterThan(0); + expect(compact.slice(Math.max(0, at - 320), at), call).toContain( + 'ShellLookupMustStop(limits, &stopped)', + ); + } + // The place check comes before the desktop.ini check, and both before the listing; the caller turns + // either answer into "not known". + expect(body.indexOf('ShellFolderMayBeListed(folder_path')).toBeLessThan( + body.indexOf('ShellFolderHasLocalizedFileNames(folder_path'), + ); + expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path')).toBeLessThan(find); + expect(compact).toContain( + 'if (!ShellFolderMayBeListed(folder_path, limits, &stopped)) return stopped;', + ); + expect(compact).toContain( + 'if (ShellFolderHasLocalizedFileNames(folder_path, limits, &stopped)) return stopped;', + ); + // NO new I/O after the caller gave up or the deadline passed: asked before the place check, the + // desktop.ini, the first listing and each candidate's shell name. + for (const step of [ + 'if (!ShellFolderMayBeListed(folder_path, limits, &stopped))', + 'if (ShellFolderHasLocalizedFileNames(folder_path, limits, &stopped))', + 'find.handle = FindFirstFileExW(', + 'SHCreateItemFromParsingName(', + ]) { + const at = compact.indexOf(step); + expect(at, step).toBeGreaterThan(0); + expect(compact.slice(Math.max(0, at - 230), at), step).toMatch( + /if \(ShellLookupMustStop\(limits, &stopped\)\) return stopped;(?: if \(candidate\.is_link\) continue;)?(?: ComRef item; PWSTR shown = nullptr; PWSTR resolved = nullptr;)? ?(?:stopped = ShadowReadResult::kAbsent; )?(?:if \(SUCCEEDED\()?$/u, + ); + } + const stop = between( + source, + 'ShellLookupMustStop(const ShellLookupLimits', + 'Whether any part of', + ); + expect(stop).toContain('if (!limits.still_valid())'); + expect(stop).toContain('limits.now_ns() > limits.deadline_ns'); + // Which entries need the shell's name is the pure rule of the matcher, fed the attributes. + expect(compact).toContain('ShellEntryNeedsShellName(found, is_directory,'); + expect(compact).toContain('(FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)'); + expect(code(body)).not.toMatch(/starts_with_display|FILE_ATTRIBUTE_HIDDEN/u); + // The place check: the volume of the path (not its first three characters), no link on the way, + // the desktop folders asked without verifying, and the drive types, all in one fixed expression. + const guardFunction = between( + source, + 'ShellFolderMayBeListed(const std::wstring &path,', + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', + ); + const guardCompact = guardFunction.replace(/\s+/gu, ' '); + expect(guardFunction.includes("path[0] == L'" + '\\\\' + "'")).toBe(true); + expect(guardCompact).toContain('if (same) return false;'); + expect(guardCompact).toContain('FOLDERID_Desktop, FOLDERID_PublicDesktop'); + expect(guardCompact).toContain( + 'SHGetKnownFolderPath(known, KF_FLAG_DONT_VERIFY, nullptr, &folder)', + ); + expect(guardCompact).toContain('GetVolumePathNameW(path.c_str(), root.data()'); + expect(guardCompact).toContain('GetDriveTypeW(root.c_str())'); + expect(guardCompact).toContain('ShellPathHasLink(path.substr(0, 3), path, limits, result)'); + // Two steps: the drive letter's type first (no I/O: GetDriveTypeW on the first three characters), + // and only then the volume of the path, whose type is checked again. A stop check sits between. + const letter = guardCompact.indexOf('GetDriveTypeW(path.substr(0, 3).c_str())'); + const volume = guardCompact.indexOf('GetVolumePathNameW('); + const second = guardCompact.indexOf('GetDriveTypeW(root.c_str())'); + expect(letter).toBeGreaterThan(0); + expect(letter).toBeLessThan(volume); + expect(volume).toBeLessThan(second); + expect(guardCompact.slice(letter, volume)).toContain('ShellLookupMustStop(limits, result)'); + expect(guardCompact).toContain( + 'if (!ShellDriveIsLocalDisk(GetDriveTypeW(path.substr(0, 3).c_str()))) return false;', + ); + expect(guardCompact).toContain( + 'if (!ShellDriveIsLocalDisk(GetDriveTypeW(root.c_str()))) return false;', + ); + const drives = between(source, 'ShellDriveIsLocalDisk(UINT drive) {', 'Whether any part of'); + expect(drives.replace(/\s+/gu, ' ')).toContain( + 'if (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || drive == DRIVE_NO_ROOT_DIR) return false;', + ); + const link = between( + source, + 'ShellPathHasLink(const std::wstring &root', + 'ShellFolderMayBeListed(', + ); + expect(link).toContain('ShellReparseIsLink(is_reparse, data.dwReserved0)'); + expect(link).toContain('FILE_ATTRIBUTE_REPARSE_POINT'); + expect(link).toContain('if (find.handle == INVALID_HANDLE_VALUE) return true;'); + // A desktop.ini is normal (a user's home has one): only its [LocalizedFileNames] section counts. It is + // opened as the file itself, once, by the shared handle reader (no profile API, no second open by + // path), and the pure rule turns the answers into a verdict. + const localized = between( + source, + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', + 'The path of the item a file browser shows', ); - // (the one `continue` left is for a readable item whose name differs) - expect(code(items).replace('if (!same) continue;', '')).not.toMatch(/\bcontinue;/u); - expect(items.match(/return ShadowReadResult::kFailed;/gu)?.length).toBeGreaterThanOrEqual(3); + expect(localized).toContain('desktop.ini'); + expect(localized.replace(/\s+/gu, ' ')).toContain( + 'return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, opened, open_error, is_reparse_point, content_says_localized);', + ); + // The profile API reopens the file by path (and follows a link): it is not used at all. + expect(code(source)).not.toMatch(/GetPrivateProfile|WritePrivateProfile/u); + expect(code(localized)).not.toMatch(/ifstream|fopen|_wfopen/u); + // The guards decide before any listing of ours. + expect(guardFunction).not.toContain('FindFirstFileExW(part'); + // A folder is a directory of the file system. + expect(body).toContain('FILE_ATTRIBUTE_DIRECTORY'); // Explorer's calls have no timeout: the lookup runs on its own thread, the caller waits a bounded // time, an unanswered lookup is abandoned, and no second one starts while one is stuck. const item = between(source, 'ShadowReadResult ItemPath(', 'private:'); expect(item).toContain('running.exchange(true)) return ShadowReadResult::kAbsent;'); expect(item).toContain('}).detach();'); expect(item).toContain('wait_for(lock, windows_uia_source_detail::kShellLookupWait'); - expect(item).toMatch(/\)\)\s*return ShadowReadResult::kAbsent;/u); + // A caller that gives up cancels the file system call the lookup's thread may be blocked in. + expect(item).toContain('pending->abandoned.store(true, std::memory_order_release);'); + expect(item).toContain('CancelSynchronousIo(pending->thread)'); + expect(item).toContain('DuplicateHandle(GetCurrentProcess(), GetCurrentThread()'); + expect(item).toMatch(/return !pending->abandoned\.load\(std::memory_order_acquire\)/u); expect(item.indexOf('running.exchange(true)')).toBeLessThan(item.indexOf('std::thread(')); // Nothing of this object is used on that thread (it may outlive the object). const thread = between(item, 'std::thread([', '}).detach();'); expect(thread).not.toMatch(/\bthis\b|window_|still_valid_|walker_|automation_/u); expect(thread).toContain('CoInitializeEx(nullptr, COINIT_MULTITHREADED)'); - expect(thread).toContain('running.store(false);'); + // No exception ends the process: the lookup is wrapped, and the slot and the waiting caller are + // released by a guard that runs whatever happened. + expect(thread).toMatch( + /try \{[\s\S]*ShellItemPathByName\([\s\S]*\} catch \(\.\.\.\) \{\s*\/\/[^\n]*\n\s*result = ShadowReadResult::kFailed;/u, + ); + expect(thread).toContain('struct ComApartment'); + expect(thread.indexOf('struct ComApartment')).toBeLessThan( + thread.indexOf('ShellItemPathByName('), + ); + expect(thread).toMatch( + /struct Finish \{[\s\S]*~Finish\(\) \{\s*running\.store\(false\);\s*pending->ready\.notify_one\(\);/u, + ); + expect(thread.indexOf('} catch (...)')).toBeLessThan(thread.indexOf('struct Finish')); expect(source).toContain( 'static std::atomic *const running = new std::atomic(false);', ); @@ -498,24 +768,247 @@ describe('Windows UI Automation source only reads', () => { '} // namespace windows_uia_source_detail', ); // Windows and views are asked for; nothing is navigated, selected, invoked, renamed or changed. - expect(code(body)).not.toMatch( + expect(code(body).replaceAll('= delete', '')).not.toMatch( /navigate|SelectItem|SelectAndPositionItems|InvokeCommand|InvokeVerb|DoVerb|Execute|Refresh|Rename|Delete|SetCurrent|SetViewModeAndIconSize|ShellExecute|SetFocus/iu, ); - // Its own limits, because it is one gated call: the action, a deadline, and a cap on the items. - expect(source).toContain('constexpr int kShellLookupMaxItems = 512;'); + // Its own limits, because it is one gated call: the action, a deadline, and a cap on the candidates. + expect(source).not.toContain('kShellLookupMaxItems'); + expect(source).toContain('#include "computer_use_windows_shell_name_match.h"'); + expect(source).toContain('kShellMaxCandidates'); expect(source).toContain("constexpr std::uint64_t kShellLookupBudgetNs = 60'000'000;"); - // Both the walk over the shell windows and the walk over the items stop on a cancel. + // The walk over the shell windows checks the cancel itself; the listing of the entries, the place + // check, the links on the way and the desktop.ini all go through the one helper (cancel and deadline + // before each file system call). expect( body.match(/if \(!limits\.still_valid\(\)\) return ShadowReadResult::kStopped;/gu)?.length, - ).toBe(2); + ).toBe(1); + const entriesLoop = between( + body, + 'for (;;) {', + 'const std::wstring_view found(entry.cFileName);', + ); + expect(entriesLoop).toContain('if (ShellLookupMustStop(limits, &stopped)) return stopped;'); + const placeCheck = between( + source, + 'ShellPathHasLink(const std::wstring &root', + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', + ); + const stops = (placeCheck.match(/ShellLookupMustStop\(limits, result\)/gu) ?? []).length; + expect(stops).toBeGreaterThanOrEqual(3); // each part of the path, the volume, each known folder + const hasLink = between( + source, + 'ShellPathHasLink(const std::wstring &root', + 'ShellFolderMayBeListed(', + ); + expect(hasLink.indexOf('ShellLookupMustStop(limits, result)')).toBeLessThan( + hasLink.indexOf('FindFirstFileExW('), + ); + const mayBe = between( + source, + 'ShellFolderMayBeListed(const std::wstring &path,', + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', + ); + expect(mayBe.indexOf('ShellLookupMustStop(limits, result)')).toBeLessThan( + mayBe.indexOf('GetVolumePathNameW('), + ); + const known = mayBe.indexOf('SHGetKnownFolderPath('); + expect(mayBe.slice(Math.max(0, known - 200), known)).toContain( + 'ShellLookupMustStop(limits, result)', + ); + // The order of the three steps: the drive letter's type, then every part of the path from that drive's + // root, then the volume (a link in the middle must be refused before the volume is asked about). + const mayBeCompact = mayBe.replace(/\s+/gu, ' '); + const letterStep = mayBeCompact.indexOf('GetDriveTypeW(path.substr(0, 3).c_str())'); + const linkStep = mayBeCompact.indexOf( + 'ShellPathHasLink(path.substr(0, 3), path, limits, result)', + ); + const volumeStep = mayBeCompact.indexOf('GetVolumePathNameW('); + expect(letterStep).toBeGreaterThan(0); + expect(linkStep).toBeGreaterThan(letterStep); + expect(volumeStep).toBeGreaterThan(linkStep); + expect(mayBeCompact.indexOf('GetDriveTypeW(root.c_str())')).toBeGreaterThan(volumeStep); + expect(mayBeCompact).toContain( + 'if (ShellPathHasLink(path.substr(0, 3), path, limits, result)) return false;', + ); + expect(mayBeCompact).not.toContain('ShellPathHasLink(root,'); + const ini = between( + source, + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', + 'The path of the item a file browser shows', + ); + const iniCompact = ini.replace(/\s+/gu, ' '); + // The folder's attributes: asked after a stop check, and the desktop.ini is read only in a read-only / + // system folder (what the shell honours). + expect( + ini + .slice(0, ini.indexOf('GetFileAttributesW(')) + .lastIndexOf('ShellLookupMustStop(limits, result)'), + ).toBeGreaterThan(0); + expect(iniCompact).toContain( + '(folder_attributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0', + ); + expect(ini.indexOf('FILE_ATTRIBUTE_READONLY')).toBeLessThan( + ini.indexOf('ShellReadDesktopIni('), + ); + // A known limit, stated where it applies. + expect(source).toContain('(TOCTOU)'); + // The shared reader: ONE open of the file, as the file itself (a link is opened, not followed), then + // the reparse check, the size cap and the read all on that same handle: no second open by path. + const reader = between( + source, + 'ShellReadDesktopIni(const std::wstring &settings', + 'enum class ShellCandidateIni', + ); + const readerCompact = reader.replace(/\s+/gu, ' '); + expect((code(reader).match(/CreateFileW\(/gu) ?? []).length).toBe(1); + expect((code(reader).match(/settings\.c_str\(\)/gu) ?? []).length).toBe(1); + expect(readerCompact).toContain( + 'CreateFileW(settings.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);', + ); + expect(readerCompact).toContain('GetFileInformationByHandle(file.handle, &info)'); + expect(readerCompact).toContain( + 'if ((info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) return ShellIniStatus::kLink;', + ); + expect(readerCompact).toContain( + 'if (size > kShellDesktopIniMaxBytes) return ShellIniStatus::kUnreadable;', + ); + expect(readerCompact).toContain('ReadFile(file.handle, content.data()'); + expect(reader.indexOf('CreateFileW(')).toBeLessThan( + reader.indexOf('GetFileInformationByHandle('), + ); + expect(reader.indexOf('GetFileInformationByHandle(')).toBeLessThan(reader.indexOf('ReadFile(')); + expect(reader.indexOf('FILE_ATTRIBUTE_REPARSE_POINT')).toBeLessThan( + reader.indexOf('ReadFile('), + ); + // The stop check comes before EACH of the three calls (open, handle information, read). + for (const call of ['CreateFileW(', 'GetFileInformationByHandle(', 'ReadFile(']) { + const at = reader.indexOf(call); + expect(at, call).toBeGreaterThan(0); + expect(reader.slice(Math.max(0, at - 320), at), call).toContain( + 'ShellLookupMustStop(limits, result)', + ); + } + // The folder's own desktop.ini goes through the reader; its text is judged by the pure rule. + expect(iniCompact).toContain( + 'ShellReadDesktopIni(settings, limits, result, &open_error, &bytes)', + ); + expect(iniCompact).toContain( + 'ShellDesktopIniTextHasLocalizedFileNames(bytes.data(), bytes.size())', + ); + // A read-only / system CANDIDATE directory: the shell would read ITS desktop.ini to name it (and may + // follow a link, load a module a resource reference names, bind a CLSID), so it is read first by the + // same reader and judged by the pure rule; no file is safe, a link / unreadable / risky one is unknown. + const candidateIni = between( + source, + 'ShellCandidateDirectoryIni(const std::wstring &directory', + 'Whether any part of', + ); + const candidateCompact = candidateIni.replace(/\s+/gu, ' '); + expect(candidateCompact).toContain( + 'ShellReadDesktopIni(ShellDirectoryWithSeparator(directory) + L"desktop.ini"', + ); + expect(candidateCompact).toContain( + 'case ShellIniStatus::kNotThere: return ShellCandidateIni::kSafe;', + ); + expect(candidateCompact).toContain( + 'ShellDesktopIniTextIsRiskyForShellName(bytes.data(), bytes.size())', + ); + expect(candidateCompact).toContain('default: return ShellCandidateIni::kUnknown;'); + // The folder's directory path gets its ONE separator from the pure helper, so that a drive root + // ("C:\\") does not become "C:\\\\" (which no shell call accepts: every candidate would be unknown). + expect(body.replace(/\s+/gu, ' ')).toContain( + 'const std::wstring directory = ShellDirectoryWithSeparator(folder_path);', + ); + expect(code(body)).not.toContain("directory.push_back(L'\\\\')"); + expect(source.replace(/\s+/gu, ' ')).toContain( + 'const std::wstring settings = ShellDirectoryWithSeparator(path) + L"desktop.ini";', + ); + const namesLoop = between( + body, + 'for (ShellNameCandidate &candidate : candidates) {', + 'MatchShellDisplayName(', + ); + const namesCompact = namesLoop.replace(/\s+/gu, ' '); + expect(namesCompact).toContain('if (candidate.renamable_directory) {'); + expect(namesCompact).toContain( + 'ShellCandidateDirectoryIni(directory + candidate.name, limits, &stopped)', + ); + expect(namesCompact).toContain('if (ini == ShellCandidateIni::kUnknown) continue;'); + expect(namesLoop.indexOf('ShellCandidateDirectoryIni(')).toBeLessThan( + namesLoop.indexOf('SHCreateItemFromParsingName('), + ); + expect(body.replace(/\s+/gu, ' ')).toContain( + 'candidate.renamable_directory = is_directory && (entry.dwFileAttributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0;', + ); + // A candidate that is a link: the moment one is found the whole answer is "not known" and nothing + // else is read; only a mount point whose target is a local drive-letter path is let through (and is + // then no link). So no link candidate reaches the loop of shell names: there is nothing to skip there + // (the matcher refuses a link candidate too, as a second line). + expect(namesLoop).not.toContain('is_link'); + const listingCompact = body.replace(/\s+/gu, ' '); + expect(listingCompact).toContain( + 'candidate.is_link = ShellReparseIsLink(is_reparse, entry.dwReserved0);', + ); + expect(listingCompact).toContain( + 'if (!ShellReparseIsMountPoint(is_reparse, entry.dwReserved0)) return ShadowReadResult::kAbsent;', + ); + expect(listingCompact).toContain( + 'ShellMountPointTarget(directory + candidate.name, limits, &stopped)', + ); + expect(listingCompact).toContain('if (target == ShellMountTarget::kStopped) return stopped;'); + expect(listingCompact).toContain( + 'if (target != ShellMountTarget::kLocalDisk) return ShadowReadResult::kAbsent;', + ); + // A mount point is read with FSCTL_GET_REPARSE_POINT on the link itself, stop check before each call, + // and accepted only for a drive-letter target on a local disk. + const mount = between( + source, + 'ShellMountPointTarget(const std::wstring &path', + 'enum class ShellIniStatus', + ); + const mountCompact = mount.replace(/\s+/gu, ' '); + expect(mountCompact).toContain('FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS'); + expect(mountCompact).toContain('DeviceIoControl(link.handle, FSCTL_GET_REPARSE_POINT'); + expect(mountCompact).toContain( + 'ShellMountPointSubstituteName(buffer.data(), returned, &substitute)', + ); + expect(mountCompact).toContain('ShellLocalDriveLetterOfSubstituteName(substitute)'); + // A local drive letter is not enough: every part of the TARGET's path is looked at from the drive's + // root, and none may name another object (no recursion: a link inside the target is refused). + expect(mountCompact).toContain('const std::wstring target = ShellMountTargetPath(substitute);'); + expect(mountCompact).toContain('if (ShellPathHasLink(root, target, limits, result, false))'); + expect(mountCompact.indexOf('ShellDriveLetterIsLocal(letter)')).toBeLessThan( + mountCompact.indexOf('ShellPathHasLink(root, target, limits, result, false)'), + ); + expect(mountCompact).toContain( + 'return *result == ShadowReadResult::kStopped ? ShellMountTarget::kStopped : ShellMountTarget::kNotLocal;', + ); + expect(mountCompact).toContain('ShellDriveLetterIsLocal(letter)'); + for (const call of ['CreateFileW(', 'DeviceIoControl(']) { + const at = mount.indexOf(call); + expect(at, call).toBeGreaterThan(0); + expect(mount.slice(Math.max(0, at - 200), at), call).toContain( + 'ShellLookupMustStop(limits, result)', + ); + } + // The same relaxation on the way: a mount point with a local target is let through; a symbolic link + // and any other target is refused. + const hasLinkCompact = between( + source, + 'ShellPathHasLink(const std::wstring &root', + 'ShellFolderMayBeListed(', + ).replace(/\s+/gu, ' '); + expect(hasLinkCompact).toContain( + 'if (!allow_local_mount_points || !ShellReparseIsMountPoint(is_reparse, data.dwReserved0)) return true;', + ); + expect(hasLinkCompact).toContain( + 'if (ShellMountPointTarget(part, limits, result) != ShellMountTarget::kLocalDisk) return true;', + ); + // More candidates than the cap are not known; so are two entries a name can stand for (the matcher). expect(body).toContain( - 'if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent;', + 'if (candidates.size() >= kShellMaxCandidates) return ShadowReadResult::kAbsent;', ); - expect(body).toContain('items > kShellLookupMaxItems) return ShadowReadResult::kAbsent;'); - // Two items with the same display name are not known; so is an item without a file-system path. - expect(body).toContain('if (matched.Get() != nullptr) return ShadowReadResult::kAbsent;'); - expect(body).toContain('SIGDN_FILESYSPATH'); - expect(body).toContain('SFGAO_STREAM'); + expect(body).toContain('if (index == kNoShellMatch) return ShadowReadResult::kAbsent;'); // The path is a string like any other: UTF-8, bound in bytes, never emptied. const item = between(source, 'ShadowReadResult ItemPath(', 'private:'); expect(item).toContain('ShadowTextFromUtf16(answer.path, false, maximum_bytes, path)'); @@ -523,6 +1016,75 @@ describe('Windows UI Automation source only reads', () => { expect((code(source).match(/ShellItemPathByName\(/gu) ?? []).length).toBe(2); }); + it('asks the shell only about a ListItem of the shell item view (S5-c-2 PR-3)', () => { + const capture = between( + reader, + 'void CaptureItem(ShadowUiaCapture *capture) {', + 'ShadowUiaSource &source_;', + ); + const compact = capture.replace(/\s+/gu, ' '); + expect(compact).toContain('class_name == "shelldll_defview" || class_name == "uiitemsview"'); + expect(compact).toContain('capture->chain_reached_root && in_item_view &&'); + // The scan for the view stops at the tab, like the scan for the tab itself. + expect(compact).toMatch( + /if \(class_name == "shelltabwindowclass"\) \{ tab = chain_elements_\[at\]; break; \}/u, + ); + expect(harness).toContain('winreader-list-item-outside-the-item-view-is-not-asked'); + expect(harness).toContain('winreader-list-item-in-a-defview-is-asked'); + expect(harness).toContain('winreader-list-item-in-the-item-view-is-asked'); + }); + + it('matches a display name to the real entry in a portable header the harness runs (S5-c-2 PR-3)', () => { + const matcher = read('computer_use_windows_shell_name_match.h'); + // Pure: no platform header, no clock, no I/O, no thread. + expect(code(matcher)).not.toMatch( + /#include\s*<(windows|shlobj|shobjidl|ole2|chrono|thread|mutex|atomic|fstream|filesystem|iostream)/u, + ); + expect(code(matcher)).not.toMatch(/FindFirstFile|CompareString|GetLastError|CoCreate/u); + expect(matcher).toContain('constexpr std::size_t kShellMaxCandidates = 64;'); + // No rule about extensions lives in the matcher: the shell's own name for each candidate is compared. + expect(code(matcher)).not.toMatch(/lnk|appref|extension|\bstem\b/u); + // Two entries a name can stand for are "not known"; so are more candidates than the cap, and a + // candidate whose shell name could not be read. + expect(matcher).toContain('if (found != kNoShellMatch) return kNoShellMatch;'); + expect(matcher).toContain('candidates.size() > kShellMaxCandidates'); + expect(matcher).toContain('if (!candidates[i].shell_name_known) return kNoShellMatch;'); + // The pure rules: which entries need the shell's name, what a desktop.ini means, and whether a + // path resolved to the candidate itself. + expect(matcher).toContain('ShellEntryNeedsShellName('); + expect(matcher).toContain('ShellDesktopIniLocalizesFileNames('); + expect(matcher).toContain('ShellResolvedNameIsTheCandidate('); + expect(matcher).toContain("candidate_name.back() == L'.' || candidate_name.back() == L' '"); + // The intro names both kinds of candidate. + expect(matcher).toContain('directories that are read-only or system'); + // The real source compares as the file system does, and the harness runs the table. + expect(source).toContain('CompareStringOrdinal('); + expect(harness).toContain('computer_use_windows_shell_name_match.h'); + expect(harness).toContain('CheckWindowsShellNameMatch()'); + for (const id of [ + 'shellname-lnk-collides-with-same-name', + 'shellname-pdf-exe-hidden-is-the-second', + 'shellname-never-shown-extension-is-the-shells-answer', + 'shellname-x-and-x-website-collide', + 'shellname-folder-with-a-dot-is-not-a-stem', + 'shellname-unreadable-after-the-match', + 'shellname-file-name-is-not-the-display-name', + 'shellname-over-the-cap-is-unknown', + 'shellneeds-read-only-or-system-directory-is-a-candidate', + 'shellneeds-ordinary-directory-is-not-a-candidate', + 'shellneeds-dot-is-never-a-candidate', + 'desktopini-link-is-a-yes-without-reading', + 'desktopini-with-the-section-is-a-yes', + 'initext-utf16le-with-a-entry', + 'initext-ansi-with-an-entry', + 'initext-comments-only-section', + 'initext-empty-section', + 'initext-header-that-never-closes', + 'resolved-trailing-dot-resolves-to-another-entry', + ]) + expect(harness, id).toContain(id); + }); + it('is included by the host, and the reader is reached only through the scheduler (S5-b-2b)', () => { expect(host).toContain('#include "computer_use_windows_uia_source.h"'); // The host names neither the reader, the classifier nor the gate: the scheduler owns them. diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index ad55436b2..e671c3dd2 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -664,6 +664,8 @@ computer_start がここで返る → { sessionId, state, stopReason, mode, roun **S5-c-2 PR-4(集計のみ)**: `ShadowRecord::V2IsStricter` の集計でネイティブの Document(Notepad の本文など、web ウィンドウではない Document で origin が unknown になった記録)を別に扱う。`BuildWindowsShadowFacts` がその記録に `kShadowGapNativeDocument`(1u << 15)を付け、`V2IsStricter()` から除いて `NativeDocumentGap()` で数える。facts と判定は変えない。 +**S5-c-2 PR-3(Explorer の実ファイル名。2026-10-17。動作不変)**: 上の S5-c (2) の「Explorer の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。**照合は規則ではなく、シェルが実際に出す表示名で行う**(拡張子を隠す設定、未登録の拡張子、`.lnk` / `.website`、ドットを含むフォルダ名、ローカライズ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない)。`ShellItemPathByName` は view を特定した後、次の順で進める。(1) **入口で外す場所(どれも列挙の前。各 I/O の前に取消しと期限を確かめる)**: ID リストが空(デスクトップ名前空間のルート。ビューは Public\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所、`FOLDERID_Desktop` / `FOLDERID_PublicDesktop` のパス(`KF_FLAG_DONT_VERIFY` で尋ねる。実機では未確認の保険)。ネットワークは 3 段階で外す: ①ドライブ文字の種類(`GetDriveTypeW` を先頭 3 文字に。I/O なし。応答しない SMB 共有に割り当てたドライブには触れない)、②パスの各要素をドライブのルートから見て、symbolic link は拒否し、mount point(ジャンクション)は `FSCTL_GET_REPARSE_POINT` で行き先を読み、ドライブ文字のローカルディスクを指し、かつ行き先のパスの各要素をドライブのルートから見てリンク(symlink、別のジャンクションを含む)が 1 つも無いときだけ許す(行き先の途中が UNC への symlink でありうるため。再帰はせず深さ 1)(`\??\Volume{...}` のようなボリューム指定は不明のまま拒否)、③その後に `GetVolumePathNameW` のボリュームを同じ判定にかける(マウントしたボリュームのルートそのものはパスより長くなるので判定なし)。リンクの判定は `IsReparseTagNameSurrogate`(タグの bit 29)で、クラウドファイルのタグには立たないので許される。経路の途中のジャンクションにも同じ緩和を当てた(ジャンクションの行き先はドライブ文字のパスかボリュームで UNC は指せず、行き先のパスの各要素も上のとおり I/O して確かめる)。desktop.ini に `[LocalizedFileNames]` があるフォルダは判定なし: シェルは読み取り専用か system のフォルダでだけ desktop.ini を使う(実機で確認)ので、そのときだけ読む。ファイルは `CreateFileW(FILE_FLAG_OPEN_REPARSE_POINT)` で 1 回だけ開き、同じハンドルで reparse の有無(種類を問わず「あり」)、大きさの上限(64 KB)、読み取りまでを行い、UTF-16(LE / BE)か ANSI を自前のパーサで判定する(`GetPrivateProfileSectionW` は使わない。パスで開き直してリンクをたどるため)。セクションが空かは、次のセクションまでに空行とコメント以外の行があるかで決め、判定できないものは「あり」とする。(2) フォルダを `FindFirstFileExW(フォルダ\*)` で 1 回列挙する(メタデータだけ。時間は `kShellLookupBudgetNs`)。シェル表示名を取る候補は、ファイル名が表示名で始まるものと、読み取り専用または system のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこのフォルダだけ)の合計で、64 件を超えたら切り捨てずに全体を判定なしにする。隠しファイルは外さない。新しい列挙は隠し項目も候補に含めるので、Explorer に表示されない項目にも I/O が発生しうる。ただし、その I/O は上の確認を通ったローカルのものだけ。候補がリンク(symlink、行き先がローカルでない mount point)だと、見つけた時点で全体を判定なしにし、残りの I/O は省く。(3) 候補ごとに、解決先(`SIGDN_FILESYSPATH` の末尾)が候補名と一致するときだけ、`SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取る(末尾がドットや空白の名前は Win32 が別のエントリに正規化するので採らない)。読み取り専用 / system の候補ディレクトリは、シェルがその desktop.ini を読んで名前を決める(リンクをたどる、リソース参照のモジュールを読み込む、CLSID を bind する)ので、先に同じ方式(ハンドルで 1 回)で読み、リンク、読めない、判定できない、`[.ShellClassInfo]` の `CLSID` / `CLSID2` / `UICLSID` がある、`LocalizedResourceName` / `IconResource` / `IconFile` / `InfoTip`(と Logo など)の値が許可リストに無い、のどれかなら、シェルには尋ねず unknown にする。許可するのは、外側の引用符(単一・二重)と空白を外した値が (a) `\ / : % @ ,` を含まないプレーンテキスト、または (b) `%SystemRoot%` / `%windir%` / `%ProgramFiles%` / `%ProgramFiles(x86)%` / `%CommonProgramFiles%` で始まる(先頭に `@`、末尾に `,-N` か `,N` があってもよい)パスで、`/`、連続した `\`、`..`、ほかの `%` を含まないもの、の 2 つだけ。`X:\` で始まる絶対パスは、途中が UNC への symbolic link でありうるので許可しない。ただし環境変数の展開先のフォルダにリンクがある可能性は残る(**既知の限界**。確認していない)。portable の `MatchShellDisplayName` は、表示名がちょうど 1 件に一致し、候補にリンクも読めないものも無いときだけ、その実パスを返す。拡張子の表示設定は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る。(4) lookup のスレッドは同期のファイルシステム I/O を発行する。取消しと期限は、新しい I/O を始める直前ごと(入口のシェル呼び出し、場所の判定、リンク、desktop.ini の open / 情報 / 読み取り、最初の列挙、各候補)に確かめる。待ちが切れた側は lookup に放棄を知らせ、スレッドの複製ハンドルへ `CancelSynchronousIo` を送ってブロックした呼び出しを打ち切る。ドライバが戻らない場合は取り消せず、そのとき lookup の枠が埋まり続けて以降の lookup は判定なしになる(V1 に任せる)のは**既知の限界**。確認してから列挙や desktop.ini の open までの間に、経路の途中の要素がリンクへ差し替えられる競合(TOCTOU)も残る(`FILE_FLAG_OPEN_REPARSE_POINT` が守るのは末尾の desktop.ini だけ)。これは攻撃者が同じユーザーでローカルの操作をできる場合に限られる残余のリスクで、同じユーザーのローカルの操作に限られ、候補は FILESYSPATH の末尾で自分自身と確かめられ、dispatch 直前に再評価される(PR-5)ので許容する。lookup のスレッドは例外で process を落とさず、COM の解放、枠の解放、待っている側への通知は必ず通る。UIA 側では、ListItem が shell の項目 view(`SHELLDLL_DefView` または `UIItemsView`)の中にあるときだけ尋ね、プレビューペインや検索候補の ListItem は実パス無し(kIncomplete)とする。(5) **常に判定なしになる場所**(fail closed の代償)。開発機で、実フォルダを読み取りだけで確かめた結果: Documents、Downloads、C:\ProgramData、C:\Program Files は判定できる(Documents の Hidden + System のジャンクション 3 個、ProgramData の 5 個は行き先がローカルなので通る)。判定なしになるのは、ユーザーのホーム(互換用のジャンクションのうち Recent は desktop.ini を開けず unknown になる。ほかの 21 件は通る)、C:\Windows(Downloaded Program Files、Fonts、Offline Web Pages の 3 つが `CLSID` を持つ名前空間拡張のフォルダで、方針どおり unknown)、C:\Users(All Users が symbolic link)。C:\\ のルートは判定できる(読み取りのみで実測。Windows、Program Files が取れた。ルートでは directory の区切りを二重にしていたため全候補が unknown になる不具合があり、`ShellDirectoryWithSeparator` で直した)。OneDrive の desktop.ini がクラウドファイル(reparse point)なら、種類を問わず unknown になる(この機で OneDrive は通った)。アクセス拒否で desktop.ini が開けない候補を「シェルも読めないので安全」とみなす緩和は、fail closed を保つため行っていない。(6) 実測値の読み方: 以前の「ホームで 22 件すべて表示名が取れた」は、リンクの候補を区別する前の数字で、現在の規則では上記のとおり。時間は自作の一時フォルダで、項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 10〜14 ms 程度(リンクの確認と候補の desktop.ini の確認を含む。最大 21 ms)で、項目数への依存は見えなかった。(7) (1) の旧記述との食い違いの整理: 以前は「symbolic link / mount point を一律に外す」と書き、その結果ホームなどで常に判定なしになることを (7) に書いていたが、現在は mount point の行き先がローカルのドライブ文字パスなら通す(symbolic link と行き先が不明のものは外す)。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実機で確かめたもの: `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(UTF-16 と ANSI、読み取り専用のフォルダで判定なし、通常のフォルダでは無視される)、コメントだけの節(取れる)、desktop.ini で別名になる読み取り専用フォルダ、末尾がドットの名前、ジャンクション(行き先がローカルなら通る)、UNC のリソース参照や CLSID を持つ候補ディレクトリ(判定なし)。desktop.ini の symbolic link は権限がなく作れなかったので、harness と source 契約のみで固定している。拡張子を隠す設定と Desktop / Public Desktop は実機で未確認。 + **C5 の分割と初回の有効化範囲(2026-10-16。所有者の判断)**: C5 は 2 つの PR に分ける。**C5-1(有効化の準備。本番では不活性)**: (1) 「承認経路が有効」の述語 `computerUseApprovalPathEnabled`(`computer-use-eligibility.ts`)。macOS、addon が承認の面を export、handshake が classifier 2 を attest、の 3 つに加えて、**session の mode と route**を見る: session は route v2 かつ実効 mode が `v2-denylist` に束縛されているときだけ開く。route v1 の session には、build が何を attest しても承認は無い。**束縛の無い session(route 無しで開始、verified close の後、開始に失敗した後)は閉じる**(fail closed。C3 以前の「束縛が無ければ attest だけで決まる」規則は廃止した)。述語は session について問うだけで、session 無しの問い合わせは無い(host と controller の引数は必須)。host(`approvalPathEnabled(sessionId)`)と controller(`nativeApprovalPathOpen(sessionId)`)は同じ述語を session 単位で使う。(2) `classifierV2Attested` の本番配線 `computerUseClassifierV2Attested`: Main にコンパイルされた eligibility が `v2-denylist` かつ `classifierVersion` = 2 で、gate が開いているときだけ true(v2 の Main は manifest と attest が自分の宣言と同一でなければ gate が閉じるので、gate が開いていれば native がその版を attest している)。環境変数もフラグも読まない。(3) `effectiveEligibilityMode` の本番配線: `index.ts`(host)と `ipc.ts`(controller)に `computerUseEligibilityWiring` を渡す。**版が 1 のままなので、どの build でも attest は 1、`classifierV2Attested` は false、host が native に渡す route は常に v1 である**(v2 の build と V2 フラグで agent セッションが route v2 を求めても、host は attest がなければ v1 を渡す)。したがって C4-2c-2 の ADR (7) の門は、Main 側では今回の配線の後も閉じている。**C5 より前に route v2 を本番で配線しない**前提は、「版を 2 に上げる PR(C5-2)だけが route v2 を native に届かせる」と読み替える。(4) 観測 metadata の高影響: `highImpact`(native の facts と Main の投影の和)と `highImpactNative`(native の facts だけ)は既にあり、`splitHighImpact` が「Main の投影だけが見た分」を明示する。**3-A**: Main の投影だけが見た高影響は、判定を強める(止める)方向にだけ使い、承認経路が開いていても緩めない(緩められるのは `highImpactNative` の分だけ)。**C5-2(有効化。#777 の実測の後)**: 版を 2 に上げる(`eligibility.json` を mode ごとの版表にする)、レーン上の承認測定の時間(1 回の AX が最大約 6 秒、Cancel ack 1 秒)を #777 で実測して `AXUIElementSetMessagingTimeout` の採否かレーン保持時間の上限を決める。**初回の有効化範囲は 2-A**: 承認可能にするのは `high_impact_confirmation` と `supervised_action` だけで、E2 / E3 は C6 まで拒否のままにする。**`eligibility.json` を mode ごとの版表にする構造変更は C5-2 に回す**: 版表の消費者(ビルドスクリプト、生成ヘッダ、約 12 本の契約テスト)が多く、C5-1 には読み手がいない。また表の形(mode ごとに ruleset / classifier / lexicon を分けるか、classifier だけか)は C5-2 で版を上げるときにしか決まらない。 **S6 の前提(2026-10-14)**: UWP / `ApplicationFrameHost` の identity と束縛(UIA によるコンテンツ HWND の解決、package family name と publisher の検証、native identity digest への束縛、package に限るパス比較の免除、プロセス入れ替わりの検出)は、設計を ADR に追記して敵対的レビューを受けるまで実装しない。レビューが出るまでは、`ApplicationFrameHost` 配下は従来どおり拒否する。Windows 11 の既定 Notepad のような packaged app が `ApplicationFrameHost` 配下か、packaged Win32 かは、プロセスツリーとウィンドウクラスの実測で確かめてから S6 の必要性を判断する。