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:
@@ -185,6 +185,13 @@ rounds — as many as needed.
|
|||||||
and writes.
|
and writes.
|
||||||
- SQLite storage (`node:sqlite`), periodic TTL cleanup job. Nothing else — no
|
- SQLite storage (`node:sqlite`), periodic TTL cleanup job. Nothing else — no
|
||||||
Redis/Postgres/queues/websockets.
|
Redis/Postgres/queues/websockets.
|
||||||
|
- **Secrets policy** (canonical: `/security.md`): agents are instructed in
|
||||||
|
every agent-facing page and in the MCP tools never to put secret values into
|
||||||
|
a room — only names/sources/transfer methods; values travel out-of-band
|
||||||
|
(SSH/SCP) or the room records `SECRET_TRANSFER_REQUIRED`. The server applies
|
||||||
|
best-effort redaction of obvious secret-looking values on input. No
|
||||||
|
DLP/secret manager in the MVP — deliberately. Principle: *Rendezvous =
|
||||||
|
control plane; secret values travel out-of-band.*
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ There are no accounts.
|
|||||||
|
|
||||||
Error format: `{ "error": { "code": "not_found|forbidden|validation|limit|conflict", "message": "..." } }`.
|
Error format: `{ "error": { "code": "not_found|forbidden|validation|limit|conflict", "message": "..." } }`.
|
||||||
|
|
||||||
|
**Secrets policy (canonical: `/security.md`):** secret values never belong in a
|
||||||
|
room — messages, questions, resolutions, contracts, URLs. Discuss only a
|
||||||
|
secret's name/source/purpose/transfer method; move values out-of-band
|
||||||
|
(SSH/SCP) or record `SECRET_TRANSFER_REQUIRED`. The server best-effort-redacts
|
||||||
|
obvious secret-looking values on input (replaced with
|
||||||
|
`[REDACTED:secret-looking-value]`).
|
||||||
|
|
||||||
## Entry points
|
## Entry points
|
||||||
|
|
||||||
| Method & path | Purpose |
|
| Method & path | Purpose |
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { RendezvousService, newToken } from './service.js';
|
|||||||
export { LIMITS } from './limits.js';
|
export { LIMITS } from './limits.js';
|
||||||
export { computeAdvice, computeTurn, availableActions } from './advice.js';
|
export { computeAdvice, computeTurn, availableActions } from './advice.js';
|
||||||
export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
|
export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
|
||||||
|
export { secretsPolicyFull, secretsPolicyShort, redactSecrets } from './security.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface MarkdownInput {
|
|||||||
resolvedQuestions: QuestionView[];
|
resolvedQuestions: QuestionView[];
|
||||||
contractMarkdown: string;
|
contractMarkdown: string;
|
||||||
contractVersion: number;
|
contractVersion: number;
|
||||||
|
/** Short secrets policy (links to the canonical /security.md). */
|
||||||
|
secretsPolicyShort: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRoomMarkdown(i: MarkdownInput): string {
|
export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||||
@@ -73,6 +75,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
|||||||
parts.push('');
|
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('**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('');
|
||||||
|
parts.push(i.secretsPolicyShort);
|
||||||
|
parts.push('');
|
||||||
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
||||||
if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
|
if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
|
||||||
parts.push(`- **Status:** ${i.status}`);
|
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,
|
Store,
|
||||||
} from './store.js';
|
} from './store.js';
|
||||||
import { LIMITS } from './limits.js';
|
import { LIMITS } from './limits.js';
|
||||||
|
import { redactSecrets } from './security.js';
|
||||||
import { availableActions, computeAdvice, computeTurn } from './advice.js';
|
import { availableActions, computeAdvice, computeTurn } from './advice.js';
|
||||||
import {
|
import {
|
||||||
CreateRoomInput,
|
CreateRoomInput,
|
||||||
@@ -91,7 +92,7 @@ export class RendezvousService {
|
|||||||
const observerToken = newToken();
|
const observerToken = newToken();
|
||||||
this.db.exec('BEGIN');
|
this.db.exec('BEGIN');
|
||||||
try {
|
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) {
|
for (const p of ps) {
|
||||||
const pid = newId('prt');
|
const pid = newId('prt');
|
||||||
const token = newToken();
|
const token = newToken();
|
||||||
@@ -103,7 +104,7 @@ export class RendezvousService {
|
|||||||
token,
|
token,
|
||||||
JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
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))),
|
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
|
created.length === 0 ? createdAt : null, // creator is present from the start
|
||||||
createdAt,
|
createdAt,
|
||||||
);
|
);
|
||||||
@@ -363,7 +364,7 @@ export class RendezvousService {
|
|||||||
postMessage(roomId: string, token: string, content: string): MessageView {
|
postMessage(roomId: string, token: string, content: string): MessageView {
|
||||||
const { room, participant } = this.authenticate(roomId, token);
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
this.ensureOpen(room);
|
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 (!body.trim()) throw new RendezvousError('validation', 'content is required');
|
||||||
if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) {
|
if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) {
|
||||||
throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`);
|
throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`);
|
||||||
@@ -407,7 +408,7 @@ export class RendezvousService {
|
|||||||
): QuestionView {
|
): QuestionView {
|
||||||
const { room, participant } = this.authenticate(roomId, token);
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
this.ensureOpen(room);
|
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 (!body) throw new RendezvousError('validation', 'question is required');
|
||||||
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
||||||
throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`);
|
throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`);
|
||||||
@@ -463,7 +464,7 @@ export class RendezvousService {
|
|||||||
if (q.status === 'resolved') {
|
if (q.status === 'resolved') {
|
||||||
throw new RendezvousError('conflict', 'question is already 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 (!body) throw new RendezvousError('validation', 'resolution text is required');
|
||||||
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
||||||
throw new RendezvousError('limit', 'resolution too large');
|
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 } {
|
proposeContract(roomId: string, token: string, markdown: string): { version: number; markdown: string } {
|
||||||
const { room, participant } = this.authenticate(roomId, token);
|
const { room, participant } = this.authenticate(roomId, token);
|
||||||
this.ensureOpen(room);
|
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 (!body.trim()) throw new RendezvousError('validation', 'markdown is required');
|
||||||
if (Buffer.byteLength(body) > LIMITS.maxContractBytes) {
|
if (Buffer.byteLength(body) > LIMITS.maxContractBytes) {
|
||||||
throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`);
|
throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 } {
|
function makeService(): { svc: RendezvousService; dbPath: string } {
|
||||||
const dbPath = `:memory:`;
|
const dbPath = `:memory:`;
|
||||||
@@ -186,6 +186,30 @@ test('join: creator auto-joined, invitee reports once and idempotently', () => {
|
|||||||
assert.equal(again.joined_at, ts);
|
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', () => {
|
test('only addressee or author can resolve a question', () => {
|
||||||
const { svc } = makeService();
|
const { svc } = makeService();
|
||||||
const created = createTwoPartyRoom(svc);
|
const created = createTwoPartyRoom(svc);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ server.tool(
|
|||||||
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
|
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
|
||||||
.join('\n\n') +
|
.join('\n\n') +
|
||||||
`\n\nObserver URL (read-only, for the human to watch whose turn it is):\n${created.observer_url}` +
|
`\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. ` +
|
`\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. ` +
|
`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.`,
|
`Then start by stating verified facts from your side.`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
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 { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
|
||||||
import { RateLimiter } from './ratelimit.js';
|
import { RateLimiter } from './ratelimit.js';
|
||||||
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage, destroyedPage } from './pages.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');
|
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', (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'));
|
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,
|
resolvedQuestions: view.resolved_questions,
|
||||||
contractMarkdown: view.current_contract?.markdown ?? '',
|
contractMarkdown: view.current_contract?.markdown ?? '',
|
||||||
contractVersion: view.current_contract?.version ?? 0,
|
contractVersion: view.current_contract?.version ?? 0,
|
||||||
|
secretsPolicyShort: secretsPolicyShort(cfg.baseUrl),
|
||||||
}), 'text/markdown; charset=utf-8');
|
}), 'text/markdown; charset=utf-8');
|
||||||
} else {
|
} else {
|
||||||
sendText(ctx.res, 200, roomHtmlPage(view, token), 'text/html; charset=utf-8');
|
sendText(ctx.res, 200, roomHtmlPage(view, token), 'text/html; charset=utf-8');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ObserverView, RoomView } from '@ai-rendezvous/core';
|
import type { ObserverView, RoomView } from '@ai-rendezvous/core';
|
||||||
|
import { secretsPolicyShort } from '@ai-rendezvous/core';
|
||||||
import { escapeHtml } from './http.js';
|
import { escapeHtml } from './http.js';
|
||||||
|
|
||||||
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
|
/** 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\`.
|
- \`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.
|
- \`GET /api/rooms/<room_id>/final.md\` — final Markdown artifact.
|
||||||
|
|
||||||
|
## Security / secrets (mandatory)
|
||||||
|
|
||||||
|
${secretsPolicyShort(baseUrl)}
|
||||||
|
|
||||||
## Negotiation protocol
|
## 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.`;
|
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>
|
<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>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>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>
|
<h2>Quick start for an agent</h2>
|
||||||
<pre>curl ${escapeHtml(baseUrl)}/create.md # read the instructions
|
<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).
|
- 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.
|
- 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)
|
## Negotiation protocol (expected agent behavior)
|
||||||
|
|
||||||
1. Poll GET room state; follow what_you_should_do_next.
|
1. Poll GET room state; follow what_you_should_do_next.
|
||||||
|
|||||||
@@ -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, /^# http/ms);
|
||||||
assert.match(llms, /\/api\/rooms/);
|
assert.match(llms, /\/api\/rooms/);
|
||||||
assert.match(llms, /what_you_should_do_next/);
|
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();
|
const roomHtml = await (await fetch(created.invite_urls[0])).text();
|
||||||
assert.match(roomHtml, /windows-1c/);
|
assert.match(roomHtml, /windows-1c/);
|
||||||
const health = await (await fetch(`${baseUrl}/health`)).json();
|
const health = await (await fetch(`${baseUrl}/health`)).json();
|
||||||
|
|||||||
Reference in New Issue
Block a user