From 2c2dc82c08ce6fd565281846078667da81b09b98 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sun, 2 Aug 2026 08:41:28 +0300 Subject: [PATCH 1/2] fix(dx): the documented starter agent could not be connected at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three snippets told the developer to paste http://localhost:8787 into "Connect an agent". Every form of that is refused: plain http fails the HTTPS check, and localhost / 127.0.0.1 / LAN addresses fail the private-range check. No tunnel was mentioned anywhere in the repo, so the documented path to the Agent API — the product's most important public surface — dead-ended with no next step. TTHW for an agent developer was not slow, it was unreachable. The SSRF guard is right and stays exactly as it is. What was missing is the remedy, in the three places someone hits the wall: the snippets, the README, and the error messages themselves (both the client-side check that fires first and the server-side rejections behind it). Co-Authored-By: Claude Opus 5 --- README.md | 42 ++++++++++++++++++- .../components/agents/ConnectAgentCard.tsx | 7 +++- apps/web/lib/server/agent-endpoint.ts | 15 +++++-- .../snippets/node/agent-server.mjs | 8 +++- .../snippets/python/agent_server.py | 8 +++- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0e8691f..3273200 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ # Quorum +[![CI](https://github.com/muthanii/quorum/actions/workflows/ci.yml/badge.svg)](https://github.com/muthanii/quorum/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + **A collaborative workspace for AI agents.** Google Sheets for AI agents, wearing Figma's clothes, behaving like a group chat. A team opens a shared Board. Each member plugs in their own AI agent via an API key or webhook. Everyone — humans and agents — talks in one real-time thread. When an agent produces something (a doc, a table, a diagram), it becomes an **Artifact** on the canvas that everyone can see, edit, and comment on simultaneously with live multiplayer cursors. @@ -116,6 +119,29 @@ Runnable starter agents live in [`packages/agent-protocol/snippets/`](packages/a QUORUM_SIGNING_SECRET=whsec_yoursecret node packages/agent-protocol/snippets/node/agent-server.mjs ``` +### Connecting a local agent + +Quorum only dials **public HTTPS** endpoints. `localhost`, `127.0.0.1`, and private LAN +addresses are refused by the SSRF guard — that is deliberate, since an agent endpoint is +attacker-supplied and would otherwise be a hole straight into your network. So a laptop +agent needs a tunnel: + +```bash +cloudflared tunnel --url http://localhost:8787 +``` + +```bash +ngrok http 8787 +``` + +Paste the `https://…` URL it prints into **Connect an agent**, along with the signing +secret Quorum shows you once. From `pnpm dev` to your agent posting its first message is +about three minutes. + +Nothing your agent does lands without a vote: `operations` are staged as Proposals, and +the board approves them. Expect your first turn to appear on the canvas as a pending +proposal, not as a finished artifact. + **Direct model agents.** Paste an OpenAI- or Anthropic-compatible base URL, API key, and model name, and Quorum runs the loop for you. A turn looks like this: @@ -171,7 +197,7 @@ pnpm analyze # bundle size report ## Testing -395 unit and integration tests. The consensus engine carries the heaviest coverage — unanimous pass, single veto, timeout expiry at the exact boundary, member disconnect mid-vote, rejoin, vote-order independence, policy change during an open proposal — plus a suite that proves vote forgery is prevented by disabling the guard and confirming the attacks succeed without it. +466 unit and integration tests. The consensus engine carries the heaviest coverage — unanimous pass, single veto, timeout expiry at the exact boundary, member disconnect mid-vote, rejoin, vote-order independence, policy change during an open proposal — plus a suite that proves vote forgery is prevented by disabling the guard and confirming the attacks succeed without it. Yjs convergence is tested by driving two documents concurrently and asserting they converge. The agent protocol has contract tests against a mock agent server covering timeouts, malformed responses, duplicate turn ids, oversized payloads, and SSRF attempts. @@ -191,6 +217,20 @@ Being honest about what is not done: - **Bundle headroom is thin.** The board route is 245KB first-load JS against a 250KB budget. Run `pnpm analyze` before adding client-side dependencies. - **The Yjs update log grows unbounded.** Snapshots are written, but the append-only update log has no compaction yet. +## Contributing + +Setup, the checks CI runs, how to run the multiplayer e2e suite, and the architecture +rules reviewers hold you to: [CONTRIBUTING.md](CONTRIBUTING.md). + +Building an agent? Start with +[`packages/agent-protocol/README.md`](packages/agent-protocol/README.md) — the wire +contract, signature verification, and the retry and idempotency rules. + +Found a security problem? Do not open an issue — see [SECURITY.md](SECURITY.md). + +Changes are tracked in [CHANGELOG.md](CHANGELOG.md), including the separate +[agent protocol compatibility policy](CHANGELOG.md#agent-protocol-compatibility). + ## License MIT — see [LICENSE](LICENSE). diff --git a/apps/web/components/agents/ConnectAgentCard.tsx b/apps/web/components/agents/ConnectAgentCard.tsx index 7ecf2e7..ab980b2 100644 --- a/apps/web/components/agents/ConnectAgentCard.tsx +++ b/apps/web/components/agents/ConnectAgentCard.tsx @@ -74,7 +74,12 @@ export function ConnectAgentCard({ boardId, doc, open, onOpenChange }: ConnectAg const trimmedUrl = endpointUrl.trim(); if (trimmedName === "") return setFormError("Give the agent a name."); if (!/^https:\/\//i.test(trimmedUrl)) { - return setFormError("Agent endpoints must be HTTPS URLs."); + // The common case behind this is an agent running on the developer's own + // machine, where "use HTTPS" is not something they can act on. Name the + // way out, or they are stuck at the first step of the Agent API. + return setFormError( + "Agent endpoints must be HTTPS URLs. Running it locally? Expose it with `cloudflared tunnel --url http://localhost:8787` (or `ngrok http 8787`) and paste the https:// URL.", + ); } const input: ConnectAgentInput = { name: trimmedName, kind, endpointUrl: trimmedUrl }; if (secret.trim() !== "") input.secret = secret.trim(); diff --git a/apps/web/lib/server/agent-endpoint.ts b/apps/web/lib/server/agent-endpoint.ts index 5475e08..df56dce 100644 --- a/apps/web/lib/server/agent-endpoint.ts +++ b/apps/web/lib/server/agent-endpoint.ts @@ -13,6 +13,15 @@ export class AgentEndpointError extends Error { } } +/** + * Appended to every rejection a local agent will hit. Quorum only dials public + * HTTPS, so "use a public URL" is not actionable on a laptop — the next step a + * developer needs is the tunnel, and an error that names the rule without the + * remedy is where they give up (CLAUDE.md §8: surface a recovery action). + */ +const TUNNEL_HINT = + " Running the agent locally? Expose it first: `cloudflared tunnel --url http://localhost:8787` (or `ngrok http 8787`), then paste the https:// URL it prints."; + const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal", "0.0.0.0"]); const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"]; @@ -38,7 +47,7 @@ export function assertSaneAgentEndpoint(raw: string): URL { throw new AgentEndpointError("Endpoint must be a valid absolute URL."); } if (url.protocol !== "https:") { - throw new AgentEndpointError("Endpoint must use https://."); + throw new AgentEndpointError("Endpoint must use https://." + TUNNEL_HINT); } if (url.username !== "" || url.password !== "") { throw new AgentEndpointError("Endpoint URL must not embed credentials."); @@ -46,7 +55,7 @@ export function assertSaneAgentEndpoint(raw: string): URL { const hostname = url.hostname.toLowerCase(); if (BLOCKED_HOSTNAMES.has(hostname) || BLOCKED_SUFFIXES.some((s) => hostname.endsWith(s))) { - throw new AgentEndpointError("Endpoint host looks internal — use a public HTTPS URL."); + throw new AgentEndpointError("Endpoint host looks internal." + TUNNEL_HINT); } // IPv6 literals (URL strips the brackets, leaving ":") are rejected // outright at this layer; the worker's resolver-based check is the place @@ -55,7 +64,7 @@ export function assertSaneAgentEndpoint(raw: string): URL { throw new AgentEndpointError("IPv6 literal endpoints are not supported — use a hostname."); } if (IPV4_LITERAL.test(hostname) && PRIVATE_IPV4_PATTERNS.some((p) => p.test(hostname))) { - throw new AgentEndpointError("Endpoint IP is in a private or reserved range."); + throw new AgentEndpointError("Endpoint IP is in a private or reserved range." + TUNNEL_HINT); } return url; } diff --git a/packages/agent-protocol/snippets/node/agent-server.mjs b/packages/agent-protocol/snippets/node/agent-server.mjs index 920ada0..46910d5 100755 --- a/packages/agent-protocol/snippets/node/agent-server.mjs +++ b/packages/agent-protocol/snippets/node/agent-server.mjs @@ -2,8 +2,12 @@ // Minimal Quorum webhook agent — plain Node, zero dependencies. // // Run: QUORUM_SIGNING_SECRET=whsec_yoursecret node agent-server.mjs -// Then paste http://localhost:8787 (or your public HTTPS URL) into Quorum's -// "connect an agent" card along with the same signing secret. +// +// Quorum only dials PUBLIC HTTPS endpoints — localhost and private IPs are +// refused by the SSRF guard — so expose this server before connecting it: +// cloudflared tunnel --url http://localhost:8787 # or: ngrok http 8787 +// Paste the https:// URL the tunnel prints into Quorum's "connect an agent" +// card, along with the signing secret Quorum shows you once. // // Quorum POSTs a TurnPayload and signs every request: // X-Quorum-Signature: t=,v1=."> diff --git a/packages/agent-protocol/snippets/python/agent_server.py b/packages/agent-protocol/snippets/python/agent_server.py index 285d02d..e4e3bb9 100755 --- a/packages/agent-protocol/snippets/python/agent_server.py +++ b/packages/agent-protocol/snippets/python/agent_server.py @@ -2,8 +2,12 @@ """Minimal Quorum webhook agent -- Python 3.10+ stdlib only. Run: QUORUM_SIGNING_SECRET=whsec_yoursecret python3 agent_server.py -Then paste http://localhost:8787 (or your public HTTPS URL) into Quorum's -"connect an agent" card along with the same signing secret. + +Quorum only dials PUBLIC HTTPS endpoints -- localhost and private IPs are +refused by the SSRF guard -- so expose this server before connecting it: + cloudflared tunnel --url http://localhost:8787 # or: ngrok http 8787 +Paste the https:// URL the tunnel prints into Quorum's "connect an agent" +card, along with the signing secret Quorum shows you once. Quorum POSTs a TurnPayload and signs every request: X-Quorum-Signature: t=,v1=."> From fa44c75b6a657e6c91b4cac8cdba697c471ab3b5 Mon Sep 17 00:00:00 2001 From: muthanii Date: Sun, 2 Aug 2026 08:41:28 +0300 Subject: [PATCH 2/2] docs: add the contributor, security, and agent-developer surfaces .github/ held a workflow and two logos; there was no CONTRIBUTING, no CHANGELOG, no security policy, and no way to report a vulnerability in a product whose threat model is "assume agent output is hostile". - CONTRIBUTING.md: setup, the checks CI runs, and the two things that will bite you on the e2e suite (it needs a dev build for the window hook, and RATE_LIMIT_BOARDS_CREATE or 8 of 18 specs fail on 429). - SECURITY.md: private reporting, what is in scope, and the known tradeoffs (DNS rebinding window, fail-open limits, single-instance ws) so reporters are not rediscovering documented decisions. - CHANGELOG.md, including a protocol compatibility policy that makes the "/v1/ never changes incompatibly" promise explicit rather than a comment. - packages/agent-protocol/README.md: the loop, signature verification, idempotency and degraded semantics, and the fact that operations are staged as Proposals rather than applied. - Issue templates, including one for agent integration problems, and a PR template that surfaces the CLAUDE.md "ask first" items. Also corrects the README test count, which read 395 against an actual 466. Co-Authored-By: Claude Opus 5 --- .github/ISSUE_TEMPLATE/agent_integration.yml | 62 +++++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 66 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 10 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 35 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 29 +++++ CHANGELOG.md | 68 ++++++++++++ CODE_OF_CONDUCT.md | 52 +++++++++ CONTRIBUTING.md | 104 ++++++++++++++++++ SECURITY.md | 51 +++++++++ packages/agent-protocol/README.md | 109 +++++++++++++++++++ 10 files changed, 586 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/agent_integration.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 packages/agent-protocol/README.md diff --git a/.github/ISSUE_TEMPLATE/agent_integration.yml b/.github/ISSUE_TEMPLATE/agent_integration.yml new file mode 100644 index 0000000..4b7b589 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/agent_integration.yml @@ -0,0 +1,62 @@ +name: Agent integration problem +description: You are building an agent against the Quorum Agent API and something is in your way +labels: ["agent-api", "dx"] +body: + - type: markdown + attributes: + value: | + The Agent API is Quorum's most important public surface. If it fought you, that + is a bug in the product even when the code is behaving as designed. + + **Before filing:** Quorum only dials **public HTTPS** endpoints. A local agent + needs a tunnel — `cloudflared tunnel --url http://localhost:8787` or + `ngrok http 8787` — then paste the `https://` URL it prints. + + - type: dropdown + id: stage + attributes: + label: Where did it go wrong + options: + - Connecting the agent (the "Connect an agent" card) + - Verifying the X-Quorum-Signature header + - Reading the turn payload + - Shaping my response (messages / operations) + - My operations never appeared on the board + - My agent got marked degraded + - Retries, timeouts, or duplicate turnIds + - The docs said one thing and the product did another + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What you expected, and what happened + description: Quote the doc or snippet you were following, if there was one. + validations: + required: true + + - type: dropdown + id: language + attributes: + label: Language / runtime + options: [Node, Python, Go, Rust, Ruby, PHP, Java, Other] + validations: + required: true + + - type: input + id: mode + attributes: + label: Integration mode + placeholder: "webhook or direct model" + validations: + required: true + + - type: textarea + id: payload + attributes: + label: Turn payload or response involved + description: > + **Redact your signing secret and any API key.** Ids (`trn_`, `brd_`, `agt_`) are + safe to include and help us trace it. + render: json diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..135f4e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug report +description: Something in Quorum behaves differently than it should +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. If this is a **security** issue, close this and use + [private reporting](https://github.com/muthanii/quorum/security/advisories/new) + instead — see [SECURITY.md](../blob/main/SECURITY.md). + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you saw, and what you expected instead. + placeholder: | + I clicked "Connect an agent" with a valid HTTPS URL and… + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Numbered steps. A board id or a screenshot helps a lot. + placeholder: | + 1. pnpm dev + 2. Open http://localhost:3000 and click New board + 3. … + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Which part + options: + - Board / canvas / artifacts + - Chat and @mentions + - Consensus (proposals, votes, quorum) + - Agent connect / agent turns + - Realtime sync or presence + - Auth, invites, or sharing + - Setup / local dev / CI + - Not sure + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Console or server output + description: > + Browser console errors and the relevant `pnpm dev` output. **Redact secrets** — + never paste `AUTH_SECRET`, `CREDENTIALS_ENCRYPTION_KEY`, or an agent signing + secret. + render: shell + + - type: input + id: env + attributes: + label: Environment + placeholder: "Node 22.14, pnpm 9.15.4, Chrome 141, macOS 15 / Ubuntu 24.04 / WSL2" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3a020d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: https://github.com/muthanii/quorum/security/advisories/new + about: > + Never open a public issue for a security problem. Private reporting reaches the + maintainers directly. See SECURITY.md for what is in scope. + - name: Ask a question or share what you built + url: https://github.com/muthanii/quorum/discussions + about: Questions, ideas, and agents you have connected. Not sure it is a bug? Start here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e93842e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest something Quorum should do +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Worth knowing before you write: Quorum is deliberately **not** an agent-building + platform, a workflow/DAG builder, or an enterprise admin console, and it is not + offline-first. Those are stated non-goals, so proposals in that direction are + likely to be declined regardless of quality. + + - type: textarea + id: problem + attributes: + label: What problem are you hitting + description: Describe the situation, not the solution. What were you trying to do? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you would like instead + validations: + required: true + + - type: textarea + id: principles + attributes: + label: How it fits the product principles + description: > + Quorum holds six: zero setup, everything on screen, unanimous by default, real + time or it doesn't count, one-click sharing, and lightweight (<250KB gzipped + initial JS). Which does this serve, and does it strain any of them? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..93f3528 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ + + +## What changed and why + +## How it was verified + + + +## Checklist + +- [ ] `pnpm typecheck && pnpm lint && pnpm test` all pass +- [ ] `pnpm test:e2e` passes, or this change cannot affect it +- [ ] New exported function in `packages/shared` or `lib/consensus` has a unit test +- [ ] Bug fix has a regression test that fails without the fix +- [ ] No agent credential is logged, echoed, or returned anywhere +- [ ] No change bypasses the consensus pipeline + +## Needs a maintainer's sign-off before merge + + + +- [ ] Adds a dependency +- [ ] Changes the Y.Doc schema (live docs exist and cannot be recreated) +- [ ] Changes the agent protocol (`packages/agent-protocol`) +- [ ] Adds a new top-level route +- [ ] Changes anything in the consensus engine or `doc-guard` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b724697 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +Notable changes to Quorum. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); +this project has not cut a tagged release yet, so everything sits under Unreleased. + +The **Agent Protocol** is versioned separately and independently — see +[Agent protocol compatibility](#agent-protocol-compatibility) at the bottom. Changes to +the app do not imply a protocol change. + +## [Unreleased] + +### Added + +- Rate limiting on the public REST surface (board creation, agent connect, invites, + board token, invite accept) and on agent turns in the ws server, both backed by Redis. + A refused turn posts a visible system message in the board chat rather than vanishing. +- `RATE_LIMIT_` env overrides, so a deployment can tune a ceiling without a code + change. Absent, non-numeric, or non-positive values keep the shipped default. +- Deploy topology: `vercel.json`, Dockerfiles and `fly.toml` for ws and worker, and + `DEPLOYING.md` covering why ws cannot run on Vercel and must stay a single machine. +- `WS_INTERNAL_URL` so the worker can reach the ws internal API when they are on + separate hosts. Without it, turns ran and then silently failed to post results back. +- A branded `/signin` page replacing Auth.js's built-in one, and a branded 404. +- A chat notice when an agent transitions to `degraded`, so @mentions that will not be + delivered are explained rather than dropped in silence. +- `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, issue and PR templates. +- Tunnel instructions for connecting a locally-running agent. + +### Fixed + +- **`pnpm dev` could not start from a fresh clone.** The documented `cp .env.example .env` + produced a root `.env` that nothing loaded — Next resolves `.env` relative to + `apps/web`, and `tsx` loaded none. Every route returned 500. +- **CI had never passed.** `pnpm/action-setup@v4` rejects a `version` input that + disagrees with `package.json`'s `packageManager` pin, so the job died before install + on every run since CI was added. typecheck, lint, test and build had never executed. +- **The e2e suite became unrunnable** once rate limiting landed: 18 self-seeding specs + across 4 workers exhausted a 10/min board-creation budget and 8 failed with `429`. +- **The 7th artifact on a board spawned invisibly** underneath the 1st. Both axes + cascaded on `count % 6`, so artifact #7 landed on identical coordinates. +- **`/api/auth/signin` rendered a blank page** when no OAuth provider was configured, + which is the documented default. +- **Open redirect** in the new sign-in page's `callbackUrl` handling: a leading `/` is + not sufficient, since browsers read `//evil.example` as protocol-relative. +- A mistyped board URL fell through to Next's unbranded 404 with no way back. +- Agent endpoint errors named the rule but not the remedy, stranding anyone running an + agent locally. + +### Security + +- Agent endpoint validation is two-layer: a DNS-free syntactic gate at registration, and + full DNS-resolving SSRF enforcement in the worker at dial time, covering IPv4-mapped + and NAT64-embedded IPv6 forms. +- Rate limits fail open with a structured warning when Redis is unreachable. + +## Agent protocol compatibility + +The wire contract lives in `packages/agent-protocol/src/v1/`. Shapes under `v1/` never +change incompatibly. New optional fields may be added and new `trigger.type` or +proposal `kind` values may appear without a version bump — **agents must ignore fields +and enum values they do not recognise.** + +A breaking change would ship as `v2/` alongside `v1/`, with `v1/` supported for at least +one release cycle after `v2/` is announced. + +| Protocol | Status | Notes | +| -------- | ------- | --------------------------------------------------------------------------- | +| `v1` | Current | Initial contract: turn payload, agent response, HMAC-SHA256 request signing | diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9f4fdf9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Code of Conduct + +## Our pledge + +We as members, contributors, and maintainers pledge to make participation in this +project a harassment-free experience for everyone, regardless of age, body size, visible +or invisible disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our standards + +Behaviour that helps: + +- Being kind and respectful to other people +- Taking disagreement as being about the work, not the person +- Giving and gracefully accepting constructive feedback +- Owning our mistakes, apologising, and learning from them +- Optimising for what is best for the project overall + +Behaviour that is not acceptable: + +- Sexualised language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without + their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional + setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported to +the maintainers through +[GitHub private reporting](https://github.com/muthanii/quorum/security/advisories/new) +or by opening a confidential discussion with a maintainer. All complaints will be +reviewed and investigated promptly and fairly. + +Maintainers are responsible for clarifying and enforcing these standards and will take +appropriate and fair corrective action in response to any behaviour they deem +inappropriate, threatening, offensive, or harmful. Maintainers have the right and +responsibility to remove, edit, or reject comments, commits, code, issues, and other +contributions that are not aligned to this Code of Conduct, and will communicate reasons +for moderation decisions when appropriate. + +All maintainers are obligated to respect the privacy and security of the reporter of any +incident. + +## Attribution + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..59e78c5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,104 @@ +# Contributing to Quorum + +Thanks for looking. This file is the short version of everything you need to be +productive; if something here is wrong or missing, that is a bug worth reporting. + +## Setup + +Requires **Node 20.9+** (`.nvmrc` pins 22), **pnpm 9+**, and Docker for Postgres and Redis. + +```bash +git clone git@github.com:muthanii/quorum.git && cd quorum +pnpm install +docker compose up -d +cp .env.example .env +``` + +`AUTH_SECRET` and `CREDENTIALS_ENCRYPTION_KEY` each need 32 random bytes, base64: + +```bash +openssl rand -base64 32 +``` + +Then: + +```bash +pnpm db:migrate && pnpm dev +``` + +`pnpm dev` runs web (`:3000`), ws (`:3001`), and the worker together. The root `.env` is +loaded for all three — web reads it in `next.config.ts`, ws and worker via `--env-file`. + +## Before you push + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +CI runs exactly these plus a production build of `apps/web`. All four must pass. + +## Running the e2e suite + +The Playwright suite is **not** self-starting. Bring the stack up yourself first: + +```bash +docker compose up -d +pnpm db:migrate +pnpm dev +``` + +then, in another shell: + +```bash +pnpm test:e2e +``` + +Two things that will bite you otherwise: + +- **It must be a dev build.** The consensus spec drives proposals through + `apps/web/lib/yjs/e2e-hook.ts`, which is compiled out of production bundles. +- **Rate limiting will starve it.** The suite creates a board per spec across 4 workers, + and `boards.create` is capped at 10/min per bucket. Locally there is no + `x-forwarded-for` header, so every caller shares one bucket. `.env.example` ships + `RATE_LIMIT_BOARDS_CREATE=200` for this reason — keep it set, or 8 of 18 specs fail + with `429`. + +The `presence-quorum` spec takes ~6 minutes on purpose: it waits out the real +disconnect grace period. A full run is about 6-7 minutes. + +## Architecture rules that are not negotiable + +These come from `CLAUDE.md` and reviewers will hold you to them: + +- **Never bypass the consensus pipeline.** Every agent-authored mutation is a Proposal, + including in tests and fixtures. The ws server is the only authority on resolution. +- **Anything both client and server must agree on lives in `packages/shared`.** Do not + duplicate a type or a rule. (A cascade constant was once copy-pasted into two files + with each comment claiming it matched the other; they had already drifted.) +- **Zod at every boundary** — API handlers, agent payloads, env vars. +- **Never log or echo agent credentials**, not even truncated, not even in dev. +- **`packages/agent-protocol` stays dependency-free** so users can vendor it. +- Ask before adding a dependency, changing the Y.Doc schema, changing the agent + protocol, or adding a new top-level route. + +## Tests + +- New exported function in `packages/shared` or `lib/consensus` → unit test required. +- Bug fix → regression test that fails before the fix. +- New conditional → cover both branches. +- Test behaviour, not implementation. No snapshot tests of whole components. + +Regression tests use a `*.regression-N.test.ts` suffix and carry an attribution comment +naming what broke. + +## Commits and PRs + +Conventional-commit prefixes (`fix:`, `feat:`, `test:`, `chore:`, `docs:`). One logical +change per commit. Explain _why_ in the body — the diff already shows what. + +Open a PR against `main`. CI must be green. + +## Reporting bugs + +Use the issue templates. For anything security-related, do **not** open an issue — see +[SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c1172fa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security Policy + +## Reporting a vulnerability + +**Do not open a public issue.** + +Use GitHub's private reporting: **Security → Advisories → Report a vulnerability** on +[this repository](https://github.com/muthanii/quorum/security/advisories/new). It reaches +the maintainers privately and gives us a place to coordinate a fix with you. + +Please include what you were able to do, the steps to reproduce it, and what you think +the impact is. A working proof of concept helps a lot. You will get an acknowledgement +within 72 hours and an assessment within 7 days. + +## Scope + +Quorum's threat model assumes **agent output is hostile** and **agent endpoints are +attacker-supplied**. Findings in these areas are especially welcome: + +| Area | Why it matters | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Consensus integrity** | Forging a vote, resolving your own proposal, or applying an operation without unanimous approval defeats the entire product. The ws server is the sole authority; clients may only write their own vote. | +| **SSRF via agent endpoints** | Users paste arbitrary URLs. We enforce HTTPS, block private/reserved/metadata ranges, and re-resolve DNS at dial time in the worker. A bypass reaches internal infrastructure. | +| **Credential exposure** | Agent secrets are AES-256-GCM encrypted at rest and returned exactly once. Any path that leaks or logs one is a vulnerability. | +| **Y.Doc authorization** | A board id is not an authorization token. Reading or writing a board you are not a member of is in scope. | +| **XSS via agent content** | Agent-authored markdown/HTML is sanitized with DOMPurify before render. A bypass is in scope. | +| **Guest session forgery** | Guest identity is a signed JWT backed by a real user row; minting or stealing one is in scope. | + +## Known limitations + +These are documented tradeoffs, not undisclosed bugs. Reports about them are still +welcome if you can demonstrate impact beyond what is described. + +- **DNS rebinding window.** The worker resolves an agent hostname, verifies every + returned address, then calls `fetch`, which performs its own lookup. A hostile DNS + server could swap in a private address inside that window. Mitigated by verifying all + addresses (not just the first), `redirect: "error"` on every call, and a + verify-then-fetch gap of milliseconds. `pinnedAddress` is surfaced so a future custom + dispatcher can close it. See `apps/worker/src/ssrf.ts`. +- **Rate limits fail open.** If Redis is unreachable, requests are allowed and a warning + is logged. A rate limiter that takes the product down when Redis blinks is worse than + no rate limiter. +- **Shared bucket without a proxy header.** Callers with no `x-forwarded-for` share one + rate-limit bucket. Deployments behind a proxy that sets the header are unaffected. +- **ws is a single instance by design.** Sharing `Y.Doc` state across instances needs + `@hocuspocus/extension-redis`, which is not installed. See `DEPLOYING.md`. + +## Supported versions + +Pre-1.0 and not yet deployed against real traffic. Fixes land on `main`; there are no +maintained release branches yet. diff --git a/packages/agent-protocol/README.md b/packages/agent-protocol/README.md new file mode 100644 index 0000000..cda14c7 --- /dev/null +++ b/packages/agent-protocol/README.md @@ -0,0 +1,109 @@ +# @quorum/agent-protocol + +The wire contract for Quorum agents. **Zero dependencies**, so you can vendor it. + +An agent is any HTTPS endpoint that accepts a signed `POST` and answers with messages +and operations. There is no SDK to install and no framework to adopt — if your language +can verify an HMAC and parse JSON, you can write a Quorum agent. + +## The loop + +``` +Quorum Your endpoint + │ POST │ + │ X-Quorum-Signature: t=…,v1=… │ + │ { turnId, boardId, agent, trigger, │ + │ context: { messages, artifacts, │ + │ openProposals }, capabilities } + │ ───────────────────────────────────────────▶│ + │ │ verify signature + │ │ decide what to do + │◀─────────────────────────────────────────── │ + │ { messages: [...], operations: [...] } │ + │ │ + │ operations are STAGED as Proposals — │ + │ the board votes before anything lands │ +``` + +The last line is the part people miss. **Your operations do not take effect when you +return them.** They become Proposals that every active member must approve. Expect your +first turn to show up as a pending card in the chat rail, not a finished artifact. + +## Start here + +Runnable agents, ~100 lines each, no dependencies: + +| Language | File | +| ------------ | -------------------------------------------------------------------- | +| Node | [`snippets/node/agent-server.mjs`](snippets/node/agent-server.mjs) | +| Python 3.10+ | [`snippets/python/agent_server.py`](snippets/python/agent_server.py) | +| Bash / curl | [`snippets/curl/verify.sh`](snippets/curl/verify.sh) | + +```bash +QUORUM_SIGNING_SECRET=whsec_yoursecret node snippets/node/agent-server.mjs +``` + +Quorum only dials **public HTTPS**, so expose it before connecting: + +```bash +cloudflared tunnel --url http://localhost:8787 +``` + +Paste the printed `https://` URL into **Connect an agent** with the same signing secret. + +## Verifying the signature + +Every request carries: + +``` +X-Quorum-Signature: t=,v1=."> +``` + +Verify it before trusting anything in the body: + +1. Parse `t` and `v1`. +2. Reject if `|now - t| > 300` seconds — this is what stops replay. +3. Recompute `HMAC-SHA256(secret, ".")` over the **raw** body bytes, before + any JSON parsing or re-serialisation. +4. Compare in **constant time**. A `==` here is a timing oracle. + +The signing secret is shown exactly once, when you connect the agent. It is stored +encrypted and can never be retrieved again — if you lose it, reconnect the agent. + +## Using the types + +TypeScript consumers inside this workspace: + +```ts +import type { TurnPayload, AgentResponse } from "@quorum/agent-protocol/v1/types"; +import { verifySignature } from "@quorum/agent-protocol/v1/signature"; +``` + +Outside the workspace, copy `src/v1/` into your project. It is dependency-free and +intended to be vendored — that is why there is nothing to `npm install`. + +## Rules that will bite you + +- **Be idempotent on `turnId`.** A turn times out at 60s and is retried up to 3 times. + Track ids you have already answered and return an empty response for repeats. +- **Answer within 60s.** Three failures in a row and the agent is marked `degraded`, + a pill appears in the roster, and the board chat says so. It stops receiving + @mentions until it recovers. +- **Ignore what you do not recognise.** New optional fields, new `trigger.type` values, + and new proposal `kind` values can appear without a version bump. Do not fail closed + on an unknown enum. +- **Respect `capabilities`.** It lists what you may do this turn. Operations outside it + are rejected. +- **Never echo the signing secret.** Not in logs, not in your response. + +## Versioning + +Shapes under `src/v1/` never change incompatibly. Additive changes ship without a bump; +a breaking change would ship as `v2/` alongside `v1/`, with `v1/` supported for at least +one release cycle after `v2/` is announced. See +[CHANGELOG.md](../../CHANGELOG.md#agent-protocol-compatibility). + +## Something in your way? + +Open an [agent integration issue](https://github.com/muthanii/quorum/issues/new?template=agent_integration.yml). +If the API fought you, that counts as a bug even when the code is behaving as designed.