@restranger/repi-permission-system-core

Security-hardened permission gateway, bash analysis, and path access control for pi-coding-agent

Packages

Package details

extension

Install @restranger/repi-permission-system-core from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@restranger/repi-permission-system-core
Package
@restranger/repi-permission-system-core
Version
0.0.1-1a48fc0
Published
Nov 3, 2026
Downloads
1,665/mo · 1,665/wk
Author
restranger
License
MIT
Types
extension
Size
1.5 MB
Dependencies
2 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@restranger/repi-permission-system-core

Шлюз авторизации для pi: перехватывает tool_call, разбирает запрос (файл, путь, команда bash) и возвращает allow / deny / ask / rewrite по правилам из плоского JSON-конфига. Плагин самостоятельный: ему нужен только @restranger/repi-lib и хост.

Главная идея: неявных запретов нет. Всё, что не описано правилом, даёт ask; deny ставит только пользователь — обычным правилом в конфиге.

pi install npm:@restranger/repi-permission-system-core
# или из этого репозитория
pi --extension ./packages/permission-system-core/src/index.ts

Что происходит при вызове инструмента

  1. Запрос (PermissionRequest: tool, input, cwd, sessionId, signal) попадает в PermissionEngine.check().
  2. Для bash команда разбирается Tree-sitter в ExecutionUnit-ы (src/bash-ast.ts, см. src/vendor/README.md) и каждый юнит проверяется отдельно: правила path для его путей и редиректов, правила bash, границы внешних каталогов, сессионные гранты.
  3. Для файловых инструментов путь каноникализируется (realpath ближайшего существующего предка), проверяются правила path и правила external_directory*.
  4. Побеждает самое строгое совпавшее правило: deny > ask > rewrite > allow. Не «первое совпадение».
  5. Если итог — ask, плагин показывает диалог; без диалога, при таймауте или ошибке решение становится deny (fail-closed).
  6. rewrite применяется к input события tool_call (сейчас так работает только NoCD, см. ниже).

Модули

Файл Роль
src/bash-ast.ts разбор bash на AST, ExecutionUnit, редиректы, вложенный код; analyzeBash, analyzeBashAsync, pathArguments
src/bash-unwrap.ts снятие обёрток (sudo, timeout, env, xargs, command, …)
src/engine.ts PermissionEngine: правила, «самое строгое», гранты, трансформеры
src/config-loader.ts поиск, валидация и слияние слоёв конфига
src/boundary.ts границы внешних каталогов, порядок строгости поверхностей
src/nocd.ts детект cd <dir> && … и рерайт в cwd
src/read-only.ts commandIsReadOnly() / isReadOnlyRequest()
src/secrets.ts чистые хелперы путей и глобов (без списков секретов)
src/grants.ts сессионные гранты
src/defaults.ts встроенный базовый слой политики (BUILTIN_DEFAULT_CONFIG)
src/prompt-ui.ts inline-диалог подтверждения
src/plugin.ts рантайм pi-расширения, публикация re:permissions@1
src/vendor/tree-sitter-bash.wasm вендоренная грамматика Bash

Конфиг

Слои ищутся в таком порядке и сливаются так, что приоритет растёт сверху вниз (сначала пользовательские, затем проектные):

$PI_CODING_AGENT_DIR/extensions/repi-permissions.json        # по умолчанию ~/.pi/agent
$PI_CODING_AGENT_DIR/extensions/repi-permissions/config.json
$PI_CODING_AGENT_DIR/extensions/repi-permissions/permissions.json
<project>/.pi/extensions/repi-permissions.json
<project>/.pi/extensions/repi-permissions/config.json
<project>/.pi/extensions/repi-permissions/permissions.json
<project>/.repi/permissions.json

Проектные слои идут последними и могут только ужесточать политику: из них применяются deny и ask, а allow, rewrite и trustedTools игнорируются (репозиторий не должен разрешать себе то, что запретил пользователь). askTimeoutMs и doublePressToConfirm из проектного слоя применяются как есть.

Шаблон .pi/extensions/repi-permissions.json в этом репозитории лежит в проектном слое, поэтому его allow-правила не действуют: разрешения нужно держать в пользовательском слое ($PI_CODING_AGENT_DIR/extensions/repi-permissions.json).

Встроенный базовый слой

