Canonical secrets policy for agents + best-effort redaction
deploy / deploy (push) Canceled after 0s
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:
@@ -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';
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user