feat: initial plugin auditing workflow (#14989)

# Description

This is an initial draft of the plugin audit workflow.

It focuses on the user experience and developer-facing permission
workflow. It does not yet include the complete implementation of every
operation that should be audited, such as the full filesystem,
networking, and process-spawning event coverage.

## User workflow

When a plugin is loaded:
1. The plugin’s register_capabilities() function is executed.
2. The plugin declares the permissions it requires.
3. OrcaSlicer displays a permission dialog listing the requested
resources.
4. If the user grants access:
  - The permission is persisted in the plugin’s .install_state.json.
  - Capability registration continues.
  - The plugin is materialized and loaded.
5. If the user denies access:
    - Plugin loading fails before capabilities are materialized.
    - on_load() is not called.
- The plugin’s install state is marked with "enabled": false to prevent
repeated automatic load attempts.

At runtime, if a plugin accesses a resource that was not approved during
loading, the audit hook displays another permission dialog. For
filesystem requests, the dialog identifies the requested filepath.
  - Granting access persists the permission and allows the operation.
  - Denying access raises a Python PermissionError.
- The error propagates to the host, which records the failure and
unloads the plugin.

Host-side traceback logging is performed outside the plugin audit
context so that logging does not generate additional permission dialogs.

## Developer-facing API

Plugins can declare filesystem read permissions through the new API:
```python

import orca
AUDIT_PATH = __file__


@orca.plugin
class ExamplePackage(orca.base):
   def register_capabilities(self):
        orca.request_permissions(
            fs_read=[AUDIT_PATH],
        )
        orca.register_capability(ExampleCapability)
```
orca.request_permissions() must be called from register_capabilities()
while the plugin is being loaded.

Currently supported permission:
orca.request_permissions(fs_read=[...])

The paths should be explicit filesystem paths that the plugin intends to
read. The host deduplicates repeated paths, presents the request after
registration completes, and persists granted paths in the plugin
install-state sidecar. This API is still experimental, and is by no
means the final implementation.

Support for additional permission categories, including filesystem write
access, networking, and process spawning, is reserved for subsequent
work.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

<img width="869" height="799" alt="image"
src="https://github.com/user-attachments/assets/8a5903cc-0cbb-45a8-b88a-706d6cba790f"
/>


## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
Ian Chua
2026-08-31 14:29:36 +08:00
committed by GitHub
18 changed files with 1200 additions and 223 deletions

View File

@@ -7,11 +7,127 @@
#include <boost/algorithm/string/predicate.hpp> #include <boost/algorithm/string/predicate.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm>
#include <cctype>
#include <cstdlib> #include <cstdlib>
#include <future>
#include <slic3r/GUI/BindDialog.hpp>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <unordered_map>
#include <unordered_set>
#include <utility> #include <utility>
#include <vector>
#include <wx/event.h>
#include <wx/msgdlg.h>
namespace Slic3r { namespace Slic3r {
// extensive list of audit events can be found at https://docs.python.org/3/library/audit_events.html
static const std::unordered_map<std::string, AuditEventCategory> audit_event_categories{
// fsread
{"glob.glob", AuditEventCategory::FsRead},
{"glob.glob/2", AuditEventCategory::FsRead},
{"os.fwalk", AuditEventCategory::FsRead},
{"os.getxattr", AuditEventCategory::FsRead},
{"os.listdir", AuditEventCategory::FsRead},
{"os.listdrives", AuditEventCategory::FsRead},
{"os.listmounts", AuditEventCategory::FsRead},
{"os.listvolumes", AuditEventCategory::FsRead},
{"os.listxattr", AuditEventCategory::FsRead},
{"os.scandir", AuditEventCategory::FsRead},
{"os.walk", AuditEventCategory::FsRead},
{"pathlib.Path.glob", AuditEventCategory::FsRead},
{"pathlib.Path.rglob", AuditEventCategory::FsRead},
// fsreadwrite
{"os.chflags", AuditEventCategory::FsReadWrite},
{"os.chmod", AuditEventCategory::FsReadWrite},
{"os.chown", AuditEventCategory::FsReadWrite},
{"os.removexattr", AuditEventCategory::FsReadWrite},
{"os.rename", AuditEventCategory::FsReadWrite},
{"os.setxattr", AuditEventCategory::FsReadWrite},
{"os.truncate", AuditEventCategory::FsReadWrite},
{"os.utime", AuditEventCategory::FsReadWrite},
{"shutil.chown", AuditEventCategory::FsReadWrite},
{"shutil.copymode", AuditEventCategory::FsReadWrite},
{"shutil.copystat", AuditEventCategory::FsReadWrite},
{"shutil.copyfile", AuditEventCategory::FsReadWrite},
{"shutil.copytree", AuditEventCategory::FsReadWrite},
{"shutil.make_archive", AuditEventCategory::FsReadWrite},
{"shutil.move", AuditEventCategory::FsReadWrite},
{"shutil.unpack_archive", AuditEventCategory::FsReadWrite},
// fscreate
{"os.link", AuditEventCategory::FsCreate},
{"os.mkdir", AuditEventCategory::FsCreate},
{"os.symlink", AuditEventCategory::FsCreate},
{"tempfile.mkdtemp", AuditEventCategory::FsCreate},
{"tempfile.mkstemp", AuditEventCategory::FsCreate},
{"_winapi.CreateJunction", AuditEventCategory::FsCreate},
// fsdelete
{"os.remove", AuditEventCategory::FsDelete},
{"os.rmdir", AuditEventCategory::FsDelete},
{"shutil.rmtree", AuditEventCategory::FsDelete},
// http
{"http.client.connect", AuditEventCategory::Http},
{"http.client.send", AuditEventCategory::Http},
{"urllib.Request", AuditEventCategory::Http},
// socket
{"socket.__new__", AuditEventCategory::Socket},
{"socket.bind", AuditEventCategory::Socket},
{"socket.connect", AuditEventCategory::Socket},
{"socket.getaddrinfo", AuditEventCategory::Socket},
{"socket.gethostbyaddr", AuditEventCategory::Socket},
{"socket.gethostbyname", AuditEventCategory::Socket},
{"socket.gethostname", AuditEventCategory::Socket},
{"socket.getnameinfo", AuditEventCategory::Socket},
{"socket.getservbyname", AuditEventCategory::Socket},
{"socket.getservbyport", AuditEventCategory::Socket},
{"socket.sendmsg", AuditEventCategory::Socket},
{"socket.sendto", AuditEventCategory::Socket},
// processcreate
{"os.fork", AuditEventCategory::ProcessCreate},
{"os.forkpty", AuditEventCategory::ProcessCreate},
{"os.posix_spawn", AuditEventCategory::ProcessCreate},
{"os.spawn", AuditEventCategory::ProcessCreate},
{"os.system", AuditEventCategory::ProcessCreate},
{"os.startfile", AuditEventCategory::ProcessCreate},
{"os.startfile/2", AuditEventCategory::ProcessCreate},
{"pty.spawn", AuditEventCategory::ProcessCreate},
{"subprocess.Popen", AuditEventCategory::ProcessCreate},
{"_winapi.CreateProcess", AuditEventCategory::ProcessCreate},
{"_posixsubprocess.fork_exec", AuditEventCategory::ProcessCreate},
};
// Returns the category event_name belongs to, or AuditEventCategory::None when it isn't audited.
static AuditEventCategory event_category(const std::string& event_name)
{
const auto it = audit_event_categories.find(event_name);
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
}
// True for the categories whose targets are filesystem paths, as opposed to a network
// address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path.
static bool is_fs_category(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead:
case AuditEventCategory::FsReadWrite:
case AuditEventCategory::FsCreate:
case AuditEventCategory::FsDelete:
return true;
default:
return false;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -78,24 +194,21 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
// ScopedPluginAuditContext // ScopedPluginAuditContext
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
thread_local std::string PluginAuditManager::m_current_plugin_key = ""; 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_capability_name = "";
thread_local PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading; thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
thread_local std::vector<boost::filesystem::path> PluginAuditManager::m_scoped_allowed_roots; thread_local bool PluginAuditManager::m_audit_denial_pending = false;
thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation; thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name, const std::string& capability_name)
PluginAuditManager::AuditMode mode)
: m_previous_id(PluginAuditManager::instance().current_plugin()) : m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_mode(PluginAuditManager::instance().audit_mode())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
{ {
PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::instance().set_audit_mode(mode);
PluginAuditManager::m_scoped_allowed_roots.clear(); PluginAuditManager::m_scoped_allowed_roots.clear();
} }
@@ -103,7 +216,6 @@ ScopedPluginAuditContext::~ScopedPluginAuditContext()
{ {
PluginAuditManager::instance().set_current_plugin(m_previous_id); PluginAuditManager::instance().set_current_plugin(m_previous_id);
PluginAuditManager::instance().set_current_capability(m_previous_capability); PluginAuditManager::instance().set_current_capability(m_previous_capability);
PluginAuditManager::instance().set_audit_mode(m_previous_mode);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
} }
@@ -129,23 +241,24 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); } void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root) void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write)
{ {
if (root.empty()) if (root.empty())
return; return;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
m_global_allowed_roots.push_back(root); m_global_allowed_roots.push_back({root, allow_write});
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string(); BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string() << " allow_write=" << allow_write;
} }
void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root) void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write)
{ {
if (root.empty()) if (root.empty())
return; return;
m_scoped_allowed_roots.push_back(root); m_scoped_allowed_roots.push_back({root, allow_write});
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string(); BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string()
<< " allow_write=" << allow_write;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,12 +312,65 @@ bool PluginAuditManager::is_denied_filename(const boost::filesystem::path& candi
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Audit mode // Denied path keywords
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void PluginAuditManager::set_audit_mode(AuditMode mode) { m_audit_mode = mode; } void PluginAuditManager::add_denied_path_keyword(const std::string& keyword)
{
if (keyword.empty())
return;
PluginAuditManager::AuditMode PluginAuditManager::audit_mode() const { return m_audit_mode; } std::string lower = keyword;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); });
std::lock_guard<std::mutex> lock(m_mutex);
m_denied_path_keywords.push_back(lower);
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Denied path keyword: " << lower;
}
std::vector<std::string> PluginAuditManager::default_denied_path_keywords()
{
// Broad, categorical rules on top of the exact-name is_denied_filename registry: a plugin
// must never be able to reach a secret, a certificate, or a configuration file just because
// it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at
// resources_dir()/cert/..., which would become reachable the moment resources_dir() is
// granted as a read-only allowed root).
return {"secret", "cert", "conf"};
}
bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& candidate) const
{
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(candidate, ec);
if (ec) {
canon = fs::absolute(candidate, ec).lexically_normal();
if (ec)
canon = candidate;
}
std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty())
return false;
for (const auto& component : canon) {
std::string name = component.string();
if (name.empty())
continue;
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
for (const auto& keyword : m_denied_path_keywords) {
if (name.find(keyword) != std::string::npos)
return true;
}
}
return false;
}
bool PluginAuditManager::is_denied_path(const boost::filesystem::path& candidate) const
{
return is_denied_filename(candidate) || is_denied_path_keyword(candidate);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Policy checks // Policy checks
@@ -219,20 +385,20 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
if (plugin_key.empty()) if (plugin_key.empty())
return {true, ""}; // not running inside a plugin context return {true, ""}; // not running inside a plugin context
// Denied filenames are checked first, above both the Loading exemption below and the // Denied filenames/keywords are checked before the allowed roots. The app config and the
// allowed roots. The app config and the cloud refresh token live directly inside // cloud refresh token live directly inside data_dir(), which is a global allowed root, and
// data_dir(), which is a global allowed root, and no scope ever sets Enforcing — so a // the bundled TLS client cert lives inside resources_dir(), a read-only global allowed
// deny placed any lower would be unreachable for reads. // root, so a deny placed any lower would be unreachable.
if (is_denied_filename(path)) { if (is_denied_filename(path)) {
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write
<< " plugin=" << plugin_key << " reason=denied filename"; << " plugin=" << plugin_key << " reason=denied filename";
return {false, "denied filename"}; return {false, "denied filename"};
} }
if (is_denied_path_keyword(path)) {
// During import/loading, only block writes. Python must be able to read BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write
// stdlib modules and the plugin file itself during import. << " plugin=" << plugin_key << " reason=denied path keyword";
if (m_audit_mode == AuditMode::Loading && !is_write) return {false, "denied path keyword"};
return {true, ""}; }
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
fs::path candidate = path; fs::path candidate = path;
@@ -245,8 +411,11 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
candidate = absolute_candidate; candidate = absolute_candidate;
} }
// A root that doesn't allow writes only matches a read-shaped request; a write/create/
// delete-shaped one falls through to "outside allowed root" for that root even though the
// path is physically inside it.
for (const auto& root : m_scoped_allowed_roots) { for (const auto& root : m_scoped_allowed_roots) {
if (is_inside_allowed_root(candidate, root)) { if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) {
return {true, ""}; return {true, ""};
} }
} }
@@ -254,14 +423,13 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
{ {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
for (const auto& root : m_global_allowed_roots) { for (const auto& root : m_global_allowed_roots) {
if (is_inside_allowed_root(candidate, root)) { if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) {
return {true, ""}; return {true, ""};
} }
} }
} }
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << candidate.string() << " is_write=" << is_write BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << candidate.string() << " is_write=" << is_write
<< " audit_mode=" << (m_audit_mode == AuditMode::Loading ? "Loading" : "Enforcing")
<< " plugin=" << plugin_key; << " plugin=" << plugin_key;
return {false, "outside allowed root"}; return {false, "outside allowed root"};
} }
@@ -269,19 +437,99 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
AuditDecision PluginAuditManager::check_open(const std::string& path_str, const std::string& mode) AuditDecision PluginAuditManager::check_open(const std::string& path_str, const std::string& mode)
{ {
const bool is_write = mode.find('w') != std::string::npos || mode.find('a') != std::string::npos || const bool is_write = mode.find('w') != std::string::npos || mode.find('a') != std::string::npos ||
mode.find('+') != std::string::npos; mode.find('+') != std::string::npos || mode.find('x') != std::string::npos;
return check_path_access(boost::filesystem::path(path_str), is_write); return check_path_access(boost::filesystem::path(path_str), is_write);
} }
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths)
{
if (plugin_key.empty() || paths.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> 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("Plugin \"%s\" requests filesystem read access to:\n%s",
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
"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<std::promise<bool>>();
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("Plugin \"%s\" requests filesystem read access to:\n%s",
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
"Plugin permissions",
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
}
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation) void PluginAuditManager::report_violation(const AuditViolation& violation)
{ {
m_last_violation = violation; m_last_violation = violation;
m_has_last_violation = true; m_has_last_violation = true;
m_audit_denial_pending = true;
BOOST_LOG_TRIVIAL(warning) << "[AUDIT BLOCKED] plugin=" << violation.plugin_key << " event=" << violation.event_name BOOST_LOG_TRIVIAL(warning) << "[AUDIT BLOCKED] plugin=" << violation.plugin_key << " event=" << violation.event_name
<< " path=" << violation.path.string() << " reason=" << violation.reason; << " reason=" << violation.reason;
} }
bool PluginAuditManager::audit_denial_pending() const { return m_audit_denial_pending; }
void PluginAuditManager::clear_audit_denial() { m_audit_denial_pending = false; }
void PluginAuditManager::clear_last_violation() void PluginAuditManager::clear_last_violation()
{ {
m_has_last_violation = false; m_has_last_violation = false;
@@ -297,31 +545,326 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
return true; return true;
} }
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{
if (plugin_key.empty() || call_site_ids.empty())
return false;
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_approved_call_sites.find(plugin_key);
if (it == m_approved_call_sites.end())
return false;
for (const auto& id : call_site_ids)
if (it->second.count(id))
return true;
return false;
}
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
if (plugin_key.empty() || call_site_ids.empty())
return;
std::lock_guard<std::mutex> lock(m_mutex);
auto& approved = m_approved_call_sites[plugin_key];
approved.insert(call_site_ids.begin(), call_site_ids.end());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The C-level audit hook // The C-level audit hook
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
namespace { namespace PluginAuditDetail {
PyObject* tuple_item(PyObject* args, Py_ssize_t index)
{
if (!args || !PyTuple_Check(args) || index < 0 || index >= PyTuple_GET_SIZE(args))
return nullptr;
return PyTuple_GET_ITEM(args, index);
}
std::string python_path(PyObject* object)
{
if (!object)
return {};
PyObject* path_object = PyOS_FSPath(object);
if (!path_object) {
PyErr_Clear();
return {};
}
const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
std::string result = path ? path : "";
Py_DECREF(path_object);
if (!path)
PyErr_Clear();
return result;
}
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
std::string python_str(PyObject* object)
{
if (!object)
return {};
PyObject* str_object = PyObject_Str(object);
if (!str_object) {
PyErr_Clear();
return {};
}
const std::string result = python_unicode(str_object);
Py_DECREF(str_object);
return result;
}
std::vector<std::string> call_site_identities(const std::string& plugin_root)
{
std::vector<std::string> ids;
if (plugin_root.empty())
return ids;
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) {
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(code->co_filename));
const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root);
std::string id;
if (!is_plugin_frame && !filename.empty()) {
const std::string funcname = python_unicode(reinterpret_cast<PyObject*>(code->co_name));
id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno);
}
Py_DECREF(code);
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
Py_DECREF(frame);
frame = back;
if (is_plugin_frame)
break;
if (!id.empty())
ids.push_back(std::move(id));
}
Py_XDECREF(frame); // only holds a reference here if the loop exited via break
return ids;
}
static const std::unordered_set<std::string> two_path_fs_events{
"os.rename", "os.link", "os.symlink", "shutil.copyfile",
"shutil.copytree", "shutil.copymode", "shutil.copystat", "shutil.move",
"shutil.unpack_archive", "_winapi.CreateJunction",
};
static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_target_arg_indices{
{"http.client.connect", {1}},
{"urllib.Request", {0}},
{"socket.connect", {1}},
{"socket.bind", {1}},
{"socket.getaddrinfo", {0}},
{"socket.gethostbyname", {0}},
{"socket.gethostbyaddr", {0}},
{"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}},
{"socket.getservbyport", {0}},
{"os.system", {0}},
{"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}},
{"os.startfile", {0}},
{"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}},
};
AuditEventCategory open_category(PyObject* args)
{
const std::string mode = python_unicode(tuple_item(args, 1));
if (!mode.empty() && mode.find_first_of("wax+") == std::string::npos)
return AuditEventCategory::FsRead;
return AuditEventCategory::FsReadWrite;
}
std::vector<std::string> audit_targets(const std::string& event_name, AuditEventCategory category, PyObject* args)
{
std::vector<std::string> targets;
switch (category) {
case AuditEventCategory::FsRead:
case AuditEventCategory::FsReadWrite:
case AuditEventCategory::FsCreate:
case AuditEventCategory::FsDelete: {
const Py_ssize_t path_count = two_path_fs_events.count(event_name) ? 2 : 1;
for (Py_ssize_t index = 0; index < path_count; ++index) {
const std::string path = python_path(tuple_item(args, index));
if (!path.empty())
targets.push_back(path);
}
return targets;
}
default:
break;
}
const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) {
std::string value = python_str(tuple_item(args, index));
if (!value.empty()) {
targets.push_back(std::move(value));
break;
}
}
}
return targets;
}
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions)
{
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;
default: return nullptr;
}
}
bool has_permission(const std::vector<std::string>& 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<std::string>& 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);
}
// Records a blocked event and raises PermissionError in the calling interpreter. Returns -1
// so an event branch can `return report_denied(...)` directly.
int report_denied(PluginAuditManager& mgr, int report_denied(PluginAuditManager& mgr,
const std::string& event_name, const std::string& event_name,
const boost::filesystem::path& path,
const AuditDecision& decision) const AuditDecision& decision)
{ {
AuditViolation violation; AuditViolation violation;
violation.plugin_key = mgr.current_plugin(); violation.plugin_key = mgr.current_plugin();
violation.event_name = event_name; violation.event_name = event_name;
violation.path = path;
violation.reason = decision.reason; violation.reason = decision.reason;
mgr.report_violation(violation); mgr.report_violation(violation);
PyErr_SetString(PyExc_PermissionError, "Plugin attempted to access a blocked file path"); PyErr_SetString(PyExc_PermissionError, "Plugin attempted an audited operation without permission");
return -1; return -1;
} }
} // namespace wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
const wxString& target_list)
{
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("Plugin \"%s\" is requesting to read the following file(s):\n%s"), plugin_name, target_list);
case AuditEventCategory::FsReadWrite:
return wxString::Format(_L("Plugin \"%s\" is requesting to read/write the following file(s):\n%s"), plugin_name, target_list);
case AuditEventCategory::FsCreate:
return wxString::Format(_L("Plugin \"%s\" is requesting to create the following file(s):\n%s"), plugin_name, target_list);
case AuditEventCategory::FsDelete:
return wxString::Format(_L("Plugin \"%s\" is requesting to delete the following file(s):\n%s"), 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("Plugin \"%s\" is requesting to run the following command(s):\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);
}
}
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<std::string>& targets,
std::vector<std::string>* permission_list,
const std::vector<std::string>& call_site_ids)
{
std::vector<std::string> unresolved = targets;
if (permission_list) {
unresolved.clear();
for (const auto& target : targets)
if (!has_permission(*permission_list, target))
unresolved.push_back(target);
if (!targets.empty() && unresolved.empty())
return 0;
}
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),
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
if (dialog.ShowModal() != wxID_YES)
return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list)
for (const auto& target : unresolved)
persist_permission(plugin_key, state, *permission_list, target);
mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0;
}
} // namespace PluginAuditDetail
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{ {
@@ -331,94 +874,95 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
std::string event_name(event ? event : ""); std::string event_name(event ? event : "");
if (event_name.empty())
return 0;
// Verbose logging of every audit event (can be noisy) // Verbose logging of every audit event (can be noisy)
if (mgr->verbose_events) { if (mgr->verbose_events) {
BOOST_LOG_TRIVIAL(debug) << "[AUDIT EVENT] " << event_name; BOOST_LOG_TRIVIAL(debug) << "[AUDIT EVENT] " << event_name;
} }
// extensive list of audit events can be found at https://docs.python.org/3/library/audit_events.html if (mgr->current_plugin().empty()) {
BOOST_LOG_TRIVIAL(trace) << "[AUDIT] event=" << event_name << " bypassed (no plugin context)";
// --- open event ---
if (event_name == "open") {
const char* path_cstr = nullptr;
const char* mode_cstr = nullptr;
int flags = 0;
// open(path, mode, flags) — path may be str, bytes, or int fd
if (!PyArg_ParseTuple(args, "s|si", &path_cstr, &mode_cstr, &flags)) {
PyErr_Clear(); // couldn't parse; allow
return 0;
}
std::string path_str(path_cstr ? path_cstr : "");
std::string mode_str(mode_cstr ? mode_cstr : "r");
AuditDecision decision = mgr->check_open(path_str, mode_str);
if (!decision.allowed)
return report_denied(*mgr, event_name, path_str, decision);
return 0; return 0;
} }
// --- os.rename event (raised by os.rename and os.replace) --- // the open function can take in different flags that determine if it is a read or readwrite.
if (event_name == "os.rename") { const AuditEventCategory event_type =
const char* src_cstr = nullptr; event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name);
const char* dst_cstr = nullptr; if (event_type == AuditEventCategory::None)
PyObject* src_dir_fd = nullptr;
PyObject* dst_dir_fd = nullptr;
// os.rename(src, dst, src_dir_fd, dst_dir_fd) — paths may be str, bytes, or int fd.
// The dir_fd arguments are unused, but must be accepted for the tuple to parse.
if (!PyArg_ParseTuple(args, "ss|OO", &src_cstr, &dst_cstr, &src_dir_fd, &dst_dir_fd)) {
PyErr_Clear(); // couldn't parse; allow
return 0;
}
// A rename writes at both ends, so either end being denied blocks the call.
for (const char* path_cstr : {src_cstr, dst_cstr}) {
std::string path_str(path_cstr ? path_cstr : "");
AuditDecision decision = mgr->check_path_access(path_str, /* is_write */ true);
if (!decision.allowed)
return report_denied(*mgr, event_name, path_str, decision);
}
return 0; return 0;
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the ancestor-cascade check below, so a cascade
// approval recorded for an unrelated action can never launder access to one, and before
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources
// folder) cannot make a denied path underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr->is_denied_path(boost::filesystem::path(target)))
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"});
}
} }
// --- os.remove event (raised by os.remove and os.unlink) --- PluginDescriptor plugin_descriptor;
if (event_name == "os.remove") { PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
const char* path_cstr = nullptr;
PyObject* dir_fd = nullptr;
// os.remove(path, dir_fd) — path may be str, bytes, or int fd // Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
if (!PyArg_ParseTuple(args, "s|O", &path_cstr, &dir_fd)) { // was already approved, socket.connect won't trigger another permission dialog.
PyErr_Clear(); // couldn't parse; allow const std::vector<std::string> call_site_ids =
return 0; PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
} if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
std::string path_str(path_cstr ? path_cstr : "");
AuditDecision decision = mgr->check_path_access(path_str, /* is_write */ true);
if (!decision.allowed)
return report_denied(*mgr, event_name, path_str, decision);
return 0; return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
} }
// Unknown event — allow by default PluginInstallState state;
return 0; 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;
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
event_type, targets, permission_list, call_site_ids);
} }
void PluginAuditManager::install_hook() void PluginAuditManager::install_hook()
{ {
if (PySys_AddAuditHook(audit_hook, this) < 0) { // data_dir() is the primary globally-allowed root during plugin execution: read+write. It
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install CPython audit hook"; // covers the plugin system's own workflow needs -- each plugin's storage folder
return; // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
} // without a separate, narrower grant for either (G-code plugins additionally get the temp
BOOST_LOG_TRIVIAL(info) << "[AUDIT] CPython audit hook installed successfully"; // G-code folder via a scoped root, see SlicingPipelinePluginCapabilityTrampoline).
// data_dir() is the only globally-allowed root during enforced plugin execution.
// The executable directory and resources directory are intentionally NOT allowed
// here: plugins must not write outside data_dir() (G-code plugins additionally get
// the temp G-code folder via a scoped root). Reads remain permissive in Loading mode.
add_global_allowed_root(data_dir()); add_global_allowed_root(data_dir());
// resources_dir() holds the app's bundled, shared assets (installed system profiles, the
// bundled TLS client cert, web assets). Plugins may read from it -- e.g. inspecting bundled
// profiles -- but must never write into the shared, potentially multi-user app install, so
// it is granted read-only. The bundled cert itself stays unreachable regardless, via the
// "cert" denied-path keyword seeded below.
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
// The user's app config and cloud credentials live directly inside data_dir(), so the // The user's app config and cloud credentials live directly inside data_dir(), so the
// root just granted would otherwise expose them to any plugin. Deny them by name. // root just granted would otherwise expose them to any plugin. Deny them by name.
// //
@@ -430,6 +974,19 @@ void PluginAuditManager::install_hook()
// (see its comment for why all four config names are denied); the tests seed from it too. // (see its comment for why all four config names are denied); the tests seed from it too.
for (const auto& name : default_denied_filenames()) for (const auto& name : default_denied_filenames())
add_denied_filename(name); add_denied_filename(name);
// Categorical denies on top of the exact-name list above: no path a plugin can reach may
// contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which
// allowed root it happens to sit inside. default_denied_path_keywords() is the single
// source of this list; the tests seed from it too.
for (const auto& keyword : default_denied_path_keywords())
add_denied_path_keyword(keyword);
if (PySys_AddAuditHook(audit_hook, this) < 0) {
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install CPython audit hook";
return;
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT] CPython audit hook installed successfully";
} }
} // namespace Slic3r } // namespace Slic3r

