Skip to content
Security rules

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-core crate MUST always use the standard Rust regex crate. 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.rs currently 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-empty shell_args count as a new decision. Env is not a file decision: restore strips a tile’s shell_env (with a toast naming what was dropped) before it can reach a spawn, and per-pane env comes only from PaneEnvStore (user://pane_env.json, keyed by attachment_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, and layoutLoad over 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_consented checks it FIRST for a profile carrying plugin_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_consent must 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_env carries gpty_core::pty::STRIPPED_INHERITED_ENV_KEYS, applied by both gpty-ai backends. The child is a third-party CLI - it must never inherit GPTY_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.rs quotes untrusted capture behind a fence one backtick longer than the longest run inside it (capped at 32), and flattens concept_name/source_pane to 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_KEYS are 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 - and BLOCKED_ENV_KEYS matching is byte-exact, which is correct on Unix but not on Windows, where the PTY layer normalises keys case-insensitively and the GPTY_* credential/marker namespace is therefore not case-sensitive. On top of the inherited environment a pane gets the user’s global shell_env overlaid by its own entry from PaneEnvStore (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/gpty or $HOME/.local/state/gpty, created 0700 and tightened if lax - and only then /tmp/gpty-<uid>.sock; macOS $TMPDIR first; GPTY_SOCKET overrides). The shared-/tmp path 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 - register defaults to 30 s, while a held answer declares a longer one with register_with_timeout (paneWait 75 s, pluginInstall 305 s, both its fallback + 5 s). A single connection-wide deadline must not come back: it cut paneWait at half its published 60 s and pluginInstall at 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. When GPTY_SECRET is 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 editing server.rs/transport.rs.
  • OMP event socket: a second listener (gpty-events.sock / default_event_socket_path(), \\.\pipe\gpty-events on Windows) does not honor GPTY_SOCKET or GPTY_SECRET. It registers three methods: ompEvent (the authenticated submission path), subscribe and eventsPoll (read-only fan-out, bounded at 64 subscriptions, not capability-gated), and nothing else. Subscriptions are per-process and expire after SUBSCRIPTION_IDLE (120 s) without an eventsPoll - 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 ephemeral GPTY_TERMINAL_SESSION_ID + GPTY_EVENT_CAPABILITY injected 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 register ompEvent on the control socket, and do not expose event submission as an MCP tool. The shipped @gpty/omp-events extension reaches the listener on either transport through one net.connect(path) call; on Windows the path must be a \\.\pipe\ name (checked from the path alone, never stated), 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_SECRET on the GUI and every client. That trust covers processes outside gPTY: a program running in a pane also passes the gate (the inherited XDG_RUNTIME_DIR lets 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 describe GPTY_SECRET or the GPTY_* 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 to HistoryStore. 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_matches in gpty-ipc/src/server.rs is the socket gate, and every arm fails closed - Linux/Android SO_PEERCRED, macOS + the BSDs getpeereid, illumos/Solaris getpeerucred, and Windows a raw-extern win_peer module (GetNamedPipeClientProcessId → OpenProcess → token user SID → EqualSid, no windows-sys dependency, the same pattern as pin_library). AIX/Haiku/QNX have no API wired up and still answer true; that is documented in SECURITY.md rather than implied. The BSD arm is compiled nowhere else - ci-check has a BSD cross-check step and CI’s rust-tests job installs x86_64-unknown-freebsd for it. Adding a platform means adding its arm here, not a cfg(unix) shortcut.
  • Store writes (v0.5.4): every user:// store goes through BasePersistenceManager._write_file, which builds the file under a random sibling temp name (no O_EXCL in 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_owner chmods the SQLite file and its -wal/-shm siblings 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 downloaded concepts.json was 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 clamps stop_timeout_ms to ≤ 600 000 ms (concept::concepts_from_json); a legacy cmd key is ignored, never parsed into the vocabulary. Lines over 16 KiB are never regex-matched; capture buffers are capped at 4 MiB. LineParser::feed additionally force-closes an OSC sequence past 64 KiB (MAX_OSC_BYTES): vte’s std build keeps OSC bytes in an unbounded Vec (its 1 KiB cap is no_std-only, and alacritty forces std on), 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_CONDITIONS 8, 1024 bytes each, standard regex crate) 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_graph caps 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_path compiles a closed key set, so a graph node can never introduce a cmd or any other key into concepts.json.
  • PTY env sanitization: pty::sanitize_envs runs 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 by GptyTerminal.start_shell(), bypassing the blocklist. GPTY_PANE_ID is the pane’s stable attachment_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 via CommandBuilder::env_remove so child shells cannot control the workspace. Do NOT remove entries from BLOCKED_ENV_KEYS without a security review.
  • cli_view is 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’s command + shell_args - exactly the keys PaneTypes.tile_spawns_untrusted/tile_plan_key already gate, so a profile or manifest tile that names a program still asks for Workspace Trust - and CliProcess::spawn holds the program to pty::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_KEYS is removed from the child: a plugin’s status command is a third-party CLI and must not inherit GPTY_SECRET/GPTY_SOCKET or 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_delta text. 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), with omp_event_stats() exposing both drop counters. Never persist GPTY_EVENT_*, OMP session IDs, or thinking text in layout/profile JSON. Reasoning accordion records stay in RAM until a later feat(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_SOCKET must be an absolute path owned by the current UID with mode 0o600-style privacy - transport::validate_socket_path runs client-side before connect and refuses insecure sockets (they would receive commands and GPTY_SECRET). GPTY_GUI binaries are validated by transport::validate_gui_binary (absolute, regular file, own UID, not group/other-writable) before auto-spawn. Do NOT weaken these checks when editing client.rs/daemon.rs/transport.rs.
  • Layout restore trust: saved tiles from workspaces.json/layout.json (legacy)/profiles are untrusted input - PaneTypes.sanitize_tile validates the pane type and settings dict, clamps grid geometry (PANE_MAX_ROWS/PANE_MAX_COLS, mirrored by MAX_ROWS/MAX_COLS in the FFI - an unbounded stored rows/cols allocated the cells before the first frame); PaneBody.apply_settings applies values only when types match; terminal shell_args are 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 at TextRead.DEFAULT_MAX_BYTES (1 MiB, the shared TextRead.read_prefix guard) and refuses anything FileAccess.file_exists reports 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 through GptyTerminal.vault_note_read, which applies the same 1 MiB cut, the same regular-file gate and the same torn-tail drop in gpty_core::vault; and the file tree asks for confirmation before OS.shell_open, because a downloaded .desktop file 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 with create_new so an existing note answers exists instead 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_program resolves a bare name through the PATH the child inherits (the tile’s own shell_env when set, otherwise the user’s - the value execvp searches) and holds the first match to validate_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. PATH itself is not blocked: the shipped profiles launch omp, lazygit and nvim by 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 at session_open. Wiki links are held to the same rule too: following a mention is a read, never an action. The renderer emits a gpty-note: URL from the note’s own text (percent-encoded, label escaped, and gated by MarkdownView.wikilinks_enabled, which only the wiki pane sets), MarkdownView hands the decoded target to its owning pane instead of OS.shell_open, vault_link_resolve maps it to a note in the index (never a caller-supplied path), and the pane opens that note through vault_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-network so only the components in crates/gpty-media/components.txt exist 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 in parser.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 and cat-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 in agent_state.rs) is display-only and never a decision input. States surface through paneStatus (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, is gpty 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, because GPTY_SOCKET/GPTY_SECRET are stripped) and submits gpty.state.declared (state.declared, one whitelisted state field) 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 of inject over the GPTY_SECRET-gated control socket. Text is written to each PTY verbatim, exactly as inject does (the target shell interprets it - no additional interpolation by gpty). Pane tags used for targeting must be sanitized like attachment_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_tile rules). Never claim sandboxing.
  • Update checker: the startup GitHub-release check is notify-only - it never downloads or executes anything, and a remote tag_name is validated by _is_valid_semver before 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.