mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-06 10:31:12 +00:00
feat: Python Plugins
This commit is contained in:
385
docs/plugins/plugin_audit_hook.md
Normal file
385
docs/plugins/plugin_audit_hook.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Plugin Audit Hook
|
||||
|
||||
OrcaSlicer's plugin system runs Python, which is extremely capable — it can read and
|
||||
write files, spawn processes, open sockets, and load native code. To keep plugins from
|
||||
reaching outside what they legitimately need, we install a **CPython audit hook** that
|
||||
inspects sensitive runtime operations performed by plugin code and blocks the ones that
|
||||
fall outside an allow‑list.
|
||||
|
||||
> **Scope of this version.** This is intentionally a *narrow, low‑risk first version* —
|
||||
> groundwork, not a complete sandbox. Today it enforces one thing: **file writes are
|
||||
> restricted to an allow‑list of directories** while a plugin is executing. Reads are
|
||||
> left permissive so Python can still import modules. Process/network/native‑code events
|
||||
> are *not* yet enforced. See [Limitations](#limitations) before relying on it as a
|
||||
> security boundary.
|
||||
|
||||
---
|
||||
|
||||
## What is a plugin audit hook?
|
||||
|
||||
CPython exposes an auditing API (PEP 578). Any interpreter‑wide hook registered with
|
||||
`PySys_AddAuditHook` is called *before* the runtime performs a sensitive operation — for
|
||||
example opening a file (`open`), spawning a subprocess (`subprocess.Popen`), or connecting
|
||||
a socket (`socket.connect`). The hook receives the event name and its arguments and may
|
||||
abort the operation by setting a Python exception and returning a non‑zero value.
|
||||
|
||||
We register exactly **one** such hook, once, from `PythonInterpreter::initialize()` via
|
||||
`PluginAuditManager::instance().install_hook()`. Everything else — *which* plugin is
|
||||
running, *what* mode it runs under, and *which* directories it may touch — is tracked by
|
||||
`PluginAuditManager`.
|
||||
|
||||
The hook itself is global to the interpreter, but it only enforces anything when a plugin
|
||||
**audit context** is active (see below). Non‑plugin Python code, and plugin loading before
|
||||
the context is set, pass through untouched.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
There are three moving parts. Keep them distinct — conflating them is the usual source of
|
||||
confusion.
|
||||
|
||||
### 1. Audit identity — *who* is running (set once, per instance)
|
||||
|
||||
Every plugin instance carries a C++‑only identity string, never exposed to Python:
|
||||
|
||||
```cpp
|
||||
// PythonPluginInterface.hpp
|
||||
class PluginCapabilityInterface {
|
||||
public:
|
||||
void set_audit_plugin_key(std::string key);
|
||||
const std::string& audit_plugin_key() const;
|
||||
private:
|
||||
std::string m_audit_plugin_key; // == PluginDescriptor::plugin_key
|
||||
};
|
||||
```
|
||||
|
||||
This is the canonical runtime ID, `PluginDescriptor::plugin_key`. It is stamped onto the
|
||||
instance by the loader **after** the plugin is captured and **before** `on_load()` runs:
|
||||
|
||||
- `PluginLoader::load_plugin_impl()` → `set_audit_plugin_key(descriptor.plugin_key)`
|
||||
- `PluginLoader::update_loaded_plugin_key()` → re‑stamps it if a key is migrated
|
||||
|
||||
Stamping the identity does **not** turn on enforcement — it only labels the object so that
|
||||
later calls know which plugin they belong to. This matters because printer‑agent plugins
|
||||
are later invoked through `IPrinterAgent` / `NetworkAgent`, where the original `plugin_key`
|
||||
is no longer available at the call site; the instance carries it instead.
|
||||
|
||||
### 2. Audit context — *how strict*, for the duration of one call (set per call)
|
||||
|
||||
The active plugin, mode, and scoped roots live in thread‑local state on
|
||||
`PluginAuditManager`. They are set and restored by an RAII guard,
|
||||
`ScopedPluginAuditContext`:
|
||||
|
||||
```cpp
|
||||
// constructor: remember previous state, then apply the new plugin/mode and clear scoped roots
|
||||
ScopedPluginAuditContext(const std::string& plugin_key,
|
||||
AuditMode mode = AuditMode::Loading);
|
||||
// destructor: restore the previous plugin/mode/scoped-roots
|
||||
```
|
||||
|
||||
A context is constructed at the **start of every C++ → Python trampoline call** and
|
||||
destroyed when that call returns or throws. So enforcement is *per call*: outside any
|
||||
trampoline call the mode is just its default and `current_plugin()` is empty, so the hook
|
||||
allows everything.
|
||||
|
||||
### 3. Audit modes — what "strict" means
|
||||
|
||||
```cpp
|
||||
enum class AuditMode {
|
||||
// Permissive reads, restricted writes. Python must be able to read stdlib
|
||||
// modules and the plugin file during import/on-load, so reads are allowed;
|
||||
// only writes outside the allowed roots are blocked.
|
||||
Loading,
|
||||
|
||||
// Restricted reads AND writes: every file path must resolve inside an
|
||||
// allowed root, or it is blocked.
|
||||
Enforcing,
|
||||
};
|
||||
```
|
||||
|
||||
The check that implements this is `PluginAuditManager::check_open(path, mode)`:
|
||||
|
||||
1. Empty path → allow.
|
||||
2. No active plugin (`current_plugin()` empty) → allow.
|
||||
3. `Loading` **and** the open is a read (`mode` has no `w`/`a`/`+`) → allow (early‑out).
|
||||
4. Otherwise the path must resolve inside a **scoped allowed root** or the **global
|
||||
allowed root**, else it is blocked.
|
||||
|
||||
So the only difference between the two modes is step 3: `Loading` lets reads through before
|
||||
the allow‑list check; `Enforcing` does not, so reads are subject to the same allow‑list as
|
||||
writes.
|
||||
|
||||
### Allowed roots
|
||||
|
||||
There are two tiers, checked in this order:
|
||||
|
||||
| Tier | Stored in | Lifetime | Set by |
|
||||
|---|---|---|---|
|
||||
| **Scoped** | thread‑local, cleared on every new context | one call | `add_scoped_allowed_root()` inside an `audit_setup` callback |
|
||||
| **Global** | shared, mutex‑guarded | process | `add_global_allowed_root()` in `install_hook()` |
|
||||
|
||||
In this version the global allow‑list contains **only `data_dir()`**. The executable
|
||||
directory and resources directory are deliberately *not* allowed — plugins must not write
|
||||
there. G‑code plugins additionally get the temp G‑code folder as a *scoped* root for the
|
||||
duration of their `execute()` call.
|
||||
|
||||
Path matching (`is_inside_allowed_root`) canonicalizes both paths with
|
||||
`weakly_canonical` (resolving symlinks without requiring existence) and does a
|
||||
component‑wise prefix match that rejects any `..` traversal.
|
||||
|
||||
### Putting it together — the flow of one call
|
||||
|
||||
```
|
||||
PluginLoader (once) set_audit_plugin_key(plugin_key) // identity stamped
|
||||
│
|
||||
▼
|
||||
C++ calls plugin->execute() ─► trampoline method
|
||||
│ ├─ ScopedPluginAuditContext ctor // mode + plugin set
|
||||
│ ├─ audit_setup() // e.g. add scoped roots
|
||||
│ └─ PYBIND11_OVERRIDE(_PURE) ──► Python runs
|
||||
│ │
|
||||
│ Python does open("/x", "w") ─┤
|
||||
│ ▼
|
||||
│ CPython raises "open" audit event
|
||||
│ │
|
||||
│ PluginAuditManager::audit_hook
|
||||
│ │
|
||||
│ check_open("/x","w") → blocked?
|
||||
│ └─ PyErr_SetString + return -1 ► PermissionError
|
||||
▼
|
||||
trampoline returns ─► ScopedPluginAuditContext dtor // previous state restored
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit hook development
|
||||
|
||||
The point of interest is **`PluginAuditManager.hpp` / `.cpp`** (the modes, the events, and
|
||||
the policy) and the trampoline macros in **`PyPluginTrampoline.hpp`** (how each plugin
|
||||
function opts into a mode).
|
||||
|
||||
### Handling events
|
||||
|
||||
Events are dispatched by name in `PluginAuditManager::audit_hook`. Return `0` to allow;
|
||||
set a Python exception and return non‑zero (we use `-1`) to block:
|
||||
|
||||
```cpp
|
||||
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||||
{
|
||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||||
std::string event_name(event ? event : "");
|
||||
|
||||
if (event_name == "open") {
|
||||
// CPython passes ("open", path, mode, flags)
|
||||
const char* path = nullptr; const char* mode = nullptr; int flags = 0;
|
||||
if (!PyArg_ParseTuple(args, "s|si", &path, &mode, &flags)) {
|
||||
PyErr_Clear();
|
||||
return 0; // couldn't parse — allow
|
||||
}
|
||||
if (!mgr->check_open(path ? path : "", mode ? mode : "r").allowed) {
|
||||
PyErr_SetString(PyExc_PermissionError,
|
||||
"Plugin attempted to access a blocked file path");
|
||||
return -1; // block
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// else if (event_name == "os.rename") { ... } // see below
|
||||
|
||||
return 0; // unhandled event — allow
|
||||
}
|
||||
```
|
||||
|
||||
To audit a new operation, add another `else if` branch. **Each event has its own argument
|
||||
tuple** — you cannot assume `(path, mode, flags)`. Look the event up in the official table
|
||||
and parse accordingly:
|
||||
|
||||
- `os.rename` → `(src, dst, src_dir_fd, dst_dir_fd)`
|
||||
- `os.remove` → `(path, dir_fd)`
|
||||
- `os.mkdir` → `(path, mode, dir_fd)`
|
||||
- `subprocess.Popen` → `(executable, args, cwd, env)`
|
||||
|
||||
The complete, version‑specific list of audit events and their arguments:
|
||||
**https://docs.python.org/3/library/audit_events.html**
|
||||
|
||||
For filesystem mutations you'll usually want to route the extracted path(s) through
|
||||
`check_open(path, "w")` (or a dedicated checker) so they share the same allow‑list logic.
|
||||
|
||||
### Defining the audit mode of a function
|
||||
|
||||
Every C++ → Python plugin call crosses a trampoline method, and those methods wrap the
|
||||
pybind11 override in the `ORCA_PY_OVERRIDE_AUDITED` macro
|
||||
(`PyPluginTrampoline.hpp`). The macro both (a) logs and rethrows Python exceptions at the
|
||||
single boundary and (b) opens the audit context. Its signature:
|
||||
|
||||
```cpp
|
||||
ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, /* args... */)
|
||||
```
|
||||
|
||||
| Param | Meaning |
|
||||
|---|---|
|
||||
| `mode` | `AuditMode::Loading` or `AuditMode::Enforcing` for this call |
|
||||
| `audit_setup` | a callable (often `[] {}`) run *after* the context is constructed — use it to register scoped roots |
|
||||
| `override_macro` | `PYBIND11_OVERRIDE` or `PYBIND11_OVERRIDE_PURE` |
|
||||
| `ret, base, name, …` | the usual pybind11 override arguments |
|
||||
|
||||
When you add a new method to any trampoline, **you must choose its mode** based on what the
|
||||
function legitimately needs:
|
||||
|
||||
```cpp
|
||||
void on_load() override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading, // imports during load → reads allowed
|
||||
[] {}, // no extra setup
|
||||
PYBIND11_OVERRIDE,
|
||||
void, Base, on_load);
|
||||
}
|
||||
```
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- Use **`Loading`** for lifecycle/setup calls that may import modules (`on_load`,
|
||||
`on_unload`, `get_type`) or any call where you only care about restricting writes.
|
||||
- Use **`Enforcing`** for calls that should also be prevented from *reading* outside the
|
||||
allow‑list. Be aware this will block lazily‑imported stdlib/3rd‑party modules read from
|
||||
disk during the call, so only use it where the plugin is not expected to import at call
|
||||
time.
|
||||
|
||||
### Adding per‑call allowed roots (the `audit_setup` callback)
|
||||
|
||||
`ScopedPluginAuditContext`'s constructor **clears** the scoped roots, so any scoped root
|
||||
must be added *after* construction — which is exactly what `audit_setup` is for. The G‑code
|
||||
trampoline uses it to grant write access to the folder holding the current temp G‑code
|
||||
file:
|
||||
|
||||
```cpp
|
||||
ExecutionResult execute(const GCodePluginContext& ctx) override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||||
[&] { // runs only when a context is active
|
||||
if (!ctx.gcode_path.empty())
|
||||
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
|
||||
std::filesystem::path(ctx.gcode_path).parent_path());
|
||||
},
|
||||
PYBIND11_OVERRIDE_PURE,
|
||||
ExecutionResult, GCodePlugin, execute, ctx);
|
||||
}
|
||||
```
|
||||
|
||||
The callback runs only when the instance has a non‑empty audit key (i.e. a context was
|
||||
actually opened), so it's safe to assume enforcement is live inside it.
|
||||
|
||||
### Adding a global allowed root
|
||||
|
||||
If *every* plugin should be allowed a directory, add it in `install_hook()`:
|
||||
|
||||
```cpp
|
||||
void PluginAuditManager::install_hook()
|
||||
{
|
||||
PySys_AddAuditHook(audit_hook, this);
|
||||
add_global_allowed_root(data_dir()); // the only global root today
|
||||
// add_global_allowed_root(std::filesystem::temp_directory_path()); // e.g. to allow /tmp
|
||||
}
|
||||
```
|
||||
|
||||
Prefer scoped roots over global ones — a global root widens the boundary for *all* plugins
|
||||
and is process‑lifetime. Only add a global root when the access is genuinely universal.
|
||||
|
||||
### Identity wiring (rarely touched)
|
||||
|
||||
If you add a new way to load or re‑key plugin instances, make sure the new path also calls
|
||||
`set_audit_plugin_key()` — otherwise the instance has an empty key and **no context is ever
|
||||
opened**, so its calls run completely unaudited. The existing call sites are
|
||||
`PluginLoader::load_plugin_impl()` and `PluginLoader::update_loaded_plugin_key()`.
|
||||
|
||||
---
|
||||
|
||||
## Current policy at a glance
|
||||
|
||||
| Plugin call | Mode | Effective access |
|
||||
|---|---|---|
|
||||
| `on_load` / `on_unload` / `get_type` | `Loading` | read anywhere; write only under `data_dir()` |
|
||||
| G‑code `execute()` | `Loading` | + write under the current temp G‑code folder |
|
||||
| Script `execute()` | `Loading` | read anywhere; write only under `data_dir()` |
|
||||
| Printer‑agent methods | `Loading` | read anywhere; write only under `data_dir()` |
|
||||
|
||||
> Modes are chosen at each trampoline call site, so this table reflects the current source —
|
||||
> always check the actual `ORCA_PY_OVERRIDE_AUDITED(...)` call when in doubt.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
This version is deliberately minimal. Do **not** treat it as a hardened sandbox. Known gaps:
|
||||
|
||||
- **Only the `open` event is enforced.** `subprocess.Popen`, `os.system`, `socket.*`,
|
||||
`ctypes.*` and friends are *not* blocked. (The `Enforcing` enum comment describes an
|
||||
aspiration, not current behavior.)
|
||||
- **`os.open` slips through.** It raises the `open` event with `mode = None`, so the
|
||||
`"s|si"` parse fails and the call is allowed. Low‑level opens are currently unaudited.
|
||||
- **`open(path, "x")`** (exclusive create — a write) contains no `w`/`a`/`+`, so it is
|
||||
classified as a read and allowed under `Loading`.
|
||||
- **Non‑`open` filesystem mutations are unaudited.** `os.remove`, `os.rename`, `os.mkdir`,
|
||||
`shutil.*` raise their own events, which we don't yet handle — a plugin can delete or
|
||||
rename files outside `data_dir()` without tripping anything.
|
||||
- Enforcement is **per process / per thread** via thread‑locals; code that hops threads
|
||||
without re‑establishing a context runs unaudited.
|
||||
|
||||
Closing these gaps (especially the filesystem‑mutation events and `os.open` flags) is the
|
||||
natural next step for anyone hardening this into a real write‑sandbox.
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
Enforcement only fires while a context is active, and the read/write distinction trips
|
||||
people up, so when something is unexpectedly blocked (or unexpectedly allowed), get the
|
||||
facts first.
|
||||
|
||||
**Temporary block log.** `check_open` logs each block just before returning, including the
|
||||
mode that was actually live:
|
||||
|
||||
```
|
||||
[AUDIT] block path=/tmp open_mode=w audit_mode=Loading plugin=local:.../Environment_Report_Script_
|
||||
```
|
||||
|
||||
Read it field by field:
|
||||
|
||||
- `open_mode=w` → it's a **write**. Under `Loading`, writes outside the allow‑list are
|
||||
*supposed* to be blocked. A blocked `open_mode=r` under `audit_mode=Loading` is
|
||||
impossible from current source — if you see it, your binary is stale (see below).
|
||||
- `audit_mode=` → tells you whether the live call site is `Loading` or `Enforcing`, which
|
||||
is the quickest way to confirm a trampoline change actually took effect.
|
||||
- `path=` → the resolved path that failed the allow‑list. Compare against `data_dir()`.
|
||||
|
||||
The permanent `report_violation` log (`[AUDIT BLOCKED] …`) fires on the same blocks and
|
||||
includes the plugin key, event name, path, and reason.
|
||||
|
||||
**Common pitfalls**
|
||||
|
||||
- **Read vs write.** `Loading` never blocks a read. If a "read" is blocked, it's actually a
|
||||
write (check `open_mode`), or the mode is `Enforcing`.
|
||||
- **Stale / incremental builds.** `PyPluginTrampoline.hpp` and `PluginAuditManager.hpp` are
|
||||
included by many translation units. A header‑only change (e.g. flipping a trampoline's
|
||||
mode) may not propagate with an incremental build. If runtime behavior contradicts the
|
||||
source, do a clean rebuild of the affected targets. `PluginAuditManager.cpp` changes are
|
||||
a single‑TU recompile + relink.
|
||||
- **No context = no enforcement.** If a plugin's calls are never audited, check that its
|
||||
instance got `set_audit_plugin_key()` (non‑empty key) and that the method actually wraps
|
||||
through `ORCA_PY_OVERRIDE_AUDITED`.
|
||||
|
||||
---
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | modes, allowed roots, `audit_hook`, `check_open`, `ScopedPluginAuditContext` |
|
||||
| `src/slic3r/plugin/PyPluginTrampoline.hpp` | the `ORCA_PY_*` macros (logging + audit context) |
|
||||
| `src/slic3r/plugin/PythonPluginInterface.hpp` | the per‑instance audit identity |
|
||||
| `src/slic3r/plugin/PluginLoader.cpp` | stamps the audit key at load / key migration |
|
||||
| `src/slic3r/plugin/pluginTypes/*/*Trampoline.hpp` | per‑plugin‑type methods and their chosen modes |
|
||||
| `src/slic3r/plugin/PythonInterpreter.cpp` | installs the hook once at interpreter init |
|
||||
Reference in New Issue
Block a user