View File

@@ -2,8 +2,11 @@
#define slic3r_PluginAuditManager_hpp_ #define slic3r_PluginAuditManager_hpp_
#include <Python.h> #include <Python.h>
#include <memory>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
@@ -18,10 +21,31 @@ struct AuditDecision {
struct AuditViolation { struct AuditViolation {
std::string plugin_key; std::string plugin_key;
std::string event_name; std::string event_name;
boost::filesystem::path path;
std::string reason; std::string reason;
}; };
// A filesystem root a plugin may access while an audit context is active. allow_write is
// false for a root that only grants reads (e.g. the bundled, shared resources folder) --
// a write-shaped event never matches such a root, even though a read-shaped one does.
struct AllowedRoot {
boost::filesystem::path path;
bool allow_write = true;
};
// The set of CPython audit events PluginAuditManager recognizes, grouped by the kind of
// operation they represent. None means the event isn't one audit_hook() acts on at all.
enum class AuditEventCategory {
None,
FsRead,
FsReadWrite,
FsCreate,
FsDelete,
Http,
Socket,
ProcessCreate,
Threading,
};
// Returns true if candidate resolves to a path inside allowed_root. // Returns true if candidate resolves to a path inside allowed_root.
// Uses weakly_canonical and component-wise comparison to reject traversal attacks. // Uses weakly_canonical and component-wise comparison to reject traversal attacks.
bool is_inside_allowed_root(const boost::filesystem::path& candidate, bool is_inside_allowed_root(const boost::filesystem::path& candidate,
@@ -49,12 +73,15 @@ public:
void clear_current_capability(); void clear_current_capability();
// --- allowed-roots registry --- // --- allowed-roots registry ---
void add_global_allowed_root(const boost::filesystem::path& root); // allow_write = false registers a read-only root: a read-shaped event inside it is allowed,
void add_scoped_allowed_root(const boost::filesystem::path& root); // but a write/create/delete-shaped event is not, so it falls through to the normal
// prompt-or-deny path instead.
void add_global_allowed_root(const boost::filesystem::path& root, bool allow_write = true);
void add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write = true);
// --- denied-filenames registry --- // --- denied-filenames registry ---
// Filenames a plugin may never touch, in any directory, regardless of audit mode or // Filenames a plugin may never touch, in any directory, regardless of the enclosing allowed
// enclosing allowed root. A candidate is denied when its filename starts with a // root. A candidate is denied when its filename starts with a
// registered name, so .bak/.tmp companions are covered by the same entry. // registered name, so .bak/.tmp companions are covered by the same entry.
// //
// The comparison is case-insensitive on every platform, unlike the _WIN32-only iequals // The comparison is case-insensitive on every platform, unlike the _WIN32-only iequals
@@ -73,33 +100,60 @@ public:
// 8.3 short name is out of scope (see the design doc). This blocks direct access only. // 8.3 short name is out of scope (see the design doc). This blocks direct access only.
bool is_denied_filename(const boost::filesystem::path& candidate) const; bool is_denied_filename(const boost::filesystem::path& candidate) const;
// --- enforcement mode --- // --- denied-path-keyword registry ---
enum class AuditMode { // Keywords that categorically deny a path if ANY of its components (directory or file
// Import/loading phase: allow reads anywhere, only block writes // name), not just the base name, contains one case-insensitively -- e.g. a "secrets"
// outside allowed roots. Python needs to read stdlib modules // subfolder, a "certificates" folder, or a "conf"/"config" file anywhere the plugin can
// during import and those are not inside plugin directories. // otherwise reach, including inside an allowed root. This is intentionally broader and
Loading, // fuzzier than the exact-name is_denied_filename registry: it exists to categorically rule
// out whole classes of sensitive paths (secrets, certificates, config) rather than name
// specific known files, at the cost of over-blocking an unrelated name that happens to
// contain the keyword -- the fail-safe direction, same rationale as is_denied_filename.
void add_denied_path_keyword(const std::string& keyword);
// Execution phase: block both reads and writes outside allowed // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact
// roots, plus subprocess/socket/ctypes. // same set without a live interpreter.
Enforcing, static std::vector<std::string> default_denied_path_keywords();
};
void set_audit_mode(AuditMode mode); // True when any component of candidate's (canonicalized) path contains a registered
AuditMode audit_mode() const; // keyword, case-insensitively.
bool is_denied_path_keyword(const boost::filesystem::path& candidate) const;
// is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for
// call sites that only need to know whether a path is categorically off-limits, not which
// specific rule fired.
bool is_denied_path(const boost::filesystem::path& candidate) const;
// --- policy checks --- // --- policy checks ---
// Shared core for every audited filesystem event. The deny list is consulted above the // Shared core for every audited filesystem event. The deny checks are consulted above the
// Loading-mode read exemption and above the allowed roots, so a denied filename is // allowed roots, so a denied path is blocked even when it sits inside an allowed root (e.g.
// blocked even though every scope currently runs in Loading and the files in question // data_dir(), which is a global allowed root).
// sit inside data_dir(), which is itself a global allowed root.
AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write); AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write);
AuditDecision check_open(const std::string& path, const std::string& mode); AuditDecision check_open(const std::string& path, const std::string& mode);
// Ask the user to grant the requested filesystem-read paths. The request may originate on a
// plugin load worker, so the implementation marshals the modal dialog to the wx main thread.
// Returns true only when every missing path was granted and persisted; denial aborts the plugin
// load without adding a permission.
bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths);
void report_violation(const AuditViolation& violation); void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const;
void clear_audit_denial();
void clear_last_violation(); void clear_last_violation();
bool last_violation(AuditViolation& violation) const; bool last_violation(AuditViolation& violation) const;
// --- call-site cascade cache ---
// A single plugin action often fires several nested CPython audit events as it passes
// through stdlib layers (urllib.request calling http.client calling socket, for example).
// Once the user approves one event, every stdlib frame still on the stack for that call
// is recorded here by (filename, function, first line) identity. A later event whose own
// ancestor chain still contains one of those frames is the same logical action seen from
// a deeper layer, so it is auto-approved instead of prompting again.
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
bool verbose_events = true; bool verbose_events = true;
private: private:
@@ -109,17 +163,19 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data); static int audit_hook(const char* event, PyObject* args, void* user_data);
static thread_local std::string m_current_plugin_key; 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_capability_name;
static thread_local AuditMode m_audit_mode; static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
static thread_local std::vector<boost::filesystem::path> m_scoped_allowed_roots; static thread_local bool m_audit_denial_pending;
static thread_local bool m_has_last_violation; static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_last_violation; static thread_local AuditViolation m_last_violation;
// mutable: is_denied_filename() is a const query that must lock. // mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
std::vector<boost::filesystem::path> m_global_allowed_roots; std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames; std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords;
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
}; };
// RAII guard that sets the current plugin key and capability name, restoring the previous // RAII guard that sets the current plugin key and capability name, restoring the previous
@@ -130,8 +186,7 @@ class ScopedPluginAuditContext
public: public:
explicit ScopedPluginAuditContext( explicit ScopedPluginAuditContext(
const std::string& plugin_key, const std::string& plugin_key,
const std::string& capability_name = {}, const std::string& capability_name = {});
PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading);
~ScopedPluginAuditContext(); ~ScopedPluginAuditContext();
@@ -141,8 +196,7 @@ public:
private: private:
std::string m_previous_id; std::string m_previous_id;
std::string m_previous_capability; std::string m_previous_capability;
PluginAuditManager::AuditMode m_previous_mode; std::vector<AllowedRoot> m_previous_scoped_roots;
std::vector<boost::filesystem::path> m_previous_scoped_roots;
}; };
} // namespace Slic3r } // namespace Slic3r

