Security rules
Policy (threat model, supported versions, reporting, what gpty does not defend against) lives in SECURITY.md. The rules below are implementation constraints for this repo.
- No concept execution path: a concept is data (trigger, stop condition, routing target). Never add a field, label match, or click handler that makes a concept write to a PTY - the trigger runs against untrusted terminal output, so that would be remote-triggered execution with no consent step. See the concept bullets above.
- Concept Engine ReDoS: The
gpty-corecrate MUST always use the standard Rustregexcrate. PCRE or back-tracking engines are strictly prohibited to prevent ReDoS (Regex Denial of Service) attacks when parsing large amounts of terminal output. - OSC 52 Clipboard Syncing:
parser.rscurrently discards all terminal escape sequences, keeping copy/paste safely bound to Godot UI inputs. Do NOT implement OSC 52 clipboard injection/syncing without placing it behind an explicit Godot confirmation dialog to prevent drive-by clipboard hijacking. - Restore vs. user intent:
PaneTypes.tile_spawns_untrusted(td, default_program)is the Workspace Trust predicate - program (command>shell) and non-emptyshell_argscount as a new decision. Env is not a file decision: restore strips a tile’sshell_env(with a toast naming what was dropped) before it can reach a spawn, and per-pane env comes only fromPaneEnvStore(user://pane_env.json, keyed byattachment_id, written only by the pane settings UI), overlaid on the user’s global env at spawn. Every restore path must consult the predicate: the sidebar profile-activation and workspace-restore paths show the dialog, andlayoutLoadover IPC refuses an untrusted profile, because a caller cannot answer a dialog and naming a profile is not consent to what the file asks for. Never compare a different key than the spawn path consumes. The consent memory (TrustedStore,user://trusted.json) applies on top of the predicate and is the only thing that short-circuits it: a shipped builtin approved at this app version, or a tile whose exact spawn plan (PaneTypes.tile_plan_key- the same program+argv derivation) an approval covers, skips the dialog; a plugin approval (id + pinned revision, written when the install review is accepted) is what covers that plugin’s own profiles -_profile_consentedchecks it FIRST for a profile carryingplugin_id, so an approved revision activates without re-asking and a newer revision re-prompts; a new version/revision re-prompts, and content that arrived any other way (user-authored profiles, layout files) never records consent and keeps asking. Only the trust dialogs’ confirm paths write the record (builtin approval from the profile dialog, plugin approval from the install review); nothing else may write it, and_profile_consented/_tiles_untrusted_without_consentmust consult nothing but the predicate and the record. Do NOT exempt built-ins from the gate outright - first approval stays explicit; the record is what makes later activations smooth. - Inspector/adapter children:
SessionOpenRequest::strip_envcarriesgpty_core::pty::STRIPPED_INHERITED_ENV_KEYS, applied by bothgpty-aibackends. The child is a third-party CLI - it must never inheritGPTY_SECRET,GPTY_SOCKET, the event capability, or the pane markers, because a GUI started from inside a pane carries all of them. - Inspector prompt construction:
gpty-ai/src/prompt.rsquotes untrusted capture behind a fence one backtick longer than the longest run inside it (capped at 32), and flattensconcept_name/source_paneto a single line. A literal ``` fence was closable by any program that printed three backticks, which let the rest of the capture land as instructions rather than quoted data - keep the fence outgrowing its content. - Environment reach: a pane inherits the GUI process’s environment (portable-pty snapshots it; only
STRIPPED_INHERITED_ENV_KEYSare removed), so launching gPTY from a credential-holding shell hands that context to every pane - and so does the GUI the CLI auto-spawns, which inherits the CLI’s environment in turn - andBLOCKED_ENV_KEYSmatching is byte-exact, which is correct on Unix but not on Windows, where the PTY layer normalises keys case-insensitively and theGPTY_*credential/marker namespace is therefore not case-sensitive. On top of the inherited environment a pane gets the user’s globalshell_envoverlaid by its own entry fromPaneEnvStore(user://pane_env.json, written by the pane settings UI); saved files cannot supply env. - IPC hardening: the control socket defaults to
$XDG_RUNTIME_DIR/gpty.sock(fallbacks/run/user/<uid>/gpty.sock, then a private state directory -$XDG_STATE_HOME/gptyor$HOME/.local/state/gpty, created 0700 and tightened if lax - and only then/tmp/gpty-<uid>.sock; macOS$TMPDIRfirst;GPTY_SOCKEToverrides). The shared-/tmppath is predictable in a world-writable directory, so another user can squat it and deny the control surface - that is why the private directory is preferred and why a squatted path is a DoS, never a spoof (the socket’s owner and mode are validated before use). One instance owns the endpoint.serve()probes an existing socket before replacing it and refuses when a server answers - a leftover file from a crash is still replaced, a live one never is (it used to be unlinked, which orphaned the running GUI while both windows stayed up); the event socket and the Windows pipe follow the same rule (a taken pipe name is refused, not retried forever), and an instance that cannot bind sets the shutdown flag and says why in a toast, because a window with no pane API is worse than no window. The server chmods the socket 0600, rejects cross-UID peers on Linux/macOS (fail-closed), caps requests at 64 KiB (-32600), connections at 16, request reads and response writes at 30 s each, and dispatches each handler under its own budget -registerdefaults to 30 s, while a held answer declares a longer one withregister_with_timeout(paneWait75 s,pluginInstall305 s, both its fallback + 5 s). A single connection-wide deadline must not come back: it cutpaneWaitat half its published 60 s andpluginInstallat a tenth of its review window, and the client saw a dropped socket instead of an answer. An overrun now answers-32000 handler timed out. WhenGPTY_SECRETis set on the GUI, clients MUST present it (mismatch →-32001); tests/scripts probing a secret-configured GUI must set the same env var. Do NOT weaken these gates when editingserver.rs/transport.rs. - OMP event socket: a second listener (
gpty-events.sock/default_event_socket_path(),\\.\pipe\gpty-eventson Windows) does not honorGPTY_SOCKETorGPTY_SECRET. It registers three methods:ompEvent(the authenticated submission path),subscribeandeventsPoll(read-only fan-out, bounded at 64 subscriptions, not capability-gated), and nothing else. Subscriptions are per-process and expire afterSUBSCRIPTION_IDLE(120 s) without aneventsPoll- the next subscribe or emit releases the idle slot and its queued events, so abandoned clients (or reconnecting ones that never reuse their id) cannot exhaust the 64-slot cap. Read access is therefore open to any same-UID process, which is inside the documented threat model but is a wider surface than the single-method submission path; do not treat the event channel as confidential from same-UID peers. Each PTY gets an ephemeralGPTY_TERMINAL_SESSION_ID+GPTY_EVENT_CAPABILITYinjected at spawn on every platform; submitting events requires the capability, and a leaked capability cannot inject text, create panes, or shut down gPTY. Do not registerompEventon the control socket, and do not expose event submission as an MCP tool. The shipped@gpty/omp-eventsextension reaches the listener on either transport through onenet.connect(path)call; on Windows the path must be a\\.\pipe\name (checked from the path alone, neverstated), and on Unix the socket must be owned by this user and inaccessible to group/other. - IPC trust model: the IPC channel is local-only and trusts same-UID processes - a process running as the user can already control that user’s session, so this is the accepted threat model for a single-user dev machine. On multi-user/shared hosts, set
GPTY_SECRETon the GUI and every client. That trust covers processes outside gPTY: a program running in a pane also passes the gate (the inheritedXDG_RUNTIME_DIRlets the CLI derive the socket path, and on Linux the secret is readable from the GUI’s/proc/<pid>/environ), so panes are not separated from the control surface today - a known limitation recorded in SECURITY.md and queued in the roadmap. Never describeGPTY_SECRETor theGPTY_*env stripping as a boundary against a program in a pane. Anyone controlling a process’s environment controls that process: env-var attacks are mitigated at the socket/binary boundary (validate_socket_path/validate_gui_binary), not by treating the environment itself as trusted. - Sensitive workspace invariant (planned, v0.9.1 - see ROADMAP): when a workspace is flagged sensitive, its panes are unreachable over the socket by every client, outside clients included, with no setting, grant or per-workspace exception; that is a permanent rule, not a default. A sensitive pane is treated as nonexistent by the one pane resolver,
list-panes,broadcast,pane-wait/pane-status, the event stream, concept matching and the ambiguous-label error, and nothing about it is written toHistoryStore. The flag is user-owned (WorkspaceStore): no profile, layout, plugin or IPC call may set or clear it. Any new IPC verb that takes a pane id or label must go through the resolver and is covered by the enumerating test; never add a second lookup path, and never add a way to widen this rule. It is a speed bump against same-user code, not encryption or a sandbox - say so wherever it is described. - Peer credentials (v0.5.4):
peer_uid_matchesingpty-ipc/src/server.rsis the socket gate, and every arm fails closed - Linux/AndroidSO_PEERCRED, macOS + the BSDsgetpeereid, illumos/Solarisgetpeerucred, and Windows a raw-externwin_peermodule (GetNamedPipeClientProcessId→OpenProcess→ token user SID →EqualSid, nowindows-sysdependency, the same pattern aspin_library). AIX/Haiku/QNX have no API wired up and still answertrue; that is documented in SECURITY.md rather than implied. The BSD arm is compiled nowhere else -ci-checkhas a BSD cross-check step and CI’srust-testsjob installsx86_64-unknown-freebsdfor it. Adding a platform means adding its arm here, not acfg(unix)shortcut. - Store writes (v0.5.4): every
user://store goes throughBasePersistenceManager._write_file, which builds the file under a random sibling temp name (noO_EXCLin GDScript, so the name is the protection - 64 random bits cannot be pre-planted) and sets mode 0600 (STORE_UNIX_MODE) before renaming it over the target; the rename replaces the target itself, never writing through a symlink planted at the store path. The scrollback database is the same idea in Rust:history::restrict_to_ownerchmods the SQLite file and its-wal/-shmsiblings to 0600, because SQLite otherwise creates them with the process umask (measured 0644). Do not replace the random suffix with a fixed one and do not drop the explicit mode. The vault index (state_dir()/vaults.db,gpty_core::vault) is the same shape and the same rule, and it holds copies of vault note text outside the vault, so its mode matters exactly as much. - Concepts have no execution path (v0.5.3):
command_template,substitute_template,matching_commands, and the click-to-run handler were removed. A trigger is a regex over untrusted output, so any action it could take would be an execution primitive governed by whatever a program printed - a downloadedconcepts.jsonwas arbitrary command execution with an attacker-chosen trigger, and it fired silently in whichever pane matched (including a root or ssh pane). Do NOT reintroduce a command/action field, a label-based PTY write, or a click handler that runs anything from a concept without a per-concept consent boundary (plugin trust lane). Concept parsing still caps count (128), trigger length (1024), actions (32), and clampsstop_timeout_msto ≤ 600 000 ms (concept::concepts_from_json); a legacycmdkey is ignored, never parsed into the vocabulary. Lines over 16 KiB are never regex-matched; capture buffers are capped at 4 MiB.LineParser::feedadditionally force-closes an OSC sequence past 64 KiB (MAX_OSC_BYTES): vte’sstdbuild keeps OSC bytes in an unboundedVec(its 1 KiB cap is no_std-only, and alacritty forcesstdon), so an unterminated sequence grows memory and leaves the parser stuck inside the string, swallowing every later line. Conditions (v0.5.4) are regexes over untrusted output held to the same stance (MAX_CONDITIONS8, 1024 bytes each, standardregexcrate) and a malformed condition rejects the whole concept rather than being dropped (dropping narrows away a predicate and widens matching). The visual editor’s storage is untrusted file input too:ConceptManager._sanitize_graphcaps positions (512), drops out-of-range/non-finite coords, caps draft chains (32) and draft nodes (256), clamps the capture timeout, and drops unknown keys;ConceptGraphModel.entry_for_pathcompiles a closed key set, so a graph node can never introduce acmdor any other key intoconcepts.json. - PTY env sanitization:
pty::sanitize_envsruns at spawn - the single choke point for settings, user-owned per-pane env, and the inherited environment. Keys must match[A-Za-z_][A-Za-z0-9_]*; dynamic-loader injection vectors (LD_PRELOAD,LD_AUDIT,LD_LIBRARY_PATH,LD_ORIGIN_PATH,DYLD_*), startup-evaluation keys the shell or the next tool runs (PROMPT_COMMAND,BASH_ENV,ENV,SHELLOPTS,PS4,ZDOTDIR,PERL5OPT,PERL5LIB,PYTHONSTARTUP,NODE_OPTIONS,RUBYOPT,LESSOPEN,GIT_SSH_COMMAND,GIT_EXTERNAL_DIFF,GIT_PAGER,PAGER), event-channel keys (GPTY_EVENT_SOCKET,GPTY_EVENT_PROTOCOL,GPTY_TERMINAL_SESSION_ID,GPTY_EVENT_CAPABILITY), and pane-marker keys (GPTY_ENV,GPTY_PANE_ID) are dropped from untrusted env. Trusted runtime vars (GPTY_ENV=1,GPTY_PANE_ID=<attachment_id>, and the event-channel vars) are injected last byGptyTerminal.start_shell(), bypassing the blocklist.GPTY_PANE_IDis the pane’s stableattachment_id; if unset at spawn it falls back to the per-PTY session id. Inherited control credentials (GPTY_SECRET,GPTY_SOCKET,GPTY_GUI) are stripped viaCommandBuilder::env_removeso child shells cannot control the workspace. Do NOT remove entries fromBLOCKED_ENV_KEYSwithout a security review. cli_viewis a second spawn surface and is held to the same rules as a terminal tile: it runs argv (never a shell string) from the pane’scommand+shell_args- exactly the keysPaneTypes.tile_spawns_untrusted/tile_plan_keyalready gate, so a profile or manifest tile that names a program still asks for Workspace Trust - andCliProcess::spawnholds the program topty::validate_program(a name resolving into the working directory is refused; an absolute path must be a file another user cannot swap).STRIPPED_INHERITED_ENV_KEYSis removed from the child: a plugin’s status command is a third-party CLI and must not inheritGPTY_SECRET/GPTY_SOCKETor a pane’s event capability. It has no PTY (that is the point - a view, not a terminal), no stdin, and renders bytes as text with no ANSI interpretation, so no sequence can move the cursor or change what is drawn. Do not give it a concept-receive, PTY-write, or command-execution path driven by terminal output - that would make a pane’s output an execution primitive.- Agent observability allowlist: the OMP extension may forward session/turn/tool metadata and bounded
thinking_deltatext. Never forward prompts, answer text, tool arguments/results, provider payloads, credentials, or session file paths. The translation boundary enforces that as a per-event field allowlist (omp_events.rs::ALLOWED_FIELDS): the downstream object is built field-by-field with caps (ids/names ≤ 128 B,reason≤ 64 B,text≤ 8 KiB, non-negative integers, bools), and any field not listed - including one a future extension version adds - is dropped rather than passed through. Adding a field to an event means adding it to that table, deliberately. A reasoning delta that would exceed the 8 KiB text cap is split by the extension on code-point boundaries (never truncated, and never capped by JavaScript string length - the server measures UTF-8 bytes); the server counts and rate-limit-warns a submission that still exceeds it instead of losing it silently. The queue between the socket and the frame’s drain is 1024 events/session and 4 MiB globally (EventQueue), withomp_event_stats()exposing both drop counters. Never persistGPTY_EVENT_*, OMP session IDs, or thinking text in layout/profile JSON. Reasoning accordion records stay in RAM until a laterfeat(history)SQLite path exists. - Third-party AI CLIs: do not read or reuse OAuth tokens, call backing services with harvested credentials, bundle proprietary CLIs, or scrape TUI output. Users launch agents in terminal panes; gPTY only consumes documented hooks/extensions.
- Env-var hijacking:
GPTY_SOCKETmust be an absolute path owned by the current UID with mode0o600-style privacy -transport::validate_socket_pathruns client-side before connect and refuses insecure sockets (they would receive commands andGPTY_SECRET).GPTY_GUIbinaries are validated bytransport::validate_gui_binary(absolute, regular file, own UID, not group/other-writable) before auto-spawn. Do NOT weaken these checks when editingclient.rs/daemon.rs/transport.rs. - Layout restore trust: saved tiles from
workspaces.json/layout.json(legacy)/profiles are untrusted input -PaneTypes.sanitize_tilevalidates the pane type and settings dict, clamps grid geometry (PANE_MAX_ROWS/PANE_MAX_COLS, mirrored byMAX_ROWS/MAX_COLSin the FFI - an unbounded storedrows/colsallocated the cells before the first frame);PaneBody.apply_settingsapplies values only when types match; terminalshell_argsare sanitized (PaneTypes.sanitize_shell_args: ≤32 args, ≤4096 chars, no U+FFFD); code_viewer/file_tree/wiki pane paths must be absolute; the code viewer caps a read atTextRead.DEFAULT_MAX_BYTES(1 MiB, the sharedTextRead.read_prefixguard) and refuses anythingFileAccess.file_existsreports false for (its regular-file check refuses directories/devices/FIFOs - a FIFO open would otherwise block the GUI thread); the wiki pane’s note view reads throughGptyTerminal.vault_note_read, which applies the same 1 MiB cut, the same regular-file gate and the same torn-tail drop ingpty_core::vault; and the file tree asks for confirmation beforeOS.shell_open, because a downloaded.desktopfile is run by the mime handler. The wiki pane’s editor is this repo’s one pane write surface and is held to the same rule: every write is an explicit user action - Save/Ctrl+S, New note, Rename, or a confirmed Delete - and never the pane’s own idea, and every path follows the walk’s vocabulary. A save must name an existing note, and its canonicalized path must stay inside the vault - a symlink out of the vault is refused, one pointing at a sibling note is written through - the write is atomic and keeps the note’s own mode, and a file whose (mtime, size) stamp differs from the one the pane loaded is never overwritten without the user confirming it. Create/rename/delete act on the vault entry (a symlinked note is unlinked or moved as a link, its target untouched, so deletion cannot reach a file the vault does not contain), a create or rename destination is claimed withcreate_newso an existing note answersexistsinstead of being replaced, and it may only target a directory that already exists and canonicalizes inside the vault. Do NOT remove these guards when adding pane settings. Programs restored from those files are validated at spawn:gpty_core::pty::validate_programresolves a bare name through thePATHthe child inherits (the tile’s ownshell_envwhen set, otherwise the user’s - the valueexecvpsearches) and holds the first match tovalidate_executable’s rule - a regular file, not group/other-writable, owned by this user or root, with no writable ancestor - while an absolute path is checked directly.PATHitself is not blocked: the shipped profiles launchomp,lazygitandnvimby name. A name that resolves nowhere is passed through (the spawn fails as it always did); a name resolving into the working directory is refused. The Inspector’s adapter argv is held to the same rule atsession_open. Wiki links are held to the same rule too: following a mention is a read, never an action. The renderer emits agpty-note:URL from the note’s own text (percent-encoded, label escaped, and gated byMarkdownView.wikilinks_enabled, which only the wiki pane sets),MarkdownViewhands the decoded target to its owning pane instead ofOS.shell_open,vault_link_resolvemaps it to a note in the index (never a caller-supplied path), and the pane opens that note throughvault_note_read- so the containment check, the 1 MiB cut and the regular-file gate are the note editor’s, unchanged. A target that resolves nowhere, or to a shared stem, is reported rather than guessed; no vault text can become a path the pane reads directly or a URL the system opens. - Media decode (v0.7.0): media files are untrusted input and the backend links FFmpeg - a C decoder stack - as a deliberate exception to the memory-safe-decoder preference; SECURITY.md states the exception and what is not claimed. The shipped FFmpeg is pinned (9.0.2, SHA256), LGPLv3, dynamically linked, and built with
--disable-everything --disable-networkso only the components incrates/gpty-media/components.txtexist at all. On top of that the runtime validates the path (absolute local regular file, never a FIFO/device), the demuxer alias list after open, and the selected stream’s codec id before a decoder runs; caps are 16 streams, 4096 px per side, 34 MiB per frame, 8 channels, bounded queues and a 10 s open budget. Decode runs on a dedicated OS thread, never the GUI thread and never the shared tokio runtime. There is no sandboxing claim - a decoder bug is a same-process memory-safety bug - and nothing in a media file can become a command or a write: the path is a read and the output is frames and samples. - OSC agent-state declaration: the published
gpty_state=<value>OSC sequence is the Tier 2 agent-state channel, implemented inparser.rs(osc_dispatch) - the FIRST interpreted sequence in a discard-only parser; do not expand the parser further without a security review. Strictly whitelist enum values (idle/working/needs-attention/completed/failed); single-shot per parse; rate-limited (≥500 ms between accepted declarations per terminal,DECLARATION_MIN_INTERVAL_MS). It is spoofable by design - pasted text andcat-ed files can emit it - so it may set UI display state ONLY and must never trigger actions, concepts, layouts, or IPC. The engine applies it only when not alt-screen, not capturing, and outside the post-resize suppression window (same guards as concept matching). Tier 1 (capability-authenticated event socket) remains authoritative; Tier 3 (regex/idle/exit heuristics inagent_state.rs) is display-only and never a decision input. States surface throughpaneStatus(agent_state,agent_state_tier) and the titlebar badge. Windows has no Tier 2 OSC channel: ConPTY re-renders its own model and consumes sequences its VT engine does not implement, so the declaration never reaches the parser - measured in CI, where a DECSET 2004 from the same PowerShell fixture arrives while every declaration test fails. The declaration path there, and the authenticated alternative everywhere, isgpty state <value>: it reads the pane’s own injected credentials (GPTY_EVENT_SOCKET,GPTY_TERMINAL_SESSION_ID,GPTY_EVENT_CAPABILITY- the only credential a pane’s child holds, becauseGPTY_SOCKET/GPTY_SECRETare stripped) and submitsgpty.state.declared(state.declared, one whitelistedstatefield) over the event socket. Same display-only authority, capability-gated instead of spoofable; it is deliberately not an MCP tool (an MCP client is not inside a pane, and there is no IPC method behind it). broadcast-input: a fan-out ofinjectover theGPTY_SECRET-gated control socket. Text is written to each PTY verbatim, exactly asinjectdoes (the target shell interprets it - no additional interpolation by gpty). Pane tags used for targeting must be sanitized likeattachment_id([a-z][a-z0-9_-]{0,31}).- Plugin trust model: plugins are arbitrary code running as the user - the same trust as any editor extension. Review-then-run (reuse the Workspace Trust confirmation dialog), pin revisions, per-plugin config/state/log dirs, manifest validation with size/count/path caps (reuse concept parse caps and
sanitize_tilerules). Never claim sandboxing. - Update checker: the startup GitHub-release check is notify-only - it never downloads or executes anything, and a remote
tag_nameis validated by_is_valid_semverbefore it reaches the toast. The response body is integrity-untrusted (TLS transport only; GitHub release artifacts are unsigned): treat remote version strings as display data. If a future “Download update” action is added, it MUST verify artifact integrity (pinned checksums or equivalent) before installation - a MITM’d response could otherwise point at a malicious payload.