cdn.si

rendered markdown titpetric

Security

Least access is an important security management principle, where access of credentials is limited to what they require, and not more. TaskUI may give a wide surface of execution, or may be limited to read-only or exec-only users that can only invoke something in a secured system. The barriers between container and machine can be a little bit blurred. Credential management is simplified and the SRE usability for the tool and its Claude bindings has proven to be pretty good. Integrations with Beszel give access to a deployment cluster, and even without specific automation in place it can sort of be used as a mix of terminal interface and claude prompts. It tells me I should water my plants.

This page says how the configuration of task-ui moves it between wide reach and least access: what least access means here, the decisions an operator makes and what each one opens up, and a checklist. A review follows, a tour of the features that handle identity, keys, encryption and roles: each section says what the code does, names the file that does it, and says where the protection ends. The findings table at the end lists what to fix or watch, by severity.

task-ui runs jobs from source trees as the user who asks, records each run, and hands each run the secrets and SSH keys its user and its workspace allow. task-ui works as a CI/CD system you can type into: a run is a terminal that people and agents watch, attach to and answer. It manages who may do what, which keys a run carries, and how secrets sit at rest. It does not sandbox the jobs it runs.

Managing task-ui security is managing Linux security

task-ui is a server in a container, and every run is a process in that container: bash -l, make, docker, ssh, Claude Code. A configured instance is as secure as the Linux system and the container it runs in: the mounts it gets, the daemons it reaches, the keys in its key folder, the network around it and the people who sign in. Managing its security is managing Linux security, with the same two moves:

Admin is root inside task-ui. An admin passes every permission check, sees every folder, opens @shell and @claude, reads and rotates every key, and edits users and policies. Anyone who holds admin credentials, a password, a session or an MCP token of an admin, can act anywhere in the system and can disrupt task-ui itself: end runs, rotate or delete keys, delete users, projects and workspaces, or stop the container through the docker socket. Guard an admin account as you guard a root login on the host.

What least access means here

Least access grants each credential the permissions its work needs and no more. A credential in task-ui is a user: a password, a session or an MCP token. What it may do is its role, its own policy on top of the role, the folders an admin limits it to, and the allow lists of folders and jobs (see Roles and policies). The default roles are a starting point to narrow: the viewer role starts with edit and delete on notes, workflows and schedules.

Profile Set up as What it does What it costs
Read-only the viewer role; the user’s policy denies note/edit, note/delete, workflow/edit, workflow/delete, schedule/edit and schedule/delete; a folder limit to the folders to watch sees those folders’ jobs, runs, live terminals and recordings; reads their files in the preview, the artifacts and the inventory running jobs, notes and replies, prompts, workflows and schedules, every setting
Exec-only the runner role; a folder limit to a secured folder, one whose code the user cannot push to; the folder’s allow list names the user or the role; denies on the workflow, schedule and artifact rules the user doesn’t need starts the folder’s jobs and ends their runs; types into the interactive jobs they may run; sees the folder’s runs @shell and @claude, Claude prompts, notes to the agent, editing pipelines (no push to the checkout, no workflow edits), secrets and env entries, keys, projects, workspaces and their export, commands on hosts, Take input on jobs that take none
Agent a user of its own with MCP agent user, run on chosen folders and notes; prompt/edit only where Claude may run commands the MCP tools as that user, for 30 days a token the limits of its user; a leaked token is the user until it expires or rotates
Admin the admin role or flag everything no limit: an admin holds the host

What the profiles leave open:

Decisions

Each row is a choice an operator makes. Default says where the image, the compose files or the database start: on in the compose files (docker/docker-compose.yml in this repository and malta/dockerfiles/task/compose.yml in production), built in to the image or the code, default for a setting with a starting value, or opt-in, off until someone turns it on.

