Security and authority #
Private Co web exception #
The separately specified OAuth-only Co chat is an operator-authorized capable-agent interface, not a thought stream consumer or a generic Jazz mutation. It binds to the existing API-backed Co identity, creates only web-scoped conversations, retains existing shared agent memory and action/privacy instructions, and explicitly uses unrestricted tools with no approval UI. It does not read memory/Coil, import other conversations, add connectors, change models, pass source credentials, or publish on its own. The existing remote runtime, not a new local sandbox, executes Co. An authenticated owner session can cause consequential existing tool use; exact Origin, CSRF, registry checks, rate limits, encrypted durable send admission, and server-only credentials are mandatory. See web-auth.md, ui.md, and recovery.md for this additional browser authority and its failure semantics.
Default posture #
thought stream has unusually broad read access. Its first security property is containment: observing a source does not grant authority to act on that source or disclose it elsewhere.
Capability split #
- Ingress capabilities: read, poll, subscribe, stat, fetch.
- Model capabilities: receive a bounded context, produce typed proposals.
- Action capabilities: send, publish, edit, delete, transact.
The system implements ingress and model capabilities plus one narrow action capability: Telegram delivery. Telegram ingress and egress are separate processes. The Telegram webhook receiver has no send or registration path. It binds to loopback, requires the exact configured secret header using constant-time comparison, rejects malformed or oversized bodies before persistence, and is exposed only through an operator-owned HTTPS reverse proxy. The dispatcher requires an enabled channel, source and actor allowlists, a destination velocity policy, and explicit started/delivered/failed receipt events. Ephemeral Telegram typing uses the same egress-only credential and direct-reply source/actor/agent/chat allowlists. It sends only { chat_id, action: "typing" }, never source or model content, and remains outside ingress and consumer authority. /correct is ingress-only feedback authority: it can append one sensitive source event and one contract-valid externally ineligible judgment, but cannot invoke a model, send a reply, publish, declassify, or mutate prior evidence. Exact /help is a trusted-parent deterministic response with zero provider requests; it grants no new effect capability and still reaches Telegram only through the receipt-backed dispatcher.
The resident has no channel tool or destination credential. Its activity-window notification surface is an inert observation tuple. The dispatcher requires a configured resident/batch-source route, high importance, exact tag, and exact destination/action recommendation before the output is eligible. Missing or approximate fields stay send-dark. Direct replies use the disjoint resident/Telegram-source route. The model cannot widen either route.
X webhook ingress follows the same effect split with different authentication. The receiver gets only the app consumer secret, uses it for CRC and constant-time raw-body signature verification, and cannot call X management endpoints. Explicit operator commands get the app-only bearer token for status, registration, subscription, replay, and deletion operations. Future user-context OAuth authority for private activity is a separate contract and cannot be inferred from consumer key, secret, or bearer availability. Public-watchlist and personal-private events use separate source ids and privacy lanes. See x-webhook.md.
Action filtering happens at the egress boundary. Producers and consumers continue at source speed; the dispatcher alone decides which completed candidate activity may cross into a channel, how candidates are batched, and when destination capacity is available. Failed-run delivery is a separate allowlisted status and may include only a classified diagnostic. The dispatcher never reconstructs content from run traces and never renders errorText or arbitrary diagnostic strings.
Credentials #
- Credentials and private learned-checkpoint paths enter through environment variables, keyring commands, or injected runtime providers. Telegram bot and webhook secrets are referenced by environment-variable name in the manifest and never stored there.
- Fastmail ingress receives only its JMAP API token. Its client accepts a fixed Fastmail HTTPS session/API set and exposes only read methods; the Fastmail process receives no model, Telegram, X, Git, or filesystem-source authority. JMAP session capabilities are not token-scope proof. An enabled source must explicitly declare dedicated ingress custody or operator-accepted shared custody; unprovisioned sources stay disabled. Fastmail's mail scope still permits mailbox management, so the actual read-only behavior boundary is the client's fixed method allowlist plus process compartment, not a provider claim. The preferred activation path uses a separate token that differs from the email-management credential and omits submission authority where Fastmail exposes that choice.
- Jazz stores only credential reference names, public learned-adapter identity, catalog generation/digest, and SHA-256 of the resolved checkpoint binding. The checkpoint value remains in a process-local
WeakMapowned by startup compilation and never enters Jazz, declarations, traces, events, inspector output, training data, or errors. - Logs, traces, lifecycle events, operational incidents, the private error ledger, repair requests, correction proposals, Telegram notifications, and training exports never contain credential values, raw prompts, provider bodies, provider thinking, malformed or raw model text, tool arguments, image bytes, source bodies, or quarantine content. Operational incidents, the private ledger, and incident alerts additionally exclude arbitrary error messages and stacks. Durable diagnostics use classifications, counts, hashes over normalized classifications, canonical contract identities, stable rule ids, and bounded issue codes/paths. Historical source-specific failure rows may contain error strings; the incident boundary never copies them.
- Test processes explicitly disable ambient
.envloading unless a live integration test is requested.
Image artifact containment #
Inbound Telegram images (photos and admitted PNG/JPEG documents) are downloaded by the webhook process, which has the Telegram bot token but never model-provider credentials. The download uses getFile and the Bot API file endpoint with hard timeout, redirect, normalized-path, and size bounds (≤ 7 MiB raw, with a matching base64 sandbox bound). Actual PNG/JPEG start/end magic bytes are validated after download; declared MIME is not trusted. SHA-256 is computed and the image is atomically written as a content-addressed private file beneath a real nonsymlink thought stream artifact root and real content-addressing parents (sha256/<2-char-prefix>/<hash>).
The event payload carries only an opaque relative path reference, SHA-256, MIME, and byte count — never raw bytes, base64, the bot token, file URL, or absolute host path. The consumer process (which has model credentials but not the Telegram token) passes artifact references through the context packet as imageArtifacts. The trusted Pi or Letta Agent SDK parent resolves each reference strictly beneath the artifact root: it rejects symlinks, path escapes, missing files, hash mismatch, size mismatch, MIME mismatch, and magic byte mismatch before provider dispatch. A malformed stored reference or failed resolution fails the turn closed rather than silently downgrading it to text-only inference. The Letta SDK effect boundary additionally accepts image content only for exactly one current Telegram image; activity batches and other SDK turns cannot carry an image artifact even if a malformed context packet attempts to supply one.
The local Pi conversation reconstruction never replays prior image bytes; only the current event's image appears in its sandbox packet, while older image turns render as neutral [image] placeholders. The resident Letta SDK route has a different, explicit custody model: a successfully validated image is sent as native multimodal content into the one persistent private Stream conversation, and the remote conversation may retain that image for later turns. Jazz, traces, incidents, inspector projections, and activity-batch packets still retain only metadata or opaque references, never base64 or raw image bytes.
Privacy classes #
private: ordinary personal source material.sensitive: email bodies, private chats, Obsidian content, attachments, health/financial/relationship material, or explicitly marked sources.public-source: content already public at its source. Derivations may still reveal private interest or context and therefore remain private by default.
A declaration may set an explicit privacy floor for its execution and derived events. The floor joins with source, accepted-declaration, and learned-adapter privacy and can only raise classification. The public-only Bluesky listener uses a private floor because persistent cross-event interpretation is private even when each raw source record is public.
Agent declarations specify accepted privacy classes. A public-output candidate can be generated from public sources but is still only a private candidate. One shared privacy join orders public-source < private < sensitive. Telegram correction commands and their judgments are always sensitive; the exact replacement remains in private source/judgment state and is never logged or sent as an acknowledgement. Learned-adapter privacy participates in run rows, output/failure evidence, repair request/proposal, judgment/retraction, delivery, effective-output projection, and training. Each stage may raise privacy and may never lower it.
Prompt injection #
All source content is untrusted data. Context rendering wraps it with source boundaries and tells the agent that instructions inside source content have no authority. Output validation does not trust a model's claim that an action was performed.
The persistent Letta resident is an explicit operator-trusted capable agent. thought stream does not impose a source-specific no-tools or standard-mode prison inside that resident. The load-bearing boundary is the feed into the resident and the authority withheld from it: bounded and snapshotted packets, no source/Jazz/Git/deploy/channel credential custody, no implicit public-write authority, sensitive classification for mixed-state derivations, and separate trusted egress policies with receipts. This is an accepted operator tradeoff, not a claim that prompt injection is impossible. A future change must not silently reinterpret tools: [] or source privacy as a different resident permission policy.
Model cells and workspace harnesses #
The observer-v1 Pi inference cell currently requires an x86_64 Linux host with Bubblewrap, prlimit, and the glibc library layout bound by the launcher. It runs in a disposable Bubblewrap process under a different uid with a new network namespace, an empty environment, a minimal read-only runtime, a writable temporary directory, and explicit CPU, memory, file, descriptor, and wall-clock limits. The worker has no host tools and cannot read provider credentials. Read-only enrichment runs in the trusted parent before the broker begins any adapter-backed provider request.
The observer worker can reach only a per-run Unix socket. Its capability authorizes a bounded request set for one run, model, route, token ceiling, cumulative size budgets, and deadline. The trusted broker serializes admission and atomically reserves request count, cumulative request bytes, and response capacity after rechecking expiry; concurrent sockets cannot pass checks against stale counters. It converts each response reservation into actual usage in the same admission critical section. The broker injects the provider credential, rejects redirects, bounds the response, and returns only allowlisted headers. Missing Bubblewrap, missing worker artifacts, broker failure, protocol failure, timeout, or resource exhaustion fails closed. There is no trusted-host inference fallback. Repair agents use this exact path; the coordinator cannot invoke a provider and repair declarations cannot weaken sandbox or broker policy.
The Stream's fixed proposal tools do not widen that socket or add a host handle. The sandbox receives only an exact retry-stable memory target, prior delivered-output targets, and admitted evidence ids. Tool execution validates and returns an inert captured request with terminate: true; it cannot reach Jazz, files, channels, credentials, or a second provider request. The trusted parent revalidates and atomically appends sensitive agent-proposed evidence with publication, quality, and external-export authority fixed false. Before any human decision, judgment projection, or memory materialization, one shared validator requires the complete completed proposer run/output/trigger, exact durable context snapshot, atomic completion receipt naming the proposal, admitted evidence, and target capability chain. One deterministic decision event identity per proposal prevents concurrent human decisions from both becoming canonical. Human decision, judgment projection, and stale-checked memory.md materialization remain separate local capabilities described in proposals.md.
The workspace-v1 profile is separately defined in harnesses.md. It runs a disposable rootless-in-container process with a read-only root filesystem, no IP network, no inherited environment, all capabilities dropped, no-new-privileges, the runtime's default seccomp policy, cgroup-backed CPU/memory/process limits, bounded tmpfs, and exactly one workspace lease, state lease, and provider socket mount. The provider lease authorizes multiple turns only within explicit request-count and cumulative byte budgets. Container image identity and isolation profile are launch evidence. A passing observer-cell canary does not satisfy the workspace-harness gate.
The trusted parent never mounts a host home, repository root, credential store, SSH agent, container-runtime socket, device, or arbitrary declaration-supplied path. Automatic loading of workspace executable extensions is disabled. Any future extension bundle must be reviewed, content-addressed, included in the trusted image, and tested as part of that image's attack surface.
Workspace access is intentional disclosure: the parent must provision a credential-dark lease rather than assuming containment hides files within the lease. Workspace and state leases require total byte and inode quotas enforced outside the container; per-file RLIMIT_FSIZE does not prevent many-file disk exhaustion. Pi session state is private content-bearing storage, not an ordinary metadata trace, and must never flow into notifications, generic inspectors, or training exports.
The letta-cloud-v1 adapter gives a Letta agent broad authority inside an SDK-managed Cloud sandbox. This is an explicit operator-selected profile, not a weakening of observer-v1. thought stream passes only the current bounded event packet and trusted instructions. It does not attach the live repository, host paths, Jazz access, channel credentials, provider credentials, or private environment values. LETTA_API_KEY remains in the trusted parent and is consumed by the SDK client; it is never added to event data, trace payloads, prompts, declarations, or agent memory.
An unrestricted Cloud permission mode authorizes the Letta harness to use its available sandbox tools without per-call thought stream approval. It does not authorize Telegram delivery, public posting, deployment, account mutation, or any other thought stream egress. Those remain separate trusted actions with their own policies and receipts. Server-side tools or secrets attached directly to the Cloud agent are a separate operator capability and cannot be inferred from the declaration.
The local Agent SDK profile is a named exception only when the SDK-owned App Server successfully applies filesystemConfinement: memory to the existing agent memory root, the declaration says outputOnly: true, skillSources: [], no MCP servers, no workspace resource, no arbitrary cwd, and strict permission mode. Coil Public Knowledge additionally requires proposalTool: public-knowledge-diff, allowedTools: [submit_public_knowledge_diff], and exactly one controller-owned tool with that name. Its callback validates and captures one inert proposal in memory and has no effect handle. Zero, duplicate, malformed, unknown, uncorrelated, or failed tool activity invalidates the turn before semantic settlement. If the kernel confinement primitive, memory root, App Server, or authenticated API-backed agent route is unavailable, the turn fails closed before source progress advances. This profile does not authorize a generic local Agent SDK declaration or a host-workspace coding agent.
The Coil Public Knowledge trusted parent may read only the configured Coil root, exact Jazz document version, default-deny policy file, and public catalog root. Those paths are operator configuration and never model-selected. It never sends policy-blocked source content to the SDK. Catalog reads accept regular Markdown files inside the configured public root; metadata for all entries and exact bodies/hashes for only a bounded relevant target set enter the snapshot. The model's full draft is stored only as a sensitive proposal with publicationEligible: false. No materializer or publisher currently consumes it. The separate reviewed-materializer contract is in public-knowledge.md.
The resident's mixed Telegram/ATProto conversation makes the thought stream-to-agent border load-bearing. Public source text and third-party Markdown are bounded, snapshotted, and marked as untrusted data; strong references remain distinguishable from mutable protocol or social renderings. The trusted parent calls only the source-appropriate fixed public services: Bluesky post/like context may use atproto.md plus bsky.md, while Semble collection-link context uses atproto.md for the link, card, and collection and never sends those records to bsky.md. thought stream does not pass source credentials, Jazz credentials, deploy keys, Git credentials, host paths, or public-write authority into the packet. Fetched bodies and context snapshots live under private runtime storage and are forbidden from Git, build artifacts, traces, accounting, Telegram delivery, operational errors, and public projections. Prompt guidance reminds the resident not to expose private continuity, but the Cloud sandbox remains an operator-selected capable-agent environment after that border.
Web authentication containment #
The public website, OAuth control routes, and private inspector forwarding share a process only for deployment convenience. They do not share data authority. The public router is a closed allowlist and cannot obtain a Jazz store, runtime manifest, source/event/trace reader, arbitrary filesystem path, environment dump, or upstream fallback. Only /inspector may reach the loopback inspector, and only after OAuth-session or explicitly enabled Basic fallback authentication.
ATProto OAuth is implemented by the official Node client rather than a partial local protocol implementation. The SDK performs mandatory PKCE, PAR, DPoP, nonce handling, metadata discovery, identity resolution, token refresh, and request serialization. thought stream additionally enforces an exact DID allowlist after callback and before creating a browser session. OAuth grants inspector read access only; the OAuth token is never used as a general PDS capability by this service.
OAuth protocol state, application state, DPoP private keys, access tokens, refresh tokens, and browser-session records are encrypted at rest with AES-256-GCM under a separately injected 32-byte key. The encrypted store is outside Git and outside the thought stream live runtime root, with owner-only directory/file modes and atomic replacement. Every store has explicit entry-count and serialized-byte limits. The ES256 confidential-client private JWK is separately injected. Neither key may appear in environment diagnostics, process output, tests, errors, events, traces, or HTTP responses. Browser cookies contain only random identifiers and use HttpOnly, Secure, SameSite=Lax, Path=/, bounded lifetime, and a __Host- name.
Login passes random browser-bound application state into the SDK. The SDK generates a distinct OAuth protocol state and owns its one-time validation. Callback requires exactly one bounded protocol-state query value, then compares the SDK-returned application state with the unique cookie and consumes its application record. Failed/timed-out callback settlement, browser expiry, DID mismatch, restore failure, and explicit logout delete only matching local staged or promoted generations and make no application-initiated remote revocation request, because provider-wide semantics cannot be proven safe against a newer concurrent grant. The unmodified SDK may independently revoke after issuer, exchange, or session-store failure; that provider-side residual is not represented as a local authorization guarantee. Logout requires a server-stored CSRF token and POST. Generic failures reveal no account, token, state, upstream, or private-object detail.
OAuth initiation and callback have independent bounded rate limits in nginx and the process. Nginx access logging records path without query and disables both callback access/error logging; the application never logs request URLs or SDK callback exceptions. Authorization discovery receives a request-disconnect abort signal because the SDK supports it. The installed SDK callback API does not support cancellation, so every callback uses attempt-scoped session staging plus one watchdog over the complete settlement path. Timeout synchronously expires both authority layers before advancing the serializer. Application-state consumption, generation promotion, and browser persistence pass authority guards that are rechecked after temporary write and immediately before rename. Cleanup is detached and generation-conditional, so hung promotion or cleanup cannot block a newer callback. Expired staging accepts late SDK session-store writes into quarantine, suppressing one known store-failure revocation path; late state is then deleted locally. It cannot suppress every SDK revocation path without transport interception or killable isolation. The underlying SDK promise may remain unresolved, so inert callback quarantines are capped at eight. Capacity refuses new callbacks with an operator-recycle-required 503 until attempts settle or the singleton process restarts.
SDK restore is also generation-scoped: get, refresh set, and failure delete are bound through AsyncLocalStorage to the browser generation that initiated restore; stale completion is a no-op and a post-restore persisted-generation check runs before authority returns.
The encrypted JSON stores are single-process stores. Startup acquires an owner-only lock in the fixed ~/.local/share/thoughtstream-inspector-auth directory and refuses a live owner; systemd runs one non-templated proxy unit and grants write access only to that directory. Environment parsing rejects custom OAuth store paths rather than allowing a configuration the sandbox cannot write. Multiple replicas or manual parallel proxy processes may not share the directory.
Basic Auth is a separately configured, default-off break-glass path. Exact true configuration plus a valid credential enables read-only inspector forwarding, bypasses OAuth restore, and is stripped before upstream access. Missing and false configuration disables the path without requiring or decoding the credential; malformed boolean configuration fails startup. Startup refuses a configuration with neither OAuth nor Basic. An operator may retain Basic during OAuth activation only until a real external HTTPS metadata fetch, redirect, callback, allowlisted-DID session, private inspector read, logout/local deletion, and one bounded Basic rollback read are observed. Retirement removes the separate Basic credential file, restarts only the proxy, and verifies generic credential rejection with no challenge. Tests and localhost callbacks are insufficient evidence.
Review decisions and course questions are the only browser mutation exceptions. Basic remains read-only. An allowlisted OAuth session and CSRF token authorize the public proxy to sign one exact bounded body with a route-specific injected capability. The inspector verifies method, normalized path, body digest, freshness, and one-time nonce before applying the fixed append-only schema. Review and course chat use different keys, so one authority cannot be replayed against the other route. Cookies, OAuth tokens, DIDs, CSRF values, and Basic credentials never reach Jazz or the loopback inspector. Missing route capability configuration removes only that mutation path. See review.md, courses.md, and web-auth.md.
Course context is repository-owned and revision-addressed. The browser sends only the known lesson id, optional section id, question, request id, and current revision. The inspector rejects unknown lessons and stale revisions, then stores one sensitive event. The tutor declaration admits only that exact source and event type. It cannot select another model, read other Stream history, call a tool, send a message, modify files, or publish. The authenticated status read returns model text only after validating a completed run and exact output lineage over the question event.
The authenticated inspector's Bluesky renderer may fetch public image bytes only through one fixed loopback route. The trusted inspector parses the requested URL and requires HTTPS, no credentials, the exact cdn.bsky.app host with default port, and an /img/ path. Fetches have a hard deadline, reject redirects, bound declared and streamed bytes, allow only JPEG/PNG/WebP/GIF response types, and verify matching file magic before returning same-origin bytes. The route has no Jazz, credential, arbitrary-host, generic-proxy, HTML, SVG, or public-route authority. Its bounded memory cache contains only already-public CDN bytes and expires entries; the authenticated proxy still applies no-store to browser responses.
Dependency audit residual #
The July 26, 2026 production audit has one unresolved high-severity finding: sharp@0.34.5 is inherited through @letta-ai/letta-agent-sdk -> @letta-ai/letta-code, while the advisory requires sharp>=0.35.0. Review prompt generation, browser grading, capability verification, and JSONL export do not invoke image decoding, so this is not exposed by the Review path. It is still a project-level dependency finding and remains visible until the upstream SDK adopts a compatible patched Sharp version or a separately tested major-minor override is approved. Direct fast-xml-parser and compatible transitive protobufjs/brace-expansion findings are patched; a clean audit must not be claimed while Sharp remains.
Filesystem containment #
- Resolve and verify real paths beneath configured roots.
- Ignore symlinks leaving the root.
- Deny secret filenames and hidden runtime directories by default.
- Apply file size and extension limits before reading.
- Never watch
/home/cameronor the entire Coil by default.
Thought artifact containment #
The trusted artifact-request broker reads exactly one relative file beneath one explicitly supplied workspace lease root. It rejects path escape, root/file/parent symlinks, special files, extension/media/magic mismatch, malformed structured text, and oversized reads. It stores verified bytes once under the existing private content-addressed root with private modes and appends metadata-only request/artifact evidence; no workspace root or artifact bytes enter events, catalogs, logs, notifications, training exports, or public projections. See artifacts.md.
Audit #
Private agent messaging is a separate narrow local ingress capability. V1 admits only Co → stream-agent-conversation, uses an exact sensitive event schema, and preserves sender/recipient/thread provenance. It cannot forge Telegram, edit trusted memory, address arbitrary declarations, grant model tools, or publish. Responses are typed Jazz events with no channel route. See agent-messages.md.
Configuration changes, agent activation, connector activation, model tier changes, adapter catalog installation, and any future action capability changes require an operator-visible deployment receipt. An adapter manifest cannot grant a provider endpoint, credential value, host path, or action capability. It may name one non-secret environment reference and selects only an already trusted provider profile. The public base model and private runtime checkpoint are checked independently against the trusted provider allowlist. Startup compilation is the only path that creates the process-local checkpoint binding; compiled identities are deeply frozen, and clone/rehydration/forgery cannot recover that binding. Adapter activation and retirement require stop/install/restart plus PID/start-time, loaded-digest, and canary receipts. There is no runtime lifecycle mutation API.
Every Telegram attempt appends a durable started claim before calling the Bot API and then appends delivered or failed evidence. Claims use deterministic identities so concurrent dispatcher processes cannot intentionally claim the same batch twice. A claimed attempt is never inferred as delivered merely because the process exited cleanly. Failure notifications contain a short receipt and allowlisted diagnostic rendering; old traces containing raw content remain unread by the dispatcher. Operational incident alerts use a separate category policy rather than normal source allowlists, and Telegram delivery failures are never recursively alerted through Telegram. Telegram delivery, reaction, or target-bound correction can supply judgment evidence. Delivery itself never changes effective output; only an active contract-valid judgment can do so through a rebuildable projection.
Jazz policy boundary #
Historical immutability and private-row access require database policies, not merely conventions in a repository class. The current local fixture policy is permissive: it does not enforce the intended per-namespace immutability or multi-user authorization rules. The alpha.55 local deployment is restricted to trusted backend clients and a loopback storage owner, with local-first authentication disabled. Do not expose browser/direct-user sync or describe this as a verified multi-user permission system. Before that deployment, policies must deny update/delete/restore for events, document versions, and trace chunks and restrict mutable cursors/progress to their owning namespace.
Before JAZZ_SERVER_URL is accepted outside an explicit test, adversarial integration tests must verify read, insert, update, and delete behavior for backend, authorized user, unauthorized user, and anonymous sessions. “The UI does not expose it” is not authorization. It is barely even interior decoration.