From f6f68573b63c44a456cf78ea617903c37b657aa8 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 14 Aug 2026 14:48:57 +0800 Subject: [PATCH] fix: add resources folder to the allowed roots as readonly --- src/slic3r/plugin/PluginAuditManager.cpp | 178 ++++++++++++++++++++--- src/slic3r/plugin/PluginAuditManager.hpp | 52 ++++++- tests/slic3rutils/plugin_test_utils.hpp | 20 +++ tests/slic3rutils/test_plugin_audit.cpp | 146 +++++++++++++++++++ 4 files changed, 365 insertions(+), 31 deletions(-) diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 023e55bd22..4d69a4f761 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -111,6 +112,22 @@ static AuditEventCategory event_category(const std::string& event_name) return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; } +// True for the categories whose targets are filesystem paths, as opposed to a network +// address or a process command line -- the deny-path and allowed-root checks only make sense +// against a path. +static bool is_fs_category(AuditEventCategory category) +{ + switch (category) { + case AuditEventCategory::FsRead: + case AuditEventCategory::FsReadWrite: + case AuditEventCategory::FsCreate: + case AuditEventCategory::FsDelete: + return true; + default: + return false; + } +} + // --------------------------------------------------------------------------- // Path safety // --------------------------------------------------------------------------- @@ -179,7 +196,7 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_capability_name = ""; -thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local AuditViolation PluginAuditManager::m_last_violation; @@ -224,23 +241,24 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); } -void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root) +void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write) { if (root.empty()) return; std::lock_guard lock(m_mutex); - m_global_allowed_roots.push_back(root); - BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string(); + m_global_allowed_roots.push_back({root, allow_write}); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string() << " allow_write=" << allow_write; } -void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root) +void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write) { if (root.empty()) return; - m_scoped_allowed_roots.push_back(root); - BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string(); + m_scoped_allowed_roots.push_back({root, allow_write}); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string() + << " allow_write=" << allow_write; } // --------------------------------------------------------------------------- @@ -293,6 +311,67 @@ bool PluginAuditManager::is_denied_filename(const boost::filesystem::path& candi return false; } +// --------------------------------------------------------------------------- +// Denied path keywords +// --------------------------------------------------------------------------- + +void PluginAuditManager::add_denied_path_keyword(const std::string& keyword) +{ + if (keyword.empty()) + return; + + std::string lower = keyword; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); }); + + std::lock_guard lock(m_mutex); + m_denied_path_keywords.push_back(lower); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Denied path keyword: " << lower; +} + +std::vector PluginAuditManager::default_denied_path_keywords() +{ + // Broad, categorical rules on top of the exact-name is_denied_filename registry: a plugin + // must never be able to reach a secret, a certificate, or a configuration file just because + // it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at + // resources_dir()/cert/..., which would become reachable the moment resources_dir() is + // granted as a read-only allowed root). + return {"secret", "cert", "conf"}; +} + +bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& candidate) const +{ + namespace fs = boost::filesystem; + + boost::system::error_code ec; + fs::path canon = fs::weakly_canonical(candidate, ec); + if (ec) { + canon = fs::absolute(candidate, ec).lexically_normal(); + if (ec) + canon = candidate; + } + + std::lock_guard lock(m_mutex); + if (m_denied_path_keywords.empty()) + return false; + + for (const auto& component : canon) { + std::string name = component.string(); + if (name.empty()) + continue; + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); }); + for (const auto& keyword : m_denied_path_keywords) { + if (name.find(keyword) != std::string::npos) + return true; + } + } + return false; +} + +bool PluginAuditManager::is_denied_path(const boost::filesystem::path& candidate) const +{ + return is_denied_filename(candidate) || is_denied_path_keyword(candidate); +} + // --------------------------------------------------------------------------- // Policy checks // --------------------------------------------------------------------------- @@ -306,14 +385,20 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat if (plugin_key.empty()) return {true, ""}; // not running inside a plugin context - // Denied filenames are checked before the allowed roots. The app config and the cloud - // refresh token live directly inside data_dir(), which is a global allowed root, so a deny - // placed any lower would be unreachable. + // Denied filenames/keywords are checked before the allowed roots. The app config and the + // cloud refresh token live directly inside data_dir(), which is a global allowed root, and + // the bundled TLS client cert lives inside resources_dir(), a read-only global allowed + // root, so a deny placed any lower would be unreachable. if (is_denied_filename(path)) { BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write << " plugin=" << plugin_key << " reason=denied filename"; return {false, "denied filename"}; } + if (is_denied_path_keyword(path)) { + BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write + << " plugin=" << plugin_key << " reason=denied path keyword"; + return {false, "denied path keyword"}; + } namespace fs = boost::filesystem; fs::path candidate = path; @@ -326,8 +411,11 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat candidate = absolute_candidate; } + // A root that doesn't allow writes only matches a read-shaped request; a write/create/ + // delete-shaped one falls through to "outside allowed root" for that root even though the + // path is physically inside it. for (const auto& root : m_scoped_allowed_roots) { - if (is_inside_allowed_root(candidate, root)) { + if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) { return {true, ""}; } } @@ -335,7 +423,7 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat { std::lock_guard lock(m_mutex); for (const auto& root : m_global_allowed_roots) { - if (is_inside_allowed_root(candidate, root)) { + if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) { return {true, ""}; } } @@ -799,6 +887,27 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user return 0; } + // the open function can take in different flags that determine if it is a read or readwrite. + const AuditEventCategory event_type = + event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name); + if (event_type == AuditEventCategory::None) + return 0; + + const bool fs_category = is_fs_category(event_type); + const std::vector targets = PluginAuditDetail::audit_targets(event_name, event_type, args); + + // A denied path (secrets, certificates, config files -- see is_denied_path) is an + // unconditional block: checked before the ancestor-cascade check below, so a cascade + // approval recorded for an unrelated action can never launder access to one, and before + // the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources + // folder) cannot make a denied path underneath it reachable. + if (fs_category) { + for (const auto& target : targets) { + if (mgr->is_denied_path(boost::filesystem::path(target))) + return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}); + } + } + PluginDescriptor plugin_descriptor; PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor); @@ -809,11 +918,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) return 0; - // the open function can take in different flags that determine if it is a read or readwrite. - const AuditEventCategory event_type = - event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name); - if (event_type == AuditEventCategory::None) - return 0; + // A filesystem target that resolves entirely inside a pre-determined allowed root -- the + // plugin system's own data_dir() tree (which holds each plugin's storage folder and the + // installed/system profile cache), the read-only bundled resources folder, or a per-call + // scoped root such as the current G-code folder -- is part of the plugin system's normal + // workflow and does not need a prompt. + if (fs_category && !targets.empty()) { + const bool is_write = event_type != AuditEventCategory::FsRead; + const bool all_inside_allowed_root = + std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { + return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed; + }); + if (all_inside_allowed_root) + return 0; + } PluginInstallState state; const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state); @@ -823,8 +941,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name; - const std::vector targets = PluginAuditDetail::audit_targets(event_name, event_type, args); - std::vector* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions); + std::vector* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions); return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name, event_type, targets, permission_list, call_site_ids); @@ -832,12 +949,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user void PluginAuditManager::install_hook() { - // data_dir() is the only globally-allowed root during plugin execution. - // The executable directory and resources directory are intentionally NOT allowed - // here: plugins must not access outside data_dir() (G-code plugins additionally get - // the temp G-code folder via a scoped root). + // data_dir() is the primary globally-allowed root during plugin execution: read+write. It + // covers the plugin system's own workflow needs -- each plugin's storage folder + // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) -- + // without a separate, narrower grant for either (G-code plugins additionally get the temp + // G-code folder via a scoped root, see SlicingPipelinePluginCapabilityTrampoline). add_global_allowed_root(data_dir()); + // resources_dir() holds the app's bundled, shared assets (installed system profiles, the + // bundled TLS client cert, web assets). Plugins may read from it -- e.g. inspecting bundled + // profiles -- but must never write into the shared, potentially multi-user app install, so + // it is granted read-only. The bundled cert itself stays unreachable regardless, via the + // "cert" denied-path keyword seeded below. + add_global_allowed_root(resources_dir(), /*allow_write=*/false); + // The user's app config and cloud credentials live directly inside data_dir(), so the // root just granted would otherwise expose them to any plugin. Deny them by name. // @@ -850,6 +975,13 @@ void PluginAuditManager::install_hook() for (const auto& name : default_denied_filenames()) add_denied_filename(name); + // Categorical denies on top of the exact-name list above: no path a plugin can reach may + // contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which + // allowed root it happens to sit inside. default_denied_path_keywords() is the single + // source of this list; the tests seed from it too. + for (const auto& keyword : default_denied_path_keywords()) + add_denied_path_keyword(keyword); + if (PySys_AddAuditHook(audit_hook, this) < 0) { BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install CPython audit hook"; return; diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 3b86014d42..d6751a57fd 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -24,6 +24,14 @@ struct AuditViolation { std::string reason; }; +// A filesystem root a plugin may access while an audit context is active. allow_write is +// false for a root that only grants reads (e.g. the bundled, shared resources folder) -- +// a write-shaped event never matches such a root, even though a read-shaped one does. +struct AllowedRoot { + boost::filesystem::path path; + bool allow_write = true; +}; + // The set of CPython audit events PluginAuditManager recognizes, grouped by the kind of // operation they represent. None means the event isn't one audit_hook() acts on at all. enum class AuditEventCategory { @@ -65,8 +73,11 @@ public: void clear_current_capability(); // --- allowed-roots registry --- - void add_global_allowed_root(const boost::filesystem::path& root); - void add_scoped_allowed_root(const boost::filesystem::path& root); + // allow_write = false registers a read-only root: a read-shaped event inside it is allowed, + // but a write/create/delete-shaped event is not, so it falls through to the normal + // prompt-or-deny path instead. + void add_global_allowed_root(const boost::filesystem::path& root, bool allow_write = true); + void add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write = true); // --- denied-filenames registry --- // Filenames a plugin may never touch, in any directory, regardless of the enclosing allowed @@ -89,10 +100,34 @@ public: // 8.3 short name is out of scope (see the design doc). This blocks direct access only. bool is_denied_filename(const boost::filesystem::path& candidate) const; + // --- denied-path-keyword registry --- + // Keywords that categorically deny a path if ANY of its components (directory or file + // name), not just the base name, contains one case-insensitively -- e.g. a "secrets" + // subfolder, a "certificates" folder, or a "conf"/"config" file anywhere the plugin can + // otherwise reach, including inside an allowed root. This is intentionally broader and + // fuzzier than the exact-name is_denied_filename registry: it exists to categorically rule + // out whole classes of sensitive paths (secrets, certificates, config) rather than name + // specific known files, at the cost of over-blocking an unrelated name that happens to + // contain the keyword -- the fail-safe direction, same rationale as is_denied_filename. + void add_denied_path_keyword(const std::string& keyword); + + // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact + // same set without a live interpreter. + static std::vector default_denied_path_keywords(); + + // True when any component of candidate's (canonicalized) path contains a registered + // keyword, case-insensitively. + bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; + + // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for + // call sites that only need to know whether a path is categorically off-limits, not which + // specific rule fired. + bool is_denied_path(const boost::filesystem::path& candidate) const; + // --- policy checks --- - // Shared core for every audited filesystem event. The deny list is consulted above the - // allowed roots, so a denied filename is blocked even when the file sits inside data_dir(), - // which is itself a global allowed root. + // Shared core for every audited filesystem event. The deny checks are consulted above the + // allowed roots, so a denied path is blocked even when it sits inside an allowed root (e.g. + // data_dir(), which is a global allowed root). AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write); AuditDecision check_open(const std::string& path, const std::string& mode); @@ -130,15 +165,16 @@ private: static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_capability_name; - static thread_local std::vector m_scoped_allowed_roots; + static thread_local std::vector m_scoped_allowed_roots; static thread_local bool m_audit_denial_pending; static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; // mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. mutable std::mutex m_mutex; - std::vector m_global_allowed_roots; + std::vector m_global_allowed_roots; std::vector m_denied_filenames; + std::vector m_denied_path_keywords; std::unordered_map> m_approved_call_sites; // plugin_key -> call-site ids }; @@ -160,7 +196,7 @@ public: private: std::string m_previous_id; std::string m_previous_capability; - std::vector m_previous_scoped_roots; + std::vector m_previous_scoped_roots; }; } // namespace Slic3r diff --git a/tests/slic3rutils/plugin_test_utils.hpp b/tests/slic3rutils/plugin_test_utils.hpp index d60b3441c8..cbdcf1ff95 100644 --- a/tests/slic3rutils/plugin_test_utils.hpp +++ b/tests/slic3rutils/plugin_test_utils.hpp @@ -34,4 +34,24 @@ struct ScopedDataDir ScopedDataDir& operator=(const ScopedDataDir&) = delete; }; +// Point resources_dir() at a throwaway directory for the lifetime of a test and restore the +// previous value afterwards, mirroring ScopedDataDir. +struct ScopedResourcesDir +{ + ScopedTemporaryDir tmp; + boost::filesystem::path dir; + std::string previous; + + explicit ScopedResourcesDir(const std::string& tag) + : tmp("orca-" + tag), dir(tmp.path()), previous(resources_dir()) + { + set_resources_dir(dir.string()); + } + + ~ScopedResourcesDir() { set_resources_dir(previous); } + + ScopedResourcesDir(const ScopedResourcesDir&) = delete; + ScopedResourcesDir& operator=(const ScopedResourcesDir&) = delete; +}; + } // namespace Slic3r diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 20fc2b86e7..71ce692671 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -27,6 +27,16 @@ void seed_denied_names() mgr.add_denied_filename(name); } +// Seed the keyword registry with the same list install_hook() uses. Same rationale as +// seed_denied_names(): a process-singleton registry, seeded from the single shared source so +// production and tests cannot drift apart. +void seed_denied_keywords() +{ + PluginAuditManager& mgr = PluginAuditManager::instance(); + for (const auto& keyword : PluginAuditManager::default_denied_path_keywords()) + mgr.add_denied_path_keyword(keyword); +} + } // namespace TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") @@ -190,3 +200,139 @@ TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]") CHECK(mgr.check_open(conf.string(), "w").allowed); CHECK(mgr.check_open(conf.string(), "r").allowed); } + +TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", "[audit]") +{ + seed_denied_keywords(); + const PluginAuditManager& mgr = PluginAuditManager::instance(); + + SECTION("a 'secrets' directory component is denied") + { + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secrets/api_key.json"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secret/token.txt"))); + } + + SECTION("a 'certificate(s)' directory component is denied") + { + CHECK(mgr.is_denied_path_keyword(fs::path("/resources/cert/slicer_base64.cer"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem"))); + } + + SECTION("a 'conf'/'config' directory or file component is denied") + { + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf"))); + } + + SECTION("matching is case-insensitive") + { + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON"))); + } + + SECTION("matching is not limited to the base name -- any ancestor component counts") + { + CHECK(mgr.is_denied_path_keyword(fs::path("/data/secrets/nested/deep/file.txt"))); + } + + SECTION("an unrelated path is not denied") + { + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json"))); + } + + SECTION("an empty path is not denied") + { + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path())); + } +} + +TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]") +{ + seed_denied_names(); + seed_denied_keywords(); + const PluginAuditManager& mgr = PluginAuditManager::instance(); + + SECTION("a filename-registry match is denied") + { + CHECK(mgr.is_denied_path(fs::path(SLIC3R_APP_KEY ".conf"))); + } + + SECTION("a keyword-registry match is denied") + { + CHECK(mgr.is_denied_path(fs::path("/plugin/secrets/token.txt"))); + } + + SECTION("a path matching neither registry is not denied") + { + CHECK_FALSE(mgr.is_denied_path(fs::path("/plugin/output/model.gcode"))); + } +} + +TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "[audit]") +{ + ScopedDataDir data_dir_guard("plugin-audit-readonly"); + ScopedResourcesDir resources_dir_guard("plugin-audit-readonly-resources"); + seed_denied_names(); + seed_denied_keywords(); + + PluginAuditManager& mgr = PluginAuditManager::instance(); + mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false); + + ScopedPluginAuditContext ctx("test_plugin", ""); + + const fs::path readonly_file = fs::path(resources_dir()) / "profiles" / "vendor.json"; + + SECTION("a read inside the read-only root is allowed") + { + CHECK(mgr.check_open(readonly_file.string(), "r").allowed); + } + + SECTION("a write inside the read-only root is blocked") + { + AuditDecision decision = mgr.check_open(readonly_file.string(), "w"); + CHECK_FALSE(decision.allowed); + CHECK(decision.reason == "outside allowed root"); + } + + SECTION("a create inside the read-only root is blocked") + { + AuditDecision decision = mgr.check_path_access(readonly_file, /*is_write=*/true); + CHECK_FALSE(decision.allowed); + } + + SECTION("the bundled cert underneath the read-only root is denied even for reads") + { + const fs::path cert = fs::path(resources_dir()) / "cert" / "slicer_base64.cer"; + AuditDecision decision = mgr.check_open(cert.string(), "r"); + CHECK_FALSE(decision.allowed); + CHECK(decision.reason == "denied path keyword"); + } +} + +TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]") +{ + ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly"); + seed_denied_names(); + seed_denied_keywords(); + + PluginAuditManager& mgr = PluginAuditManager::instance(); + + const fs::path readonly_dir = fs::path(data_dir()) / "readonly_scope"; + fs::create_directories(readonly_dir); + + ScopedPluginAuditContext ctx("test_plugin", ""); + mgr.add_scoped_allowed_root(readonly_dir, /*allow_write=*/false); + + SECTION("a read inside the scoped read-only root is allowed") + { + CHECK(mgr.check_open((readonly_dir / "vendor.json").string(), "r").allowed); + } + + SECTION("a write inside the scoped read-only root is blocked") + { + CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed); + } +}