Security model
orch is for small teams who trust each other. It is a shared to-do list with automation, not a multi-tenant platform. Read this before you adopt it.
-
Trusted teams only. Anyone who can get an issue labeled for the pool can, in effect, write a prompt that runs on a teammate’s machine. Do not use orch with strangers or with public repos where outsiders can file labeled issues.
-
What the sandbox does. Each task runs in its own container, non-root, with only that task’s clone mounted. No docker socket, no host home directory. Each task gets an isolated clone, not your working copy. Git metadata (the bare mirror and the clone’s
.gitbookkeeping) is kept outside the task container, so a task cannot rewrite hooks or config that the agent’s own git would later run. -
The repo’s compose file is validated and hardened. When a task uses the repository’s own
compose.yamlfor its sidecars (database, cache, …), the agent checks it before starting anything (see The compose guard) and runs only the checked, hardened copy. A compose file that tries to reach the host or another task is refused with an error naming the key. -
What the sandbox does not do. In auto mode the agent runs
claude -p --permission-mode bypassPermissionsinside the task container. That container has network access and receives the member’s Claude token. A malicious or compromised issue can therefore make Claude exfiltrate that token, call external services, or push harmful code to the task branch. There is no egress filtering in v1. The token is the member’s own and can be revoked from their Claude account. -
The agent is root-equivalent on the member’s host. The agent container mounts
/var/run/docker.sockto start task containers. Anyone controlling the agent controls that Docker daemon. Rootless Docker is a supported way to reduce this. -
Claude credentials never leave the member’s machine. The token lives in the agent’s local volume and in that member’s task containers. The server stores no Claude credentials. The agent mounts
~/.clauderead-only, only to estimate usage. -
Usage figures are estimates. Anthropic does not publish exact subscription limits. Budgets, margins and reset times come from local logs plus a safety reserve and may be wrong. Treat them as hints.
-
Review PRs. Auto-mode PRs are drafts. Enable branch protection and require human review before merging. The GitHub App’s
Contents: writecan push to any branch. -
Who can sign in. Production requires admins (
ORCH_ADMIN_GITHUB_LOGINS) and/or an allowlist of GitHub logins or organizations (see Who may sign in). Admins approve everyone else on the Access page; a refused sign-in creates no session, only a request form protected by a short-lived HMAC-signed token, the Origin check and a rate limit. Invite links are stored hashed and expire. A task board is visible only to the project owner, active participants and GitHub collaborators; owner rights are re-checked against the GitHub ADMIN role (cached a few minutes), so a former admin loses them. -
GitHub outages: last known decision for 15 minutes, then fail closed. Organization membership is checked with the user’s GitHub token. When that check fails (network error, 5xx, rate limit, or a 401 because the user revoked the orch App authorization in their GitHub settings), the user’s last known allow/deny decision is reused only if it is younger than
ORCH_ACCESS_GRACE_SEC(default 900 seconds). After that, or with no earlier decision, the user is refused: sessions, personal tokens, OAuth tokens and agents alike. A failed check is not retried for 30 seconds, so an outage does not turn every request into a GitHub call. A revoked App authorization never grants access: it is “unknown”, gets the same grace, then is refused; so is a GitHub token that can no longer be refreshed. Admins, logins inORCH_ALLOWED_GITHUB_LOGINSand logins approved on the Access page do not depend on GitHub and are unaffected. A refused member’s agents are disconnected with the non-fatal close code 4403 (an upgrade gets HTTP 403) and receive no offers; they reconnect on their own once access is restored. Tasks already running keep their GitHub installation token until it expires (at most one hour). -
Repository access is re-checked, not remembered. The project owner stored in orch (
addedById) is only a hint. For every private repository, each call that depends on it (usage dashboard, task history, board, templates, the pool, the MCP tools, the project list, notification recipients, the owner rights on tasks and projects) asks one cached check: does GitHub still give this user a role on the repository, and for owner rights,ADMIN? An owner who was removed, lost the repository, or whose installation was removed or suspended sees nothing of it, even with an old participation row, and receives no notifications. The same check gates work on the repository: a member (former owner or participant) GitHub no longer lets in is not offered its tasks, cannot accept an offer still open, cannot list or claim them from an agent and is refused repository tokens for a run in progress, unless the project lets every member participate (ALL_MEMBERS) or the owner allowed them explicitly. The dispatcher reads the cached roles of a whole tick in one query and asks GitHub at most once per stale project and member; list endpoints do the same per request, and concurrent checks of one pair share a single GitHub call. The answer is cached per project and user forORCH_REPO_ACCESS_TTL_SEC(default 300 seconds). Webhooks end the wait: collaborator added/removed/changed (member), repository made private or public, deleted or transferred (repository), organization member removed (organization), team membership or team repository changes (membership,team) and installation deleted, suspended or unsuspended mark the cached roles stale, so the next request asks GitHub. If GitHub answers 403 or 404 for a private repository, access is denied immediately (a 403 that is a rate limit counts as an outage, and so does a 401, which concerns the orch App’s own credentials rather than the user). A refresh that was in flight when a webhook invalidated the role never overwrites the invalidation. If GitHub is unreachable (5xx, 429, 401, timeout, network), the last verified role is reused forORCH_REPO_ACCESS_GRACE_SEC(default 900 seconds), then access is denied; a role marked stale by a webhook gets no grace. Each refresh, denial, grace use and outage denial is counted inorch_repo_access_checks_total{result}and the last two are logged as warnings. Public repositories are unaffected: they keep the stored owner and the previous behaviour. Owner overrides (“may submit”, “may participate”) are kept when a role is invalidated. -
Live task logs. While a task runs in auto mode, the agent streams a compact form of Claude’s output (messages, tool names and short arguments, the start of tool results, stderr) to the server, which shows it on the task card (see Live logs). The agent masks secrets before anything leaves the machine (see Redaction), strips terminal escape codes, and the server masks the secret formats again. Masking only catches known values and formats: a secret it does not know can still appear. Logs are readable by exactly the people who can see the task board (project owner, active participants, GitHub collaborators, with the same rules for private repositories), are kept in server memory only (512 KB per run, 30 minutes after the run, never in the database or backups), and an open stream is re-authorized every 2 minutes, so removing someone stops their stream within that delay. Only the agent running an assignment can write to its log.
-
Server side. Only Caddy is published. Postgres and the server are internal. GitHub user tokens are encrypted with
ORCH_ENCRYPTION_KEY. Agent credentials and personal MCP tokens are stored hashed. Webhooks are verified with HMAC and deduplicated by delivery id and by body hash. Caddy sends HSTS and a strict Content-Security-Policy, and the server never logs query strings (they carry the OAuth code). Every state change is written to an append-only audit log. -
Rate limiting. Requests are limited per signed-in user (or per client IP), with much stricter limits on sign-in, OAuth and enrollment endpoints, the webhook (also capped at 1 MiB per body) and the MCP endpoint (per token). New agent connections are limited per IP and an agent that floods messages is disconnected. Limits are in memory (single instance) and configurable, see Rate limits. The client IP is read from
X-Forwarded-Foronly when the request comes from the trusted proxy (ORCH_TRUST_PROXY), so a spoofed header does not change the key. This slows down abuse and brute force; it is not a substitute for a firewall or Cloudflare in front of a public server.
Report vulnerabilities privately through GitHub security advisories on the repository.
The compose guard
Section titled “The compose guard”The agent holds the host’s Docker socket, so a sidecar definition that mounts a host path, joins a host namespace or gains a privilege would be root on the member’s machine. The guard works in two steps and denies everything it does not know.
Before Docker reads anything, every compose file is parsed (a YAML error fails closed) and its include, extends, env_file, label_file and project-directory references are followed: remote references (oci://, git@, URLs), interpolated paths and anything that resolves outside the clone (symlinks included) are refused, as is an implicit .env that resolves outside it.
After full interpolation and merge, the output of docker compose config --format json is checked against allowlists: every key of a service, build, deploy section, volume entry, network, volume, secret and config must be known, and unknown keys (including keys a future Compose version adds) are refused. docker compose runs with a minimal environment (PATH, HOME, the DOCKER_* connection settings), so a compose file cannot copy an agent variable into a sidecar through ${VAR} or environment: [VAR].
Refused:
privileged,devices,device_cgroup_rules,runtime,isolation,cgroup_parent,oom_kill_disable,gpus,use_api_socket,provider,blkio_config,storage_opt,credential_spec, real-time CPU settings andexternal_links;cap_addof anything other than Docker’s usual harmless set (CHOWN,SETUID,NET_BIND_SERVICE, … andIPC_LOCK).SYS_NICE, asked for by MySQL examples, is removed with a warning;security_optother thanno-new-privileges(noseccomp/apparmorunconfined or custom profiles, no SELinux labels);- host or foreign namespaces:
pid,ipc,uts,userns_mode,cgroupset tohostorcontainer:*,network_modeother thannone, the task network or a service of the same project, andvolumes_froma container; - networks other than the project’s own (external networks, other drivers than
bridge,driver_opts, custom IPAM). The default network is replaced by the task’s network; - bind mounts outside the clone (the Docker socket,
/, the home directory), shared mount propagation, external volumes, volumes not named after the task’s project, anydriver_optson volumes (the local driver’s bind trick), and a writable bind mount of a directory that is a parent of another service’s bind source (one sidecar could replace that source with a symlink to the host before the other mounts it — use a named volume to share data between services), and a writable bind mount of a top-levelsecrets/configsfile or of a directory containing one, in any service (the file could be swapped for a symlink to the host before it is mounted); tmpfsoptions other than size, mode, ownership andnoexec/nosuid/nodev-style flags;ulimitsother than the restrictive ones, withnofile,nproc,msgqueue,sigpendingcapped andcoreat 0 (nortprio,rttime,nice); non-namespacedsysctls(onlynet.*,kernel.shm*/msg*/semandfs.mqueue.*);extra_hostspointing athost-gateway;dnsthat is not an IP address; a negativeoom_score_adj; logging drivers other thanjson-file,localandnone;- builds whose context,
dockerfileoradditional_contextsare outside the clone or remote; buildsecrets,ssh,entitlements,privileged,cache_to,tags, a host or foreign buildnetwork, andcache_fromother than registry or inline; secretsandconfigswhosefileis outside the clone, or that read the agent’s environment (environment) or a swarm object (external);- labels in the
orch.orcom.docker.compose.namespaces (they would make a sidecar look like another task’s resource); - more than 16 services, or a
scale/deploy.replicasabove 3.
Then the checked model is hardened and only that copy is run: published ports, fixed container_names (kept as network aliases), restart policies and develop are removed; every sidecar gets cap_drop: [ALL] plus the harmless baseline it did not drop, no-new-privileges, a memory limit (default 2 GiB, at most 4 GiB, no swap on top), CPUs (default 2, at most 4), a pids limit (default 1024, at most 4096), cpu_shares and shm_size capped, a stop grace period of at most 30 seconds, rotated logs (10 MB, 3 files) and orch’s labels. Built images are tagged per task and removed with the sidecars. Extension fields (x-*), healthchecks, profiles, init, depends_on and the usual environment and command settings are kept.
The guard is tested against a corpus of real docker compose config outputs: realistic files for Postgres, Redis, MySQL, MinIO, LocalStack, MailHog and an application built from the repository’s Dockerfile are accepted, and one malicious file per escape vector above is refused.
What it does not cover: sidecars, like the task container, have network access (no egress filtering) and can reach services listening on the host’s bridge address; images are pulled from wherever the compose file says; a sidecar can use its CPU, memory and disk quota fully (the image layers and named volumes count against the host’s disk).
Redaction
Section titled “Redaction”One shared module (packages/shared/src/redact) masks secrets as [REDACTED]. It is applied to the live task logs (agent and server), the run log file on the member’s machine, failure messages and summaries sent in task.finished (and stored as the run’s reason), the PR body and the commit message orch writes, notification titles and bodies (every channel), and the agent and server logs (errors included).
Known values (agent only: the server never sees them). Every secret the agent knows for a run: the member’s Claude token, the run’s GitHub tokens, the agent credential, and the values injected into the task and sidecar containers (devcontainer containerEnv, build args, compose environment and env_file values once resolved, the password part of URLs). An env value is masked when it has 8+ characters, is not a common word (production, postgres…), a number, a version or a path, and either its key looks secret (*TOKEN*, *PASSW*, *KEY*, *SECRET*, *_DSN…) or the value looks random (12+ characters, letters and digits, entropy of at least 3.5 bits per character). Each value is also matched JSON-escaped, URL-encoded, in hex (both cases), in base64 and base64url (at the 3 byte alignments, so it is found inside a larger encoded blob such as a Basic auth header), and when line breaks (and the indentation after them) split it.
Formats (agent and server): Anthropic sk-ant- keys, OpenAI project keys, GitHub (gh[pousr]_, github_pat_), GitLab glpat-, npm, orch agent credentials and personal tokens, AWS access key ids (AKIA, ASIA…), Google API keys, Slack tokens (xox*, xapp-) and webhook URLs, Discord webhook URLs, Stripe sk_/rk_ live and test keys and whsec_ secrets, JWTs, private key blocks (PEM, OpenSSH, PGP, also JSON-escaped as in a GCP service-account file; an unterminated block is masked to the end of its body), Azure AccountKey= / SharedAccessKey= and SAS sig=, Authorization and API-key headers, credentials in URLs, and generic assignments such as FOO_SECRET=..., "api_key": "..." or password: ... when the value looks random (8+ characters, entropy of at least 3.5 bits per character, or 3 with both letters and digits; numbers, placeholders like ${TOKEN} and code like options.token are left alone). AWS secret keys and GCP private_key_id values are caught by the generic rule through their key names.
Streams. Live logs and the run log are masked piece by piece with a small tail of the previous piece: a known value split between two lines or batches is masked on both sides (5+ characters of its start at the end of a line are masked there), a token cut at the end of a line masks the next line’s leading token characters, and a private key block stays masked until its END line.
Not covered. Values the agent does not know and that have no recognizable format (a password printed by a test, a secret fetched at run time), other encodings (gzip, double encoding, a secret split into pieces by code), values shorter than 8 characters or too common to mask, and line-wrapped format tokens inside one piece (only known values are matched across line breaks). Commit SHAs, UUIDs and base64 data (images) are not masked: there is no generic high-entropy rule outside assignments. After an agent restart, a run’s env values are known again only once the run is prepared again. Masking is a safety net, not a guarantee: treat live logs like the repository itself.
Cost. All rules run in linear time (tested with adversarial inputs, no catastrophic backtracking); a 32 KB batch with 30 known values takes about 0.05 to 0.1 ms per KB.
Limitations
Section titled “Limitations”- Single server instance (in-process scheduler); no high availability. One host, scheduled dumps.
- No egress control, devcontainer sanitizing or token-injecting proxy for task containers (see “Future hardening” in orch: Implementation Plan). The repository’s compose file is validated and hardened (The compose guard), but sidecars share the task’s network access.
- Usage and budget are estimates; plans are declared by the member.
- Repos must build from a
Dockerfileor a simple.devcontainer.json; complex devcontainer features are not honored. - Manual-mode
screensessions end if the agent container restarts; the task container survives andorch attach <n>reopens a session. - One concurrent task per machine by default.
- GitHub only. English UI only.
- The production images run TypeScript through
tsxrather than a compiled build; the server image is still about 380 MB (Alpine, trimmed Prisma CLI).