View File

@@ -784,6 +784,23 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
parsed.plugin_name = state["plugin_name"].get<std::string>(); parsed.plugin_name = state["plugin_name"].get<std::string>();
if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string())
parsed.cloud_uuid = state["cloud_uuid"].get<std::string>(); parsed.cloud_uuid = state["cloud_uuid"].get<std::string>();
if (state.contains("permissions") && state["permissions"].is_object()) {
const auto& permissions = state["permissions"];
auto read_string_list = [&permissions](const char* key, std::vector<std::string>& out) {
if (!permissions.contains(key) || !permissions[key].is_array())
return;
for (const auto& entry : permissions[key])
if (entry.is_string())
out.push_back(entry.get<std::string>());
};
read_string_list("fs_read", parsed.permissions.fs_read);
read_string_list("fs_readwrite", parsed.permissions.fs_readwrite);
read_string_list("network_http", parsed.permissions.network_http);
read_string_list("network_socket", parsed.permissions.network_socket);
read_string_list("process", parsed.permissions.process);
}
if (state.contains("enabled") && state["enabled"].is_boolean()) if (state.contains("enabled") && state["enabled"].is_boolean())
parsed.enabled = state["enabled"].get<bool>(); parsed.enabled = state["enabled"].get<bool>();
@@ -819,6 +836,14 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
if (!state.cloud_uuid.empty()) if (!state.cloud_uuid.empty())
json["cloud_uuid"] = state.cloud_uuid; json["cloud_uuid"] = state.cloud_uuid;
json["permissions"] = {
{"fs_read", state.permissions.fs_read},
{"fs_readwrite", state.permissions.fs_readwrite},
{"network_http", state.permissions.network_http},
{"network_socket", state.permissions.network_socket},
{"process", state.permissions.process},
};
nlohmann::json capabilities = nlohmann::json::array(); nlohmann::json capabilities = nlohmann::json::array();
for (const auto& [name, enabled] : state.capabilities) for (const auto& [name, enabled] : state.capabilities)
capabilities.push_back(nlohmann::json{{name, enabled}}); capabilities.push_back(nlohmann::json{{name, enabled}});
@@ -836,6 +861,9 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
const std::vector<std::pair<std::string, bool>>& capabilities) const std::vector<std::pair<std::string, bool>>& capabilities)
{ {
PluginInstallState state; PluginInstallState state;
// Loading a plugin updates its lifecycle/capability state, but must retain permissions granted
// during register_capabilities() or by a previous runtime audit prompt.
read_install_state(plugin_dir, state);
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud // Prefer the descriptor's recorded installed_version (the version fetched from the cloud
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
@@ -852,10 +880,16 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry)
{ {
// Install-time writer: the package is not loaded, so its capabilities are not known yet and the // Install-time writer: the package is not loaded, so its capabilities are not known yet and the
// sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real // sidecar is (re)initialized to "auto-load, nothing disabled". This intentionally resets
// per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) // permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags
// overload. // while preserving permissions once the package is loaded, via the overload above.
return write_install_state(plugin_dir, entry, true, {}); PluginInstallState state;
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = true;
return write_install_state(plugin_dir, state);
} }
bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error)

