Canonical secrets policy for agents + best-effort redaction
deploy / deploy (push) Canceled after 0s

- /security.md is the single canonical policy page (control plane principle,
  SECRET_TRANSFER_REQUIRED, out-of-band transfer via SSH)
- short version with link embedded in /create.md, room .md, llms.txt, landing,
  MCP create tool response; docs updated
- redactSecrets() applied on input to messages, questions, resolutions,
  contracts and room brief/goal/participant instructions (best-effort: PEM
  keys, JWTs, common token prefixes, password/token/secret assignments)
This commit is contained in:
2026-09-06 21:49:15 +03:00
parent bb0eb6979e
commit fde1ae152f
11 changed files with 162 additions and 9 deletions
+1
View File
@@ -3,4 +3,5 @@ export { RendezvousService, newToken } from './service.js';
export { LIMITS } from './limits.js';
export { computeAdvice, computeTurn, availableActions } from './advice.js';
export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
export { secretsPolicyFull, secretsPolicyShort, redactSecrets } from './security.js';
export * from './types.js';
+4
View File
@@ -62,6 +62,8 @@ export interface MarkdownInput {
resolvedQuestions: QuestionView[];
contractMarkdown: string;
contractVersion: number;
/** Short secrets policy (links to the canonical /security.md). */
secretsPolicyShort: string;
}
export function renderRoomMarkdown(i: MarkdownInput): string {
@@ -73,6 +75,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
parts.push('');
parts.push('**First action:** if the Participants list below marks you as "has NOT reported" — POST /api/rooms/{room_id}/join (Authorization: Bearer your token) to announce you have taken the room into work. The other side sees it.');
parts.push('');
parts.push(i.secretsPolicyShort);
parts.push('');
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
parts.push(`- **Status:** ${i.status}`);
+87
View File
@@ -0,0 +1,87 @@
/**
* Canonical secrets policy for AI Rendezvous.
*
* The full text is served at GET /security.md (one canonical location).
* Everywhere else agent-facing text lives, embed only SECRETS_POLICY_SHORT
* (which links to the canonical page) instead of duplicating the full text.
*/
export const SECRETS_POLICY_SHORT =
'Security / secrets: NEVER put secret VALUES (passwords, API keys, tokens, private keys, cookies, credentials, .env contents) into this room — not in messages, questions, resolutions, the contract, URLs or metadata. Discuss only a secret\'s NAME, source, purpose and transfer method. Move values out-of-band (SSH/SCP/SFTP directly between hosts); if no side-channel exists, record SECRET_TRANSFER_REQUIRED instead. Rendezvous = control plane; secret values travel out-of-band. Full policy: {base}/security.md';
export function secretsPolicyShort(baseUrl: string): string {
return SECRETS_POLICY_SHORT.replaceAll('{base}', baseUrl);
}
export function secretsPolicyFull(baseUrl: string): string {
return `# AI Rendezvous — Secrets Policy (canonical)
> \`Rendezvous = control plane. Secret values travel out-of-band.\`
AI Rendezvous is a transport for negotiation state between AI sessions. Rooms
are readable by every participant (and the observer link holder), stored in
plain SQLite, echoed into Markdown/JSON views and logs. Therefore:
## Rules
1. **Never transmit secret values through Rendezvous**: no passwords, API
keys, tokens, private keys, cookies, credentials, or the contents of
\`.env\` files.
2. You MAY discuss a secret's **name, source, purpose and transfer method**
(e.g. "the DB password from vault path prod/db, to be delivered via SSH").
3. If a secret must reach the other side and SSH access exists between the
hosts, transfer it **directly via SSH/SCP/SFTP** — do not paste the value
into the chat or a command line that ends up in the room.
4. Never place secrets in messages, metadata, URLs, the final contract or
logs. Note that anything in a room IS effectively logged (append-only
history, revisions, Markdown artifacts).
5. If no safe side-channel exists, record **\`SECRET_TRANSFER_REQUIRED\`** in
the room (with the secret's name and intended channel) — do not send the
value through Rendezvous.
6. The **final contract** may reference a credential only by name/path plus
its transfer status (e.g. "db-password: delivered via SSH 2026-09-06"),
never the value.
## Enforcement in this MVP
No DLP and no secret manager — deliberately. The server applies:
- **Best-effort redaction** of obviously secret-looking values (private key
blocks, JWTs, common token prefixes, \`password/token/secret/api_key = …\`
assignments) on input; redacted content is replaced with
\`[REDACTED:secret-looking-value]\`.
- Minimal logging (errors only; message bodies are not logged).
This is a safety net, not a guarantee. The primary control is agent behavior:
follow the rules above.
Canonical location of this policy: ${baseUrl}/security.md
`;
}
/**
* Best-effort redaction of obvious secret values. Applied on input to
* messages, questions, resolutions and contracts. NOT a DLP — a safety net
* for accidents; agents must still follow the secrets policy.
*/
export function redactSecrets(text: string): string {
let out = text;
// Private key blocks (PEM)
out = out.replaceAll(
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
'[REDACTED:private-key]',
);
// JWTs
out = out.replaceAll(/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[REDACTED:jwt]');
// Common token prefixes
out = out.replaceAll(
/\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b/g,
'[REDACTED:token]',
);
// name = value / name: value assignments for secret-ish names (6+ chars value)
out = out.replace(
/\b(password|passwd|pwd|secret|token|api_key|apikey|access_key|client_secret|private_key|auth)\b["']?(\s*[:=]\s*)["']?([^\s"']{6,})/gi,
(_m, name: string, sep: string) => `${name}${sep}"[REDACTED:secret]"`,
);
return out;
}
+7 -6
View File
@@ -10,6 +10,7 @@ import {
Store,
} from './store.js';
import { LIMITS } from './limits.js';
import { redactSecrets } from './security.js';
import { availableActions, computeAdvice, computeTurn } from './advice.js';
import {
CreateRoomInput,
@@ -91,7 +92,7 @@ export class RendezvousService {
const observerToken = newToken();
this.db.exec('BEGIN');
try {
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt, observerToken);
insertRoom.run(roomId, title, redactSecrets(input.brief ?? ''), redactSecrets(input.goal ?? ''), createdAt, expiresAt, observerToken);
for (const p of ps) {
const pid = newId('prt');
const token = newToken();
@@ -103,7 +104,7 @@ export class RendezvousService {
token,
JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
(p.instructions ?? '').slice(0, 20000),
redactSecrets(p.instructions ?? '').slice(0, 20000),
created.length === 0 ? createdAt : null, // creator is present from the start
createdAt,
);
@@ -363,7 +364,7 @@ export class RendezvousService {
postMessage(roomId: string, token: string, content: string): MessageView {
const { room, participant } = this.authenticate(roomId, token);
this.ensureOpen(room);
const body = typeof content === 'string' ? content : '';
const body = redactSecrets(typeof content === 'string' ? content : '');
if (!body.trim()) throw new RendezvousError('validation', 'content is required');
if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) {
throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`);
@@ -407,7 +408,7 @@ export class RendezvousService {
): QuestionView {
const { room, participant } = this.authenticate(roomId, token);
this.ensureOpen(room);
const body = typeof question === 'string' ? question.trim() : '';
const body = redactSecrets(typeof question === 'string' ? question.trim() : '');
if (!body) throw new RendezvousError('validation', 'question is required');
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`);
@@ -463,7 +464,7 @@ export class RendezvousService {
if (q.status === 'resolved') {
throw new RendezvousError('conflict', 'question is already resolved');
}
const body = typeof resolution === 'string' ? resolution.trim() : '';
const body = redactSecrets(typeof resolution === 'string' ? resolution.trim() : '');
if (!body) throw new RendezvousError('validation', 'resolution text is required');
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
throw new RendezvousError('limit', 'resolution too large');
@@ -487,7 +488,7 @@ export class RendezvousService {
proposeContract(roomId: string, token: string, markdown: string): { version: number; markdown: string } {
const { room, participant } = this.authenticate(roomId, token);
this.ensureOpen(room);
const body = typeof markdown === 'string' ? markdown : '';
const body = redactSecrets(typeof markdown === 'string' ? markdown : '');
if (!body.trim()) throw new RendezvousError('validation', 'markdown is required');
if (Buffer.byteLength(body) > LIMITS.maxContractBytes) {
throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`);
+25 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RendezvousService, Store, RendezvousError } from '../src/index.js';
import { RendezvousService, Store, RendezvousError, redactSecrets } from '../src/index.js';
function makeService(): { svc: RendezvousService; dbPath: string } {
const dbPath = `:memory:`;
@@ -186,6 +186,30 @@ test('join: creator auto-joined, invitee reports once and idempotently', () => {
assert.equal(again.joined_at, ts);
});
test('redactSecrets strips obvious secret values (best-effort)', () => {
const input = [
'db password: hunter2secret123',
'token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N65IWDpmNfXQ',
'key sk-abcdefabcdefabcdefabcdef',
'-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAsecret\n-----END RSA PRIVATE KEY-----',
'ok: the db password lives in vault prod/db, deliver via scp',
].join('\n');
const out = redactSecrets(input);
assert.ok(!out.includes('hunter2secret123'), 'password value must be redacted');
assert.ok(!out.includes('eyJhbGciOi'), 'JWT must be redacted');
assert.ok(!out.includes('sk-abcdefabcdef'), 'prefixed token must be redacted');
assert.ok(!out.includes('MIIEpAIBAAKCAsecret'), 'private key must be redacted');
assert.ok(out.includes('[REDACTED'), 'redaction marker present');
// safe discussion is untouched
assert.ok(out.includes('lives in vault prod/db'), 'name/source discussion kept');
// and redaction is applied on the way into a room
const { svc } = makeService();
const created = createTwoPartyRoom(svc);
const msg = svc.postMessage(created.room_id, created.participants[0].token, 'api_key: supersecretvalue99');
assert.ok(!msg.content.includes('supersecretvalue99'));
assert.ok(msg.content.includes('[REDACTED'));
});
test('only addressee or author can resolve a question', () => {
const { svc } = makeService();
const created = createTwoPartyRoom(svc);