Под всеми файлами всегда лежит BUILTIN_DEFAULT_CONFIG (src/defaults.ts): работает, даже если файлов нет вообще. В нём только path и bash, ключ * не задан — глобальный фолбэк остаётся ваш.

  • path (deny): **/.env, **/.env.*, **/.env_*, **/.envrc, **/*.pem, **/*.key, **/*.p12, **/*.pfx, **/*.pkcs12, **/id_rsa*, **/id_ed25519*, **/id_ecdsa*, **/id_dsa*, **/.ssh/**, **/auth.json, **/mcp-auth.json, **/credentials.json, **/.netrc, **/.pgpass, **/.aws/**, **/.gnupg/**, /etc/shadow*, /etc/sudoers*.
  • bash (deny): rm -rf /, rm -rf /*, rm -rf ., rm -rf .., rm -rf ./*, rm -rf ~*, rm -rf $HOME*, rm -rf /etc* (/usr*, /bin*, /sbin*, /boot*, /dev*, /lib*, /var*, /root*, /home*, /sys*, /proc*), rm -r /, rm -fr /, rm --recursive /, find * -delete*, find * -exec rm *, dd * of=/dev/{sd,nvme,hd,vd,disk,mmcblk}*, mkfs*, fdisk*, parted*, wipefs*, shutdown*, reboot*, poweroff*, init 0*, init 6*, :(){ :|:& };:, nc -e *, ncat -e *.
  • bash (allow): bun run test*, bun run check*, bun run format*, bunx biome *, git status*, git diff*, git log*, git show*, git branch*, ls *, pwd, which *, whoami, rg *, grep *, cat *, head *, tail *.

Правила слоя подчиняются обычной логике «самое строгое побеждает»: "**": "allow" в вашем файле не отменяет встроенный **/.env, а cat .env остаётся deny, потому что аргумент-путь уходит в path-правила. Чтобы ослабить конкретное встроенное правило — повторите тот же паттерн (например "path": {"**/.env": "allow"}); чтобы убрать слой целиком — "builtinDefaults": false в пользовательском слое (проектный слой этого сделать не может).

Схема

Ключ Тип Смысл
* action глобальный фолбэк для всего, что не совпало; по умолчанию ask
path glob → action проектные глобы: файловые инструменты и пути-аргументы bash-команд
bash паттерн → action команда → действие; матчится после снятия обёрток
mcp action или glob → action правила для MCP-инструментов; по умолчанию MCP разрешён
external_directory action или glob → action общий набор правил для путей вне корня проекта
external_directory_read action или glob → action то же для чтения (если задан, заменяет общий)
external_directory_write action или glob → action то же для записи/правки
askTimeoutMs число > 0 таймаут диалога, по умолчанию 30000
trustedTools string[] инструменты, для которых ask повышается до allow (явный deny сильнее)
doublePressToConfirm boolean требовать второе нажатие y/s/n/r в TUI
builtinDefaults boolean false полностью убирает встроенный базовый слой (только пользовательский слой)

action — одно из allow, deny, ask, rewrite. Действие rewrite умеет только NoCD (оно подставляет modifiedInput.cwd); rewrite, заданный правилом в конфиге, вырождается в ask, потому что замена команды из правила не выводится.

Пример

{
  "askTimeoutMs": 30000,
  "doublePressToConfirm": false,
  "*": "ask",
  "path": {
    "**": "allow",
    ".pi/extensions/**": "deny",
    ".repi/**": "deny",
    "**/*.pem": "deny",
    "**/id_rsa*": "deny",
    "**/auth.json": "deny"
  },
  "bash": {
    "*": "ask",
    "ls *": "allow",
    "pwd": "allow",
    "rg *": "allow"
  },
  "external_directory": { "*": "ask" },
  "external_directory_read": { "/nix/store/*": "allow", "/tmp/pi/*": "allow" }
}

Поверхности external_* можно записать одним действием: "external_directory_write": "deny".

Старая форма и ошибки

Вложенная форма {"permission": {…, "options": {…}}} ещё принимается и нормализуется при загрузке; плоские ключи выигрывают у вложенных. Неизвестный ключ — ошибка конфига, а не молчаливое игнорирование; нечитаемый или битый файл — тоже ошибка, и плагин запускается с DENY_ALL_CONFIG (deny-all), показывая уведомление.

Как матчатся правила

