Security
Terminal Deck runs untrusted things on purpose: agent CLIs that write to your files, and web pages loaded into a browser inside the app window. This page says what is done about that, and what deliberately is not.
Every claim below is a property of the code, checkable in the public source. Where a defence has a known limit, the limit is stated next to it — a security page that lists only its wins is not much use to the person deciding whether to trust it.
Last updated 14 August 2026 · applies to version 0.15.0
The threat model, stated first
This is a local desktop app with no account and no multi-user surface, and it stores nothing of yours on anyone else's machine. Remote access is the one thing that puts a third party anywhere near your work — the app holds a link to a rendezvous relay from launch, so a paired phone has something to attach to — and it is built so that having that link open is not a decision about trusting the relay. What is left is worth being precise about.
- Defended: a hostile web page loaded in the embedded browser tab; a process on your machine poking at the local hook endpoint; a secret leaking out through a support bundle or the debug log; the app being talked into reading or deleting something outside the folder it was pointed at; the relay that carries a remote session, which is treated as hostile and cannot read a byte of what passes through it — every payload is sealed under keys established between the desktop and one paired phone, from material the relay never sees.
- Not defended, by design: the agents themselves. Terminal Deck runs them with your permissions and does not confine, filter or review what they do — see below.
- Out of scope: an attacker who is already running code as your user account. At that point they can read the same config files and trigger the same keychain prompts the app can, and nothing in a userland app changes that.
The application window
The standard Electron hardening, plus the one deviation, named rather than omitted.
Context isolation on, node integration off
The interface runs with contextIsolation: true and
nodeIntegration: false. Interface code has no require, no
process and no filesystem.
A single preload is the only bridge, and it exposes explicit named methods —
ipcRenderer itself is never handed to page code, so there is no channel
to call that the bridge did not deliberately publish.
A strict Content Security Policy (CSP — a rule the app sets that stops its window loading anything from outside itself) in production
The policy is set from the main process as a response header rather than a
<meta> tag, so it cannot be edited out of the markup. In a
production build it is
default-src 'self'; script-src 'self'; connect-src 'self', with fonts
and images limited to 'self' and data:.
Two honest details. style-src permits inline styles, which the
interface needs. And the development build is deliberately looser — Vite's dev
server injects an inline module preamble that a strict script-src
blocks, which renders the window blank — so 'unsafe-inline' and
'unsafe-eval' apply only when you are running from source.
Links leave the app
Any attempt to open a new window from the interface is denied, and the URL is handed to your real browser instead. Nothing navigates the application window away from the app's own interface.
The deviation: the renderer is not sandboxed
The main window runs with sandbox: false. Context isolation still
separates the preload's world from page code, and the preload uses no Node APIs —
but the Chromium sandbox is not applied to the app's own renderer, and that is worth
knowing rather than discovering. The embedded browser's guest views, which load
pages nobody vetted, do run sandboxed.
The embedded browser is treated as hostile
A page you load is untrusted whether it is your own dev server or something you followed a link to. It is given its own process, its own session and no route inward.
Guest pages are loaded into a separate WebContentsView — a distinct
process with its own session, not an iframe sharing the app's renderer. The view is
created with sandbox: true, contextIsolation: true,
nodeIntegration: false, nodeIntegrationInSubFrames: false,
webSecurity: true, allowRunningInsecureContent: false and
webviewTag: false.
- Navigation
-
Allow-listed to
http:andhttps:— not deny-listed.file:is refused at every entry point: the URL bar, link clicks, redirects and subframe navigations. A page that could reachfile:///could read your disk through the very inspection channel the feature exists to provide, so each of those paths is guarded separately. - Permissions
- Every permission request and permission check is refused outright — camera, microphone, location, notifications, clipboard, the lot. There is no prompt, because there is no interface here to prompt with and a silent yes would be worse.
- Downloads
- Blocked. A page cannot put a file on your disk through this browser.
- Pop-ups
-
window.openandtarget="_blank"get no window; the attempt is reported in the tab's error line instead. - The inspect channel
- The script running inside the guest page only reports facts about a clicked element — tag name, id, whether that id is unique, position among siblings. It exposes nothing over a context bridge, so page code has no route to any IPC channel. Every decision — which selector wins, what gets escaped, what is allowed to reach a terminal — is made in the main process against a closed list of attributes, where it is tested against adversarial input. The URL attached to a capture is the one the main process knows, never the page's own claim about itself.
- Isolated tabs
- A tab can be given a partition of its own that exists only in memory and is cleared when the tab closes. The partition name is validated against a strict pattern before use, so a malformed value cannot quietly create an on-disk partition instead. An isolated tab is hardened identically to a shared one — isolation is not a softer mode.
The local hook endpoint
The app's only listening socket. It exists so agent hooks have somewhere to report instead of the app scraping terminal output.
Three properties do the work, in this order.
-
It binds to
127.0.0.1only, on a port the operating system assigns. Nothing off your machine can reach it — that is the boundary that actually matters, and it does not depend on the two below being perfect. - Every request must carry a per-run token, compared in constant time — including the length check, so a wrong length does not fail faster than wrong bytes. The token is generated at launch and never written to disk by the app, so a hook left behind by a previous run cannot post into this one. It is deliberately not exposed to the interface: a secret that reaches page code is a secret one bug away from leaving.
-
The
Hostheader must be a loopback literal. A hostile page in any browser on your machine can be pointed at127.0.0.1by DNS rebinding. It could not guess the token anyway, but refusing a rebound host costs nothing.
Bodies are capped at 1 MB and drained rather than cut off, so an oversized upload
is answered instead of hanging. Header and request deadlines are seconds rather than
Node's minutes. The response is always an empty 204 — anything returned
would be parsed by the CLI as hook output and could steer the agent, and this endpoint
observes rather than steers. The token is forgotten at shutdown.
What the token is not worth. It lives in the agent CLI's own
settings file, which for two of the three providers is mode 0644 on
this platform. Another process running as your user can read it. The token stops
confused software and drive-by requests from a browser; it is not a defence against
a local attacker who is already reading your home directory. The loopback bind is
the boundary that is.
Secrets are redacted before they can leave
A support bundle exists to be pasted into an issue, which makes it the single most likely place for a credential to escape.
Four layers, in order
First, literal values already known to be secret — anything secret-looking read out
of the environment. Then structure: PEM blocks, credentials embedded in URLs,
Authorization and Cookie headers, and assignments whose key
name says the value is a secret. Then known token shapes by issuer prefix. Then an
entropy sweep for long, dense, mixed strings carrying no other signal.
Home directories and usernames are folded away last. They are not secrets, but a bundle that names you is more personal than it needs to be.
It errs towards losing information
A 40-character lowercase hex string is redacted, which means commit SHAs are collateral damage — a legacy GitHub token has exactly that shape and nothing in the string tells them apart. A missing SHA costs one follow-up question; a leaked token costs an account.
The bundle reports how many substitutions it made, so a suspiciously low number is visible rather than silent.
Two things are never recorded at all
IPC call arguments are never captured. Which channel was called and how long it took is useful; the arguments are where your prompts, file contents and tokens are, and a debug panel showing them would be a leak with a table around it.
Environment variables are reported by name only, except a short allowlist
(PATH, SHELL, TERM, LANG) that
carries no secrets. Knowing GITHUB_TOKEN is set is useful; knowing its
value is not.
The log too
The rotating application log is capped and every line is passed through the same redaction before the Debug panel displays it or a bundle carries it. Redaction is careful, not magic — read a bundle before you post it.
Pairing, stated as arithmetic
A pairing code is six digits. That is a smaller number than it used to be, so here is the whole of what makes it sound rather than an assurance that it is.
Six decimal digits is 106 — a million codes. The format it replaced was eight Crockford base32 characters, which was 328, about 1.1 trillion. That is a reduction by a factor of roughly 1,099,511, and no wording makes it smaller. It is acceptable because of what a guess has to survive, and each of the following is pinned by a test that fails if somebody removes it:
- The code lives sixty seconds, and is dead at the boundary.
- It is single-use, and is burned the instant it matches — before the device name is checked and before anything is written to disk.
- Five wrong answers kill the code, not the guesser. The counter is against the one code on screen, so an attacker who dials from a fresh address or mints a fresh key for every attempt does not get a fresh budget. That counter is what carries this format.
Five guesses against 106 is 5 × 10−6 — one in two hundred thousand, per pairing window. The old format was 4.5 × 10−12. The number is written here rather than buried, because one in two hundred thousand is a figure somebody should be allowed to disagree with.
The code has never been the gate. What redeeming one buys is a pending device — a row in a list on the other machine that a human has to approve before it can attach to anything. It is the first of two doors, and it is the half that is only worth as much as the sixty seconds and the five tries.
A typed code cannot carry an address, so it names a rendezvous slot at the relay rather than the machine itself. That lookup would be a free oracle if it were cheap — an attacker who could enumerate slots would sweep the million, find the live one and learn the code exactly — so the slot is derived through memory-hard scrypt. The responder's key pair for that offer is derived from the code as well, so a hostile relay cannot answer in the machine's place.
There is no QR code and no pairing link. Both were removed rather than hidden: the QR did not work, and the link was a two-hundred-character string carrying a live bearer secret that had to travel through a messaging app to be useful — which is a pairing token somebody else's server then holds a copy of. Every route into a credential write that they used is gone, along with the camera permissions the scanners needed.
Nothing is stored that could become a device. Paired devices are recorded as scrypt
hashes and public keys only, so a stolen credential file cannot be replayed into a
pairing. The one new secret on disk is this machine's own static private key, written
0600.
Filesystem and configuration
- Search scope
- Project search only runs against roots you have explicitly added, so a folder that merely looks like a project cannot be enumerated over IPC.
- File reads
- Files over 2 MB are refused rather than loaded, symlinks that resolve outside the project are refused, and directory loops are detected instead of walked.
- Deleting an account
- Two independent guards. The directory must be one the app itself created inside its own storage, and it must not be your home directory, an agent CLI's real configuration directory or the filesystem root — the second check exists precisely for a hand-edited or corrupted config file. An account pointing at a directory you already had is removed from the list, and its files are left alone.
- Editing agent settings
- Hook entries the app writes are tagged, and only tagged entries are ever removed — a machine that also runs another tool's hooks keeps them. Writes are atomic, the file's original permissions are preserved rather than widened, a backup is taken before the first edit, and any file that does not parse as strict JSON is left untouched.
- MCP servers
- An MCP server is an arbitrary program you configured. Each connection is a separate process with a wall-clock timeout on every call, so a server that fails to spawn, spawns and never speaks, or dies mid-listing cannot hang the app.
- Chrome cookie import
- Chrome's profile is opened read-only through a temporary copy and never written to. The keychain prompt macOS shows names this app; denying it is reported as a denial, not as an empty profile. Cookie values never enter a log, an error message or an IPC payload.
What is deliberately not defended
The agents are not sandboxed. A session is the real CLI running as a
subprocess with your account's permissions and your login shell's PATH.
Terminal Deck does not confine it to the project folder, does not review its tool
calls, does not filter its file writes and does not add a confirmation step. Whatever
an agent can do in your terminal, it can do here — including in a session you are not
currently looking at.
That is the honest shape of the product rather than a gap to be closed in a patch. The app's job is to show you what your agents are doing, not to stand between them and your disk. Run them on projects under version control, and grant permissions the way you would at a bare prompt.
Also true today: the macOS build is signed with an Apple Developer ID certificate but not notarised, and the Windows build is not signed at all, so Gatekeeper will object on macOS and SmartScreen will object on Windows, and you will have to open each one explicitly the first time. Nothing is ever pushed to you: on macOS the app can check for a new version and install one you accept, but on Windows that path has never been exercised and a portable executable cannot be replaced in place at all. So a security fix reaches you when you come and get it — from this site or from the repository.
Reporting a vulnerability
Please report privately first, and give it time to be fixed before writing about it publicly.
- Open a private security advisory on the repository. That keeps the report visible only to you and the maintainer until there is a fix.
- If advisories are unavailable to you, open a normal issue that says only "security report, please make contact" — with no details in it — and the conversation can move somewhere private.
What helps, in rough order of usefulness:
- The version, and whether it is a packaged build or run from source.
- What an attacker gains, in one sentence.
- Steps to reproduce, or a small proof of concept.
- Any suggested fix, if one is obvious to you.
This is a project maintained by one person, so no response time is promised and there is no bug bounty. What is promised: a genuine report gets a genuine reply, the fix is described honestly in the release notes rather than folded silently into a "maintenance" line, and credit goes to you unless you would rather it did not.
Please do not use this issue tracker to report a vulnerability in Claude Code, Codex CLI, Gemini CLI or any other program this app runs. Those go to their own vendors — nothing here can fix them.