Однажды на просторах интернета я нашел интересную концепцию — Capability-based Security. Это подход, при котором разрешения управляют доступом к функциям или ресурсам. Я решил не распыляться на изучение концепции целиком и сразу, а разобрать для начала конкретное понятие — интенты.
Интенты (декларация намерений) — это декларативное объявление ресурсов, то есть указание того, какие именно возможности или права понадобятся функции или модулю для работы.
Я загорелся идеей попробовать реализовать такую функциональность в Python через собственную библиотеку. Это отличный способ изучить шаблоны проектирования и работу с абстрактным синтаксическим деревом (AST).
Python — динамичный и мощный язык, но эта мощь нередко становится источником уязвимостей. Моя идея заключается в том, чтобы с помощью декоратора функция декларировала необходимые ресурсы, а библиотека блокировала вызовы неразрешенных функций.
Идея звучит жизнеспособно и отлично подходит для создания систем плагинов, проектирования AI-агентов или фреймворков для тестирования. В этой статье я расскажу о capability-based security и мы попробуем решить дилемму: как дать функции только те привилегии, которые ей необходимы, и ничего лишнего.
Перед тем как начать, хочу показать, что у меня получилось. Посмотрите на следующий код:
import os
from pyintents import IntentNamespace
main_namespace = IntentNamespace(
uses=[print], # декларация разрешенных функций
recursive=True, # рекурсивное применение интентов к вызываемым функциям
usemodule=True, # автоматическое разрешение вызова функций нашего модуля, для удобства
)
def inner() -> None:
os.system("echo Inner")
def outer() -> None:
print("Outer")
inner()
@main_namespace.intent()
def func() -> None:
print("Func")
outer()
if __name__ == "__main__":
try:
func()
except Exception as exc:
print(f"Error: {exc}")
При запуске эта программа выведет всего одну строку: Error: Function 'func -> outer -> inner' calls forbidden 'os.system'.
В этой статье мы подробно разберем, как устроена библиотека изнутри и как реализовать такую функциональность.
Capability-based Security: безопасность, основанная на возможностях
Для начала давайте погрузимся в теоретическую часть, чтобы понять, откуда пришла эта идея и как она устроена. Если попытаться описать концепцию одной фразой, то это подход, при котором доступ к ресурсу (файлу, сетевому сокету, памяти, устройству) дается не по имени владельца (кто вы), а по наличию у вас в руках специального «ключа-пропуска» (capability), который нельзя подделать, украсть или использовать не по назначению.
Если говорить метафорами, то ресурсы — это квартиры. Вместо того чтобы пускать только хозяина (даже если он пришел без ключей), мы пускаем любого, у кого есть ключ-карта. Capability — ссылка на объект, которая не может быть подделана.
Основные принципы этой системы:
- отсутствие глобального пространства имен;
- невозможность запросить доступ «по праву рождения» (например, из-за статуса root или родительского процесса);
- принцип минимальных прав «из коробки»;
- передаваемость capability между компонентами.
Самой этой концепции уже много лет. Все началось в 1966 году, когда Джек Деннис и Эрл Ван Хорн в MIT представили саму концепцию capability. Они работали над многопользовательскими системами и хотели покончить с матрицами доступа, которые плохо масштабировались.
В 1970-х годах появились первые системы-пионеры:
- CAP computer (Cambridge) — аппаратная реализация capability на уровне тегированной памяти;
- Hydra (CMU) — операционная система, в которой разработчики строили защиту вокруг capability, а не списков контроля доступа (ACL);
- Multics — попытка построить кольцевую защиту, пусть и без чистых возможностей

IBM System/38 (позже AS/400) — это единственная коммерческая архитектура с аппаратными capability на уровне микрокода. Каждый указатель в ней представлял собой capability с тегом — архитектурный прорыв, о котором сегодня незаслуженно забыли.

