diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 9cf010d803..7177918f27 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -528,81 +528,6 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const return check_path_access(boost::filesystem::path(path_str), is_write); } -bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key, - const std::vector& paths) -{ - if (plugin_key.empty() || paths.empty()) - return true; - - PluginDescriptor descriptor; - if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) - return false; - - PluginInstallState state; - read_install_state(boost::filesystem::path(descriptor.plugin_root), state); - - std::vector missing; - for (const std::string& path : paths) { - if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end()) - missing.push_back(path); - } - if (missing.empty()) - return true; - - if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) - return false; - - auto show_dialog = [&descriptor, &missing]() { - wxString requested_paths; - for (const std::string& path : missing) - requested_paths += wxString::FromUTF8(path.c_str()) + "\n"; - - wxMessageDialog dialog( - nullptr, - wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"), - wxString::FromUTF8(descriptor.name.c_str()), requested_paths), - _L("Plugin permissions"), - wxYES_NO | wxICON_WARNING); - return dialog.ShowModal() == wxID_YES; - }; - - bool granted = false; - if (wxIsMainThread()) { - granted = show_dialog(); - } else { - auto result = std::make_shared>(); - auto future = result->get_future(); - GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() { - wxString requested_paths; - for (const std::string& path : missing) - requested_paths += wxString::FromUTF8(path.c_str()) + "\n"; - - wxMessageDialog dialog( - nullptr, - wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"), - wxString::FromUTF8(descriptor_name.c_str()), requested_paths), - _L("Plugin permissions"), - wxYES_NO | wxICON_WARNING); - result->set_value(dialog.ShowModal() == wxID_YES); - }); - granted = future.get(); - } - - if (!granted) - return false; - - if (state.plugin_name.empty()) { - state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; - state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version; - state.plugin_name = descriptor.name; - state.cloud_uuid = descriptor.cloud_uuid(); - state.enabled = true; - } - - state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end()); - return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); -} - void PluginAuditManager::report_violation(const AuditViolation& violation) { m_last_violation = violation; @@ -838,7 +763,7 @@ std::vector audit_targets(const std::string& event_name, AuditEvent // Thread creation exposes no user-supplied target. Use a fixed sentinel so the grant // persists per plugin: the permission list matches targets by exact string, and the // started function's repr embeds an address that changes every run. - targets.emplace_back("thread"); + targets.emplace_back(kThreadPermissionTarget); return targets; default: break; @@ -894,6 +819,20 @@ bool has_permission(const std::vector& granted, const std::string& return std::find(granted.begin(), granted.end(), target) != granted.end(); } +// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata the +// writer needs so clicking Yes still creates the JSON file. +void seed_install_metadata(const PluginDescriptor& descriptor, PluginInstallState& state) +{ + if (!state.plugin_name.empty()) + return; + + state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; + state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version; + state.plugin_name = descriptor.name; + state.cloud_uuid = descriptor.cloud_uuid(); + state.enabled = true; +} + bool persist_permission(const std::string& plugin_key, PluginInstallState& state, std::vector& permission_list, @@ -903,15 +842,7 @@ bool persist_permission(const std::string& plugin_key, if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) return false; - // A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata - // needed by the writer so clicking Yes still creates the JSON file. - if (state.plugin_name.empty()) { - state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; - state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version; - state.plugin_name = descriptor.name; - state.cloud_uuid = descriptor.cloud_uuid(); - state.enabled = true; - } + seed_install_metadata(descriptor, state); if (!has_permission(permission_list, target)) permission_list.push_back(target); @@ -997,6 +928,13 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n return dialog.ShowModal() == wxID_YES; } +// Shows a prompt whose body may cover several permission buckets. Must run on the GUI thread. +bool prompt_for_permission_message(const wxString& message) +{ + wxMessageDialog dialog(nullptr, message, _L("Plugin permissions"), wxYES_NO | wxICON_WARNING); + return dialog.ShowModal() == wxID_YES; +} + // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // freshly because the async prompt outlives the caller's stack copy of it. void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector& keys) @@ -1023,7 +961,7 @@ static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and // BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops. // Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that -// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the +// request_permissions runs on -- would therefore deadlock the application (the // invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt // is therefore posted asynchronously and the current event denied (fail closed, like an unanswered // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without @@ -1147,6 +1085,105 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A } // namespace PluginAuditDetail +bool PluginAuditManager::request_permissions(const std::string& plugin_key, const PluginPermissions& requested) +{ + if (plugin_key.empty()) + return true; + + PluginDescriptor descriptor; + if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) + return false; + + PluginInstallState state; + read_install_state(boost::filesystem::path(descriptor.plugin_root), state); + + // Per bucket, keep only the targets that no earlier grant already covers. Dropping these + // before prompting keeps a repeated declaration from asking again for what is on disk. + struct MissingBucket { + AuditEventCategory category; + std::vector targets; + }; + std::vector missing_buckets; + + const auto collect_missing = [&missing_buckets](AuditEventCategory category, + const std::vector& requested_targets, + const std::vector& granted_targets) { + MissingBucket bucket{category, {}}; + for (const std::string& target : requested_targets) { + if (target.empty()) + continue; + if (std::find(granted_targets.begin(), granted_targets.end(), target) == granted_targets.end() && + std::find(bucket.targets.begin(), bucket.targets.end(), target) == bucket.targets.end()) + bucket.targets.push_back(target); + } + if (!bucket.targets.empty()) + missing_buckets.push_back(std::move(bucket)); + }; + + collect_missing(AuditEventCategory::FsRead, requested.fs_read, state.permissions.fs_read); + collect_missing(AuditEventCategory::FsReadWrite, requested.fs_readwrite, state.permissions.fs_readwrite); + collect_missing(AuditEventCategory::Http, requested.network_http, state.permissions.network_http); + collect_missing(AuditEventCategory::Socket, requested.network_socket, state.permissions.network_socket); + collect_missing(AuditEventCategory::ProcessCreate, requested.process, state.permissions.process); + collect_missing(AuditEventCategory::Threading, requested.threading, state.permissions.threading); + + if (missing_buckets.empty()) + return true; + + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) + return false; + + const auto build_message = [](const std::string& plugin_name, const std::vector& buckets) { + wxString message; + for (const MissingBucket& bucket : buckets) { + wxString targets; + for (const std::string& target : bucket.targets) + targets += wxString::FromUTF8(target.c_str()) + "\n"; + + if (!message.IsEmpty()) + message += "\n\n"; + message += PluginAuditDetail::audit_message(bucket.category, wxString::FromUTF8(plugin_name.c_str()), + wxEmptyString, targets, bucket.targets.size()); + } + return message; + }; + + bool granted = false; + if (wxIsMainThread()) { + granted = PluginAuditDetail::prompt_for_permission_message(build_message(descriptor.name, missing_buckets)); + } else { + auto result = std::make_shared>(); + auto future = result->get_future(); + GUI::wxGetApp().CallAfter([result, plugin_name = descriptor.name, missing_buckets, build_message]() { + result->set_value(PluginAuditDetail::prompt_for_permission_message(build_message(plugin_name, missing_buckets))); + }); + granted = future.get(); + } + + if (!granted) + return false; + + // The dialog can stay open while another grant is persisted; re-read before saving so this + // request adds to the current state instead of replacing it. + PluginInstallState latest_state; + if (read_install_state(boost::filesystem::path(descriptor.plugin_root), latest_state)) + state = std::move(latest_state); + + PluginAuditDetail::seed_install_metadata(descriptor, state); + + for (const MissingBucket& bucket : missing_buckets) { + std::vector* permission_list = + PluginAuditDetail::permission_list_for(bucket.category, /*by_event=*/false, state.permissions); + if (!permission_list) + continue; + for (const std::string& target : bucket.targets) + if (std::find(permission_list->begin(), permission_list->end(), target) == permission_list->end()) + permission_list->push_back(target); + } + + return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); +} + void PluginAuditManager::record_root_frame() { PyObject* frame = reinterpret_cast(PyEval_GetFrame()); // borrowed reference diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index ac6d99a4da..52e2e2c0ef 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -13,6 +13,12 @@ namespace Slic3r { +struct PluginPermissions; + +// Thread creation exposes no user-supplied target, so both the runtime audit event and a +// request_permissions(threading=True) declaration use this fixed target. +inline constexpr const char* kThreadPermissionTarget = "thread"; + struct AuditDecision { bool allowed = true; std::string reason; @@ -130,12 +136,12 @@ public: AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write); AuditDecision check_open(const std::string& path, const std::string& mode); - // Ask the user to grant the requested filesystem-read paths. The request may originate on a - // plugin load worker, so the implementation marshals the modal dialog to the wx main thread. - // Returns true only when every missing path was granted and persisted; denial aborts the plugin - // load without adding a permission. - bool request_filesystem_read_permissions(const std::string& plugin_key, - const std::vector& paths); + // Ask the user to grant the requested permissions. The request may originate on a plugin + // load worker, so the implementation marshals the modal dialog to the wx main thread. + // Targets already persisted for the plugin are not shown or asked for again. Returns true + // only when every missing target was granted and persisted; denial aborts the plugin load + // without adding a permission. + bool request_permissions(const std::string& plugin_key, const PluginPermissions& requested); void report_violation(const AuditViolation& violation); bool audit_denial_pending() const; diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 103de8e362..dc227d3c8a 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -49,7 +49,7 @@ thread_local std::string g_active_plugin_key; std::mutex g_registry_mutex; std::unordered_map> g_pending_capabilities; std::unordered_map g_pending_package; -std::unordered_map> g_pending_fs_read_permissions; +std::unordered_map g_pending_permissions; struct PluginInstanceHandle { // The C++ plugin interface points into a Python object. Keep both alive through one @@ -88,7 +88,7 @@ void discard_pending_capture_without_python(const std::string& plugin_key) g_pending_package.erase(package); } - g_pending_fs_read_permissions.erase(plugin_key); + g_pending_permissions.erase(plugin_key); } } // namespace @@ -113,7 +113,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key) // for this same entry path. g_pending_capabilities.erase(plugin_key); g_pending_package.erase(plugin_key); - g_pending_fs_read_permissions.erase(plugin_key); + g_pending_permissions.erase(plugin_key); } // From now until finalize/cancel, @orca.plugin and register_capability() calls made by // Python code on this thread are attributed to this plugin. @@ -144,7 +144,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons std::lock_guard lock(g_registry_mutex); g_pending_capabilities.erase(capture_key); g_pending_package.erase(capture_key); - g_pending_fs_read_permissions.erase(capture_key); + g_pending_permissions.erase(capture_key); }; try { @@ -177,23 +177,27 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons // Permission declarations are collected while register_capabilities() runs so the // plugin can describe its needs without touching wx from the Python load worker. Ask // only after registration returns, before capability instances are materialized. - std::vector fs_read_permissions; + PluginPermissions requested_permissions; { std::lock_guard lock(g_registry_mutex); - auto it = g_pending_fs_read_permissions.find(capture_key); - if (it != g_pending_fs_read_permissions.end()) { - fs_read_permissions = std::move(it->second); - g_pending_fs_read_permissions.erase(it); + auto it = g_pending_permissions.find(capture_key); + if (it != g_pending_permissions.end()) { + requested_permissions = std::move(it->second); + g_pending_permissions.erase(it); } } - if (!fs_read_permissions.empty()) { + const bool has_permission_request = + !requested_permissions.fs_read.empty() || !requested_permissions.fs_readwrite.empty() || + !requested_permissions.network_http.empty() || !requested_permissions.network_socket.empty() || + !requested_permissions.process.empty() || !requested_permissions.threading.empty(); + if (has_permission_request) { bool granted = false; { py::gil_scoped_release release; - granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions); + granted = PluginAuditManager::instance().request_permissions(plugin_key, requested_permissions); } if (!granted) { - error = _u8L("Plugin filesystem read permission request denied"); + error = _u8L("Plugin permission request denied"); BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key; discard_pending_for_key(); clear_active_key(); @@ -322,7 +326,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key) // may already have registered under this key. g_pending_capabilities.erase(plugin_key); g_pending_package.erase(plugin_key); - g_pending_fs_read_permissions.erase(plugin_key); + g_pending_permissions.erase(plugin_key); } if (g_active_plugin_key == plugin_key) @@ -349,7 +353,7 @@ void PythonPluginBridge::clear_pending_captures() (void) pkg.release(); } g_pending_package.clear(); - g_pending_fs_read_permissions.clear(); + g_pending_permissions.clear(); g_active_plugin_key.clear(); return; } @@ -358,7 +362,7 @@ void PythonPluginBridge::clear_pending_captures() BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)"; g_pending_capabilities.clear(); g_pending_package.clear(); - g_pending_fs_read_permissions.clear(); + g_pending_permissions.clear(); g_active_plugin_key.clear(); } @@ -558,24 +562,54 @@ void bind_python_api(pybind11::module_& m) m.def( "request_permissions", - [](const std::vector& fs_read) { + [](const std::vector& fs_read, + const std::vector& fs_readwrite, + const std::vector& network_http, + const std::vector& network_socket, + const std::vector& process, + bool threading) { if (g_active_plugin_key.empty()) throw py::value_error("request_permissions() called outside plugin discovery context"); std::lock_guard lock(g_registry_mutex); - auto& requested = g_pending_fs_read_permissions[g_active_plugin_key]; - for (const std::string& path : fs_read) { - if (path.empty()) - throw py::value_error("request_permissions(fs_read=...) does not accept empty paths"); - if (std::find(requested.begin(), requested.end(), path) == requested.end()) - requested.push_back(path); - } + PluginPermissions& requested = g_pending_permissions[g_active_plugin_key]; + + const auto add_targets = [](std::vector& bucket, const char* name, + const std::vector& targets) { + for (const std::string& target : targets) { + if (target.empty()) + throw py::value_error(std::string("request_permissions(") + name + + "=...) does not accept empty values"); + if (std::find(bucket.begin(), bucket.end(), target) == bucket.end()) + bucket.push_back(target); + } + }; + + add_targets(requested.fs_read, "fs_read", fs_read); + add_targets(requested.fs_readwrite, "fs_readwrite", fs_readwrite); + add_targets(requested.network_http, "network_http", network_http); + add_targets(requested.network_socket, "network_socket", network_socket); + add_targets(requested.process, "process", process); + + if (threading && std::find(requested.threading.begin(), requested.threading.end(), + kThreadPermissionTarget) == requested.threading.end()) + requested.threading.emplace_back(kThreadPermissionTarget); }, py::arg("fs_read") = std::vector{}, - R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module. + py::arg("fs_readwrite") = std::vector{}, + py::arg("network_http") = std::vector{}, + py::arg("network_socket") = std::vector{}, + py::arg("process") = std::vector{}, + py::arg("threading") = false, + R"pbdoc(Request permissions while OrcaSlicer loads your module. -The host presents the request to the user after register_capabilities() returns. Denying the -request aborts the plugin load.)pbdoc"); +Each bucket takes the target strings the plugin expects the corresponding audit event to +report: filesystem paths for fs_read and fs_readwrite, URLs for network_http, socket addresses +for network_socket, and commands for process. threading=True requests permission to start +threads. Targets already granted to the plugin are not asked for again. + +The host presents one request to the user after register_capabilities() returns and before any +capability instance is created. Denying the request aborts the plugin load.)pbdoc"); m.def("plugin", [](py::object cls) { if (g_active_plugin_key.empty())