View File

@@ -82,11 +82,23 @@ inline nlohmann::json py_to_json(const pybind11::handle& o)
return py::str(o).cast<std::string>(); // fallback: str() return py::str(o).cast<std::string>(); // fallback: str()
} }
struct PluginPermissions
{
std::vector<std::string> fs_read;
std::vector<std::string> fs_readwrite;
std::vector<std::string> network_http;
std::vector<std::string> network_socket;
std::vector<std::string> process;
};
struct PluginInstallState { struct PluginInstallState {
std::string installed_from; // "local" | "cloud" std::string installed_from; // "local" | "cloud"
std::string installed_version; std::string installed_version;
std::string plugin_name; std::string plugin_name;
std::string cloud_uuid; // empty for local std::string cloud_uuid; // empty for local
PluginPermissions permissions;
bool enabled = true; bool enabled = true;
std::vector<std::pair<std::string, bool>> capabilities; // name -> enabled, ordered std::vector<std::pair<std::string, bool>> capabilities; // name -> enabled, ordered
}; };

View File

@@ -280,7 +280,7 @@ bool load(const PluginDescriptor& descriptor,
// (while the active plugin key is set), then instantiates each registered capability and caches // (while the active plugin key is set), then instantiates each registered capability and caches
// its get_name(). Returns one entry per capability. // its get_name(). Returns one entry per capability.
std::string bridge_error; std::string bridge_error;
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, bridge_error); auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
if (!bridge_error.empty()) { if (!bridge_error.empty()) {
capabilities_found.clear(); capabilities_found.clear();
error = "Plugin registration failed: " + bridge_error; error = "Plugin registration failed: " + bridge_error;

View File

@@ -21,6 +21,7 @@
#include <chrono> #include <chrono>
#include <mutex> #include <mutex>
#include <slic3r/plugin/PluginConfig.hpp> #include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginLoader.hpp> #include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp> #include <slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp>
@@ -622,6 +623,20 @@ std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(
return nullptr; return nullptr;
} }
bool PluginManager::get_install_state(const std::string& plugin_key, PluginInstallState& install_state)
{
PluginDescriptor descriptor;
if (!try_get_plugin_descriptor(plugin_key, descriptor)) {
return false;
}
if (!read_install_state(boost::filesystem::path(descriptor.plugin_root), install_state)) {
return false;
}
return true;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────────────────────────────────────
bool PluginManager::is_plugin_loaded(const std::string& plugin_key) const bool PluginManager::is_plugin_loaded(const std::string& plugin_key) const
@@ -910,6 +925,8 @@ void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_de
if (!plugin_loader::load(descriptor, skip_deps, capabilities_to_enable, registry_precheck, plugin, error)) { if (!plugin_loader::load(descriptor, skip_deps, capabilities_to_enable, registry_precheck, plugin, error)) {
if (error == LOAD_CANCELLED) if (error == LOAD_CANCELLED)
return; // cancelled: nothing materialized survives, and no error is recorded return; // cancelled: nothing materialized survives, and no error is recorded
if (error.rfind("Plugin registration failed:", 0) == 0)
mark_plugin_install_state_disabled(plugin_key);
fail(std::move(error)); fail(std::move(error));
return; return;
} }
@@ -1135,6 +1152,51 @@ void PluginManager::write_loaded_plugin_install_state(const std::string& plugin_
write_install_state(boost::filesystem::path(descriptor.plugin_root), descriptor, /*enabled=*/true, capabilities); write_install_state(boost::filesystem::path(descriptor.plugin_root), descriptor, /*enabled=*/true, capabilities);
} }
void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin_key)
{
std::lock_guard<std::mutex> state_lock(m_install_state_mutex);
PluginDescriptor descriptor;
if (!try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return;
const boost::filesystem::path root(descriptor.plugin_root);
PluginInstallState state;
if (!read_install_state(root, state)) {
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 = false;
if (!write_install_state(root, state))
return;
std::lock_guard<std::mutex> lock(m_mutex);
if (Plugin* plugin = find_plugin_locked(plugin_key))
plugin->descriptor.enabled = false;
}
void PluginManager::revoke_plugin_permissions(const std::string& plugin_key)
{
std::lock_guard<std::mutex> state_lock(m_install_state_mutex);
PluginDescriptor descriptor;
if (!try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return;
const boost::filesystem::path root(descriptor.plugin_root);
PluginInstallState state;
if (!read_install_state(root, state)) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to read install state for " << plugin_key;
return;
}
state.permissions = {};
if (!write_install_state(root, state))
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to revoke permissions for " << plugin_key;
}
// ── Callbacks ─────────────────────────────────────────────────────────────────────────────── // ── Callbacks ───────────────────────────────────────────────────────────────────────────────
void PluginManager::subscribe_on_load_callback(PluginLifecycleCompleteFn fn) void PluginManager::subscribe_on_load_callback(PluginLifecycleCompleteFn fn)
@@ -1354,6 +1416,11 @@ bool PluginManager::install_plugin(const boost::filesystem::path& filepath, Plug
return false; return false;
} }
// Every successful install may have replaced executable plugin code. Revoke any permissions
// associated with the previous package so the newly installed version must request them again.
if (!plugin_descriptor.plugin_key.empty())
revoke_plugin_permissions(plugin_descriptor.plugin_key);
if (!plugin_descriptor.plugin_key.empty()) if (!plugin_descriptor.plugin_key.empty())
clear_plugin_error(plugin_descriptor.plugin_key); clear_plugin_error(plugin_descriptor.plugin_key);
@@ -1741,6 +1808,7 @@ bool PluginManager::update_cloud_plugin(const std::string& plugin_key, std::stri
} }
clear_plugin_error(plugin_key); clear_plugin_error(plugin_key);
return true; return true;
} }

