levelrail-cli commands against your control plane. It is not a shell: a typed line is split into words, checked against a reviewed command allowlist and flag deny rules, and started as the CLI binary with no shell in between. Pipes, redirects, chaining, substitutions and environment assignments are refused.
It is a browser feature only. There is no CLI command or MCP tool that opens a console; from a terminal you run levelrail-cli directly.
Turn it on
The console is off by default. Turn it on in Settings, Beta features, or from the CLI:APP_EXPERIMENTAL=cli-console on the control plane turns it on too, and then Settings cannot turn it off. APP_CLI_CONSOLE=off (also false, 0, no, disabled) is a kill switch that wins over both. While the feature is off, the console’s two routes answer 409 with "code": "experimental_feature_disabled", and the dashboard shows a locked Console entry in the sidebar and a “turn it on” prompt in the dock. See Experimental features.
Where it shows up
- Console in the global navigation (
/terminal). - A Console tab in the bottom dock, so you can keep it open next to another page.
- A quick actions toolbar of ready-made lines: status, version, apps, deploys, nodes and databases lists, per-app status, logs and deploys, and node details (read only); app restart and redeploy (change); app rollback and system restart (destructive). Change and destructive actions ask you to confirm before the line is sent.
- Command completion from
GET /api/v1/system/cli-console/commands, and arrow-key history for the open session. Typingclearclears the screen.
Who may use it
Only users with theroot ability, on both routes. On top of that, opening the console needs:
- A dashboard sign-in. A session link or an API token is refused (
step_up_session_only). - A same-origin request. The WebSocket must carry an
Originthat matches the dashboard host, otherwisecross_origin. - A fresh step-up. You confirm your password (or passkey) for the action
manageon the pseudo appcli-console. The grant is bound to your session and lasts 5 minutes by default (APP_SECRET_STEP_UP_TTL, at most 15 minutes). Without it the answer isstep_up_required.
root ability while a session is open, the server ends the session.
What it can run
Only the command paths in the reviewed allowlist,internal/cliconsole/allowlist.go (236 of the CLI’s 1488 leaf commands). Every entry has a one-line reason and was reviewed as API-only: it takes no host path, file, directory, URL to fetch or program to launch, and does not print secret values by default. Most entries are reads (list, get, show, status, logs, metrics, history, wait); the few writes are apps restart, apps rollback, apps projects restart, compose redeploy and models restart. Everything else, including deploy, dev, functions dev, databases connect, images lint, bench, plugins and storage ... upload, is refused. A new CLI command is refused until someone adds it to the table.
The session runs with a token minted for you, valid for the session only and revoked when it ends. It never carries root, read:sensitive, write:sensitive, secrets:reveal or signin:approve: a root caller gets read, write and deploy. Endpoints that need root or a sensitive ability answer 403 inside the console. The CLI is pointed at the control plane’s own loopback address.
Refused flags, on every command, because they read or write local paths, stdin or local environment, launch programs, or redirect the CLI’s own credentials and target: any flag whose name ends in file, files, -path, -dir, -dirs, -stdin, -from-env, -cert, -socket, -bin or -binary, plus a fixed list that includes --file, -f, --out, -o, --output, --input, --include, --exclude, --manifest, --signature, --config, --redirects, --bin, --image, --launch, --csv, --ratchet, --record, --fix, --static, --path, --dir, --token, --api-url, --profile, --interactive and --debug (the full list is deniedFlags in internal/cliconsole/policy.go). Any argument containing file:, a bare --, a bare - (stdin), an argument or flag value starting with @, and --flag=- are refused too.
Drift tests
TestCheckDeniesByDefault(internal/cliconsole) walks the whole CLI command tree and fails if any path outside the allowlist passes the check, or if an allowlist entry has no reason or is no longer a CLI command.TestConsoleAllowlistFlagsReviewed(cmd/levelrail-cli) runs-hfor every allowlisted command and fails when one accepts a flag that looks like a path, URL or launch flag (a deliberately broader pattern than the deny rules) and the console does not refuse it. A flag that only filters an API response is listed with a reason inreviewedSafeFlags. It also fails if fewer than half of the allowlisted commands print their flags, so it cannot pass blind.TestConsoleScopeStripMatchesCLI(cmd/levelrail-cli) checks, for every allowlisted command, that the console removes--teamand--projectexactly as the CLI’s own dispatch does, so the check vets the command the CLI will run.
`, $( and ${ are refused; a subcommand must come right after its command, before flags.
A refusal never runs anything. It shows the reason and is audited.
Host diagnostics
A small read-onlyhost namespace (host df, host free, host uptime, host load, host os, host service, host help) is answered inside the control plane process without starting any program. See CLI reference. APP_CLI_CONSOLE_HOST=off turns only that namespace off.
Limits
One command runs at a time per session; a second
run while one is active is refused until you cancel it. A command past the timeout or the output cap is killed together with everything it started, and the stream says why. A value that does not parse keeps the default.
Security notes
- No shell. The CLI binary is started directly with the checked words. Its location is
APP_CLI_BINARY(an absolute path) or the file next to the control plane, neverPATH, and it must be an executable regular file that is not group or world writable. - Clean environment. The child inherits nothing from the control plane. It gets only the loopback API address, the session token, a throwaway home directory that is also its working directory (mode 0700), and the enabled beta feature list. Stdin is empty, no file descriptors beyond stdout and stderr are passed, and the child is in its own session and process group. The home directory is removed after each command.
- What is guaranteed, per deployment mode.
- Control plane runs as root: the child is dropped to the unprivileged account above with no supplementary groups, so it cannot read the data directory or the Docker socket by file permissions. The console refuses to run if that account is missing or is uid 0.
- Control plane runs as a non-root user (the official image runs
USER nonroot): a non-root process cannot switch users, so the child runs as the same uid and could read whatever the control plane can, data directory included. Here the guarantee rests on the allowlist and flag rules: only API-only commands run, none takes a host path, and the drift tests above keep it that way. It is not an operating system boundary.
- Output is sanitized. Everything streamed to the browser passes through one filter, line by line: terminal escape sequences (CSI, OSC, other ESC sequences) and control bytes are stripped, the session token,
KEY=VALUEand JSON pairs with a secret-looking key, URL credentials, bearer tokens and private key blocks are masked, and each line is cut at 4096 bytes (APP_CLI_CONSOLE_MAX_LINE_BYTES). The same filter runs on the audit row. - Least privilege. Each command is in its own process group, killed as a group on cancel or timeout.
- Audit. Every typed line writes an audit row, actor, address and client kind included:
cli_console.runwith the exit code, duration and a note (timed out, cancelled, output truncated), orcli_console.rejectedwith the reason. The line is stored with secret-looking values masked: flag values whose name looks secret,KEY=VALUEpairs with a secret-looking key, URL credentials, and all values after the first positional word of secret-like commands (secret,env,vault,password,credential,users). Audit lines are cut at 512 bytes. The masking is pattern based: a secret in an unusual shape can get through. - Not on the demo instance. The console routes are blocked in demo mode.
Troubleshooting
The console shows a locked entry or “turn it on”
The beta is off. Turn it on in Settings, Beta features, orlevelrail-cli settings beta-features set cli-console on. If it is on and still refuses, check that APP_CLI_CONSOLE is not set to off on the control plane.
”Confirm your password to open the CLI console”
The step-up grant is missing or has expired (5 minutes by default). Confirm again in the dialog. If you signed in with a link or token, sign in to the dashboard directly.cli_console_unavailable
Either the CLI binary was not found or is unsafe (set APP_CLI_BINARY to an absolute path, or install levelrail-cli next to the control plane, not writable by group or others), or the control plane’s own listen address is not known. The server log has the exact reason.
”running as root needs an unprivileged user”
The control plane runs as root and the account inAPP_CLI_CONSOLE_USER (default nobody) does not exist. Create it or point the variable at another unprivileged account.
Too many sessions
More than 6 opens in a minute answers with a rate-limit response and a retry time. Wait, or raiseAPP_CLI_CONSOLE_OPENS_PER_MINUTE.
A command is refused
Read the reason. Only allowlisted commands run; commands that need local files (up, apply, deploy, env pull) belong on your own machine with a local CLI. Put the subcommand right after the command and before flags.
What is missing before it leaves beta
- No end-to-end test. There are unit tests for the line parser, policy, redaction and runner (
internal/cliconsole), and API tests for the gate order, step-up, rate limit and a session that runs and revokes its token (internal/api/cli_console*_test.go). Nothing intest/e2edrives a real browser session, and the dashboard component has tests only for the quick actions table. - No recorded run on a real host. The root-to-unprivileged user drop, process-group kill and the loopback address behind a proxy have not been exercised on a live server. Non-unix builds use a stub: not verified.
- Output masking is pattern based. A secret printed in a shape the filter does not know is shown.
- Same uid without root. On a non-root control plane the child is not isolated by the operating system (see above). Not verified on a live host: whether any allowlisted command reads a file through a code path its flags do not show.
- Allowlist review is by command and flag name. Each allowlisted command was classified from its name and its
-hflags, not by reading every code path. Root-only endpoints behind allowlisted reads answer403because the session token has noroot; which ones do is not verified. - Parity. The console is dashboard only. There is no API route to run a line without a browser session, which is intended.