Коротка відповідь. Permission Model – це стабільний (з v23.5) механізм Node.js, який за прапорцем --permission забороняє процесу все, що ти явно не дозволив: читання/запис файлів, мережу, child processes, worker-и, нативні аддони. Це захист від «залежність робить більше, ніж обіцяла», а не повноцінна пісочниця: символічні лінки, вже відкриті дескриптори і код у межах дозволених шляхів модель не контролює. Використовуй її як дешевий додатковий шар для CLI, білд-скриптів і AI-агентів – поверх, а не замість контейнерів і рев'ю залежностей.

Threat model: від чого це захищає

Чесно сформулюємо проблему. Коли ти запускаєш npm install && npm run build, на твоїй машині виконується код сотень пакетів – з повними правами твого користувача. Прочитати ~/.ssh/id_ed25519, ~/.aws/credentials, надіслати їх на чужий сервер – для скомпрометованої залежності це три рядки коду. Про те, як typosquatting-пакети потрапляють у проєкти через AI-генерований код, я вже писав у чеклісті перевірки AI-коду – Permission Model закриває наступний рубіж: навіть якщо поганий пакет уже в node_modules, його можливості обмежені.

Той самий аргумент працює для AI-агентів у терміналі: процес, який запускає згенерований код, хочеться тримати на короткому повідку.

Node.js у документації прямо називає модель «ременем безпеки»: вона не захищає від зловмисного коду, якому ти сам дав права, – вона гарантує, що процес не вийде за явно окреслені межі.

Як це вмикається

Два режими:

  • --permission – enforce: усе, що не дозволено, падає з ERR_ACCESS_DENIED;
  • --permission-audit – audit: нічого не блокується, але кожне «порушення» публікується в diagnostics channel. Ідеально, щоб спочатку дізнатися, куди насправді лізе твій процес.

Капсули дозволів – окремі прапорці:

--allow-fs-read=<шлях>     # читання: шляхи, wildcard * або повний доступ
--allow-fs-write=<шлях>    # запис
--allow-child-process      # spawn/exec
--allow-worker             # worker_threads
--allow-net                # мережа
--allow-addons             # нативні аддони
--allow-wasi               # WASI

Шляхи можна передавати відносні, абсолютні, кілька разів і з wildcard (/home/test*). Файл-ентрипоінт автоматично додається до списку читання – інакше Node не зміг би запустити сам скрипт.

Практика: CLI з мінімальними правами

Задача з брифу нашого ж блогу: скрипт читає Markdown із ./content, пише результат у ./dist – і не має жодних причин чіпати ~/.ssh чи ходити в мережу.

node --permission \
  --allow-fs-read=./content/ \
  --allow-fs-write=./dist/ \
  build-content.mjs

Що станеться, якщо залежність усередині build-content.mjs спробує зазирнути в домашню директорію:

Error: Access to this API has been restricted
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/Users/you/.ssh/id_ed25519'

Процес навіть не дізнається, чи існує файл. Мережі немає взагалі – --allow-net не передано, тож ексфільтрувати дані нікуди. child_process теж мертвий: обхід через curl у дочірньому процесі не спрацює.

Перед enforce-режимом корисно тиждень пожити в аудиті:

node --permission-audit build-content.mjs
import diagnostics_channel from 'node:diagnostics_channel';

diagnostics_channel
  .channel('node:permission-model:fs')
  .subscribe((msg) => {
    console.warn(`[audit] ${msg.permission}: ${msg.resource}`);
  });

Так ти отримаєш реальний список ресурсів, які потрібні процесу, – і не зламаєш продакшен першого ж дня суворими правилами.

Є і runtime-API: process.permission.has('fs.read', path) для перевірки та process.permission.drop(...) – незворотне звуження прав «на льоту» (прочитав конфіг на старті – відмовився від права читати його директорію назавжди).

Що модель НЕ гарантує

Це найважливіший розділ – документація Node.js тут приємно чесна:

  • Символічні лінки не зупиняють. Symlink усередині дозволеної директорії, що вказує на ~/.ssh, – документований обхід. Якщо обробляєш чужі файли, перевіряй лінки сам.
  • Уже відкриті file descriptors живуть поза моделлю (операції типу fchmod на відкритих fd під моделлю просто вимкнені повністю).
  • Worker-и не успадковують обмеження – тому --allow-worker і варто давати лише свідомо.
  • Деякі прапорці (--env-file, конфіги OpenSSL) читають файли до ініціалізації моделі.
  • Код у межах дозволених шляхів може робити будь-що: дозволив писати в ./dist – залежність може писати в ./dist сміття.
  • Окремі підсистеми мають власні дірки: наприклад, node:sqlite розширення не завантажуються, а деякі шляхи доступу модель не покриває.

Звідси правильна ієрархія захисту: рев'ю залежностей → Permission Model → контейнер/VM з обмеженнями ОС. У CI це поєднується природно: контейнер дає зовнішній периметр, --permission – внутрішній, дрібнозернистий.

Типові помилки

  1. Увімкнути enforce одразу в проді. Спочатку --permission-audit і тиждень логів – інакше перший же legitimate edge case покладе сервіс.
  2. --allow-fs-read=* «щоб працювало». Це вимикає половину сенсу. Витрать 10 хвилин на реальний список шляхів з аудиту.
  3. Вважати це пісочницею для недовіреного коду. Запускати чужий довільний код треба в ізоляції рівня ОС/VM – модель лише зменшує радіус ураження.
  4. Забути про тести. Тест-ранер теж треба запускати з тими ж прапорцями, інакше в CI усе зелене, а в проді – ERR_ACCESS_DENIED.
  5. Дозволити child_process «на всяк випадок». Дочірній процес успадковує повні права користувача – це найширша дірка з усіх прапорців.

Практичне завдання

Візьми будь-який свій скрипт із package.json (build, кодоген, лінтер контенту) і:

  1. Запусти його з --permission-audit + підписка на diagnostics channel – зафіксуй, куди він реально ходить.
  2. Склади мінімальний набір --allow-* і переведи скрипт в enforce-режим.
  3. Додай у скрипт навмисну спробу прочитати ~/.ssh/known_hosts і переконайся, що отримуєш ERR_ACCESS_DENIED з правильним resource.
  4. Бонус: винеси прапорці в node.config.json (--experimental-default-config-file), щоб команда не розповзалась по Makefile-ах.

Пів години роботи – і твій білд більше не має прав, яких йому ніхто не обіцяв.