View File

@@ -159,6 +159,8 @@ public:
PluginCapabilityType type = PluginCapabilityType::Unknown, PluginCapabilityType type = PluginCapabilityType::Unknown,
bool only_enabled = true) const; bool only_enabled = true) const;
bool get_install_state(const std::string& plugin_key, PluginInstallState& install_state);
void load_plugin(const std::string& plugin_key, bool skip_deps = false, std::vector<std::string> capabilities_to_enable = {}); void load_plugin(const std::string& plugin_key, bool skip_deps = false, std::vector<std::string> capabilities_to_enable = {});
bool unload_plugin(const std::string& plugin_key); bool unload_plugin(const std::string& plugin_key);
void unload_all_plugins(); void unload_all_plugins();
@@ -255,6 +257,9 @@ private:
// Writes the sidecar for a loaded plugin (enabled=true plus the current per-capability flags). // Writes the sidecar for a loaded plugin (enabled=true plus the current per-capability flags).
void write_loaded_plugin_install_state(const std::string& plugin_key); void write_loaded_plugin_install_state(const std::string& plugin_key);
void mark_plugin_install_state_disabled(const std::string& plugin_key);
// Revoke permissions after a package replacement so the new package must request them again.
void revoke_plugin_permissions(const std::string& plugin_key);
bool finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error); bool finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error);
bool delete_installed_plugin_package(const PluginDescriptor& plugin, std::string& error); bool delete_installed_plugin_package(const PluginDescriptor& plugin, std::string& error);

View File

@@ -26,25 +26,30 @@
try { \ try { \
override_call; \ override_call; \
} catch (pybind11::error_already_set & err) { \ } catch (pybind11::error_already_set & err) { \
const bool _orca_audit_denial = ::Slic3r::PluginAuditManager::instance().audit_denial_pending(); \
if (_orca_audit_denial) \
::Slic3r::PluginAuditManager::instance().clear_current_plugin(); \
::Slic3r::log_python_exception_keep(err); \ ::Slic3r::log_python_exception_keep(err); \
if (_orca_audit_denial) \
::Slic3r::PluginAuditManager::instance().clear_audit_denial(); \
throw; \ throw; \
} }
// Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call, and publishes // Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call, and publishes
// the calling capability's cached name so host APIs invoked from Python can tell which capability // the calling capability's cached name so host APIs invoked from Python can tell which capability
// they are serving. No-op without an audit plugin key. Declares a local `_orca_audit_scope`. // they are serving. No-op without an audit plugin key. Declares a local `_orca_audit_scope`.
#define ORCA_PY_AUDIT_SCOPE(mode) \ #define ORCA_PY_AUDIT_SCOPE() \
std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \ std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \
if (const std::string& _orca_audit_key = this->audit_plugin_key(); !_orca_audit_key.empty()) \ if (const std::string& _orca_audit_key = this->audit_plugin_key(); !_orca_audit_key.empty()) \
_orca_audit_scope.emplace(_orca_audit_key, this->name(), mode) _orca_audit_scope.emplace(_orca_audit_key, this->name())
#define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \ #define ORCA_PY_OVERRIDE_AUDITED(audit_setup, override_macro, ret, base, name, ...) \
do { \ do { \
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); \ ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); \
::Slic3r::PythonGILState _orca_python_gil; \ ::Slic3r::PythonGILState _orca_python_gil; \
if (!_orca_python_gil) \ if (!_orca_python_gil) \
throw std::runtime_error("Python interpreter is shutting down"); \ throw std::runtime_error("Python interpreter is shutting down"); \
ORCA_PY_AUDIT_SCOPE(mode); \ ORCA_PY_AUDIT_SCOPE(); \
if (_orca_audit_scope) \ if (_orca_audit_scope) \
audit_setup(); \ audit_setup(); \
ORCA_PY_LOGGED_OVERRIDE_BODY(override_macro(ret, base, name, ##__VA_ARGS__)); \ ORCA_PY_LOGGED_OVERRIDE_BODY(override_macro(ret, base, name, ##__VA_ARGS__)); \
@@ -58,7 +63,7 @@ public:
std::string get_name() const override std::string get_name() const override
{ {
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name); ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name);
} }
// Config UI hooks. Available on every capability type, so they live here rather than in // Config UI hooks. Available on every capability type, so they live here rather than in
@@ -66,7 +71,6 @@ public:
bool has_config_ui() const override bool has_config_ui() const override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {}, [] {},
PYBIND11_OVERRIDE, PYBIND11_OVERRIDE,
bool, bool,
@@ -77,7 +81,6 @@ public:
std::string get_config_ui() const override std::string get_config_ui() const override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {}, [] {},
PYBIND11_OVERRIDE, PYBIND11_OVERRIDE,
std::string, std::string,
@@ -94,7 +97,7 @@ public:
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
nlohmann::json get_default_config() const override nlohmann::json get_default_config() const override
{ {
ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); ORCA_PY_AUDIT_SCOPE();
try { try {
pybind11::gil_scoped_acquire gil; pybind11::gil_scoped_acquire gil;
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config"); pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
@@ -117,17 +120,17 @@ public:
void on_load() override void on_load() override
{ {
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_load); ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_load);
} }
void on_unload() override void on_unload() override
{ {
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_unload); ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_unload);
} }
void on_cancelled() override void on_cancelled() override
{ {
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_cancelled); ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_cancelled);
} }
}; };
@@ -139,7 +142,7 @@ public:
PluginCapabilityType get_type() const override PluginCapabilityType get_type() const override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, PluginCapabilityType, PluginCapabilityInterface, [] {}, PYBIND11_OVERRIDE, PluginCapabilityType, PluginCapabilityInterface,
get_type); get_type);
} }
}; };