# Decision Default The less secure choice enables The more secure alternative, and what it costs
D1 The host’s docker socket mounted at /var/run/docker.sock on To build and push docker images. To run docker images on the host, such as docker compose up of a deployment beside task-ui. The socket is root on the host: docker run -v /:/host reads and writes everything. A :ro mount, as in this repository’s compose file, leaves the whole API open to a client. A docker-in-docker (dind) sidecar, with DOCKER_HOST in task-ui’s environment, which runs inherit: builds, pushes and containers stay in the sidecar’s daemon, which runs privileged. Costs: deploying on the host, and a docker run -v resolves its paths in the sidecar, which needs the data folder at the host’s path. A socket proxy that allows build and push alone: costs running containers. No socket: no docker at all, and no image builds.
D2 One container and one OS user, root, for task-ui and every run built in To run any tool a job needs, beside /keys, the database, the registry logins and Claude Code’s settings. Any run reads every key file, the encryption key included. task-ui has no setting for a run user of its own. Run one instance per trust boundary; the instances share nothing but the host.
D3 The container’s network on: a bridge network in both compose files; production publishes no port and its proxy forwards to it Host networking (network_mode: host) lets runs reach the host’s loopback services and every port the host listens on. Neither compose file uses it. A bridge network, with the port behind a TLS proxy. Costs: services bound to the host’s loopback.
D4 Home folders mounted from the host: /root/.docker, the registry logins, and /root/.claude, Claude Code’s settings and sessions on in production To push docker images with a stored login, and to keep Claude Code’s sign-in and sessions across deploys. Every run of every user reads both. A registry token as a scoped secret and docker login --password-stdin in the job that pushes; no mount for Claude Code’s settings. Costs: a login shared by every job, and Claude’s sessions after a redeploy.
D5 Parent environment on the Instance page: what of the server’s environment runs get, such as SECRETSMANAGER_KEY default None, the base variables alone; All on an instance that had runs before the setting, production included All lets every job open the secretsmanager stores without a secret of its own: every run gets every server variable, and no read log records it. Auto passes all but the names holding KEY, TOKEN, SECRET, PASS, CREDENTIAL or PRIVATE (runner/environment.go). None, with each credential a secret scoped to the folders or workspace that use it, read with task-ui get-secret NAME where a script needs it, with its read log. Costs: moving the values, and a line in each script that used the server’s variable.
D6 Host keys: StrictHostKeyChecking accept-new built in To clone and push over SSH and to reach new hosts with no “continue connecting?” question: the first answer for a host becomes its key. Seed /keys/known_hosts with the git remotes and inventory hosts: ssh checks a key the file holds, and accept-new adds only hosts it lacks. For the jobs’ own ssh, StrictHostKeyChecking yes in the image’s ssh_config; task-ui’s git and inventory probe keep accept-new. Costs: reaching an unseeded host.
D7 Which runs get an SSH key (Who gets it) and the git key opt-in per key; a workspace key linked to nobody goes to every run of the workspace To clone and push over SSH, and to deploy from any run of a workspace. A job pushes anywhere the key may. Link each key to the roles and users that need it; scope keys on the git host to the repositories they serve; make the git key a read-only deploy key where task-ui only pulls. Costs: keys in the runs of users not linked.
D8 The Beszel integration opt-in To list the hosts on Inventory, and to reach inventory hosts: @host:<name> and @host:* run a command over ssh with every instance SSH key, for users with hosts. Leave it off: no inventory and no host commands (hostrun/starter.go, beszel.ErrOff). Or keep it on with hosts for admins alone and a token of a read-only Beszel user.
D9 The Claude Code integration and its Who opt-in; with Who empty, every run gets the token To let Claude act in runs: @claude, @claude-prompt and @claude-note sign in with it. Who naming the agent user and the roles that use Claude; or no integration, and no Claude in runs.
D10 Prompts and notes to the agent (prompt/edit, the user flag Run instance Claude when mentioned) the claude user holds prompt/edit; no role does To let Claude act in runs on a prompt or a note. Claude Code runs with --dangerously-skip-permissions, any command as root. prompt/edit for admins and the agent user; the flag on the agent user alone. Costs: prompts for everyone else.
D11 MCP agent user opt-in per user To let an agent outside task-ui run jobs, read runs and artifacts, and post notes, with a 30-day token. An account of its own for each agent, with run on chosen folders. Costs: an account to keep for each agent.
D12 Private keys never leave: no API route, MCP tool or page returns one The ssh agent of a run consumes them, and signing happens inside task-ui; only that agent needs the material. Tight as it stands; the one copy that travels is inside an admin’s workspace export bundle (D19).
D13 Interactive input: the input rule and jobs marked interactive input for admins alone; @shell, @claude and jobs marked interactive take keystrokes To answer a job’s prompt from the run window, and to watch and type into shells together. Keep input with admins; mark interactive only the jobs that need it. Costs: answering a waiting job from the browser.
D14 Who sees a folder’s runs default: every user who sees the folder To watch terminals live, an admin’s @shell included, as a shared remote desktop. Folder limits per user; admins work in folders viewers don’t see. Costs: watching those runs.
D15 Data retention default: every area 31 days, no size cap To replay a run’s terminal, read its summary and its threads, and look back at workflow runs after they end. A shorter age and a size cap on recordings where jobs touch secrets. Costs: older replays and history; a run’s row stays as long as run history keeps it.
D16 Secret scope per secret To reach every folder of a workspace with one secret (workspace:<name>). A folder’s own secret. Costs: one secret for many folders.
D17 Env file path (legacy import) per imported env file A bundle of an older instance carries env files; an import writes one where a tool expects it, such as deploy/app.env. The file preview serves it, and a secret value in it sits there in plain text. A dot path, such as .env, which task-ui seals. Costs: tools that read a fixed path without a dot.
D18 Workflow webhooks: a git host calls POST /api/workflows/{id}/runs with a token opt-in To start a workflow on a push. A leaked hook token starts the workflow. A user of its own with run on the workflow’s folders alone, and the token’s 30-day expiry; or no webhook. Costs: push-started workflows.
D19 Workspace export and import admins alone, with or without workspace/edit To move a workspace with its secrets and SSH keys between instances: the bundle is the one place a private key leaves by, inside its passphrase. A passphrase of 12 characters or more; delete the file after the import. Costs non-admins nothing.
D20 Triage of a failed scheduled run (triage on a schedule) opt-in per schedule To have Claude fix a failing job on its own: it runs as the schedule’s user with that user’s permissions, reads the run and the folder, changes the folder’s files and commits, and posts the result as that user on the board. The instruction forbids it task-ui’s settings, users and secrets, which only the user’s permissions bound. Leave it off, or turn it on for the schedules of a user whose prompt/edit and folders are narrow, with a folder whose key may push nowhere it should not. Costs: a person reading each failure.
D21 The Forgejo integration: its token, and the webhook POST /api/webhooks/forgejo opt-in To mirror a git host: an admin-held token with read:user, read:organization, read:repository and write:repository, the write for the push hooks alone, which sync installs on every repository it syncs and administers; the webhook takes no session, as Forgejo calls it, and is open to the network. The webhook checks the HMAC-SHA256 of the body under the integration’s webhook secret before it reads anything, answers 404 while the integration is off, and only ever pulls a checkout whose clone URL names the repository pushed; pulling is its only write. A token of a user who is in the organisations to sync alone, and read:repository without the write when the hooks go in by hand. Costs: installing the hooks yourself.
D22 TASK_UI_AUTOLOGIN opt-in; unset To skip sign in, for development and screenshots. Unset.
D23 TASK_UI_SESSION_TTL default: 12 hours To stay signed in for a working day. A shorter session. Costs more sign-ins.

