dsh-sealed-secrets
v0.3.12
Published
Encrypted credential vault for DeepSeek Harness: passphrase- or DPAPI-protected, decrypted in memory, injected into a command's environment, expanded from [[placeholders]], exposed through ctx.credentials, and audited without values.
Maintainers
Readme
dsh-sealed-secrets
Encrypted credential vault for DeepSeek Harness.
Пакет называется dsh-sealed-secrets (имя dsh-secrets в npm занято); репозиторий,
каталог данных и маршруты остались с прежним именем.
A .env file in the workspace is plaintext that any local process can read and any
backup can copy; this plugin replaces it with one AES-256-GCM vault outside the
workspace and hands values only to the command that needs them.
secret run -- python deploy.py # values arrive as ordinary env vars
deploy --token [[dsh-secrets:HA_TOKEN]] # or a placeholder the host expands
secret list # key names onlyHonest limits: the model can still print a value on purpose (that is what the placeholder masking and the audit log are for), and anything running as your user can read the vault while it is unlocked. This protects against a plaintext file, a copied directory and a stolen disk — not against code running in your session.
Шифрованное хранилище учётных данных для DeepSeek Harness.
Что нового в 0.2
Восемь механизмов, которых не хватало до этого:
| Механизм | Что даёт |
|---|---|
| Плейсхолдеры | модель пишет [[dsh-secrets:NAME]], значение подставляет хост, а вывод команды маскируется обратно в плейсхолдер |
| Фоновая зачистка логов | значения вычищаются из логов сессий по расписанию, а не только вручную |
| accessMode + подтверждение | readonly / ask / auto: ask проводит каждый secret run через штатный канал approval |
| Автоблокировка | ключ затирается после N секунд бездействия |
| Профили инструментов | basic (list/run) или full (+status/audit) |
| Провайдер ctx.credentials | хранилище отдаёт секреты штатному шву harness, а не только своему инструменту |
| Переход C ↔ B | перенос ключей из .credentials.yaml в сейф с проверкой по диску, DPAPI-печатью, бэкапом профиля и откатом |
| Аудит без значений | кто, что и с какими ключами делал; значений в журнале нет по конструкции |
| DPAPI-ключ | на Windows ключ хранилища запечатывается под текущего пользователя — старт без ввода пароля |
Что он делает
- Хранилище:
~/.dsh/secrets/vault.json— AES-256-GCM, ключ выводится из пароля через scrypt (N=2^17). В файле нет ни одного открытого значения. - Разблокировка: панель 🔑 в правом нижнем углу Web-интерфейса, либо
автоматически из DPAPI-печати (
keychain: dpapi). Пароль живёт только в памяти процесса хоста; при блокировке ключ затирается. - Доступ для модели: инструмент
secret:secret list— только имена ключей и их плейсхолдеры;secret run— запускает команду, подставив значения обычными переменными окружения ($env:HA_TOKEN). Значения не возвращаются в ответ и не попадают в лог сессии; всё, что команда напечатала, маскируется.
- Панель умеет создавать хранилище, импортировать
.envцеликом, добавлять и удалять отдельные ключи, блокировать хранилище и включать/выключать DPAPI-печать. Поле имени работает и как поиск: ввод фильтрует список по вхождению, буквы приводятся к верхнему регистру. Переключение режимов убрано под спойлер «Режим хранения», рядом с ним — «?» с кратким разбором рисков.
Конфигурация
- id: dsh-sealed-secrets
name: dsh-sealed-secrets
config:
accessMode: auto # readonly | ask | auto
lockTimeoutSeconds: 900 # 0 — не блокировать по бездействию
tools: basic # basic | full
provider: auto # auto | off — регистрировать ctx.credentials
keychain: dpapi # off | dpapi (Windows)
sweep:
enabled: true
intervalMinutes: 30
skipMinutes: 5Ошибка в значении (accessMode: sometimes) валит активацию плагина с внятным
текстом — молча менять политику безопасности нельзя.
Режимы доступа
readonly— модели доступен толькоsecret list;secret runотклоняется.ask— каждыйsecret runидёт черезctx.approval.request; без сервиса подтверждений вызов закрывается ошибкой, а не проходит молча.auto— по умолчанию; запуск без подтверждений.
Отказы тоже попадают в аудит с outcome: denied.
Плейсхолдеры и маскирование
Токен [[dsh-secrets:NAME]] можно ставить туда, где ожидается значение:
secret run -- curl -H "Authorization: Bearer [[dsh-secrets:HA_TOKEN]]" https://apiКвадратные скобки выбраны намеренно: {{…}} — синтаксис переменных системного
промпта DSH, и имя вида dsh-secrets:NAME он отвергает, роняя всю секцию
промпта. [[…]] шаблонизатор не трогает, поэтому плейсхолдер можно упоминать в
промпте, описании инструмента и командной строке без последствий.
- токен вне кавычек заменяется на строку в одинарных кавычках (PowerShell-экранирование);
- внутри
"..."— экранируется для двойных кавычек, внутри'...'— удвоение кавычки; - если токен склеен с другими символами слова, значение подставляется как есть, а
факт отмечается в отчёте (
unquoted) — вставлять кавычки в середину слова нельзя; - неизвестный токен — ошибка, а не пустой аргумент.
После выполнения значения, похожие на секреты, заменяются обратно на
плейсхолдеры в stdout/stderr, поэтому Write-Output $env:TOKEN не утечёт в
транскрипт. Отбор — тот же, что у зачистки логов (scrub.js): логины, имена,
пути и URL остаются читаемыми, а не превращаются в плейсхолдеры (иначе слово
config в имени файла выглядело бы как секрет).
Автоблокировка
lockTimeoutSeconds — срок бездействия, по умолчанию 0 (выключено). Проверка
идёт на каждом обращении к хранилищу и дополнительно по таймеру; блокировка
пишется в аудит как source: autolock.
В режиме «Единый сейф» включать TTL стоит с пониманием: сейф — единственный источник ключей моделей, поэтому после автоблокировки запросы к модели будут падать, пока вы снова не откроете панель. В режиме «Два хранилища» TTL ни на что больше не влияет.
DPAPI-ключ (Windows)
Ключ хранилища (32 байта + соль) запечатывается ConvertFrom-SecureString и
лежит рядом в vault.dpapi. Автоматически он применяется только в режиме
«Единый сейф»: в отдельном режиме на старте сейфу нечего делать, поэтому он
остаётся запертым, а печать просто ждёт переключения. Так расшифрованные
секреты не висят в памяти весь сеанс без пользы.
Честно: DPAPI отвечает «это тот же пользователь», а не «это тот же процесс».
Другой процесс под вашей учётной записью сможет расшифровать файл так же, как и
сам DSH. Пароль при этом не хранится нигде — ни в открытом виде, ни в конфиге.
Выключить: POST /dsh-secrets/seal {enabled:false} (кнопка в панели) — файл удаляется.
Провайдер ctx.credentials
Штатный шов harness: настройки хранят ссылку (DEEPSEEK_API_KEY), провайдер
владеет значением. Плагин умеет быть таким провайдером, и это ровно тот
компромисс «свой шифрованный файл + регистрация провайдера»:
- при
provider: autoплагин смотрит, зарегистрирован ли ужеctx.credentials(ctx.get('credentials')). Если да — остаётся обычным хранилищем (provider: external), ничего не подменяя; - если шов пуст — регистрирует свой провайдер (
ctx.provide('credentials', …)); - имена ссылок читаются из шифрованного payload; ниже vault идут слои
env→ проектный.env→~/.dsh/.env; - записи второго пространства (
<scope>/<id>,api-key/grant) живут там же и поддерживаютmodifyRecordс read-decide-write.
Чтобы это включилось в бою, из композиции должен уйти штатный
@deepseek-ai/dsh-credentials-local (он монтируется в @deepseek-ai/dsh-base).
Иначе провайдер остаётся в режиме external — и это ожидаемое поведение, а не
поломка. Цена включения: пока хранилище заблокировано, resolve возвращает
undefined, то есть ключи LLM не работают до разблокировки. Поэтому включать
это стоит вместе с keychain: dpapi. Файл .credentials.yaml при включённом
провайдере больше не читается — перенесите ключи в сейф заранее.
Переход: «Два хранилища» ↔ «Единый сейф»
Два состояния установки:
- Два хранилища (
separate, по умолчанию): штатный~/.dsh/.credentials.yamlи сейф живут раздельно. Ключи моделей — в файле, остальные секреты — в сейфе. - Единый сейф (
unified): сейф становится провайдеромctx.credentials, и ключи моделей, OAuth-записи аккаунта и записи плагинов читаются из шифрованного файла.
Промежуточные статусы: unified-ready (скопировано и проверено, профиль ещё не
тронут), unified-pending (записано, ждёт перезапуска), unified-failed
(перезапуск не сделал сейф провайдером).
Переход делается из панели 🔑 и состоит из пяти шагов:
- Автозапуск (
POST /dsh-secrets/seal). Ключ сейфа запечатывается DPAPI. Это не удобство, а условие: при старте DSH плагинconnectionпишет служебную запись (client-connection/browser-session) прямо в активации, и если сейф в этот момент заперт, обязательный плагин падает и приложение не запускается. Поэтомуprepareбез запечатанного ключа отказывает. Сам провайдер при этом ждёт идущую разблокировку, а не отвечает «заперто»: вызовы, начатые во время активации, дожидаются восстановления ключа. - Подготовка (
POST /dsh-secrets/mode/prepare). Читает имена ссылок из.credentials.yaml(только имена), значения забирает у смонтированного штатного провайдера черезresolve— то есть тем же вызовом, которым пользуются потребители. Копируются только ссылки с источникомfile(значения изenv/.envостаются слоями ниже и не дублируются). Записи (<scope>/<id>) забираются черезlistRecords/readRecord. - Сверка: сейф переоткрывается с диска тем же ключом и сравнивается с исходником; ключ ещё и распечатывается из DPAPI обратно и сверяется побайтово. Не сошлось — переход не начинается.
- Применение (
/mode/commit). Пишет вprofiles/<имя>/cordis.patch.ymlодну строку для строкиcredentials:
Перед записью делается бэкап- id: credentials name: '@deepseek-ai/dsh-credentials-local' disabled: truecordis.patch.yml.bak-mode-<дата>, после — перечитывание и проверка, что строка на месте. Маркер перехода (~/.dsh/secrets/mode.json) переводится вpending-restart. - Перезапуск DSH. При следующем старте плагин видит маркер и записывает
фактический итог:
active, если он действительно стал провайдером, илиfailed, если нет (тогда панель предложит откат).
Откат (/mode/rollback) восстанавливает файл из бэкапа и удаляет маркер.
Штатный .credentials.yaml не изменяется и не удаляется ни на одном шаге,
поэтому откат возвращает ровно прежнее состояние.
Важные честности:
- Автозапуск обязателен. Без запечатанного ключа переход не начинается:
плагин
connectionпишет служебную запись в активации, и запертый сейф валит обязательный плагин, а с ним весь запуск. Проверено на практике:DesktopHostFatalError: startup failed: 1 required plugin did not activate. Если такое случилось — кнопка «Disable third-party plugins» в окне ошибки возвращает композицию и.credentials.yamlостаётся нетронутым; - сам провайдер ждёт идущую разблокировку: вызовы, начатые во время активации, дожидаются восстановления ключа, а не получают «заперто»;
- слои профиля живо перезагружаются: запись патча может примениться сразу,
без перезапуска. Если живая пересборка не сложится, DSH оставит последнее
рабочее дерево, а статус покажет
unified-failed— тогда нужен перезапуск или откат; - переключение доступно только из панели (маршруты с проверкой Origin и
confirm: true). Из инструмента модели доступно лишь чтение состояния: действиеmodeв профилеtools: full.
Аудит
~/.dsh/secrets/audit.log — JSONL, ротация при 512 КБ (одна предыдущая копия).
Пишутся unlock, lock, autolock, run, list, set, delete, import,
sweep, provider. В журнале есть имена ключей, код выхода и длительность, но
нет значений: поля собираются по закрытому словарю, лишнее отбрасывается.
Фоновая зачистка
sweep.enabled запускает периодический проход по ~/.dsh/sessions и
projection-cache: значения заменяются на [redacted:NAME], каждая перезапись
проходит round-trip проверку и оставляет датированный бэкап. Файлы, изменённые
меньше skipMinutes назад, пропускаются и обрабатываются в следующий раз.
Ручной scrub-cli.mjs остаётся для отчёта и разового прогона.
Профили инструментов
basic—secretс действиямиlist,run;full— плюсstatusиaudit.
Схема и обработчик проверяют профиль дважды: ручной вызов скрытого действия отклоняется, а не просто не показывается модели.
Установка
# из репозитория, зафиксированный тег:
dsh plugin --profile desktop add "git+https://gitverse.ru/GigaVik/dsh-secrets.git#v0.3.6"
# локальная разработка:
dsh plugin --profile desktop add link:<путь>\dsh-secrets
# из npm, когда пакет опубликован:
dsh plugin --profile desktop add dsh-sealed-secretsТег фиксирует коммит: правки в main сами не приезжают — нужен новый тег и
повторный add. После pnpm add профиль перезагружается, инструмент secret
появляется сразу; панель — после обновления страницы.
Терминал (включая DSH Mobile)
unlock.cmd/unlock.mjs — инструменты 0.1.x, в пакет они не входят. Начиная с
0.3.6 в пакете есть cli.mjs (bin dsh-sealed-secrets), который работает с тем
же HTTP-фасадом, что и панель 🔑, и сам находит запущенный хост: --url, затем
DSH_WEB_URL из окружения оболочки, затем ~/.dsh/secrets/endpoint.json,
который плагин пишет при старте.
# из установленного пакета (путь не зависит от рабочей папки):
$cli = "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-sealed-secrets\cli.mjs"
node $cli status
node $cli unlock --plain # пароль в cmd; --plain делает ввод видимым (мобильная клавиатура)
node $cli unlock # без --plain: ввод без эха, звёздочки
node $cli lock
node $cli list
node $cli set NPM_TOKEN --stdin # вставить, Enter, Ctrl+Z, Enter
node $cli set HA_TOKEN --from .token --keep
node $cli delete OLD_KEY --yes
node $cli import --from .env --replaceТот же файл есть в репозитории: default-workspace\dsh-secrets\cli.mjs.
Плагин также кладёт рядом с сейфом короткий запускатор, который берёт node из
рантайма DSH (шим из .bin требует node в PATH, а на десктопе его обычно нет):
%USERPROFILE%\.dsh\secrets\vault.cmd status
%USERPROFILE%\.dsh\secrets\vault.cmd unlock --plain
%USERPROFILE%\.dsh\secrets\vault.cmd set NPM_TOKEN --plainМеню и короткая команда
С 0.3.9 у CLI есть интерактивное меню: vault без аргументов печатает пункты
1) статус … 8) сменить пароль, 0) выход, и на телефоне достаточно ввести цифру.
Ввод в меню видимый (пароль и значение показываются), это осознанный компромисс
ради мобильной клавиатуры — если это неприемлемо, используйте vault unlock
без --plain (ввод без эха).
Сменить пароль хранилища (соль и ключ создаются заново, файл перешифровывается на месте):
vault passwd # спросит новый пароль дважды, ввод без эха
vault passwd --plain # то же, но символы видныЧтобы не печатать длинный путь, один раз положите запускатор в рабочую папку:
%USERPROFILE%\.dsh\secrets\vault.cmd shortcut
vault status
vaultvault shortcut создаёт vault.cmd (или vault) в текущем каталоге с
абсолютными путями к node и cli.mjs, после чего vault работает как обычная
команда в этой папке. Терминал рабочей области стартует в корне workspace, так
что этого достаточно.
Экспорт в парольный ZIP
Экспорт доступен только при разблокированном сейфе и заново спрашивает пароль
хранилища — чтобы тот, кто оказался за вашим открытым DSH, не смог выгрузить
все ключи. Внутри архива — плоский dsh-secrets.env; сам ZIP шифруется AES-256
новым паролем, который задаётся при экспорте и нигде не сохраняется.
- панель 🔑 → «Экспорт .env (ZIP)»: пароль хранилища, пароль архива (дважды) →
скачивается
dsh-sealed-secrets-YYYYMMDD.zip; - терминал:
vault export --to secrets.zip(или пункт9в меню); - 7-Zip встроен (7z-wasm, ~1.8 МБ): ставить ничего не нужно, работает и на машине без установленного 7-Zip;
- пароль архива не попадает в командную строку процесса: он передаётся внутрь WASM и живёт только в памяти Node на время сборки архива.
Скачанный ZIP лежит в «Загрузках»; пароль архива не хранится — потеряли, файл не открыть.
Значения не печатаются — только имена ключей и длина; для set/import
хранилище должно быть разблокировано в панели 🔑.
webServer опционален: в headless-композиции инструмент и секция промпта
работают, панели нет.
Проверка
node test/smoke.mjs # манифест, крипто, подмена, HTTP, профили, headless
node test/placeholders.mjs # подстановка/экранирование + реальный PowerShell-процесс
node test/provider.mjs # ctx.credentials: ссылки, записи, locked, отсутствие plaintext
node test/sweep.mjs # реальные zstd-логи: замена, бэкап, пропуск активной сессии
node test/audit.mjs # словарь событий, ротация, отсутствие значений
node test/lock.mjs # TTL: активность продлевает, простой блокирует, autolock в аудите
node test/access.mjs # readonly / ask (approve, reject, cancel, нет сервиса)
node test/mode.mjs # редактор патча профиля + prepare/commit/перезапуск/rollback
node test/scrub.mjs # контейнер Zstandard и точечная замена
node test/dpapi.mjs # реальный DPAPI round-trip и автозапуск без пароляnode берётся из рантайма DSH:
...\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe.
Тесты не трогают ваш настоящий сейф: каждый работает в своей временной папке и
не читает ~/.dsh/secrets.
Формат файла
{
"v": 1,
"kdf": { "name": "scrypt", "N": 131072, "r": 8, "p": 1, "salt": "..." },
"cipher": { "name": "aes-256-gcm", "nonce": "...", "tag": "..." },
"ct": "...",
"createdAt": "...", "updatedAt": "..."
}Открытый payload: { "version": 1, "keys": {…}, "records": {…} }. AAD привязан к
параметрам KDF, поэтому подмена заголовка ломает расшифровку. Файл, записанный
до появления records, открывается с пустой картой записей.
Чего он не делает
- Не прячет значения от модели навсегда. Маскирование — по точному совпадению строки; значение, перекодированное (base64, изменённый регистр), оно не поймает.
- Не защищает от кода под вашей учётной записью. Пока хранилище разблокировано, значения доступны процессу DSH и всему, что он породит. DPAPI-печать не меняет это: она убирает пароль, а не доступ.
- Не чистит историю мгновенно. Sweep работает по расписанию и пропускает
свежие файлы; для «здесь и сейчас» есть
scrub-cli.mjs --apply. - Пароль восстановить нельзя: забыли — хранилище потеряно (DPAPI-печать спасает, только если она была включена).
Совместимость
- Формат хранилища
v1. Сейф, созданный 0.1.x, открывается текущей версией:keysчитаются,recordsпоявляется пустой. Обратная совместимость тоже есть — старый код открывает новый файл и видит ключи, — но сохранение из 0.1.x выбрасывает секциюrecords. Перед откатом на 0.1.x сделайте копиюvault.json; проверяется это вnode test/compat.mjs(нужен каталог старой версии, по умолчанию~/.dsh/plugins/dsh-secrets.backup-0.1.1, иначе тест пропускается). - Конфиг: прежние конфиги без новых полей работают на значениях по умолчанию;
неизвестное значение (
accessMode: sometimes) намеренно валит активацию. - Панель:
client.jsи хост — один пакет, версии не расходятся; браузер получает скрипт сno-store. - DSH: используются только публичные швы (
tools,shell,systemPrompt,webServer, функциональный плагин иctx.provide), без импортов из@deepseek-ai/*. Проверено на desktop0.2.1-rc.5; другие версии не проверялись.
Публикация
Имя dsh-secrets в npm занято (0.0.1, «name reserved» у другого автора),
поэтому пакет называется dsh-sealed-secrets. Репозиторий и каталог данных
остались прежними (GigaVik/dsh-secrets, ~/.dsh/secrets), внутренние маршруты
/dsh-secrets/* и префикс плейсхолдера [[dsh-secrets:NAME]] — стабильные
идентификаторы, они не переименовываются вместе с пакетом.
Перед публикацией в npm:
- заполнить
repository,homepage,bugs,authorвpackage.json; npm run test(иnode test/compat.mjs, если есть старая версия);npm packи посмотреть содержимое —filesуже ограничивает пакет (lib/, патч, оба CLI, README, LICENSE);- помнить, что установка даёт плагину доступ к секретам пользователя: README с границами и разделом «Чего он не делает» — часть поставки, а не приложение.
npm login
npm publish --access publicЛицензии
- сам плагин — MIT (
LICENSE); - ZIP создаёт 7-Zip через
7z-wasm(WASM-сборка, ~1.8 МБ). Лицензия 7-Zip: LGPL с ограничением unRAR; текст — вnode_modules/7z-wasm/License.txt, ссылка на исходники: https://www.7-zip.org/; readline-sync(зависимость7z-wasm) — MIT.
7z-wasm — обычная зависимость npm: ставится вместе с плагином и не требует
отдельной установки 7-Zip на машине.
