
Тонкий TypeScript + клиент Python с нулевыми зависимостями и рецепты для ограничения высокорисковых действий с помощью утверждения passkey, привязанного к полезной нагрузке.
Тонкий TypeScript-клиент без зависимостей для Cosignet — человек в цикле для одобрения высокорисковых действий AI-агентов, с подписями, привязанными к полезной нагрузке, с использованием passkey.
Включайте человека в цикл перед выполнением опасного действия: приостановите его, получите явное одобрение с помощью passkey от человека (Face ID / Touch ID / Windows Hello / ключ безопасности) и продолжайте только при наличии подписанного решения, привязанного к точной полезной нагрузке — измените действие после подписания, и подпись перестанет совпадать. Cosignet — это слой одобрения и подтверждения, а не исполнитель или движок политик. Работает везде, где есть глобальный fetch + Web Crypto: Node 18+, Cloudflare Workers, Deno и браузеры.
Статус: ранний доступ. Опубликовано на npm как
@cosignet/sdk.
npm install @cosignet/sdk
import { Cosignet } from '@cosignet/sdk';
const cosignet = new Cosignet({ apiKey: process.env.COSIGNET_API_KEY! });
const decision = await cosignet.requestApproval(
{
username: 'alex',
action: 'Wire transfer to vendor',
payload: { to: 'acct_8821', amount_usd: 4200, memo: 'INV-2025-118' },
notify: 'telegram_or_email',
},
{ onCreated: (c) => console.log('Approve here:', c.url) },
);
if (decision.status === 'approved') {
// continue — decision.rawAssertion — это подписанное доказательство
} else {
// 'rejected' | 'expired' | 'pending' (тайм-аут)
}
requestApproval выполняет длительный опрос через ваше собственное исходящее соединение (~25-секундные шаги), поэтому он работает из CLI-инструментов и изолированных VPC за NAT/межсетевыми экранами — не требуется входящий вебхук, открытый порт или публичный IP.
Если ваша система не может получить подписанное одобрение, защищённое действие не должно выполняться. Недоступность не должна превращаться в автоматическое одобрение. Любой тайм-аут, сетевая ошибка или 5xx следует рассматривать как «не одобрено».
| Сигнал | Значение | Ваши действия |
|---|---|---|
status: approved (подпись проверена) | Человек одобрил именно эту полезную нагрузку | Продолжить |
status: rejected | Человек отклонил | Не выполнять; сообщить запросившему |
status: expired | Никто не принял решение вовремя | Не выполнять; запросить повторно, если необходимо |
| Тайм-аут, сетевая ошибка или 5xx | Неизвестное состояние | Не выполнять; повторить с экспоненциальной задержкой; уведомить после N неудач |
Замечания по реализации:
Полные рекомендации по доступности и аварийному отключению см. в Когда Cosignet недоступен.
const created = await cosignet.createConfirmation({ username, action, payload });
const status = await cosignet.getConfirmation(created.id, { wait: 25 }); // длительный опрос
Передайте idempotencyKey (отправляется как заголовок Idempotency-Key), чтобы повторный вызов create — например, после сетевого сбоя, прервавшего длительный опрос, — с тем же ключом и теми же действием/полезной нагрузкой возвращал исходное подтверждение (idempotent: true) вместо создания дубликата или повторного уведомления утверждающего. Повторное использование ключа с другими параметрами отклоняется с кодом 422.
await cosignet.createConfirmation({
username: 'alex',
action: 'Wire transfer to vendor',
payload: { to: 'acct_8821', amount_usd: 4200 },
idempotencyKey: 'wire-INV-2025-118', // стабильно для каждой логической операции
});
Передайте returnUrl, чтобы на странице одобрения отображалась кнопка "Вернуться на <хост>" после разрешения запроса (одобрено / отклонено / истекло) — удобно, чтобы человек мог вернуться в ваше приложение для повторного запуска действия, истекшего по тайм-ауту. URL должен быть HTTPS и отображается только как ссылка для перехода (никогда не запрашивается на стороне сервера).
await cosignet.requestApproval({
username: 'alex',
action: 'Wire transfer to vendor',
payload: { to: 'acct_8821', amount_usd: 4200 },
returnUrl: 'https://app.example.com/approvals',
});
notify ('none' | 'telegram' | 'email' | 'telegram_or_email') управляет персональным оповещением конкретного подписывающего:
telegram — личное сообщение в связанный Telegram утверждающего (telegram_or_email предпочитает этот способ, если утверждающий связал чат).email — письмо на собственный адрес утверждающего. Приоритет: прямой email, который вы указали для утверждающего в панели управления (только для доставки), затем его подтверждённый email участника, затем контактный адрес учётной записи как последний вариант.telegram_or_email — Telegram, если связан, иначе email выше.none — без персонального оповещения.Отдельно командное оповещение — это рассылка в Slack в общий канал, настраивается для каждой учётной записи в панели управления. Оно дополняется и всегда срабатывает, когда настроено, независимо от notify (так что notify:'none' всё равно отправляет сообщение в Slack). Утверждающие получают свои Telegram и email из раздела "Утверждающие" панели управления.
Примечание о раскрытии при
public: true: email, сохраняемый в журнале прозрачности, — это назначенный подтверждённый email учётной записи (выбран в настройках панели управления, как ответственная сторона — не отдельный подписывающий). Прямой email для уведомлений используется только для доставки и никогда не участвует в хэше для раскрытия.
Каждый канал — только со ссылкой: уведомление содержит URL для одобрения и никогда не содержит action или payload. Одобрение всегда требует привязки passkey утверждающего к полезной нагрузке на странице подтверждения — нет возможности одобрить в канале.
verifyWebhookSignature пересчитывает HMAC-SHA256 в шестнадцатеричном формате от сырого тела запроса и сравнивает его с заголовком Cosignet-Signature за константное время. Передавайте точные байты, которые вы получили — повторная сериализация JSON изменяет подпись.
import { verifyWebhookSignature } from '@cosignet/sdk';
const ok = await verifyWebhookSignature({
body: rawBody, // сырая строка, не перепарсенный JSON
signature: req.headers['cosignet-signature'],
secret: process.env.COSIGNET_WEBHOOK_SECRET!,
timestamp: req.headers['cosignet-timestamp'], // необязательная защита от повторной отправки
toleranceSeconds: 300, // необязательно
});
if (!ok) return res.status(401).end();