pyintents
Declarative capability-based access control for Python functions.
Explore the docs »Getting Started · Basic Usage · Latest Documentation · License
Overview
PyIntents brings capability-based security to Python through declarative intents.
The library allows functions to explicitly declare what they are allowed to do using an @intent decorator. PyIntents enforces these permissions at call time by performing static analysis on the function's source code and its entire call graph. It validates every call in the chain against the declared policy before any code is executed.
This approach provides a fail-closed security model: by default, anything not explicitly allowed is denied. Dynamic primitives such as eval, exec, getattr, and globals are blocked by default. Unknown or unresolvable calls are treated as violations unless explicitly permitted.
PyIntents is useful for:
- Plugin systems and sandboxes
- AI agent tool control and capability restriction
- Environment-specific security policies
- Testing and dependency isolation
- Security audits and explicit capability boundaries
Trust explicitly. Fail safely.
PyIntents is not a full operating-system-level sandbox. It is a declarative policy layer for Python functions. For strong isolation of untrusted code, combine it with process isolation, containers, WASM, or a dedicated sandboxing solution.
Architecture
PyIntents consists of three main components:
1. Introspection Module (introspect.py)
This module is responsible for parsing source code and extracting call information.
_get_function_ast()retrieves the AST of a function usinginspect.getsource()andast.parse(). If source code is unavailable, it raisesIntentParseError._OuterCallCollectoris an AST visitor that traverses the function body and collects:- Call locations (
CallLocation) with target name, line number, and dynamic flag - Local function definitions for deferred analysis
- Shadowing protection to prevent assignment to protected names
- Call locations (
_format_target()converts AST call expressions to textual representations and detects dynamic calls.SafeResolverresolves dotted names to callable objects without executing descriptors or properties. It usesinspect.getattr_static()and validates intermediate objects as modules or classes.
2. Call Graph Construction (CallTree)
CallTree builds a directed graph of function calls.
CallNoderepresents a node in the call graph, storing identity, call name, resolved function reference, position, and state flags (is_local_definition,is_dynamic,is_unresolved,is_source_available,is_cycle).CallTreerecursively traverses calls up to a configurable depth.- Local functions are analyzed when called within the parent scope.
- External functions are resolved through
SafeResolver. - Cycle detection prevents infinite recursion during traversal.
3. Policy Engine (namespace.py)
IntentNamespace defines and enforces security policies.
IntentNamespace.__init__()configures the policy with parameters:uses: allowlist of permitted functionsdeny: blocklist of forbidden functionswithout: exempt from allowlist checksrecursive: enable recursive validationuselocals: allow local nested functionsusemodule: allow functions from the same moduleallow_unknown: permit unresolved or dynamic callsdeny_dynamic_primitives: block dynamic primitives by defaultonly_warnings: emit warnings instead of raising exceptions
RuleSetnormalizes rules into four forms: objects, identities (module:qualname), full names (module.qualname), and short names.@namespace.intent()decorator wraps functions and validates them on each call._validate_tree()traverses the call graph and checks each node against the policy.
4. Exceptions (exceptions.py)
IntentError: base exception classIntentViolationError: raised when a function violates declared permissionsIntentParseError: raised when source code cannot be parsedIntentConfigurationError: raised for invalid configurationIntentShadowingError: raised when a protected name is shadowed in local scope
Getting Started
Installation
pip install pyintents
Python 3.12+ is recommended.
Quick Example
from pyintents import IntentNamespace
# Allow only print()
namespace = IntentNamespace(uses=[print])
@namespace.intent()
def safe_function():
print("This is allowed") # OK
@namespace.intent()
def unsafe_function():
import os
os.system("echo bad") # IntentViolationError
By default, PyIntents validates the function before execution. If a forbidden or unknown call is found anywhere in the statically visible call chain, the decorated function is not executed.
Basic Usage
1. Allow Specific Functions
from pyintents import IntentNamespace
namespace = IntentNamespace(uses=[print, len])
@namespace.intent()
def my_func():
print("Hello") # Allowed
return len([1]) # Allowed
Rules can be specified as callable objects or strings:
namespace = IntentNamespace(uses=["print", "len"])
2. Recursive Enforcement
Recursive validation is enabled by default. PyIntents checks not only the decorated function but also every function it calls, and every function those call, and so on.
namespace = IntentNamespace(uses=[print])
def helper():
print("Inside helper")
@namespace.intent(uses=[helper])
def main():
helper()
helper is validated recursively. If helper called os.system, the violation would be detected.
3. Allow Functions From the Same Module
If your module has many internal helper functions, allowing each one manually can be tedious. Use usemodule=True:
namespace = IntentNamespace(
uses=[print],
recursive=True,
usemodule=True,
)
def inner():
print("Inner")
def outer():
print("Outer")
inner()
@namespace.intent()
def func():
outer()
With usemodule=True:
- Functions defined in the same module as the decorated function are automatically allowed
- Their calls are still recursively validated
- Functions from other modules are not automatically allowed
usemodulerequiresrecursive=True
4. Allow Local Nested Functions
uselocals=True allows functions defined inside the decorated function:
namespace = IntentNamespace(
uses=[print],
uselocals=True,
)
@namespace.intent()
def main():
def local_helper():
print("Local helper")
local_helper()
Important:
uselocals=Trueallows nested local functions as call targets- It does not automatically allow module-level global functions
- The contents of local functions are still validated recursively
For module-level helpers, use usemodule=True or explicit uses=[...].
5. Exempt Trusted Functions From Allowlist Checks
without exempts a function from allowlist checks, but deny rules are still enforced:
def helper():
print("OK")
namespace = IntentNamespace(
uses=[print],
without=[helper],
)
@namespace.intent()
def main():
helper()
Important: without does not mean "ignore everything inside this function forever." It means:
- This function does not need to be explicitly allowed by
uses - Forbidden calls inside it can still be rejected
- The function's body is still recursively validated
6. Explicit Denial
import os
from pyintents import IntentNamespace
namespace = IntentNamespace(
uses=[print],
deny=[os.system],
)
@namespace.intent()
def restricted():
print("OK")
os.system("echo bad") # Explicitly denied
You can also use string rules:
namespace = IntentNamespace(
uses=[print],
deny=["os.system"],
)
Deny rules have priority over allow rules. If a function appears in both uses and deny, it is denied.
7. Runtime Layering
Decorator-level rules extend or override namespace-level rules:
base = IntentNamespace(uses=[print])
@base.intent(uses=[len])
def layered_func():
print("Hi")
return len("world")
The decorated function inherits print from the namespace and adds len as an additional allowed call.
8. Unknown and Dynamic Calls Are Blocked by Default
PyIntents is fail-closed by default. Unknown calls are denied unless explicitly allowed.
Dynamic primitives such as:
eval
exec
compile
__import__
getattr
setattr
delattr
globals
locals
vars
breakpoint
are denied by default.
You can disable this behavior with:
IntentNamespace(deny_dynamic_primitives=False)
but this is discouraged as it weakens security.
9. Warning Mode
Use only_warnings=True to emit warnings instead of raising exceptions:
import warnings
warnings.simplefilter("always")
namespace = IntentNamespace(
uses=[print],
only_warnings=True,
)
def helper():
import os
os.system("echo warning")
@namespace.intent()
def func():
print("Hello")
helper()
func() # Executes but prints a warning about os.system
This is useful for auditing existing codebases before enforcing strict policies.
10. Override Namespace Defaults Per Function
namespace = IntentNamespace(
uses=[print],
recursive=True,
usemodule=False,
)
@namespace.intent(
usemodule=True,
allow_unknown=True,
)
def custom():
pass
Rule Specification
Rules can be specified in several formats:
| Format | Example | Description |
|---|---|---|
| Callable object | print |
Direct function reference |
| String with colon | "builtins:print" |
Module:qualname identity |
| Dotted string | "os.system" |
Full module.attribute name |
| Bare string | "system" |
Short name (least precise) |
When a string rule contains a colon, it is treated as an identity (module:qualname). When it contains a dot but no colon, it is treated as a full name (module.qualname). Otherwise, it is treated as a short name.
Matching Priority
Rules are matched in the following order:
- Object reference (requires hashable callable)
- Identity (
module:qualname) - Full name (
module.qualname) - Short name (last part after dot)
This priority ensures precise matching when available and fallback matching for convenience.
Exceptions
IntentViolationError
Raised when a function violates declared permissions:
from pyintents import IntentNamespace, IntentViolationError
namespace = IntentNamespace(uses=[print])
@namespace.intent()
def bad():
import os
os.system("echo bad")
try:
bad()
except IntentViolationError as exc:
print(exc) # Function 'bad' calls forbidden 'os.system'
The exception includes the full call path, e.g., Function 'func -> outer -> inner' calls forbidden 'os.system'.
IntentParseError
Raised when function source code is unavailable or cannot be parsed:
from pyintents.exceptions import IntentParseError
This can occur for built-in functions, C extensions, or functions defined interactively.
IntentConfigurationError
Raised when namespace or decorator configuration is invalid:
from pyintents.exceptions import IntentConfigurationError
For example, enabling usemodule=True without recursive=True.
IntentShadowingError
Raised when a protected name is shadowed in the local scope:
namespace = IntentNamespace(uses=[print])
@namespace.intent()
def bad():
print = os.system # IntentShadowingError
print("echo")
This prevents the common Python attack pattern of reassigning a trusted name to a malicious function.
API Reference
IntentNamespace
IntentNamespace(
uses=None,
*,
recursive=True,
without=None,
uselocals=False,
usemodule=False,
deny=None,
allow_unknown=False,
deny_dynamic_primitives=True,
only_warnings=False,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
uses |
Iterable[Callable or str] |
None |
Explicitly allowed functions or names |
recursive |
bool |
True |
Recursively validate called functions |
without |
Iterable[Callable or str] |
None |
Exempt from allowlist checks only |
uselocals |
bool |
False |
Allow nested functions defined inside the decorated function |
usemodule |
bool |
False |
Allow functions from the same module. Requires recursive=True |
deny |
Iterable[Callable or str] |
None |
Explicitly forbidden functions or names |
allow_unknown |
bool |
False |
Allow unresolved or opaque calls |
deny_dynamic_primitives |
bool |
True |
Deny dynamic primitives like eval, exec, getattr, etc. |
only_warnings |
bool |
False |
Emit warnings instead of raising exceptions |
@namespace.intent()
Overrides or extends namespace settings per function:
@namespace.intent(
uses=[print],
recursive=True,
without=[helper],
uselocals=True,
usemodule=True,
deny=[os.system],
allow_unknown=False,
)
def custom_func():
pass
Available parameters match those of IntentNamespace.__init__.
Security Model
PyIntents follows a fail-closed model with explicit trust:
- Only explicitly allowed calls are permitted
- Recursive validation is enabled by default
- Unknown calls are denied
- Dynamic primitives are denied by default
- Functions without available source code are treated with caution
- Shadowing of protected names is blocked
- Violations prevent execution
Pre-Execution Validation
PyIntents validates the call chain before execution. This means:
@namespace.intent()
def func():
print("Func")
outer()
If outer eventually calls something forbidden, func will not execute at all. This prevents partially executed functions from producing side effects before a violation is detected.
Shadowing Protection
PyIntents protects against local shadowing of allowed names:
# This is blocked
@namespace.intent()
def bad():
print = os.system
print("echo Hello")
# This is also blocked
@namespace.intent()
def bad():
from os import system as print
print("echo Hello")
Without this protection, an attacker could reassign print to os.system and bypass the allowlist.
Limitations
PyIntents is a static and runtime policy layer, not a complete sandbox.
Python is highly dynamic, so some behavior cannot be fully analyzed statically:
getattr(os, "system")("echo bad")
eval("os.system('echo bad')")
globals()["os"].system("echo bad")
PyIntents mitigates many of these cases by denying dynamic primitives by default, but no AST-only solution can guarantee complete isolation.
For strong security boundaries, use:
- Subprocesses with restricted permissions
- Containers
- seccomp
- WASM
- RestrictedPython
- Custom import hooks
- Runtime monitoring
Performance Considerations
Currently, CallTree is built on every call to a decorated function. This is acceptable for functions called infrequently but may impact performance in hot code paths. Future versions may add caching to reuse the call graph across invocations.
Source Code Requirement
PyIntents requires source code to be available for analysis. Built-in functions, C extensions, and functions defined interactively cannot be analyzed. Such calls are either blocked or treated as unknown depending on policy settings.
Dynamic Code Execution
PyIntents cannot analyze strings passed to eval or exec. Even if eval is denied by default, allowing it through uses creates a bypass for all other restrictions.
Use Cases
| Use Case | Description |
|---|---|
| Plugin Sandboxes | Restrict what third-party plugins can do within your application |
| AI Agent Control | Limit tool access for LLM-powered agents |
| Environment Policies | Enforce different rules per deployment environment |
| Testing | Isolate unit tests from external dependencies and system calls |
| Security Audits | Document and enforce capability boundaries in your codebase |
| Internal APIs | Prevent accidental access to dangerous internal helpers |
| Privilege Separation | Minimize the attack surface of privileged functions |
How It Works
PyIntents performs static policy validation before the decorated function is executed.
The pipeline is:
-
AST Parsing PyIntents parses the function source code using Python's
astmodule. -
Call Graph Construction It builds a tree of statically visible function calls. Local functions are deferred until called. External functions are resolved safely.
-
Safe Resolution Call names are resolved to actual function objects when possible using
inspect.getattr_static()without executing descriptors. -
Shadowing Detection The AST is analyzed for assignments or imports that shadow protected names.
-
Rule Matching Each call is validated against:
usesallowlistdenyblocklistwithoutexemptionsuselocalslocal function policyusemodulesame-module policy- Unknown-call policy (
allow_unknown) - Dynamic-primitive policy
-
Pre-Execution Enforcement If a violation is found, the decorated function is not executed. An exception is raised (or a warning is emitted if
only_warnings=True).
Example call path:
func -> outer -> inner -> os.system
If os.system is forbidden or unknown, PyIntents blocks the root call to func before any code inside func runs.
Documentation
License
Licensed under the GNU General Public License v3.0.
See LICENSE for details.
Contributing
Contributions are welcome.
Feel free to:
- Open issues for bugs or feature requests
- Submit pull requests with improvements
- Suggest new features or use cases
- Improve documentation
- Report security concerns
Development
# Clone the repository
git clone https://github.com/alexeev-prog/pyintents.git
cd pyintents
# Install development dependencies
pip install -e .[dev]
# Run tests
pytest
# Run linting
ruff check .
mypy .
Support
If you find PyIntents useful, consider:
- Starring the repository on GitHub
- Reporting issues
- Suggesting features
- Improving documentation
- Sharing it with others who might benefit
Trust explicitly. Fail safely.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyintents-0.3.0.tar.gz.
File metadata
- Download URL: pyintents-0.3.0.tar.gz
- Upload date:
- Size: 14.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
04de5a86749a22f695866c0b74e16d3e04891aec41fb4b375964840d3e7b37fa
|
|
| MD5 |
db16911f1b7ed1c298cf0b3d146efbfb
|
|
| BLAKE2b-256 |
8811ad438893fc16ce687e3dff2cb39403307977b05808032bd200761748383d
|
File details
Details for the file pyintents-0.3.0-py3-none-any.whl.
File metadata
- Download URL: pyintents-0.3.0-py3-none-any.whl
- Upload date:
- Size: 16.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b7c0cf12dfa914960c11cc536b0cb954d099549155a2dd3555f5acd87a470fee
|
|
| MD5 |
002136628bc8e05c99212e597c03c172
|
|
| BLAKE2b-256 |
d583edf9e5a26da92d808a08eb908c9a755e0a74dff25c2173f7332c73f8f39c
|