diff --git a/CMakeLists.txt b/CMakeLists.txt index 62798ebfc4..f4b757c74d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,6 +123,7 @@ option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0) # Python stubgen module option(ORCA_BUILD_PYTHON_STUBGEN_MODULE "Build importable Python module for pybind11-stubgen" ON) +option(ORCA_BUILD_PLUGIN_PERMISSION_PROBE "Build the headless Python plugin permission probe (developer tool)" OFF) set(ORCA_BUNDLED_UV_EXECUTABLE "" CACHE FILEPATH "Path to a uv executable to bundle for Python package installation. Leave empty to auto-download.") # Auto-download uv if not provided by the user. diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index 128318039f..5fb8c4b3da 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -366,6 +366,7 @@ src/slic3r/Utils/MoonrakerPrinterAgent.cpp src/slic3r/Utils/BBLNetworkPlugin.cpp src/slic3r/Utils/OrcaCloudServiceAgent.cpp src/slic3r/plugin/PluginAuditManager.cpp +src/slic3r/plugin/PluginAuditManagerGui.cpp src/slic3r/plugin/PluginConfig.cpp src/slic3r/plugin/PluginResolver.cpp src/slic3r/plugin/CloudPluginService.cpp diff --git a/scripts/run_gettext.bat b/scripts/run_gettext.bat index c678ffeb9e..63fec21bbe 100644 --- a/scripts/run_gettext.bat +++ b/scripts/run_gettext.bat @@ -23,7 +23,7 @@ if %FULL_MODE%==1 ( call :prepareGettextList "%list_file%" "%filtered_list%" "%missing_list%" if "!has_sources!"=="1" ( if not exist "%generated_i18n%" mkdir "%generated_i18n%" - .\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%" + .\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%" if errorlevel 1 ( set "script_exit_code=1" ) else ( diff --git a/scripts/run_gettext.sh b/scripts/run_gettext.sh index 1a927e5414..edd88afbb5 100755 --- a/scripts/run_gettext.sh +++ b/scripts/run_gettext.sh @@ -85,7 +85,7 @@ if $FULL_MODE; then generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot" mkdir -p "$generated_i18n_dir" - xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file" + xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file" python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir" if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 367aadaa27..c8292496dc 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -707,6 +707,10 @@ set(SLIC3R_GUI_SOURCES plugin/PluginManager.hpp plugin/PluginAuditManager.cpp plugin/PluginAuditManager.hpp + plugin/PluginAuditManagerGui.cpp + plugin/PluginAuditManagerInternal.hpp + plugin/PluginModule.cpp + plugin/PluginText.hpp plugin/PluginResolver.cpp plugin/PluginResolver.hpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp @@ -1092,3 +1096,61 @@ if (ORCA_BUILD_PYTHON_STUBGEN_MODULE) PREFIX "" ) endif() + +# Headless plugin permission probe. A distinct executable so normal GUI loading and prompt +# behavior stay untouched. It compiles the GUI-free plugin sources directly instead of linking +# libslic3r_gui, so neither the GUI library nor wx is pulled into the probe. The permission +# front end is selected by source composition: PluginAuditManagerGui.cpp for the GUI app and +# probe/PluginAuditManagerHeadless.cpp here. +# +# Residual cost: the probe still links libslic3r (the large core library) for Utils, +# LifecycleEvents, the model/print types used by the GUI-free host bindings and libslic3r's own +# transitive dependencies. It does not link libslic3r_gui, wx or the GUI-only host/plugin sources. +# Developer/security-analysis tool, so it is off by default. +if (ORCA_BUILD_PLUGIN_PERMISSION_PROBE) + add_executable(orca-plugin-probe + plugin/PluginPermissionProbe.cpp + plugin/PluginProbeCollector.hpp + plugin/probe/NanoSvgImplementation.cpp + plugin/PluginAuditManager.cpp + plugin/PluginAuditManagerInternal.hpp + plugin/PluginModule.cpp + plugin/PluginText.hpp + plugin/probe/PluginAuditManagerHeadless.cpp + plugin/PluginFsUtils.cpp + plugin/PluginLoader.cpp + plugin/PythonInterpreter.cpp + plugin/PythonPluginBridge.cpp + plugin/probe/PluginHostProbe.cpp + plugin/probe/PluginProbeConfig.cpp + plugin/host/PluginHostGeometry.cpp + plugin/host/PluginHostMesh.cpp + plugin/host/PluginHostModel.cpp + plugin/host/PluginHostSlicing.cpp + plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp + plugin/pluginTypes/pages/PagesPluginCapability.cpp + plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp + plugin/pluginTypes/script/ScriptPluginCapability.cpp + plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp + ) + + target_compile_definitions(orca-plugin-probe PRIVATE ORCA_PLUGIN_PROBE_BUILD) + + target_include_directories(orca-plugin-probe PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR} + ${CMAKE_SOURCE_DIR}/src + Utils + ) + + target_link_libraries(orca-plugin-probe PRIVATE + libslic3r + nanosvg + pybind11::embed + ) + + set_target_properties(orca-plugin-probe PROPERTIES + OUTPUT_NAME orca-plugin-probe + FOLDER "tools" + ) +endif() diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 9cf010d803..c4f57c691e 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -1,4 +1,5 @@ #include "PluginAuditManager.hpp" +#include "PluginAuditManagerInternal.hpp" #include "../Utils/OrcaCloudServiceAgent.hpp" #include "libslic3r/Utils.hpp" @@ -15,25 +16,12 @@ #include #include #include -#include #include -#include -#include -#include -#include -#include #include #include #include #include #include -#include -#include -#include -#include -#include -#include "slic3r/GUI/I18N.hpp" -#include "slic3r/plugin/PluginDescriptor.hpp" #include namespace Slic3r { @@ -161,8 +149,10 @@ static bool is_fs_category(AuditEventCategory category) } } +namespace plugin_audit { + // The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open". -static const char* category_name(AuditEventCategory category) +const char* category_name(AuditEventCategory category) { switch (category) { case AuditEventCategory::FsRead: return "FsRead"; @@ -179,6 +169,29 @@ static const char* category_name(AuditEventCategory category) return "None"; } +int report_denied(PluginAuditManager& mgr, + const std::string& event_name, + const AuditDecision& decision, + const std::string& target) +{ + AuditViolation violation; + violation.plugin_key = mgr.current_plugin(); + violation.event_name = event_name; + violation.reason = decision.reason; + mgr.report_violation(violation); + + std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission"; + if (!decision.reason.empty()) + message += ": " + decision.reason; + if (!target.empty()) + message += ": " + target; + + PyErr_SetString(PyExc_PermissionError, message.c_str()); + return -1; +} + +} // namespace plugin_audit + // --------------------------------------------------------------------------- // Path safety // --------------------------------------------------------------------------- @@ -247,6 +260,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::string PluginAuditManager::m_current_audit_phase = ""; thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; thread_local std::vector PluginAuditManager::m_root_frames; thread_local int PluginAuditManager::m_releasing_frames = 0; @@ -316,6 +330,76 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); } +void PluginAuditManager::set_current_audit_phase(const std::string& phase) { m_current_audit_phase = phase; } + +const std::string& PluginAuditManager::current_audit_phase() const { return m_current_audit_phase; } + +void PluginAuditManager::clear_current_audit_phase() { m_current_audit_phase.clear(); } + +void PluginAuditManager::set_observation_observer(AuditObserver observer) +{ + std::lock_guard lock(m_observer_mutex); + m_observation_observer = std::move(observer); +} + +void PluginAuditManager::clear_observation_observer() +{ + std::lock_guard lock(m_observer_mutex); + m_observation_observer = nullptr; +} + +bool PluginAuditManager::observation_mode() const +{ + std::lock_guard lock(m_observer_mutex); + return static_cast(m_observation_observer); +} + +// The audit hook holds the GIL here. Copy the observer out before invoking it so a callback that +// reads the manager cannot deadlock on m_observer_mutex. +bool PluginAuditManager::record_observation(const std::string& event_name, + const std::string& category, + const std::vector& targets) +{ + AuditObserver observer; + { + std::lock_guard lock(m_observer_mutex); + observer = m_observation_observer; + } + if (!observer) + return false; + + // The observer is called from a CPython C callback; a C++ exception must not unwind through + // the audit hook. The probe's collector only allocates, so a throw here is a lost observation, + // not a reason to abort the audited operation. + try { + AuditObservation observation; + observation.plugin_key = m_current_plugin_key; + observation.capability = m_current_capability_name; + observation.phase = m_current_audit_phase; + observation.event_name = event_name; + observation.category = category; + observation.targets = targets; + // Probe mode never denies: the hook returns 0 after recording. A later failure of the + // surrounding Python operation is reported by the probe's callback/load result, not here. + observation.continued = true; + observer(observation); + } catch (...) { + } + return true; +} + +ScopedPluginAuditPhase::ScopedPluginAuditPhase(std::string phase) + : m_phase(std::move(phase)) + , m_previous_phase(PluginAuditManager::instance().current_audit_phase()) +{ + PluginAuditManager::instance().set_current_audit_phase(m_phase); +} + +ScopedPluginAuditPhase::~ScopedPluginAuditPhase() +{ + PluginAuditManager::instance().set_current_audit_phase(m_previous_phase); +} + void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write) { if (root.empty()) @@ -534,73 +618,14 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string& 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()) + // Probe mode records the declaration and proceeds. It must not consult the install state, + // open the permission dialog, or persist a grant. + if (observation_mode()) { + record_observation("orca.request_permissions", "FsRead", paths); 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); + return plugin_audit::request_filesystem_read_permissions(plugin_key, paths); } void PluginAuditManager::report_violation(const AuditViolation& violation) @@ -862,239 +887,18 @@ std::vector audit_targets(const std::string& event_name, AuditEvent return targets; } -// The saved list that grants an event: its category's target list, or the event-grant list for -// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an -// os.fork grant is not an os.exec of a command named "os.fork"). -std::vector* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions) -{ - if (by_event) - return &permissions.events; - - switch (category) { - case AuditEventCategory::FsRead: return &permissions.fs_read; - case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; - case AuditEventCategory::Http: return &permissions.network_http; - case AuditEventCategory::Socket: return &permissions.network_socket; - case AuditEventCategory::ProcessCreate: return &permissions.process; - case AuditEventCategory::Threading: return &permissions.threading; - case AuditEventCategory::ProcessReplace: return &permissions.process; - default: return nullptr; - } -} - -// An event with no target is granted by its effective category and name, e.g. "FsRead:open" -// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open"). -std::string event_grant_key(AuditEventCategory category, const std::string& event_name) -{ - return std::string(category_name(category)) + ":" + event_name; -} - -bool has_permission(const std::vector& granted, const std::string& target) -{ - return std::find(granted.begin(), granted.end(), target) != granted.end(); -} - -bool persist_permission(const std::string& plugin_key, - PluginInstallState& state, - std::vector& permission_list, - const std::string& target) -{ - PluginDescriptor descriptor; - 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; - } - - if (!has_permission(permission_list, target)) - permission_list.push_back(target); - return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); -} - -int report_denied(PluginAuditManager& mgr, - const std::string& event_name, - const AuditDecision& decision, - const std::string& target = {}) -{ - AuditViolation violation; - violation.plugin_key = mgr.current_plugin(); - violation.event_name = event_name; - violation.reason = decision.reason; - mgr.report_violation(violation); - - std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission"; - if (!decision.reason.empty()) - message += ": " + decision.reason; - if (!target.empty()) - message += ": " + target; - - PyErr_SetString(PyExc_PermissionError, message.c_str()); - return -1; -} - -wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name, - const wxString& target_list, size_t target_count) -{ - if (target_list.IsEmpty()) - return wxString::Format( - _L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n" - "This operation does not expose a target the audit hook can display."), - plugin_name, event_name); - - switch (category) { - case AuditEventCategory::FsRead: - return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s", - "Plugin \"%s\" is requesting to read the following files:\n%s", target_count), - plugin_name, target_list); - case AuditEventCategory::FsReadWrite: - return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s", - "Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count), - plugin_name, target_list); - case AuditEventCategory::FsCreate: - return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s", - "Plugin \"%s\" is requesting to create the following files:\n%s", target_count), - plugin_name, target_list); - case AuditEventCategory::FsDelete: - return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s", - "Plugin \"%s\" is requesting to delete the following files:\n%s", target_count), - plugin_name, target_list); - case AuditEventCategory::Http: - return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list); - case AuditEventCategory::Socket: - return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list); - case AuditEventCategory::ProcessCreate: - return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s", - "Plugin \"%s\" is requesting to run the following commands:\n%s", target_count), - plugin_name, target_list); - case AuditEventCategory::Threading: - return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name); - case AuditEventCategory::ProcessReplace: - return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\n%s"), plugin_name, target_list); - default: - return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name); - } -} - -// Builds and shows the modal permission prompt. Must run on the GUI thread. -bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name, - const std::vector& unresolved) -{ - wxString target_list; - for (const auto& target : unresolved) - target_list += wxString::FromUTF8(target.c_str()) + "\n"; - - wxMessageDialog dialog(nullptr, - audit_message(category, wxString::FromUTF8(plugin_name.c_str()), - wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()), - _L("Plugin permission request"), 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) -{ - PluginInstallState state; - if (!PluginManager::instance().get_install_state(plugin_key, state)) - return; - - std::vector* permission_list = permission_list_for(category, by_event, state.permissions); - if (!permission_list) - return; - - for (const auto& key : keys) - persist_permission(plugin_key, state, *permission_list, key); -} - -static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; - -// Asks the user to grant an audited event, returning true when they approve an inline prompt. -// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has -// no target (by_event), which the prompt does not show as a target. -// -// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the -// 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 -// 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 -// re-prompting. -bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, - const std::string& event_name, bool by_event, const std::vector& keys) -{ - const std::vector shown = by_event ? std::vector{} : keys; - - if (s_permission_prompt_for_testing) - return s_permission_prompt_for_testing(event_name, shown); - - if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) - return false; - - if (wxIsMainThread()) - return prompt_for_targets(category, plugin_name, event_name, shown); - - GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() { - if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) - return; - if (prompt_for_targets(category, plugin_name, event_name, shown)) - persist_grant(plugin_key, category, by_event, keys); - }); - return false; -} - -// Decides an event that no allowed root or running operation covers: a saved grant allows it, -// otherwise the user is asked and a Yes is saved. -int decide_audited_event(PluginAuditManager& mgr, - PluginInstallState& state, - const std::string& plugin_key, - const std::string& plugin_name, - const std::string& event_name, - AuditEventCategory category, - const std::vector& targets) -{ - const bool by_event = targets.empty(); - const std::vector keys = by_event ? std::vector{event_grant_key(category, event_name)} : targets; - std::vector* permission_list = permission_list_for(category, by_event, state.permissions); - - std::vector unresolved = keys; - if (permission_list) { - unresolved.clear(); - for (const auto& key : keys) - if (!has_permission(*permission_list, key)) - unresolved.push_back(key); - if (unresolved.empty()) - return 0; - } - - // Letting a root frame go runs finalizers inside a scope destructor or another event's hook, - // where no dialog may open: what they do must already be granted. - if (mgr.releasing_frames()) - return report_denied(mgr, event_name, {false, "audit permission required"}); - - if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved)) - return report_denied(mgr, event_name, {false, "audit permission required"}); - - if (permission_list) - for (const auto& key : unresolved) - persist_permission(plugin_key, state, *permission_list, key); - return 0; -} - // Judges an audited event raised while a plugin context is active. int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args) { const bool fs_category = is_fs_category(event_type); const std::vector targets = audit_targets(event_name, event_type, args); + // Probe mode: report the classified event and let it through. Deliberately before the deny + // registry and allowed-root checks, so the report covers operations that production would + // block as well as those it would allow. No grant is read or written. + if (mgr.record_observation(event_name, plugin_audit::category_name(event_type), targets)) + return 0; + // A denied path (secrets, certificates, config files -- see is_denied_path) is an // unconditional block: checked before the running-operation check below, so no granted // operation can launder access to one, and before the allowed-root shortcut further down, @@ -1103,7 +907,7 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A if (fs_category) { for (const auto& target : targets) { if (mgr.is_denied_path(boost::filesystem::path(target))) - return report_denied(mgr, event_name, {false, "denied path"}, target); + return plugin_audit::report_denied(mgr, event_name, {false, "denied path"}, target); } } @@ -1128,15 +932,7 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A return 0; } - PluginInstallState state; - const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state); - if (!have_install_state) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin(); - } - - const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name; - - const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets); + const int result = plugin_audit::decide_event(mgr, event_name, event_type, targets); // A granted operation covers its nested events while it runs. A targetless grant names no // operation, so it covers nothing else. @@ -1223,11 +1019,6 @@ void PluginAuditManager::release_frames(std::vector& frames) PyErr_SetRaisedException(error); } -void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt) -{ - PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt); -} - int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) { auto* mgr = static_cast(user_data); diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index ac6d99a4da..ed3f5ca936 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -24,6 +24,19 @@ struct AuditViolation { std::string reason; }; +// One classified CPython audit event observed while the probe is in record-only mode. This is +// evidence about the code path that ran, not a grant request: the hook allowed the operation to +// continue and did not consult the permission dialog or the persisted install state. +struct AuditObservation { + std::string plugin_key; + std::string capability; + std::string phase; + std::string event_name; + std::string category; + std::vector targets; + bool continued = true; +}; + // 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. @@ -137,6 +150,30 @@ public: bool request_filesystem_read_permissions(const std::string& plugin_key, const std::vector& paths); + // --- probe observation mode (thread-safe, never used by the GUI) --- + // When an observer is installed, every classified event raised by plugin-authored code is + // reported to it and then allowed through (the hook returns 0). The permission dialogs, saved + // grants and .install_state.json are never consulted, so this is not a containment boundary. + // The observer must not touch Python objects: it runs inside the audit hook with the GIL held. + using AuditObserver = std::function; + void set_observation_observer(AuditObserver observer); + void clear_observation_observer(); + bool observation_mode() const; + + // Stop after classification/target extraction; true means an observer consumed the event. + // Exposed for the probe and its tests, not for production callers. + bool record_observation(const std::string& event_name, + const std::string& category, + const std::vector& targets); + + // --- probe phase attribution (thread_local) --- + // Labels events for the probe's report. Production leaves it empty. A Python thread spawned by + // plugin code inherits no phase, so its events are reported as unattributed rather than being + // guessed at. + void set_current_audit_phase(const std::string& phase); + const std::string& current_audit_phase() const; + void clear_current_audit_phase(); + void report_violation(const AuditViolation& violation); bool audit_denial_pending() const; void clear_audit_denial(); @@ -178,6 +215,7 @@ private: static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_capability_name; + static thread_local std::string m_current_audit_phase; static thread_local std::vector m_scoped_allowed_roots; static thread_local std::vector m_root_frames; // strong references static thread_local int m_releasing_frames; @@ -190,6 +228,11 @@ private: std::vector m_global_allowed_roots; std::vector m_denied_filenames; std::vector m_denied_path_keywords; + + // Kept apart from m_mutex: observation_mode() is queried from the audit hook while the hook + // already holds the GIL, and the setter runs before plugin code starts. + mutable std::mutex m_observer_mutex; + AuditObserver m_observation_observer; }; // RAII guard that sets the current plugin key and capability name, restoring the previous @@ -220,6 +263,22 @@ private: std::vector m_previous_root_frames; }; +// Sets the probe's phase label on the current thread and restores the previous one on scope exit. +// Empty in production, where no phase is ever set. +class ScopedPluginAuditPhase +{ +public: + explicit ScopedPluginAuditPhase(std::string phase); + ~ScopedPluginAuditPhase(); + + ScopedPluginAuditPhase(const ScopedPluginAuditPhase&) = delete; + ScopedPluginAuditPhase& operator=(const ScopedPluginAuditPhase&) = delete; + +private: + std::string m_phase; + std::string m_previous_phase; +}; + } // namespace Slic3r #endif // slic3r_PluginAuditManager_hpp_ diff --git a/src/slic3r/plugin/PluginAuditManagerGui.cpp b/src/slic3r/plugin/PluginAuditManagerGui.cpp new file mode 100644 index 0000000000..b50956e821 --- /dev/null +++ b/src/slic3r/plugin/PluginAuditManagerGui.cpp @@ -0,0 +1,329 @@ +#include "PluginAuditManagerInternal.hpp" + +#include "PluginDescriptor.hpp" +#include "PluginFsUtils.hpp" +#include "PluginManager.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace Slic3r { +namespace { + +// The saved list that grants an event: its category's target list, or the event-grant list for +// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an +// os.fork grant is not an os.exec of a command named "os.fork"). +std::vector* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions) +{ + if (by_event) + return &permissions.events; + + switch (category) { + case AuditEventCategory::FsRead: return &permissions.fs_read; + case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; + case AuditEventCategory::Http: return &permissions.network_http; + case AuditEventCategory::Socket: return &permissions.network_socket; + case AuditEventCategory::ProcessCreate: return &permissions.process; + case AuditEventCategory::Threading: return &permissions.threading; + case AuditEventCategory::ProcessReplace: return &permissions.process; + default: return nullptr; + } +} + +// An event with no target is granted by its effective category and name, e.g. "FsRead:open" +// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open"). +std::string event_grant_key(AuditEventCategory category, const std::string& event_name) +{ + return std::string(plugin_audit::category_name(category)) + ":" + event_name; +} + +bool has_permission(const std::vector& granted, const std::string& target) +{ + return std::find(granted.begin(), granted.end(), target) != granted.end(); +} + +bool persist_permission(const std::string& plugin_key, + PluginInstallState& state, + std::vector& permission_list, + const std::string& target) +{ + PluginDescriptor descriptor; + 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; + } + + if (!has_permission(permission_list, target)) + permission_list.push_back(target); + return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); +} + +wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name, + const wxString& target_list, size_t target_count) +{ + if (target_list.IsEmpty()) + return wxString::Format( + _L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n" + "This operation does not expose a target the audit hook can display."), + plugin_name, event_name); + + switch (category) { + case AuditEventCategory::FsRead: + return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s", + "Plugin \"%s\" is requesting to read the following files:\n%s", target_count), + plugin_name, target_list); + case AuditEventCategory::FsReadWrite: + return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s", + "Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count), + plugin_name, target_list); + case AuditEventCategory::FsCreate: + return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s", + "Plugin \"%s\" is requesting to create the following files:\n%s", target_count), + plugin_name, target_list); + case AuditEventCategory::FsDelete: + return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s", + "Plugin \"%s\" is requesting to delete the following files:\n%s", target_count), + plugin_name, target_list); + case AuditEventCategory::Http: + return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list); + case AuditEventCategory::Socket: + return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list); + case AuditEventCategory::ProcessCreate: + return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s", + "Plugin \"%s\" is requesting to run the following commands:\n%s", target_count), + plugin_name, target_list); + case AuditEventCategory::Threading: + return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name); + case AuditEventCategory::ProcessReplace: + return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\n%s"), plugin_name, target_list); + default: + return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name); + } +} + +// Builds and shows the modal permission prompt. Must run on the GUI thread. +bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name, + const std::vector& unresolved) +{ + wxString target_list; + for (const auto& target : unresolved) + target_list += wxString::FromUTF8(target.c_str()) + "\n"; + + wxMessageDialog dialog(nullptr, + audit_message(category, wxString::FromUTF8(plugin_name.c_str()), + wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()), + _L("Plugin permission request"), 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) +{ + PluginInstallState state; + if (!PluginManager::instance().get_install_state(plugin_key, state)) + return; + + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); + if (!permission_list) + return; + + for (const auto& key : keys) + persist_permission(plugin_key, state, *permission_list, key); +} + +PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; + +// Asks the user to grant an audited event, returning true when they approve an inline prompt. +// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has +// no target (by_event), which the prompt does not show as a target. +// +// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the +// 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 +// 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 +// re-prompting. +bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, + const std::string& event_name, bool by_event, const std::vector& keys) +{ + const std::vector shown = by_event ? std::vector{} : keys; + + if (s_permission_prompt_for_testing) + return s_permission_prompt_for_testing(event_name, shown); + + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) + return false; + + if (wxIsMainThread()) + return prompt_for_targets(category, plugin_name, event_name, shown); + + GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() { + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) + return; + if (prompt_for_targets(category, plugin_name, event_name, shown)) + persist_grant(plugin_key, category, by_event, keys); + }); + return false; +} + +// Decides an event that no allowed root or running operation covers: a saved grant allows it, +// otherwise the user is asked and a Yes is saved. +int decide_audited_event(PluginAuditManager& mgr, + PluginInstallState& state, + const std::string& plugin_key, + const std::string& plugin_name, + const std::string& event_name, + AuditEventCategory category, + const std::vector& targets) +{ + const bool by_event = targets.empty(); + const std::vector keys = by_event ? std::vector{event_grant_key(category, event_name)} : targets; + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); + + std::vector unresolved = keys; + if (permission_list) { + unresolved.clear(); + for (const auto& key : keys) + if (!has_permission(*permission_list, key)) + unresolved.push_back(key); + if (unresolved.empty()) + return 0; + } + + // Letting a root frame go runs finalizers inside a scope destructor or another event's hook, + // where no dialog may open: what they do must already be granted. + if (mgr.releasing_frames()) + return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"}); + + if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved)) + return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"}); + + if (permission_list) + for (const auto& key : unresolved) + persist_permission(plugin_key, state, *permission_list, key); + return 0; +} + +} // namespace + +bool plugin_audit::request_filesystem_read_permissions(const std::string& plugin_key, + const std::vector& paths) +{ + 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); +} + +int plugin_audit::decide_event(PluginAuditManager& mgr, + const std::string& event_name, + AuditEventCategory category, + const std::vector& targets) +{ + PluginInstallState state; + const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state); + if (!have_install_state) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin(); + } + + const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name; + + return decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, category, targets); +} + +void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt) +{ + s_permission_prompt_for_testing = std::move(prompt); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginAuditManagerInternal.hpp b/src/slic3r/plugin/PluginAuditManagerInternal.hpp new file mode 100644 index 0000000000..5d286ff1e4 --- /dev/null +++ b/src/slic3r/plugin/PluginAuditManagerInternal.hpp @@ -0,0 +1,38 @@ +#ifndef slic3r_PluginAuditManagerInternal_hpp_ +#define slic3r_PluginAuditManagerInternal_hpp_ + +// Internal seam between the GUI-free audit core and the permission front end. The GUI library +// supplies the dialogs, prompts and .install_state.json persistence; the headless probe supplies +// a fail-closed implementation that never links wx. Production behavior lives in the GUI +// implementation, which is the code that used to be part of PluginAuditManager.cpp. + +#include "PluginAuditManager.hpp" + +#include +#include + +namespace Slic3r::plugin_audit { + +const char* category_name(AuditEventCategory category); + +int report_denied(PluginAuditManager& mgr, + const std::string& event_name, + const AuditDecision& decision, + const std::string& target = {}); + +// Used only when the manager is not in observation mode. The GUI implementation consults the +// install state, prompts and persists; the probe implementation fails closed. +bool request_filesystem_read_permissions(const std::string& plugin_key, + const std::vector& paths); + +// Decides an event that no allowed root or running operation covered. The GUI implementation +// reads the install state and prompts; the probe implementation fails closed. In observation +// mode the audit hook records and returns before reaching this seam. +int decide_event(PluginAuditManager& mgr, + const std::string& event_name, + AuditEventCategory category, + const std::vector& targets); + +} // namespace Slic3r::plugin_audit + +#endif // slic3r_PluginAuditManagerInternal_hpp_ diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 00077d3f65..c25c8e7ea2 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -14,8 +14,7 @@ #include "PluginAuditManager.hpp" #include "PythonInterpreter.hpp" -#include "slic3r/GUI/I18N.hpp" -#include "slic3r/GUI/format.hpp" +#include "PluginText.hpp" #include #include @@ -92,7 +91,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor, const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor); if (plugin_root.empty()) { - error = _u8L("Plugin folder could not be determined."); + error = PLUGIN_U8L("Plugin folder could not be determined."); return false; } @@ -103,7 +102,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor, resolved_root = fs::absolute(plugin_root, ec); } if (ec || resolved_root.empty()) { - error = GUI::format(_u8L("Failed to resolve plugin folder: %1%"), plugin_root.string()); + error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to resolve plugin folder: %1%"), plugin_root.string()); return false; } @@ -123,7 +122,7 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std: const auto removed_count = fs::remove_all(resolved_root, ec); if (ec) { // TRN %1% is a folder path, %2% the system error message. - error = GUI::format(_u8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message()); + error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message()); return false; } @@ -1199,7 +1198,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat namespace fs = boost::filesystem; if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { - error = GUI::format(_u8L("Plugin directory does not exist: %1%"), plugin_dir.string()); + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory does not exist: %1%"), plugin_dir.string()); return {}; } @@ -1216,7 +1215,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat if (ext == ".py") { if (!py_entry.empty()) { // TRN %1% and %2% are file names. - error = GUI::format(_u8L("Plugin directory contains multiple .py files: %1% and %2%"), + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .py files: %1% and %2%"), py_entry.filename().string(), it->path().filename().string()); return {}; } @@ -1224,7 +1223,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat } else if (ext == ".whl") { if (!whl_entry.empty()) { // TRN %1% and %2% are file names. - error = GUI::format(_u8L("Plugin directory contains multiple .whl files: %1% and %2%"), + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .whl files: %1% and %2%"), whl_entry.filename().string(), it->path().filename().string()); return {}; } @@ -1233,7 +1232,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat } if (!py_entry.empty() && !whl_entry.empty()) { - error = _u8L("Plugin directory contains both .py and .whl entry files"); + error = PLUGIN_U8L("Plugin directory contains both .py and .whl entry files"); return {}; } @@ -1243,7 +1242,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat if (!whl_entry.empty()) return whl_entry; - error = _u8L("Plugin directory does not contain a .py or .whl entry file"); + error = PLUGIN_U8L("Plugin directory does not contain a .py or .whl entry file"); return {}; } @@ -1260,34 +1259,34 @@ std::vector collect_resource_texture_files(const std::string& dir, error.clear(); if (dir.empty()) { - error = _u8L("Resource texture directory is not set"); + error = PLUGIN_U8L("Resource texture directory is not set"); return files; } if (plugin_root.empty()) { - error = _u8L("Plugin folder could not be determined."); + error = PLUGIN_U8L("Plugin folder could not be determined."); return files; } boost::system::error_code ec; const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec); if (ec) { - error = _u8L("Resource texture directory could not be resolved"); + error = PLUGIN_U8L("Resource texture directory could not be resolved"); return files; } if (!fs::is_directory(resolved_dir, ec) || ec) { - error = _u8L("Resource texture path is not a directory"); + error = PLUGIN_U8L("Resource texture path is not a directory"); return files; } const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec); if (ec) { - error = _u8L("Plugin folder could not be resolved"); + error = PLUGIN_U8L("Plugin folder could not be resolved"); return files; } if (!is_inside_allowed_root(resolved_dir, resolved_root)) { - error = _u8L("Resource texture directory is outside the plugin folder"); + error = PLUGIN_U8L("Resource texture directory is outside the plugin folder"); return files; } @@ -1312,7 +1311,7 @@ std::vector collect_resource_texture_files(const std::string& dir, } if (ec) { - error = _u8L("Resource texture directory could not be read"); + error = PLUGIN_U8L("Resource texture directory could not be read"); return {}; } diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 70601b3a5b..48b92b2661 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -11,8 +11,7 @@ #include "PythonPluginBridge.hpp" #include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp" #include "libslic3r/Utils.hpp" -#include "slic3r/GUI/I18N.hpp" -#include "slic3r/GUI/format.hpp" +#include "PluginText.hpp" #include #include @@ -102,7 +101,7 @@ bool read_local_plugin_package_metadata(const boost::filesystem::path& source_pa const std::string ext = plugin_package_extension(source_path); if (ext != ".py" && ext != ".whl") { - error = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext); + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext); return false; } @@ -179,14 +178,14 @@ bool add_wheel_dependencies_to_sys_path(const PluginDescriptor& descriptor, std::string extract_error; if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) { // TRN %1% is the dependency file, %2% the error detail. - error = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error); + error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error); return false; } } std::string syspath_error; if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) { - error = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error); + error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error); return false; } plugin_paths.push_back(dep_dir.string()); @@ -248,12 +247,12 @@ bool load(const PluginDescriptor& descriptor, PythonInterpreter& interpreter = PythonInterpreter::instance(); if (!interpreter.is_initialized()) { - error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error()); + error = PLUGIN_FORMAT(PLUGIN_U8L("Python interpreter not initialized: %1%"), interpreter.last_error()); return false; } if (!descriptor.is_metadata_valid()) { - error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key); + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key); if (descriptor.has_error()) error += " - " + descriptor.normalized_error(); return false; @@ -274,7 +273,7 @@ bool load(const PluginDescriptor& descriptor, if (!skip_deps) { std::string pkg_install_error; if (!install_packages(descriptor.dependencies, pkg_install_error)) { - error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error); + error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to install plugin dependencies: %1%"), pkg_install_error); return false; } } @@ -293,7 +292,7 @@ bool load(const PluginDescriptor& descriptor, PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error); if (module == nullptr) { bridge.cancel_plugin_capture(descriptor.entry_path); - error = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error); + error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to load plugin module: %1%"), load_error); return false; } @@ -308,13 +307,13 @@ bool load(const PluginDescriptor& descriptor, auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error); if (!bridge_error.empty()) { capabilities_found.clear(); - error = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error); + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin registration failed: %1%"), bridge_error); if (registration_failed != nullptr) *registration_failed = true; return false; } if (capabilities_found.empty()) { - error = _u8L("Plugin module did not register any capabilities"); + error = PLUGIN_U8L("Plugin module did not register any capabilities"); return false; } @@ -366,7 +365,7 @@ bool load(const PluginDescriptor& descriptor, if (!seen_capabilities[type].insert(found.name).second) { // TRN %1% is the capability name, %2% the capability type (e.g. Script). - materialization_error = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"), + materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin declares duplicate capability '%1%' for type %2%"), found.name, PluginConfig::capability_type_label(type)); break; } @@ -422,9 +421,9 @@ bool load(const PluginDescriptor& descriptor, capabilities.push_back(std::move(instance)); } } catch (const std::exception& ex) { - materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what()); + materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin capability materialization failed: %1%"), ex.what()); } catch (...) { - materialization_error = _u8L("Plugin capability materialization failed"); + materialization_error = PLUGIN_U8L("Plugin capability materialization failed"); } } @@ -466,11 +465,11 @@ bool load(const PluginDescriptor& descriptor, } catch (const std::exception& ex) { teardown_capabilities(plugin.capabilities, lifecycle_count); // TRN on_load is the name of the plugin's startup function; keep it untranslated. - error = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what()); + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin on_load failed: %1%"), ex.what()); return false; } catch (...) { teardown_capabilities(plugin.capabilities, lifecycle_count); - error = _u8L("Plugin on_load failed"); + error = PLUGIN_U8L("Plugin on_load failed"); return false; } @@ -651,7 +650,7 @@ bool install_plugin(const boost::filesystem::path& filepath, bool backup_created = false; auto fail = [&error](const std::string& message) { - error = GUI::format(_u8L("Plugin installation failed: %1%"), message); + error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin installation failed: %1%"), message); return false; }; @@ -659,23 +658,23 @@ bool install_plugin(const boost::filesystem::path& filepath, const fs::path source_path = canonical_or_absolute(filepath); if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec)) - return fail(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string())); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package is not a file: %1%"), filepath.string())); const std::string ext = plugin_package_extension(source_path); if (ext != ".py" && ext != ".whl") - return fail(GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext)); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext)); // The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid). // It comes verbatim from the server's catalog JSON, so reject anything that could escape the // per-user plugin root (traversal, leading dots, separators) before touching the filesystem. if (is_cloud_install && !is_valid_plugin_id(cloud_uuid)) - return fail(GUI::format(_u8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid)); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid)); const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) : local_plugin_root(); fs::create_directories(plugin_root, ec); if (ec) // TRN %1% is a folder path, %2% the system error message. - return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message())); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message())); std::string meta_error; if (is_cloud_install) { @@ -718,7 +717,7 @@ bool install_plugin(const boost::filesystem::path& filepath, fs::rename(final_dir, backup_dir, ec); if (ec) // TRN %1% is a folder path, %2% the system error message. - return fail(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message())); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message())); backup_created = true; } @@ -729,7 +728,7 @@ bool install_plugin(const boost::filesystem::path& filepath, boost::system::error_code restore_ec; fs::rename(backup_dir, final_dir, restore_ec); } - return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message())); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message())); } fs::copy_file(source_path, dest_file, ec); @@ -740,7 +739,7 @@ bool install_plugin(const boost::filesystem::path& filepath, fs::rename(backup_dir, final_dir, restore_ec); } // TRN %1% is a file path, %2% the system error message. - return fail(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message())); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message())); } // Update entry_path to the installed location. @@ -760,7 +759,7 @@ bool install_plugin(const boost::filesystem::path& filepath, fs::remove_all(final_dir, rollback_ec); if (backup_created) fs::rename(backup_dir, final_dir, rollback_ec); - return fail(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string())); + return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string())); } } diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 98bf0b6ad6..2ae76b784f 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -53,18 +53,6 @@ const char* const LOAD_CANCELLED = "__plugin_load_cancelled__"; } // namespace -void Plugin::release_module() -{ - if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty()) - return; - - PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules); - module = nullptr; - module_name.clear(); - plugin_sys_paths.clear(); - plugin_modules.clear(); -} - PluginManager& PluginManager::instance() { // Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to diff --git a/src/slic3r/plugin/PluginModule.cpp b/src/slic3r/plugin/PluginModule.cpp new file mode 100644 index 0000000000..25b9730757 --- /dev/null +++ b/src/slic3r/plugin/PluginModule.cpp @@ -0,0 +1,22 @@ +// Plugin module ownership is part of the loader's contract, not of the registry's GUI-facing +// surface. Kept in its own translation unit so the headless probe can link the loader without +// pulling PluginManager.cpp (and therefore wx/GUI) into the probe. + +#include "PluginManager.hpp" +#include "PythonInterpreter.hpp" + +namespace Slic3r { + +void Plugin::release_module() +{ + if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty()) + return; + + PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules); + module = nullptr; + module_name.clear(); + plugin_sys_paths.clear(); + plugin_modules.clear(); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginPermissionProbe.cpp b/src/slic3r/plugin/PluginPermissionProbe.cpp new file mode 100644 index 0000000000..6b47285189 --- /dev/null +++ b/src/slic3r/plugin/PluginPermissionProbe.cpp @@ -0,0 +1,565 @@ +// Headless, record-only plugin permission probe. +// +// This is a developer/security-analysis tool, not a second production loader. It reuses the +// embedded interpreter, the production plugin_loader sequence and the GUI-free `orca` bindings +// (capability types plus the geometry/model/slicing host domains), so the observations come from +// the same audit classification and target extraction as the GUI. It then lets every audited +// operation continue instead of consulting permission grants or dialogs. Host domains that need +// the live GUI (app, presets, ui) and plugin.storage are registered as explicit unsupported +// errors; the config API reports an empty config and refuses writes. +// +// It is NOT a sandbox. Plugin code runs with the probe's filesystem, network and process +// privileges. The temporary data directory only keeps OrcaSlicer's own state out of the user's +// tree; it does not confine plugin-authored code. Use OS-level restrictions (container, user, +// seccomp/AppArmor, firewall) when the package is not trusted. +// +// The report describes only the paths this run exercised. CPython audit events are observations of +// particular operations, not a notification for every Python call, so absence of a category in the +// report is not evidence that the plugin cannot perform it. + +#include "PluginAuditManager.hpp" +#include "PluginDescriptor.hpp" +#include "PluginFsUtils.hpp" +#include "PluginLoader.hpp" +#include "PluginManager.hpp" +#include "PluginProbeCollector.hpp" +#include "PythonInterpreter.hpp" +#include "PythonPluginInterface.hpp" + +#include "libslic3r/LifecycleEvents.hpp" +#include "libslic3r/Utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = boost::filesystem; +namespace py = pybind11; + +using namespace Slic3r; + +namespace { + +constexpr const char* kLoadPhase = "load:import+registration+on_load"; + +struct Options +{ + fs::path plugin_path; + bool json = false; +}; + +void print_usage(std::ostream& out) +{ + out << "Usage: orca-plugin-probe [--json] \n" + "\n" + "Runs a plugin in a temporary data/working directory and reports the CPython audit\n" + "events observed while its import, registration, on_load and discoverable callbacks\n" + "execute. The probe does not prompt for permissions, write grants, install dependencies,\n" + "or start the OrcaSlicer GUI.\n" + "\n" + " --json Emit the report as JSON (default: human-readable text)\n" + " --help Show this message\n" + "\n" + "WARNING: this is not a sandbox. Plugin Python code runs with this process's privileges.\n" + "Results are observational and non-exhaustive. Use OS-level restrictions for untrusted\n" + "packages.\n" + "\n" + "Exit status: 0 plugin loaded, 1 initialization/load failure, 2 usage error.\n"; +} + +bool parse_options(int argc, char** argv, Options& options, std::string& error) +{ + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + if (argument == "--help" || argument == "-h") { + print_usage(std::cout); + std::exit(0); + } + if (argument == "--json") { + options.json = true; + continue; + } + if (!argument.empty() && argument.front() == '-') { + error = "Unknown option: " + argument; + return false; + } + if (!options.plugin_path.empty()) { + error = "Only one plugin package may be probed at a time."; + return false; + } + options.plugin_path = fs::path(argument); + } + + if (options.plugin_path.empty()) { + error = "No plugin package given."; + return false; + } + return true; +} + +// Keep every write OrcaSlicer makes during the run inside a throwaway tree, and keep relative +// paths opened by plugin code out of the caller's working directory. This is hygiene, not +// confinement: an absolute path supplied by the plugin is still opened normally. +class ScopedProbeWorkspace +{ +public: + ScopedProbeWorkspace() + : m_root(fs::temp_directory_path() / fs::unique_path("orca-plugin-probe-%%%%-%%%%-%%%%")) + , m_previous_data_dir(data_dir()) + , m_previous_working_dir(fs::current_path()) + { + fs::create_directories(m_root); + set_data_dir(m_root.string()); + fs::current_path(m_root); + m_moved = true; + } + + ~ScopedProbeWorkspace() + { + boost::system::error_code ec; + set_data_dir(m_previous_data_dir); + if (m_moved) + fs::current_path(m_previous_working_dir, ec); + fs::remove_all(m_root, ec); + } + + const fs::path& root() const { return m_root; } + +private: + fs::path m_root; + std::string m_previous_data_dir; + fs::path m_previous_working_dir; + bool m_moved = false; +}; + +class ScopedObservationObserver +{ +public: + explicit ScopedObservationObserver(PluginProbeCollector& collector) : m_collector(collector) + { + PluginAuditManager::instance().set_observation_observer( + [this](const AuditObservation& observation) { m_collector.record(observation); }); + } + + ~ScopedObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); } + + ScopedObservationObserver(const ScopedObservationObserver&) = delete; + ScopedObservationObserver& operator=(const ScopedObservationObserver&) = delete; + +private: + PluginProbeCollector& m_collector; +}; + +bool prepare_descriptor(const fs::path& plugin_path, PluginDescriptor& descriptor, std::string& error) +{ + boost::system::error_code ec; + const fs::path canonical = fs::weakly_canonical(plugin_path, ec); + if (ec || !fs::exists(canonical) || !fs::is_regular_file(canonical)) { + error = "Plugin package is not a readable file: " + plugin_path.string(); + return false; + } + + std::string extension = canonical.extension().string(); + boost::algorithm::to_lower(extension); + if (extension != ".py" && extension != ".whl") { + error = "Plugin package must be a .py or .whl file, got: " + extension; + return false; + } + + if (extension == ".whl") { + if (!read_wheel_plugin_metadata(canonical, descriptor, error)) + return false; + } else { + if (!read_python_plugin_metadata(canonical, descriptor, error)) + return false; + } + + descriptor.entry_path = canonical.string(); + descriptor.plugin_key = make_local_plugin_key(canonical.stem().string()); + descriptor.metadata_valid = true; + // Leaving plugin_root empty keeps the probe independent of any .install_state.json next to + // the package and avoids the production sidecar path. + descriptor.plugin_root.clear(); + if (descriptor.name.empty()) + descriptor.name = descriptor.plugin_key; + return true; +} + +void redirect_python_stdout() +{ + py::gil_scoped_acquire gil; + py::object sys = py::module_::import("sys"); + // Keep plugin prints from corrupting the C++ report on stdout. sys.stderr is the interpreter's + // tee to the temporary log, so plugin output remains available there. + sys.attr("stdout") = sys.attr("stderr"); +} + +template +void invoke_callback(const std::shared_ptr& capability, + const std::string& phase, + const std::string& callback, + PluginProbeCollector& collector, + Function&& function) +{ + ScopedPluginAuditPhase phase_scope(phase); + try { + std::forward(function)(); + collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "invoked", {}}); + } catch (const std::exception& error) { + collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", error.what()}); + } catch (...) { + collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", "non-standard exception"}); + } +} + +struct ProbeCapability +{ + std::string name; + std::string type; + bool enabled = true; +}; + +std::vector describe_capabilities(const Plugin& plugin) +{ + std::vector capabilities; + capabilities.reserve(plugin.capabilities.size()); + for (const auto& capability : plugin.capabilities) { + if (!capability) + continue; + capabilities.push_back({capability->name(), plugin_capability_type_to_string(capability->type()), capability->is_enabled()}); + } + return capabilities; +} + +void exercise_callbacks(const Plugin& plugin, PluginProbeCollector& collector) +{ + // on_load() already ran inside plugin_loader::load. The callbacks below are the additional + // entry points a loaded capability exposes to the host. Synthetic inputs are used; a callback + // may behave differently with a real application state, which the report cannot capture. + static constexpr LifecycleEvent lifecycle_events[] = { + LifecycleEvent::NewProject, LifecycleEvent::ProjectOpened, LifecycleEvent::ProjectBeforeSave, + LifecycleEvent::ProjectAfterSave, LifecycleEvent::ProjectClosed, LifecycleEvent::ProjectDirtyChanged, + LifecycleEvent::SliceStarted, LifecycleEvent::SliceGeometryFinished, LifecycleEvent::GCodeExportStarted, + LifecycleEvent::GCodeExportFinished, LifecycleEvent::SlicingJobComplete, LifecycleEvent::ObjectAdded, + LifecycleEvent::ObjectDeleted, LifecycleEvent::ObjectTransformed, LifecycleEvent::ObjectChanged, + LifecycleEvent::ObjectRenamed, LifecycleEvent::PlateCreated, LifecycleEvent::PlateDeleted, + LifecycleEvent::PlateSelected, LifecycleEvent::PlateRenamed, LifecycleEvent::PresetSelected, + LifecycleEvent::PresetSaved, LifecycleEvent::PrintStateChanged, LifecycleEvent::DeviceOnline, + LifecycleEvent::DeviceOffline, LifecycleEvent::DeviceDiscovered, LifecycleEvent::DeviceSelected, + LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished, LifecycleEvent::PrintJobStarted, + LifecycleEvent::PrintJobFinished, LifecycleEvent::SendJobStarted, LifecycleEvent::SendJobFinished, + }; + + for (const auto& capability : plugin.capabilities) { + if (!capability) + continue; + + invoke_callback(capability, "callback:get_default_config", "get_default_config", collector, + [&capability]() { (void) capability->get_default_config(); }); + + if (capability->config_ui_available()) { + invoke_callback(capability, "callback:get_config_ui", "get_config_ui", collector, + [&capability]() { (void) capability->get_config_ui(); }); + } else { + collector.record_callback( + {capability->name(), "callback:get_config_ui", "get_config_ui", "skipped", "has_config_ui() is false"}); + } + + for (const LifecycleEvent event : lifecycle_events) { + const std::string event_name = lifecycle_event_to_string(event); + const std::string phase = "callback:on_lifecycle_event(" + event_name + ")"; + LifecycleEventContext context; + context.source = "orca-plugin-probe"; + context.msg = "synthetic event generated by the permission probe"; + invoke_callback(capability, phase, "on_lifecycle_event(" + event_name + ")", collector, + [&capability, event, &context]() { capability->on_lifecycle_event(event, context); }); + } + + // Cancellation is a load-time path; calling it after a successful load would misrepresent + // the capability's real lifecycle. Report it as skipped instead. + collector.record_callback( + {capability->name(), "callback:on_cancelled", "on_cancelled", "skipped", "not invoked after a successful load"}); + } +} + +nlohmann::json observation_json(const AuditObservation& observation) +{ + return { + {"plugin", observation.plugin_key}, + {"capability", observation.capability}, + {"phase", observation.phase.empty() ? "unattributed" : observation.phase}, + {"event", observation.event_name}, + {"category", observation.category}, + {"targets", observation.targets}, + {"continued", observation.continued}, + }; +} + +nlohmann::json callback_json(const ProbeCallbackRecord& callback) +{ + return { + {"capability", callback.capability}, {"phase", callback.phase}, {"callback", callback.callback}, + {"status", callback.status}, {"detail", callback.detail}, + }; +} + +nlohmann::json capability_json(const ProbeCapability& capability) +{ + return { + {"name", capability.name}, + {"type", capability.type}, + {"enabled", capability.enabled}, + }; +} + +nlohmann::json build_report(const Options& options, + const PluginProbeCollector& collector, + const std::vector& capabilities, + const std::string& plugin_key, + bool python_initialized, + const std::string& python_error, + bool loaded, + const std::string& load_error, + const std::string& data_dir) +{ + nlohmann::json observations = nlohmann::json::array(); + for (const AuditObservation& observation : collector.observations()) + observations.push_back(observation_json(observation)); + + nlohmann::json callbacks = nlohmann::json::array(); + for (const ProbeCallbackRecord& callback : collector.callbacks()) + callbacks.push_back(callback_json(callback)); + + nlohmann::json capability_list = nlohmann::json::array(); + for (const ProbeCapability& capability : capabilities) + capability_list.push_back(capability_json(capability)); + + return { + {"tool", "orca-plugin-permission-probe"}, + {"mode", "record-only"}, + {"observed_by_this_run_only", true}, + {"plugin", + { + {"path", options.plugin_path.string()}, + {"key", plugin_key}, + }}, + {"environment", + { + {"python_initialized", python_initialized}, + {"python_error", python_error}, + {"temporary_data_dir", data_dir}, + {"temporary_data_dir_removed_on_exit", true}, + {"working_directory", data_dir}, + {"interactive_prompts", false}, + {"grants_persisted", false}, + }}, + {"load", {{"ok", loaded}, {"error", load_error}, {"capabilities", capability_list}}}, + {"callbacks", callbacks}, + {"observations", observations}, + {"summary", collector.summary()}, + {"coverage", + { + {"exhaustive", false}, + {"sandbox", false}, + {"plugin_code_executed", true}, + {"notes", + { + "Only the code paths this probe exercised are represented. Audit events cover " + "particular operations, not every Python call.", + "The probe does not start the GUI. Host APIs that require a live application or " + "UI (orca.host.app/presets/ui) and plugin.storage report an explicit unsupported " + "error and are reported as failed callbacks.", + "The probe links libslic3r for core geometry/print types, but not libslic3r_gui " + "or wx.", + "The temporary data directory isolates OrcaSlicer state, not plugin-authored file, " + "network or process access. Use OS-level restrictions for untrusted packages.", + "The loader may extract adjacent .whl dependencies into the package's directory; " + "probe a copy of the package when that side effect is unwanted.", + "Import, registration and on_load share one loader phase; the production loader " + "does not expose a finer boundary without changing its behavior.", + "Callbacks run with synthetic inputs, which can change plugin control flow " + "compared with a real application session.", + "continued=true means the audit hook did not block the operation. A surrounding " + "callback or load step can still fail afterwards, as reported in callbacks/load.", + "Events raised on a plugin-spawned thread carry no phase label and are reported " + "as unattributed.", + "Plugin code can spawn threads/processes, write anywhere this process can, or " + "terminate the process. None of that is contained by the probe.", + }}, + }}, + }; +} + +void print_text_report(const nlohmann::json& report) +{ + std::cout << "OrcaSlicer plugin permission probe (record-only)\n" + "WARNING: observational only; not exhaustive and not a sandbox.\n\n"; + std::cout << "Plugin: " << report["plugin"]["path"].get() << "\n"; + std::cout << "Python initialized: " << (report["environment"]["python_initialized"].get() ? "yes" : "no") << "\n"; + if (!report["environment"]["python_error"].get().empty()) + std::cout << "Python error: " << report["environment"]["python_error"].get() << "\n"; + std::cout << "Load: " << (report["load"]["ok"].get() ? "ok" : "failed") << "\n"; + if (!report["load"]["error"].get().empty()) + std::cout << "Load error: " << report["load"]["error"].get() << "\n"; + + std::cout << "\nCapabilities (" << report["load"]["capabilities"].size() << "):\n"; + for (const auto& capability : report["load"]["capabilities"]) + std::cout << " " << capability["name"].get() << " [" << capability["type"].get() + << "] enabled=" << (capability["enabled"].get() ? "yes" : "no") << "\n"; + + std::cout << "\nCallbacks (" << report["callbacks"].size() << "):\n"; + for (const auto& callback : report["callbacks"]) { + std::cout << " " << callback["status"].get() << " " << callback["callback"].get(); + if (!callback["capability"].get().empty()) + std::cout << " capability=" << callback["capability"].get(); + if (!callback["detail"].get().empty()) + std::cout << " - " << callback["detail"].get(); + std::cout << "\n"; + } + + std::cout << "\nObserved audit events (" << report["observations"].size() << "):\n"; + for (const auto& observation : report["observations"]) { + std::cout << " phase=" << observation["phase"].get() << " event=" << observation["event"].get() + << " category=" << observation["category"].get(); + if (!observation["capability"].get().empty()) + std::cout << " capability=" << observation["capability"].get(); + if (!observation["targets"].empty()) { + std::cout << " targets="; + bool first = true; + for (const auto& target : observation["targets"]) { + if (!first) + std::cout << ","; + std::cout << target.get(); + first = false; + } + } + std::cout << " continued=" << (observation["continued"].get() ? "yes" : "no") << "\n"; + } + + std::cout << "\nObserved permission categories:\n"; + for (const auto& [category, detail] : report["summary"]["categories"].items()) { + std::cout << " " << category << ":"; + for (const auto& event : detail["events"]) + std::cout << " " << event.get(); + if (!detail["targets"].empty()) { + std::cout << " -> "; + bool first = true; + for (const auto& target : detail["targets"]) { + if (!first) + std::cout << ", "; + std::cout << target.get(); + first = false; + } + } + std::cout << "\n"; + } + if (report["summary"]["categories"].empty()) + std::cout << " (none observed)\n"; + + std::cout << "\nCoverage limits:\n"; + for (const auto& note : report["coverage"]["notes"]) + std::cout << " - " << note.get() << "\n"; +} + +} // namespace + +int main(int argc, char** argv) +{ + Options options; + std::string option_error; + if (!parse_options(argc, argv, options, option_error)) { + std::cerr << "orca-plugin-probe: " << option_error << "\n\n"; + print_usage(std::cerr); + return 2; + } + + boost::system::error_code ec; + const fs::path canonical_plugin = fs::weakly_canonical(options.plugin_path, ec); + if (!ec) + options.plugin_path = canonical_plugin; + + ScopedProbeWorkspace workspace; + PluginProbeCollector collector; + ScopedObservationObserver observer(collector); + + PluginDescriptor descriptor; + std::string descriptor_error; + const bool descriptor_ready = prepare_descriptor(options.plugin_path, descriptor, descriptor_error); + + PythonInterpreter& interpreter = PythonInterpreter::instance(); + bool python_initialized = false; + std::string python_error; + if (descriptor_ready) { + python_initialized = interpreter.initialize(); + if (!python_initialized) + python_error = interpreter.last_error(); + } + + if (python_initialized) { + try { + redirect_python_stdout(); + } catch (const std::exception& error) { + python_error = std::string("Could not redirect Python stdout: ") + error.what(); + } + } + + bool loaded = false; + std::string load_error = descriptor_error; + Plugin plugin; + std::vector capabilities; + + if (python_initialized && descriptor_ready) { + // The outer context covers import, registration and on_load in plugin_loader::load; the + // per-callback scopes below cover the follow-up calls. The production loader is unchanged. + ScopedPluginAuditContext audit_context(descriptor.plugin_key); + { + ScopedPluginAuditPhase load_phase(kLoadPhase); + loaded = plugin_loader::load(descriptor, /*skip_deps=*/true, /*capabilities_to_enable=*/{}, nullptr, plugin, load_error); + collector.record_callback({std::string{}, kLoadPhase, "plugin_loader::load", loaded ? "invoked" : "failed", load_error}); + collector.record_callback({std::string{}, "callback:on_load", "on_load", loaded ? "invoked" : "skipped", + loaded ? "invoked by plugin_loader::load for each materialized capability" : + "plugin_loader::load failed before completing on_load"}); + } + + capabilities = describe_capabilities(plugin); + + if (loaded) + exercise_callbacks(plugin, collector); + + if (loaded) { + ScopedPluginAuditPhase unload_phase("callback:on_unload"); + plugin_loader::unload(plugin); + collector.record_callback({std::string{}, "callback:on_unload", "on_unload", "invoked", + "invoked by plugin_loader::unload; per-capability failures are logged by the loader"}); + } + } + + if (interpreter.is_initialized()) + interpreter.shutdown(); + + const nlohmann::json report = build_report(options, collector, capabilities, + descriptor.plugin_key.empty() ? make_local_plugin_key(options.plugin_path.stem().string()) : + descriptor.plugin_key, + python_initialized, python_error, loaded, load_error, workspace.root().string()); + + if (options.json) + std::cout << report.dump(2, ' ', false, nlohmann::json::error_handler_t::replace) << "\n"; + else + print_text_report(report); + + return loaded ? 0 : 1; +} diff --git a/src/slic3r/plugin/PluginProbeCollector.hpp b/src/slic3r/plugin/PluginProbeCollector.hpp new file mode 100644 index 0000000000..626cce3b20 --- /dev/null +++ b/src/slic3r/plugin/PluginProbeCollector.hpp @@ -0,0 +1,97 @@ +#ifndef slic3r_PluginProbeCollector_hpp_ +#define slic3r_PluginProbeCollector_hpp_ + +// Record-only collection for the headless plugin permission probe. This is deliberately a plain +// in-memory store: it has no dialog, grant or persistence path, and it never decides whether an +// audited operation may proceed. Keeping it independent of wx/Python also makes its grouping and +// phase attribution testable without starting the interpreter. + +#include "PluginAuditManager.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +// What the probe attempted and what came back. `status` is one of "invoked", "failed" or +// "skipped"; a skipped callback is reported so the absence of observations is not mistaken for a +// clean result. +struct ProbeCallbackRecord +{ + std::string capability; + std::string phase; + std::string callback; + std::string status; + std::string detail; +}; + +class PluginProbeCollector +{ +public: + void record(const AuditObservation& observation) + { + std::lock_guard lock(m_mutex); + m_observations.push_back(observation); + } + + void record_callback(ProbeCallbackRecord record) + { + std::lock_guard lock(m_mutex); + m_callbacks.push_back(std::move(record)); + } + + std::vector observations() const + { + std::lock_guard lock(m_mutex); + return m_observations; + } + + std::vector callbacks() const + { + std::lock_guard lock(m_mutex); + return m_callbacks; + } + + // Groups the run by the permission categories the audit hook already assigned, and lists the + // distinct targets and event names seen in each. This is a view of this run only; it says + // nothing about code paths the plugin did not exercise. + nlohmann::json summary() const + { + std::lock_guard lock(m_mutex); + + std::map> events; + std::map> targets; + for (const AuditObservation& observation : m_observations) { + events[observation.category].insert(observation.event_name); + targets[observation.category].insert(observation.targets.begin(), observation.targets.end()); + } + + nlohmann::json categories = nlohmann::json::object(); + for (const auto& [category, category_events] : events) { + categories[category] = { + {"events", category_events}, + {"targets", targets[category]}, + }; + } + + return { + {"observed_event_count", m_observations.size()}, + {"categories", categories}, + }; + } + +private: + mutable std::mutex m_mutex; + std::vector m_observations; + std::vector m_callbacks; +}; + +} // namespace Slic3r + +#endif // slic3r_PluginProbeCollector_hpp_ diff --git a/src/slic3r/plugin/PluginText.hpp b/src/slic3r/plugin/PluginText.hpp new file mode 100644 index 0000000000..5a85120d0e --- /dev/null +++ b/src/slic3r/plugin/PluginText.hpp @@ -0,0 +1,44 @@ +#pragma once + +// User-facing plugin messages are translated in the GUI build. The headless probe links neither +// libslic3r_gui nor wx, so it formats the same English text without wxGetTranslation. The GUI path +// expands to exactly the calls the plugin sources used before this header existed. + +#if defined(ORCA_PLUGIN_PROBE_BUILD) + +#include "libslic3r/format.hpp" + +#include +#include + +namespace Slic3r::plugin_text { + +inline std::string translate_utf8(const char* text) { return text == nullptr ? std::string{} : std::string(text); } +inline std::string translate_utf8(const std::string& text) { return text; } + +template +inline std::string format(const char* fmt, TArgs&&... args) +{ + return Slic3r::format(fmt, std::forward(args)...); +} + +template +inline std::string format(const std::string& fmt, TArgs&&... args) +{ + return Slic3r::format(fmt, std::forward(args)...); +} + +} // namespace Slic3r::plugin_text + +#define PLUGIN_U8L(text) ::Slic3r::plugin_text::translate_utf8(text) +#define PLUGIN_FORMAT(...) ::Slic3r::plugin_text::format(__VA_ARGS__) + +#else + +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/format.hpp" + +#define PLUGIN_U8L(text) _u8L(text) +#define PLUGIN_FORMAT(...) GUI::format(__VA_ARGS__) + +#endif diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 103de8e362..ed63b80215 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -30,7 +30,7 @@ #include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include #include -#include "slic3r/GUI/I18N.hpp" +#include "PluginText.hpp" namespace py = pybind11; @@ -193,7 +193,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions); } if (!granted) { - error = _u8L("Plugin filesystem read permission request denied"); + error = PLUGIN_U8L("Plugin filesystem read permission request denied"); BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key; discard_pending_for_key(); clear_active_key(); diff --git a/src/slic3r/plugin/probe/NanoSvgImplementation.cpp b/src/slic3r/plugin/probe/NanoSvgImplementation.cpp new file mode 100644 index 0000000000..e06aa302f6 --- /dev/null +++ b/src/slic3r/plugin/probe/NanoSvgImplementation.cpp @@ -0,0 +1,4 @@ +// libslic3r references NanoSVG without carrying its header-only implementation. This probe links +// libslic3r without the GUI BitmapCache.cpp that supplies the implementation in the application. +#define NANOSVG_IMPLEMENTATION +#include "nanosvg/nanosvg.h" diff --git a/src/slic3r/plugin/probe/PluginAuditManagerHeadless.cpp b/src/slic3r/plugin/probe/PluginAuditManagerHeadless.cpp new file mode 100644 index 0000000000..9ca5d79126 --- /dev/null +++ b/src/slic3r/plugin/probe/PluginAuditManagerHeadless.cpp @@ -0,0 +1,32 @@ +// Headless replacement for the GUI permission front end. The probe installs an observation +// observer, so the audit hook records and returns before reaching this seam. If that observer is +// ever absent, permission decisions fail closed instead of consulting GUI or install state. + +#include "slic3r/plugin/PluginAuditManagerInternal.hpp" + +#include + +#include +#include + +namespace Slic3r::plugin_audit { + +bool request_filesystem_read_permissions(const std::string& plugin_key, + const std::vector& paths) +{ + BOOST_LOG_TRIVIAL(warning) << "[PROBE] Refusing filesystem read permission request for " << plugin_key + << " (" << paths.size() << " path(s)): the headless probe does not evaluate grants"; + return false; +} + +int decide_event(PluginAuditManager& mgr, + const std::string& event_name, + AuditEventCategory category, + const std::vector& targets) +{ + (void) category; + (void) targets; + return report_denied(mgr, event_name, {false, "permission decisions are unavailable in the headless probe"}); +} + +} // namespace Slic3r::plugin_audit diff --git a/src/slic3r/plugin/probe/PluginHostProbe.cpp b/src/slic3r/plugin/probe/PluginHostProbe.cpp new file mode 100644 index 0000000000..9cac10567b --- /dev/null +++ b/src/slic3r/plugin/probe/PluginHostProbe.cpp @@ -0,0 +1,75 @@ +// Probe-only `orca.host` registration. The GUI-free domains use the production bindings; the +// domains that need a live GUI application, main window or preset bundle are exposed as explicit +// unsupported submodules instead of being compiled (and linked) into the probe. +// +// A plugin that touches one of those domains fails its own callback with a clear message; the +// failure is recorded by the probe's callback report rather than becoming a link dependency. + +#include "slic3r/plugin/host/PluginHost.hpp" +#include "slic3r/plugin/host/PluginHostBindings.hpp" + +#include +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +void add_unavailable_domain(py::module_& host, const char* name, const char* description) +{ + py::module_ domain = host.def_submodule(name, description); + const std::string prefix = std::string("orca.host.") + name + "."; + domain.attr("__getattr__") = py::cpp_function([prefix](const std::string& attribute) -> py::object { + throw std::runtime_error(prefix + attribute + + " is not available in the headless plugin permission probe because it needs the GUI application"); + }); +} + +void add_unavailable_function(py::module_& host, const char* name) +{ + // Production registers these in PluginHostApp::register_app(). Do not overwrite a slot that + // another registrar already claimed (the model domain binds the Model class through + // register_model); the probe only fills the GUI-backed accessors that are absent here. + if (py::hasattr(host, name)) + return; + + const std::string detail = std::string("orca.host.") + name + + " is not available in the headless plugin permission probe because it needs the GUI application"; + host.def(name, [detail](py::args, py::kwargs) -> py::object { throw std::runtime_error(detail); }, detail.c_str()); +} + +} // namespace + +void PluginHost::RegisterBindings(py::module_& module) +{ + auto host = module.def_submodule("host", "Host application API"); + + // Value types first so the docstring signatures of later registrars resolve to the bound names. + host_bindings::register_geometry(host); + host_bindings::register_mesh(host); + host_bindings::register_model(host); + host_bindings::register_slicing(host); + + // These domains are backed by the live GUI, its Plater/MainFrame and the preset bundle. + add_unavailable_domain(host, "presets", "Preset bundle access (headless probe: unavailable)"); + add_unavailable_domain(host, "app", "Live application and Plater access (headless probe: unavailable)"); + add_unavailable_domain(host, "ui", "Native dialogs and HTML windows (headless probe: unavailable)"); + for (const char* name : {"plater", "model", "preset_bundle", "app_info", "gl_info", "selected_printer"}) + add_unavailable_function(host, name); + + // Storage needs the PluginManager registry, which is not part of the headless probe. + auto plugin_host = host.def_submodule("plugin", "Plugin host API (headless probe: storage unavailable)"); + plugin_host.def("storage", []() -> std::string { + throw std::runtime_error("orca.host.plugin.storage() is not available in the headless plugin permission probe"); + }, "Return the installed folder of the current plugin."); + plugin_host.attr("__getattr__") = py::cpp_function([](const std::string& attribute) -> py::object { + throw std::runtime_error("orca.host.plugin." + attribute + + " is not available in the headless plugin permission probe"); + }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/probe/PluginProbeConfig.cpp b/src/slic3r/plugin/probe/PluginProbeConfig.cpp new file mode 100644 index 0000000000..1ca2c9e8c2 --- /dev/null +++ b/src/slic3r/plugin/probe/PluginProbeConfig.cpp @@ -0,0 +1,32 @@ +// Minimal replacement for the config API PythonPluginBridge binds into `orca`. +// +// The production implementation (PluginConfig.cpp) resolves presets, reads the sidebar's effective +// config and writes config.json through PluginManager, all of which pull in the GUI and the +// registry. The probe has neither: a capability reports an empty config, an empty version and a +// refused save. That keeps the bridge's Python surface intact without pretending to reproduce +// application state the probe does not have. + +#include "slic3r/plugin/PluginConfig.hpp" +#include "slic3r/plugin/PythonPluginInterface.hpp" + +#include + +#include + +namespace Slic3r { + +nlohmann::json capability_get_config(const PluginCapabilityInterface&) +{ + return nlohmann::json::object(); +} + +std::string capability_get_config_version(const PluginCapabilityInterface&) { return {}; } + +bool capability_save_config(const PluginCapabilityInterface&, const nlohmann::json&) { return false; } + +std::string PluginConfig::capability_type_label(PluginCapabilityType type) +{ + return plugin_capability_type_display_name(type); +} + +} // namespace Slic3r diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index debf5784b1..393c6ff244 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include +#include #include #include #include @@ -67,6 +69,26 @@ void seed_denied_keywords() mgr.add_denied_path_keyword(keyword); } +// Installs the record-only observer for a test and always clears it, so a failed CHECK cannot +// leave a callback pointing at a destroyed collector for the next test in this process. +struct ScopedTestObservationObserver +{ + explicit ScopedTestObservationObserver(PluginProbeCollector& collector) + : m_collector(collector) + { + PluginAuditManager::instance().set_observation_observer( + [this](const AuditObservation& observation) { m_collector.record(observation); }); + } + + ~ScopedTestObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); } + + ScopedTestObservationObserver(const ScopedTestObservationObserver&) = delete; + ScopedTestObservationObserver& operator=(const ScopedTestObservationObserver&) = delete; + +private: + PluginProbeCollector& m_collector; +}; + } // namespace TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") @@ -654,6 +676,71 @@ const char* const PLUGIN = "AuditPlugin"; } // namespace +TEST_CASE("Probe phase scope restores the previous phase", "[audit]") +{ + PluginAuditManager& mgr = PluginAuditManager::instance(); + mgr.set_current_audit_phase("outer"); + { + ScopedPluginAuditPhase inner("inner"); + CHECK(mgr.current_audit_phase() == "inner"); + } + CHECK(mgr.current_audit_phase() == "outer"); + mgr.clear_current_audit_phase(); + CHECK(mgr.current_audit_phase().empty()); +} + +TEST_CASE("Record-only probe observations bypass prompts and retain phase attribution", "[audit][Python]") +{ + AuditHarness harness; + if (!harness.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + harness.add_plugin(PLUGIN); + py::gil_scoped_acquire gil; + PythonSide python(harness); + + PluginProbeCollector collector; + ScopedTestObservationObserver observer(collector); + + // A declared read permission is recorded and allowed, but must not be written to the sidecar. + { + ScopedPluginAuditContext context(PLUGIN); + CHECK(PluginAuditManager::instance().request_filesystem_read_permissions(PLUGIN, {"/tmp/probe-declared"})); + } + + { + ScopedPluginAuditPhase phase("probe:callback"); + python.run_as(PLUGIN, "outcome = attempt(lambda: open(PRIVATE).close())"); + } + + CHECK(python.str("outcome") == "ok"); + CHECK(harness.prompts.empty()); + CHECK(harness.saved_permissions(PLUGIN).fs_read.empty()); + + const std::vector observations = collector.observations(); + const auto open_event = std::find_if( + observations.begin(), observations.end(), [](const AuditObservation& observation) { + return observation.event_name == "open"; + }); + REQUIRE(open_event != observations.end()); + CHECK(open_event->plugin_key == PLUGIN); + CHECK(open_event->phase == "probe:callback"); + CHECK(open_event->category == "FsRead"); + CHECK(open_event->continued); + CHECK(std::find(open_event->targets.begin(), open_event->targets.end(), python.str("PRIVATE")) != + open_event->targets.end()); + + const auto declared = std::find_if(observations.begin(), observations.end(), [](const AuditObservation& observation) { + return observation.event_name == "orca.request_permissions"; + }); + REQUIRE(declared != observations.end()); + CHECK(declared->category == "FsRead"); + CHECK(std::find(declared->targets.begin(), declared->targets.end(), "/tmp/probe-declared") != declared->targets.end()); + + const nlohmann::json summary = collector.summary(); + CHECK(summary["categories"].contains("FsRead")); + CHECK(summary["observed_event_count"].get() == observations.size()); +} + TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]") { AuditHarness harness;