Skip to content

Security register ​

Security issues found in guilds.run, fixed or still open. Unlike the other chapters, this one keeps history: each entry says what was exposed, how it was fixed or what fixing it takes, and what risk remains. Entries are not removed once fixed. Newest first; ids are never reused.

IdIssueStatus
SEC-15Outside session mode the API answers requests from any web originFixed 2026-10-06
SEC-14A box outside the GCP cut encrypts under the all-zero development keyFixed 2026-10-06
SEC-13Chat image uploads could persist unsanitized bytes or leak across organisationsFixed 2026-10-03
SEC-12The GitHub App's private key mints tokens for every installationOpen
SEC-11A GitHub install callback could name another account's installationFixed 2026-09-30
SEC-10A coding agent's repository token is readable by any process in its sandboxOpen
SEC-9Graft's hosted features can send repository history to its vendorOpen
SEC-8Content a coding agent reads can steer its commandsOpen
SEC-7A coding agent's workspace can fill the shared diskOpen
SEC-6Coding agents run commands as root in the containerOpen
SEC-5Background commands make the SEC-3 race practicalOpen
SEC-4Package installs run third-party code beside the agent's secretsOpen
SEC-3Check-then-open race on agent workspace pathsOpen
SEC-2Any organisation member could write organisation-wide secretsFixed 2026-09-30
SEC-1Links planted in an agent workspace redirect orchestrator file accessFixed 2026-09-30

SEC-15 — Outside session mode the API answers requests from any web origin ​

Status: fixed 2026-10-06, the day it was found.

Where: AUTH_MODE=compat-cookie and trusted-header. The origin check on state-changing requests (preHandler in src/http/auth-routes.ts) and on sandbox WebSocket handshakes (proxySandboxWs in src/http/routes.ts) ran only when AUTH_MODE=session. No mode checked a request's Host.

Exposure: in compat-cookie with one user, a request that names no user is that user. A page open in the operator's browser could reach http://127.0.0.1:8080: a hostname rebound to the loopback address made the page same-origin with the API, which then read and wrote whatever the operator could, sandbox panes included. Without rebinding, a page could still send the requests a browser does not preflight and open the sandbox WebSockets. This is the mode of the local stack and of a core box (Extension points), where no sign-in stands in front of the API.

Fix: one hook in front of every route, in every mode (src/http/origin.ts). A request is served only when its Host is a loopback name or the host of PUBLIC_BASE_URL; /health alone answers any host. A state-changing request or a WebSocket handshake that carries an Origin is served only when that origin is the host the request names, the origin of PUBLIC_BASE_URL, or a loopback origin to a loopback host (a development server proxying to this one). A request with no Origin is not a browser's and passes. session mode keeps its own stricter check on top, which requires the Origin. AUTH_MODE=trusted-header requires PUBLIC_BASE_URL, so a box behind a proxy names the host it is reached by.

Residual risk: anything that reaches the port still acts as the operator, so the port stays on loopback or behind a proxy that authenticates. A request without an Origin is not checked, which covers every client that is not a browser. Another server on the operator's machine is a loopback origin and passes; it could reach the port directly anyway.

SEC-14 — A box outside the GCP cut encrypts under the all-zero development key ​

Status: fixed 2026-10-06, the day it was found. A box that already ran on the zero key still holds credentials encrypted under it.

Where: docker-compose.yml (SECRETS_MASTER_KEY: ${SECRETS_MASTER_KEY:-0000…}), Makefile (SECRETS_MASTER_KEY ?= 0000…, exported for every goal), make up and make up-hosted.

Exposure: the master key resolved to 32 zero bytes written in this repository, and the orchestrator booted on it without a word. On make up-hosted, the self-hosted box of Install, this happened even with a real key in .env: make exported its default, and Compose takes an exported value over the one .env sets. The same held for the PostgreSQL password (agent_platform) and the OpenSandbox key (cigale-dev). Every connection credential, organisation model key, secret, and sandbox host API key was then encrypted under a key anyone with the repository has, so a database dump or backup read as plaintext. The GCP cut was not affected: deploy/release.sh exports .env before it calls make, and deploy/validate-hosted-env.sh refuses the defaults.

