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 theApproverand only then callsRun. With no approver, a gated tool fails (ErrNoApprover). (internal/agent) - Integrity re-check. Each gated tool's
Runrebuilds 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_jobcompares 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_dataandpreview_fileonly look underLocalRoots(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
.;~/Libraryon macOS andAppDataon 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_commandhas 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/*/environand 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 throughredactSecrets, aspreview_filedoes. 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,timeoutand output caps remain as mechanics. The exception to closed stdin is the optionalscriptfield: multi-line shell code is sent on stdin tobash -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 usesubmit_job. - Approve everything automatically. A user setting (
gui.jsonapproveAll, off by default, banner while on) makesagent.AutoApprover(wrapped around the front end'sApproverinlaunch.Build, so the GUI, the TUI and the tests share it) approve eachProposalat once and unedited, then emitEventAutoApproved(chat line "Auto-approved:", plus a line in labmill.log).Onis read per proposal (settings.AutoApproveAll). It does not apply to: aProposalwithSecretset (todayremote_commandnaming 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 setSecret. The decision carriesDecision.Auto:remote_commandthen applies the run-without-asking safeguards (output redacted,remoteAutoNotefirst), and the loop prefixes every other automatically approved result withagent.AutoApprovedNoteso Claude knows nobody reviewed it. Thelabmill.logline holds only the tool name and a short line redacted byapp.RedactLine(credential keys and--token/--password/--keystyle arguments). The setting is a superset ofrunWithoutAsking, 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_dataandpreview_filetherefore 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_datareturns 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_dataruns its folder resolution and walk understall.Run. When the caller's context ends,Runreturnsctx.Err(), not a stall. Privacy rules and results are otherwise unchanged. - Windows junctions. Since Go 1.23
filepath.EvalSymlinksdoesn't resolve junctions (mount points), so a junction inside the home folder pointing at a private place would passCheckFolder/insideRoots. The app's checks resolve paths throughevalLinks(internal/app/pathresolve*.go):EvalSymlinkseverywhere, and on Windows additionallyGetFinalPathNameByHandleon a handle opened withFILE_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 withmklink /J).stage_dataresolves each named path throughevalLinksand 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.BuildManifestand the config/ssh-config code still useEvalSymlinks. Also to check on real Windows: a code or data folder saved inworkspace.jsonby an older version holds the oldEvalSymlinksresult; 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 passbackend.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) Quoteandbackend.SplitWordshave 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 withrsa-sha2-256/512, never SHA-1ssh-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/labmillare validated first, because a newline in a host name would inject directives into a file everysshreads. The file is written atomically with mode 0600, and theIncludeline 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.logand 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 withWithoutEnvironmentDefaults, soANTHROPIC_BASE_URLand otherANTHROPIC_*variables can't send the key somewhere else. (internal/launch/model.go) - When a broker is configured (
broker_urlinSite.yamlorLABMILL_BROKER_URL), the client sends model calls there with the bearer token fromLABMILL_BROKER_TOKEN. The broker client requires anhttpsbase URL (plainhttponly for loopback), ignoresANTHROPIC_*environment variables, and treats a stream that ends withoutmessage_stopas an error. (internal/broker)
Labmill account link¶
- Off until the user pastes a program token (
labmill loginreads it from stdin, hidden on a terminal, never from argv; the GUI uses a password field).account.Loginchecks the form (lmt_, printable, under 100 characters) and callsGET /api/mebefore it saves anything; a refused token is not saved. - The token is kept in
account.jsonnext toclusters.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 inlabmill.log, shown on the Settings page or printed bywhoami.labmill logoutdeletes the file. - The account URL is
https://app.labmill.org, orLABMILL_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
sha256sumrun on the cluster over the transfer connection. A differing file is renamed<name>.mismatchand 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.