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.1.2
The threat model, stated first
This is a local desktop app with no server, no account and no multi-user surface. There is no data of yours on anyone else's machine to breach. 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.
- 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 CSP 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.
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 a profile
- Two independent guards. The directory must be one the app itself created inside its own profiles folder, and it must not be your home directory, your real Claude config directory or the filesystem root — the second check exists precisely for a hand-edited or corrupted config file. A profile 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 and what they cost, 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 of version 0.1.2: neither build is signed, 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.