View File

@@ -87,6 +87,12 @@ void log_python_exception_keep(pybind11::error_already_set& err)
if (!gil) if (!gil)
return; return;
// Traceback output is host-owned work. In particular, the stderr tee opens the Python log
// file on every write. Keep that open outside the plugin audit context, otherwise an ordinary
// exception raised by a plugin can recursively trigger the filesystem permission dialog while
// its original exception is being reported.
ScopedPluginAuditContext audit_suppression("");
// Non-destructive: print the traceback to sys.stderr (tee'd to the session log) // Non-destructive: print the traceback to sys.stderr (tee'd to the session log)
// WITHOUT consuming err, so the caller can rethrow it intact. For example, downstream C++ // WITHOUT consuming err, so the caller can rethrow it intact. For example, downstream C++
// catchers can still read err.what() for the user-facing dialog. We must NOT use // catchers can still read err.what() for the user-facing dialog. We must NOT use
@@ -622,10 +628,6 @@ bool PythonInterpreter::initialize()
else else
BOOST_LOG_TRIVIAL(info) << "Bundled uv executable not found"; BOOST_LOG_TRIVIAL(info) << "Bundled uv executable not found";
// Install the CPython audit hook for plugin policy enforcement.
// This is defense-in-depth: today it only inspects the `open` audit event
// and blocks writes outside the allowed roots; subprocess/socket/ctypes and
// other events are not yet handled. It is NOT a full security sandbox.
PluginAuditManager::instance().install_hook(); PluginAuditManager::instance().install_hook();
// Persist Python stderr (plugin tracebacks, including uncaught // Persist Python stderr (plugin tracebacks, including uncaught

View File

