Skip to content
AgentDock

Security

Security model

Auth, the runner command allowlist, secrets at rest and known gaps.

AgentDock can start coding agents that run with broad permissions on machines holding your source code, GitHub credentials and model subscriptions. Those machines are the assets worth protecting. This page describes how AgentDock guards them, layer by layer, and where the gaps are.

Threat model

The threats the design is built against:

  • a stolen user session or runner token turned into code execution on a runner;
  • a malicious skill from a public registry;
  • a forged inbound webhook used to start runs;
  • a teammate spending the owner's subscription.

Deployment model

AgentDock is fully self-hosted. You run the control plane (web app, API, PostgreSQL and Redis) and one runner per agent machine. There is no AgentDock cloud service and no account with the project's author.

The control plane never touches a project's files. Everything that happens on a machine goes through that machine's runner, and the runner only accepts a fixed list of typed commands.

User authentication

  • Local accounts only. Email and password, hashed with argon2id. There is no SSO or OAuth login.
  • First admin from the command line. bun run admin:create (or the same CLI inside the API container). The database seed creates no users.
  • Registration is closed by default. An admin can open it. New accounts stay pending until an admin approves them and assigns a role in one action.
  • Sessions are server-side. The browser holds an ad_session cookie (httpOnly, SameSite=Lax, Secure when APP_ENV=production). The server stores only a SHA-256 hash of the token. A session expires after 7 days idle or 30 days in total, and every session is revoked on a password change. Account lists signed-in devices and can revoke them.
  • CSRF. State-changing requests must echo the ad_csrf cookie's value in the x-csrf-token header.
  • Lockout. After 10 failed logins the account is locked for 15 minutes. Login requests are also rate-limited (10 per 60 seconds), keyed on the client IP that TRUST_PROXY lets the API see.

Authorization

Every API request and every runner command is checked against global role × project membership.

RoleCan
adminEverything, on every project: runners, users, integrations, prices, budgets, terminal attach
operatorSteer the projects they belong to: start and stop the orchestrator, stop slots, message workers, run skills, approve merges
viewerSee the projects they belong to: fleet, queue, history, cost

A per-project override can only lower a role: the effective role is the lower of the global role and the override. A project you cannot see answers 404, so its existence is not revealed. Choosing the bypassPermissions mode for an orchestrator is admin-only.

Runners

The runner is the component that can execute things, so it carries most of the hardening.

  • It dials out. The runner opens a WebSocket to wss://<server>/runner. Agent machines need no open inbound port, which also means a runner can sit behind NAT or a firewall you do not control.
  • Pairing. An admin creates a one-time code in Admin → Runners → Add runner: 8 characters from an alphabet without look-alikes, shown as XXXX-XXXX, valid for 10 minutes, usable once. Pairing exchanges it for a 256-bit token.
  • Token storage. The server stores only an argon2id hash of the token and an 8-character prefix. The runner keeps the token in ~/.config/agentdock/runner.json with mode 0600 and redacts it from its logs. An admin can revoke a runner at any time; the runner then exits for good.
  • Typed command allowlist. The runner executes only named commands such as orchestrator.start, slot.stop, issue.create or skill.run. Each has a schema, a minimum role and a timeout. There is no "run this shell string" command, and arguments never pass through a shell.
  • Path confinement. Commands may touch only registered project roots and their .wt-<repo>-* worktree siblings.
  • Local veto. Whoever runs the machine can list commands in disabledCommands in runner.json, and the runner refuses them no matter what the server sends.
  • Loopback telemetry. The runner's OpenTelemetry receiver listens on 127.0.0.1:4318 and refuses non-loopback connections.

Agent credentials (Claude, Codex, gh) never leave the machine. Runtime profiles reference config directories by path only.

Terminal attach

Admins can attach an interactive terminal to a slot, the orchestrator or a skill run. It opens read-only, and taking control shows a banner that keystrokes go to a live agent session. Every attach is audited with its duration and byte counts. The bytes themselves are never stored. Idle sessions close after 15 minutes and any session after 4 hours (TERMINAL_IDLE_TIMEOUT_SEC, TERMINAL_MAX_DURATION_SEC).