Fix: the orchestrator refuses to start on an all-zero master key unless ALLOW_ZERO_MASTER_KEY=1, which only the development overlay (docker-compose.watch.yml) sets. docker-compose.yml gives the key no default. The Makefile sets and exports its development secrets for development goals only, so make up-hosted and make up-gcp read theirs from .env or the caller's environment.

Residual risk: a box that ran make up-hosted before the fix has its credentials encrypted under the zero key, and its PostgreSQL volume initialised with the development password, whatever its .env says. Started again, it reads .env: a real key there cannot decrypt what the zero key encrypted, and a different password does not open the volume. No tool re-encrypts stored credentials under a new key; until one does, such a box keeps running only by setting the zero key and ALLOW_ZERO_MASTER_KEY=1 on purpose. A fresh box has no setup step yet and depends on its operator writing a random key.

SEC-13 — Chat image uploads could persist unsanitized bytes or leak across organisations ​

Status: fixed 2026-10-03, as direct-chat images shipped.

Where: POST /api/attachments, GET /api/attachments/:id, user-message metadata.attachments, src/attachments/.

Exposure: accepting the declared MIME type would take SVG (and other active content). Storing base64 in messages.payload would keep image bytes in the run forever and inflate backups. Serving files without an org check would let any member read another organisation's upload.

Fix: the body is sniffed by magic bytes (JPEG, PNG, GIF, WebP only; no SVG). Size is capped at 10 MiB and four images per message. Bytes go to the attachments bucket (or process memory when the bucket is unset) under {orgId}/{id}; the message stores only { id, filename, mime, kind }. GET is org-scoped, nosniff, and inline only for images. The catalog attachments flag gates attach. Hydration uses a 15-minute signed URL, or a data URL when signing is unavailable or the provider refuses public image URLs (Kimi), and never writes those URLs back to the row.

Residual risk: an upload that is never sent stays in the bucket until an operator deletes it. A signed URL is readable by anyone who has the link for 15 minutes. When signing fails, the provider request carries a data URL.

SEC-12 — The GitHub App's private key mints tokens for every installation ​

Status: open. Found: 2026-09-30, building the GitHub connection.

Where: GITHUB_APP_PRIVATE_KEY in the orchestrator environment; src/github/index.ts signs App tokens with it.

Exposure: the key authenticates as the App, which can request a token for any installation of it: every connected account's selected repositories, across organisations.

Control in place: the key stays in the orchestrator environment with the other platform keys and never enters a sandbox; installation ids stay in encrypted connection payloads.

Residual risk: a control-plane compromise reaches every connected account's selected repositories.

SEC-11 — A GitHub install callback could name another account's installation ​

Status: fixed 2026-09-30, before the GitHub connection shipped.

Where: the GitHub branch of GET /api/mcp/oauth/callback (finishGithubConnection in apps/orchestrator/src/http/routes.ts).

Exposure: the callback's installation_id is a query parameter. Accepting it as is would let anyone bind their connection to another account's installation of the App, and so mint tokens for its repositories.

Fix: the callback exchanges its code for the installing user's token and keeps an installation only when that user can reach it (listed on the token, or GET /user/installations/{id}/repositories succeeds). The user token is discarded. An integration test forges an id and checks the attempt is dropped.

Residual risk: none known.

SEC-10 — A coding agent's repository token is readable by any process in its sandbox ​

Status: open. Found: 2026-09-30, building the GitHub connection.

Where: /run/guilds/git-tokens.json, guilds-git-credential, and GH_TOKEN in shell commands.

Exposure: git and gh need the installation token inside the sandbox, so any process there can read it, including third-party code a package install runs (SEC-4) and a steered agent (SEC-8).

