Documentation
Security
Trust boundaries, permissions and data handling.
On this page
The trust boundary is your user account
TDE is a single-user system. Everything it owns — sockets, state directories, logs, the credential vault — is owner-only on the local filesystem, and the security model assumes that anything running as you is you. What the model does defend against is a different set of threats: another account on the same host, a stolen disk, an agent that decides to approve its own work, a leaked share link, and a credential ending up somewhere it can be read later.
This page consolidates guarantees that are enforced in code. Each one is a property you can rely on, not a policy someone has to remember.
Owner-only files and fail-closed sockets
| Path | Mode | What it is |
|---|---|---|
~/.tde/agent.sock | 0600, parent 0700 | The desktop's agent control socket |
~/.tde/runtime/<profile>/semantic.sock | 0600 | The Runtime's semantic control socket |
~/.tde/runtime/<profile>/control.sock | 0600 | The Runtime's operations socket |
~/.tde/runtime/<profile>/ and jobs/ | 0700 | Runtime state and durable job files |
~/.tde/sessions/<name>/ | 0700 | Session state and its socket |
~/.tde/vault.key | 0600 | The vault key, on headless machines |
~/.tde/web-share/token | 0600 | The Web Share operator token |
~/.tde/bot-webhook.token | 0600 | The local webhook bearer token |
The interesting word is fail-closed. Binding the agent socket is not a
chmod and a hope: TDE creates the parent, chmods it 0700, then re-reads the
mode and refuses to continue if it is anything else. It refuses to replace a
path that is not a socket or whose owner differs. After binding it chmods 0600
and then verifies, via symlink_metadata, that the result really is a socket
with exactly those permissions — and if not it drops the listener, removes the
file and errors with agent socket permissions could not be secured. There is no
degraded mode where the socket exists but is loose.
The Runtime's transport applies the same rules, and the Runtime's systemd unit
sets UMask=0077 so files it creates start correct rather than being corrected.
Owner-only is not treated as sufficient on its own. The agent socket also bounds requests to 1 MiB, responses to 16 MiB and in-flight connections to 32, on the stated reasoning that a compromised process running as the owner must still not be able to open unbounded work.
The credential vault
Credentials live encrypted in ~/.tde/secrets.db under a per-installation key
held in the OS credential store, or in an owner-only key file on a headless
machine. The file and its key never leave the machine; Sync replicates a small
allowlist of values under a separate key. Secrets are absent from job payloads,
event JSON, logs and Debug output by construction, and are reloaded at
execution time rather than frozen into scheduled work.
The full model — cipher, key custody, threat table, what replicates and what deliberately does not — is on Secret vault.
Agent chat is read-only by construction
tde ask and tde chat run a real agent CLI against your checkout, and that
checkout is read-only to the agent. This is enforced per CLI, using each
vendor's own sandbox, not by asking politely:
| Agent | How read-only is imposed |
|---|---|
| Claude Code | --permission-mode plan, --strict-mcp-config, --setting-sources "", --disable-slash-commands, Edit/Write/NotebookEdit disallowed, tools narrowed to read, glob, grep, fetch, search plus TDE's MCP |
| Codex | --sandbox read-only --ignore-user-config --ignore-rules |
| Cursor | --mode ask --sandbox enabled in a private temp home with only its auth file copied in |
| OpenCode | --pure --agent tde-readonly with a generated policy denying everything by default, then allowing read, glob, grep and fetch |
Cursor and OpenCode additionally run against private 0700 home and config
directories built for that invocation, with their own config env vars cleared, so
a project-level or user-level setting cannot re-enable a tool.
The only path from a chat agent to a mutation is Control MCP. Read-only is hard-coded for agentic CLI turns; the system prompt states that direct access to the checkout is read-only and that TDE workflow mutations go through the semantic MCP tools; and the MCP server itself sits behind the owner-only socket. Three layers, deliberately redundant. See Ask and chat and MCP.
Agents request approval, they never grant it
The asymmetry is structural in three independent places.
The tool surface has no approve verb. Control MCP exposes exactly
runtime_status, task_create, task_list, task_show, task_run,
task_watch, task_logs, task_cancel, task_retry, task_block,
task_unblock, task_comment, project_list, pipeline_list, agent_list and
share_status. task_approve, task_reject, share_start, share_stop and
share_url are not exposed, and a call to one is rejected with unknown_tool
before it reaches the transport — the socket never sees it. A test asserts
that the underlying handler recorded zero calls.
Guarded actions wait on a channel only the human owns. An agent that hits a
guard rule pushes a request onto a queue and blocks on a one-shot reply. The only
two pieces of code that ever construct a decision are the on-screen confirmation
modal and the Telegram button handler. Whichever answers first wins; the other
reports Already handled.
Decisions are proposed, never accepted. Brain MCP has decision_propose and
decision_request_approval, and no decision_accept. A proposal is never
auto-accepted; accepting is a click in the Decisions panel.
share_status is additionally redacted for agents: it returns state, reach and a
start time, and the URL — which carries the token in its fragment — is dropped.
The same field is skip-serialized on the desktop socket, whose Debug prints
only whether a URL exists.
See Human gates.
Mutations happen at most once
Every mutating Control MCP tool requires a caller-supplied idempotency_key, and
TDE keeps a durable idempotency journal in a 0600 SQLite file beside the
socket. It is keyed on the pair (tool name, idempotency key) and additionally
stores a fingerprint of the normalized command.
- Reusing a key with different arguments fails with
idempotency_conflictrather than doing something you did not ask for. - A claim is a lease. An abandoned
pendingclaim can be re-taken after 60 seconds with a new token, and the previous owner can no longer commit. - If the outcome cannot be persisted after dispatch, the caller is told
operation_outcome_unknownand instructed to inspect state rather than retry blindly. - If the journal itself is unavailable, no mutation is dispatched at all
(
idempotency_store_unavailable).
The journal is never pruned. It is small, and losing it would mean losing the guarantee.
Fail-closed leases
Two mechanisms use the phrase, and they mean the same thing: when ownership is in doubt, nothing happens.
Runtime job leases carry an owner, a generation and an expiry. Every mutation is a conditional update fenced on owner and generation, so an executor whose lease was reclaimed matches zero rows and simply cannot complete or cancel the job it thought it held. Recovery only touches expired leases, never live ones.
Bot host leases decide which machine in a licensed fleet runs the scheduled work. A solo, unactivated install has no shared lease and stays enabled. A licensed install must positively establish that it is the elected host, and any error in that check — expired token, failed heartbeat, unreachable store — is treated as "not the host". Ownership is rechecked while long actions run, so a device that loses the lease mid-flight stops. A claim that arrives after Sync was deactivated is rejected outright.
Service installs are transactional rather than leased: TDE keeps the previous service definition and restores it if the new one fails to activate or become ready.
Web Share
The share link is the credential, and encryption is always on — there is no plaintext mode. The handshake is hybrid post-quantum, running X25519 and ML-KEM-768 together and concatenating both shared secrets before HKDF-SHA256 derivation over a hash of the exact handshake transcript. The session is secure if either primitive holds, and a downgrade cannot go unnoticed. Records are ChaCha20-Poly1305 with per-direction keys and a sequence-numbered nonce; counter exhaustion fails closed.
The pre-shared secret in that schedule is the share token, and it is a high-entropy 256-bit value hashed to a key — never a short PIN. It travels in the URL fragment, which browsers do not send to servers; the client transmits only a short non-reversible token id. A spectator token is derived from the operator token, so operator and viewer are one secret to manage, and only the operator can send input.
Because the LAN page is plain HTTP, the browser has no crypto.subtle — the
primitives ship as WebAssembly served from the same port.
The full model, including reach modes, controller leases, presence logging and the PIN, is on Web Share.
Deliberate omissions
Some capabilities are absent on purpose. They are not on a roadmap; their absence is the feature.
- Sending email is not in the agent API. An agent can read mail and create a reviewable draft. The comments in the mail triage code say it twice: rendering never sends, and sending is never allowed. A human presses send.
- Approval and share lifecycle are not MCP tools, as above.
- Direct messages to the Slack Assistant are unsupported, so a private DM cannot become work without passing through a channel the bot was invited to.
- The Assistant's Slack bot token never reaches a desktop. Sharing it would let any subscriber post as the workspace bot. See Slack and Telegram.
Smaller properties worth knowing
Sandbox profiles fail closed on secrets. A task sandbox profile that requests
secrets without explicit environment grants is refused with
sandbox profile requests secrets; explicit environment grants are required.
Terminal output is sanitized. Everything the CLI prints passes through a filter that strips control characters other than newline and tab and the Unicode bidirectional override ranges. A task title from an untrusted tracker cannot repaint your terminal or spoof a command through right-to-left reordering. JSON output is left structured and lets the encoder escape control bytes.
The diagnostic is privacy-safe. tde doctor collects no hostname, no
username, no IP addresses, no credential environment variables and no absolute
$HOME paths — enforced by a test that asserts the JSON contains none of them.
That is what makes it safe to paste into an agent prompt or a bug report.
The local webhook is authenticated twice. The bot webhook binds
127.0.0.1 only, requires a bearer token from a 0600 file compared in constant
time, and requires a stable idempotency key. If the port is taken, the listener
is disabled and cron keeps running rather than the whole subsystem failing.
Agent errors are redacted. A configured API key is replaced with
[REDACTED] in agent runner errors, pinned by a test.
Reporting a vulnerability
Do not open a public issue. Use GitHub's private vulnerability reporting on the TDE repository, and include reproduction steps, the affected versions and the impact you expect. Until the first stable release, security fixes target the latest published version — update before reporting something that may already be fixed.