Глобы путей. * не переходит через /, ** переходит. Ведущий **/ может совпасть с нулём сегментов, поэтому **/*.pem покрывает и key.pem в корне, и certs/key.pem. Замыкающий /* покрывает всё поддерево: /nix/store/* — это «что угодно внутри /nix/store». Шаблон path пишется относительно корня проекта, а путь проверяется в четырёх написаниях (абсолютное, относительное, ./относительное, базовое имя), чтобы одно правило ловило src/a.ts, ./src/a.ts и /abs/src/a.ts. * и ** в path напрямую не матчатся — это фолбэк, если ничего не совпало.

Паттерны bash. * матчит что угодно, включая пробелы; сравнение идёт по всей строке (ls * покрывает ls -la, а pwd — только pwd). Паттерн проверяется против трёх кандидатов юнита: исходного текста (raw), argv после снятия обёрток (targetArgv.join(" ")) и имени команды (name). "*" — фолбэк.

Юниты bash. &&, ||, |, ;, &, переводы строк, $(…), <(…), subshell, функции, циклы, case, sh -c '…', eval, find -exec дают отдельные юниты; обёртки (sudo, timeout, env, nice, xargs, command, doas, …) снимаются, чтобы правило видело настоящую команду. cd внутри последовательности меняет cwd последующих юнитов. Пути-аргументы и редиректы (включая heredoc) проверяются теми же правилами path, что и файловые инструменты.

Внешние каталоги. Путь вне корня проекта проверяется по external_directory_read / external_directory_write (или по общему external_directory), иначе — по глобальному *. Правка (edit) — это и чтение, и запись: побеждает более строгое из двух.

Статически не проверяемое. $VAR в опасной позиции, curl … | bash, heredoc/herestring в интерпретатор, source <(…)/bash <(…), неразрешимый cd (cd -, cd $VAR, голый pushd), вложенность глубже 4 и синтаксические ошибки помечаются unverifiable или tooComplex и дают ask — никогда молчаливый allow. Если WASM-парсер не загрузился, команда тоже уходит в ask (bash:parser-unavailable), а не в allow.

NoCD. Ведущий cd <dir> && … превращается в решение rewrite с modifiedInput.cwd, если <dir> остаётся внутри корня проекта. При выходе за корень рерайта нет: остаток команды проверяется с реальным cwd, поэтому правила external_directory* видят настоящие пути.

ask, fail-closed и таймаут

  • Диалог берётся из re:ui@1, если плагин @restranger/repi-ui установлен, иначе из ctx.ui.
  • TUI: inline-диалог с фактами запроса (инструмент, правило, команда/путь, риск, причина) и вариантами Yes / Yes, for this session / No / No, provide reason (горячие клавиши y/s/n/r); doublePressToConfirm требует второго нажатия. RPC использует select/input с теми же вариантами.
  • Нет диалога (headless, print/json), таймаут (askTimeoutMs, по умолчанию 30000 мс), abort или исключение → deny. Таймаут закрывает и сам диалог.
  • Ошибка пайплайна, прерванный signal, bash без command, bash с посторонними file-аргументами → deny с ruleId из pipeline:/bash:.
  • Трансформер решений (режимы, другие плагины) может сузить решение, но не поднять deny из конфига или пайплайна: такие решения «заблокированы».

trustedTools, гранты, read-only

  • trustedTools — список инструментов, для которых ask повышается до allow (ruleId: options:trusted-tool). Правила при этом всё равно вычисляются, поэтому явный deny (включая path-deny) сильнее доверия. Для bash механика тоже работает, но разбор AST не пропускается: запрет на команду всё равно победит.
  • Сессионные гранты. «Yes, for this session» сохраняет грант в SessionGrantStore: для bash — точная строка команды, для файла — канонический путь. Грант для команды сравнивается без глобов (одно одобрение не расширяется до rm -rf *), для пути — с глобами. Гранты живут в памяти, переживают /compact, сбрасываются на session_shutdown и /reload. Catch-all и гранты на оболочки/интерпретаторы (sh, sudo, eval, xargs, python, …) отвергаются, а deny из конфига сильнее любого гранта. API: grantSession, clearSessionGrants, listSessionGrants.
  • Read-only. commandIsReadOnly(command, cwd) (модуль src/read-only.ts) консервативен: false, если команду не удалось разобрать, если она unverifiable/tooComplex, если есть пишущий редирект или юнит с -delete/-exec/-i/-o. Интерпретатор считается прозрачным только для sh -c '…'/eval, чей скрипт разложен на юниты: bash script.sh, голый sh и heredoc/herestring в оболочку — не read-only. isReadOnlyRequest(req) возвращает true для не-bash запросов и делегирует в commandIsReadOnly для bash.

Что экспортирует движок

src/index.ts реэкспортирует всё публичное:

  • рантайм: createPermissionRuntime, createPermissionPlugin, createPermissionPluginInstance, default (entry point для pi.extensions);
  • движок: PermissionEngine (check, explain, isReadOnly, grantSession, clearSessionGrants, listSessionGrants, setDecisionTransformer, addDecisionTransformer, onDecision, getPolicy, setConfig);
  • ask: resolveAsk, isAskDecision, askViaUi, mapToolCallPayload, synthesizeGrantPattern;
  • AST: analyzeBash, analyzeBashAsync, ensureBashParser, isBashParserReady, bashParserError, disposeBashParser, clearBashAnalysisCache, pathArguments, isPathLikeToken, unwrapCommand, tokenizeCommand, checkNoCdRewrite;
  • конфиг: loadPermissionConfig, loadPermissionConfigFromLayers, findConfigPaths, validatePermissionConfig, normalizePermissionConfig, permissionOptions, PermissionConfigError, DENY_ALL_CONFIG;
  • встроенный слой: BUILTIN_DEFAULT_CONFIG, BUILTIN_PATH_RULES, BUILTIN_BASH_DENY_RULES, BUILTIN_BASH_ALLOW_RULES, withBuiltinDefaults, builtinDefaultsEnabled;
  • MCP: isMcpTool (плюс PermissionEngine.setKnownMcpTools для инструментов, известных хосту);
  • правила и пути: resolveSurfaceRule, mostRestrictive, mostRestrictiveOf, evaluateBoundary, operationForTool, isExternalPath, matchPathGlob, matchGlobPart, matchCommandPattern, resolveCanonicalPath, expandHome, extractPathCandidate, pathDynamism, looksLikePath;
  • гранты и read-only: SessionGrantStore, isUnsafeGrantPattern, commandIsReadOnly, isReadOnlyRequest;
  • типы контракта: PermissionPolicy, PermissionConfig, PermissionSurfaceRules, PermissionAction, PermissionDecision, PermissionRequest, PermissionExplanation и др.

Сервис публикуется как re:permissions@1 на сессию с capabilities permission-gate, decision-transformers, read-only-check, decision-events.

MCP-инструменты

MCP-инструменты (mcp__<server>__<tool>, <server>/<tool>, read_mcp_resource, а также всё, что попало в setKnownMcpTools из pi.getAllTools()) идут по отдельной поверхности и:

  • разрешены по умолчанию (ruleId: default:mcp-allow), независимо от plugin/* и внешнего вида вызова (exposure: codemode или direct);
  • их аргументы (path, file, …) — это пути внутри удалённого ресурса, поэтому они не проверяются по локальным path / external_directory* правилам (github/get_file_contents с path="/" больше не блокируется external_directory_write: deny);
  • ограничиваются только секцией mcp: {"mcp": {"mcp__github__delete_*": "deny", "*": "allow"}}; имя github/x и mcp__github__x матчатся как одно правило;
  • сессионный грант для MCP — это имя инструмента, а не файловый путь.

Чего больше нет

Жёстко зашитого слоя «hard deny» нет: списки вроде rm -rf /, find -delete, записи в блочные устройства, .env/*.pem/id_rsa живут только во встроенном базовом слое (src/defaults.ts, отключается "builtinDefaults": false), а не в коде проверки решений. Регулярок вместо AST тоже нет. Любое правило можно переопределить своим:

{
  "path": { "**/.env": "deny", "**/*.pem": "deny", "**/id_rsa*": "deny" },
  "bash": { "rm -rf /*": "deny", "find * -delete": "deny" }
}

Без такого правила команда спросит подтверждение, а не будет заблокирована молча.

Зависимости

  • web-tree-sitter@^0.25.10 — обычная runtime-зависимость (WASM-рантайм).
  • tree-sitter-bash.wasm вендорен в src/vendor/ и поставляется вместе с пакетом: грамматика должна быть ABI-совместима с рантаймом (язык версии 15 требует web-tree-sitter >= 0.25). Подробности, источник и SHA-256 — src/vendor/README.md.
  • @restranger/repi-lib — контракты и seam.