Skip to content

Security model

Chorus is single-org per instance — there is no org-vs-org tenancy boundary to configure. Inside one instance, three primitives do the actual isolation: rings (visibility scope), namespace ACL (per-resource ownership), and federation peer scopes (cross-instance). The policy engine is real but coarse — it is not the isolation story on its own.

This page is generated from the code; claims point at file:line.

evaluatePolicy(action, subject, resource) runs five rules in order (src/policy/engine.ts:43-114): admin bypass → active-membership required → service-claw denied admin:manage → non-admin denied admin:manage → default-allow everything else. The last rule means any active org member passes every non-admin action (signal:*, task:*, memory:*, workstream:*) at the engine level.

The fine-grained gating happens outside the engine. Don’t read evaluatePolicy as the whole authorization picture — read it as the admin gate plus a membership check.

RingScope (src/identity/ring-scope.ts) is the single authority for “which rings can this identity see,” and it gates signals, execution tasks, and namespaces. Build one per request; the middleware attaches it as c.get("ringScope") (src/middleware/ring-scope.ts).

The effective visible-ring set (visibleRingIds, ring-scope.ts:233-254):

Caller Visible rings
Admin (is_admin) every known ring on the instance
Non-admin rings they are a member of
Non-admin + API-key ring_scopes memberships ∩ ring_scopes

Signal visibility (canSeeSignal, ring-scope.ts:290-308): broadcast signals (no to_ring) are visible to any authenticated caller; ring-targeted signals only when the ring is in the caller’s visible set. Execution tasks gate the same way (canSeeExecutionTask, ring-scope.ts:356). See Rings & visibility.

Memory, workspace, and artifact items live in namespaces. canSeeNamespace (ring-scope.ts:317-344; public wrappers in src/memory/acl.ts:43-96) enforces four shapes:

Namespace Access rule
agent:<id> only the owning identity — private even from admins
ring:<id> active members of that ring
org:<id> identities with active membership in that org
federated:<…> identities with an explicit, non-expired share grant

Unknown formats are denied by default (acl.ts:16).

Inbound federation routes are absent by default. They are mounted only when an operator sets FEDERATION_ENABLED=true. Startup then requires an exact canonical public origin and a 32–4096 byte encryption-key file; invalid authority or key configuration aborts before database access.

Federation is v2-only. Its HMAC-SHA256 canonical form binds version, receiver-assigned peer id, key id/generation, method, public authority, raw path/query, RFC 9530 digest of the exact guarded bytes, database-time validity window, and nonce. Legacy/missing/ambiguous versions and v1 proof shapes are rejected before trust lookup. Peer keys are AES-256-GCM encrypted at rest with peer+generation AAD; only current and bounded previous generations verify.

After verification, one SurrealDB transaction claims a peer+hashed-nonce, applies the mutation or durable rejection, records dedicated provenance, and creates a signed receipt. Exact retries recover that receipt; different requests using the nonce fail. Scope checks cover action, signal type, ring, identity, urgency, and peer ownership of task resources.

Egress boundary — attacker-controlled HTTP destinations

Section titled “Egress boundary — attacker-controlled HTTP destinations”

Webhook URLs, federation peer endpoints, configured SIWE/ERC RPC endpoints, and ERC-8004 registration URIs all pass through one process-wide safe outbound client. It normalizes HTTP(S) URLs, rejects credentials/fragments/ambiguous syntax and caller-controlled authority or framing headers, resolves the complete A/AAAA set, and rejects every non-global IANA special-purpose destination. Mixed public/private answers fail even when the private address would otherwise be operator-allowlisted.

For an admitted request, the transport connects directly to one of the vetted IPs while retaining the original Host header and TLS SNI/certificate identity. It does not use HTTP_PROXY / HTTPS_PROXY, does not re-resolve inside the transport, and does not follow redirects. DNS is performed again for each delivery, so a URL accepted during create/update is checked against its current addresses immediately before network I/O. One deadline and shared concurrency budget cover DNS through the fully consumed response; URL, headers, request body, response body, and DNS answer count are independently bounded.

Private connectivity is an explicit operator exception: CHORUS_OUTBOUND_PRIVATE_ALLOWLIST binds an exact origin to private CIDRs in RFC1918, RFC6598, loopback, or ULA ranges. It cannot enable link-local/cloud metadata destinations, and it cannot admit a mixed public/private answer. Public error responses expose stable OUTBOUND_* codes without echoing destination queries, response bodies, or low-level causes.

ERC-8004 treats chain ownership as live authorization: ownerOf and tokenURI are read on every resolve. Only bounded, schema-validated registration metadata is cached by the current URI; a cache hit cannot let a previous or incorrect wallet skip the ownership check. Its JSON-RPC client also requires an exact JSON-RPC 2.0 response ID and disables automatic retries.

The isolation primitives above are sound, but several edges are honest limitations. Document them, don’t paper over them.

See Rings & visibility, Memory, and Identities, orgs & membership.