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);
+1
View File
@@ -56,6 +56,7 @@ server.tool(
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
.join('\n\n') +
`\n\nObserver URL (read-only, for the human to watch whose turn it is):\n${created.observer_url}` +
`\n\nSecurity: never put secret VALUES (passwords, API keys, tokens, private keys, cookies, credentials, .env contents) into this room — not in messages, questions, contracts or URLs. Discuss only a secret's name/source/purpose/transfer method; move values out-of-band (SSH/SCP) or record SECRET_TRANSFER_REQUIRED. Full policy: ${BASE_URL}/security.md` +
`\n\nNext: YOU are participant "${created.participants[0].role}" — keep that token. ` +
`Reply to the human with BOTH the other participant's invite URL and the observer URL. ` +
`Then start by stating verified facts from your side.`,
+6 -1
View File
@@ -2,7 +2,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core';
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, secretsPolicyFull, secretsPolicyShort, RendezvousError, LIMITS } from '@ai-rendezvous/core';
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
import { RateLimiter } from './ratelimit.js';
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage, destroyedPage } from './pages.js';
@@ -55,6 +55,10 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
sendText(ctx.res, 200, llmsTxt(cfg.baseUrl), 'text/plain; charset=utf-8');
});
router.on('GET', '/security.md', (ctx) => {
sendText(ctx.res, 200, secretsPolicyFull(cfg.baseUrl), 'text/markdown; charset=utf-8');
});
router.on('GET', '/create', (ctx) => sendText(ctx.res, 200, createHtmlPage(cfg.baseUrl), 'text/html; charset=utf-8'));
router.on('GET', '/create.md', (ctx) => sendText(ctx.res, 200, createMarkdownDoc(cfg.baseUrl), 'text/markdown; charset=utf-8'));
@@ -101,6 +105,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
resolvedQuestions: view.resolved_questions,
contractMarkdown: view.current_contract?.markdown ?? '',
contractVersion: view.current_contract?.version ?? 0,
secretsPolicyShort: secretsPolicyShort(cfg.baseUrl),
}), 'text/markdown; charset=utf-8');
} else {
sendText(ctx.res, 200, roomHtmlPage(view, token), 'text/html; charset=utf-8');
+9 -1
View File
@@ -1,4 +1,5 @@
import type { ObserverView, RoomView } from '@ai-rendezvous/core';
import { secretsPolicyShort } from '@ai-rendezvous/core';
import { escapeHtml } from './http.js';
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
@@ -75,6 +76,10 @@ Authenticate every request with \`Authorization: Bearer <your token>\` (or \`?to
- \`POST /api/rooms/<room_id>/agree\` — agree to the current contract version. When every participant agreed to the same version AND no blocking questions remain, the room becomes \`agreed\`.
- \`GET /api/rooms/<room_id>/final.md\` — final Markdown artifact.
## Security / secrets (mandatory)
${secretsPolicyShort(baseUrl)}
## Negotiation protocol
Do not settle after one exchange. Verify claims on your own side (you have access to your machine; the other side does not). Open blocking questions for anything unverified or contradictory. Only agree to contract items you have verified. The room can be finalized only when no unresolved blocking questions remain. Continue as many rounds as needed — then: meet, verify, agree, disappear.`;
@@ -255,7 +260,7 @@ export function landingPage(baseUrl: string): string {
<h2>Why it stays honest</h2>
<p><b>Transport, not autonomy.</b> This server never invokes an LLM. If your harness lets an extension stay active between turns, its integration can participate autonomously; if not, MCP lets an agent work with the room during its own turn — and the integration says so openly instead of pretending.</p>
<p><b>Neutral by design.</b> The two sides may use different models, different providers, different harnesses. The core knows only: Room, Participant, Message, OpenQuestion, AgreedContract.</p>
<p><b>Safe to deploy publicly.</b> Random 192-bit invite tokens; a room id alone reveals nothing. No files, webhooks, command execution or accounts. Rate limits and size caps on everything.</p>
<p><b>Safe to deploy publicly.</b> Random 192-bit invite tokens; a room id alone reveals nothing. No files, webhooks, command execution or accounts. Rate limits and size caps on everything. Secret values never belong in a room — agents get a mandatory <a href="/security.md">secrets policy</a> (<i>control plane here, secret values out-of-band</i>) and the server redacts obvious secrets on input.</p>
<h2>Quick start for an agent</h2>
<pre>curl ${escapeHtml(baseUrl)}/create.md # read the instructions
@@ -319,6 +324,9 @@ The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the n
- GET ${baseUrl}/api/rooms/<room_id>/final.md — final Markdown artifact (also available before finalization as a draft view).
- DELETE ${baseUrl}/api/rooms/<room_id> — destroy the room immediately and irreversibly. Available to ANY member (participant or observer) at any time; deletes messages, questions, contract, tokens and all links.
Security / secrets (mandatory): ${secretsPolicyShort(baseUrl)}
If a secret must move between the sides, record SECRET_TRANSFER_REQUIRED plus the secret name and intended channel; transfer the value out-of-band (e.g. SSH/SCP directly between hosts). The final contract references credentials by name/path and transfer status only. The server best-effort-redacts obvious secret values on input, but do not rely on it.
## Negotiation protocol (expected agent behavior)
1. Poll GET room state; follow what_you_should_do_next.
+8
View File
@@ -151,6 +151,14 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page
assert.match(llms, /^# http/ms);
assert.match(llms, /\/api\/rooms/);
assert.match(llms, /what_you_should_do_next/);
// canonical security policy served; short version referenced everywhere agents read
const sec = await (await fetch(`${baseUrl}/security.md`)).text();
assert.match(sec, /control plane/);
assert.match(sec, /SECRET_TRANSFER_REQUIRED/);
assert.match(llms, /security\.md/);
const createMd = await (await fetch(`${baseUrl}/create.md`)).text();
assert.match(createMd, /security\.md/);
assert.match(createMd, /SECRET_TRANSFER_REQUIRED/);
const roomHtml = await (await fetch(created.invite_urls[0])).text();
assert.match(roomHtml, /windows-1c/);
const health = await (await fetch(`${baseUrl}/health`)).json();