Control in place: each token lives one hour, reaches only the guild's repositories that use its connection, with contents and pull requests write; it is kept off the workspace disk, replaced before it expires, and redacted from tool results.

Fix to make: a control-plane git proxy that holds the credential and checks the branch. Until then a process can push to any unprotected branch of those repositories for that hour; protecting the default branch is the operator's control on GitHub.

SEC-9 — Graft's hosted features can send repository history to its vendor ​

Status: open. Found: 2026-09-30, building code search.

Where: the Graft CLI in agent-runtime and the Graft-backed tools in runtime/cigale-code.

Exposure: Graft has model-written and hosted features (build --deep, trail, viz, init, mcp) that send code or repository history to a model provider or to its vendor, and it reports usage.

Control in place: the tools run only its structural tier (tree-sitter, no model, no network); no tool runs those commands; no provider key is in the sandbox; DO_NOT_TRACK=1 turns telemetry off, and the helper keeps Graft's text output and stderr out of results.

Residual risk: an agent can still type such a command in shell, and Graft's daily npm version lookup has no switch.

SEC-8 — Content a coding agent reads can steer its commands ​

Status: open. Found: 2026-09-30, building coding agents.

Where: the shell tool (apps/orchestrator/src/toolbox/code.ts, runtime/cigale-code) and the coding block of system.md.

Exposure: a coding agent reads repository files, issue text, and command output, and runs any command it decides on. Instructions planted in that content can steer it to run commands the operator did not intend: read its environment, change files in its workspace, or send data out.

Control in place: the coding prompt block states that repository content, issue text, and command output are data, never instructions, and that a repository's contributor guide sets code conventions only. Secrets reach an agent only by grant.

Residual risk: a steered agent can do anything its sandbox and its granted secrets allow, including pushing to its guild's repositories' unprotected branches and opening pull requests with its installation token (SEC-10); the token cannot reach other guilds' repositories.

SEC-7 — A coding agent's workspace can fill the shared disk ​

Status: open. Found: 2026-09-30, building coding agents.

Where: the workspace check in GenerationLoop and workspace_size in runtime/cigale-code.

Exposure: a coding agent installs packages, builds, and clones into /home/agent, a folder on the sandbox host's disk that every agent on that host shares.

Control in place: after each generation the orchestrator measures the workspace with du inside the sandbox and stores it on the agent; above the agent's max_workspace_mib (default 10240), and never above the host WORKSPACE_MAX_MIB (default 10240), the next generation fails at start with workspace.full. The Usage tab shows used against the agent's max.

Residual risk: one generation can write past the cap before the check, and a background command can keep writing until the idle stop.

SEC-6 — Coding agents run commands as root in the container ​

Status: open. Found: 2026-09-30, building coding agents.

Where: the shell tool; scripts and the CLI tab run as root too.

Exposure: commands run as root inside the agent's container, so a container escape would start from root.

Control in place: OpenSandbox drops capabilities (SYS_ADMIN, NET_ADMIN, SYS_PTRACE, and others), sets no_new_privileges, and limits processes.

Residual risk: root inside the container.

SEC-5 — Background commands make the SEC-3 race practical ​

Status: open. Found: 2026-09-30, building coding agents.

Where: shell with background, and any process a command leaves running.

Exposure: a background command keeps running between tool calls until the sandbox's idle stop. That is the process SEC-3 needs: it can swap a checked folder for a link while a tool that opens workspace paths in the orchestrator (Drive, Qonto, secret_store) runs.

Control in place: the code tools never open a workspace path in the orchestrator: they run inside the container through cigale-code, where a link resolves inside the container. Background commands end with the idle stop.

Fix to make: SEC-3's fix closes this.

SEC-4 — Package installs run third-party code beside the agent's secrets ​

Status: open. Found: 2026-09-30, building coding agents.

Where: a coding agent's shell: npm install, pip install, mise install, build scripts, and test suites run third-party code.

Exposure: that code runs in the agent's container with the agent's secrets in its environment, and egress is open.

