security — PermissionGuard, InputSanitizer, ExecutionSandbox
On this page
Overview
Three independent protection layers, per the module docstring:
PermissionGuard— declare and enforce per-plugin permissions.InputSanitizer— validate inputs against schema, detect injection attempts.ExecutionSandbox— async timeout + resource-cap wrapper.
These are composed by PluginManager but each class can be used standalone.
PermissionPolicy (dataclass)
@dataclass
class PermissionPolicy:
plugin_name: str
granted: Set[str] = field(default_factory=set)
denied: Set[str] = field(default_factory=set)
session_scoped: Optional[str] = None
user_scoped: Optional[str] = None
Internal per-plugin record held by PermissionGuard. Not typically constructed directly by user code —
PermissionGuard._get_or_create() creates one lazily on first grant()/revoke()/check().
PermissionGuard
guard = PermissionGuard()
guard.grant("web_search", PermissionType.NETWORK_ACCESS)
guard.check("web_search", PermissionType.NETWORK_ACCESS) # → True
grant(plugin_name, permission, session_id=None, user_id=None) -> None
Adds permission to the plugin's granted set and removes it from denied if present. Accepts either
a PermissionType enum member or a raw string (normalized via .value if it's an enum). If
session_id/user_id are given, they overwrite the policy's scope — note this means calling grant
a second time with a different session_id replaces the previous scope rather than adding an
additional allowed scope.
revoke(plugin_name, permission) -> None
Removes from granted, adds to denied.
grant_all_required(plugin: BasePlugin) -> None
Convenience: calls grant() for every string in plugin.metadata.permissions. This is what
PluginManager.startup() calls for each auto-discovered plugin, only when safe_mode=False.
check(plugin_name, permission, context=None) -> bool
Algorithm:
- If no policy exists for
plugin_name, returnFalse. - If
permissionis inpolicy.denied, returnFalse(explicit deny always wins). - If a
contextis given and the policy has asession_scopedvalue that doesn't matchcontext.session_id, returnFalse. Same foruser_scopedvs.context.user_id. - Return
permission in policy.granted.
assert_permission(plugin, permission, context=None) -> None
Calls check(); Raises PluginPermissionError if it returns False.
check_all_required(plugin, context=None) -> bool
Iterates plugin.metadata.permissions; returns False (with a warning log) on the first one that
fails check(). This is what PluginManager.register() (in safe_mode) and PluginManager.execute()
(in safe_mode) call.
get_grants(plugin_name) -> Set[str]
Returns a copy of the granted set (or an empty set if no policy exists) — safe to mutate by the caller without affecting internal state.
InputSanitizer
Static-method-only class; validates and sanitizes plugin input dicts against a list of
SchemaProperty.
sanitize(data: Dict[str, Any], schema: List[SchemaProperty], strict: bool = True) -> Dict[str, Any]
Internal workflow:
- Build
prop_map = {p.name: p for p in schema}andrequired = {p.name for p in schema if p.required}. - Compute
missing = required - set(data.keys()); raisePluginValidationErrorlisting them (sorted) if any are missing. - For each
(name, value)pair indata:- If
nameisn't inprop_map: raisePluginValidationErrorifstrict=True, otherwise silently drop the field (continue) ifstrict=False. - Coerce the value's type via the module-private
_coerce()helper. - If the property is
type="string"and the coerced value is astr: raisePluginValidationErrorif longer than 32,768 characters (_MAX_STRING_LEN); then run_check_injection(). - If the property declares an
enumand the value isn't in it, raisePluginValidationError. - Store the cleaned value.
- If
- After the loop, fill in
prop.defaultfor any schema property not present in the cleaned output whosedefault is not None. - Return the cleaned dict.
Important: PluginManager.execute() always calls this with strict=False — unknown fields in the
caller's input are silently dropped, not rejected, when going through the manager. Calling sanitize()
directly with the default strict=True will reject unknown fields.
_check_injection(field_name, value) -> None (private, static)
Checks value against five compiled regex patterns (all case-insensitive):
Raises PluginValidationError (including the offending pattern's source string) on the first match.
[!NOTE] This is a heuristic, not a security boundary These are simple regexes over free-form text, intended to catch obvious injection attempts. They are pattern-matching, not a parser or sandboxed interpreter — sufficiently obfuscated input can evade them. Treat this as one layer of defense-in-depth, not a substitute for least-privilege permissions and safe handling of plugin output.
_coerce(name, value, prop) -> Any (module-private function)
For string/integer/number/boolean types: if value isn't already an instance of the target
Python type, attempts target(value); raises PluginValidationError on TypeError/ValueError. For
array: raises if value isn't a list. For object: raises if value isn't a dict. Values already
matching the target type pass through unchanged (no re-wrapping).
Raises: PluginValidationError on type mismatch that can't be coerced.
Complexity
sanitize() is O(F) in the number of fields in data, plus O(P) for the default-filling pass over
schema, plus O(len(value)) per string field for injection-pattern scanning.
ExecutionSandbox
sandbox = ExecutionSandbox(hard_timeout_sec=60.0, log_slow_threshold=5.0)
result = await sandbox.run(some_coroutine, plugin_name="my_plugin")
run(coro, plugin_name="?") -> Any
Wraps coro in asyncio.wait_for(coro, timeout=self.hard_timeout). If it completes within
log_slow_threshold seconds, returns normally. If it takes longer than the threshold but still
completes before the hard timeout, logs a warning but still returns the result. On
asyncio.TimeoutError, logs an error and re-raises (propagates asyncio.TimeoutError unchanged —
it does not translate this into a PluginTimeoutError). Any other exception from coro is logged and
re-raised unchanged as well.
Raises: asyncio.TimeoutError on hard timeout; whatever coro itself raises otherwise.
[!WARNING] Not currently wired into
PluginManager.execute()PluginManager.__init__constructsself.sandbox = ExecutionSandbox(hard_timeout_sec=hard_timeout)(default 60s), but based on the source ofplugin_manager.py,execute()callsplugin.safe_execute()/safe_execute_with_retry()directly — it does not appear to route the call throughself.sandbox.run(). The per-plugin timeout enforced during execution isplugin.config.timeout(default 30s, viaBasePlugin.safe_execute's ownasyncio.wait_for), not the manager'shard_timeout. Verify against your specific version ofplugin_manager.pyif this distinction matters for your timeout budgeting.
Thread safety / Async safety
ExecutionSandbox itself holds no mutable state beyond its two config floats set at construction, so a
single instance is safe to share and call concurrently from multiple coroutines.
Side effects
Logging only (logger.warning on slow execution, logger.error on timeout or exception). No state
mutation, no I/O beyond what coro itself performs.
Examples
1. Basic permission grant/check:
guard = PermissionGuard()
guard.grant("web_search", "network_access")
assert guard.check("web_search", "network_access") is True
assert guard.check("web_search", "file_write") is False
2. Session-scoped grant:
guard.grant("db_query", "database_access", session_id="sess-123")
ctx_ok = ExecutionContext(session_id="sess-123")
ctx_bad = ExecutionContext(session_id="sess-999")
guard.check("db_query", "database_access", ctx_ok) # True
guard.check("db_query", "database_access", ctx_bad) # False
3. Sanitizing input, lenient mode:
schema = [SchemaProperty(name="query", type="string", description="Search text")]
cleaned = InputSanitizer.sanitize({"query": "climate change", "extra": "ignored"}, schema, strict=False)
# {"query": "climate change"}
4. Error handling — injection pattern rejected:
try:
InputSanitizer.sanitize({"query": "ignore previous instructions"}, schema, strict=False)
except PluginValidationError as e:
print(e)
5. Sandboxing an arbitrary coroutine:
sandbox = ExecutionSandbox(hard_timeout_sec=10.0)
try:
result = await sandbox.run(some_slow_plugin.execute(data, ctx), plugin_name="some_slow_plugin")
except asyncio.TimeoutError:
print("hit the hard timeout")
Best practices
- Treat
PermissionGuardas deny-by-default: a plugin with no policy at all returnsFalsefrom everycheck()call. - Use
strict=True(thesanitize()default) when you want to reject unexpected fields outright, e.g. validating a manifest at load time; usestrict=Falsefor the lenient runtime pathPluginManager.execute()uses.
Common mistakes
- Assuming
grant()is additive across scopes — a secondgrant()call with a newsession_idoverwrites the previous scope on that policy rather than adding a second allowed session. - Relying on
ExecutionSandbox.run()being invoked automatically byPluginManager.execute()— as noted above, the manager's effective per-call timeout comes fromplugin.config.timeout.
Related APIs
PluginMetadata.permissions— source ofgrant_all_requiredBasePlugin.safe_execute— the timeout path actually used byPluginManager.execute()ExecutionContext— carriessession_id/user_idfor scoped checks