В 1990-х и 2000-х годах появился языковой подход, примерами которого стали языки программирования E и Joule.
В 2010–2020-х годах область применения этого концепта только расширялась: в 2013 году браузер Google Chrome получил изолированные вкладки через механизмы capabilities, а в 2016 году появилась Fuchsia от Google — операционная система с capability-моделью от ядра до пользовательского интерфейса. К 2018 году в WebAssembly также появились capabilities для безопасного выполнения браузерного кода.
Переносим концепцию в Python
Но что общего у всей этой истории с современной разработкой на Python?
В классических системах capability — это физический объект или защищенная ссылка в памяти. Но в Python у нас нет аппаратной поддержки, и мы не можем просто так раздавать «неподделываемые ключи» от os.system. К тому же Python — это язык с глобальным пространством имен, динамической диспетчеризацией и возможностью менять поведение в рантайме. Как же быть?
Здесь мы приходим к концепции интентов. Если capability — это объект-ключ, то intent — это декларация намерения: «Я собираюсь использовать этот набор функций». То есть мы смещаем фокус на то, что требуем от разработчика явно декларировать используемые права в нашей функции. Проверка происходит не во время выполнения, а до него, через статический анализ AST.
В этом и заключается мое решение: вместо того чтобы раздавать capabilities в процессе выполнения (что в Python почти невозможно реализовать безопасно), мы анализируем код функции и весь граф ее вызовов, сверяя их с объявленными интентами.
Допустим, у нас есть функция:
def dangerous():
import os
os.system("rm -rf /")
Мы как разработчики знаем, что она опасна. Но как объяснить это интерпретатору? Мы можем завернуть ее в декоратор и объявить интенты:
from pyintents import IntentNamespace
ns = IntentNamespace(uses=[print])
@ns.intent()
def safe():
print("Hello") # Разрешено, print есть в uses
@ns.intent()
def unsafe():
import os
os.system("rm -rf /") # Нарушение, os.system не в uses
При вызове функции unsafe() библиотека PyIntents парсит исходный код с помощью модуля ast, строит дерево вызовов (Call Tree), включая все вложенные вызовы, и проверяет каждый узел дерева. Если библиотека обнаруживает вызов функции, которой нет в uses (или которая указана в deny), она вызывает исключение.
Выполнение кода прервется еще до его фактического запуска, если библиотека обнаружит нарушение.
Но почему именно интенты, а не что-то еще? В Python, конечно, уже есть похожие проекты, например RestrictedPython, который выполняет код в ограниченной среде, подменяя глобальные переменные и запрещая опасные конструкции. Но это тяжеловесное решение, которое меняет семантику языка. Самостоятельное же написание проверок вручную и внедрение контекстных менеджеров — неудобный подход, о котором легко забыть.
Разбор архитектуры
Достаточно теории, пришло время практики. Библиотека состоит из трех основных частей: интроспекция (сбор информации о коде), построение графа вызовов и движок политик. Она доступна для установки из репозитория PyPI командой pip install pyintents.
А полный исходный код вы можете найти в моем репозитории. В статье я не буду прикладывать его целиком, расскажу только о ключевых функциях и классах.
introspect.py
Сердце библиотеки — модуль introspect.py. Он отвечает за парсинг исходного кода и извлечение информации о вызовах.
Вся магия начинается с функции _get_function_ast, которая получает AST исследуемой функции. Метод принимает вызываемый объект (callable), пытается получить его исходный код через inspect.getsource, нормализует отступы через textwrap.dedent и парсит через ast.parse. Если исходный код недоступен, библиотека вызывает исключение IntentParseError.
def _get_function_ast(func: Callable[..., Any]) -> FunctionDefLike:
func_name = getattr(
func,
"__qualname__",
getattr(func, "__name__", repr(func)),
)
try:
source = inspect.getsource(func)
except (OSError, TypeError) as exc:
raise IntentParseError(
func_name,
"source code is not available",
) from exc
try:
module = ast.parse(textwrap.dedent(source))
except (SyntaxError, IndentationError) as exc:
raise IntentParseError(
func_name,
f"invalid source: {exc}",
) from exc
target_name = getattr(func, "__name__", None)
for node in module.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if target_name is None or node.name == target_name:
return node
for node in ast.walk(module):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if target_name is None or node.name == target_name:
return node
raise IntentParseError(
func_name,
"function definition was not found in source",
)
Функция сначала ищет определение в теле модуля, а если не находит — обходит все дерево целиком через ast.walk. Это нужно для случаев, когда функция определена внутри условных конструкций или классов. Если исходный код недоступен — мы не можем анализировать функцию, и это честно сообщается через исключение.
Ключевая структура данных — _OuterCallCollector, наследник ast.NodeVisitor. Этот класс обходит AST и собирает только вызовы из тела функции, но намеренно не спускаясь во вложенные функции. Почему? Дело в том, что интерпретатор не выполняет вложенные функции в момент вызова внешней, если не вызвать их явно. Мы записываем их отдельно и анализируем только при непосредственном вызове.
class _OuterCallCollector(ast.NodeVisitor):
def __init__(self, protected_names: set[str] | None = None) -> None:
self.calls: list[CallLocation] = []
self.local_functions: dict[str, FunctionDefLike] = {}
self.protected_names: set[str] = protected_names or set()
def _check_shadowing(self, node: ast.AST) -> None:
protected_short_names = {p.rsplit(".", 1)[-1] for p in self.protected_names}
if isinstance(node, ast.Name):
if node.id in protected_short_names:
conflicts = [
p for p in self.protected_names if p.rsplit(".", 1)[-1] == node.id
]
raise IntentShadowingError(
f"Assignment to '{node.id}' is forbidden. It shadows: {', '.join(conflicts)}"
)
def visit_Import(self, node: ast.Import):
protected_short_names = {p.rsplit(".", 1)[-1] for p in self.protected_names}
for alias in node.names:
name = alias.asname or alias.name
if name in protected_short_names:
conflicts = [
p for p in self.protected_names if p.rsplit(".", 1)[-1] == name
]
raise IntentShadowingError(
f"Forbidden import: '{alias.name}' imported as '{name}' "
f"shadows protected name(s): {', '.join(conflicts)}"
)
def visit_ImportFrom(self, node: ast.ImportFrom):
protected_short_names = {p.rsplit(".", 1)[-1] for p in self.protected_names}
for alias in node.names:
name = alias.asname or alias.name
if name in protected_short_names:
conflicts = [
p for p in self.protected_names if p.rsplit(".", 1)[-1] == name
]
source = f"{node.module}." if node.module else ""
raise IntentShadowingError(
f"Forbidden import: '{source}{alias.name}' imported as '{name}' "
f"shadows protected name(s): {', '.join(conflicts)}"
)
def visit_Assign(self, node: ast.Assign) -> None:
for target in node.targets:
if isinstance(target, ast.Name):
self._check_shadowing(target)
elif isinstance(target, (ast.Tuple, ast.List)):
for elt in target.elts:
self._check_shadowing(elt)
self.generic_visit(node)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
self._check_shadowing(node.target)
self.generic_visit(node)
def visit_AugAssign(self, node: ast.AugAssign) -> None:
self._check_shadowing(node.target)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
target, is_dynamic = _format_target(node.func)
short_name = target.rsplit(".", maxsplit=1)[-1]
self.calls.append(
CallLocation(
target=target,
short_name=short_name,
lineno=node.lineno,
col_offset=node.col_offset,
is_dynamic=is_dynamic,
)
)
if isinstance(node.func, ast.Lambda):
self.visit(node.func.body)
self.generic_visit(node)
def visit_FunctionDef(self, node: FunctionDefLike) -> None:
self.local_functions[node.name] = node
self._visit_function_definition_creation(node)
visit_AsyncFunctionDef = visit_FunctionDef
def visit_Lambda(self, node: ast.Lambda) -> None:
self._visit_arguments(node.args)
def visit_ClassDef(self, node: ast.ClassDef) -> None:
for decorator in node.decorator_list:
self.visit(decorator)
for base in node.bases:
self.visit(base)
for keyword in node.keywords:
self.visit(keyword.value)
for statement in node.body:
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
self._visit_function_definition_creation(statement)
elif isinstance(statement, ast.ClassDef):
self.visit(statement)
else:
self.visit(statement)
def _visit_function_definition_creation(
self,
node: FunctionDefLike,
) -> None:
for decorator in node.decorator_list:
self.visit(decorator)
self._visit_arguments(node.args)
def _visit_arguments(self, args: ast.arguments) -> None:
all_args = [*args.posonlyargs, *args.args, *args.kwonlyargs]
for arg in all_args:
if arg.annotation is not None:
self.visit(arg.annotation)
if args.vararg is not None and args.vararg.annotation is not None:
self.visit(args.vararg.annotation)
if args.kwarg is not None and args.kwarg.annotation is not None:
self.visit(args.kwarg.annotation)
for default in args.defaults:
self.visit(default)
for default in args.kw_defaults: # type: ignore
if default is not None:
self.visit(default)
В методе visit_Call мы форматируем целевой вызов через вспомогательную функцию _format_target и сохраняем его в список calls. Особый случай — немедленно вызываемые лямбда-выражения вида (lambda: foo())(). Здесь node.func представляет собой ast.Lambda, и мы должны проанализировать его тело, потому что оно выполняется немедленно. В таких случаях мы явно вызываем self.visit(node.func.body).
В visit_FunctionDef мы запоминаем определение вложенной функции в local_functions, но не спускаемся в ее тело. Это позволяет нам отложить анализ до момента, когда мы узнаем, что эта функция действительно вызывается. Асинхронные функции обрабатываются точно так же через visit_AsyncFunctionDef = visit_FunctionDef.
Класс visit_ClassDef обрабатывает определение класса. Интерпретатор выполняет тело класса во время его инициализации, поэтому мы анализируем декораторы, базовые классы, аргументы ключевых слов и выражения на уровне класса. Однако методы класса на этом этапе мы не анализируем — они будут вызваны только при создании экземпляра и вызове метода, поэтому их анализ откладывается.
Метод _visit_function_definition_creation обрабатывает декораторы и аргументы по умолчанию, потому что они вычисляются в момент определения функции и могут содержать вызовы. Это важный нюанс: декоратор @log(calculate()) выполнит calculate() во время декорирования, еще до вызова самой функции.
Отдельного внимания заслуживает механизм защиты от затенения (shadowing), который реализован в `_OuterCallCollector`. Это важный аспект безопасности, связанный с тем, как Python разрешает имена в локальных областях видимости.
То есть без этой системы, код print = os.system; print(“rm -rf /”) прошел бы проверку! Или можно прямо в функции написать from os import system as print и это также бы обошло бы проверку.
PyIntents блокирует такие атаки на этапе анализа AST. В _OuterCallCollector есть методы visit_Assign, visit_AnnAssign и visit_AugAssign, которые проверяют каждое присваивание на предмет затенения защищенных имен. Если обнаруживается, что имя из uses или short_names используется как левая часть присваивания, выбрасывается IntentShadowingError. _check_shadowing сравнивает имя с множеством protected_names — это все короткие имена функций, разрешенных через uses. Если есть конфликт, мы не просто предупреждаем, а блокируем выполнение до того, как код вообще начнет работать. А для защиты от импортов есть методы visit_Import и visit_ImportFrom, которые через похожую логику (получение имени алиаса) блокируют затенение.
Вспомогательная функция _format_target преобразует AST-выражение вызова в текстовое представление и определяет, является ли вызов динамическим.
def _format_target(node: ast.expr) -> tuple[str, bool]:
if isinstance(node, ast.Name):
return node.id, False
if isinstance(node, ast.Attribute):
base, dynamic = _format_target(node.value)
if dynamic:
return f".{node.attr}", True
return f"{base}.{node.attr}", False
if isinstance(node, ast.Call):
return "", True
if isinstance(node, ast.Subscript):
return "", True
return "", True
Если вызов представляет собой просто имя (например, os), мы возвращаем его. Если это атрибут (os.system), мы рекурсивно форматируем базовое выражение и добавляем к нему атрибут. Если базовое выражение динамическое (getattr(os, 'system')()), то весь вызов помечается как динамический. Для вызовов вида foo()(), индексов list[0]() и любых других непонятных конструкций мы возвращаем заглушки и помечаем их как динамические.
Это важно для безопасности: динамические вызовы не могут быть надежно разрешены статически, поэтому мы помечаем их как потенциально опасные.
Класс SafeResolver — еще один ключевой компонент. Он отвечает за безопасное разрешение имен в объекты. Обычный getattr может запустить дескрипторы и выполнить произвольный код, что недопустимо при статическом анализе.
class SafeResolver:
def resolve(
self,
name: str,
parent_func: Callable[..., Any] | None,
) -> Callable[..., Any] | None:
if parent_func is None:
return None
if not name or name.startswith("<"):
return None
parts = name.split(".")
try:
parent_globals = parent_func.__globals__
except AttributeError:
return None
obj = parent_globals.get(parts[0])
if obj is None:
obj = getattr(builtins, parts[0], None)
if obj is None:
return None
for part in parts[1:]:
if not isinstance(obj, (types.ModuleType, type)):
return None
obj = inspect.getattr_static(obj, part, None)
if obj is None:
return None
return obj if callable(obj) else None
SafeResolver принимает имя ("os.system") и родительскую функцию, в контексте которой оно используется. Из __globals__ родительской функции мы получаем объект для первого сегмента имени (os), после чего проходим по оставшимся сегментам, проверяя, что каждый промежуточный объект является модулем или классом. Мы используем метод inspect.getattr_static, чтобы не запускать дескрипторы и свойства. И в конце проверяем, является ли полученный объект вызываемым.
Такой подход исключает выполнение произвольного кода во время статического разрешения имен, что критично для безопасности.
CallNode — это узел графа вызовов. Он хранит идентификатор функции (identity), имя вызова (call_name), позицию в исходном коде (lineno, col_offset), разрешенный объект (resolved_func), если он известен, и флаги состояния.
@dataclass
class CallNode:
identity: str
call_name: str
lineno: int | None = None
col_offset: int | None = None
resolved_func: Callable[..., Any] | None = None
is_local_definition: bool = False
is_dynamic: bool = False
is_unresolved: bool = False
is_source_available: bool = True
is_cycle: bool = False
children: list[CallNode] = field(default_factory=list)
Переменная is_local_definition указывает, что узел — это локальная функция, определенная внутри родительской. is_unresolved сообщает, что мы не смогли разрешить вызов, is_dynamic указывает на динамический характер вызова, а is_cycle сигнализирует о том, что мы обнаружили рекурсию (это предотвращает бесконечный обход). Свойство children содержит дочерние узлы, представляющие вызовы внутри этой функции.
CallTree — это основной класс для построения графа вызовов.
class CallTree:
def __init__(
self,
func: Callable[..., Any],
max_depth: int | float = 1,
resolver: SafeResolver | None = None,
protected_names: set[str] | None = None,
) -> None:
self.max_depth: int | float = max(0, max_depth)
self.resolver = resolver or SafeResolver()
self.protected_names = protected_names or set()
self.root = self._build_from_callable(
func=func,
depth=0,
stack=frozenset(),
require_source=True,
)
При создании CallTree мы начинаем с корневой функции. Параметр max_depth контролирует глубину рекурсивного обхода, что позволяет ограничить анализ для производительности или при recursive=False. Метод _build_from_callable строит узел для корневой функции и рекурсивно обходит все вызовы.
Процесс построения выглядит следующим образом: сначала мы создаем узел для текущей функции. Если эта функция уже присутствует в стеке вызовов (identity in stack) — мы помечаем узел как цикл и останавливаемся, чтобы избежать бесконечной рекурсии. Если достигнута максимальная глубина, обход также прекращается. Затем пытаемся получить AST функции через _get_function_ast. Если получить его не удается и require_source=True, библиотека генерирует исключение. В противном случае помечаем узел как недоступный источник.
Метод _expand_node раскрывает тело функции. Он извлекает вызовы и локальные определения через extract_calls_from_function_def, после чего для каждого вызова строит дочерний узел с помощью _build_child.
_build_child решает, как обработать конкретный вызов. Если это не динамический вызов и имя не содержит точек, мы пытаемся найти локальное определение в local_defs. При его наличии строим узел из AST через _build_from_ast. В противном случае пытаемся разрешить через SafeResolver. Если разрешение успешно — строим узел из разрешенного callable-объекта через _build_from_callable, а при неудаче создаем узел с пометкой is_unresolved.
Такой подход позволяет строить максимально полный граф вызовов безопасно — мы не выполняем код, не запускаем дескрипторы и не ходим по ссылкам в процессе выполнения.
namespace.py
IntentNamespace — это движок политик. Он принимает правила и применяет их к графу вызовов.
class IntentNamespace:
def __init__(
self,
uses: Iterable[Rule] | None = None,
*,
recursive: bool = True,
without: Iterable[Rule] | None = None,
uselocals: bool = False,
usemodule: bool = False,
deny: Iterable[Rule] | None = None,
allow_unknown: bool = False,
deny_dynamic_primitives: bool = True,
only_warnings: bool = False,
) -> None:
Какие параметры конфигурации здесь принимаются:
- Управление доступом:
usesзадает белый список разрешенных вызовов,deny— черный список запрещенных вызовов, аwithoutточечно исключает функции из проверки белым списком. - Контекст вызовов: флаги
uselocalsиusemoduleавтоматически разрешают работу с локальными функциями и кодом из того же модуля. - Поведение при нарушениях:
allow_unknownразрешает неизвестные вызовы,deny_dynamic_primitivesблокирует динамические примитивы по умолчанию, аonly_warningsвместо исключений выводит предупреждения. Правила нормализуются вRuleSetчерез_normalize_rules. Это позволяет задавать правила как строки ("os.system"), так и callable-объекты (os.system).
@dataclass(frozen=True)
class RuleSet:
objects: frozenset[Callable[..., Any]] = frozenset()
identities: frozenset[str] = frozenset()
names: frozenset[str] = frozenset()
short_names: frozenset[str] = frozenset()
RuleSet хранит правила в четырех формах: объекты, идентификаторы (module:qualname), полные имена (module.qualname) и короткие имена. Это обеспечивает гибкость при проверке совпадений.
_normalize_rules обрабатывает строки с двоеточием как идентификаторы, строки с точкой как полные имена, а простые строки как короткие имена. Метод matches в классе RuleSet проверяет, соответствует ли узел правилу, в следующем порядке: объект, идентификатор, полное имя, короткое имя.
def matches(self, node: CallNode) -> bool:
if node.resolved_func is not None:
try:
if node.resolved_func in self.objects:
return True
except TypeError:
pass
if get_function_identity(node.resolved_func) in self.identities:
return True
if node.identity in self.identities:
return True
if node.call_name in self.names:
return True
short_name = node.call_name.rsplit(".", maxsplit=1)[-1]
return short_name in self.short_names
При проверке мы сначала пробуем объектную проверку, но если объект нехешируемый, ловим TypeError и продолжаем. Затем проверяем идентификатор, полное имя и короткое имя.
Главный метод intent возвращает декоратор, который оборачивает функцию.
def intent(...) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
policy = _EffectivePolicy(...)
max_depth = float("inf") if effective_recursive else 1
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
protected_names = set(merged_uses.names | merged_uses.short_names)
tree = CallTree(func, max_depth=max_depth, protected_names=protected_names)
root_module = getattr(func, "__module__", None)
self._validate_tree(tree.root, policy, root_module=root_module)
return func(*args, **kwargs)
return wrapper
return decorator
Декоратор при каждом вызове строит CallTree и проверяет его через _validate_tree. Если проверка не пройдена, функция не выполняется.
_validate_tree обходит граф вызовов в глубину. Для каждого узла, кроме корневого, проверяется: если узел есть в deny — нарушение. Затем проверяется without: если узел не в without и не разрешен через _is_allowed — нарушение.
_is_allowed определяет, разрешен ли узел. Проверяются: uses, uselocals для локальных определений, usemodule для функций из того же модуля и allow_unknown для неразрешенных или динамических вызовов. Порядок важен: deny проверяется первым, поэтому он всегда имеет приоритет над uses и without. Это позволяет явно запретить опасные функции, даже если они указаны в uses.
usemodule требует recursive=True, потому что проверка функций из того же модуля имеет смысл только при рекурсивном обходе графа.
exceptions.py
Исключения выстроены в иерархию: IntentError — базовый класс. IntentViolationError — нарушение политики. IntentParseError — ошибка парсинга кода. IntentConfigurationError — ошибка конфигурации. IntentShadowingError — ошибка когда мы затеняем разрешенную переменную (например разрешаем print, а в функции print = os.system, и дабы такого не было, в коде есть защита от затенения).
Класс IntentViolationError хранит имя функции, нарушение и путь вызовов для генерации детального сообщения об ошибке:
class IntentViolationError(IntentError):
def __init__(
self,
func_name: str,
violation: str,
*,
path: tuple[str, ...] = (),
) -> None:
self.func_name = func_name
self.violation = violation
self.path = tuple(path)
if self.path:
where = " -> ".join((*self.path, func_name))
else:
where = func_name
super().__init__(f"Function '{where}' calls forbidden '{violation}'")
Благодаря этому мы получаем информативные сообщения об ошибках вида: Function 'func -> outer -> inner' calls forbidden 'os.system', как в примере.
AST-интроспекция строит граф вызовов, политики проверяют каждый узел, а если что-то не так — исключение выбрасывается до выполнения функции. В следующей части я покажу, как этим пользоваться на практике, и разберу ограничения, с которыми столкнулся из-за динамичной природы Python.
Примеры использования
Теперь, когда мы разобрались с архитектурой, давайте посмотрим на библиотеку в действии.
Простой сценарий
Начнем с примера, который я показывал в начале статьи.
import os
from pyintents import IntentNamespace, IntentViolationError
namespace = IntentNamespace(
uses=[print],
recursive=True,
)
@namespace.intent()
def safe_function() -> None:
print("This is allowed")
@namespace.intent()
def unsafe_function() -> None:
os.system("echo This should be blocked")
if __name__ == "__main__":
safe_function()
try:
unsafe_function()
except IntentViolationError as exc:
print(f"Blocked: {exc}")
Мы создаем неймспейс с единственным разрешением — функция print. Безопасная функция safe_function выполняется корректно. А вот unsafe_function при попытке вызова выбрасывает IntentViolationError, потому что os.system отсутствует в списке uses. Самое важное здесь то, что библиотека обнаруживает ошибку до начала работы кода, поэтому тело функции unsafe_function даже не начинает выполняться.
Использование usemodule
Теперь усложним пример. Добавим вложенные функции и включим usemodule.
import os
from pyintents import IntentNamespace
main_namespace = IntentNamespace(
uses=[print],
recursive=True,
usemodule=True,
)
def inner() -> None:
os.system("rm test.txt")
def outer() -> None:
print("Outer")
inner()
@main_namespace.intent()
def func() -> None:
print("Func")
outer()
if __name__ == "__main__":
try:
func()
except Exception as exc:
print(f"Error: {exc}")
Здесь recursive=True включает рекурсивную проверку — PyIntents анализирует не только тело func, но и все функции, которые она вызывает. usemodule=True означает, что функции, определенные в том же модуле, что и декорированная функция, разрешены автоматически. Это удобно, когда у вас много вспомогательных функций, и вы не хотите перечислять их все в uses.
При вызове func PyIntents строит граф вызовов func -> outer -> inner и проверяет разрешения для каждого узла. print разрешен явно через uses.outer и inner разрешены через usemodule — они определены в том же модуле. А вот os.system внутри inner не разрешен, и мы получаем ошибку с полным путем: Function 'func -> outer -> inner' calls forbidden 'os.system'.
usemodule работает только в связке с recursive=True. Если вы попытаетесь включить usemodule без рекурсии, получите IntentConfigurationError. Это логично — разрешать функции из того же модуля имеет смысл, только если мы проверяем их содержимое.
Работа с локальными функциями
Теперь рассмотрим сценарий с локальными вложенными функциями.
from pyintents import IntentNamespace, IntentViolationError
namespace = IntentNamespace(
uses=[print],
recursive=True,
uselocals=True,
)
@namespace.intent()
def ok_function() -> None:
def local_helper() -> None:
print("Local helper is allowed")
local_helper()
@namespace.intent()
def bad_function() -> None:
def local_helper() -> None:
import os
os.system("echo This should be blocked")
local_helper()
if __name__ == "__main__":
ok_function()
try:
bad_function()
except IntentViolationError as exc:
print(f"Blocked: {exc}")
Здесь мы включаем uselocals=True, что разрешает вызовы локальных функций, определенных внутри декорируемой функции. Функция ok_function успешно работает, так как ее локальный помощник вызывает только разрешенный print. А вот в bad_function локальный помощник пытается импортировать os и вызывает os.system. PyIntents находит этот вызов в графе, видит, что os.system не разрешен, и блокирует выполнение всей цепочки.
Важный момент: uselocals разрешает саму локальную функцию как вызов, но не отключает проверку того, что эта локальная функция вызывает внутри себя. То есть local_helper разрешена как вызов, но ее содержимое все равно проверяется.
Режим мягких предупреждений
Теперь о ситуации, когда нам нужно отладить политику или мигрировать существующий код. PyIntents предоставляет режим only_warnings, который вместо исключений выводит предупреждения. Это позволяет увидеть все нарушения, не блокируя работу приложения.
from pyintents import IntentNamespace
import warnings
warnings.simplefilter("always") # чтобы увидеть предупреждения
namespace = IntentNamespace(
uses=[print],
only_warnings=True,
)
def helper():
import os
os.system("echo This would be blocked")
@namespace.intent()
def func():
print("Hello")
helper()
if __name__ == "__main__":
func() # Выполнится, но выведет предупреждение о нарушении
В этом режиме функция выполнится, но в консоль будет выведено предупреждение: UserWarning: Intent violation: os.system called from func.helper. Это полезно при внедрении PyIntents в существующий проект — вы можете включить only_warnings, увидеть все потенциальные нарушения, исправить их, и только потом переключиться на строгий режим с исключениями.
Черные списки
Параметр deny позволяет явно запретить определенные вызовы, даже если они есть в uses. Это может быть полезно, когда вы хотите разрешить целую группу функций, но запретить конкретную опасную.
import os
namespace = IntentNamespace(
uses=[print, os.system],
deny=[os.system],
)
@namespace.intent()
def restricted():
print("OK")
os.system("echo This is explicitly denied")
Здесь os.system одновременно и разрешен через uses, и запрещен через deny. Приоритет у deny выше, поэтому вызов будет заблокирован.
Обработка неизвестных вызовов
По умолчанию параметр allow_unknown, который разрешает неразрешенные вызовы, отключен, что соответствует строгой модели безопасности fail-closed (запрещено все, что явно не разрешено). Но если вы работаете с динамическими вызовами, которые невозможно разрешить статически, этот параметр можно включить.
namespace = IntentNamespace(
uses=[print],
allow_unknown=True,
)
@namespace.intent()
def dynamic_call():
cb = print
cb("This works with allow_unknown=True")
В этом примере cb() не может быть разрешен статически, потому что cb — локальная переменная. С allow_unknown=False такой вызов был бы заблокирован. С allow_unknown=True — разрешен. Используйте это с осторожностью, потому что allow_unknown ослабляет проверки.
Под капотом: нетривиальные случаи и ограничения
Хотя я и покрыл почти все способы вызова функции, к сожалению все равно столкнулся с несколькими сложными моментами, которые хорошо иллюстрируют, где статический анализ упирается в динамическую природу Python. Начну с обработки разных видов вызовов.
Самый простой случай — прямой вызов по имени: os.system(“ls”). AST представляет его как ast.Attribute, где value — это ast.Name(“os”), а attr — “system”. _format_target преобразует это в строку “os.system”, и SafeResolver находит соответствующий объект через __globals__ родительской функции.
Но что, если вызов динамический? Например, getattr(os, “system”)(“ls”). Здесь getattr возвращает функцию по строковому имени. Статически мы не можем определить, какую именно функцию вернет getattr — это зависит от рантайма. _format_target видит ast.Call в качестве базового выражения и помечает весь вызов как динамический: “<dynamic>.system”. Такой вызов не может быть разрешен через SafeResolver, и он либо блокируется, либо пропускается в зависимости от allow_unknown.
При вызове вида cb = print; cb(“hello”) выражение не содержит точек и парсится как обычное имя ast.Name(“cb”). SafeResolver не может разрешить cb, потому что это локальная переменная, ее нет в __globals__. Такой вызов помечается как is_unresolved и блокируется по умолчанию. Это единственное безопасное решение: если мы не можем статически определить имя функции, мы не можем гарантировать безопасность системы.
Отдельного внимания заслуживают сразу вызванные лямбды: (lambda: os.system(“ls”))(). В AST строится узел ast.Call, где свойство func ссылается на ast.Lambda. В обычной ситуации мы не спускаемся в тело лямбды, потому что она будет выполнена только при вызове. Но здесь она вызывается немедленно. В visit_Call я добавил проверку: если node.func — это ast.Lambda, мы явно вызываем self.visit(node.func.body), анализируя тело лямбды как часть текущего контекста выполнения. Это позволяет обнаружить os.system внутри немедленно вызванной лямбды.
С классами ситуация сложнее. Тело класса выполняется во время определения, поэтому я анализирую декораторы классов, базовые классы, аргументы ключевых слов и выражения на уровне класса. Например, декоратор @log(calculate()) вызовет calculate() во время декорирования, еще до создания экземпляра класса. А вот методы класса я не анализирую — они будут вызваны только при создании экземпляра и вызове метода. Их анализ откладывается до момента, когда мы увидим вызов instance.method() в графе.
Еще одна проблема — это рекурсия. Если функция вызывает саму себя, граф вызовов становится бесконечным. Чтобы этого избежать, я передаю стек уже обработанных идентификаторов в _build_from_callable и _build_from_ast. Если мы встречаем функцию, чей идентификатор уже есть в стеке, то помечаем узел как is_cycle и не раскрываем его дальше. Это позволяет корректно обрабатывать рекурсивные функции, не уходя в бесконечный обход.
Теперь об ограничениях. PyIntents — это дополнительный слой защиты приложения, но не полноценная изолированная песочница. Обеспечить абсолютную безопасность статического анализа в Python невозможно из-за его динамической природы. Рассмотрим такой код:
eval("import os; os.system('rm -rf /')")
Функция eval входит в список DEFAULT_DENIED_DYNAMIC_PRIMITIVES и блокируется по умолчанию. Но если разработчик явно разрешит eval через uses, PyIntents не сможет проанализировать строку, переданную в eval — это произвольный код, который может содержать что угодно.
Другой пример:
globals()["os"].system("ls")
globals() также входит в список запрещенных примитивов. Но если разрешить globals() и getattr(), можно обойти практически любую проверку. Именно поэтому я запрещаю эти примитивы по умолчанию.
Важно понимать: PyIntents работает на уровне AST, анализируя статическую структуру кода. Он не может анализировать строки, передаваемые в eval, или значения переменных в процессе выполнения. Это фундаментальное ограничение любого статического анализа.
Что можно сделать для улучшения
На данный момент CallTree строится при каждом вызове декорированной функции. Это не проблема для функций, которые вызываются редко, но для высоконагруженных hot-path функций создает задержку. Планируется добавить кэширование: построенное дерево для каждой декорированной функции будет сохраняться после первого вызова и переиспользоваться при следующих вызовах. Это значительно повысит производительность, потому что AST-анализ будет выполняться только один раз.
Также в планах — улучшить обработку декораторов. Сейчас декораторы с параметрами анализируются корректно, но если декоратор возвращает другой объект (например, @staticmethod или @classmethod), анализ может давать ложные срабатывания. Это требует более глубокого понимания того, как декораторы трансформируют функции.
Еще одно направление — поддержка анализа импортов. Сейчас PyIntents видит только вызовы внутри анализируемой функции. Если функция использует импортированные на уровне модуля глобальные переменные, их разрешение происходит только через SafeResolver и __globals__. В планах — добавить полноценное отслеживание внешних импортов, от выполнения кода в __init__.py.
Однако главное ограничение остается: Python слишком динамичен для полной статической гарантии безопасности. PyIntents — это инструмент, который делает код более безопасным и предсказуемым, но не заменяет другие уровни защиты: изоляцию процессов, контейнеры, seccomp, WASM. Используйте его как часть многоуровневой стратегии безопасности или как поле для экспериментов.
Заключение
Capability-based Security — это мощная концепция, которая десятилетиями доказывала свою эффективность на уровне операционных систем и аппаратного обеспечения. PyIntents — моя попытка привнести эту идею в экосистему Python, адаптировав ее под динамическую природу языка через статический анализ и декларативные интенты.
Мне удалось реализовать рабочую систему, которая позволяет разработчикам явно декларировать намерения своих функций и проверять их до выполнения. Это не серебряная пуля, но полезный инструмент для случаев, где контроль над тем, что может делать функция, критичен.
Как я уже говорил, библиотека доступна на PyPI: pip install pyintents. Исходный код открыт и лежит на GitHub. Там же можно найти документацию, примеры и инструкции по установке.
PyIntents, на мой взгляд, особенно полезен в системах плагинов, где вы не доверяете стороннему коду, в AI-агентах, где нужно ограничить набор доступных инструментов, и в тестировании, где важно изолировать зависимости. Хотя это и не замена полноценной песочнице, но зато отличный слой безопасности для Python-приложений.
Я буду рад, если вы попробуете PyIntents в своих проектах. Если найдете баги, придумаете интересные сценарии использования или захотите улучшить что-то в коде — открывайте обращения (issues), предлагайте пулл-реквесты (pull requests). Особенно актуальны идеи по оптимизации производительности и обработке сложных декораторов.
Явное лучше, чем неявное, как говорится!