Control in place: secrets reach an agent only by grant, so a coding agent gets the ones its operator granted it.

Residual risk: anything granted can be read and sent. Default-deny egress is not built.

SEC-3 — Check-then-open race on agent workspace paths ​

Status: open. Found: 2026-09-30, while fixing SEC-1.

Where: readAgentFile and writeAgentFile in apps/orchestrator/src/lib/agent-file.ts, so Drive create_file and download_file_content, Qonto upload_invoice, and secret_store.

Exposure: the helpers check the path, then open it. A process left running in the sandbox (a script started in the background) can swap a checked folder for a link between those two steps. On a read, the window is between realpath and the lstat that pins the file's identity; a later swap is caught by comparing the opened file with the checked one. On a write, the window is between walking the folders and opening the file. Winning the race gives the same reach as SEC-1, one call at a time. A coding agent's background commands (SEC-5) are such a process.

Fix to make: resolve the path relative to an open handle on the workspace, with no link allowed at any step: Linux openat2 with RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS through a small native helper, or move file transfer into the sandbox (read and write through the sandbox's own file API, where a link can only resolve inside the container).

SEC-2 — Any organisation member could write organisation-wide secrets ​

Status: fixed 2026-09-30, in the organisation-owned secrets change.

Where: the secret routes in apps/orchestrator/src/http/routes.ts.

Exposure: creating an organisation secret checked only that the caller belonged to the organisation, and updating or deleting one checked only that the caller could see it. Any member could therefore add a variable to every agent of the organisation, overwrite the value every agent used, or delete it.

Fix: org owners and admins write any secret and are the only ones who change who gets one. A member creates a secret only with grants inside guilds they can see, and edits, rotates, or deletes only a secret whose every grant lies in such guilds. A refused write is 403 inside the organisation and 404 outside it. See Secrets and access.

Residual risk: none known.

Status: fixed 2026-09-30. SEC-3 is the race that remains.

Where: Drive create_file (apps/orchestrator/src/drive/upload.ts), Drive download_file_content (src/drive/content.ts), and Qonto upload_invoice (src/adapters/qonto.ts).

Exposure: an agent's /home/agent is its folder on the orchestrator's machine, mounted into its sandbox. These tools took a file_path, checked only that the path text stayed inside that folder, then opened it on the orchestrator's own filesystem, following links. A link is resolved by the process that opens it, so a link the agent created in its sandbox (for example report.pdf -> /proc/self/environ) pointed the orchestrator at its own files.

  • Drive create_file and Qonto upload_invoice read any file the orchestrator could read and sent it to Drive or Qonto, where the agent could read it back. The orchestrator's environment holds SECRETS_MASTER_KEY, DATABASE_URL, and platform API keys.
  • Drive download_file_content wrote downloaded bytes through a link at the destination, or through a linked folder on the way to it, onto any file the orchestrator could write.
  • A FIFO the agent created made a read wait forever; a device node would have been opened as if it were a file.

An agent needed Drive or Qonto enabled and one script that runs ln -s, which it can run as root in its sandbox. A prompt injection, such as a web page the agent browses, is enough to drive it.

Fix: every orchestrator read and write of an agent-named path goes through readAgentFile or writeAgentFile (apps/orchestrator/src/lib/agent-file.ts); secret_store uses the same reader. A read follows every link, requires the result to stay in the workspace and be a regular file, then opens it without following a link and without blocking, and requires the opened file to be the one checked. A write refuses any link on the way: each existing folder must be a real folder in the workspace, missing folders are created one at a time, and the file is created or replaced without following a link at its name. Unit tests plant links, a linked folder, a FIFO, and a folder at the file name for each helper and each tool.

Unaffected code, checked: deleting an agent or guild workspace and the workspace sweep remove links without following them; prepareWorkspace creates only two folders; scripts reach /home/agent/scripts/ through the sandbox's own file API; deploy/backup.sh archives links as links.

Free software under the GNU Affero General Public License, version 3 only.