From c302e98b135b11b4a5de484b6414b0c2a6f79c66 Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 01:08:36 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20Explorer=20=E3=81=AE=E5=AE=9F=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E5=90=8D=E3=82=92=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80?= =?UTF-8?q?=E3=81=AE=E3=83=91=E3=82=B9=E3=81=A8=E5=89=8D=E6=96=B9=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E3=81=AE=E5=88=97=E6=8C=99=E3=81=A7=E5=8F=96=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellItemPathByName は view の項目の走査をやめ、フォルダのパスを 1 回取り、自分のプロセスの FindFirstFileExW(表示名*) で候補を集め、portable の MatchShellDisplayName で実パスを決める (隠れる拡張子・.lnk の衝突・8.3 名・同名は判定なし)。動作不変。 Co-Authored-By: Claude Opus 5.5 --- .../computer_use_protocol_fuzz.cc | 78 +++++++++++ .../computer_use_windows_shell_name_match.h | 100 ++++++++++++++ .../computer_use_windows_uia_source.h | 130 ++++++++++++------ ...computer-use-windows-shadow-reader.test.ts | 102 +++++++++++--- .../adr-computer-use-agent-driven-v2.md | 2 + 5 files changed, 349 insertions(+), 63 deletions(-) create mode 100644 apps/desktop/computer-use-native/computer_use_windows_shell_name_match.h 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 2834277b7..458649152 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" @@ -6751,6 +6752,82 @@ 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; + const auto entries = [](std::initializer_list names) { + std::vector list; + for (const wchar_t *name : names) list.push_back({name, false}); + return list; + }; + const auto match = [](std::wstring_view display, const std::vector &list, bool shown) { + return MatchShellDisplayName(display, list, shown); + }; + // An exact name is the entry (extensions shown or hidden). + if (!Check(match(L"run.bat", entries({L"run.bat"}), true) == 0, "shellname-exact-shown")) return false; + if (!Check(match(L"run.bat", entries({L"run.bat"}), false) == 0, "shellname-exact-hidden")) return false; + // Extensions hidden: the display name is the stem of exactly one entry. + if (!Check(match(L"report", entries({L"report.pdf"}), false) == 0, "shellname-hidden-stem-matches")) return false; + if (!Check(match(L"report", entries({L"report.pdf", L"report.docx"}), false) == kNoShellMatch, + "shellname-hidden-stem-two-extensions-is-unknown")) return false; + // Extensions shown: a stem is NOT the display name of an ordinary file ... + if (!Check(match(L"report", entries({L"report.pdf"}), true) == kNoShellMatch, "shellname-shown-stem-is-not-a-file")) return false; + // ... except for the extensions Explorer always hides. + for (const wchar_t *hidden : {L"x.lnk", L"x.url", L"x.pif", L"x.scf", L"x.shb", L"x.appref-ms", L"x.library-ms", L"x.LNK"}) + if (!Check(match(L"x", entries({hidden}), true) == 0, "shellname-always-hidden-extension")) return false; + // A shortcut beside a file of the same shown name collides. + if (!Check(match(L"x", entries({L"x", L"x.lnk"}), true) == kNoShellMatch, "shellname-lnk-collides-with-same-name")) return false; + if (!Check(match(L"x", entries({L"x.lnk", L"x.url"}), true) == kNoShellMatch, "shellname-two-hidden-extensions-collide")) return false; + // x.pdf and x.pdf.exe: extensions hidden, "x.pdf" stands for both (the exact name and the stem of the + // second); shown, the exact name is the only entry. + if (!Check(match(L"x.pdf", entries({L"x.pdf", L"x.pdf.exe"}), false) == kNoShellMatch, "shellname-pdf-and-pdf-exe-hidden-is-unknown")) return false; + if (!Check(match(L"x.pdf", entries({L"x.pdf", L"x.pdf.exe"}), true) == 0, "shellname-pdf-and-pdf-exe-shown-is-the-exact-one")) return false; + // x.pdf alone, extensions hidden: "x.pdf" is also the stem of x.pdf.exe only, never of x.pdf itself. + if (!Check(match(L"x.pdf", entries({L"x.pdf.exe"}), false) == 0, "shellname-double-extension-stem")) return false; + // Only ONE extension is hidden: "a" is not the stem of "a.b.c". + if (!Check(match(L"a", entries({L"a.b.c"}), false) == kNoShellMatch, "shellname-two-extensions-are-not-one")) return false; + // A folder and a file of the same name cannot coexist, but a folder named like a stem can. + { + std::vector list{{L"data", true}, {L"data.txt", false}}; + if (!Check(match(L"data", list, false) == kNoShellMatch, "shellname-folder-and-file-stem-collide")) return false; + if (!Check(match(L"data", list, true) == 0 && list[0].is_directory, "shellname-folder-shown-is-the-folder")) return false; + } + // Case is not significant. + if (!Check(match(L"README", entries({L"readme.MD"}), false) == 0, "shellname-case-insensitive-stem")) return false; + if (!Check(match(L"Run.BAT", entries({L"run.bat"}), true) == 0, "shellname-case-insensitive-exact")) return false; + // 8.3 names: the caller lists by pattern, which an alternate name can satisfy; only the LONG name is + // a candidate, so an entry whose long name does not fit the display name is no match. + if (!Check(match(L"PROGRA~1", entries({L"Program Files"}), true) == kNoShellMatch, "shellname-8dot3-alternate-is-dropped")) return false; + if (!Check(match(L"PROGRA~1", entries({L"PROGRA~1"}), true) == 0, "shellname-a-real-8dot3-looking-name-is-itself")) return false; + // Nothing, a prefix that is no stem, and a longer name that only starts alike. + if (!Check(match(L"a", entries({}), false) == kNoShellMatch, "shellname-no-candidate")) return false; + if (!Check(match(L"a", entries({L"ab.txt"}), false) == kNoShellMatch, "shellname-prefix-is-not-a-stem")) return false; + if (!Check(match(L"a", entries({L"a."}), false) == kNoShellMatch, "shellname-empty-extension-is-not-one")) 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, entries({L"a*", L"a?b", L"a\\b", L"a/b", L"a:b", L"a|b", L"a"}), false) == kNoShellMatch, + "shellname-unusable-display-name")) return false; + // More candidates than the cap are not known; exactly the cap is fine. + { + std::vector list; + for (std::size_t i = 0; i < kShellMaxCandidates; ++i) list.push_back({L"n" + std::to_wstring(i) + L".txt", false}); + list.push_back({L"target.txt", false}); + if (!Check(list.size() == kShellMaxCandidates + 1 && match(L"target", list, false) == kNoShellMatch, "shellname-over-the-cap-is-unknown")) return false; + list.erase(list.begin()); + if (!Check(list.size() == kShellMaxCandidates && match(L"target", list, false) == kShellMaxCandidates - 1, "shellname-exactly-the-cap-is-fine")) return false; + } + // The comparison is the caller's: a stricter one is used for the exact name and the stem. + { + const auto exact_case = [](std::wstring_view a, std::wstring_view b) { return a == b; }; + if (!Check(MatchShellDisplayName(L"Run", entries({L"run.bat"}), false, exact_case) == kNoShellMatch, "shellname-uses-the-given-comparison")) return false; + if (!Check(MatchShellDisplayName(L"run", entries({L"run.bat"}), false, exact_case) == 0, "shellname-given-comparison-matches")) return false; + if (!Check(MatchShellDisplayName(L"run", entries({L"run.bat"}), false, nullptr) == kNoShellMatch, "shellname-null-comparison-is-unknown")) 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 @@ -7266,6 +7343,7 @@ int main() { if (!CheckArtifactReadJudgement()) return 1; if (!CheckNativeInterlockCapture()) return 1; if (!CheckWindowsInterlockCapture()) return 1; + if (!CheckWindowsShellNameMatch()) return 1; if (!CheckNativeApprovedRisk()) return 1; if (!CheckNativeApprovalOffer()) return 1; if (!CheckNativePreflightV2()) return 1; 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..2fdbe2b78 --- /dev/null +++ b/apps/desktop/computer-use-native/computer_use_windows_shell_name_match.h @@ -0,0 +1,100 @@ +#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 name is found by listing the folder's entries whose names START WITH the +// display name (the caller does that, in its own process, bounded) and asking this function which +// entry the display name stands for. The answer is an entry only when it is the ONLY one the display +// name can stand for; otherwise it is "not known" (kNoShellMatch), never a guess: +// +// - the entry is named exactly like the display name; or +// - the entry is the display name + "." + ONE extension (no further dot), where the extension is +// hidden by Explorer: with extensions hidden (extensions_shown == false) any extension may be +// hidden, and with extensions shown only the ones Explorer hides for good (.lnk, .url, .pif, .scf, +// .shb, .appref-ms, .library-ms) may be. +// +// Names are compared as Explorer's 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. A candidate is the entry's LONG name: an entry that only its 8.3 alternate name matched +// is dropped by this comparison, because the long name is what is compared. More candidates than +// kShellMaxCandidates (the caller stops collecting one past it) is "not known". + +#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; +}; + +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; +} + +// The extensions Explorer never shows, whatever the setting. +[[nodiscard]] inline bool ShellExtensionAlwaysHidden(std::wstring_view extension, + ShellNameEquals equals) { + for (const std::wstring_view hidden : {std::wstring_view(L"lnk"), std::wstring_view(L"url"), + std::wstring_view(L"pif"), std::wstring_view(L"scf"), + std::wstring_view(L"shb"), std::wstring_view(L"appref-ms"), + std::wstring_view(L"library-ms")}) + if (equals(extension, hidden)) return true; + return false; +} + +// A display name that cannot be a file name (it holds a wildcard or a separator) matches nothing. +[[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, + bool extensions_shown, 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) { + const std::wstring_view name = candidates[i].name; + bool stands_for = false; + if (equals(name, display)) { + stands_for = true; + } else if (name.size() > display.size() + 1 && name[display.size()] == L'.' && + equals(name.substr(0, display.size()), display)) { + const std::wstring_view extension = name.substr(display.size() + 1); + if (extension.find(L'.') == std::wstring_view::npos) + stands_for = !extensions_shown || ShellExtensionAlwaysHidden(extension, equals); + } + if (!stands_for) 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..850633900 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 @@ -34,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -167,23 +169,45 @@ 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); + } +}; + +// 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; the entries of that folder whose names START WITH the display name +// are then listed in THIS process (FindFirstFileExW: a cost that follows the number of candidates, not +// the size of the folder), and MatchShellDisplayName decides which entry the display name stands for +// (a hidden extension, a shortcut, an 8.3 name). 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 has no file-system path (a search, This PC, a library), no entry or more than one entry fits +// the name, there are more candidates than the cap, or the deadline passed. [[nodiscard]] inline ShadowReadResult ShellItemPathByName(HWND tab, HWND top, std::wstring_view display_name, const ShellLookupLimits &limits, @@ -244,46 +268,62 @@ 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; - - ComRef matched; - for (int index = 0; index < items; ++index) { + // The folder's path, once. A folder that is no directory of the file system has none. + PIDLIST_ABSOLUTE folder_id = nullptr; + if (FAILED(SHGetIDListFromObject(folder.Get(), &folder_id)) || folder_id == nullptr) + return ShadowReadResult::kAbsent; + std::wstring folder_path(32768, L'\0'); + const BOOL has_path = SHGetPathFromIDListEx(folder_id, folder_path.data(), + static_cast(folder_path.size()), GPFIDL_DEFAULT); + ILFree(folder_id); + if (!has_path) return ShadowReadResult::kAbsent; + folder_path.resize(wcslen(folder_path.c_str())); + if (folder_path.empty() || !ShellDisplayNameUsable(display_name)) return ShadowReadResult::kAbsent; + std::wstring pattern = folder_path; + if (pattern.back() != L'\\') pattern.push_back(L'\\'); + pattern.append(display_name); + pattern.push_back(L'*'); + // A path past MAX_PATH would need the extended prefix: it is not looked up. + if (pattern.size() >= MAX_PATH) return ShadowReadResult::kAbsent; + + // Whether Explorer shows known extensions decides which entries a display name can stand for. + SHELLSTATE state{}; + SHGetSetSettings(&state, SSF_SHOWEXTENSIONS, FALSE); + const bool extensions_shown = state.fShowExtensions != 0; + + std::vector candidates; + FindHandle find; + WIN32_FIND_DATAW entry{}; + 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 (!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) - return ShadowReadResult::kFailed; - 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) + const std::wstring_view found(entry.cFileName); + if (found != L"." && found != L"..") { + // One past the cap is enough to know there are too many. + if (candidates.size() >= kShellMaxCandidates) return ShadowReadResult::kAbsent; + candidates.push_back( + {std::wstring(found), (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0}); + } + if (!FindNextFileW(find.handle, &entry)) { + if (GetLastError() == ERROR_NO_MORE_FILES) break; 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; + const std::size_t index = + MatchShellDisplayName(display_name, candidates, extensions_shown, ShellNameEqualsOrdinal); + if (index == kNoShellMatch) return ShadowReadResult::kAbsent; + answer->path = folder_path; + if (answer->path.back() != L'\\') answer->path.push_back(L'\\'); + answer->path.append(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; } 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..cb8e6e1f6 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 @@ -232,7 +232,7 @@ describe('Windows UI Automation source only reads', () => { 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|SH\w+|Shell\w+)\(/gu, ), ].map((match) => match[1]!), ); @@ -253,8 +253,19 @@ 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', + 'SHGetSetSettings', + 'FindFirstFileExW', + 'FindNextFileW', + 'FindClose', + 'CompareStringOrdinal', // the shell lookup of the item path (functions of this file, not Win32 calls) 'ShellItemPathByName', + 'ShellNameEqualsOrdinal', + 'ShellDisplayNameUsable', + 'MatchShellDisplayName', 'ShellLookupRunning', 'InterlockShellLookupRunning', ]); @@ -309,6 +320,13 @@ describe('Windows UI Automation source only reads', () => { 'GetLastError', 'IID_PPV_ARGS', 'ILFree', + 'CompareStringOrdinal', + 'FindClose', + 'FindFirstFileExW', + 'FindNextFileW', + 'SHGetIDListFromObject', + 'SHGetPathFromIDListEx', + 'SHGetSetSettings', 'NormalizeString', 'OpenProcess', 'QueryFullProcessImageNameW', @@ -332,6 +350,7 @@ describe('Windows UI Automation source only reads', () => { 'CoreWindowProcessPath', 'Create', 'DocumentUrl', + 'FindHandle', 'FirstChild', 'Focused', 'Get', @@ -350,6 +369,9 @@ describe('Windows UI Automation source only reads', () => { 'ShadowTextFromUtf16', 'ShadowUnitsTooLong', 'ShellItemPathByName', + 'ShellNameEqualsOrdinal', + 'ShellDisplayNameUsable', + 'MatchShellDisplayName', 'ShellLookupRunning', 'InterlockShellLookupRunning', 'Text', @@ -461,16 +483,29 @@ 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( - body, - 'for (int index = 0; index < items; ++index) {', - 'if (matched.Get() == nullptr)', + // The folder is listed in THIS process, by the display name as a prefix: 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;'); + expect(body).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); + // The setting that hides extensions is read from the shell, and the matcher gets it. + expect(body).toContain('SHGetSetSettings(&state, SSF_SHOWEXTENSIONS, FALSE);'); + expect(body).toContain( + 'MatchShellDisplayName(display_name, candidates, extensions_shown, ShellNameEqualsOrdinal)', ); - // (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); + // 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:'); @@ -501,21 +536,23 @@ describe('Windows UI Automation source only reads', () => { expect(code(body)).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. + // Both the walk over the shell windows and the listing of the entries stop on a cancel. expect( body.match(/if \(!limits\.still_valid\(\)\) return ShadowReadResult::kStopped;/gu)?.length, ).toBe(2); expect(body).toContain( 'if (limits.now_ns() > limits.deadline_ns) 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'); + // More candidates than the cap are not known; so are two entries a name can stand for (the matcher). + expect(body).toContain( + 'if (candidates.size() >= kShellMaxCandidates) return ShadowReadResult::kAbsent;', + ); + 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 +560,35 @@ describe('Windows UI Automation source only reads', () => { expect((code(source).match(/ShellItemPathByName\(/gu) ?? []).length).toBe(2); }); + 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;'); + // The extensions Explorer hides whatever the setting says. + for (const extension of ['lnk', 'url', 'pif', 'scf', 'shb', 'appref-ms', 'library-ms']) + expect(matcher).toContain(`L"${extension}"`); + // Two entries a name can stand for are "not known"; so are more candidates than the cap. + expect(matcher).toContain('if (found != kNoShellMatch) return kNoShellMatch;'); + expect(matcher).toContain('candidates.size() > kShellMaxCandidates'); + // Only ONE extension is hidden; with extensions shown only the always-hidden ones are. + expect(matcher).toContain("extension.find(L'.') == std::wstring_view::npos"); + expect(matcher).toContain( + 'stands_for = !extensions_shown || ShellExtensionAlwaysHidden(extension, equals);', + ); + // 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()'); + expect(harness).toContain('shellname-lnk-collides-with-same-name'); + expect(harness).toContain('shellname-pdf-and-pdf-exe-hidden-is-unknown'); + expect(harness).toContain('shellname-8dot3-alternate-is-dropped'); + expect(harness).toContain('shellname-over-the-cap-is-unknown'); + }); + 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 2eccbdab4..4974a49ef 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -663,6 +663,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 の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと前方一致の列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダのパスを 1 回だけ取る(`SHGetIDListFromObject` と `SHGetPathFromIDListEx`。検索、ライブラリ、PC などファイルシステムのディレクトリでないものはパスが無く kAbsent)。同じ lookup のスレッドで、自分のプロセスの `FindFirstFileExW(フォルダ\表示名*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` を呼び、名前が表示名で始まる項目だけを候補に集める(上限 64 件、時間は `kShellLookupBudgetNs` の範囲、取消しは項目ごとに確認)。コストは候補の数に比例し、フォルダの大きさには比例しない。`IFolderView2::Item` の走査と `kShellLookupMaxItems`(512)は廃止した。表示名が実ファイルのどれを指すかは portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が決める。候補は、名前が表示名と完全一致するもの、および「表示名 + `.` + 拡張子 1 つ」のうち、Explorer が隠しうるもの(拡張子を隠す設定ではどの拡張子でも、表示する設定では常に隠れる lnk / url / pif / scf / shb / appref-ms / library-ms だけ)の和集合で、1 件だけならその実パスを返し、0 件または 2 件以上は判定なし(kAbsent)。比較は序数比較で大文字小文字を区別しない(`CompareStringOrdinal`)。候補は長い名前で比べるので、8.3 名だけで一致した項目は外れる。拡張子を表示するかは `SHGetSetSettings(SSF_SHOWEXTENSIONS)`、`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回): 項目数 4 / 120 / 500 / 5,000 のどれでも、実パスが取れ、1 回 3〜6 ms 程度(最大 31 ms、初回のみ)で、項目数への依存は見えなかった。拡張子を隠す設定と同名の `.lnk` は実機で未測定で、harness の表(完全一致、隠す設定での語幹、`.lnk` の衝突、`x.pdf` と `x.pdf.exe`、フォルダとファイル、大文字小文字、8.3 名、上限)で固定している。 + **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 の必要性を判断する。 From b0ce45ca6b88277c27243e8a3da6ae4c0849cfd4 Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 01:28:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AE=E5=8F=8D=E6=98=A0=EF=BC=88=E3=82=B7=E3=82=A7=E3=83=AB?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E5=90=8D=E3=81=A7=E3=81=AE=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=83=BB=E9=81=85=E3=81=84=E5=A0=B4=E6=89=80=E3=81=A8=E7=B5=B1?= =?UTF-8?q?=E5=90=88=E8=A1=A8=E7=A4=BA=E3=81=AE=E9=99=A4=E5=A4=96=E3=83=BB?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E3=81=97=E5=8F=AF=E8=83=BD=E3=81=AA=20lookup?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 照合を規則から、候補ごとにシェルが出す表示名の比較へ(拡張子・ローカライズ・フォルダ名のドット) - 入口で外す: 空の ID リスト、ISSLOW、UNC/リモート/光学ドライブ、Desktop/PublicDesktop、[LocalizedFileNames] のある desktop.ini - フォルダを 1 回列挙し、前方一致と読み取り専用/system ディレクトリだけシェル名を取る - 待ち切れの lookup に放棄と CancelSynchronousIo を送る Co-Authored-By: Claude Opus 5.5 --- .../computer_use_protocol_fuzz.cc | 140 +++++++------ .../computer_use_windows_shell_name_match.h | 60 ++---- .../computer_use_windows_uia_source.h | 192 +++++++++++++++--- ...computer-use-windows-shadow-reader.test.ts | 136 +++++++++++-- .../adr-computer-use-agent-driven-v2.md | 2 +- 5 files changed, 382 insertions(+), 148 deletions(-) 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 458649152..159bb336c 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -6756,73 +6756,99 @@ bool CheckWindowsInterlockCapture() { bool CheckWindowsShellNameMatch() { using namespace sprint_coder::computer_use; - const auto entries = [](std::initializer_list names) { - std::vector list; - for (const wchar_t *name : names) list.push_back({name, false}); - return list; + // 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 match = [](std::wstring_view display, const std::vector &list, bool shown) { - return MatchShellDisplayName(display, list, shown); + const auto unreadable = [](const wchar_t *name) { + ShellNameCandidate candidate; + candidate.name = name; + return candidate; }; - // An exact name is the entry (extensions shown or hidden). - if (!Check(match(L"run.bat", entries({L"run.bat"}), true) == 0, "shellname-exact-shown")) return false; - if (!Check(match(L"run.bat", entries({L"run.bat"}), false) == 0, "shellname-exact-hidden")) return false; - // Extensions hidden: the display name is the stem of exactly one entry. - if (!Check(match(L"report", entries({L"report.pdf"}), false) == 0, "shellname-hidden-stem-matches")) return false; - if (!Check(match(L"report", entries({L"report.pdf", L"report.docx"}), false) == kNoShellMatch, - "shellname-hidden-stem-two-extensions-is-unknown")) return false; - // Extensions shown: a stem is NOT the display name of an ordinary file ... - if (!Check(match(L"report", entries({L"report.pdf"}), true) == kNoShellMatch, "shellname-shown-stem-is-not-a-file")) return false; - // ... except for the extensions Explorer always hides. - for (const wchar_t *hidden : {L"x.lnk", L"x.url", L"x.pif", L"x.scf", L"x.shb", L"x.appref-ms", L"x.library-ms", L"x.LNK"}) - if (!Check(match(L"x", entries({hidden}), true) == 0, "shellname-always-hidden-extension")) return false; - // A shortcut beside a file of the same shown name collides. - if (!Check(match(L"x", entries({L"x", L"x.lnk"}), true) == kNoShellMatch, "shellname-lnk-collides-with-same-name")) return false; - if (!Check(match(L"x", entries({L"x.lnk", L"x.url"}), true) == kNoShellMatch, "shellname-two-hidden-extensions-collide")) return false; - // x.pdf and x.pdf.exe: extensions hidden, "x.pdf" stands for both (the exact name and the stem of the - // second); shown, the exact name is the only entry. - if (!Check(match(L"x.pdf", entries({L"x.pdf", L"x.pdf.exe"}), false) == kNoShellMatch, "shellname-pdf-and-pdf-exe-hidden-is-unknown")) return false; - if (!Check(match(L"x.pdf", entries({L"x.pdf", L"x.pdf.exe"}), true) == 0, "shellname-pdf-and-pdf-exe-shown-is-the-exact-one")) return false; - // x.pdf alone, extensions hidden: "x.pdf" is also the stem of x.pdf.exe only, never of x.pdf itself. - if (!Check(match(L"x.pdf", entries({L"x.pdf.exe"}), false) == 0, "shellname-double-extension-stem")) return false; - // Only ONE extension is hidden: "a" is not the stem of "a.b.c". - if (!Check(match(L"a", entries({L"a.b.c"}), false) == kNoShellMatch, "shellname-two-extensions-are-not-one")) return false; - // A folder and a file of the same name cannot coexist, but a folder named like a stem can. - { - std::vector list{{L"data", true}, {L"data.txt", false}}; - if (!Check(match(L"data", list, false) == kNoShellMatch, "shellname-folder-and-file-stem-collide")) return false; - if (!Check(match(L"data", list, true) == 0 && list[0].is_directory, "shellname-folder-shown-is-the-folder")) return false; - } + 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", entries({L"readme.MD"}), false) == 0, "shellname-case-insensitive-stem")) return false; - if (!Check(match(L"Run.BAT", entries({L"run.bat"}), true) == 0, "shellname-case-insensitive-exact")) return false; - // 8.3 names: the caller lists by pattern, which an alternate name can satisfy; only the LONG name is - // a candidate, so an entry whose long name does not fit the display name is no match. - if (!Check(match(L"PROGRA~1", entries({L"Program Files"}), true) == kNoShellMatch, "shellname-8dot3-alternate-is-dropped")) return false; - if (!Check(match(L"PROGRA~1", entries({L"PROGRA~1"}), true) == 0, "shellname-a-real-8dot3-looking-name-is-itself")) return false; - // Nothing, a prefix that is no stem, and a longer name that only starts alike. - if (!Check(match(L"a", entries({}), false) == kNoShellMatch, "shellname-no-candidate")) return false; - if (!Check(match(L"a", entries({L"ab.txt"}), false) == kNoShellMatch, "shellname-prefix-is-not-a-stem")) return false; - if (!Check(match(L"a", entries({L"a."}), false) == kNoShellMatch, "shellname-empty-extension-is-not-one")) return false; + 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, entries({L"a*", L"a?b", L"a\\b", L"a/b", L"a:b", L"a|b", L"a"}), false) == kNoShellMatch, - "shellname-unusable-display-name")) return false; + 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. { - std::vector list; - for (std::size_t i = 0; i < kShellMaxCandidates; ++i) list.push_back({L"n" + std::to_wstring(i) + L".txt", false}); - list.push_back({L"target.txt", false}); - if (!Check(list.size() == kShellMaxCandidates + 1 && match(L"target", list, false) == kNoShellMatch, "shellname-over-the-cap-is-unknown")) return false; + 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, false) == kShellMaxCandidates - 1, "shellname-exactly-the-cap-is-fine")) return false; + 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: a stricter one is used for the exact name and the stem. + // The comparison is the caller's. { - const auto exact_case = [](std::wstring_view a, std::wstring_view b) { return a == b; }; - if (!Check(MatchShellDisplayName(L"Run", entries({L"run.bat"}), false, exact_case) == kNoShellMatch, "shellname-uses-the-given-comparison")) return false; - if (!Check(MatchShellDisplayName(L"run", entries({L"run.bat"}), false, exact_case) == 0, "shellname-given-comparison-matches")) return false; - if (!Check(MatchShellDisplayName(L"run", entries({L"run.bat"}), false, nullptr) == kNoShellMatch, "shellname-null-comparison-is-unknown")) return false; + 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; } std::cout << "Computer Use native Windows shell name match: PASS\n"; return 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 index 2fdbe2b78..82ce47f63 100644 --- 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 @@ -4,22 +4,22 @@ // 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 name is found by listing the folder's entries whose names START WITH the -// display name (the caller does that, in its own process, bounded) and asking this function which -// entry the display name stands for. The answer is an entry only when it is the ONLY one the display -// name can stand for; otherwise it is "not known" (kNoShellMatch), never a guess: +// are hidden. The real file is found in two steps: // -// - the entry is named exactly like the display name; or -// - the entry is the display name + "." + ONE extension (no further dot), where the extension is -// hidden by Explorer: with extensions hidden (extensions_shown == false) any extension may be -// hidden, and with extensions shown only the ones Explorer hides for good (.lnk, .url, .pif, .scf, -// .shb, .appref-ms, .library-ms) may be. +// 1. the caller lists the folder's entries whose names START WITH the display name (bounded, at most +// kShellMaxCandidates), only to narrow the field; 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. // -// Names are compared as Explorer's 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. A candidate is the entry's LONG name: an entry that only its 8.3 alternate name matched -// is dropped by this comparison, because the long name is what is compared. More candidates than -// kShellMaxCandidates (the caller stops collecting one past it) is "not known". +// 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 @@ -31,6 +31,8 @@ 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; + std::wstring shell_name; // the name the shell shows for it (valid when shell_name_known) + bool shell_name_known = false; }; constexpr std::size_t kShellMaxCandidates = 64; @@ -50,18 +52,8 @@ using ShellNameEquals = bool (*)(std::wstring_view, std::wstring_view); return true; } -// The extensions Explorer never shows, whatever the setting. -[[nodiscard]] inline bool ShellExtensionAlwaysHidden(std::wstring_view extension, - ShellNameEquals equals) { - for (const std::wstring_view hidden : {std::wstring_view(L"lnk"), std::wstring_view(L"url"), - std::wstring_view(L"pif"), std::wstring_view(L"scf"), - std::wstring_view(L"shb"), std::wstring_view(L"appref-ms"), - std::wstring_view(L"library-ms")}) - if (equals(extension, hidden)) return true; - return false; -} - -// A display name that cannot be a file name (it holds a wildcard or a separator) matches nothing. +// 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) @@ -74,23 +66,15 @@ using ShellNameEquals = bool (*)(std::wstring_view, std::wstring_view); // 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, - bool extensions_shown, ShellNameEquals equals = ShellNameEqualsAsciiFold) { + 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) { - const std::wstring_view name = candidates[i].name; - bool stands_for = false; - if (equals(name, display)) { - stands_for = true; - } else if (name.size() > display.size() + 1 && name[display.size()] == L'.' && - equals(name.substr(0, display.size()), display)) { - const std::wstring_view extension = name.substr(display.size() + 1); - if (extension.find(L'.') == std::wstring_view::npos) - stands_for = !extensions_shown || ShellExtensionAlwaysHidden(extension, equals); - } - if (!stands_for) continue; + // A candidate whose shell name is not known could be the one, or a second one. + 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; } 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 850633900..7b13c2286 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 @@ -200,14 +200,82 @@ struct FindHandle { } }; +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 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 file system access that could block, and fail-closed (a doubt is "no"): +// - a network location (a UNC path, a remote, optical or unknown drive) can block for long; +// - 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; +[[nodiscard]] inline bool ShellFolderMayBeListed(const std::wstring &path) { + if (path.size() < 3 || path[0] == L'\\') return false; + if (path[1] != L':' || path[2] != L'\\') return false; + const std::wstring root = path.substr(0, 3); + const UINT drive = GetDriveTypeW(root.c_str()); + if (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || + drive == DRIVE_NO_ROOT_DIR) + return false; + const std::wstring trimmed = ShellPathWithoutTrailingSeparator(path); + for (const KNOWNFOLDERID &known : {FOLDERID_Desktop, FOLDERID_PublicDesktop}) { + PWSTR folder = nullptr; + if (FAILED(SHGetKnownFolderPath(known, 0, 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 [LocalizedFileNames] section does that. Then an entry that is not a +// candidate by its file name could still carry the display name, so the folder is not looked into. +// Only that section is read (a desktop.ini is normal, in a user's home for one; it is not by itself a +// reason), and what cannot be read is taken to be a yes (fail-closed). +[[nodiscard]] inline bool ShellFolderHasLocalizedFileNames(const std::wstring &path) { + const std::wstring settings = ShellPathWithoutTrailingSeparator(path) + L"\\desktop.ini"; + const DWORD attributes = GetFileAttributesW(settings.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + const DWORD error = GetLastError(); + return !(error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND); + } + std::wstring section(32767, L'\0'); + SetLastError(0); + const DWORD length = GetPrivateProfileSectionW(L"LocalizedFileNames", section.data(), + static_cast(section.size()), settings.c_str()); + if (length != 0) return true; + return GetLastError() != 0 && GetLastError() != ERROR_FILE_NOT_FOUND; +} + // 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; the entries of that folder whose names START WITH the display name -// are then listed in THIS process (FindFirstFileExW: a cost that follows the number of candidates, not -// the size of the folder), and MatchShellDisplayName decides which entry the display name stands for -// (a hidden extension, a shortcut, an 8.3 name). 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 has no file-system path (a search, This PC, a library), no entry or more than one entry fits -// the name, there are more candidates than the cap, or the deadline passed. +// 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, @@ -268,28 +336,38 @@ struct FindHandle { folder_view.Get() == nullptr || FAILED(folder_view.Get()->GetFolder(IID_PPV_ARGS(folder.Receive()))) || folder.Get() == nullptr) return ShadowReadResult::kAbsent; - // The folder's path, once. A folder that is no directory of the file system has none. - PIDLIST_ABSOLUTE folder_id = nullptr; - if (FAILED(SHGetIDListFromObject(folder.Get(), &folder_id)) || folder_id == nullptr) + // 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 (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 (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'); - const BOOL has_path = SHGetPathFromIDListEx(folder_id, folder_path.data(), + const BOOL has_path = SHGetPathFromIDListEx(folder_id.id, folder_path.data(), static_cast(folder_path.size()), GPFIDL_DEFAULT); - ILFree(folder_id); if (!has_path) return ShadowReadResult::kAbsent; folder_path.resize(wcslen(folder_path.c_str())); if (folder_path.empty() || !ShellDisplayNameUsable(display_name)) return ShadowReadResult::kAbsent; - std::wstring pattern = folder_path; - if (pattern.back() != L'\\') pattern.push_back(L'\\'); - pattern.append(display_name); + // Only a plain local directory without a desktop.ini: decided before the first listing. + if (!ShellFolderMayBeListed(folder_path)) return ShadowReadResult::kAbsent; + std::wstring directory = ShellPathWithoutTrailingSeparator(folder_path); + directory.push_back(L'\\'); + // 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 (pattern.size() >= MAX_PATH) return ShadowReadResult::kAbsent; - - // Whether Explorer shows known extensions decides which entries a display name can stand for. - SHELLSTATE state{}; - SHGetSetSettings(&state, SSF_SHOWEXTENSIONS, FALSE); - const bool extensions_shown = state.fShowExtensions != 0; + 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 (ShellFolderHasLocalizedFileNames(folder_path)) return ShadowReadResult::kAbsent; std::vector candidates; FindHandle find; @@ -305,23 +383,47 @@ struct FindHandle { if (!limits.still_valid()) return ShadowReadResult::kStopped; if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent; const std::wstring_view found(entry.cFileName); - if (found != L"." && found != L"..") { + // An entry needs its shell name only when it can carry the display name: its file name starts + // with it, or it is a read-only / system directory (a desktop.ini can rename those). Hidden + // entries are not left out (a doubt keeps an entry, which can only make the answer "not known"). + const bool starts_with_display = + found.size() >= display_name.size() && + ShellNameEqualsOrdinal(found.substr(0, display_name.size()), display_name); + const bool renamable_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (entry.dwFileAttributes & + (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0; + if (found != L"." && found != L".." && (starts_with_display || renamable_directory)) { // One past the cap is enough to know there are too many. if (candidates.size() >= kShellMaxCandidates) return ShadowReadResult::kAbsent; - candidates.push_back( - {std::wstring(found), (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0}); + ShellNameCandidate candidate; + candidate.name = std::wstring(found); + candidate.is_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + candidates.push_back(std::move(candidate)); } if (!FindNextFileW(find.handle, &entry)) { if (GetLastError() == ERROR_NO_MORE_FILES) break; return ShadowReadResult::kFailed; } } - const std::size_t index = - MatchShellDisplayName(display_name, candidates, extensions_shown, ShellNameEqualsOrdinal); + // The name the shell shows for each candidate. One that cannot be read is left unknown, which makes + // the whole answer "not known" (it might be the one, or a second one). + for (ShellNameCandidate &candidate : candidates) { + if (!limits.still_valid()) return ShadowReadResult::kStopped; + if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent; + ComRef item; + PWSTR shown = nullptr; + if (SUCCEEDED(SHCreateItemFromParsingName((directory + candidate.name).c_str(), nullptr, + IID_PPV_ARGS(item.Receive()))) && + item.Get() != nullptr && + SUCCEEDED(item.Get()->GetDisplayName(SIGDN_NORMALDISPLAY, &shown)) && shown != nullptr) { + candidate.shell_name = shown; + candidate.shell_name_known = true; + } + if (shown != nullptr) CoTaskMemFree(shown); + } + const std::size_t index = MatchShellDisplayName(display_name, candidates, ShellNameEqualsOrdinal); if (index == kNoShellMatch) return ShadowReadResult::kAbsent; - answer->path = folder_path; - if (answer->path.back() != L'\\') answer->path.push_back(L'\\'); - answer->path.append(candidates[index].name); + 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; @@ -335,6 +437,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; }; @@ -578,7 +691,18 @@ class WindowsUiaShadowSource final : public ShadowUiaSource { .count()); }; windows_uia_source_detail::ShellLookupLimits limits; - limits.still_valid = [valid] { return valid == nullptr || valid(); }; + // 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; windows_uia_source_detail::ShellItemAnswer answer; @@ -602,8 +726,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 cb8e6e1f6..591c3f9b9 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 @@ -232,7 +232,7 @@ describe('Windows UI Automation source only reads', () => { 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|FindFirstFileExW|FindNextFileW|FindClose|CompareStringOrdinal|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]!), ); @@ -256,16 +256,26 @@ describe('Windows UI Automation source only reads', () => { // the folder's path and a listing of its entries in this process (read-only) 'SHGetIDListFromObject', 'SHGetPathFromIDListEx', - 'SHGetSetSettings', + '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', + 'ShellFolderHasLocalizedFileNames', + 'ShellPathWithoutTrailingSeparator', 'ShellLookupRunning', 'InterlockShellLookupRunning', ]); @@ -326,7 +336,18 @@ describe('Windows UI Automation source only reads', () => { 'FindNextFileW', 'SHGetIDListFromObject', 'SHGetPathFromIDListEx', - 'SHGetSetSettings', + 'SHGetKnownFolderPath', + 'SHCreateItemFromIDList', + 'SHCreateItemFromParsingName', + 'GetDriveTypeW', + 'GetFileAttributesW', + 'CancelSynchronousIo', + 'DuplicateHandle', + 'GetCurrentProcess', + 'GetCurrentThread', + 'GetPrivateProfileSectionW', + 'SetLastError', + 'ILIsEmpty', 'NormalizeString', 'OpenProcess', 'QueryFullProcessImageNameW', @@ -372,6 +393,11 @@ describe('Windows UI Automation source only reads', () => { 'ShellNameEqualsOrdinal', 'ShellDisplayNameUsable', 'MatchShellDisplayName', + 'ShellFolderMayBeListed', + 'ShellFolderHasLocalizedFileNames', + 'ShellPathWithoutTrailingSeparator', + 'AbsoluteIdList', + 'ShellLookupPending', 'ShellLookupRunning', 'InterlockShellLookupRunning', 'Text', @@ -494,16 +520,80 @@ describe('Windows UI Automation source only reads', () => { 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;'); - expect(body).toContain('pattern.append(display_name);'); + // 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); - // The setting that hides extensions is read from the shell, and the matcher gets it. - expect(body).toContain('SHGetSetSettings(&state, SSF_SHOWEXTENSIONS, FALSE);'); + // No rule about extensions: the SHELL is asked, for every candidate, for the name it shows, and the + // matcher compares those. A candidate whose name cannot be read stays unknown (the whole answer is). + expect(code(body)).not.toMatch(/SHGetSetSettings|SSF_SHOWEXTENSIONS|extensions_shown/u); + const names = between( + body, + 'for (ShellNameCandidate &candidate : candidates) {', + 'MatchShellDisplayName(', + ); + expect(names).toContain('SHCreateItemFromParsingName('); + expect(names).toContain('GetDisplayName(SIGDN_NORMALDISPLAY, &shown)'); + expect(names).toContain('candidate.shell_name_known = true;'); + expect(names).toContain('if (!limits.still_valid()) return ShadowReadResult::kStopped;'); expect(body).toContain( - 'MatchShellDisplayName(display_name, candidates, extensions_shown, ShellNameEqualsOrdinal)', + 'MatchShellDisplayName(display_name, candidates, ShellNameEqualsOrdinal)', + ); + // 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', + 'if (!ShellFolderMayBeListed(folder_path)) return ShadowReadResult::kAbsent;', + ]) { + expect(body.indexOf(guard), guard).toBeGreaterThanOrEqual(0); + expect(body.indexOf(guard), guard).toBeLessThan(find); + } + const guardFunction = between( + source, + 'ShellFolderMayBeListed(const std::wstring &path) {', + 'The path of the item a file browser shows', + ); + expect(guardFunction.includes("path[0] == L'" + '\\\\' + "'")).toBe(true); + for (const part of [ + 'DRIVE_REMOTE', + 'DRIVE_CDROM', + 'DRIVE_UNKNOWN', + 'DRIVE_NO_ROOT_DIR', + 'FOLDERID_Desktop', + 'FOLDERID_PublicDesktop', + ]) + expect(guardFunction, part).toContain(part); + // A desktop.ini is normal (a user's home has one): only its [LocalizedFileNames] section counts, and + // it is read with the profile API, never opened; what cannot be read is a yes (fail-closed). + const localized = between( + source, + 'ShellFolderHasLocalizedFileNames(const std::wstring &path) {', + 'The path of the item a file browser shows', ); + expect(localized).toContain('GetPrivateProfileSectionW(L"LocalizedFileNames"'); + expect(localized).toContain('desktop.ini'); + expect(code(localized)).not.toMatch(/CreateFile|ReadFile|ifstream|fopen/u); + expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path)')).toBeGreaterThanOrEqual(0); + expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path)')).toBeLessThan(find); + // Which entries need the shell's name: a file name that starts like the display name, and the + // read-only / system directories (a desktop.ini can only rename those); hidden entries are kept. + expect(body).toContain('FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM'); + expect(body).toContain('starts_with_display || renamable_directory'); + expect(body).toContain( + 'renamable_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 &&', + ); + // 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))', + ); + expect(code(body)).not.toContain('FILE_ATTRIBUTE_HIDDEN'); + // The guard decides before any listing of ours. + expect(guardFunction).not.toContain('FindFirstFileExW'); // 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 @@ -512,7 +602,11 @@ describe('Windows UI Automation source only reads', () => { 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();'); @@ -533,7 +627,7 @@ 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 candidates. @@ -544,7 +638,7 @@ describe('Windows UI Automation source only reads', () => { // Both the walk over the shell windows and the listing of the entries stop on a cancel. expect( body.match(/if \(!limits\.still_valid\(\)\) return ShadowReadResult::kStopped;/gu)?.length, - ).toBe(2); + ).toBe(3); expect(body).toContain( 'if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent;', ); @@ -568,24 +662,24 @@ describe('Windows UI Automation source only reads', () => { ); expect(code(matcher)).not.toMatch(/FindFirstFile|CompareString|GetLastError|CoCreate/u); expect(matcher).toContain('constexpr std::size_t kShellMaxCandidates = 64;'); - // The extensions Explorer hides whatever the setting says. - for (const extension of ['lnk', 'url', 'pif', 'scf', 'shb', 'appref-ms', 'library-ms']) - expect(matcher).toContain(`L"${extension}"`); - // Two entries a name can stand for are "not known"; so are more candidates than the cap. + // 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|stem|find\(/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'); - // Only ONE extension is hidden; with extensions shown only the always-hidden ones are. - expect(matcher).toContain("extension.find(L'.') == std::wstring_view::npos"); - expect(matcher).toContain( - 'stands_for = !extensions_shown || ShellExtensionAlwaysHidden(extension, equals);', - ); + expect(matcher).toContain('if (!candidates[i].shell_name_known) return kNoShellMatch;'); // 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()'); expect(harness).toContain('shellname-lnk-collides-with-same-name'); - expect(harness).toContain('shellname-pdf-and-pdf-exe-hidden-is-unknown'); - expect(harness).toContain('shellname-8dot3-alternate-is-dropped'); + expect(harness).toContain('shellname-pdf-exe-hidden-is-the-second'); + expect(harness).toContain('shellname-never-shown-extension-is-the-shells-answer'); + expect(harness).toContain('shellname-x-and-x-website-collide'); + expect(harness).toContain('shellname-folder-with-a-dot-is-not-a-stem'); + expect(harness).toContain('shellname-unreadable-after-the-match'); + expect(harness).toContain('shellname-file-name-is-not-the-display-name'); expect(harness).toContain('shellname-over-the-cap-is-unknown'); }); diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index 4974a49ef..e314c097c 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -663,7 +663,7 @@ 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 の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと前方一致の列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダのパスを 1 回だけ取る(`SHGetIDListFromObject` と `SHGetPathFromIDListEx`。検索、ライブラリ、PC などファイルシステムのディレクトリでないものはパスが無く kAbsent)。同じ lookup のスレッドで、自分のプロセスの `FindFirstFileExW(フォルダ\表示名*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` を呼び、名前が表示名で始まる項目だけを候補に集める(上限 64 件、時間は `kShellLookupBudgetNs` の範囲、取消しは項目ごとに確認)。コストは候補の数に比例し、フォルダの大きさには比例しない。`IFolderView2::Item` の走査と `kShellLookupMaxItems`(512)は廃止した。表示名が実ファイルのどれを指すかは portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が決める。候補は、名前が表示名と完全一致するもの、および「表示名 + `.` + 拡張子 1 つ」のうち、Explorer が隠しうるもの(拡張子を隠す設定ではどの拡張子でも、表示する設定では常に隠れる lnk / url / pif / scf / shb / appref-ms / library-ms だけ)の和集合で、1 件だけならその実パスを返し、0 件または 2 件以上は判定なし(kAbsent)。比較は序数比較で大文字小文字を区別しない(`CompareStringOrdinal`)。候補は長い名前で比べるので、8.3 名だけで一致した項目は外れる。拡張子を表示するかは `SHGetSetSettings(SSF_SHOWEXTENSIONS)`、`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回): 項目数 4 / 120 / 500 / 5,000 のどれでも、実パスが取れ、1 回 3〜6 ms 程度(最大 31 ms、初回のみ)で、項目数への依存は見えなかった。拡張子を隠す設定と同名の `.lnk` は実機で未測定で、harness の表(完全一致、隠す設定での語幹、`.lnk` の衝突、`x.pdf` と `x.pdf.exe`、フォルダとファイル、大文字小文字、8.3 名、上限)で固定している。 +**S5-c-2 PR-3(Explorer の実ファイル名。2026-10-17。動作不変)**: 上の S5-c (2) の「Explorer の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダの ID リストを取る。**照合は規則ではなく、シェルが実際に出す表示名で行う**。(1) 入口で fail-closed に外す場所(I/O の前に決める): ID リストが空(デスクトップ名前空間のルート。パスはユーザーのデスクトップだが、ビューは Public\\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所(検索、ライブラリ、PC)、UNC / リモート / 光学 / 不明なドライブ、`FOLDERID_Desktop` と `FOLDERID_PublicDesktop` のパス(ルートの ID リスト以外の入り口でも統合表示を避ける。実機では未確認の保険)、`desktop.ini` に `[LocalizedFileNames]` の節があるフォルダ(ファイルの表示名がファイル名と無関係になりうる。節の有無だけを `GetPrivateProfileSectionW` で読み、読めなければ「あり」とみなす。ユーザーのホームのように desktop.ini 自体があるのは普通なので、それだけでは外さない)。(2) フォルダを `FindFirstFileExW(フォルダ\\*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` で 1 回列挙する(ファイルシステムのメタデータだけ。時間は `kShellLookupBudgetNs`、取消しは項目ごと)。シェル表示名を取る候補は、ファイル名が表示名で始まるもの(大文字小文字を無視)と、読み取り専用または system 属性のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこの属性のフォルダだけ。「Documents」が日本語版で「ドキュメント」と出る場合を拾う)。隠しファイルは候補から外さない(迷ったら残し、曖昧になる側=判定なしに倒す)。候補は 64 件まで(超えたら判定なし)。(3) 各候補に `SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取り、portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が UIA の表示名と序数比較(`CompareStringOrdinal`、大文字小文字を無視)で照合する。ちょうど 1 件ならその実パスを返し、0 件、2 件以上、表示名が取れない候補が 1 つでもある、のどれかは判定なし(kAbsent)。拡張子を隠す設定、未登録の拡張子(LICENSE と LICENSE.md)、`.lnk` / `.website` など隠れる拡張子、ドットを含むフォルダ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない。拡張子の表示設定(`SSF_SHOWEXTENSIONS`)は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。(4) lookup のスレッドは新しく同期のファイルシステム I/O を発行する。入口で遅い場所を外すほか、待ちが切れた側が lookup に放棄を知らせ(次の一歩で止まる)、スレッドの複製ハンドルへ `CancelSynchronousIo` を送って、ブロックした呼び出しを打ち切る。応答しないローカル USB などでドライバが戻らない場合は取り消せない。**既知の限界**として、そのときは lookup の枠(`lookup_slot`)が埋まり続け、以降の lookup は判定なしになる(拒否にも許可にも倒れず、V1 に任せる)。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回、候補ごとの表示名の取得を含む): 項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 8〜11 ms 程度(最大 18 ms)で、項目数への依存は見えなかった。同名の `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(判定なし)、節の無い desktop.ini(取れる)、desktop.ini で別名になる読み取り専用フォルダ(シェル名で取れ、同名のファイルがあれば判定なし)を実機で確かめた。拡張子を隠す設定と、デスクトップ / Public Desktop を開いたときの挙動は実機で未確認で、harness の表と入口の判定(source 契約)で固定している。 **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 で版を上げるときにしか決まらない。 From 28431928676c62e3589c4ef4b8bb254b9afe7524 Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 01:51:37 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20=E5=86=8D=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87?= =?UTF-8?q?=E6=91=98=E3=81=AE=E5=8F=8D=E6=98=A0=EF=BC=88=E6=9C=AB=E5=B0=BE?= =?UTF-8?q?=E3=83=89=E3=83=83=E3=83=88=E3=83=BB=E9=A0=85=E7=9B=AE=20view?= =?UTF-8?q?=20=E9=99=90=E5=AE=9A=E3=83=BBI/O=20=E5=89=8D=E3=81=AE=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E7=A2=BA=E8=AA=8D=E3=83=BB=E3=83=AA=E3=83=B3=E3=82=AF?= =?UTF-8?q?/=E3=83=9C=E3=83=AA=E3=83=A5=E3=83=BC=E3=83=A0=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=83=BB=E7=B4=94=E7=B2=8B=E9=96=A2=E6=95=B0=E5=8C=96?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解決先は SIGDN_FILESYSPATH の末尾が候補名と一致するときだけ採る(末尾ドット/空白は採らない) - ListItem が SHELLDLL_DefView / UIItemsView の中にあるときだけ shell に尋ねる - 新しい I/O の直前ごとに取消しと期限を確認、KF_FLAG_DONT_VERIFY、GetVolumePathNameW と symlink/mount point の除外 - lookup スレッドを try/catch と RAII で包む - 候補の選び方・desktop.ini 判定・解決名の判定を portable の純粋関数にして harness で固定 Co-Authored-By: Claude Opus 5.5 --- .../computer_use_protocol_fuzz.cc | 75 +++++- .../computer_use_windows_shadow_reader.h | 15 +- .../computer_use_windows_shell_name_match.h | 59 ++++- .../computer_use_windows_uia_source.h | 215 ++++++++++++------ ...computer-use-windows-shadow-reader.test.ts | 209 +++++++++++++---- .../adr-computer-use-agent-driven-v2.md | 2 +- 6 files changed, 451 insertions(+), 124 deletions(-) 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 159bb336c..d48ef121b 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -3089,7 +3089,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; @@ -3097,7 +3098,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; } @@ -3452,6 +3453,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')); @@ -6850,6 +6872,55 @@ bool CheckWindowsShellNameMatch() { 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 section's length and the errors decide. + { + constexpr unsigned long kAccessDenied = 5; + if (!Check(!ShellDesktopIniLocalizesFileNames(false, kShellErrorFileNotFound, 0, 0), "desktopini-none-file-not-found")) return false; + if (!Check(!ShellDesktopIniLocalizesFileNames(false, kShellErrorPathNotFound, 0, 0), "desktopini-none-path-not-found")) return false; + if (!Check(ShellDesktopIniLocalizesFileNames(false, kAccessDenied, 0, 0), "desktopini-unknown-lookup-error-is-a-yes")) return false; + if (!Check(!ShellDesktopIniLocalizesFileNames(true, 0, 0, 0), "desktopini-without-the-section-is-normal")) return false; + if (!Check(ShellDesktopIniLocalizesFileNames(true, 0, 17, 0), "desktopini-with-the-section-is-a-yes")) return false; + if (!Check(!ShellDesktopIniLocalizesFileNames(true, 0, 0, kShellErrorFileNotFound), "desktopini-section-file-not-found-is-no")) return false; + if (!Check(ShellDesktopIniLocalizesFileNames(true, 0, 0, kAccessDenied), "desktopini-unreadable-section-is-a-yes")) return false; + if (!Check(ShellDesktopIniLocalizesFileNames(true, 0, 3, kAccessDenied), "desktopini-length-wins-over-an-error")) 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; } 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 index 82ce47f63..7ddefe470 100644 --- 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 @@ -6,8 +6,10 @@ // 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's entries whose names START WITH the display name (bounded, at most -// kShellMaxCandidates), only to narrow the field; and +// 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"). At most kShellMaxCandidates are kept, both kinds counted together; 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 @@ -52,6 +54,59 @@ using ShellNameEquals = bool (*)(std::wstring_view, std::wstring_view); 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 error numbers the folder checks below read (kept here so the rule stays pure). +constexpr unsigned long kShellErrorFileNotFound = 2; +constexpr unsigned long kShellErrorPathNotFound = 3; + +// 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). A desktop.ini is normal (a user's home +// has one), so its existence is not a yes; but what cannot be known is: a desktop.ini that cannot be +// looked up for another reason than "not there", and a section that cannot be read. Inputs: whether +// the file was found (and the error of that lookup if not), and the length the profile API returned +// for the section (and its error). +[[nodiscard]] inline bool ShellDesktopIniLocalizesFileNames(bool file_found, + unsigned long lookup_error, + std::size_t section_length, + unsigned long section_error) { + if (!file_found) + return !(lookup_error == kShellErrorFileNotFound || lookup_error == kShellErrorPathNotFound); + if (section_length != 0) return true; + return section_error != 0 && section_error != kShellErrorFileNotFound; +} + +// 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) { 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 7b13c2286..76aa47322 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 @@ -215,16 +215,67 @@ struct AbsoluteIdList { 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 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). +[[nodiscard]] inline bool ShellPathHasLink(const std::wstring &root, const std::wstring &path) { + 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()) { + 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; + if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 && + (data.dwReserved0 == IO_REPARSE_TAG_SYMLINK || data.dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT)) + 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 file system access that could block, and fail-closed (a doubt is "no"): -// - a network location (a UNC path, a remote, optical or unknown drive) can block for long; +// 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. The +// drive is that of the VOLUME the path is on (GetVolumePathNameW), not of its first three +// characters, and a symbolic link or mount point on the way (which may lead to such a location) is +// refused; // - 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; +// 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. [[nodiscard]] inline bool ShellFolderMayBeListed(const std::wstring &path) { if (path.size() < 3 || path[0] == L'\\') return false; if (path[1] != L':' || path[2] != L'\\') return false; - const std::wstring root = path.substr(0, 3); + std::wstring root(MAX_PATH * 2, L'\0'); + 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; const UINT drive = GetDriveTypeW(root.c_str()); if (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || drive == DRIVE_NO_ROOT_DIR) @@ -232,7 +283,7 @@ struct AbsoluteIdList { const std::wstring trimmed = ShellPathWithoutTrailingSeparator(path); for (const KNOWNFOLDERID &known : {FOLDERID_Desktop, FOLDERID_PublicDesktop}) { PWSTR folder = nullptr; - if (FAILED(SHGetKnownFolderPath(known, 0, nullptr, &folder)) || folder == nullptr) { + if (FAILED(SHGetKnownFolderPath(known, KF_FLAG_DONT_VERIFY, nullptr, &folder)) || folder == nullptr) { if (folder != nullptr) CoTaskMemFree(folder); return false; } @@ -240,27 +291,28 @@ struct AbsoluteIdList { CoTaskMemFree(folder); if (same) return false; } - return true; + return !ShellPathHasLink(root, path); } // 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 [LocalizedFileNames] section does that. Then an entry that is not a -// candidate by its file name could still carry the display name, so the folder is not looked into. -// Only that section is read (a desktop.ini is normal, in a user's home for one; it is not by itself a -// reason), and what cannot be read is taken to be a yes (fail-closed). +// names (a desktop.ini with a non-empty [LocalizedFileNames] section: see +// ShellDesktopIniLocalizesFileNames for the rule, which treats what cannot be read as a yes). Then an +// entry that is not a candidate by its file name could still carry the display name. Only that +// section is read; the file is never opened otherwise. [[nodiscard]] inline bool ShellFolderHasLocalizedFileNames(const std::wstring &path) { const std::wstring settings = ShellPathWithoutTrailingSeparator(path) + L"\\desktop.ini"; - const DWORD attributes = GetFileAttributesW(settings.c_str()); - if (attributes == INVALID_FILE_ATTRIBUTES) { - const DWORD error = GetLastError(); - return !(error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND); - } - std::wstring section(32767, L'\0'); - SetLastError(0); - const DWORD length = GetPrivateProfileSectionW(L"LocalizedFileNames", section.data(), - static_cast(section.size()), settings.c_str()); - if (length != 0) return true; - return GetLastError() != 0 && GetLastError() != ERROR_FILE_NOT_FOUND; + const bool found = GetFileAttributesW(settings.c_str()) != INVALID_FILE_ATTRIBUTES; + const DWORD lookup_error = found ? 0 : GetLastError(); + DWORD length = 0; + DWORD section_error = 0; + if (found) { + std::wstring section(32767, L'\0'); + SetLastError(0); + length = GetPrivateProfileSectionW(L"LocalizedFileNames", section.data(), + static_cast(section.size()), settings.c_str()); + section_error = GetLastError(); + } + return ShellDesktopIniLocalizesFileNames(found, lookup_error, length, section_error); } // The path of the item a file browser shows under `display_name`. The folder the view shows is asked @@ -357,7 +409,10 @@ struct AbsoluteIdList { 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 without a desktop.ini: decided before the first listing. + // 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). + ShadowReadResult stopped = ShadowReadResult::kAbsent; + if (ShellLookupMustStop(limits, &stopped)) return stopped; if (!ShellFolderMayBeListed(folder_path)) return ShadowReadResult::kAbsent; std::wstring directory = ShellPathWithoutTrailingSeparator(folder_path); directory.push_back(L'\\'); @@ -367,11 +422,13 @@ struct AbsoluteIdList { // 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; if (ShellFolderHasLocalizedFileNames(folder_path)) return ShadowReadResult::kAbsent; 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) { @@ -383,21 +440,20 @@ struct AbsoluteIdList { if (!limits.still_valid()) return ShadowReadResult::kStopped; if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent; const std::wstring_view found(entry.cFileName); - // An entry needs its shell name only when it can carry the display name: its file name starts - // with it, or it is a read-only / system directory (a desktop.ini can rename those). Hidden - // entries are not left out (a doubt keeps an entry, which can only make the answer "not known"). - const bool starts_with_display = - found.size() >= display_name.size() && - ShellNameEqualsOrdinal(found.substr(0, display_name.size()), display_name); - const bool renamable_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && - (entry.dwFileAttributes & - (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0; - if (found != L"." && found != L".." && (starts_with_display || renamable_directory)) { + 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 = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + candidate.is_directory = is_directory; candidates.push_back(std::move(candidate)); } if (!FindNextFileW(find.handle, &entry)) { @@ -405,21 +461,26 @@ struct AbsoluteIdList { return ShadowReadResult::kFailed; } } - // The name the shell shows for each candidate. One that cannot be read is left unknown, which makes - // the whole answer "not known" (it might be the one, or a second one). + // 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 (!limits.still_valid()) return ShadowReadResult::kStopped; - if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent; + if (ShellLookupMustStop(limits, &stopped)) return stopped; ComRef item; PWSTR shown = nullptr; + 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; @@ -684,39 +745,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; - // 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; + 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); 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 591c3f9b9..668953c06 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 @@ -274,6 +274,12 @@ describe('Windows UI Automation source only reads', () => { 'ShellDisplayNameUsable', 'MatchShellDisplayName', 'ShellFolderMayBeListed', + 'ShellLookupMustStop', + 'ShellPathHasLink', + 'ShellEntryNeedsShellName', + 'ShellDesktopIniLocalizesFileNames', + 'ShellResolvedNameIsTheCandidate', + 'ShellLeafName', 'ShellFolderHasLocalizedFileNames', 'ShellPathWithoutTrailingSeparator', 'ShellLookupRunning', @@ -346,6 +352,7 @@ describe('Windows UI Automation source only reads', () => { 'GetCurrentProcess', 'GetCurrentThread', 'GetPrivateProfileSectionW', + 'GetVolumePathNameW', 'SetLastError', 'ILIsEmpty', 'NormalizeString', @@ -394,9 +401,17 @@ describe('Windows UI Automation source only reads', () => { 'ShellDisplayNameUsable', 'MatchShellDisplayName', 'ShellFolderMayBeListed', + 'ShellLookupMustStop', + 'ShellPathHasLink', + 'ShellEntryNeedsShellName', + 'ShellDesktopIniLocalizesFileNames', + 'ShellResolvedNameIsTheCandidate', + 'ShellLeafName', 'ShellFolderHasLocalizedFileNames', 'ShellPathWithoutTrailingSeparator', 'AbsoluteIdList', + 'ComApartment', + 'Finish', 'ShellLookupPending', 'ShellLookupRunning', 'InterlockShellLookupRunning', @@ -509,8 +524,9 @@ describe('Windows UI Automation source only reads', () => { 'ShellItemPathByName(HWND tab', '} // namespace windows_uia_source_detail', ); - // The folder is listed in THIS process, by the display name as a prefix: no walk over the view's - // items (IFolderView2::Item / ItemCount) and no per-item shell object are left. + 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, ); @@ -526,8 +542,7 @@ describe('Windows UI Automation source only reads', () => { // 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, and the - // matcher compares those. A candidate whose name cannot be read stays unknown (the whole answer is). + // 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, @@ -535,40 +550,105 @@ describe('Windows UI Automation source only reads', () => { 'MatchShellDisplayName(', ); expect(names).toContain('SHCreateItemFromParsingName('); + expect(names).toContain('GetDisplayName(SIGDN_FILESYSPATH, &resolved)'); expect(names).toContain('GetDisplayName(SIGDN_NORMALDISPLAY, &shown)'); - expect(names).toContain('candidate.shell_name_known = true;'); - expect(names).toContain('if (!limits.still_valid()) return ShadowReadResult::kStopped;'); 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', - 'if (!ShellFolderMayBeListed(folder_path)) return ShadowReadResult::kAbsent;', + '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))', + ); + // 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)) return ShadowReadResult::kAbsent;', + ); + expect(compact).toContain( + 'if (ShellFolderHasLocalizedFileNames(folder_path)) return ShadowReadResult::kAbsent;', + ); + // 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))', + 'if (ShellFolderHasLocalizedFileNames(folder_path))', + 'find.handle = FindFirstFileExW(', + 'SHCreateItemFromParsingName(', + ]) { + const at = compact.indexOf(step); + expect(at, step).toBeGreaterThan(0); + expect(compact.slice(Math.max(0, at - 170), at), step).toMatch( + /if \(ShellLookupMustStop\(limits, &stopped\)\) return stopped;(?: ComRef item; PWSTR shown = nullptr; PWSTR resolved = nullptr;)? ?(?: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|renamable_directory|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) {', - 'The path of the item a file browser shows', + 'ShellFolderHasLocalizedFileNames(const std::wstring &path) {', ); + const guardCompact = guardFunction.replace(/\s+/gu, ' '); expect(guardFunction.includes("path[0] == L'" + '\\\\' + "'")).toBe(true); - for (const part of [ - 'DRIVE_REMOTE', - 'DRIVE_CDROM', - 'DRIVE_UNKNOWN', - 'DRIVE_NO_ROOT_DIR', - 'FOLDERID_Desktop', - 'FOLDERID_PublicDesktop', - ]) - expect(guardFunction, part).toContain(part); - // A desktop.ini is normal (a user's home has one): only its [LocalizedFileNames] section counts, and - // it is read with the profile API, never opened; what cannot be read is a yes (fail-closed). + expect(guardCompact).toContain( + 'if (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || drive == DRIVE_NO_ROOT_DIR) return false;', + ); + 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('return !ShellPathHasLink(root, path);'); + expect(guardFunction).not.toContain('substr(0, 3)'); + const link = between( + source, + 'ShellPathHasLink(const std::wstring &root', + 'ShellFolderMayBeListed(', + ); + expect(link).toContain('IO_REPARSE_TAG_SYMLINK'); + expect(link).toContain('IO_REPARSE_TAG_MOUNT_POINT'); + 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 read with the profile API, never opened, and the pure rule turns the answers into a verdict. const localized = between( source, 'ShellFolderHasLocalizedFileNames(const std::wstring &path) {', @@ -576,24 +656,12 @@ describe('Windows UI Automation source only reads', () => { ); expect(localized).toContain('GetPrivateProfileSectionW(L"LocalizedFileNames"'); expect(localized).toContain('desktop.ini'); - expect(code(localized)).not.toMatch(/CreateFile|ReadFile|ifstream|fopen/u); - expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path)')).toBeGreaterThanOrEqual(0); - expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path)')).toBeLessThan(find); - // Which entries need the shell's name: a file name that starts like the display name, and the - // read-only / system directories (a desktop.ini can only rename those); hidden entries are kept. - expect(body).toContain('FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM'); - expect(body).toContain('starts_with_display || renamable_directory'); - expect(body).toContain( - 'renamable_directory = (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 &&', - ); - // 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))', + expect(localized).toContain( + 'return ShellDesktopIniLocalizesFileNames(found, lookup_error, length, section_error);', ); - expect(code(body)).not.toContain('FILE_ATTRIBUTE_HIDDEN'); - // The guard decides before any listing of ours. - expect(guardFunction).not.toContain('FindFirstFileExW'); + expect(code(localized)).not.toMatch(/CreateFile|ReadFile|ifstream|fopen/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 @@ -612,7 +680,19 @@ describe('Windows UI Automation source only reads', () => { 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);', ); @@ -638,7 +718,7 @@ describe('Windows UI Automation source only reads', () => { // Both the walk over the shell windows and the listing of the entries stop on a cancel. expect( body.match(/if \(!limits\.still_valid\(\)\) return ShadowReadResult::kStopped;/gu)?.length, - ).toBe(3); + ).toBe(2); expect(body).toContain( 'if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent;', ); @@ -654,6 +734,24 @@ 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. @@ -663,24 +761,41 @@ describe('Windows UI Automation source only reads', () => { 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|stem|find\(/u); + 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()'); - expect(harness).toContain('shellname-lnk-collides-with-same-name'); - expect(harness).toContain('shellname-pdf-exe-hidden-is-the-second'); - expect(harness).toContain('shellname-never-shown-extension-is-the-shells-answer'); - expect(harness).toContain('shellname-x-and-x-website-collide'); - expect(harness).toContain('shellname-folder-with-a-dot-is-not-a-stem'); - expect(harness).toContain('shellname-unreadable-after-the-match'); - expect(harness).toContain('shellname-file-name-is-not-the-display-name'); - expect(harness).toContain('shellname-over-the-cap-is-unknown'); + 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-unreadable-section-is-a-yes', + 'desktopini-with-the-section-is-a-yes', + '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)', () => { diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index e314c097c..d80106059 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -663,7 +663,7 @@ 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 の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダの ID リストを取る。**照合は規則ではなく、シェルが実際に出す表示名で行う**。(1) 入口で fail-closed に外す場所(I/O の前に決める): ID リストが空(デスクトップ名前空間のルート。パスはユーザーのデスクトップだが、ビューは Public\\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所(検索、ライブラリ、PC)、UNC / リモート / 光学 / 不明なドライブ、`FOLDERID_Desktop` と `FOLDERID_PublicDesktop` のパス(ルートの ID リスト以外の入り口でも統合表示を避ける。実機では未確認の保険)、`desktop.ini` に `[LocalizedFileNames]` の節があるフォルダ(ファイルの表示名がファイル名と無関係になりうる。節の有無だけを `GetPrivateProfileSectionW` で読み、読めなければ「あり」とみなす。ユーザーのホームのように desktop.ini 自体があるのは普通なので、それだけでは外さない)。(2) フォルダを `FindFirstFileExW(フォルダ\\*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` で 1 回列挙する(ファイルシステムのメタデータだけ。時間は `kShellLookupBudgetNs`、取消しは項目ごと)。シェル表示名を取る候補は、ファイル名が表示名で始まるもの(大文字小文字を無視)と、読み取り専用または system 属性のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこの属性のフォルダだけ。「Documents」が日本語版で「ドキュメント」と出る場合を拾う)。隠しファイルは候補から外さない(迷ったら残し、曖昧になる側=判定なしに倒す)。候補は 64 件まで(超えたら判定なし)。(3) 各候補に `SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取り、portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が UIA の表示名と序数比較(`CompareStringOrdinal`、大文字小文字を無視)で照合する。ちょうど 1 件ならその実パスを返し、0 件、2 件以上、表示名が取れない候補が 1 つでもある、のどれかは判定なし(kAbsent)。拡張子を隠す設定、未登録の拡張子(LICENSE と LICENSE.md)、`.lnk` / `.website` など隠れる拡張子、ドットを含むフォルダ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない。拡張子の表示設定(`SSF_SHOWEXTENSIONS`)は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。(4) lookup のスレッドは新しく同期のファイルシステム I/O を発行する。入口で遅い場所を外すほか、待ちが切れた側が lookup に放棄を知らせ(次の一歩で止まる)、スレッドの複製ハンドルへ `CancelSynchronousIo` を送って、ブロックした呼び出しを打ち切る。応答しないローカル USB などでドライバが戻らない場合は取り消せない。**既知の限界**として、そのときは lookup の枠(`lookup_slot`)が埋まり続け、以降の lookup は判定なしになる(拒否にも許可にも倒れず、V1 に任せる)。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回、候補ごとの表示名の取得を含む): 項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 8〜11 ms 程度(最大 18 ms)で、項目数への依存は見えなかった。同名の `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(判定なし)、節の無い desktop.ini(取れる)、desktop.ini で別名になる読み取り専用フォルダ(シェル名で取れ、同名のファイルがあれば判定なし)を実機で確かめた。拡張子を隠す設定と、デスクトップ / Public Desktop を開いたときの挙動は実機で未確認で、harness の表と入口の判定(source 契約)で固定している。 +**S5-c-2 PR-3(Explorer の実ファイル名。2026-10-17。動作不変)**: 上の S5-c (2) の「Explorer の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダの ID リストを取る。**照合は規則ではなく、シェルが実際に出す表示名で行う**。(1) 入口で fail-closed に外す場所(I/O の前に決める): ID リストが空(デスクトップ名前空間のルート。パスはユーザーのデスクトップだが、ビューは Public\\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所(検索、ライブラリ、PC)、UNC / リモート / 光学 / 不明なドライブ、`FOLDERID_Desktop` と `FOLDERID_PublicDesktop` のパス(ルートの ID リスト以外の入り口でも統合表示を避ける。実機では未確認の保険)、`desktop.ini` に `[LocalizedFileNames]` の節があるフォルダ(ファイルの表示名がファイル名と無関係になりうる。節の有無だけを `GetPrivateProfileSectionW` で読み、読めなければ「あり」とみなす。ユーザーのホームのように desktop.ini 自体があるのは普通なので、それだけでは外さない)。(2) フォルダを `FindFirstFileExW(フォルダ\\*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` で 1 回列挙する(ファイルシステムのメタデータだけ。時間は `kShellLookupBudgetNs`、取消しは項目ごと)。シェル表示名を取る候補は、ファイル名が表示名で始まるもの(大文字小文字を無視)と、読み取り専用または system 属性のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこの属性のフォルダだけ。「Documents」が日本語版で「ドキュメント」と出る場合を拾う)。隠しファイルは候補から外さない(迷ったら残し、曖昧になる側=判定なしに倒す)。候補は 64 件まで(超えたら判定なし)。(3) 各候補に `SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取り、portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が UIA の表示名と序数比較(`CompareStringOrdinal`、大文字小文字を無視)で照合する。ちょうど 1 件ならその実パスを返し、0 件、2 件以上、表示名が取れない候補が 1 つでもある、のどれかは判定なし(kAbsent)。拡張子を隠す設定、未登録の拡張子(LICENSE と LICENSE.md)、`.lnk` / `.website` など隠れる拡張子、ドットを含むフォルダ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない。拡張子の表示設定(`SSF_SHOWEXTENSIONS`)は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。(4) lookup のスレッドは新しく同期のファイルシステム I/O を発行する。入口で遅い場所を外すほか、待ちが切れた側が lookup に放棄を知らせ(次の一歩で止まる)、スレッドの複製ハンドルへ `CancelSynchronousIo` を送って、ブロックした呼び出しを打ち切る。応答しないローカル USB などでドライバが戻らない場合は取り消せない。**既知の限界**として、そのときは lookup の枠(`lookup_slot`)が埋まり続け、以降の lookup は判定なしになる(拒否にも許可にも倒れず、V1 に任せる)。(5) 追加の fail-closed。ドライブは先頭 3 文字ではなく `GetVolumePathNameW` のボリュームで判定し、パスの途中に symbolic link / mount point があれば(ネットワークや光学ドライブを指しうる)判定なしにする(クラウドファイルのタグなど他の reparse point は許す)。`SHGetKnownFolderPath` は `KF_FLAG_DONT_VERIFY` で呼ぶ(リダイレクトされたデスクトップへのネットワーク I/O を避ける)。候補の解決先は `SIGDN_FILESYSPATH` の末尾が候補名と序数で一致するときだけ採る(末尾がドットや空白の名前は Win32 が別のエントリに正規化するので、最初から採らない。ID リスト由来の parsing name は既知フォルダで GUID になるため使わない)。新しい I/O を始める直前ごと(場所の判定、desktop.ini、最初の列挙、候補ごとの表示名)に取消しと期限を確かめる(待ちが切れた後に始まった I/O は取り消せないため)。lookup のスレッドは例外で process を落とさず、COM の解放、枠の解放、待っている側への通知は必ず通る。UIA 側では、ListItem が shell の項目 view(`SHELLDLL_DefView` または `UIItemsView`)の中にあるときだけ shell に尋ね、プレビューペインや検索候補の ListItem は実パス無し(kIncomplete)とする。(6) 上限 64 件は、前方一致の候補と読み取り専用 / system のディレクトリを合わせた「表示名を取る件数」の上限(列挙全体の件数ではない)。開発機での実測: ユーザーのホームで 22 件、C:\\Windows で 6 件、C:\\Windows\\System32(4,835 項目)で 6 件で、上限に届かず、いずれも表示名が取れた。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回、候補ごとの表示名の取得を含む): 項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 10〜14 ms 程度(最大 19 ms。リンクの確認を含む)で、項目数への依存は見えなかった。同名の `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(判定なし)、節の無い desktop.ini(取れる)、desktop.ini で別名になる読み取り専用フォルダ(シェル名で取れ、同名のファイルがあれば判定なし)を実機で確かめた。拡張子を隠す設定と、デスクトップ / Public Desktop を開いたときの挙動は実機で未確認で、harness の表と入口の判定(source 契約)で固定している。 **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 で版を上げるときにしか決まらない。 From dca9240f6f9f2f964ead9d20b418b8650b64b617 Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 11:09:31 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20ReviewBOT=20=E6=8C=87=E6=91=98=E3=81=AE=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=EF=BC=88desktop.ini=20=E3=81=A8=E3=83=AA=E3=83=B3=E3=82=AF?= =?UTF-8?q?=E3=81=AE=E6=89=B1=E3=81=84=E3=83=BBI/O=20=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E7=A2=BA=E8=AA=8D=E3=83=BB=E3=83=89=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=96=E5=88=A4=E5=AE=9A=E3=81=AE=202=20=E6=AE=B5?= =?UTF-8?q?=E9=9A=8E=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desktop.ini は読み取り専用/system のフォルダでだけ読み、desktop.ini 自体が reparse point なら読まずに「あり」とする - symlink / mount point の候補は表示名を取らず unknown にする - GetDriveTypeW(先頭 3 文字) を I/O なしで先に判定し、その後に GetVolumePathNameW - ShellFolderMayBeListed / ShellPathHasLink / desktop.ini の各 I/O の前に取消しと期限を確認 - 候補上限・TOCTOU・マウントルートのコメント、列挙ループを ShellLookupMustStop に統一 Co-Authored-By: Claude Opus 5.5 --- .../computer_use_protocol_fuzz.cc | 42 +++-- .../computer_use_windows_shell_name_match.h | 41 ++++- .../computer_use_windows_uia_source.h | 116 ++++++++++---- ...computer-use-windows-shadow-reader.test.ts | 151 +++++++++++++++--- .../adr-computer-use-agent-driven-v2.md | 2 +- 5 files changed, 278 insertions(+), 74 deletions(-) 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 3c54c0a87..69cf74e15 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -6902,17 +6902,41 @@ bool CheckWindowsShellNameMatch() { 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 section's length and the errors decide. + // 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; - if (!Check(!ShellDesktopIniLocalizesFileNames(false, kShellErrorFileNotFound, 0, 0), "desktopini-none-file-not-found")) return false; - if (!Check(!ShellDesktopIniLocalizesFileNames(false, kShellErrorPathNotFound, 0, 0), "desktopini-none-path-not-found")) return false; - if (!Check(ShellDesktopIniLocalizesFileNames(false, kAccessDenied, 0, 0), "desktopini-unknown-lookup-error-is-a-yes")) return false; - if (!Check(!ShellDesktopIniLocalizesFileNames(true, 0, 0, 0), "desktopini-without-the-section-is-normal")) return false; - if (!Check(ShellDesktopIniLocalizesFileNames(true, 0, 17, 0), "desktopini-with-the-section-is-a-yes")) return false; - if (!Check(!ShellDesktopIniLocalizesFileNames(true, 0, 0, kShellErrorFileNotFound), "desktopini-section-file-not-found-is-no")) return false; - if (!Check(ShellDesktopIniLocalizesFileNames(true, 0, 0, kAccessDenied), "desktopini-unreadable-section-is-a-yes")) return false; - if (!Check(ShellDesktopIniLocalizesFileNames(true, 0, 3, kAccessDenied), "desktopini-length-wins-over-an-error")) return false; + constexpr unsigned long kDirectory = 0x10; + const auto ini = [](bool folder_known, unsigned long folder_attributes, bool found, unsigned long lookup_error, + bool reparse, std::size_t length, unsigned long section_error) { + return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, found, lookup_error, reparse, length, + section_error); + }; + const unsigned long read_only = kDirectory | kShellAttributeReadOnly; + const unsigned long system_dir = kDirectory | kShellAttributeSystem; + if (!Check(!ini(true, kDirectory, true, 0, false, 17, 0), "desktopini-plain-folder-is-not-read")) return false; + if (!Check(!ini(true, kDirectory, true, 0, true, 17, 0), "desktopini-plain-folder-link-is-not-read-either")) return false; + if (!Check(ini(false, 0, true, 0, false, 0, 0), "desktopini-unknown-folder-attributes-is-a-yes")) return false; + if (!Check(!ini(true, read_only, false, kShellErrorFileNotFound, false, 0, 0), "desktopini-none-file-not-found")) return false; + if (!Check(!ini(true, system_dir, false, kShellErrorPathNotFound, false, 0, 0), "desktopini-none-path-not-found")) return false; + if (!Check(ini(true, read_only, false, kAccessDenied, false, 0, 0), "desktopini-unknown-lookup-error-is-a-yes")) return false; + if (!Check(ini(true, read_only, true, 0, true, 0, 0), "desktopini-link-is-a-yes-without-reading")) return false; + if (!Check(ini(true, system_dir, true, 0, true, 0, 0), "desktopini-link-in-a-system-folder-is-a-yes")) return false; + if (!Check(!ini(true, read_only, true, 0, false, 0, 0), "desktopini-without-the-section-is-normal")) return false; + if (!Check(ini(true, read_only, true, 0, false, 17, 0), "desktopini-with-the-section-is-a-yes")) return false; + if (!Check(ini(true, system_dir, true, 0, false, 17, 0), "desktopini-with-the-section-in-a-system-folder-is-a-yes")) return false; + if (!Check(!ini(true, read_only, true, 0, false, 0, kShellErrorFileNotFound), "desktopini-section-file-not-found-is-no")) return false; + if (!Check(ini(true, read_only, true, 0, false, 0, kAccessDenied), "desktopini-unreadable-section-is-a-yes")) return false; + if (!Check(ini(true, read_only, true, 0, false, 3, kAccessDenied), "desktopini-length-wins-over-an-error")) return false; + } + // A candidate that is a symbolic link or a mount point is never followed for its shell name. + { + 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, 0x9000001A), "link-cloud-tag-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; } // The leaf of a file system path identifies a resolved item (a known folder's parsing name is a GUID). { 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 index 7ddefe470..7892001e9 100644 --- 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 @@ -9,7 +9,11 @@ // 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"). At most kShellMaxCandidates are kept, both kinds counted together; and +// 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" (kAbsent). 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 @@ -35,6 +39,7 @@ struct ShellNameCandidate { bool is_directory = false; std::wstring shell_name; // the name the shell shows for it (valid when shell_name_known) bool shell_name_known = false; + bool is_link = false; // a symbolic link / mount point: its shell name is never asked for (unknown) }; constexpr std::size_t kShellMaxCandidates = 64; @@ -67,22 +72,42 @@ using ShellNameEquals = bool (*)(std::wstring_view, std::wstring_view); return is_directory && readonly_or_system; } -// The Win32 error numbers the folder checks below read (kept here so the rule stays pure). +// 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; + +// Whether a directory entry is a symbolic link or a mount point (a junction): 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 == kShellReparseTagSymlink || tag == kShellReparseTagMountPoint); +} // 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). A desktop.ini is normal (a user's home -// has one), so its existence is not a yes; but what cannot be known is: a desktop.ini that cannot be -// looked up for another reason than "not there", and a section that cannot be read. Inputs: whether -// the file was found (and the error of that lookup if not), and the length the profile API returned -// for the section (and its error). -[[nodiscard]] inline bool ShellDesktopIniLocalizesFileNames(bool file_found, +// 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 section that cannot be read, and a desktop.ini that is itself a reparse point of ANY kind (the +// profile API follows it, to a place that can be on the network, and is never asked). +[[nodiscard]] inline bool ShellDesktopIniLocalizesFileNames(bool folder_attributes_known, + unsigned long folder_attributes, + bool file_found, unsigned long lookup_error, + bool file_is_reparse_point, std::size_t section_length, unsigned long section_error) { + if (!folder_attributes_known) return true; + if ((folder_attributes & (kShellAttributeReadOnly | kShellAttributeSystem)) == 0) return false; if (!file_found) return !(lookup_error == kShellErrorFileNotFound || lookup_error == kShellErrorPathNotFound); + if (file_is_reparse_point) return true; if (section_length != 0) return true; return section_error != 0 && section_error != kShellErrorFileNotFound; } 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 76aa47322..6063790a4 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 @@ -231,24 +231,37 @@ struct AbsoluteIdList { 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; +} + // 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). -[[nodiscard]] inline bool ShellPathHasLink(const std::wstring &root, const std::wstring &path) { +// as a link (fail-closed), and so does a stop (see ShellLookupMustStop; *result then says why). +[[nodiscard]] inline bool ShellPathHasLink(const std::wstring &root, const std::wstring &path, + const ShellLookupLimits &limits, ShadowReadResult *result) { 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; - if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 && - (data.dwReserved0 == IO_REPARSE_TAG_SYMLINK || data.dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT)) + if (ShellReparseIsLink((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0, + data.dwReserved0)) return true; } if (next == std::wstring::npos) return false; @@ -259,30 +272,45 @@ struct AbsoluteIdList { // 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. The -// drive is that of the VOLUME the path is on (GetVolumePathNameW), not of its first three -// characters, and a symbolic link or mount point on the way (which may lead to such a location) is -// refused; +// - a network location (a UNC path, a remote, optical or unknown drive) can block for long. It is +// excluded in TWO steps: 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), +// and only then GetVolumePathNameW finds the VOLUME the path is on (a mount point inside a local +// drive can lead elsewhere), whose type is checked again. A symbolic link or mount point on the +// way is refused. 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. -[[nodiscard]] inline bool ShellFolderMayBeListed(const std::wstring &path) { +// +// 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: the volume the path is really on, and its type. From here on every call may block. 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; - const UINT drive = GetDriveTypeW(root.c_str()); - if (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || - drive == DRIVE_NO_ROOT_DIR) - 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; @@ -291,7 +319,7 @@ struct AbsoluteIdList { CoTaskMemFree(folder); if (same) return false; } - return !ShellPathHasLink(root, path); + return !ShellPathHasLink(root, path, limits, result); } // Whether the files of the folder can be shown under names that have nothing to do with their file @@ -299,20 +327,43 @@ struct AbsoluteIdList { // ShellDesktopIniLocalizesFileNames for the rule, which treats what cannot be read as a yes). Then an // entry that is not a candidate by its file name could still carry the display name. Only that // section is read; the file is never opened otherwise. -[[nodiscard]] inline bool ShellFolderHasLocalizedFileNames(const std::wstring &path) { - const std::wstring settings = ShellPathWithoutTrailingSeparator(path) + L"\\desktop.ini"; - const bool found = GetFileAttributesW(settings.c_str()) != INVALID_FILE_ATTRIBUTES; - const DWORD lookup_error = found ? 0 : GetLastError(); +[[nodiscard]] inline bool ShellFolderHasLocalizedFileNames(const std::wstring &path, + const ShellLookupLimits &limits, + ShadowReadResult *result) { + const std::wstring folder = ShellPathWithoutTrailingSeparator(path); + const std::wstring settings = folder + L"\\desktop.ini"; + // A stop counts as "yes" (fail-closed); *result then says why. Asked before EVERY call below. + // The shell reads a desktop.ini only in a folder that is read-only or system: other folders are + // not read at all. And a desktop.ini that is a link (of any kind) is never read, because the profile + // API would follow it, possibly to a network location (and authenticate there). + if (ShellLookupMustStop(limits, result)) return true; + const DWORD folder_attributes = GetFileAttributesW(folder.c_str()); + const bool folder_known = folder_attributes != INVALID_FILE_ATTRIBUTES; + bool found = false; + DWORD lookup_error = 0; + bool is_reparse_point = false; DWORD length = 0; DWORD section_error = 0; - if (found) { - std::wstring section(32767, L'\0'); - SetLastError(0); - length = GetPrivateProfileSectionW(L"LocalizedFileNames", section.data(), - static_cast(section.size()), settings.c_str()); - section_error = GetLastError(); - } - return ShellDesktopIniLocalizesFileNames(found, lookup_error, length, section_error); + if (folder_known && (folder_attributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0) { + if (ShellLookupMustStop(limits, result)) return true; + WIN32_FIND_DATAW data{}; + FindHandle find; + find.handle = FindFirstFileExW(settings.c_str(), FindExInfoBasic, &data, FindExSearchNameMatch, + nullptr, 0); + found = find.handle != INVALID_HANDLE_VALUE; + lookup_error = found ? 0 : GetLastError(); + is_reparse_point = found && (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + if (found && !is_reparse_point) { + if (ShellLookupMustStop(limits, result)) return true; + std::wstring section(32767, L'\0'); + SetLastError(0); + length = GetPrivateProfileSectionW(L"LocalizedFileNames", section.data(), + static_cast(section.size()), settings.c_str()); + section_error = GetLastError(); + } + } + return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, found, lookup_error, + is_reparse_point, length, section_error); } // The path of the item a file browser shows under `display_name`. The folder the view shows is asked @@ -413,7 +464,8 @@ struct AbsoluteIdList { // up or the deadline passed (a call started after the cancel could not be canceled). ShadowReadResult stopped = ShadowReadResult::kAbsent; if (ShellLookupMustStop(limits, &stopped)) return stopped; - if (!ShellFolderMayBeListed(folder_path)) return ShadowReadResult::kAbsent; + stopped = ShadowReadResult::kAbsent; + if (!ShellFolderMayBeListed(folder_path, limits, &stopped)) return stopped; std::wstring directory = ShellPathWithoutTrailingSeparator(folder_path); directory.push_back(L'\\'); // The folder itself: every entry (a name that is only the display name's prefix does not decide). @@ -423,7 +475,8 @@ struct AbsoluteIdList { 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; - if (ShellFolderHasLocalizedFileNames(folder_path)) return ShadowReadResult::kAbsent; + stopped = ShadowReadResult::kAbsent; + if (ShellFolderHasLocalizedFileNames(folder_path, limits, &stopped)) return stopped; std::vector candidates; FindHandle find; @@ -437,8 +490,7 @@ struct AbsoluteIdList { : ShadowReadResult::kFailed; } for (;;) { - if (!limits.still_valid()) return ShadowReadResult::kStopped; - if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent; + 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 @@ -454,6 +506,9 @@ struct AbsoluteIdList { ShellNameCandidate candidate; candidate.name = std::wstring(found); candidate.is_directory = is_directory; + // A link is never followed to get a name (its target can be on the network): it stays unknown. + candidate.is_link = ShellReparseIsLink((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0, + entry.dwReserved0); candidates.push_back(std::move(candidate)); } if (!FindNextFileW(find.handle, &entry)) { @@ -466,6 +521,7 @@ struct AbsoluteIdList { // answer "not known" (it might be the one, or a second one). for (ShellNameCandidate &candidate : candidates) { if (ShellLookupMustStop(limits, &stopped)) return stopped; + if (candidate.is_link) continue; ComRef item; PWSTR shown = nullptr; PWSTR resolved = nullptr; 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 668953c06..9f94ec207 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 @@ -274,6 +274,8 @@ describe('Windows UI Automation source only reads', () => { 'ShellDisplayNameUsable', 'MatchShellDisplayName', 'ShellFolderMayBeListed', + 'ShellDriveIsLocalDisk', + 'ShellReparseIsLink', 'ShellLookupMustStop', 'ShellPathHasLink', 'ShellEntryNeedsShellName', @@ -401,6 +403,8 @@ describe('Windows UI Automation source only reads', () => { 'ShellDisplayNameUsable', 'MatchShellDisplayName', 'ShellFolderMayBeListed', + 'ShellDriveIsLocalDisk', + 'ShellReparseIsLink', 'ShellLookupMustStop', 'ShellPathHasLink', 'ShellEntryNeedsShellName', @@ -568,7 +572,7 @@ describe('Windows UI Automation source only reads', () => { for (const guard of [ 'ILIsEmpty(folder_id.id)', 'SFGAO_ISSLOW', - 'ShellFolderMayBeListed(folder_path)', + 'ShellFolderMayBeListed(folder_path', ]) { expect(body.indexOf(guard), guard).toBeGreaterThanOrEqual(0); expect(body.indexOf(guard), guard).toBeLessThan(find); @@ -580,28 +584,28 @@ describe('Windows UI Automation source only reads', () => { ); // 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('ShellFolderMayBeListed(folder_path')).toBeLessThan( + body.indexOf('ShellFolderHasLocalizedFileNames(folder_path'), ); - expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path)')).toBeLessThan(find); + expect(body.indexOf('ShellFolderHasLocalizedFileNames(folder_path')).toBeLessThan(find); expect(compact).toContain( - 'if (!ShellFolderMayBeListed(folder_path)) return ShadowReadResult::kAbsent;', + 'if (!ShellFolderMayBeListed(folder_path, limits, &stopped)) return stopped;', ); expect(compact).toContain( - 'if (ShellFolderHasLocalizedFileNames(folder_path)) return ShadowReadResult::kAbsent;', + '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))', - 'if (ShellFolderHasLocalizedFileNames(folder_path))', + '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 - 170), at), step).toMatch( - /if \(ShellLookupMustStop\(limits, &stopped\)\) return stopped;(?: ComRef item; PWSTR shown = nullptr; PWSTR resolved = nullptr;)? ?(?:if \(SUCCEEDED\()?$/u, + 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( @@ -621,14 +625,11 @@ describe('Windows UI Automation source only reads', () => { // 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) {', + '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 (drive == DRIVE_REMOTE || drive == DRIVE_CDROM || drive == DRIVE_UNKNOWN || drive == DRIVE_NO_ROOT_DIR) return false;', - ); expect(guardCompact).toContain('if (same) return false;'); expect(guardCompact).toContain('FOLDERID_Desktop, FOLDERID_PublicDesktop'); expect(guardCompact).toContain( @@ -636,28 +637,47 @@ describe('Windows UI Automation source only reads', () => { ); expect(guardCompact).toContain('GetVolumePathNameW(path.c_str(), root.data()'); expect(guardCompact).toContain('GetDriveTypeW(root.c_str())'); - expect(guardCompact).toContain('return !ShellPathHasLink(root, path);'); - expect(guardFunction).not.toContain('substr(0, 3)'); + expect(guardCompact).toContain('return !ShellPathHasLink(root, 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('IO_REPARSE_TAG_SYMLINK'); - expect(link).toContain('IO_REPARSE_TAG_MOUNT_POINT'); + expect(link).toContain( + 'ShellReparseIsLink((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0,', + ); 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 read with the profile API, never opened, and the pure rule turns the answers into a verdict. const localized = between( source, - 'ShellFolderHasLocalizedFileNames(const std::wstring &path) {', + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', 'The path of the item a file browser shows', ); expect(localized).toContain('GetPrivateProfileSectionW(L"LocalizedFileNames"'); expect(localized).toContain('desktop.ini'); - expect(localized).toContain( - 'return ShellDesktopIniLocalizesFileNames(found, lookup_error, length, section_error);', + expect(localized.replace(/\s+/gu, ' ')).toContain( + 'return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, found, lookup_error, is_reparse_point, length, section_error);', ); expect(code(localized)).not.toMatch(/CreateFile|ReadFile|ifstream|fopen/u); // The guards decide before any listing of ours. @@ -715,12 +735,91 @@ describe('Windows UI Automation source only reads', () => { 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 listing of the entries 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); - expect(body).toContain( - 'if (limits.now_ns() > limits.deadline_ns) return ShadowReadResult::kAbsent;', + ).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.lastIndexOf('ShellLookupMustStop(limits, result)', known)).toBeGreaterThan( + mayBe.indexOf('GetDriveTypeW('), + ); + const ini = between( + source, + 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', + 'The path of the item a file browser shows', + ); + expect(ini.indexOf('ShellLookupMustStop(limits, result)')).toBeLessThan( + ini.indexOf('GetFileAttributesW('), + ); + expect(ini.lastIndexOf('ShellLookupMustStop(limits, result)')).toBeGreaterThan( + ini.indexOf('GetFileAttributesW('), + ); + expect(ini.lastIndexOf('ShellLookupMustStop(limits, result)')).toBeLessThan( + ini.indexOf('GetPrivateProfileSectionW('), + ); + // A known limit, stated where it applies. + expect(source).toContain('(TOCTOU)'); + // A desktop.ini is read only in a read-only / system folder (what the shell honours) and never when + // it is itself a link of any kind: the profile API would follow it, possibly to the network. + const iniCompact = ini.replace(/\s+/gu, ' '); + expect(iniCompact).toContain( + '(folder_attributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0', + ); + expect(iniCompact).toContain('FindFirstFileExW(settings.c_str(), FindExInfoBasic'); + expect(iniCompact).toContain( + 'is_reparse_point = found && (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;', + ); + expect(iniCompact).toContain('if (found && !is_reparse_point) {'); + expect(ini.indexOf('FindFirstFileExW(')).toBeLessThan( + ini.indexOf('GetPrivateProfileSectionW('), + ); + expect(ini.indexOf('FILE_ATTRIBUTE_READONLY')).toBeLessThan(ini.indexOf('FindFirstFileExW(')); + expect( + ini.lastIndexOf('ShellLookupMustStop(limits, result)', ini.indexOf('FindFirstFileExW(')), + ).toBeGreaterThan(ini.indexOf('GetFileAttributesW(')); + // A candidate that is a link gets no shell name: no call that could reach its target. + const linkSkip = between( + body, + 'for (ShellNameCandidate &candidate : candidates) {', + 'MatchShellDisplayName(', + ); + expect(linkSkip.indexOf('if (candidate.is_link) continue;')).toBeGreaterThan(0); + expect(linkSkip.indexOf('if (candidate.is_link) continue;')).toBeLessThan( + linkSkip.indexOf('SHCreateItemFromParsingName('), + ); + expect(body.replace(/\s+/gu, ' ')).toContain( + 'candidate.is_link = ShellReparseIsLink((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0, entry.dwReserved0);', ); // More candidates than the cap are not known; so are two entries a name can stand for (the matcher). expect(body).toContain( diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index dbbe33cd8..01dc82449 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -664,7 +664,7 @@ 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 の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダの ID リストを取る。**照合は規則ではなく、シェルが実際に出す表示名で行う**。(1) 入口で fail-closed に外す場所(I/O の前に決める): ID リストが空(デスクトップ名前空間のルート。パスはユーザーのデスクトップだが、ビューは Public\\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所(検索、ライブラリ、PC)、UNC / リモート / 光学 / 不明なドライブ、`FOLDERID_Desktop` と `FOLDERID_PublicDesktop` のパス(ルートの ID リスト以外の入り口でも統合表示を避ける。実機では未確認の保険)、`desktop.ini` に `[LocalizedFileNames]` の節があるフォルダ(ファイルの表示名がファイル名と無関係になりうる。節の有無だけを `GetPrivateProfileSectionW` で読み、読めなければ「あり」とみなす。ユーザーのホームのように desktop.ini 自体があるのは普通なので、それだけでは外さない)。(2) フォルダを `FindFirstFileExW(フォルダ\\*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` で 1 回列挙する(ファイルシステムのメタデータだけ。時間は `kShellLookupBudgetNs`、取消しは項目ごと)。シェル表示名を取る候補は、ファイル名が表示名で始まるもの(大文字小文字を無視)と、読み取り専用または system 属性のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこの属性のフォルダだけ。「Documents」が日本語版で「ドキュメント」と出る場合を拾う)。隠しファイルは候補から外さない(迷ったら残し、曖昧になる側=判定なしに倒す)。候補は 64 件まで(超えたら判定なし)。(3) 各候補に `SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取り、portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が UIA の表示名と序数比較(`CompareStringOrdinal`、大文字小文字を無視)で照合する。ちょうど 1 件ならその実パスを返し、0 件、2 件以上、表示名が取れない候補が 1 つでもある、のどれかは判定なし(kAbsent)。拡張子を隠す設定、未登録の拡張子(LICENSE と LICENSE.md)、`.lnk` / `.website` など隠れる拡張子、ドットを含むフォルダ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない。拡張子の表示設定(`SSF_SHOWEXTENSIONS`)は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。(4) lookup のスレッドは新しく同期のファイルシステム I/O を発行する。入口で遅い場所を外すほか、待ちが切れた側が lookup に放棄を知らせ(次の一歩で止まる)、スレッドの複製ハンドルへ `CancelSynchronousIo` を送って、ブロックした呼び出しを打ち切る。応答しないローカル USB などでドライバが戻らない場合は取り消せない。**既知の限界**として、そのときは lookup の枠(`lookup_slot`)が埋まり続け、以降の lookup は判定なしになる(拒否にも許可にも倒れず、V1 に任せる)。(5) 追加の fail-closed。ドライブは先頭 3 文字ではなく `GetVolumePathNameW` のボリュームで判定し、パスの途中に symbolic link / mount point があれば(ネットワークや光学ドライブを指しうる)判定なしにする(クラウドファイルのタグなど他の reparse point は許す)。`SHGetKnownFolderPath` は `KF_FLAG_DONT_VERIFY` で呼ぶ(リダイレクトされたデスクトップへのネットワーク I/O を避ける)。候補の解決先は `SIGDN_FILESYSPATH` の末尾が候補名と序数で一致するときだけ採る(末尾がドットや空白の名前は Win32 が別のエントリに正規化するので、最初から採らない。ID リスト由来の parsing name は既知フォルダで GUID になるため使わない)。新しい I/O を始める直前ごと(場所の判定、desktop.ini、最初の列挙、候補ごとの表示名)に取消しと期限を確かめる(待ちが切れた後に始まった I/O は取り消せないため)。lookup のスレッドは例外で process を落とさず、COM の解放、枠の解放、待っている側への通知は必ず通る。UIA 側では、ListItem が shell の項目 view(`SHELLDLL_DefView` または `UIItemsView`)の中にあるときだけ shell に尋ね、プレビューペインや検索候補の ListItem は実パス無し(kIncomplete)とする。(6) 上限 64 件は、前方一致の候補と読み取り専用 / system のディレクトリを合わせた「表示名を取る件数」の上限(列挙全体の件数ではない)。開発機での実測: ユーザーのホームで 22 件、C:\\Windows で 6 件、C:\\Windows\\System32(4,835 項目)で 6 件で、上限に届かず、いずれも表示名が取れた。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回、候補ごとの表示名の取得を含む): 項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 10〜14 ms 程度(最大 19 ms。リンクの確認を含む)で、項目数への依存は見えなかった。同名の `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(判定なし)、節の無い desktop.ini(取れる)、desktop.ini で別名になる読み取り専用フォルダ(シェル名で取れ、同名のファイルがあれば判定なし)を実機で確かめた。拡張子を隠す設定と、デスクトップ / Public Desktop を開いたときの挙動は実機で未確認で、harness の表と入口の判定(source 契約)で固定している。 +**S5-c-2 PR-3(Explorer の実ファイル名。2026-10-17。動作不変)**: 上の S5-c (2) の「Explorer の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダの ID リストを取る。**照合は規則ではなく、シェルが実際に出す表示名で行う**。(1) 入口で fail-closed に外す場所(I/O の前に決める): ID リストが空(デスクトップ名前空間のルート。パスはユーザーのデスクトップだが、ビューは Public\\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所(検索、ライブラリ、PC)、UNC / リモート / 光学 / 不明なドライブ、`FOLDERID_Desktop` と `FOLDERID_PublicDesktop` のパス(ルートの ID リスト以外の入り口でも統合表示を避ける。実機では未確認の保険)、`desktop.ini` に `[LocalizedFileNames]` の節があるフォルダ(ファイルの表示名がファイル名と無関係になりうる。節の有無だけを `GetPrivateProfileSectionW` で読み、読めなければ「あり」とみなす。ユーザーのホームのように desktop.ini 自体があるのは普通なので、それだけでは外さない)。(2) フォルダを `FindFirstFileExW(フォルダ\\*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` で 1 回列挙する(ファイルシステムのメタデータだけ。時間は `kShellLookupBudgetNs`、取消しは項目ごと)。シェル表示名を取る候補は、ファイル名が表示名で始まるもの(大文字小文字を無視)と、読み取り専用または system 属性のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこの属性のフォルダだけ。「Documents」が日本語版で「ドキュメント」と出る場合を拾う)。隠しファイルは候補から外さない(迷ったら残し、曖昧になる側=判定なしに倒す)。候補は 64 件まで(超えたら判定なし)。(3) 各候補に `SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取り、portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が UIA の表示名と序数比較(`CompareStringOrdinal`、大文字小文字を無視)で照合する。ちょうど 1 件ならその実パスを返し、0 件、2 件以上、表示名が取れない候補が 1 つでもある、のどれかは判定なし(kAbsent)。拡張子を隠す設定、未登録の拡張子(LICENSE と LICENSE.md)、`.lnk` / `.website` など隠れる拡張子、ドットを含むフォルダ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない。拡張子の表示設定(`SSF_SHOWEXTENSIONS`)は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。(4) lookup のスレッドは新しく同期のファイルシステム I/O を発行する。入口で遅い場所を外すほか、待ちが切れた側が lookup に放棄を知らせ(次の一歩で止まる)、スレッドの複製ハンドルへ `CancelSynchronousIo` を送って、ブロックした呼び出しを打ち切る。応答しないローカル USB などでドライバが戻らない場合は取り消せない。**既知の限界**として、そのときは lookup の枠(`lookup_slot`)が埋まり続け、以降の lookup は判定なしになる(拒否にも許可にも倒れず、V1 に任せる)。(5) 追加の fail-closed。ドライブは先頭 3 文字ではなく `GetVolumePathNameW` のボリュームで判定し、パスの途中に symbolic link / mount point があれば(ネットワークや光学ドライブを指しうる)判定なしにする(クラウドファイルのタグなど他の reparse point は許す)。`SHGetKnownFolderPath` は `KF_FLAG_DONT_VERIFY` で呼ぶ(リダイレクトされたデスクトップへのネットワーク I/O を避ける)。候補の解決先は `SIGDN_FILESYSPATH` の末尾が候補名と序数で一致するときだけ採る(末尾がドットや空白の名前は Win32 が別のエントリに正規化するので、最初から採らない。ID リスト由来の parsing name は既知フォルダで GUID になるため使わない)。新しい I/O を始める直前ごと(場所の判定、desktop.ini、最初の列挙、候補ごとの表示名)に取消しと期限を確かめる(待ちが切れた後に始まった I/O は取り消せないため)。lookup のスレッドは例外で process を落とさず、COM の解放、枠の解放、待っている側への通知は必ず通る。UIA 側では、ListItem が shell の項目 view(`SHELLDLL_DefView` または `UIItemsView`)の中にあるときだけ shell に尋ね、プレビューペインや検索候補の ListItem は実パス無し(kIncomplete)とする。(7) リンクと遅い場所。ドライブは 2 段階で外す: まずドライブ文字の種類(`GetDriveTypeW` を先頭 3 文字に。I/O なし。応答しない SMB 共有に割り当てたドライブには触れない)、その後に `GetVolumePathNameW` のボリュームを同じ判定にかける(マウントしたボリュームのルートそのものはパスより長くなるので判定なし)。desktop.ini は、フォルダが読み取り専用か system のときだけ読む(実機で確認: 同じ [LocalizedFileNames] を持つ desktop.ini は、通常のフォルダでは名前を変えず、読み取り専用 / system のフォルダでだけ名前を変えた)。desktop.ini 自体が reparse point(種類を問わず)なら、プロファイル API がリンク先(ネットワークでありうる。認証も起こりうる)をたどるので、読まずに「あり」とみなす。候補のエントリ自体が symbolic link / mount point(ジャンクション)なら、表示名を取りに行くとリンク先に触れるので、表示名は取らず unknown にする(全体が判定なし)。クラウドファイルなど他のタグは許す。この結果、ユーザーのホームのように hidden + system のジャンクション(Application Data、Cookies など 10 個)が並ぶフォルダでは、読み取り専用 / system のディレクトリを候補にする規則により、常に判定なしになる(fail closed の代償。hidden のジャンクションを候補から外す緩和は、Explorer が hidden を表示する設定のときの取り違えを許すので、行っていない)。確認してから列挙するまでの間にフォルダがリンクへ差し替えられる競合(TOCTOU)は残る。同じユーザーのローカルの操作に限られ、候補は FILESYSPATH の末尾で自分自身と確かめられ、dispatch 直前に再評価される(PR-5)ので許容する。(6) 上限 64 件は、前方一致の候補と読み取り専用 / system のディレクトリを合わせた「表示名を取る件数」の上限(列挙全体の件数ではない)。開発機での実測: ユーザーのホームで 22 件、C:\\Windows で 6 件、C:\\Windows\\System32(4,835 項目)で 6 件で、上限に届かず、いずれも表示名が取れた。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回、候補ごとの表示名の取得を含む): 項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 10〜14 ms 程度(最大 19 ms。リンクの確認を含む)で、項目数への依存は見えなかった。同名の `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(判定なし)、節の無い desktop.ini(取れる)、desktop.ini で別名になる読み取り専用フォルダ(シェル名で取れ、同名のファイルがあれば判定なし)を実機で確かめた。拡張子を隠す設定と、デスクトップ / Public Desktop を開いたときの挙動は実機で未確認で、harness の表と入口の判定(source 契約)で固定している。 **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 で版を上げるときにしか決まらない。 From 0f30d8119391d6722a7100b64b4efd1d4eec7aba Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 11:26:06 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AE=E5=8F=8D=E6=98=A0=EF=BC=88desktop.ini=20=E3=82=92?= =?UTF-8?q?=E3=83=8F=E3=83=B3=E3=83=89=E3=83=AB=E3=81=A7=E8=AA=AD=E3=82=80?= =?UTF-8?q?=E3=83=BB=E5=80=99=E8=A3=9C=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=81=AE=20desktop.ini=E3=83=BB=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E3=81=AE=E7=B7=A9=E5=92=8C=E3=81=A8=E9=A0=86?= =?UTF-8?q?=E5=BA=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desktop.ini は CreateFileW(FILE_FLAG_OPEN_REPARSE_POINT) で 1 回だけ開き、同じハンドルで reparse 判定・64 KB 上限・読み取りを行い、自前のパーサ (UTF-16/ANSI) で判定する - 読み取り専用/system の候補ディレクトリは、シェルに尋ねる前にその desktop.ini を読み、UNC/非ローカルのリソース参照や CLSID があれば unknown にする - パスのリンク確認をドライブのルートから先に行い、その後に GetVolumePathNameW。ローカルを指すジャンクションは通す - リンクは IsReparseTagNameSurrogate (bit 29) で判定、リンクの候補は matcher で一致させず、見つけた時点で全体を判定なしにする - フォルダに対するシェル呼び出しの前にも取消しと期限を確認、kAbsent と kNoShellMatch の対応のコメント Co-Authored-By: Claude Opus 5.5 --- .../computer_use_protocol_fuzz.cc | 199 ++++++++++++-- .../computer_use_windows_shell_name_match.h | 257 ++++++++++++++++-- .../computer_use_windows_uia_source.h | 256 ++++++++++++++--- ...computer-use-windows-shadow-reader.test.ts | 250 ++++++++++++++--- .../adr-computer-use-agent-driven-v2.md | 2 +- 5 files changed, 839 insertions(+), 125 deletions(-) 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 69cf74e15..577c9198f 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -6908,35 +6908,194 @@ bool CheckWindowsShellNameMatch() { { constexpr unsigned long kAccessDenied = 5; constexpr unsigned long kDirectory = 0x10; - const auto ini = [](bool folder_known, unsigned long folder_attributes, bool found, unsigned long lookup_error, - bool reparse, std::size_t length, unsigned long section_error) { - return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, found, lookup_error, reparse, length, - section_error); + 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, 17, 0), "desktopini-plain-folder-is-not-read")) return false; - if (!Check(!ini(true, kDirectory, true, 0, true, 17, 0), "desktopini-plain-folder-link-is-not-read-either")) return false; - if (!Check(ini(false, 0, true, 0, false, 0, 0), "desktopini-unknown-folder-attributes-is-a-yes")) return false; - if (!Check(!ini(true, read_only, false, kShellErrorFileNotFound, false, 0, 0), "desktopini-none-file-not-found")) return false; - if (!Check(!ini(true, system_dir, false, kShellErrorPathNotFound, false, 0, 0), "desktopini-none-path-not-found")) return false; - if (!Check(ini(true, read_only, false, kAccessDenied, false, 0, 0), "desktopini-unknown-lookup-error-is-a-yes")) return false; - if (!Check(ini(true, read_only, true, 0, true, 0, 0), "desktopini-link-is-a-yes-without-reading")) return false; - if (!Check(ini(true, system_dir, true, 0, true, 0, 0), "desktopini-link-in-a-system-folder-is-a-yes")) return false; - if (!Check(!ini(true, read_only, true, 0, false, 0, 0), "desktopini-without-the-section-is-normal")) return false; - if (!Check(ini(true, read_only, true, 0, false, 17, 0), "desktopini-with-the-section-is-a-yes")) return false; - if (!Check(ini(true, system_dir, true, 0, false, 17, 0), "desktopini-with-the-section-in-a-system-folder-is-a-yes")) return false; - if (!Check(!ini(true, read_only, true, 0, false, 0, kShellErrorFileNotFound), "desktopini-section-file-not-found-is-no")) return false; - if (!Check(ini(true, read_only, true, 0, false, 0, kAccessDenied), "desktopini-unreadable-section-is-a-yes")) return false; - if (!Check(ini(true, read_only, true, 0, false, 3, kAccessDenied), "desktopini-length-wins-over-an-error")) return false; - } - // A candidate that is a symbolic link or a mount point is never followed for its shell name. + 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; + } + // What a read-only / system folder's desktop.ini can make the shell reach. + { + const auto expand = [](std::wstring_view text) { + std::wstring out(text); + const auto replace = [&out](const std::wstring &from, const std::wstring &to) { + for (std::size_t at = out.find(from); at != std::wstring::npos; at = out.find(from, at + to.size())) out.replace(at, from.size(), to); + }; + replace(L"%SystemRoot%", L"C:\\Windows"); + replace(L"%UncVar%", L"\\\\server\\share"); + return out; + }; + const auto local = [](wchar_t letter) { return letter == L'C' || letter == L'c' || letter == L'D' || letter == L'd'; }; + const auto risky = [&](const wchar_t *value) { return ShellIniValueIsRisky(value, expand, local); }; + if (!Check(!risky(L"@%SystemRoot%\\system32\\shell32.dll,-21770"), "inival-system-resource-is-safe")) return false; + if (!Check(!risky(L"%SystemRoot%\\system32\\imageres.dll,-112"), "inival-icon-resource-is-safe")) return false; + if (!Check(!risky(L"@shell32.dll,-21770"), "inival-relative-resource-is-safe")) return false; + if (!Check(!risky(L"@D:\\res\\x.dll,-1"), "inival-another-local-drive-is-safe")) return false; + if (!Check(!risky(L"Documents"), "inival-plain-text-is-safe")) return false; + if (!Check(!risky(L""), "inival-empty-is-safe")) return false; + if (!Check(!risky(L"Look at this"), "inival-plain-text-with-spaces-is-safe")) return false; + if (!Check(risky(L"@\\\\server\\share\\x.dll,-1"), "inival-unc-resource-is-risky")) return false; + if (!Check(risky(L"@%UncVar%\\x.dll,-1"), "inival-unc-after-expansion-is-risky")) return false; + if (!Check(risky(L"@%Unknown%\\x.dll,-1"), "inival-unknown-variable-is-risky")) return false; + if (!Check(risky(L"@Z:\\x.dll,-1"), "inival-network-drive-is-risky")) return false; + if (!Check(risky(L"\\\\server\\share\\icon.ico"), "inival-unc-path-is-risky")) return false; + if (!Check(risky(L"\"\\\\server\\share\\icon.ico\""), "inival-quoted-unc-path-is-risky")) return false; + if (!Check(risky(L"\\Windows\\x.dll"), "inival-path-rooted-on-the-current-drive-is-risky")) return false; + if (!Check(risky(L"http://example.invalid/x"), "inival-url-is-risky")) return false; + if (!Check(risky(L"@"), "inival-a-bare-reference-is-risky")) return false; + if (!Check(ShellIniValueIsRisky(L"@C:\\x.dll,-1", nullptr, nullptr), "inival-without-the-callbacks-a-path-is-risky")) 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(), expand, local); + }; + 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-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-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\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). { 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 index 7892001e9..40bb232c1 100644 --- 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 @@ -11,7 +11,9 @@ // 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" (kAbsent). MatchShellDisplayName +// 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 @@ -37,9 +39,12 @@ 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; - bool is_link = false; // a symbolic link / mount point: its shell name is never asked for (unknown) + // 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; @@ -61,7 +66,7 @@ using ShellNameEquals = bool (*)(std::wstring_view, std::wstring_view); // 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 +// 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, @@ -79,13 +84,228 @@ 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 symbolic link or a mount point (a junction): 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). +// 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 == kShellReparseTagSymlink || tag == kShellReparseTagMountPoint); + 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; +} + +// 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 ShellExpandEnvironment = std::wstring (*)(std::wstring_view); +using ShellDriveIsLocal = bool (*)(wchar_t); + +// Whether a value from a desktop.ini names a place the shell might LOAD or READ and that is not on a +// local disk: a resource reference ("@path,-id"), or anything that looks like a path. A value that +// is plain text (a title, a tip) names nothing. The path is expanded by the caller's function, then: +// a name still holding '%' (an unknown variable), a UNC or device path, a URL, a path rooted on the +// current drive, and a drive letter that is not a local disk are all RISKY. A relative path is not +// (it is searched in the system directories or the folder). The "path,-id" or "path,index" resource +// suffix is not part of the path. +[[nodiscard]] inline bool ShellIniValueIsRisky(std::wstring_view value, ShellExpandEnvironment expand, + ShellDriveIsLocal is_local) { + using shell_desktop_ini_detail::Trim; + value = Trim(value); + if (value.empty()) return false; + if (value.size() >= 2 && value.front() == L'"' && value.back() == L'"') value = Trim(value.substr(1, value.size() - 2)); + const bool at_reference = !value.empty() && value.front() == L'@'; + if (at_reference) value = Trim(value.substr(1)); + if (value.empty()) return at_reference; // a bare "@" refers to nothing we can read: not known + const bool letter_path = value.size() >= 2 && value[1] == L':' && + ((value[0] >= L'A' && value[0] <= L'Z') || (value[0] >= L'a' && value[0] <= L'z')); + const bool looks_like_path = at_reference || value.front() == L'%' || value.front() == L'\\' || letter_path || + value.find(L"://") != std::wstring_view::npos; + if (!looks_like_path) return false; + if (value.find(L"://") != std::wstring_view::npos) return true; + const std::size_t comma = value.find_last_of(L','); + if (comma != std::wstring_view::npos) { + std::wstring_view tail = Trim(value.substr(comma + 1)); + if (!tail.empty() && tail.front() == L'-') tail.remove_prefix(1); + bool digits = !tail.empty(); + for (const wchar_t c : tail) + if (c < L'0' || c > L'9') digits = false; + if (digits) value = Trim(value.substr(0, comma)); + } + if (expand == nullptr || is_local == nullptr) return true; + std::wstring path = expand(value); + if (path.find(L'%') != std::wstring::npos) return true; + std::wstring_view view = Trim(path); + if (view.size() >= 2 && view.front() == L'"' && view.back() == L'"') view = Trim(view.substr(1, view.size() - 2)); + if (view.empty()) return false; + if (view.front() == L'\\' || view.front() == L'/') return true; // UNC, device, or rooted on the current drive + if (view.size() >= 2 && view[1] == L':') { + const wchar_t letter = view[0]; + const bool alpha = (letter >= L'A' && letter <= L'Z') || (letter >= L'a' && letter <= L'z'); + return !alpha || !is_local(letter); + } + return false; +} + +// 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 and InfoTip 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: any of those keys that is risky, or present at all for a +// CLSID, is a YES. Text that cannot be judged is a YES. +[[nodiscard]] inline bool ShellDesktopIniTextIsRiskyForShellName(const unsigned char *bytes, + std::size_t size, + ShellExpandEnvironment expand, + ShellDriveIsLocal is_local) { + 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; + if (SameName(entry.key, L"LocalizedResourceName") || SameName(entry.key, L"IconResource") || + SameName(entry.key, L"IconFile") || SameName(entry.key, L"InfoTip")) { + if (ShellIniValueIsRisky(entry.value, expand, is_local)) return true; + } + } + return false; } // Whether a folder's desktop.ini means that its files can be shown under names with no relation to @@ -94,22 +314,22 @@ constexpr unsigned long kShellReparseTagSymlink = 0xA000000C; // 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 section that cannot be read, and a desktop.ini that is itself a reparse point of ANY kind (the -// profile API follows it, to a place that can be on the network, and is never asked). +// 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_found, - unsigned long lookup_error, + bool file_opened, + unsigned long open_error, bool file_is_reparse_point, - std::size_t section_length, - unsigned long section_error) { + bool content_says_localized) { if (!folder_attributes_known) return true; if ((folder_attributes & (kShellAttributeReadOnly | kShellAttributeSystem)) == 0) return false; - if (!file_found) - return !(lookup_error == kShellErrorFileNotFound || lookup_error == kShellErrorPathNotFound); + if (!file_opened) + return !(open_error == kShellErrorFileNotFound || open_error == kShellErrorPathNotFound); if (file_is_reparse_point) return true; - if (section_length != 0) return true; - return section_error != 0 && section_error != kShellErrorFileNotFound; + return content_says_localized; } // The last part of a file system path (what follows the last separator; a trailing separator is @@ -153,6 +373,7 @@ constexpr unsigned long kShellReparseTagSymlink = 0xA000000C; 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 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 6063790a4..e6f646e92 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 @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -200,6 +201,16 @@ struct FindHandle { } }; +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; @@ -240,10 +251,126 @@ struct AbsoluteIdList { return true; } +[[nodiscard]] inline std::wstring ShellExpandEnvironmentStrings(std::wstring_view text) { + const std::wstring source(text); + std::wstring out(MAX_PATH * 4, L'\0'); + const DWORD needed = ExpandEnvironmentStringsW(source.c_str(), out.data(), static_cast(out.size())); + if (needed == 0 || needed > out.size()) return source + L"%"; // could not expand: stays risky + out.resize(needed - 1); + return out; +} + +[[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 }; + +// 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); + return letter != 0 && ShellDriveLetterIsLocal(letter) ? ShellMountTarget::kLocalDisk + : ShellMountTarget::kNotLocal; +} + +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(ShellPathWithoutTrailingSeparator(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(), ShellExpandEnvironmentStrings, + ShellDriveLetterIsLocal) + ? 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). +// 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) { const std::wstring trimmed = ShellPathWithoutTrailingSeparator(path); @@ -260,9 +387,14 @@ struct AbsoluteIdList { find.handle = FindFirstFileExW(part.c_str(), FindExInfoBasic, &data, FindExSearchNameMatch, nullptr, 0); if (find.handle == INVALID_HANDLE_VALUE) return true; - if (ShellReparseIsLink((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0, - data.dwReserved0)) - 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 (!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; @@ -273,11 +405,13 @@ struct AbsoluteIdList { // 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 TWO steps: 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), -// and only then GetVolumePathNameW finds the VOLUME the path is on (a mount point inside a local -// drive can lead elsewhere), whose type is checked again. A symbolic link or mount point on the -// way is refused. The root of a mounted volume itself (C:\\mnt\\vol, listed as the folder) is not +// 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 @@ -298,7 +432,10 @@ struct AbsoluteIdList { 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: the volume the path is really on, and its type. From here on every call may block. + // 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; @@ -319,51 +456,56 @@ struct AbsoluteIdList { CoTaskMemFree(folder); if (same) return false; } - return !ShellPathHasLink(root, path, limits, result); + 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: see -// ShellDesktopIniLocalizesFileNames for the rule, which treats what cannot be read as a yes). Then an -// entry that is not a candidate by its file name could still carry the display name. Only that -// section is read; the file is never opened otherwise. +// 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 = folder + L"\\desktop.ini"; - // A stop counts as "yes" (fail-closed); *result then says why. Asked before EVERY call below. - // The shell reads a desktop.ini only in a folder that is read-only or system: other folders are - // not read at all. And a desktop.ini that is a link (of any kind) is never read, because the profile - // API would follow it, possibly to a network location (and authenticate there). if (ShellLookupMustStop(limits, result)) return true; const DWORD folder_attributes = GetFileAttributesW(folder.c_str()); const bool folder_known = folder_attributes != INVALID_FILE_ATTRIBUTES; - bool found = false; - DWORD lookup_error = 0; + bool opened = false; + DWORD open_error = 0; bool is_reparse_point = false; - DWORD length = 0; - DWORD section_error = 0; + bool content_says_localized = true; if (folder_known && (folder_attributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0) { - if (ShellLookupMustStop(limits, result)) return true; - WIN32_FIND_DATAW data{}; - FindHandle find; - find.handle = FindFirstFileExW(settings.c_str(), FindExInfoBasic, &data, FindExSearchNameMatch, - nullptr, 0); - found = find.handle != INVALID_HANDLE_VALUE; - lookup_error = found ? 0 : GetLastError(); - is_reparse_point = found && (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; - if (found && !is_reparse_point) { - if (ShellLookupMustStop(limits, result)) return true; - std::wstring section(32767, L'\0'); - SetLastError(0); - length = GetPrivateProfileSectionW(L"LocalizedFileNames", section.data(), - static_cast(section.size()), settings.c_str()); - section_error = GetLastError(); + 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, found, lookup_error, - is_reparse_point, length, section_error); + 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 @@ -439,15 +581,20 @@ struct AbsoluteIdList { folder_view.Get() == nullptr || FAILED(folder_view.Get()->GetFolder(IID_PPV_ARGS(folder.Receive()))) || folder.Get() == nullptr) 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)) || @@ -455,6 +602,7 @@ struct AbsoluteIdList { 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; @@ -462,7 +610,6 @@ struct AbsoluteIdList { 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). - ShadowReadResult stopped = ShadowReadResult::kAbsent; if (ShellLookupMustStop(limits, &stopped)) return stopped; stopped = ShadowReadResult::kAbsent; if (!ShellFolderMayBeListed(folder_path, limits, &stopped)) return stopped; @@ -506,9 +653,21 @@ struct AbsoluteIdList { ShellNameCandidate candidate; candidate.name = std::wstring(found); candidate.is_directory = is_directory; - // A link is never followed to get a name (its target can be on the network): it stays unknown. - candidate.is_link = ShellReparseIsLink((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0, - entry.dwReserved0); + 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)) { @@ -522,6 +681,15 @@ struct AbsoluteIdList { for (ShellNameCandidate &candidate : candidates) { if (ShellLookupMustStop(limits, &stopped)) return stopped; if (candidate.is_link) continue; + // 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; PWSTR shown = nullptr; PWSTR resolved = nullptr; 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 9f94ec207..24c7c1f44 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,7 +226,7 @@ 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( @@ -276,6 +276,18 @@ describe('Windows UI Automation source only reads', () => { 'ShellFolderMayBeListed', 'ShellDriveIsLocalDisk', 'ShellReparseIsLink', + 'ShellExpandEnvironmentStrings', + 'ShellDriveLetterIsLocal', + 'ShellMountPointTarget', + 'ShellReadDesktopIni', + 'ShellCandidateDirectoryIni', + 'ShellReparseIsMountPoint', + 'ShellMountPointSubstituteName', + 'ShellLocalDriveLetterOfSubstituteName', + 'ShellIniValueIsRisky', + 'ShellDesktopIniTextIsRiskyForShellName', + 'ShellDesktopIniTextHasLocalizedFileNames', + 'FileHandle', 'ShellLookupMustStop', 'ShellPathHasLink', 'ShellEntryNeedsShellName', @@ -353,8 +365,12 @@ describe('Windows UI Automation source only reads', () => { 'DuplicateHandle', 'GetCurrentProcess', 'GetCurrentThread', - 'GetPrivateProfileSectionW', 'GetVolumePathNameW', + 'CreateFileW', + 'GetFileInformationByHandle', + 'ReadFile', + 'DeviceIoControl', + 'ExpandEnvironmentStringsW', 'SetLastError', 'ILIsEmpty', 'NormalizeString', @@ -405,6 +421,18 @@ describe('Windows UI Automation source only reads', () => { 'ShellFolderMayBeListed', 'ShellDriveIsLocalDisk', 'ShellReparseIsLink', + 'ShellExpandEnvironmentStrings', + 'ShellDriveLetterIsLocal', + 'ShellMountPointTarget', + 'ShellReadDesktopIni', + 'ShellCandidateDirectoryIni', + 'ShellReparseIsMountPoint', + 'ShellMountPointSubstituteName', + 'ShellLocalDriveLetterOfSubstituteName', + 'ShellIniValueIsRisky', + 'ShellDesktopIniTextIsRiskyForShellName', + 'ShellDesktopIniTextHasLocalizedFileNames', + 'FileHandle', 'ShellLookupMustStop', 'ShellPathHasLink', 'ShellEntryNeedsShellName', @@ -582,6 +610,18 @@ describe('Windows UI Automation source only reads', () => { 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( @@ -618,9 +658,7 @@ describe('Windows UI Automation source only reads', () => { // 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|renamable_directory|FILE_ATTRIBUTE_HIDDEN/u, - ); + 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( @@ -637,7 +675,7 @@ describe('Windows UI Automation source only reads', () => { ); expect(guardCompact).toContain('GetVolumePathNameW(path.c_str(), root.data()'); expect(guardCompact).toContain('GetDriveTypeW(root.c_str())'); - expect(guardCompact).toContain('return !ShellPathHasLink(root, path, limits, result);'); + 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())'); @@ -662,9 +700,7 @@ describe('Windows UI Automation source only reads', () => { 'ShellPathHasLink(const std::wstring &root', 'ShellFolderMayBeListed(', ); - expect(link).toContain( - 'ShellReparseIsLink((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0,', - ); + 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 @@ -674,12 +710,13 @@ describe('Windows UI Automation source only reads', () => { 'ShellFolderHasLocalizedFileNames(const std::wstring &path,', 'The path of the item a file browser shows', ); - expect(localized).toContain('GetPrivateProfileSectionW(L"LocalizedFileNames"'); expect(localized).toContain('desktop.ini'); expect(localized.replace(/\s+/gu, ' ')).toContain( - 'return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, found, lookup_error, is_reparse_point, length, section_error);', + 'return ShellDesktopIniLocalizesFileNames(folder_known, folder_attributes, opened, open_error, is_reparse_point, content_says_localized);', ); - expect(code(localized)).not.toMatch(/CreateFile|ReadFile|ifstream|fopen/u); + // 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. @@ -771,55 +808,179 @@ describe('Windows UI Automation source only reads', () => { mayBe.indexOf('GetVolumePathNameW('), ); const known = mayBe.indexOf('SHGetKnownFolderPath('); - expect(mayBe.lastIndexOf('ShellLookupMustStop(limits, result)', known)).toBeGreaterThan( - mayBe.indexOf('GetDriveTypeW('), - ); + 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', ); - expect(ini.indexOf('ShellLookupMustStop(limits, result)')).toBeLessThan( - ini.indexOf('GetFileAttributesW('), - ); - expect(ini.lastIndexOf('ShellLookupMustStop(limits, result)')).toBeGreaterThan( - ini.indexOf('GetFileAttributesW('), + 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.lastIndexOf('ShellLookupMustStop(limits, result)')).toBeLessThan( - ini.indexOf('GetPrivateProfileSectionW('), + expect(ini.indexOf('FILE_ATTRIBUTE_READONLY')).toBeLessThan( + ini.indexOf('ShellReadDesktopIni('), ); // A known limit, stated where it applies. expect(source).toContain('(TOCTOU)'); - // A desktop.ini is read only in a read-only / system folder (what the shell honours) and never when - // it is itself a link of any kind: the profile API would follow it, possibly to the network. - const iniCompact = ini.replace(/\s+/gu, ' '); + // 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( - '(folder_attributes & (FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM)) != 0', + 'ShellReadDesktopIni(settings, limits, result, &open_error, &bytes)', ); - expect(iniCompact).toContain('FindFirstFileExW(settings.c_str(), FindExInfoBasic'); expect(iniCompact).toContain( - 'is_reparse_point = found && (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;', + 'ShellDesktopIniTextHasLocalizedFileNames(bytes.data(), bytes.size())', ); - expect(iniCompact).toContain('if (found && !is_reparse_point) {'); - expect(ini.indexOf('FindFirstFileExW(')).toBeLessThan( - ini.indexOf('GetPrivateProfileSectionW('), + // 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', ); - expect(ini.indexOf('FILE_ATTRIBUTE_READONLY')).toBeLessThan(ini.indexOf('FindFirstFileExW(')); - expect( - ini.lastIndexOf('ShellLookupMustStop(limits, result)', ini.indexOf('FindFirstFileExW(')), - ).toBeGreaterThan(ini.indexOf('GetFileAttributesW(')); - // A candidate that is a link gets no shell name: no call that could reach its target. - const linkSkip = between( + const candidateCompact = candidateIni.replace(/\s+/gu, ' '); + expect(candidateCompact).toContain( + 'ShellReadDesktopIni(ShellPathWithoutTrailingSeparator(directory) + L"\\\\desktop.ini"', + ); + expect(candidateCompact).toContain( + 'case ShellIniStatus::kNotThere: return ShellCandidateIni::kSafe;', + ); + expect(candidateCompact).toContain( + 'ShellDesktopIniTextIsRiskyForShellName(bytes.data(), bytes.size(), ShellExpandEnvironmentStrings, ShellDriveLetterIsLocal)', + ); + expect(candidateCompact).toContain('default: return ShellCandidateIni::kUnknown;'); + const namesLoop = between( body, 'for (ShellNameCandidate &candidate : candidates) {', 'MatchShellDisplayName(', ); - expect(linkSkip.indexOf('if (candidate.is_link) continue;')).toBeGreaterThan(0); - expect(linkSkip.indexOf('if (candidate.is_link) continue;')).toBeLessThan( - linkSkip.indexOf('SHCreateItemFromParsingName('), + 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.is_link = ShellReparseIsLink((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0, entry.dwReserved0);', + '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. + expect(namesLoop.indexOf('if (candidate.is_link) continue;')).toBeGreaterThan(0); + expect(namesLoop.indexOf('if (candidate.is_link) continue;')).toBeLessThan( + namesLoop.indexOf('SHCreateItemFromParsingName('), + ); + 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)'); + 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 (!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( @@ -890,8 +1051,13 @@ describe('Windows UI Automation source only reads', () => { 'shellneeds-read-only-or-system-directory-is-a-candidate', 'shellneeds-ordinary-directory-is-not-a-candidate', 'shellneeds-dot-is-never-a-candidate', - 'desktopini-unreadable-section-is-a-yes', + '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); diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index 01dc82449..fefa27366 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -664,7 +664,7 @@ 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 の項目を表示名で探す」を、ビューの項目の走査から、フォルダのパスと列挙に替えた。`ShellItemPathByName` は view(タブ、または窓の唯一の view)を特定した後、そのフォルダの ID リストを取る。**照合は規則ではなく、シェルが実際に出す表示名で行う**。(1) 入口で fail-closed に外す場所(I/O の前に決める): ID リストが空(デスクトップ名前空間のルート。パスはユーザーのデスクトップだが、ビューは Public\\Desktop も統合して見せる)、`SFGAO_ISSLOW`、ファイルシステムのディレクトリでない場所(検索、ライブラリ、PC)、UNC / リモート / 光学 / 不明なドライブ、`FOLDERID_Desktop` と `FOLDERID_PublicDesktop` のパス(ルートの ID リスト以外の入り口でも統合表示を避ける。実機では未確認の保険)、`desktop.ini` に `[LocalizedFileNames]` の節があるフォルダ(ファイルの表示名がファイル名と無関係になりうる。節の有無だけを `GetPrivateProfileSectionW` で読み、読めなければ「あり」とみなす。ユーザーのホームのように desktop.ini 自体があるのは普通なので、それだけでは外さない)。(2) フォルダを `FindFirstFileExW(フォルダ\\*, FindExInfoBasic, FIND_FIRST_EX_LARGE_FETCH)` で 1 回列挙する(ファイルシステムのメタデータだけ。時間は `kShellLookupBudgetNs`、取消しは項目ごと)。シェル表示名を取る候補は、ファイル名が表示名で始まるもの(大文字小文字を無視)と、読み取り専用または system 属性のディレクトリ(desktop.ini の LocalizedResourceName で別名になるのはこの属性のフォルダだけ。「Documents」が日本語版で「ドキュメント」と出る場合を拾う)。隠しファイルは候補から外さない(迷ったら残し、曖昧になる側=判定なしに倒す)。候補は 64 件まで(超えたら判定なし)。(3) 各候補に `SHCreateItemFromParsingName` と `GetDisplayName(SIGDN_NORMALDISPLAY)` で表示名を取り、portable の `MatchShellDisplayName`(`computer_use_windows_shell_name_match.h`)が UIA の表示名と序数比較(`CompareStringOrdinal`、大文字小文字を無視)で照合する。ちょうど 1 件ならその実パスを返し、0 件、2 件以上、表示名が取れない候補が 1 つでもある、のどれかは判定なし(kAbsent)。拡張子を隠す設定、未登録の拡張子(LICENSE と LICENSE.md)、`.lnk` / `.website` など隠れる拡張子、ドットを含むフォルダ名はシェルの答えに任せ、固定の拡張子リストと語幹の規則は持たない。拡張子の表示設定(`SSF_SHOWEXTENSIONS`)は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る(zip などはファイル)。(4) lookup のスレッドは新しく同期のファイルシステム I/O を発行する。入口で遅い場所を外すほか、待ちが切れた側が lookup に放棄を知らせ(次の一歩で止まる)、スレッドの複製ハンドルへ `CancelSynchronousIo` を送って、ブロックした呼び出しを打ち切る。応答しないローカル USB などでドライバが戻らない場合は取り消せない。**既知の限界**として、そのときは lookup の枠(`lookup_slot`)が埋まり続け、以降の lookup は判定なしになる(拒否にも許可にも倒れず、V1 に任せる)。(5) 追加の fail-closed。ドライブは先頭 3 文字ではなく `GetVolumePathNameW` のボリュームで判定し、パスの途中に symbolic link / mount point があれば(ネットワークや光学ドライブを指しうる)判定なしにする(クラウドファイルのタグなど他の reparse point は許す)。`SHGetKnownFolderPath` は `KF_FLAG_DONT_VERIFY` で呼ぶ(リダイレクトされたデスクトップへのネットワーク I/O を避ける)。候補の解決先は `SIGDN_FILESYSPATH` の末尾が候補名と序数で一致するときだけ採る(末尾がドットや空白の名前は Win32 が別のエントリに正規化するので、最初から採らない。ID リスト由来の parsing name は既知フォルダで GUID になるため使わない)。新しい I/O を始める直前ごと(場所の判定、desktop.ini、最初の列挙、候補ごとの表示名)に取消しと期限を確かめる(待ちが切れた後に始まった I/O は取り消せないため)。lookup のスレッドは例外で process を落とさず、COM の解放、枠の解放、待っている側への通知は必ず通る。UIA 側では、ListItem が shell の項目 view(`SHELLDLL_DefView` または `UIItemsView`)の中にあるときだけ shell に尋ね、プレビューペインや検索候補の ListItem は実パス無し(kIncomplete)とする。(7) リンクと遅い場所。ドライブは 2 段階で外す: まずドライブ文字の種類(`GetDriveTypeW` を先頭 3 文字に。I/O なし。応答しない SMB 共有に割り当てたドライブには触れない)、その後に `GetVolumePathNameW` のボリュームを同じ判定にかける(マウントしたボリュームのルートそのものはパスより長くなるので判定なし)。desktop.ini は、フォルダが読み取り専用か system のときだけ読む(実機で確認: 同じ [LocalizedFileNames] を持つ desktop.ini は、通常のフォルダでは名前を変えず、読み取り専用 / system のフォルダでだけ名前を変えた)。desktop.ini 自体が reparse point(種類を問わず)なら、プロファイル API がリンク先(ネットワークでありうる。認証も起こりうる)をたどるので、読まずに「あり」とみなす。候補のエントリ自体が symbolic link / mount point(ジャンクション)なら、表示名を取りに行くとリンク先に触れるので、表示名は取らず unknown にする(全体が判定なし)。クラウドファイルなど他のタグは許す。この結果、ユーザーのホームのように hidden + system のジャンクション(Application Data、Cookies など 10 個)が並ぶフォルダでは、読み取り専用 / system のディレクトリを候補にする規則により、常に判定なしになる(fail closed の代償。hidden のジャンクションを候補から外す緩和は、Explorer が hidden を表示する設定のときの取り違えを許すので、行っていない)。確認してから列挙するまでの間にフォルダがリンクへ差し替えられる競合(TOCTOU)は残る。同じユーザーのローカルの操作に限られ、候補は FILESYSPATH の末尾で自分自身と確かめられ、dispatch 直前に再評価される(PR-5)ので許容する。(6) 上限 64 件は、前方一致の候補と読み取り専用 / system のディレクトリを合わせた「表示名を取る件数」の上限(列挙全体の件数ではない)。開発機での実測: ユーザーのホームで 22 件、C:\\Windows で 6 件、C:\\Windows\\System32(4,835 項目)で 6 件で、上限に届かず、いずれも表示名が取れた。lookup の待ち(150 ms)、PR-1 で分けた枠の引数、実パスが無いファイル項目は kIncomplete にする規則(S5-c-1 (2))は変えていない。実測(開発機、自作の一時フォルダを Explorer で開き、拡張子は表示する設定、1 件を 5 回、候補ごとの表示名の取得を含む): 項目数 4 / 120 / 500 / 5,000 のどれでも実パスが取れ、1 回 10〜14 ms 程度(最大 19 ms。リンクの確認を含む)で、項目数への依存は見えなかった。同名の `x` と `x.website`(判定なし)、LICENSE と LICENSE.md、フォルダ `data` と `data.xxx`、`[LocalizedFileNames]` のある desktop.ini(判定なし)、節の無い desktop.ini(取れる)、desktop.ini で別名になる読み取り専用フォルダ(シェル名で取れ、同名のファイルがあれば判定なし)を実機で確かめた。拡張子を隠す設定と、デスクトップ / Public Desktop を開いたときの挙動は実機で未確認で、harness の表と入口の判定(source 契約)で固定している。 +**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` で行き先を読み、ドライブ文字のローカルディスクを指すときだけ許す(`\??\Volume{...}` のようなボリューム指定は不明のまま拒否)、③その後に `GetVolumePathNameW` のボリュームを同じ判定にかける(マウントしたボリュームのルートそのものはパスより長くなるので判定なし)。リンクの判定は `IsReparseTagNameSurrogate`(タグの bit 29)で、クラウドファイルのタグには立たないので許される。経路の途中のジャンクションにも同じ緩和を当てた(ジャンクションはネットワークを指せず、行き先のドライブ文字は 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 件を超えたら切り捨てずに全体を判定なしにする。隠しファイルは外さない。候補がリンク(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` の値がリソース参照やパスで環境変数を展開した結果が UNC かローカルでないドライブ(や未展開の `%`)を指す、のどれかなら、シェルには尋ねず unknown にする。portable の `MatchShellDisplayName` は、表示名がちょうど 1 件に一致し、候補にリンクも読めないものも無いときだけ、その実パスを返す。拡張子の表示設定は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る。(4) lookup のスレッドは同期のファイルシステム I/O を発行する。取消しと期限は、新しい I/O を始める直前ごと(入口のシェル呼び出し、場所の判定、リンク、desktop.ini の open / 情報 / 読み取り、最初の列挙、各候補)に確かめる。待ちが切れた側は lookup に放棄を知らせ、スレッドの複製ハンドルへ `CancelSynchronousIo` を送ってブロックした呼び出しを打ち切る。ドライバが戻らない場合は取り消せず、そのとき lookup の枠が埋まり続けて以降の lookup は判定なしになる(V1 に任せる)のは**既知の限界**。確認してから列挙するまでの間にフォルダがリンクへ差し替えられる競合(TOCTOU)も残るが、同じユーザーのローカルの操作に限られ、候補は 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)。ドライブのルートは未測定。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 で版を上げるときにしか決まらない。 From bca8bad50e07bc662490a9ad8f80242b7db44599 Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 11:48:12 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AE=E5=8F=8D=E6=98=A0=EF=BC=88=E3=82=B8=E3=83=A3=E3=83=B3?= =?UTF-8?q?=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E8=A1=8C=E3=81=8D=E5=85=88?= =?UTF-8?q?=E3=81=AE=E7=A2=BA=E8=AA=8D=E3=83=BBdesktop.ini=20=E3=81=AE?= =?UTF-8?q?=E8=A8=B1=E5=8F=AF=E3=83=AA=E3=82=B9=E3=83=88=E3=83=BB=E3=83=89?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=96=E3=83=AB=E3=83=BC=E3=83=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ジャンクションの行き先のパスも、ドライブのルートから要素ごとにリンクを確認する(深さ 1、再帰しない) - desktop.ini の値は拒否リストをやめ許可リストにする(両方の引用符を外し、プレーンテキストか既知の環境変数で始まるパスだけ。X:\ の絶対パスは外す) - ドライブのルートで directory の区切りが二重になり全候補が unknown になる不具合を ShellDirectoryWithSeparator で修正 - 実行されない is_link の continue を削除、古いコメントを修正、ADR を更新 Co-Authored-By: Claude Opus 5.5 --- .../computer_use_protocol_fuzz.cc | 109 +++++++++++----- .../computer_use_windows_shell_name_match.h | 122 +++++++++++------- .../computer_use_windows_uia_source.h | 42 +++--- ...computer-use-windows-shadow-reader.test.ts | 48 +++++-- .../adr-computer-use-agent-driven-v2.md | 2 +- 5 files changed, 206 insertions(+), 117 deletions(-) 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 577c9198f..52b628276 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -7038,53 +7038,96 @@ bool CheckWindowsShellNameMatch() { odd[10] = 5; if (!Check(!ShellMountPointSubstituteName(odd.data(), odd.size(), &name), "mount-an-odd-length-is-not-read")) return false; } - // What a read-only / system folder's desktop.ini can make the shell reach. - { - const auto expand = [](std::wstring_view text) { - std::wstring out(text); - const auto replace = [&out](const std::wstring &from, const std::wstring &to) { - for (std::size_t at = out.find(from); at != std::wstring::npos; at = out.find(from, at + to.size())) out.replace(at, from.size(), to); - }; - replace(L"%SystemRoot%", L"C:\\Windows"); - replace(L"%UncVar%", L"\\\\server\\share"); - return out; - }; - const auto local = [](wchar_t letter) { return letter == L'C' || letter == L'c' || letter == L'D' || letter == L'd'; }; - const auto risky = [&](const wchar_t *value) { return ShellIniValueIsRisky(value, expand, local); }; - if (!Check(!risky(L"@%SystemRoot%\\system32\\shell32.dll,-21770"), "inival-system-resource-is-safe")) return false; - if (!Check(!risky(L"%SystemRoot%\\system32\\imageres.dll,-112"), "inival-icon-resource-is-safe")) return false; - if (!Check(!risky(L"@shell32.dll,-21770"), "inival-relative-resource-is-safe")) return false; - if (!Check(!risky(L"@D:\\res\\x.dll,-1"), "inival-another-local-drive-is-safe")) return false; - if (!Check(!risky(L"Documents"), "inival-plain-text-is-safe")) return false; - if (!Check(!risky(L""), "inival-empty-is-safe")) return false; - if (!Check(!risky(L"Look at this"), "inival-plain-text-with-spaces-is-safe")) return false; - if (!Check(risky(L"@\\\\server\\share\\x.dll,-1"), "inival-unc-resource-is-risky")) return false; - if (!Check(risky(L"@%UncVar%\\x.dll,-1"), "inival-unc-after-expansion-is-risky")) return false; - if (!Check(risky(L"@%Unknown%\\x.dll,-1"), "inival-unknown-variable-is-risky")) return false; - if (!Check(risky(L"@Z:\\x.dll,-1"), "inival-network-drive-is-risky")) return false; - if (!Check(risky(L"\\\\server\\share\\icon.ico"), "inival-unc-path-is-risky")) return false; - if (!Check(risky(L"\"\\\\server\\share\\icon.ico\""), "inival-quoted-unc-path-is-risky")) return false; - if (!Check(risky(L"\\Windows\\x.dll"), "inival-path-rooted-on-the-current-drive-is-risky")) return false; - if (!Check(risky(L"http://example.invalid/x"), "inival-url-is-risky")) return false; - if (!Check(risky(L"@"), "inival-a-bare-reference-is-risky")) return false; - if (!Check(ShellIniValueIsRisky(L"@C:\\x.dll,-1", nullptr, nullptr), "inival-without-the-callbacks-a-path-is-risky")) 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 or on a local drive (with an optional '@' and ",-N" / ",N"), are safe. + { + 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(), expand, local); + 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-unc-resource-name")) 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-unc-icon-resource")) 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; 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 index 40bb232c1..f03df8b3b 100644 --- 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 @@ -131,6 +131,26 @@ constexpr unsigned long kShellReparseNameSurrogate = 0x20000000; 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; @@ -234,65 +254,65 @@ struct IniEntry { return false; } -using ShellExpandEnvironment = std::wstring (*)(std::wstring_view); using ShellDriveIsLocal = bool (*)(wchar_t); -// Whether a value from a desktop.ini names a place the shell might LOAD or READ and that is not on a -// local disk: a resource reference ("@path,-id"), or anything that looks like a path. A value that -// is plain text (a title, a tip) names nothing. The path is expanded by the caller's function, then: -// a name still holding '%' (an unknown variable), a UNC or device path, a URL, a path rooted on the -// current drive, and a drive letter that is not a local disk are all RISKY. A relative path is not -// (it is searched in the system directories or the folder). The "path,-id" or "path,index" resource -// suffix is not part of the path. -[[nodiscard]] inline bool ShellIniValueIsRisky(std::wstring_view value, ShellExpandEnvironment expand, - ShellDriveIsLocal is_local) { +// Whether a value from a desktop.ini is SAFE for the shell to be given: an ALLOW-list. The shell reads +// these values with GetPrivateProfileStringW, which removes one pair of surrounding quotes (' or ", the +// same at both ends), so that is removed here first, and the blanks. 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.empty()) return false; - if (value.size() >= 2 && value.front() == L'"' && value.back() == L'"') value = Trim(value.substr(1, value.size() - 2)); - const bool at_reference = !value.empty() && value.front() == L'@'; - if (at_reference) value = Trim(value.substr(1)); - if (value.empty()) return at_reference; // a bare "@" refers to nothing we can read: not known - const bool letter_path = value.size() >= 2 && value[1] == L':' && - ((value[0] >= L'A' && value[0] <= L'Z') || (value[0] >= L'a' && value[0] <= L'z')); - const bool looks_like_path = at_reference || value.front() == L'%' || value.front() == L'\\' || letter_path || - value.find(L"://") != std::wstring_view::npos; - if (!looks_like_path) return false; - if (value.find(L"://") != std::wstring_view::npos) return true; - const std::size_t comma = value.find_last_of(L','); + 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 = Trim(value.substr(comma + 1)); + std::wstring_view tail = value.substr(comma + 1); if (!tail.empty() && tail.front() == L'-') tail.remove_prefix(1); - bool digits = !tail.empty(); - for (const wchar_t c : tail) - if (c < L'0' || c > L'9') digits = false; - if (digits) value = Trim(value.substr(0, comma)); + if (tail.empty() || tail.find_first_not_of(L"0123456789") != std::wstring_view::npos) return false; + value = value.substr(0, comma); } - if (expand == nullptr || is_local == nullptr) return true; - std::wstring path = expand(value); - if (path.find(L'%') != std::wstring::npos) return true; - std::wstring_view view = Trim(path); - if (view.size() >= 2 && view.front() == L'"' && view.back() == L'"') view = Trim(view.substr(1, view.size() - 2)); - if (view.empty()) return false; - if (view.front() == L'\\' || view.front() == L'/') return true; // UNC, device, or rooted on the current drive - if (view.size() >= 2 && view[1] == L':') { - const wchar_t letter = view[0]; - const bool alpha = (letter >= L'A' && letter <= L'Z') || (letter >= L'a' && letter <= L'z'); - return !alpha || !is_local(letter); + 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 '\', or a local drive letter followed by ":\". + 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; + 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 and InfoTip 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: any of those keys that is risky, or present at all for a -// CLSID, is a YES. Text that cannot be judged is a YES. +// 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, - ShellExpandEnvironment expand, - ShellDriveIsLocal is_local) { + std::size_t size) { using shell_desktop_ini_detail::SameName; std::vector entries; if (!shell_desktop_ini_detail::ParseIni(bytes, size, &entries)) return true; @@ -300,10 +320,12 @@ using ShellDriveIsLocal = bool (*)(wchar_t); 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; - if (SameName(entry.key, L"LocalizedResourceName") || SameName(entry.key, L"IconResource") || - SameName(entry.key, L"IconFile") || SameName(entry.key, L"InfoTip")) { - if (ShellIniValueIsRisky(entry.value, expand, is_local)) 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; } 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 e6f646e92..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 @@ -251,15 +251,6 @@ struct AbsoluteIdList { return true; } -[[nodiscard]] inline std::wstring ShellExpandEnvironmentStrings(std::wstring_view text) { - const std::wstring source(text); - std::wstring out(MAX_PATH * 4, L'\0'); - const DWORD needed = ExpandEnvironmentStringsW(source.c_str(), out.data(), static_cast(out.size())); - if (needed == 0 || needed > out.size()) return source + L"%"; // could not expand: stays risky - out.resize(needed - 1); - return out; -} - [[nodiscard]] inline bool ShellDriveLetterIsLocal(wchar_t letter) { const wchar_t root[4] = {letter, L':', L'\\', L'\0'}; return ShellDriveIsLocalDisk(GetDriveTypeW(root)); // no I/O @@ -267,6 +258,10 @@ struct AbsoluteIdList { 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 @@ -291,8 +286,17 @@ enum class ShellMountTarget { kLocalDisk, kNotLocal, kStopped }; if (!ShellMountPointSubstituteName(buffer.data(), returned, &substitute)) return ShellMountTarget::kNotLocal; const wchar_t letter = ShellLocalDriveLetterOfSubstituteName(substitute); - return letter != 0 && ShellDriveLetterIsLocal(letter) ? ShellMountTarget::kLocalDisk - : ShellMountTarget::kNotLocal; + 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 }; @@ -350,15 +354,14 @@ enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; ShadowReadResult *result) { DWORD open_error = 0; std::vector bytes; - switch (ShellReadDesktopIni(ShellPathWithoutTrailingSeparator(directory) + L"\\desktop.ini", limits, + 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(), ShellExpandEnvironmentStrings, - ShellDriveLetterIsLocal) + return ShellDesktopIniTextIsRiskyForShellName(bytes.data(), bytes.size()) ? ShellCandidateIni::kUnknown : ShellCandidateIni::kSafe; default: @@ -372,7 +375,8 @@ enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; // 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) { + const ShellLookupLimits &limits, ShadowReadResult *result, + bool allow_local_mount_points) { const std::wstring trimmed = ShellPathWithoutTrailingSeparator(path); std::size_t at = root.size(); for (;;) { @@ -392,7 +396,7 @@ enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; // 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 (!ShellReparseIsMountPoint(is_reparse, data.dwReserved0)) return true; + if (!allow_local_mount_points || !ShellReparseIsMountPoint(is_reparse, data.dwReserved0)) return true; if (ShellMountPointTarget(part, limits, result) != ShellMountTarget::kLocalDisk) return true; } } @@ -475,7 +479,7 @@ enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; const ShellLookupLimits &limits, ShadowReadResult *result) { const std::wstring folder = ShellPathWithoutTrailingSeparator(path); - const std::wstring settings = folder + L"\\desktop.ini"; + 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; @@ -613,8 +617,7 @@ enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; if (ShellLookupMustStop(limits, &stopped)) return stopped; stopped = ShadowReadResult::kAbsent; if (!ShellFolderMayBeListed(folder_path, limits, &stopped)) return stopped; - std::wstring directory = ShellPathWithoutTrailingSeparator(folder_path); - directory.push_back(L'\\'); + 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'*'); @@ -680,7 +683,6 @@ enum class ShellCandidateIni { kSafe, kUnknown, kStopped }; // answer "not known" (it might be the one, or a second one). for (ShellNameCandidate &candidate : candidates) { if (ShellLookupMustStop(limits, &stopped)) return stopped; - if (candidate.is_link) continue; // 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. 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 24c7c1f44..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 @@ -276,7 +276,9 @@ describe('Windows UI Automation source only reads', () => { 'ShellFolderMayBeListed', 'ShellDriveIsLocalDisk', 'ShellReparseIsLink', - 'ShellExpandEnvironmentStrings', + 'ShellMountTargetPath', + 'ShellDirectoryWithSeparator', + 'ShellIniValueIsSafe', 'ShellDriveLetterIsLocal', 'ShellMountPointTarget', 'ShellReadDesktopIni', @@ -370,7 +372,6 @@ describe('Windows UI Automation source only reads', () => { 'GetFileInformationByHandle', 'ReadFile', 'DeviceIoControl', - 'ExpandEnvironmentStringsW', 'SetLastError', 'ILIsEmpty', 'NormalizeString', @@ -421,7 +422,9 @@ describe('Windows UI Automation source only reads', () => { 'ShellFolderMayBeListed', 'ShellDriveIsLocalDisk', 'ShellReparseIsLink', - 'ShellExpandEnvironmentStrings', + 'ShellMountTargetPath', + 'ShellDirectoryWithSeparator', + 'ShellIniValueIsSafe', 'ShellDriveLetterIsLocal', 'ShellMountPointTarget', 'ShellReadDesktopIni', @@ -703,8 +706,9 @@ describe('Windows UI Automation source only reads', () => { 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 read with the profile API, never opened, and the pure rule turns the answers into a verdict. + // 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,', @@ -901,15 +905,24 @@ describe('Windows UI Automation source only reads', () => { ); const candidateCompact = candidateIni.replace(/\s+/gu, ' '); expect(candidateCompact).toContain( - 'ShellReadDesktopIni(ShellPathWithoutTrailingSeparator(directory) + L"\\\\desktop.ini"', + 'ShellReadDesktopIni(ShellDirectoryWithSeparator(directory) + L"desktop.ini"', ); expect(candidateCompact).toContain( 'case ShellIniStatus::kNotThere: return ShellCandidateIni::kSafe;', ); expect(candidateCompact).toContain( - 'ShellDesktopIniTextIsRiskyForShellName(bytes.data(), bytes.size(), ShellExpandEnvironmentStrings, ShellDriveLetterIsLocal)', + '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) {', @@ -928,11 +941,10 @@ describe('Windows UI Automation source only reads', () => { '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. - expect(namesLoop.indexOf('if (candidate.is_link) continue;')).toBeGreaterThan(0); - expect(namesLoop.indexOf('if (candidate.is_link) continue;')).toBeLessThan( - namesLoop.indexOf('SHCreateItemFromParsingName('), - ); + // 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);', @@ -961,6 +973,16 @@ describe('Windows UI Automation source only reads', () => { '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); @@ -977,7 +999,7 @@ describe('Windows UI Automation source only reads', () => { 'ShellFolderMayBeListed(', ).replace(/\s+/gu, ' '); expect(hasLinkCompact).toContain( - 'if (!ShellReparseIsMountPoint(is_reparse, data.dwReserved0)) return true;', + 'if (!allow_local_mount_points || !ShellReparseIsMountPoint(is_reparse, data.dwReserved0)) return true;', ); expect(hasLinkCompact).toContain( 'if (ShellMountPointTarget(part, limits, result) != ShellMountTarget::kLocalDisk) return true;', diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index fefa27366..915f41d75 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -664,7 +664,7 @@ 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` で行き先を読み、ドライブ文字のローカルディスクを指すときだけ許す(`\??\Volume{...}` のようなボリューム指定は不明のまま拒否)、③その後に `GetVolumePathNameW` のボリュームを同じ判定にかける(マウントしたボリュームのルートそのものはパスより長くなるので判定なし)。リンクの判定は `IsReparseTagNameSurrogate`(タグの bit 29)で、クラウドファイルのタグには立たないので許される。経路の途中のジャンクションにも同じ緩和を当てた(ジャンクションはネットワークを指せず、行き先のドライブ文字は 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 件を超えたら切り捨てずに全体を判定なしにする。隠しファイルは外さない。候補がリンク(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` の値がリソース参照やパスで環境変数を展開した結果が UNC かローカルでないドライブ(や未展開の `%`)を指す、のどれかなら、シェルには尋ねず unknown にする。portable の `MatchShellDisplayName` は、表示名がちょうど 1 件に一致し、候補にリンクも読めないものも無いときだけ、その実パスを返す。拡張子の表示設定は読まない。`is_folder` は `FILE_ATTRIBUTE_DIRECTORY` から取る。(4) lookup のスレッドは同期のファイルシステム I/O を発行する。取消しと期限は、新しい I/O を始める直前ごと(入口のシェル呼び出し、場所の判定、リンク、desktop.ini の open / 情報 / 読み取り、最初の列挙、各候補)に確かめる。待ちが切れた側は lookup に放棄を知らせ、スレッドの複製ハンドルへ `CancelSynchronousIo` を送ってブロックした呼び出しを打ち切る。ドライバが戻らない場合は取り消せず、そのとき lookup の枠が埋まり続けて以降の lookup は判定なしになる(V1 に任せる)のは**既知の限界**。確認してから列挙するまでの間にフォルダがリンクへ差し替えられる競合(TOCTOU)も残るが、同じユーザーのローカルの操作に限られ、候補は 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)。ドライブのルートは未測定。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 は実機で未確認。 +**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)で、クラウドファイルのタグには立たないので許される。経路の途中のジャンクションにも同じ緩和を当てた(ジャンクションはネットワークを指せず、行き先のドライブ文字は 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 に任せる)のは**既知の限界**。確認してから列挙するまでの間にフォルダがリンクへ差し替えられる競合(TOCTOU)も残るが、同じユーザーのローカルの操作に限られ、候補は 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 で版を上げるときにしか決まらない。 From fa9f4a6be636171f21294f1446c287d8c9151557 Mon Sep 17 00:00:00 2001 From: yusei Date: Sat, 17 Oct 2026 11:53:25 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(computer-use):=20#500=20S5-c-2=20PR-3?= =?UTF-8?q?=20=E8=A8=B1=E5=8F=AF=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E8=AA=AC=E6=98=8E=E3=80=81=E3=82=B8=E3=83=A3=E3=83=B3=E3=82=AF?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E6=A0=B9=E6=8B=A0=E3=80=81?= =?UTF-8?q?TOCTOU=20=E3=81=AE=E6=AE=8B=E4=BD=99=E3=83=AA=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../computer-use-native/computer_use_protocol_fuzz.cc | 3 ++- .../computer_use_windows_shell_name_match.h | 7 ++++--- tasks/designs/adr-computer-use-agent-driven-v2.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) 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 52b628276..29f34086b 100644 --- a/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc +++ b/apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc @@ -7057,7 +7057,8 @@ bool CheckWindowsShellNameMatch() { 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 or on a local drive (with an optional '@' and ",-N" / ",N"), are safe. + // 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. 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 index f03df8b3b..33c1d233b 100644 --- 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 @@ -257,8 +257,9 @@ struct IniEntry { 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 with GetPrivateProfileStringW, which removes one pair of surrounding quotes (' or ", the -// same at both ends), so that is removed here first, and the blanks. What is left is safe only if it is +// 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%, @@ -288,7 +289,7 @@ using ShellDriveIsLocal = bool (*)(wchar_t); 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 '\', or a local drive letter followed by ":\". + // 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%")}) { diff --git a/tasks/designs/adr-computer-use-agent-driven-v2.md b/tasks/designs/adr-computer-use-agent-driven-v2.md index 915f41d75..e671c3dd2 100644 --- a/tasks/designs/adr-computer-use-agent-driven-v2.md +++ b/tasks/designs/adr-computer-use-agent-driven-v2.md @@ -664,7 +664,7 @@ 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)で、クラウドファイルのタグには立たないので許される。経路の途中のジャンクションにも同じ緩和を当てた(ジャンクションはネットワークを指せず、行き先のドライブ文字は 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 に任せる)のは**既知の限界**。確認してから列挙するまでの間にフォルダがリンクへ差し替えられる競合(TOCTOU)も残るが、同じユーザーのローカルの操作に限られ、候補は 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 は実機で未確認。 +**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 で版を上げるときにしか決まらない。