Secrets at rest

The secrets AgentDock has to keep are encrypted with AES-256-GCM under APP_ENCRYPTION_KEY:

  • the Telegram bot token;
  • inbound trigger secrets and outbound webhook secrets;
  • the GitHub App's private key, webhook secret and client secret.

They are never returned by the API, never logged and never written to audit records. Without the key, the features that need a secret refuse to store one (409 encryption_key_missing).

Audit log

Admin actions, runner commands, approvals, attaches and integration changes are written to an append-only audit log:

  • Each record includes a hash of the previous one: hash = sha256(prevHash ‖ canonical(row)). Editing or removing a row breaks the chain.
  • A database trigger rejects UPDATE, DELETE and TRUNCATE on the table.
  • A nightly job (AUDIT_VERIFY_CRON, default 0 3 * * *) verifies the chain; Admin → Audit shows the result.
  • Before and after values are stored with secrets redacted before hashing.

Audit records are never deleted. For defence in depth, give the API's database role no UPDATE or DELETE permission on audit_records in production.

Inbound requests

Only two kinds of request reach AgentDock from outside without a user session, and both are signed:

  • GitHub App deliveries to POST /hooks/github: X-Hub-Signature-256 checked in constant time over the raw body before parsing, delivery ids deduplicated for 14 days, bodies up to 5 MB. A delivery can only trigger read-only, debounced polls. See GitHub App.
  • Inbound triggers to POST /hooks/<publicId>: HMAC-SHA256 over timestamp, delivery id and raw body, a ±5-minute window, replayed ids refused, 30 accepted deliveries per hour, and payload values reaching a skill only through allowlisted placeholders, never a shell. See Webhooks.

Outbound requests

Outbound webhooks are the one place where an admin chooses a URL the server will call, so they go through an SSRF guard. Only https:// is allowed unless a host is allowlisted. The host is resolved before every attempt and every address is checked against loopback, link-local (including the 169.254.169.254 metadata address), private, CGNAT, ULA, multicast, reserved and mapped ranges. The connection goes to the checked address, and redirects are never followed. To reach a service on your own network, an admin allowlists its host or CIDR.

The payload is a fixed envelope built from an allowlist of fields. LLM requests, tool calls and pane text cannot be sent.

Skills

Skills from the public catalogue are fetched by the runner, only from https://github.com/<owner>/<repo>.git. Their content is hashed at inspection and verified again at install, so a skill that changed in between is refused. Install shows the files it adds, and a project install arrives as a pull request you review. Install and run are operator or higher and audited.

Known gaps

These are open, and you should weigh them before running agents unattended:

  • Agents can run with permission prompts off. Workers run in bypassPermissions mode by design, and an admin can start the orchestrator that way too. The file fence and the brief are what keep a worker in its lane.
  • Skill runs are not fenced. The file fence that confines orchestrator workers is not applied to headless skill runs. A run's working directory is its own worktree, and the pr output mode shows every change as a diff, but nothing mechanically stops a write outside it.
  • Codex workers cannot be fenced like Claude workers. Codex in orchestrator slots is not built yet (milestone M4); the planned protection is a sandbox plus an after-the-fact ownership check before merge.
  • Pane secret masking is best-effort. Before sending live pane frames, the runner masks common token shapes (GitHub, sk-…, Slack, AWS access keys, PEM private keys). Anything else that appears on screen is shown to viewers of that project. Frames are never stored.
  • The plugin's init scaffold writes a local database password inline into its compose file on the released plugin version. A fix exists on the plugin's develop branch.

Reporting a vulnerability

Report security issues privately through a GitHub security advisory on the repository: https://github.com/AndreyBegma/AgentDock/security/advisories/new. Include the version or commit, what an attacker can do, and steps to reproduce.

For hardening suggestions or problems that are not sensitive, open an issue on the tracker: https://github.com/AndreyBegma/AgentDock/issues.

Next steps