@@ -1,5 +1,6 @@
#include "PythonPluginBridge.hpp" #include "PythonPluginBridge.hpp"
#include <algorithm>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <exception> #include <exception>
#include <memory> #include <memory>
@@ -40,6 +41,7 @@ thread_local std::string g_active_plugin_key;
std::mutex g_registry_mutex; std::mutex g_registry_mutex;
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities; std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
std::unordered_map<std::string, py::object> g_pending_package; std::unordered_map<std::string, py::object> g_pending_package;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
struct PluginInstanceHandle struct PluginInstanceHandle
{ {
// The C++ plugin interface points into a Python object. Keep both alive through one // The C++ plugin interface points into a Python object. Keep both alive through one
@@ -77,6 +79,8 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
(void) package->second.release(); (void) package->second.release();
g_pending_package.erase(package); g_pending_package.erase(package);
} }
g_pending_fs_read_permissions.erase(plugin_key);
} }
} // namespace } // namespace
@@ -101,34 +105,38 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
// for this same entry path. // for this same entry path.
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key);
} }
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by // From now until finalize/cancel, @orca.plugin and register_capability() calls made by
// Python code on this thread are attributed to this plugin. // Python code on this thread are attributed to this plugin.
g_active_plugin_key = plugin_key; g_active_plugin_key = plugin_key;
} }
std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(const std::string& plugin_key, std::string& error) std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(const std::string& capture_key,
const std::string& plugin_key,
std::string& error)
{ {
PythonGILState gil; PythonGILState gil;
if (!gil) { if (!gil) {
error = "Python interpreter is shutting down"; error = "Python interpreter is shutting down";
return {}; return {};
} }
BOOST_LOG_TRIVIAL(info) << "Finalizing Python plugin capture for key " << plugin_key; BOOST_LOG_TRIVIAL(info) << "Finalizing Python plugin capture for key " << capture_key;
// Phase 1: run the package class's register_capabilities() while the active key is // Phase 1: run the package class's register_capabilities() while the active key is
// still set. That method is expected to call orca.register_capability() once per // still set. That method is expected to call orca.register_capability() once per
// capability class, and register_capability() needs g_active_plugin_key to know which // capability class, and register_capability() needs g_active_plugin_key to know which
// pending bucket to append to. // pending bucket to append to.
{ {
auto clear_active_key = [&plugin_key]() { auto clear_active_key = [&capture_key]() {
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == capture_key)
g_active_plugin_key.clear(); g_active_plugin_key.clear();
}; };
auto discard_pending_for_key = [&plugin_key]() { auto discard_pending_for_key = [&capture_key]() {
std::lock_guard<std::mutex> lock(g_registry_mutex); std::lock_guard<std::mutex> lock(g_registry_mutex);
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(capture_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key);
}; };
try { try {
@@ -138,7 +146,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
py::object package_cls; py::object package_cls;
{ {
std::lock_guard<std::mutex> lock(g_registry_mutex); std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_package.find(plugin_key); auto it = g_pending_package.find(capture_key);
if (it != g_pending_package.end()) { if (it != g_pending_package.end()) {
package_cls = it->second; package_cls = it->second;
g_pending_package.erase(it); g_pending_package.erase(it);
@@ -157,6 +165,33 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
// are kept. // are kept.
py::object package = package_cls(); py::object package = package_cls();
package.attr("register_capabilities")(); package.attr("register_capabilities")();
// Permission declarations are collected while register_capabilities() runs so the
// plugin can describe its needs without touching wx from the Python load worker. Ask
// only after registration returns, before capability instances are materialized.
std::vector<std::string> fs_read_permissions;
{
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_fs_read_permissions.find(capture_key);
if (it != g_pending_fs_read_permissions.end()) {
fs_read_permissions = std::move(it->second);
g_pending_fs_read_permissions.erase(it);
}
}
if (!fs_read_permissions.empty()) {
bool granted = false;
{
py::gil_scoped_release release;
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
}
if (!granted) {
error = "Plugin filesystem read permission request denied";
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
return {};
}
}
} catch (py::error_already_set& err) { } catch (py::error_already_set& err) {
log_python_exception_keep(err); log_python_exception_keep(err);
error = err.what(); error = err.what();
@@ -180,7 +215,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
std::vector<py::object> classes; std::vector<py::object> classes;
{ {
std::lock_guard<std::mutex> lock(g_registry_mutex); std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_capabilities.find(plugin_key); auto it = g_pending_capabilities.find(capture_key);
if (it != g_pending_capabilities.end()) { if (it != g_pending_capabilities.end()) {
classes = std::move(it->second); classes = std::move(it->second);
g_pending_capabilities.erase(it); g_pending_capabilities.erase(it);
@@ -189,10 +224,10 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
// Registration is complete. Later register_capability() calls should fail instead of // Registration is complete. Later register_capability() calls should fail instead of
// accidentally attaching themselves to this plugin. // accidentally attaching themselves to this plugin.
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == capture_key)
g_active_plugin_key.clear(); g_active_plugin_key.clear();
BOOST_LOG_TRIVIAL(info) << "Collected " << classes.size() << " registered capability class(es) for key " << plugin_key; BOOST_LOG_TRIVIAL(info) << "Collected " << classes.size() << " registered capability class(es) for key " << capture_key;
std::vector<CapturedCapability> capabilities; std::vector<CapturedCapability> capabilities;
capabilities.reserve(classes.size()); capabilities.reserve(classes.size());
@@ -204,7 +239,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
py::object instance = cls(); py::object instance = cls();
if (!py::isinstance<PluginCapabilityInterface>(instance)) { if (!py::isinstance<PluginCapabilityInterface>(instance)) {
error = "Registered capability must inherit from a PluginCapability base"; error = "Registered capability must inherit from a PluginCapability base";
BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed type check for key " << plugin_key BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed type check for key " << capture_key
<< " error=" << error; << " error=" << error;
return {}; return {};
} }
@@ -212,7 +247,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
auto capability_iface = instance.cast<std::shared_ptr<PluginCapabilityInterface>>(); auto capability_iface = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
if (!capability_iface) { if (!capability_iface) {
error = "Failed to cast Python capability to PluginCapabilityInterface"; error = "Failed to cast Python capability to PluginCapabilityInterface";
BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed cast for key " << plugin_key BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed cast for key " << capture_key
<< " error=" << error; << " error=" << error;
return {}; return {};
} }
@@ -226,7 +261,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
// here is a hard error that rejects the whole plugin capture. // here is a hard error that rejects the whole plugin capture.
if (name.find(';') != std::string::npos) { if (name.find(';') != std::string::npos) {
error = "Capability name must not contain ';': " + name; error = "Capability name must not contain ';': " + name;
BOOST_LOG_TRIVIAL(error) << "Python plugin capture rejected capability for key " << plugin_key BOOST_LOG_TRIVIAL(error) << "Python plugin capture rejected capability for key " << capture_key
<< " error=" << error; << " error=" << error;
return {}; return {};
} }
@@ -246,12 +281,12 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
// log the traceback here. GIL is held for the duration of finalize_plugin_capture. // log the traceback here. GIL is held for the duration of finalize_plugin_capture.
log_python_exception_keep(err); log_python_exception_keep(err);
error = err.what(); error = err.what();
BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised Python exception for key " << plugin_key BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised Python exception for key " << capture_key
<< " error=" << error; << " error=" << error;
return {}; return {};
} catch (const std::exception& ex) { } catch (const std::exception& ex) {
error = ex.what(); error = ex.what();
BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised exception for key " << plugin_key BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised exception for key " << capture_key
<< " error=" << error; << " error=" << error;
return {}; return {};
} }
@@ -279,6 +314,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
// may already have registered under this key. // may already have registered under this key.
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key);
} }
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == plugin_key)
@@ -305,6 +341,7 @@ void PythonPluginBridge::clear_pending_captures()
(void) pkg.release(); (void) pkg.release();
} }
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
return; return;
} }
@@ -313,6 +350,7 @@ void PythonPluginBridge::clear_pending_captures()
BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)"; BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)";
g_pending_capabilities.clear(); g_pending_capabilities.clear();
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
} }
@@ -450,6 +488,27 @@ void bind_python_api(pybind11::module_& m)
}, },
R"pbdoc(Register a PluginCapability subclass while OrcaSlicer loads your module.)pbdoc"); R"pbdoc(Register a PluginCapability subclass while OrcaSlicer loads your module.)pbdoc");
m.def(
"request_permissions",
[](const std::vector<std::string>& fs_read) {
if (g_active_plugin_key.empty())
throw py::value_error("request_permissions() called outside plugin discovery context");
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
if (std::find(requested.begin(), requested.end(), path) == requested.end())
requested.push_back(path);
}
},
py::arg("fs_read") = std::vector<std::string>{},
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
The host presents the request to the user after register_capabilities() returns. Denying the
request aborts the plugin load.)pbdoc");
m.def("plugin", [](py::object cls) { m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
throw py::value_error("@orca.plugin used outside plugin discovery context"); throw py::value_error("@orca.plugin used outside plugin discovery context");

View File

@@ -31,7 +31,7 @@ public:
// Returns one CapturedCapability per capability, or an empty vector on failure // Returns one CapturedCapability per capability, or an empty vector on failure
// (error message populated). // (error message populated).
std::vector<CapturedCapability> finalize_plugin_capture( std::vector<CapturedCapability> finalize_plugin_capture(
const std::string& plugin_key, std::string& error); const std::string& capture_key, const std::string& plugin_key, std::string& error);
// Clear any pending registrations for the key. Safe to call when import fails. // Clear any pending registrations for the key. Safe to call when import fails.
void cancel_plugin_capture(const std::string& plugin_key); void cancel_plugin_capture(const std::string& plugin_key);

View File

@@ -16,7 +16,6 @@ public:
std::string get_icon() override std::string get_icon() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {}, [] {},
PYBIND11_OVERRIDE, PYBIND11_OVERRIDE,
std::string, std::string,
@@ -27,7 +26,6 @@ public:
std::string get_ui() override std::string get_ui() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {}, [] {},
PYBIND11_OVERRIDE_PURE, PYBIND11_OVERRIDE_PURE,
std::string, std::string,
@@ -42,7 +40,7 @@ public:
if (!gil) if (!gil)
throw std::runtime_error("Python interpreter is shutting down"); throw std::runtime_error("Python interpreter is shutting down");
ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); ORCA_PY_AUDIT_SCOPE();
pybind11::function override = pybind11::get_override(static_cast<PagesPluginCapability*>(this), "on_message"); pybind11::function override = pybind11::get_override(static_cast<PagesPluginCapability*>(this), "on_message");
if (!override) if (!override)

View File

@@ -16,206 +16,206 @@ public:
AgentInfo get_agent_info() override AgentInfo get_agent_info() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, AgentInfo, PrinterAgentPluginCapability, [] {}, PYBIND11_OVERRIDE_PURE, AgentInfo, PrinterAgentPluginCapability,
get_agent_info); get_agent_info);
} }
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, connect_printer, dev_id, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, connect_printer, dev_id,
dev_ip, username, password, use_ssl); dev_ip, username, password, use_ssl);
} }
int disconnect_printer() override int disconnect_printer() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, disconnect_printer); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, disconnect_printer);
} }
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override int send_message(std::string dev_id, std::string json_str, int qos, int flag) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message, dev_id, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message, dev_id,
json_str, qos, flag); json_str, qos, flag);
} }
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message_to_printer, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message_to_printer,
dev_id, json_str, qos, flag); dev_id, json_str, qos, flag);
} }
bool start_discovery(bool start, bool sending) override bool start_discovery(bool start, bool sending) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, start_discovery, start, [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, start_discovery, start,
sending); sending);
} }
int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind_detect, dev_ip, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind_detect, dev_ip,
sec_link, detect); sec_link, detect);
} }
std::string get_user_selected_machine() override std::string get_user_selected_machine() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability,
get_user_selected_machine); get_user_selected_machine);
} }
int set_user_selected_machine(std::string dev_id) override int set_user_selected_machine(std::string dev_id) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability,
set_user_selected_machine, dev_id); set_user_selected_machine, dev_id);
} }
int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability,
start_send_gcode_to_sdcard, params, update_fn, cancel_fn, wait_fn); start_send_gcode_to_sdcard, params, update_fn, cancel_fn, wait_fn);
} }
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_local_print, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_local_print,
params, update_fn, cancel_fn); params, update_fn, cancel_fn);
} }
FilamentSyncMode get_filament_sync_mode() const override FilamentSyncMode get_filament_sync_mode() const override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, FilamentSyncMode, PrinterAgentPluginCapability, [] {}, PYBIND11_OVERRIDE_PURE, FilamentSyncMode, PrinterAgentPluginCapability,
get_filament_sync_mode); get_filament_sync_mode);
} }
bool fetch_filament_info(std::string dev_id) override bool fetch_filament_info(std::string dev_id) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, fetch_filament_info, dev_id); [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, fetch_filament_info, dev_id);
} }
int check_cert() override int check_cert() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, check_cert); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, check_cert);
} }
void install_device_cert(std::string dev_id, bool lan_only) override void install_device_cert(std::string dev_id, bool lan_only) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, void, PrinterAgentPluginCapability, install_device_cert, dev_id, [] {}, PYBIND11_OVERRIDE_PURE, void, PrinterAgentPluginCapability, install_device_cert, dev_id,
lan_only); lan_only);
} }
int ping_bind(std::string ping_code) override int ping_bind(std::string ping_code) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, ping_bind, ping_code); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, ping_bind, ping_code);
} }
int bind(std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) override int bind(std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind, dev_ip, dev_id, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind, dev_ip, dev_id,
dev_model, sec_link, timezone, improved, update_fn); dev_model, sec_link, timezone, improved, update_fn);
} }
int unbind(std::string dev_id) override int unbind(std::string dev_id) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, unbind, dev_id); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, unbind, dev_id);
} }
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_print, params, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_print, params,
update_fn, cancel_fn, wait_fn); update_fn, cancel_fn, wait_fn);
} }
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability,
start_local_print_with_record, params, update_fn, cancel_fn, wait_fn); start_local_print_with_record, params, update_fn, cancel_fn, wait_fn);
} }
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_sdcard_print, params, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_sdcard_print, params,
update_fn, cancel_fn); update_fn, cancel_fn);
} }
int get_hms_snapshot(std::string dev_id, std::string file_name, std::function<void(std::string, int)> callback) override int get_hms_snapshot(std::string dev_id, std::string file_name, std::function<void(std::string, int)> callback) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, get_hms_snapshot, dev_id, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, get_hms_snapshot, dev_id,
file_name, callback); file_name, callback);
} }
int set_server_callback(OnServerErrFn fn) override int set_server_callback(OnServerErrFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_server_callback, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_server_callback, fn);
} }
int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_ssdp_msg_fn, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_ssdp_msg_fn, fn);
} }
int set_on_printer_connected_fn(OnPrinterConnectedFn fn) override int set_on_printer_connected_fn(OnPrinterConnectedFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_printer_connected_fn, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_printer_connected_fn,
fn); fn);
} }
int set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) override int set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_subscribe_failure_fn, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_subscribe_failure_fn,
fn); fn);
} }
int set_on_message_fn(OnMessageFn fn) override int set_on_message_fn(OnMessageFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_message_fn, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_message_fn, fn);
} }
int set_on_user_message_fn(OnMessageFn fn) override int set_on_user_message_fn(OnMessageFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_user_message_fn, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_user_message_fn, fn);
} }
int set_on_local_connect_fn(OnLocalConnectedFn fn) override int set_on_local_connect_fn(OnLocalConnectedFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_connect_fn, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_connect_fn, fn);
} }
int set_on_local_message_fn(OnMessageFn fn) override int set_on_local_message_fn(OnMessageFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_message_fn, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_message_fn, fn);
} }
int set_queue_on_main_fn(QueueOnMainFn fn) override int set_queue_on_main_fn(QueueOnMainFn fn) override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_queue_on_main_fn, fn); [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_queue_on_main_fn, fn);
} }
// request_bind_ticket returns its ticket through a std::string* out-param, which pybind11 // request_bind_ticket returns its ticket through a std::string* out-param, which pybind11
@@ -223,7 +223,7 @@ public:
// returns a (result, ticket) tuple, which we unpack into the int result and the out-param. // returns a (result, ticket) tuple, which we unpack into the int result and the out-param.
int request_bind_ticket(std::string* ticket) override int request_bind_ticket(std::string* ticket) override
{ {
ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); ORCA_PY_AUDIT_SCOPE();
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
::Slic3r::PythonGILState gil; ::Slic3r::PythonGILState gil;
if (!gil) if (!gil)

View File

@@ -13,7 +13,6 @@ public:
ExecutionResult execute() override ExecutionResult execute() override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {}, [] {},
PYBIND11_OVERRIDE_PURE, PYBIND11_OVERRIDE_PURE,
ExecutionResult, ExecutionResult,

View File

@@ -10,7 +10,6 @@ public:
using PyPluginCommonTrampoline<SlicingPipelinePluginCapability>::PyPluginCommonTrampoline; using PyPluginCommonTrampoline<SlicingPipelinePluginCapability>::PyPluginCommonTrampoline;
ExecutionResult execute(SlicingPipelineContext& ctx) override { ExecutionResult execute(SlicingPipelineContext& ctx) override {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[&]{ [&]{
// At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives // At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives
// outside data_dir() (a temp/output folder), so writing to it would otherwise be // outside data_dir() (a temp/output folder), so writing to it would otherwise be

View File

@@ -34,4 +34,24 @@ struct ScopedDataDir
ScopedDataDir& operator=(const ScopedDataDir&) = delete; ScopedDataDir& operator=(const ScopedDataDir&) = delete;
}; };
// Point resources_dir() at a throwaway directory for the lifetime of a test and restore the
// previous value afterwards, mirroring ScopedDataDir.
struct ScopedResourcesDir
{
ScopedTemporaryDir tmp;
boost::filesystem::path dir;
std::string previous;
explicit ScopedResourcesDir(const std::string& tag)
: tmp("orca-" + tag), dir(tmp.path()), previous(resources_dir())
{
set_resources_dir(dir.string());
}
~ScopedResourcesDir() { set_resources_dir(previous); }
ScopedResourcesDir(const ScopedResourcesDir&) = delete;
ScopedResourcesDir& operator=(const ScopedResourcesDir&) = delete;
};
} // namespace Slic3r } // namespace Slic3r

View File

@@ -27,6 +27,16 @@ void seed_denied_names()
mgr.add_denied_filename(name); mgr.add_denied_filename(name);
} }
// Seed the keyword registry with the same list install_hook() uses. Same rationale as
// seed_denied_names(): a process-singleton registry, seeded from the single shared source so
// production and tests cannot drift apart.
void seed_denied_keywords()
{
PluginAuditManager& mgr = PluginAuditManager::instance();
for (const auto& keyword : PluginAuditManager::default_denied_path_keywords())
mgr.add_denied_path_keyword(keyword);
}
} // namespace } // namespace
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
@@ -81,7 +91,7 @@ TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit
} }
} }
TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption", "[audit]") TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
{ {
ScopedDataDir data_dir_guard("plugin-audit-deny"); ScopedDataDir data_dir_guard("plugin-audit-deny");
seed_denied_names(); seed_denied_names();
@@ -91,8 +101,8 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption"
// config and the token would otherwise be reachable simply by living inside it. // config and the token would otherwise be reachable simply by living inside it.
mgr.add_global_allowed_root(data_dir()); mgr.add_global_allowed_root(data_dir());
// Enter a plugin context. The deny must hold in Loading mode, which every scope runs in. // Enter a plugin context. The deny must hold even inside a globally allowed root.
ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading); ScopedPluginAuditContext ctx("test_plugin", "");
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
@@ -103,6 +113,13 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption"
CHECK(decision.allowed); CHECK(decision.allowed);
} }
SECTION("a file outside the allowed root is blocked for reads as well as writes")
{
AuditDecision decision = mgr.check_open((fs::path(data_dir()).parent_path() / "outside.txt").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "outside allowed root");
}
SECTION("writing the app config is blocked despite data_dir() being allowed") SECTION("writing the app config is blocked despite data_dir() being allowed")
{ {
AuditDecision decision = mgr.check_open(conf.string(), "w"); AuditDecision decision = mgr.check_open(conf.string(), "w");
@@ -110,16 +127,14 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption"
CHECK(decision.reason == "denied filename"); CHECK(decision.reason == "denied filename");
} }
SECTION("reading the app config is blocked even though Loading exempts reads") SECTION("reading the app config is blocked despite the allowed root")
{ {
// Without the deny, a read in Loading mode short-circuits to allow. The deny sits above
// that exemption, so this must still be blocked.
AuditDecision decision = mgr.check_open(conf.string(), "r"); AuditDecision decision = mgr.check_open(conf.string(), "r");
CHECK_FALSE(decision.allowed); CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename"); CHECK(decision.reason == "denied filename");
} }
SECTION("reading the cloud refresh token is blocked in Loading mode") SECTION("reading the cloud refresh token is blocked")
{ {
AuditDecision decision = mgr.check_open(token.string(), "r"); AuditDecision decision = mgr.check_open(token.string(), "r");
CHECK_FALSE(decision.allowed); CHECK_FALSE(decision.allowed);
@@ -150,7 +165,7 @@ TEST_CASE("Plugin audit deny beats a plugin's own scoped root", "[audit]")
const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin"; const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin";
fs::create_directories(plugin_dir); fs::create_directories(plugin_dir);
ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading); ScopedPluginAuditContext ctx("test_plugin", "");
mgr.add_scoped_allowed_root(plugin_dir); mgr.add_scoped_allowed_root(plugin_dir);
SECTION("the plugin's own non-denied file opens for read and write") SECTION("the plugin's own non-denied file opens for read and write")
@@ -185,3 +200,139 @@ TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]")
CHECK(mgr.check_open(conf.string(), "w").allowed); CHECK(mgr.check_open(conf.string(), "w").allowed);
CHECK(mgr.check_open(conf.string(), "r").allowed); CHECK(mgr.check_open(conf.string(), "r").allowed);
} }
TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", "[audit]")
{
seed_denied_keywords();
const PluginAuditManager& mgr = PluginAuditManager::instance();
SECTION("a 'secrets' directory component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secrets/api_key.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secret/token.txt")));
}
SECTION("a 'certificate(s)' directory component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/cert/slicer_base64.cer")));
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem")));
}
SECTION("a 'conf'/'config' directory or file component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf")));
}
SECTION("matching is case-insensitive")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt")));
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON")));
}
SECTION("matching is not limited to the base name -- any ancestor component counts")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/data/secrets/nested/deep/file.txt")));
}
SECTION("an unrelated path is not denied")
{
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json")));
}
SECTION("an empty path is not denied")
{
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path()));
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{
seed_denied_names();
seed_denied_keywords();
const PluginAuditManager& mgr = PluginAuditManager::instance();
SECTION("a filename-registry match is denied")
{
CHECK(mgr.is_denied_path(fs::path(SLIC3R_APP_KEY ".conf")));
}
SECTION("a keyword-registry match is denied")
{
CHECK(mgr.is_denied_path(fs::path("/plugin/secrets/token.txt")));
}
SECTION("a path matching neither registry is not denied")
{
CHECK_FALSE(mgr.is_denied_path(fs::path("/plugin/output/model.gcode")));
}
}
TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "[audit]")
{
ScopedDataDir data_dir_guard("plugin-audit-readonly");
ScopedResourcesDir resources_dir_guard("plugin-audit-readonly-resources");
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false);
ScopedPluginAuditContext ctx("test_plugin", "");
const fs::path readonly_file = fs::path(resources_dir()) / "profiles" / "vendor.json";
SECTION("a read inside the read-only root is allowed")
{
CHECK(mgr.check_open(readonly_file.string(), "r").allowed);
}
SECTION("a write inside the read-only root is blocked")
{
AuditDecision decision = mgr.check_open(readonly_file.string(), "w");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "outside allowed root");
}
SECTION("a create inside the read-only root is blocked")
{
AuditDecision decision = mgr.check_path_access(readonly_file, /*is_write=*/true);
CHECK_FALSE(decision.allowed);
}
SECTION("the bundled cert underneath the read-only root is denied even for reads")
{
const fs::path cert = fs::path(resources_dir()) / "cert" / "slicer_base64.cer";
AuditDecision decision = mgr.check_open(cert.string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
}
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
{
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
const fs::path readonly_dir = fs::path(data_dir()) / "readonly_scope";
fs::create_directories(readonly_dir);
ScopedPluginAuditContext ctx("test_plugin", "");
mgr.add_scoped_allowed_root(readonly_dir, /*allow_write=*/false);
SECTION("a read inside the scoped read-only root is allowed")
{
CHECK(mgr.check_open((readonly_dir / "vendor.json").string(), "r").allowed);
}
SECTION("a write inside the scoped read-only root is blocked")
{
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
}
}

View File

@@ -112,6 +112,22 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
REQUIRE(read_install_state(plugin_dir, state)); REQUIRE(read_install_state(plugin_dir, state));
CHECK(state.installed_version == "1.2.0"); CHECK(state.installed_version == "1.2.0");
state.permissions.fs_read = {"/path/to/read"};
state.permissions.fs_readwrite = {"/path/to/readwrite"};
state.permissions.network_http = {"https://api.example.com"};
state.permissions.network_socket = {"192.168.45.6:443"};
state.permissions.process = {"/usr/bin/curl"};
REQUIRE(write_install_state(plugin_dir, state));
// Permission data is persisted in the same sidecar as the installation metadata.
PluginInstallState persisted;
REQUIRE(read_install_state(plugin_dir, persisted));
CHECK(persisted.permissions.fs_read == state.permissions.fs_read);
CHECK(persisted.permissions.fs_readwrite == state.permissions.fs_readwrite);
CHECK(persisted.permissions.network_http == state.permissions.network_http);
CHECK(persisted.permissions.network_socket == state.permissions.network_socket);
CHECK(persisted.permissions.process == state.permissions.process);
// Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still // Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare // 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare
// the cloud's latest version against the installed version instead of the stale header, so an // the cloud's latest version against the installed version instead of the stale header, so an