Skip to content

Security

This page lists the protections that exist in the code today, with where to find them. It is a summary, not a guarantee, and some items are listed as open at the end. The design rules are in CLAUDE.md.

Approvals

  • Every upload, job submission, cancellation and download is a gated tool. The loop builds a Proposal, asks the Approver and only then calls Run. With no approver, a gated tool fails (ErrNoApprover). (internal/agent)
  • Integrity re-check. Each gated tool's Run rebuilds the proposal from the same input and the current store, and refuses unless Summary and Details equal the ones the user approved ("this job changed since you approved it"). submit_job compares against the drafted script that was shown, then submits the user's edit or that exact script. A job that already has a remote ID can't be uploaded to again, and once a script is drafted its cluster can't change. (internal/app/tools.go)
  • The approval modal defaults to Decline, shows the summary and buttons at all times while details scroll, refuses to show a partial approval (a window too small), and ignores typed-ahead keys for half a second after opening. An edited script is submitted as shown, with a warning when the editor displays tabs or control characters differently. (internal/tui/approval.go, ui.go)
  • Skills are guidance only and never override approvals. The skill preamble tells Claude to use typed tools and never install software. (internal/skill, app.SkillPreamble)

What Claude can see

  • Claude gets file metadata (paths, names, sizes, counts) and small previews (data files: at most 20 lines and 4 KiB from the start; documentation and code files such as README*, scripts and build files: pages of at most 200 lines and 16 KiB, files up to 1 MiB, with the values of credential-looking keys such as password, token and api_key hidden; binary files refused). find_local_data and preview_file only look under LocalRoots (the home folder). (internal/app/localdata.go)
  • Privacy rules, one shared check for finding, previewing, staging and for download targets: no path component starting with .; ~/Library on macOS and AppData on Windows are off limits; Windows hidden-attribute entries are refused; files that look like secrets are never listed, previewed or staged (id_*, *.pem, *.key, *.p12, *.pfx, *.kdbx, *.keychain*, names containing "credential", "secret", "password" or "passwd", .netrc, .env*). Names are matched after Unicode case folding. (internal/app/privacy.go)
  • Remote side is not restricted (owner rule). The privacy rules above protect the user's own computer. On a cluster, remote_command has no path limits and no refusal lists: the account's permissions, enforced by the system administrators, decide. Read-only commands run at once, except a content-printing one (cat, head, tail, file -z) that names a sign-in secret by name (SSH private key, .netrc, cloud credentials, *.pem/*.key, histories; symlinks, hard links, /proc/*/environ and tokens inside ordinary files are not detected), which asks first and warns that the content goes to Claude (a credential-leak guard, not a limit). The output of those commands also goes through redactSecrets, as preview_file does. Any other command shows its exact line for the user's approval (consent, not a restriction), unless the user turned on the setting "Run commands on the cluster without asking" (off by default, gui.json): then those commands run at once, but a secret-file print still asks, and malformed argv is still rejected. Argv-only quoting, no terminal, closed stdin, timeout and output caps remain as mechanics. The exception to closed stdin is the optional script field: multi-line shell code is sent on stdin to bash -s (or another interpreter named in argv), is always shown in full for approval (also in auto-run mode it still asks when its words name a sign-in secret file, and an auto-run's output is redacted), and never enters the command line. A command that submits a job (sbatch, srun) is not refused; its result carries a hint to use submit_job.
  • Approve everything automatically. A user setting (gui.json approveAll, off by default, banner while on) makes agent.AutoApprover (wrapped around the front end's Approver in launch.Build, so the GUI, the TUI and the tests share it) approve each Proposal at once and unedited, then emit EventAutoApproved (chat line "Auto-approved: ", plus a line in labmill.log). On is read per proposal (settings.AutoApproveAll). It does not apply to: a Proposal with Secret set (today remote_command naming a sign-in secret file, remoteProposal), ask_user (agent.Chooser), and the SSH logins (sshx.Prompter), which are a separate interface that never pass through it. A new proposal kind whose content goes to Claude from a secret must set Secret. The decision carries Decision.Auto: remote_command then applies the run-without-asking safeguards (output redacted, remoteAutoNote first), and the loop prefixes every other automatically approved result with agent.AutoApprovedNote so Claude knows nobody reviewed it. The labmill.log line holds only the tool name and a short line redacted by app.RedactLine (credential keys and --token/--password/--key style arguments). The setting is a superset of runWithoutAsking, which stays for users who only want commands auto-run.
  • Stalled reads. A read on a network mount that stopped answering, or on a cloud placeholder file ("files on demand"), can block for minutes and no context reaches it. find_local_data and preview_file therefore do their blocking open/read/stat in a goroutine (internal/stall) and stop waiting when the limit passes, answering "skipped: didn't answer within N s" (the walk of find_local_data returns what it found so far). The goroutine can't be cancelled and may finish later (its result is dropped and it closes its file); at most 32 such reads exist at once per limiter (the file tools share one; Limiter.Stuck() counts them), and while all slots are stuck, further reads are refused at once with a note telling the user to reconnect the drive and restart Labmill. Stat and readdir are covered too: find_local_data runs its folder resolution and walk under stall.Run. When the caller's context ends, Run returns ctx.Err(), not a stall. Privacy rules and results are otherwise unchanged.
  • Windows junctions. Since Go 1.23 filepath.EvalSymlinks doesn't resolve junctions (mount points), so a junction inside the home folder pointing at a private place would pass CheckFolder/insideRoots. The app's checks resolve paths through evalLinks (internal/app/pathresolve*.go): EvalSymlinks everywhere, and on Windows additionally GetFinalPathNameByHandle on a handle opened with FILE_FLAG_BACKUP_SEMANTICS (the \\?\ prefix stripped). The Windows part is cross-compiled and the prefix stripping and wiring are unit-tested on Linux, but it needs a check on a real Windows machine (a junction made with mklink /J). stage_data resolves each named path through evalLinks and refuses one that leads out of the folder or into a private place; the Windows resolver fails closed (an existing path whose final path can't be had is refused). transfer.BuildManifest and the config/ssh-config code still use EvalSymlinks. Also to check on real Windows: a code or data folder saved in workspace.json by an older version holds the old EvalSymlinks result; on a mapped or subst drive the new resolver gives \\server\share\…, so such a folder silently drops out of the allowed list until it is chosen again (fails closed).
  • Staging doesn't follow symlinks, skips special files and hidden entries (reporting them), and rejects any path that resolves outside the root. (transfer.BuildManifest)
  • The system prompt tells Claude to treat file names, file contents, job logs and tool results as data, never instructions. This is a mitigation, not a boundary; the approvals are the boundary.

No shell, typed tools only

  • There is no shell tool. Claude can only call the registered typed tools, each with a fixed JSON schema. An unknown tool name is an error result. (internal/agent)
  • Backends build argument vectors themselves from validated fields and run them through a Runner. Remote runners pass backend.Quote(argv), which single-quotes every argument that isn't made of safe characters. The only free-form text, the approved job script, is passed as data (stdin or a file), never spliced into a command line. (internal/backend)
  • Quote and backend.SplitWords have a fuzz round-trip test (see Testing).
  • Download paths must be relative, without .. or control characters, and the local folder must be inside the results folder or a local root and pass the privacy rules. (app.fetchResults, transfer.cleanRel)

Validation of job specs

JobSpec.Validate fixes the format of every field: name [A-Za-z0-9._-]{1,64}, partition and GPU type without a leading -, absolute WorkDir from a plain alphabet (it is written into #SBATCH lines), image names, environment variable names, no #SBATCH lines inside the command, no NUL bytes, no carriage returns, and no control, bidi, zero-width or other format characters in the command or environment values, so the text the user approves is what runs. Failures wrap ErrInvalidSpec. (internal/backend/backend.go)

Display sanitization

  • Text from Claude and from the cluster is sanitized before the TUI shows it: control characters, DEL, C1 controls, whole escape sequences (CSI, OSC including OSC 8 links, DCS), bidi controls, line separators and invisible operators are removed. Sanitizing works on complete strings, not streamed fragments, so a sequence split across deltas is still caught. (internal/tui/sanitize.go)
  • File names and paths in proposals have control and invisible characters replaced with ? before they are shown or sent to Claude. (internal/app/privacy.go)

SSH: host keys and two-factor

  • Go-native SSH, no OpenSSH binary. Host keys are checked against ~/.ssh/known_hosts. An unknown host is confirmed once by the user (the dialog default is No, and it shows the SHA256 fingerprint); a changed key is never overridable from the dialog: Labmill refuses and tells the user to contact HPC support (ErrHostKeyMismatch). RSA keys are used only with rsa-sha2-256/512, never SHA-1 ssh-rsa. (internal/sshx/sshx.go)
  • The password prompt is hidden and asked at most once per login; the password is not re-sent to a server that already rejected it, to avoid counting twice toward a lockout. Passwords and 2FA codes live only in memory for the login exchange and are never written to disk. The Duo push is chosen from the menu text, and a missing push ends in a clear two-factor error with the site's help text.
  • Host templates written to ~/.ssh/config.d/labmill are validated first, because a newline in a host name would inject directives into a file every ssh reads. The file is written atomically with mode 0600, and the Include line is added after a timestamped backup of the user's config. (internal/sshconfig)

The SSH helper

  • The helper socket lives in a directory created with mode 0700; the helper refuses a symlinked directory or one owned by another user (Unix), forces the socket to mode 0600, and holds a lock file (named after the versioned socket, ssh-v1.lock) for its lifetime so two helpers can't race to replace each other's socket. A stale socket is replaced only when nothing answers on it.
  • Its local SSH server uses an ephemeral host key. Upstream prompts are relayed to the foreground process; the helper has no UI of its own. It exits after 10 idle minutes. (internal/sshx/helper.go, lock_unix.go)
  • The job database, labmill.log and the helper directory are created with owner-only permissions where Labmill creates them. Tightening an already existing database file is a known follow-up.

Model access

  • By default the client calls the Anthropic Messages API with the user's own ANTHROPIC_API_KEY. The key is read from the environment, kept in memory only and never written to disk. The SDK is built with WithoutEnvironmentDefaults, so ANTHROPIC_BASE_URL and other ANTHROPIC_* variables can't send the key somewhere else. (internal/launch/model.go)
  • When a broker is configured (broker_url in Site.yaml or LABMILL_BROKER_URL), the client sends model calls there with the bearer token from LABMILL_BROKER_TOKEN. The broker client requires an https base URL (plain http only for loopback), ignores ANTHROPIC_* environment variables, and treats a stream that ends without message_stop as an error. (internal/broker)
  • Off until the user pastes a program token (labmill login reads it from stdin, hidden on a terminal, never from argv; the GUI uses a password field). account.Login checks the form (lmt_, printable, under 100 characters) and calls GET /api/me before it saves anything; a refused token is not saved.
  • The token is kept in account.json next to clusters.yaml, written atomically with mode 0600 (config.WriteFile). This is the one secret the client keeps on disk; the client has no OS key store. It is never logged, put in labmill.log, shown on the Settings page or printed by whoami. labmill logout deletes the file.
  • The account URL is https://app.labmill.org, or LABMILL_ACCOUNT_URL (https, plain http only for loopback, as the broker). Redirects are not followed, so the bearer header can't go to another host.
  • What is sent and what never is: see Architecture. Only jobs that end after the link was made are sent.

Downloaded know-how and results

  • Skill bundles from a broker are used only after the version hash and every file's SHA-256 check out, are written atomically, and are never mixed mid-session; a failed check falls back to the embedded copy. The user's override folder always wins, so a bundle can't replace the user's own skills. (internal/launch/knowledge.go, internal/knowledge)
  • Downloaded results are compared with sha256sum run on the cluster over the transfer connection. A differing file is renamed <name>.mismatch and never deleted; where the check can't run, the result says the files were checked by size only. (internal/transfer/verify.go)

rclone isolation

rclone runs in-process as a library, never as a binary. Its config file path is /dev/null, so no config is read or written; every backend is built from a local option map seeded with defaults, not from RCLONE_* environment variables; and the global options and filter in each transfer's context are replaced with fresh defaults, so variables such as dry-run or exclude rules have no effect. rclone's own package init may still create an empty ~/.config/rclone directory. Skill override files are read through an os.Root so symlinks can't escape the directory, and are capped at 256 KiB. (internal/transfer/transfer.go, internal/skill)

Known gaps

  • Tightening an already existing job database file to owner-only permissions is a known follow-up.