add audit hook for fs, network and processes

This commit is contained in:
Ian Chua
2026-08-13 19:16:18 +08:00
parent 35970f61dc
commit ddd62e25df
6 changed files with 424 additions and 97 deletions

View File

@@ -14,12 +14,103 @@
#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 <vector>
#include <wx/event.h>
#include <wx/msgdlg.h>
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;
}
// ---------------------------------------------------------------------------
// Path safety
// ---------------------------------------------------------------------------
@@ -344,7 +435,7 @@ void PluginAuditManager::report_violation(const AuditViolation& violation)
m_audit_denial_pending = true;
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; }
@@ -366,6 +457,34 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
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
// ---------------------------------------------------------------------------
@@ -398,38 +517,163 @@ std::string python_path(PyObject* object)
return result;
}
void add_filesystem_path(std::vector<boost::filesystem::path>& paths, PyObject* object)
std::string python_unicode(PyObject* object)
{
const std::string path = python_path(object);
if (!path.empty())
paths.emplace_back(path);
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
std::vector<boost::filesystem::path> filesystem_paths(const std::string& event_name, PyObject* args)
std::string python_str(PyObject* object)
{
std::vector<boost::filesystem::path> paths;
if (!object)
return {};
if (event_name == "open") {
add_filesystem_path(paths, tuple_item(args, 0));
} else if (event_name == "os.rename") {
add_filesystem_path(paths, tuple_item(args, 0));
add_filesystem_path(paths, tuple_item(args, 1));
} else if (event_name == "os.remove") {
add_filesystem_path(paths, tuple_item(args, 0));
PyObject* str_object = PyObject_Str(object);
if (!str_object) {
PyErr_Clear();
return {};
}
return paths;
const std::string result = python_unicode(str_object);
Py_DECREF(str_object);
return result;
}
bool has_filesystem_permission(const PluginInstallState& state, const boost::filesystem::path& path)
std::vector<std::string> call_site_identities(const std::string& plugin_root)
{
return std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path.string()) !=
state.permissions.fs_read.end();
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;
}
bool persist_filesystem_permission(const std::string& plugin_key,
PluginInstallState& state,
const boost::filesystem::path& path)
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())
@@ -445,23 +689,18 @@ bool persist_filesystem_permission(const std::string& plugin_key,
state.enabled = true;
}
const std::string path_string = path.string();
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path_string) ==
state.permissions.fs_read.end())
state.permissions.fs_read.push_back(path_string);
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.
int report_denied(PluginAuditManager& mgr,
const std::string& event_name,
const boost::filesystem::path& target,
const AuditDecision& decision)
{
AuditViolation violation;
violation.plugin_key = mgr.current_plugin();
violation.event_name = event_name;
violation.path = target;
violation.reason = decision.reason;
mgr.report_violation(violation);
@@ -469,6 +708,74 @@ int report_denied(PluginAuditManager& mgr,
return -1;
}
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)
@@ -479,12 +786,33 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
std::string event_name(event ? event : "");
if (event_name.empty())
return 0;
// Verbose logging of every audit event (can be noisy)
if (mgr->verbose_events) {
BOOST_LOG_TRIVIAL(debug) << "[AUDIT EVENT] " << event_name;
}
if (mgr->current_plugin().empty())
if (mgr->current_plugin().empty()) {
BOOST_LOG_TRIVIAL(trace) << "[AUDIT] event=" << event_name << " bypassed (no plugin context)";
return 0;
}
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
// the open function can take in different flags that determine if it is a read or readwrite.
const AuditEventCategory event_type =
event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name);
if (event_type == AuditEventCategory::None)
return 0;
PluginInstallState state;
@@ -494,53 +822,16 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
}
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
const std::vector<boost::filesystem::path> paths = PluginAuditDetail::filesystem_paths(event_name, args);
for (const auto& path : paths) {
if (PluginAuditDetail::has_filesystem_permission(state, path))
continue;
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" is requesting filesystem access to:\n%s"),
wxString::FromUTF8(plugin_name.c_str()),
wxString::FromUTF8(path.string().c_str())),
_L("Plugin permission request"),
wxYES_NO | wxICON_WARNING);
if (dialog.ShowModal() == wxID_YES &&
PluginAuditDetail::persist_filesystem_permission(mgr->current_plugin(), state, path))
continue;
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
return PluginAuditDetail::report_denied(
*mgr, event_name, path, {false, "filesystem permission required"});
}
if (!paths.empty())
return 0;
wxMessageDialog dialog(
nullptr,
wxString::Format(
_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n"
"This operation does not expose a filesystem path to the audit hook."),
wxString::FromUTF8(plugin_name.c_str()),
wxString::FromUTF8(event_name.c_str())),
_L("Plugin permission request"),
wxYES_NO | wxICON_WARNING);
if (dialog.ShowModal() == wxID_YES)
return 0;
return PluginAuditDetail::report_denied(
*mgr, event_name, boost::filesystem::path(), {false, "audit permission required"});
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()
{
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";
// data_dir() is the only globally-allowed root during plugin execution.
// The executable directory and resources directory are intentionally NOT allowed
// here: plugins must not access outside data_dir() (G-code plugins additionally get
@@ -558,6 +849,12 @@ void PluginAuditManager::install_hook()
// (see its comment for why all four config names are denied); the tests seed from it too.
for (const auto& name : default_denied_filenames())
add_denied_filename(name);
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

View File

@@ -5,6 +5,8 @@
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <boost/filesystem.hpp>
@@ -19,10 +21,23 @@ struct AuditDecision {
struct AuditViolation {
std::string plugin_key;
std::string event_name;
boost::filesystem::path path;
std::string reason;
};
// 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.
// Uses weakly_canonical and component-wise comparison to reject traversal attacks.
bool is_inside_allowed_root(const boost::filesystem::path& candidate,
@@ -94,6 +109,16 @@ public:
void clear_last_violation();
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;
private:
@@ -110,10 +135,11 @@ private:
static thread_local bool m_has_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;
std::vector<boost::filesystem::path> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames;
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

View File

@@ -788,18 +788,18 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
if (state.contains("permissions") && state["permissions"].is_object()) {
const auto& permissions = state["permissions"];
if (permissions.contains("networking") && permissions["networking"].is_array())
for (const auto& host : permissions["networking"])
if (host.is_string())
parsed.permissions.networking.push_back(host.get<std::string>());
if (permissions.contains("fs_read") && permissions["fs_read"].is_array())
for (const auto& path : permissions["fs_read"])
if (path.is_string())
parsed.permissions.fs_read.push_back(path.get<std::string>());
if (permissions.contains("fs_write") && permissions["fs_write"].is_array())
for (const auto& path : permissions["fs_write"])
if (path.is_string())
parsed.permissions.fs_write.push_back(path.get<std::string>());
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())
@@ -838,9 +838,11 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
json["cloud_uuid"] = state.cloud_uuid;
json["permissions"] = {
{"networking", state.permissions.networking},
{"fs_read", state.permissions.fs_read},
{"fs_write", state.permissions.fs_write},
{"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();

View File

@@ -83,9 +83,11 @@ inline nlohmann::json py_to_json(const pybind11::handle& o)
struct PluginPermissions
{
std::vector<std::string> networking;
std::vector<std::string> fs_read;
std::vector<std::string> fs_write;
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 {

View File

@@ -628,10 +628,6 @@ bool PythonInterpreter::initialize()
else
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();
// Persist Python stderr (plugin tracebacks, including uncaught

View File

@@ -112,17 +112,21 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
REQUIRE(read_install_state(plugin_dir, state));
CHECK(state.installed_version == "1.2.0");
state.permissions.networking = {"api.example.com", "192.168.45.6"};
state.permissions.fs_read = {"/path/to/read"};
state.permissions.fs_write = {"/path/to/write"};
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.networking == state.permissions.networking);
CHECK(persisted.permissions.fs_read == state.permissions.fs_read);
CHECK(persisted.permissions.fs_write == state.permissions.fs_write);
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
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare