Read in:
Русский

Секреты для код-ролей

Код-роли, которая ходит во внешний API, нужен токен. Дать его можно двумя способами, и ни один не костыль для другого.

Обычный способ — окружение запуска: значение держит оператор сервиса codellm, а код читает его как любую переменную окружения. Если у вас уже подняты Docker secrets, секреты Kubernetes или Vault agent, ваши креденшелы уже лежат там, и переносить их никто не просит.

Второй способ — тот же, что у Rails с credentials.yml.enc: зашифрованное значение коммитится, ключ остаётся снаружи. Ролевая заметка несёт шифротекст, ключ держит codellm, и значение расшифровывается для одной этой роли в момент запуска. За ним тянутся, когда секрет принадлежит одной роли или когда добавить его надо без правки compose и перезапуска.

В статье:

Из окружения запуска

Что увидит код, решает оператор codellm — списком имён переменных:

CODELLM_EXPOSE_ENV=KRISP_TOKEN,KRISP_BASE_URL
CODELLM_EXPOSE_ENV_PREFIX=KRISP_

По умолчанию не открыто ничего. Переменная, которой нет ни в одном списке, до кода не доедет, что бы роль ни просила.

Роль читает её так:

import os
token = os.environ["KRISP_TOKEN"]

По умолчанию каждая код-роль на этом codellm видит все разрешённые переменные. Роль может сузить свою долю:

env_passthrough: [KRISP_TOKEN]
env_prefix: [KRISP_]

Это только сужает. Границей остаётся список оператора: объявить переменную, которую он не разрешил, — значит не получить ничего, а роль, не объявившая ни одного поля, по-прежнему видит весь список. Сужать стоит, когда на одном codellm живёт несколько ролей и у каждой свой креденшел.

Цена этого пути — добавление секрета остаётся действием оператора: новая переменная означает правку деплоя и перезапуск. Именно это убирает второй путь.

Запечатать значение

Ключ — строка ровно в 32 байта в окружении codellm, по умолчанию SEAL_KEY. Сгенерируйте один раз:

openssl rand -hex 16     # 32 символа

Положите его в окружение codellm так же, как любой другой серверный секрет, и запечатайте значение:

printf %s "$KRISP_TOKEN" | codellm seal
sealed:v1:9nR2v0QkX8tWc1pE...

Секрет уходит в stdin, а не аргументом: флаг был бы виден в таблице процессов и осел бы в истории шелла.

Из браузера

Терминал на хосте codellm не обязателен. Та же операция есть формой на /_system/codellm/seal, за тем же админским логином, что и остальной codellm: укажите переменную с ключом (SEAL_KEY, если не используете другую), вставьте значение и скопируйте результат sealed:v1:... прямо в заметку.

Значение уходит в теле формы и никогда не попадает в URL — секрет в URL оседает в логах прокси, истории браузера и заголовке Referer. Страница с результатом показывает только запечатанный блоб, но не то, что вы ввели.

Путь настраивается (CODELLM_SEAL_PATH), если значение по умолчанию с чем-то конфликтует.

Объявить его в роли

Вставьте результат во frontmatter ролевой заметки и перечислите поля, которые надо открывать:

---
fleet_id: codellm
mode: cron
cron_schedule: "*/30 * * * *"
write_patterns: ["transcripts/**"]

unseal: [krisp_token]
krisp_token: sealed:v1:9nR2v0QkX8tWc1pE...
krisp_base_url: https://api.krisp.ai
---

unseal — список имён полей, а не переключатель. Когда поля названы явно, испорченный блоб падает с понятной ошибкой сразу, а не едет дальше обычной строкой, чтобы через двадцать минут вернуться из API как 401.

Остальной frontmatter остаётся собой. krisp_base_url не секрет, и церемоний ему не нужно.

Прочитать в коде

Два отдельных доступа, и разделены они намеренно:

import fleetkit

cfg = fleetkit.frontmatter()   # собственные настройки роли
sec = fleetkit.secrets()       # то, что открыто для этого запуска

base_url = cfg.krisp_base_url.rstrip("/")
token = sec.krisp_token

Секреты не подмешиваются во frontmatter по практической причине: bag доставки — это то, что распечатывают при отладке роли. print(cfg) с токеном внутри опубликовал бы его в заметку, то есть ровно туда, откуда весь механизм его и убирает. Разделение оставляет секрет вне объекта, который чаще всего дампят целиком.

Шифротекст при этом виден в cfg.krisp_token, так что роль может отличить запечатанное поле.

Node-роли читают те же два:

const cfg = fleetkit.frontmatter();
const sec = fleetkit.secrets();

От чего это защищает

Граница уже, чем обещает слово «зашифровано», поэтому её стоит назвать точно.

Секрет не лежит открытым текстом нигде, куда попадает волт: ни в синхронизации Obsidian, ни в истории git, ни в бэкапе заметок, ни в дампе базы. Тот, кто прочитает ваши заметки, не узнает из них ничего. Каждый блоб открывается только для той роли, которая его несёт, поэтому неаккуратная роль не выльет чужой креденшел в заметку.

Тот, кто запускает codellm, читает любой открытый секрет. Исполняемому коду нужен плейнтекст, значит плейнтекст есть в этом процессе. Это не способ спрятать креденшел от собственного оператора сервера, и никакая схема хранения им быть не может: машина, которая пользуется секретом, его видит.

Роль, которая решит записать свой токен в заметку, тоже это сделает. Проверка границ смотрит, в какие пути роль может писать, и никогда — что именно она туда пишет. Останавливает это не шифрование, а то, что роль написали вы.

Сам ключ в песочницу не попадает. Код ничего не расшифровывает — он получает только значения, объявленные его же заметкой. А если ключ настроить так, чтобы он передавался в исполняемый код, codellm откажется стартовать: одна такая строка отменила бы всю схему, и снаружи ничего бы не сломалось.

Ротация ключей

Роль может назвать свой ключ:

unseal: [krisp_token]
unseal_env_key: SEAL_KEY_V2

Так ротация становится постепенной вместо общего дня переезда. Добавьте SEAL_KEY_V2 рядом со старым ключом, перепечатывайте и переводите роли по одной, а старый ключ уберите, когда на него больше никто не ссылается.

Перепечатать значит поправить заметку: замена скомпрометированного токена — это правка и синхронизация, а не обновление строки в базе. И старый шифротекст остаётся в истории git волта: без ключа он нечитаем, но ротация его оттуда не стирает, так что утечку SEAL_KEY считайте утечкой всего, что им запечатано.