Checklist

Stance and threat model

task-ui serves two jobs: development, where people and agents work in checkouts with the tools of a full machine at hand, and the orchestration of restricted production environments, where task-ui holds the keys and credentials and the people who use it hold roles. The first wants reach; the second wants control over who reaches what. The image chooses reach and draws the line at who may run code.

What task-ui trusts:

What the image grants by design (malta/dockerfiles/task/compose.yml):

The consequence: anyone who can make task-ui run a command of their choosing is root on the host, through docker run -v /:/host, and can read every key and every secret of the instance. A user without admin can still run a command of their choosing. Each of these does it, in the container or, for hosts, on the inventory’s hosts:

Permission How it runs a command of the user’s choosing
admin @shell and @claude, a login shell and Claude Code in a terminal: their allow list names the admin role, and no folder or job rule changes it (folder/shell.go, folder/claude.go); task-ui checks shell besides
prompt/edit a prompt starts @claude-prompt, Claude Code with --dangerously-skip-permissions (agent/claude.go)
run with push access to a checkout the job runs whatever the pushed atkins.yml, Taskfile.yml, Makefile or scripts/* says; ./<path> runs any executable file of the folder
run and note/edit a note naming the agent starts @claude-note on the thread
hosts @host:<name> runs a command on an inventory host over ssh, with every instance login key
project/edit adding a project clones a repository of the user’s choosing, whose jobs then list
workspace/edit a workspace import brings SSH keys, secrets and projects

Grant these permissions only to people you would trust with root on the host. Viewers watch and read; runners on folders whose code they cannot change start what others wrote. Where people of different trust share one host, run one instance per trust boundary, and keep production keys on an instance that only its operators reach.

Identity and access

Users, passwords and sessions

Who may sign in is the password file, .htpasswd in the data directory, bcrypt hashes only (access/htpasswd.go, access/password.go). task-ui backup <user> makes ten single-use backup passwords of about 59 bits each, of which the database keeps only the hashes (access/backup_passwords.go).

A sign in issues a session cookie: 256 random bits, HttpOnly, SameSite=Strict, Secure behind TLS or X-Forwarded-Proto: https (access/guard.go, SetCookie). The database keeps its SHA-256 hash, the user and the expiry, TASK_UI_SESSION_TTL, 12 hours by default (access/session.go). A new password or the user’s removal ends their sessions. Requests that change state from another site fail the origin check (access/handler.go, SameOrigin), and the run window’s websocket refuses a foreign Origin (api/attach.go).

TASK_UI_AUTOLOGIN signs every request in as one user with no password. It exists for development and the screenshot scripts; production leaves it unset.

Roles and policies

A user is an admin, a runner or a viewer (model/user.go). What a role may do is a policy: one entry per rule, allow, deny or nothing, which inherits. A user’s own policy sits on top of the role’s (permission/set.go, decide):

  1. an admin may do everything, with any policy;
  2. the user’s own entry, when it has one, decides;
  3. else the role’s entry decides; nothing means no.

A section’s all entry stands for its rules, and a deny on all denies a rule even where an allow on that rule allows it (permission/policy.go, Effect). No section adds a rule of its own: a saved entry for key/read, from before private keys stopped leaving, has no effect, since no check names it.

The rules live in one file, config/permissions.yml: view, edit, delete for each section (workspaces, projects, notes, prompts, workflows, schedules, artifacts, secrets, instance keys, integrations, inventory, users), and four application rules, run, shell, hosts and input. go run ./internal/cmd/rulelint fails the build when a check names a rule the file lacks, or a rule of the file goes unchecked. The matrix on Settings, Permissions is the whole list.

By default (schema/permission_policy.up.sql) a runner may run, and view, edit and delete notes, workflows and schedules, view the artifacts and the inventory, and write the artifact tree; a viewer the same without run and without writing artifacts. Neither holds shell, hosts, input, prompt/edit or any key, secret, project or workspace rule. The claude user holds run, notes and prompts in its own policy.

On top of the rules, an admin may limit a user to some folders, and a folder or a job may carry an allow list of users and roles (permission/set.go, Run). A schedule runs as its maker; only an admin picks another user, who must be able to run the job (api/schedule.go, runAs). A user with user/edit changes policies only when the role grants it: task-ui reads that right from the role alone (api/permission.go).

Tokens

Token Who holds it Lifetime Storage Scope
Session cookie a browser TASK_UI_SESSION_TTL SHA-256 hash the user
MCP token, tui_... a user with MCP agent user 30 days, then a rotation (access/token.go, MCPTokenTTL) SHA-256 hash the user, over /api and /api/mcp
Run token a Claude run that task-ui starts the run memory alone (access/run_tokens.go) the run’s user, its thread

An admin turns on MCP agent user; a user then makes, rotates and turns off their own token, and the token shows once, in the response that makes it (api/user_mcp.go). /api/mcp refuses a user without the flag unless a run token signed the request in (access/handler.go, RequireMCP).

A run token exists for @claude-prompt and @claude-note runs. The board mints it at start, writes it into an MCP configuration file in a 0700 directory of its own, and revokes it and removes the directory when the run ends (note/board_mcp.go). It signs in as the run’s user with only that user’s permissions. It cannot start a prompt: POST /api/prompts answers 409 to it (api/prompt.go). A Claude run does not spawn more Claude runs.

Interactive input

A window on a run shows output to everyone who may see the run. Whether it types depends on the job:

Keys

Instance keys

Every key lives in one folder, /keys in the image (TASK_UI_SSH_DIR), 0700, each file 0600, as <name>.key and <name>.pub (keystore/local.go, write). The name ends in the algorithm and size, such as deploy_rsa4096. The database says what each key is for, its scope (the instance or a workspace) and whose runs get it. The page is Settings, Keys (/settings/keys); the API is /api/keys.

A key has one of two purposes (keystore/local.go, ErrPurpose):

task-ui signs nothing with an instance key: there is no signing purpose. A job may still use a key from its agent to sign, such as a git commit with gpg.format ssh; that is the job’s choice.

Rotate key writes new material of the same algorithm and size under the key’s name and moves the old pair to backup/<name>.key and .pub (keystore/local_rotate.go). The key’s row shows BACKUP ACTIVE: backup/<name> while the backup exists, and both serve:

A key keeps one backup. While it has one, Rotate key answers 409; an admin adds the new public key where it belongs, then deletes the backup, and the key rotates again.

One key, one use

A key either encrypts secrets or authenticates SSH, never both, and the folder holds a key material once, by its fingerprint: an upload of material it already holds, under any name or purpose, answers 409 (keystore/local.go, add). From there:

This limits the blast radius. A leaked deploy key opens the hosts that trust it and no secret; a leaked encryption key opens the database’s secrets and no host.

Who gets an SSH key

An SSH key links to roles and users (Settings, Keys, Who gets it; PUT /api/keys/{name}). A run gets (keystore/local_runs.go, ForRun):

An instance key linked to nobody stays with the instance: the inventory signs in with it, runs don’t. A host command, @host:<name> or @host:*, gets every instance SSH key besides, as the inventory does (runner/keys.go). One SSH key may be the git key, which task-ui’s own clones and pulls use (keystore/local_git.go). A typical layout: the git key and a deploy key linked to the admin role, a key per user for their own hosts, and a workspace build key linked to nobody.

The run’s ssh agent

A run gets its keys in an ssh agent of its own, served from memory on a socket, and SSH_AUTH_SOCK names it (runner/manager.go, execute; sshagent/agent.go). A run with no keys gets an empty SSH_AUTH_SOCK, never the server’s agent. The terminal’s first line names the keys: $ ssh-agent: deploy_rsa4096, task_ssh_ed25519, or (none). The agent closes when the run ends.

The agent decides which keys task-ui loads for a run. It does not decide what a run can reach: a job runs as root beside /keys and reads every key file there. Links are a statement of intent and keep honest jobs to their keys; they are not a wall against a job that goes looking.

Host keys

Every ssh in the image accepts a host’s key on first contact and keeps it in /keys/known_hosts: StrictHostKeyChecking accept-new in /etc/ssh/ssh_config.d/task-ui.conf (Dockerfile), in git’s ssh command (git/git.go) and in the inventory’s probe (sshprobe/prober.go). No run waits on a “continue connecting?” question, and a key that changes later fails the connection. The cost is trust on first use: whoever answers for a host the first time is the host from then on. Seed known_hosts with the keys of the git remotes and the inventory before the first run reaches them.

Private keys never leave

Two readers open a private key: the ssh agent of a run, which gets the keys the run’s links name ($ ssh-agent: ... on the terminal’s first line), and task-ui itself for signing and decrypting. No API route, MCP tool or page returns a private key, encrypted or not, and no log counts reads of one: the key_read table stays in the schema, empty and unused. The one copy that leaves is inside a workspace export, for an admin alone, encrypted to the bundle’s passphrase (Workspace export).

Secrets

Encryption at rest

A variable is a name and a value that runs get as an environment variable. Each is a secret or an env entry: task-ui encrypts both with age to the instance’s encryption key, an ed25519 or RSA key used through agessh, and stores them armored (secret/vault.go; secret/store.go, Set, SetEnv). The public key encrypts; the private key in /keys decrypts. A copy of the database alone opens nothing; a copy of the database and /keys opens everything. Rotating the encryption key re-encrypts every variable (secret/store.go, Rekey). A secret’s value is never shown after a save; Settings, Secrets lists an env entry’s value. Anything that must stay out of the pages belongs in a secret.

Scope

A variable’s scope decides which runs get it (secret/store.go, Reaching):

The scope keeps a variable out of another workspace’s environment. It does not keep it out of reach on disk: the database and the key that opens it sit beside every run.

The read log

Each run records one read per variable it gets: the variable, the run, the folder, the directory, the job and the user (runner/command.go; storage/secret_read_storage.go). Settings, Secrets shows each variable’s Reads and Last read, and /settings/secrets/<id>/reads pages the log, newest first. task-ui keeps the last 1000 reads of each variable.

A run records nothing of its environment

A run’s environment is sensitive. task-ui writes neither a value nor a name of it into the run. The run’s page, its output, its recording and its row hold no secret and no variable name. The only trace of which run got which secret is the read log on the secret’s page, which takes secret/view. The terminal’s first line names the SSH keys in the run’s agent, by name; no key material and no fingerprint.

What a job prints, the recording keeps. A job that echoes a secret puts it in the recording, readable by everyone who sees the run (see Terminals).

Env entries

An env entry is a variable of the same store whose value Settings, Secrets shows (is_secret false, value in the API). It reaches runs exactly as a secret does, and task-ui get-secret reads it too; the read log records it, and a job that prints one puts it in the recording. task-ui writes no dotenv file into a folder before a run: the environment is the only place a run gets these values.

Env files of older instances survive in the database, and a workspace export carries them and an import writes them back into the folders it clones (api/workspace_import.go, writeImportedEnv). A bundle is an admin’s, and the path it writes is relative to the folder, with no parent reference (envfile.SafeRelative); the file preview serves a path that does not start with a dot (see the findings).

Workspace export

A workspace exports as <folder>.tui.age: an age file encrypted to a passphrase of at least 12 characters, through age’s scrypt recipient (workspace/bundle.go). Inside are the manifest, with the workspace, its projects, its env files and its variables in plain text, and the SSH keys its runs get (workspace/manifest.go). No instance key and no encryption key goes: the importing instance encrypts the variables for its own key. The passphrase travels in a POST body, out of URLs and logs (api/workspace_export.go). Export and import are for admins alone, with or without workspace/edit: the bundle carries the workspace’s secrets and SSH private keys, the one way a private key leaves task-ui, and the import writes keys into the key folder. The bundle is as strong as its passphrase: no file task-ui produces needs more care.

Terminals

Every run is a terminal on the server, a PTY, that belongs to no browser window (runner/). The dashboard shows each terminal in progress as a tile with its live screen; a click opens a window on it, and /api/runs/{id}/frame.svg and frame.txt serve the screen as a picture or text. Together this works as a shared remote desktop for terminals: an admin opens @shell in a folder, a colleague attaches and sees the same screen, and an agent’s run shows next to it.

Identity carries into the terminal:

What a terminal shows, everyone who sees the run sees:

A secret printed in a shell, a token pasted on a command line, a cat .env: each lands in the recording and on the screens of everyone attached.

Pages and markdown

Every page carries a content security policy (markdown/markdown.go, CSP; frontend/frontend.go, policy): scripts, styles, fonts and images come from task-ui itself, an image may be inline as a data URL, forms post to task-ui alone, and no other site shows a page in a frame. Inline styles stay allowed, as the templates size bars with style attributes and the terminal writes its own. A note, a reply, a prompt’s answer, a run’s summary and a markdown file of a checkout or of the artifacts render through the same renderer: raw HTML shows as text, links keep to safe schemes, and an image from elsewhere shows as a link to it. No reader’s browser fetches it, and no host outside learns who reads what, and when. A file a run or a checkout wrote, served on its own, carries a stricter policy still: no script, in a sandbox (frontend/artifact.go, serveFile).

Workspaces

A workspace is a directory under the root with a home, the checkout of its home repository or an empty directory, and a src of projects, with its own SSH keys and its own secrets (Workspaces). Every run in a workspace gets the home as HOME, always, the server’s HOME never: atkins finds the workspace’s skills there, bash -l reads its .profile, and Claude Code reads its CLAUDE.md (runner/home.go, HomeEnv). So whoever commits to the workspace’s home repository controls that workspace’s runs. What the server keeps in its own HOME stays in use through four variables pointed back at it, each set only when the server does not set it: the registry logins (DOCKER_CONFIG), Claude Code’s settings (CLAUDE_CONFIG_DIR), the cache (XDG_CACHE_HOME) and GOPATH. atkins’ history, ATKINS_DB, is the home’s, with the instance’s copy as a mirror it writes and never reads. ssh reads ~/.ssh from the password database, and its configuration stays the server’s.

What a workspace keeps apart: which variables enter a run’s environment and which SSH keys enter its agent. What it does not keep apart: the OS user, the container, the key folder, the database, every checkout under the root, the registry logins and Claude Code’s sessions. A job someone in one workspace can change can read another workspace’s secrets. Workspaces organise trusted people; they do not separate untrusted ones.

Agents and MCP

Claude Code runs inside task-ui in three ways: @claude, an interactive terminal; @claude-prompt, a prompt from a page, the API or ask_prompt; and @claude-note, a note to the agent. All three start Claude Code with --dangerously-skip-permissions (agent/shell.go, agent/claude.go): Claude runs commands without asking. What bounds it:

The MCP server at /api/mcp serves its tools by calling the JSON API in process as the token’s user (mcp/server.go), and a tool checks what the page checks. The tools: whoami, list_jobs, list_workflows, run_job, run_workflow, get_workflow, create_workflow, update_workflow, list_runs, get_run, list_workflow_runs, list_notes, note_comment, post_note, ask_prompt, list_artifacts, read_artifact and write_artifact. run_job opens @shell or @claude for admins alone, and cannot start @claude-prompt or @claude-note, which start from their own routes (MCP). A refused call names the permission it needs.

An agent outside task-ui, such as Claude Code on a laptop, holds an MCP token for 30 days. Treat it as the user’s password: a dedicated user with only the permissions the agent needs, run on chosen folders and notes, is the usual setup.

Findings

Severity Finding Where Recommendation
High The task container mounts the host’s docker socket. Any user who can run a command of their choosing (admin, prompt/edit, run on code they can change, project/edit, workspace/edit) is root on the host. malta/dockerfiles/task/compose.yml Grant those permissions only to people trusted with the host. Where builds need docker but users don’t need the host, put a socket proxy that allows build and push alone in front of the daemon, or run one instance per trust boundary.
High Every run is root in the container beside /keys, the database, /root/.docker and /root/.claude. Key links, ssh agents and secret scopes decide what task-ui loads for a run. Any run reads every private key, the encryption key included, and so every secret. malta/dockerfiles/task/Dockerfile (no USER), keystore/docs.go State it to operators (this page, Workspaces). For real separation, run jobs as another uid without read access to /keys and the data folder, or in a container of their own that gets only the run’s agent socket.
Medium A workspace bundle can carry an env file at a path that does not start with a dot, such as deploy/app.env, which an import writes into the folder; the file preview serves it to every user who sees the folder, viewers included. envfile/store.go (SafeRelative), api/file.go (viewFile), frontend/file.go (checkoutFile) Refuse an env file path whose last segment does not start with a dot on save, and task-ui seals the file; have both file viewers refuse a file that holds envfile.Marker.
Medium Under Parent environment All, runs get the server’s whole environment. SECRETSMANAGER_KEY, set in production, reaches every run of every user and workspace, and no read log records it. A fresh instance starts on None; production stays on All until an admin switches. runner/environment.go (ParentEnv), Instance page, compose.yml Move the value into a scoped secret that scripts read with task-ui get-secret, where scopes and the read log apply, then set Parent environment to None, or Auto.
Medium ssh trusts host keys on first use: accept-new everywhere ssh runs. The first answer for a host, right or wrong, becomes its key. Dockerfile (ssh_config.d/task-ui.conf), git/git.go, sshprobe/prober.go Seed /keys/known_hosts for the git remotes and inventory hosts; review it after adding hosts.
Low The Claude Code token reaches every run of the users and roles Who names, and every run when it names nobody. integration/store.go (Env) Check a Who box, or set the token only for @claude, @claude-prompt and @claude-note.
Low An interactive run takes keystrokes from any window whose user may run the job: any admin types into another admin’s @shell, which carries that admin’s SSH agent, and any runner the allow lists name types into a job marked interactive that someone else started. Nobody records who typed. api/attach.go (serveAttach) Let only the starter type into @shell and @claude; log the users who attach with input.
Low A job’s allow list narrows that job alone. ./<path>, any executable file of the folder, and @skill:<job> run under the folder’s allow list. A user the folder names runs them though the folder’s listed jobs name others. folder/folder_path.go (FileJob), folder/skill.go (SkillJob) Name in a folder’s allow list only users who may run every job of it, or have file and skill jobs take the strictest job allow list of the folder.
Low Terminal output reaches every user who sees the folder and stays in the recording, 31 days by default. A recording is 0644 in a 0755 tree on the host. model/user.go (SeesRun), recording/store.go, retention/settings.go Limit folders per user where admins work in shells; shorten retention where jobs touch secrets; write recordings 0600 in 0700 folders.
Low The production data folder holds 32 copies of the database and 58 of its WAL and SHM files, task-ui.db*.pre-*, 0644 in a 0755 folder. They hold secret ciphertext, password and token hashes. /keys still holds the old-layout id_ed25519 and id_rsa_deploy, which may open secrets in the older copies. /mnt/apps/task/data, /mnt/apps/task/keys Move the copies to /mnt/apps/task/backup/<date>-<name>/ under 0700, or delete them; remove the old-layout key files once the migrated keys work.
Low Sign in has no limit on attempts; bcrypt slows each guess, nothing stops many. access/authenticator.go, the login handler Rate limit /login at the proxy or in the handler.
Low Every run’s shell history goes to the server’s ~/.bash_history, across users and workspaces. runner/home.go (homeKept) Point HISTFILE per user, or nowhere for @shell.
Info A run token’s MCP configuration file sits in a 0700 temporary directory, owned by root, which every other run can read while the run lasts. note/board_mcp.go Covered by the High findings; separate uids would close it.
Info TASK_UI_AUTOLOGIN signs every request in as one user. Production leaves it unset (checked). app/config.go Keep it out of production configuration.

Contents · Running the server · API