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.
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:
- Limit the attack surface. Leave out the mounts, sockets and integrations the jobs don't need, scope keys and secrets to the runs that use them, and grant each user the narrowest role.
- Block access. A policy deny, a folder limit or an allow list removes a path; an instance of its own for each trust boundary removes every path between them.
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.
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:
- Read-only is not blind. A viewer sees what a run prints, live and in its recording: a secret a job echoes, and an admin's
@shellin a folder the viewer sees (see Terminals). The file preview serves any file of a folder the viewer sees, an imported env file at a path that doesn't start with a dot included (see the findings). The folder limit is what keeps a viewer out. - Exec-only holds while the code holds.
runon a folder whoseatkins.yml,Taskfile.yml,Makefileorscripts/*the user can change through git is a command of their choosing. A job's allow list narrows that job alone:./<path>, any executable file of the folder, and@skill:<job>, an atkins skill that applies there, run under the folder's allow list (folder/folder_path.go,FileJob;folder/skill.go,SkillJob). Name in the folder's allow list only those who may run every one of them. - Least access costs reach. Without admin and
prompt/edita user can't look around in a terminal or ask Claude; withoutrunon a folder they can't start its jobs; without the Claude Code integration, docker or Beszel, no run builds images, asks Claude or reaches the inventory. Each decision names what its less secure setting enables, which the more secure one forgoes.
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. |
- Admins are people you trust with the host; each admin signs in with their own account (D2).
-
prompt/edit,hosts,project/editandworkspace/editgo only to people trusted with the host (D10, D8, D19). - Runners run folders whose code they cannot change, or you trust them with the host; a folder's allow list names only those who may run every job, file and skill job of it (Least access).
- Viewers that should only read have denies on the edit and delete rules of notes, workflows and schedules, and folder limits (D14).
- The compose file mounts the docker socket only where jobs build or deploy; otherwise a dind sidecar, a socket proxy, or no docker (D1).
- The container runs on a bridge network behind the TLS proxy (D3).
- The compose file mounts registry logins and Claude Code's settings only where jobs need them (D4).
- Parent environment is None or Auto;
SECRETSMANAGER_KEYand the like live in scoped secrets that scripts read withtask-ui get-secret(D5). -
/keys/known_hostsholds the git remotes and inventory hosts before the first run (D6). - Each SSH key links to the roles and users that need it; the git key exists; no production key goes to a role it doesn't need (D7).
- Beszel, when on, signs in as a read-only user, and
hostsstays with admins (D8). - The Claude Code integration's Who names the agent user and the roles that use Claude (D9).
- Each MCP agent user is an account of its own, with only what the agent needs, and its token rotates within 30 days (D11, D18).
- No private key leaves but inside an admin's export bundle (D12, D19);
inputstays with admins (D13). - Data retention fits what the jobs print and how far back the history needs to reach; jobs do not echo secrets (D15).
- Each variable's scope is
allor the workspace that uses it (D16). - An env entry, whose value the Secrets page shows, holds nothing that needs keeping out of the pages; that belongs in a secret (D17).
- Backups of the database live outside the data folder,
0700; a copy of the database and/keystogether opens every secret. No backup off the host holds both. - Workspace exports use a long passphrase; delete the file after the import (D19).
-
TASK_UI_AUTOLOGINis unset (D22). -
/loginhas a rate limit at the proxy (see the findings).
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:
- The host. The host's root reads the data and key folders, the database and the recordings.
- The container. task-ui and every run share one container and one OS user, root: the image sets no
USER(malta/dockerfiles/task/Dockerfile). - Admins. An admin passes every permission check (
permission/set.go,decide), sees every folder and runs every job.
What the image grants by design (malta/dockerfiles/task/compose.yml):
/var/run/docker.sock, the host's docker daemon, for jobs to build and push images and start containers;/keys, every private key of the instance, encryption key included;/root/.docker, the registry logins jobs push with;/root/.claude, Claude Code's settings and sessions for every user;SECRETSMANAGER_KEYin the server's environment, which every run gets while Parent environment is All (runner/environment.go).
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.
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.
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):
- an admin may do everything, with any policy;
- the user's own entry, when it has one, decides;
- 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).
| 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.
A window on a run shows output to everyone who may see the run. Whether it types depends on the job:
- an interactive job,
@shell,@claudeor a job marked interactive, takes keystrokes from every window whose user may run that job (api/attach.go,ReadOnly: !canRun): any admin for@shelland@claude, any runner the allow lists name for a job marked interactive; - a job that takes no input types only after Take input, which takes the
inputpermission, which only admins hold by default (api/attach.go,takesInput).
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):
- encrypt: the instance's encryption key, one per instance, which encrypts every secret. task-ui makes it at the first start when the instance has none, as
task_encryption_ed25519. A scope holds one encryption key (keystore/local.go,free). - ssh: keys that authenticate runs, git and the inventory to hosts and remotes. A workspace holds any number for its own runs.
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:
- the encryption key decrypts with the new key and then the backup, and the rotation re-encrypts every secret for the new key;
- an SSH key goes into an agent with its backup after it. A host that knows only the old public key still lets the run in.
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.
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:
- no encryption key goes into a run's agent, git's ssh command, a link or the inventory's logins (
keystore/local_runs.go,AgentKeysrefuses one withErrNotSSH); - no SSH key is an age recipient or opens a secret (
secret/vault.go); - no route hands out a private key of either kind (see Private keys never leave), and a workspace export leaves encryption keys out (
keystore/bundle.go).
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.
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):
- the instance SSH keys linked to the run's role or to its user;
- its workspace's SSH keys linked the same way, or linked to nobody, which goes to every run of the workspace.
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.
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.
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.
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).
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.
A variable's scope decides which runs get it (secret/store.go, Reaching):
allreaches every folder of every workspace, including a workspace added later;workspace:<name>reaches every folder of that workspace, and no folder of another workspace;- a folder's own variable, an older scope, reaches that folder's runs, and other runs in the workspace read the workspace's variable of the same name; a run in a workspace without a variable of that name reads the one of every workspace.
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.
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'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).
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).
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.
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:
@shell, a login shell, and@claude, Claude Code in a terminal, run for admins alone: their allow list names the admin role, and the task-ui checks theshellpermission besides (folder/shell.go,api/target.go,canRun);@claudealso takes a Claude token from the Claude Code integration.- The shell runs with the opener's SSH keys in its agent, and with
TASK_UI_USER,TASK_UI_ROLEandTASK_UI_PERMISSIONSin its environment (runner/command.go). - Every run records who started it; schedules run as their user.
What a terminal shows, everyone who sees the run sees:
- a run shows to every user who sees its folder (
model/user.go,SeesRun), viewers included: a viewer watches an admin's@shellin that folder as it happens; - an interactive run takes keystrokes from every window whose user may run the job (see Interactive input);
- the recording is an asciicast of the terminal's output (
recording/recorder.go): what the screen showed, keystrokes included as the terminal echoes them, a password typed at a prompt that does not echo left out. Data retention keeps recordings and runs 31 days by default (retention/settings.go); a run's row stays after its recording goes, until run history lets it go.
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.
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).
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.
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 user. A prompt runs as the asking user, with that user's folders, keys and secrets. Claude's system prompt names the user, the role and the permissions (
agent/claude.go,context), and its MCP connection is the run token, with that user's permissions. - The token. The run token lives for the run, signs in as one user, and cannot start another prompt.
- The container. Claude runs as root in the task container, with
docker.sock. Inside the container only the user's own keys and secrets bound it, and those only by convention (see Stance).
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.
| 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. |