Skip to content
v0.7 - Media Pane & Trust Boundary

v0.7 - Media Pane & Trust Boundary

v0.7.0 (2026-10-20) released the media pane, a Rust media backend over a pinned, minimal FFmpeg build. v0.7.1 is the release in progress: a trust-boundary hardening pass that came out of the October 2026 security audit, placed ahead of v0.8.0’s UI work because most of its findings are reachable from data gPTY is documented to treat as untrusted. The next task in the roadmap is the first unchecked row below.

v0.7.0 - Media Pane

Done Area Item
✅ GUI First terminal is focused at launch - no pane had last_body/focus, so echo typed_without_click produced nothing until the pane was clicked (the control run typed the same text at the prompt), and gpty list-panes reported FOCUS false. _apply_active_workspace_view only activates _tm.last_body, which startup never sets. the view refresh - the one path shared by launch, workspace switching and profile activation - resolves a fallback when no remembered pane is valid: the workspace’s first terminal, else its first pane, activated through activate_pane (so the sidebar accent, status bar and focusPane-visible state agree, and a read-only workspace still records an active row). The same hole covered a restored workspace that was never visited and a profile rebuild, so those entries are fixed by the same rule rather than a launch-only special case; a blank workspace still releases focus (Grid hidden → Godot drops the owner, measured), so keys cannot flow into a hidden pane. godot/tests/integration/test_startup_focus.gd pins both halves: launch leaves the first terminal as last_body and the viewport’s focus owner, and a line dispatched through Input.parse_input_event is echoed and executed by the shell without a click (the audit’s exact observable); a second test switches to a workspace built in the background and asserts its first terminal takes the keyboard. AGENTS.md (Pane activation) and the 0.7.0 CHANGELOG carry the rule.
✅ GUI Reset or closing a pane asks before killing running work - verified (Reset) and code (close/quit): a terminal with a foreground sleep 600 was removed by one click on Reset with no dialog, and PaneBody.request_close is implemented only by the Wiki pane; the sidebar’s destructive red Reset sat directly under Settings and Ctrl+Shift+R fired it. a terminal now reports has_running_work() from the status document’s new foreground_job sample - tcgetpgrp on the PTY master vs. the spawned child, refreshed by the existing 200 ms child poll, exact on Unix (a background cmd & never took the terminal and does not count; a pane whose configured program is the child reports none) and null on Windows/ConPTY, which has no equivalent and is treated conservatively while the shell runs. The close contract gains a second, action-level phase: _ask_close_then(bodies, next, declined, ask_running) calls _ask_running_then first, which raises one dialog naming every busy pane (capped at 8 names + a count) before any per-pane request_close - a workspace close, quit, reset or profile activation asks once, not once per terminal. _kill and _swap_pane route through the same walk, so the titlebar X, Ctrl+Shift+W, the palette, a type swap and IPC-adjacent GUI paths all ask; the base PaneBody.has_running_work() is false, so the wiki’s Save/Discard question is unchanged and only a terminal can add the running question. IPC callers cannot answer a dialog and must not raise one: killPane uses the _kill_now primitive (an explicit kill is consent to stop the process; unsaved work still refuses), and layoutLoad activates with ask_running: false for the same reason. The sidebar action is renamed Close all panes with the trash glyph (it shared the counter-clockwise arrow with “change pane type”) and the palette command is close all panes; godot/README.md follows. Tests: test_pane_close_guard.gd pins the single aggregate dialog (one ask, busy panes named, idle one not, cancel before any per-pane question, confirm then walks), a live Unix foreground command asking and cancelling/confirming around a real sleep 600, an idle shell closing with no dialog, and the Windows conservative branch; crates/gpty-core pins foreground_job at a prompt (Some(false)), for a typed command (Some(true)) and for a background descendant (Some(false)). The Windows gap (every close/quit of a live shell asks) is queued in Future rather than left implicit.
✅ MEDIA Media pane type - media in PaneTypes.ALL; plays local audio/video from an absolute file path validated by sanitize_tile (same trust rule as code_viewer/file_tree). Remote/URL streaming deferred - a separate trust boundary. media is an eighth pane type on both sides of the vocabulary seam - PaneType (variant, as_str, FromStr, ALL: [PaneType; 8]) and PaneTypes.ALL (appended last in both, the order the key-for-key seam test enforces), plus TerminalManager._PaneScripts - so the sidebar, palette (new media), gpty new-pane -t media, the swap menu, M<n> labels and the plugin-manifest validator all carry it with no second list. The new MediaPane plays local files through Godot’s native streams: Theora .ogv video and Ogg Vorbis/MP3/WAV audio (.ogg/.oga/.mp3/.wav), with the file name and Playing/Finished state shown in the pane and a real letterboxed video surface (AspectRatioContainer given the clip’s own ratio - a bare VideoStreamPlayer.expand is a cover fit and cropped a 4:3 clip in a tall pane, measured in the live capture). MediaFiles is the one closed table and path gate - absolute, FileAccess.file_exists’s regular-file check, known extension - and the pane builds a typed stream (VideoStreamTheora.file / the three audio static loaders) rather than handing the path to ResourceLoader, because a file-supplied .gd or .tscn behind a media extension would be loaded as code; remote URLs and res:///user:// are refused with an in-pane explanation, and a refusal clears file_path instead of pretending the pane still shows a file. The file tree’s in-app route now covers media (ViewableFiles first, then MediaFiles.pane_for), and a same-path request focuses the media pane already playing it (two would double the sound and the decode work). A media tile restores its path and starts playing; clearing the settings path returns to the idle view. The eighth sidebar button was clipped by the old one-row strip (found in the live capture), so the Add Pane buttons are a 4×2 GridContainer now, pinned by a sidebar width assertion. Native-transport scope is deliberate: the Rust demux/decode matrix, wider containers/codecs, transport controls and paneStatus state are the remaining v0.7.0 media items below, and the CLI/agent file-opening gap is queued there too. Verified: test_media_files.gd (the table and every path-gate branch), test_media_pane.gd (audio and video actually playing in headless - AudioStreamPlayer.playing and VideoStreamPlayer.is_playing() - plus refusals before a decoder, idle/clear, layout state and no routed content), test_pane_file_sinks.gd (the media route), test_pane_open_request.gd (same-file focus), test_layout_restore.gd (a media tile restores and plays), the pane-spawning/label and sidebar updates, cargo test -p gpty-core -p gpty -p gpty-ipc green (including both vocabulary seam tests), gut-check: OK - 59 scripts, 764 tests, the live smoke’s new media step (new-pane -t media reports the type), and a real-window capture showing the letterboxed video, the playing status and the two-row pane buttons.
✅ MEDIA Rust media backend - a new gpty-media crate owns the only FFmpeg binding (ffmpeg-the-third 6.0.0, pinned behind a facade), and GptyMedia is the JSON + packed-array FFI edge. Integration decision from the review: a SHA256-pinned FFmpeg 9.0.2 built shared, LGPLv3 (--disable-everything --disable-network, the closed component set in crates/gpty-media/components.txt) and bundled per platform; ffmpeg-the-third was chosen over maintenance-mode ffmpeg-next for its active soundness work, with ffmpeg-next documented as the fallback. Decode: H.264/HEVC/VP8/VP9/AV1/Theora video to packed RGBA8, MP3/FLAC/Vorbis/Opus/AAC/ALAC/AC-3/PCM to interleaved f32 at the requested rate/channels; one acknowledged-seek worker thread per file with bounded queues, epoch filtering and per-stream cursors that drop pre-target frames, and a worker that survives EOF for replay. Trust: the Rust path gate (absolute local regular file), demuxer-alias and codec-id allowlists, 16 streams / 4096 px / 34 MiB frame / 8 channels / 10 s open caps, and no network protocols in the build. Evidence: 16 Rust tests (decode/seek/path/refusal/backpressure plus the build_recipe.rs and binding_rules.rs seams), the three-test GptyMedia GUT smoke, and a local link against the vendored prefix (ldd resolves the bundle path). Known gap: release artifacts do not yet bundle the runtime libraries - queued next.
✅ MEDIA Bundle the pinned FFmpeg runtime into release artifacts - scripts/bundle-media is the one packer: linux/windows copy the pinned libav*/libsw*/DLLs beside the GDExtension and run an ldd “not found” check, macos-libs lipos the two per-arch builds into universal dylibs and ad-hoc signs them, and macos-zip injects them into the exported .app (so it can run on the Linux export job without touching a signature). The macOS FFmpeg build uses --install-name-dir=@rpath plus @loader_path, and macos-libs fails if a GDExtension still links FFmpeg by absolute path. scripts/build calls the same helper for the host bundle; release build jobs upload release/ffmpeg, a new bundle-macos job produces the universal set, and the three export steps bundle before archiving. FFmpeg’s LGPLv3/LGPLv2.1 texts and the component list travel in licenses/, and the AUR recipe installs the libs and licenses under /usr/lib/gpty and /usr/share/licenses/gpty. Verified: a locally exported Linux bundle extracted to a fresh directory resolves every library from $ORIGIN and starts headless with LD_LIBRARY_PATH unset. macOS and Windows bundling are wired but only exercised by the first tagged release; macOS real signing (v1.0.0) must run after injection. Windows still uses vcpkg’s FFmpeg (wider internal codec set; the runtime allowlist gates use), queued below.
✅ MEDIA Video rendering - the media pane now renders the Rust backend’s frames instead of a VideoStreamPlayer. MediaFiles widened the video table to ogv/mp4/m4v/mov/mkv/webm/avi/ts/m2ts (Theora moved to the same backend, so there is one video path), and GptyMedia was opened with audio: false at this stage (an unconsumed audio queue is backpressure; the Audio playback item below removed the limitation and opens both streams). Frames are drained in a bounded batch per _process, held in a small PTS-ordered backlog, and presented against Time.get_ticks_usec() (a decoder that runs ahead must not play the clip at 4×); a stale frame is dropped only when a newer one is waiting. The surface is a TextureRect with aspect-preserving center stretch (the old AspectRatioContainer fit without needing dimensions before the first frame), and ImageTexture.update reuses the texture when the size is unchanged. The backend’s probe stays off the GUI thread; status failure clears the pane’s file exactly like a refused path. Verified: test_media_pane.gd decodes the crate’s 64×48 MP4 and the five-second Theora fixture to a non-null 64×48 texture through GptyMedia, the file-table tests cover the expanded list, and the full GUT suite is green (773 tests). Audio for video files is silent until the next item.
✅ MEDIA Audio playback - every audio format in the table (ogg/oga/opus/mp3/wav/flac/m4a/aac) now decodes through gpty-media and is pushed into an AudioStreamGenerator at 48 kHz stereo with a 0.2 s buffer; the native AudioStreamOggVorbis/MP3/WAV loaders are gone, so the pane has one backend. Chunks are pushed while the generator has room, with a bounded 32-chunk local lead: leaving the backend audio queue full would block the worker, and that worker also decodes video. When audio is running, the video clock is the player’s get_playback_position() mapped from the first chunk’s PTS (20 ms present tolerance, 150 ms catch-up drop); without audio the monotonic start clock is used. Volume (-40..6 dB) and mute (mute = -80 dB) are pane settings carried in layout state. A generated 8-bit mono WAV exposed two backend gaps that are now pinned by an_unspecified_source_layout_is_normalized: pcm_u8 was missing from the component set, and a layout-less frame must be normalized on the frame as well as on the resampler (swresample answers InputChanged otherwise). Verified: 15 gpty-media session tests, the GUT audio/A-V/volume tests and the restore test (which now waits for the first chunk instead of assuming synchronous playback), and the full suite (775 tests). Transport controls (pause/seek/loop, paneStatus) are the remaining media item.
✅ MEDIA Playback controls - the media pane has a transport bar (Play/Pause, seek slider, elapsed/total, Loop). Pause pauses the audio generator and freezes the media clock; a video-only pane offsets its monotonic clock on resume, so a pause consumes no media time. Seek uses the backend’s acknowledged seek, clears the pane’s frame/audio queues and the generator buffer, and resets the clock epoch; the slider is marked dragging so the position label does not fight the user. Loop seeks to 0 when the backend finishes (the worker survives EOF) and is saved in layout state; Play on a finished non-looping file restarts it. PaneBody.status_document() is the generic paneStatus hook: TerminalPane returns its process/grid document and MediaPane returns {kind,file,state,position_us,duration_us,paused,loop,volume_db,muted}; the aggregate and single-pane IPC paths both use it, and a pane with no primitives still reports status: null / a “no status primitives” error. Verified: GUT pause (position frozen), seek (position moves to the target), loop (restart observed) and status tests, the updated IPC contract test, and the full suite (777 tests).
✅ MEDIA Format matrix - the Rust backend covers MP4/MOV/MKV/WebM/OGG/WAV/FLAC/MP3/AAC/AVI/MPEG-TS containers, H.264/HEVC/VP8/VP9/AV1/Theora video, and MP3/FLAC/Vorbis/Opus/AAC/ALAC/AC-3/PCM audio (components.txt); the pane exposes it through MediaFiles and the GUT fixtures decode every allowed video codec (H.264, HEVC, VP9, AV1, Theora) plus the audio set. Windows uses vcpkg’s wider build (queued to narrow); the runtime allowlist is identical on every platform. Per-platform decoder verification beyond Linux is the release check, not an open implementation item.
✅ MEDIA Media security notes in AGENTS.md - the media bullet and a new security bullet in AGENTS.md, plus SECURITY.md’s Hardening and Known limitations sections, state the FFmpeg exception honestly (in-process C decoders, no sandbox claim), the pinned minimal LGPLv3 build (--disable-everything --disable-network), the Rust path/demuxer/codec gates, the size/queue/open caps, and that hardware decode is off. The earlier “memory-safe/audited decoders only” wording was replaced by the owner’s FFmpeg decision, so the policy and the code cannot disagree.
✅ MEDIA Standalone by design - the media backend is its own crate and Godot class; no other feature consumes it, its failure is localized to the media pane, and its build dependency (scripts/media-deps) is limited to jobs that compile the extension. The release packaging (also closed) carries the runtime privately, so no other release surface depends on FFmpeg.
✅ CLI The Windows smoke’s CLI occasionally exits 0xC0000374 (STATUS_HEAP_CORRUPTION) on inject - one occurrence so far: run 39262704860 / job 118094242727 on commit 0a6c940, at the gpty state step’s first inject (--text "\"…\\gpty.exe\" state needs-attention"). The CLI printed no stdout and no stderr, so the corruption happened during the call, not at exit; the same SHA’s rerun of the same job passed that step, and the command has passed on every earlier commit. Each CLI command is a fresh process and makes two named-pipe connections (the best-effort version probe, then the command itself), so the plausible class is a Windows named-pipe client teardown race (mio/tokio), not a logic error in inject. Fix direction: keep the failure diagnosable (the smoke now tails the --log-file engine log and carries the pane tail plus the hex status), then reproduce with a Windows-only loop of throwaway pane-status/inject processes (N runs ≈ N connection teardowns) before changing anything; if it reproduces, test the CLI’s runtime shape (process::exit from #[tokio::main] skips a clean runtime shutdown; a current-thread runtime removes worker-thread teardown entirely) and the mio client path. Do not add a retry around the step: the crash is the signal. it did not reproduce before changing anything - no Windows host is reachable from this workspace - and it has not been seen again in the four windows-smoke runs after the recorded failure (39276550729, 39305294761, 39307240548, 39308596924; 39275354197 and 39272179130 failed in GUT, not here). The runtime-shape half was applied rather than left waiting for a second occurrence: crates/gpty-cli/src/main.rs is now #[tokio::main(flavor = "current_thread")]. Every command is one request in a process that exits as soon as it has an answer, and nothing in the CLI or gpty-ipc uses spawn_blocking, block_in_place or tokio::process, so the multi-thread flavor’s workers bought no parallelism - they were the only concurrency around process::exit, which is exactly what a worker torn down mid-allocation needs to corrupt the heap. It also removes the worker pool’s startup from every invocation: measured on Linux, 300 gpty version runs went from 1.037 s to 0.599 s (3.46 → 2.00 ms each). The loop the item asked for is permanent now: scripts/smoke_pane_api.py runs 100 throwaway pane-status processes right after the pane-status checks - ≈ 200 connections, because each process opens the best-effort version probe and then its command - and a nonzero exit fails with its hex status, the pane tail and the engine-log tail, with no retry. Verified live: the Linux smoke passes with the new step, cargo test -p gpty -p gpty-ipc passes, and ./scripts/ci-check --fast is green. The mio client path was not implicated: the only known heap-corruption class there is tokio#6369 (a ScheduledIo use-after-free when a named-pipe client disconnects), fixed by mio#1760 and released in mio 0.8.11, while this tree pins mio 1.2.2 / tokio 1.53.1. The Windows run after this commit is the first live exercise of the stress step; if it ever reports 0xC0000374 again, that step is the amplifier, and a WER LocalDumps crash dump (or Application Verifier’s heap checks on the CLI) is the next evidence to collect before touching the client path.

v0.7.1 - Trust Boundary Hardening

The October 2026 security audit (a read-only review at commit 97eec6b: the Rust crates, the GDScript GUI, cargo audit, semgrep and proofs of concept in an isolated sandbox) found no remote-code-execution or memory-safety bug in the Rust core; its weaknesses are trust boundaries around the control socket and around files the GUI loads. Every item below was re-checked on 2026-10-19 against 5cf373a plus the working tree, and each item says how: re-run (the probe was executed again), measured, or re-read (the code is unchanged and the original proof of concept was not repeated). None had been closed since the audit; two (the profile-consent item and the release-token item) turned out narrower than first reported, and the text says so. The audit also left two memory-growth concerns untested; both are measured here, and one of them (an unterminated escape string) is unbounded and became an item.

This is a patch release ahead of v0.8.0’s UI work because most of these are reachable from data gPTY is documented to treat as untrusted (SECURITY.md: terminal output, files, other panes), and the first two were the project’s own stated boundaries. Items are ordered by reachability and impact. The control-socket finding is split: its documentation half is closed here (the owner decided that gPTY’s goal is for a pane not to be able to drive other panes without permission, so SECURITY.md now says plainly that this is not enforced yet), and its enforcement half is queued in v0.9.0, where the daemon’s client model is decided. The threat model they are judged against is SECURITY.md’s, and where an item changes behaviour that document changes in the same commit.

Done Area Item
✅ CLI A fresh plugin install always fails after review: the destination’s parent is never created - found on the v0.7.0 download (reproduced on HEAD with both the release CLI and a source build): every valid id is owner/name, so move_into_place created only <data>/plugins and renamed the staged clone to plugins/<owner>/<name>, whose parent did not exist; the rename failed with ENOENT after the review had been accepted, so no content moved and no plugins.json record was written — gpty plugin list stayed empty and the sidebar had no profile. The printed error was only moving … into …, dropping the ENOENT, which is why it read as a successful install. move_staged_into now creates the destination’s parent, and report_error prints the full anyhow cause chain ({e:#}). Tests: plugin_move_creates_the_id_parent_directory and plugin_move_replaces_an_existing_install; verified by installing godot-pty/gpty-lazygit on the fixed build — the store record and its Lazygit profile are written, and the row appears in the sidebar. Re-testing also exposed the already-installed early return disarming the staging guard, which leaked a full clone under <data>/staging on every re-install; the guard now stays armed so Drop removes it.
✅ GUI A profile accent leaks across workspaces - reported: activate P1 in W1, create W2, and P1 stays highlighted although W2 never activated it. _active_profile was one app-level string, set on activation and cleared only by reset/delete; workspace switching never re-derived it. Each workspace now records its own profile name (session state; restored tabs start with none), and _apply_active_workspace_view re-derives the accent from the active workspace on every switch, creation, close and restore - a new workspace starts with none, switching back restores that tab’s own, delete/rename clear or rename the name in every workspace, and reset clears it for its own tab only. Test: test_profile_accent_follows_the_active_workspace (W1->P1, new W2 has none, W2->P2, switching restores each, W2’s reset leaves W1’s accent).
⬜ CLI Plugin commands never run the stale-binary check - reported on the v0.7.0 download: the CLI on PATH was 0.5.5 and gpty plugin install godot-pty/gpty-herdr failed with plugins.json is corrupt: record 0: unknown key 'concepts'. The record was written by the v0.7-era CLI (which stores concepts); the older reader rejects keys it does not know. The store was intact and HEAD reads it (profiles/concepts are optional) — installing with HEAD succeeds at revision 05f94806a0a5. No build of 0.5.5 vintage can warn, but the current plugin arm still sits above the generic dispatch and never runs warn_on_version_mismatch, so the reverse skew (newer CLI, older GUI) is also silent, and a newer store then looks corrupt to the older side. Fix direction: probe before plugin commands (bounded, best-effort, silent without a GUI), as mcp already does.
✅ SEC SECURITY.md states the pane / control-socket position the code actually has - the audit showed, by running it, that a program in any pane can read and drive every other pane: the CLI inside a pane derives the default socket path from the inherited XDG_RUNTIME_DIR (the PTY layer strips only GPTY_*), the server’s only gate is the peer’s user id, and GPTY_SECRET is readable from the GUI’s /proc/<pid>/environ because the pane’s shell is the GUI’s child - so inject, pane-run, broadcast, new-pane, kill-pane and pane-read (another pane’s output as well as its input) are open to any process that can run in a pane, root shells and SSH sessions included; the mechanism was re-read on 2026-10-19 with the code unchanged. The owner decided the position: gPTY’s goal is that a process in a pane cannot drive other panes without permission, but that is not enforced today, so the documentation now says exactly that instead of implying protection. Closed (2026-10-19): SECURITY.md’s trust paragraph limits same-user trust to processes outside gPTY and says GPTY_SECRET protects against other users only; the scope list keeps “a lower-trust pane influencing a higher-trust one” as an in-scope goal marked not enforced yet, and the out-of-scope line now reads “processes outside gPTY”; a new Known limitations entry gives the mechanism, the reach, what to do until enforcement lands (keep untrusted code and root or SSH sessions apart by running one outside gPTY, since workspaces share one control socket) and why the fix is a per-pane permission rather than removing the surface; the AGENTS.md IPC trust-model bullet and the gpty-ipc README’s secret bullet carry the same correction, so nothing describes the secret or the GPTY_* stripping as a boundary against a pane. Documentation only, no behaviour change. Enforcement is the v0.9.0 item “Panes are separated from the control surface by a permission the user grants”.
⬜ SEC A pane label that exists in more than one workspace is resolved to the active workspace’s pane, not the caller’s - re-read (Workspace._find_pane_by_label, used by inject, pane-run, pane-read, pane-wait, kill-pane, focus-pane): it searches the active workspace first and then every other one, and its own comment says labels (T1) collide across workspaces and attachment ids do not. The caller is not part of the lookup, so an agent running in workspace A while the user has switched to workspace B and sending pane-run --pane T1 "rm -rf build" delivers it to B’s T1 - a pane the agent never meant, with no error and nothing to show it happened. Ordinary use triggers it (labels restart per workspace, and the user switches tabs while an agent works in the background), so this is a correctness and data-loss hazard before it is a security one, and it is why it leads the release. Fix: a label that matches exactly one pane across all workspaces still resolves; a label that matches more than one is refused with an error that names each candidate’s workspace and attachment id ("T1 exists in workspaces A, B - use a pane id"), and an attachment id always resolves, since ids are unique. Do not pick the active workspace silently any more. list-panes and broadcast already stay within the active workspace and are not changed here. A pane client that learns candidate ids from the error gains nothing it could not already guess from labels; the real boundary is the v0.9.0 workspace scope. Tests: two workspaces that each hold a T1 - an ambiguous label is refused and neither pane receives text; a label unique across workspaces resolves; an id resolves from either workspace; the refusal names both candidates. Extends test_ipc_dispatch_contract.
⬜ SEC A layout, profile or settings file can make the GUI load() an arbitrary resource, and loading one runs its embedded script - re-run (the load) and re-read (the path to it); the one link not run end to end is a real layout file through the whole GUI. A .tres whose sub-resource is a GDScript runs its _init during load() - shown again today on Godot 4.7.2 for both a res:// path and an absolute filesystem path, and ResourceLoader.exists() is true for both, so the existence check in _load_font is not a gate. How a file reaches it: sanitize_tile clamps rows/cols and the attachment id and passes the rest of a tile’s settings through; PaneBody.apply_settings handles a few named keys and sends every other key to _set_typed, which sets any existing String/int/float/bool property the file names; font_path, font_bold_path and font_italic_path are exported strings on TerminalPane (and font_path is written into saved layouts by _get_layout_state, so a shared layout carries it), their setter calls _reload_fonts, and _load_font does load(path). settings.json’s font_path takes the same road through cfg_font_path. Workspace Trust does not intervene, because a font is not a spawn plan. The cause is wider than fonts: the tile schema is closed (position, span, type) but a pane’s properties are an open set, so a file can set every exported property (color_scheme_path makes the pane read any file it names; only parsed as hex colours, so harmless today, but there is no reason for it to be file-settable). Fix, in three parts: (1) a per-pane-type allowlist of the keys a file may set, each with its own validator (type, range, enum, path rule); unknown keys are dropped and counted for a notice rather than applied, and _set_typed becomes the allowlisted path’s coercion instead of the fallback; (2) a font loader that never reaches a resource loader - an extension gate (.ttf, .otf, .ttc, .woff, .woff2) and a typed load (FontFile with data from FileAccess.get_file_as_bytes), falling back to the bundled font on any failure; (3) the same rule for color_scheme_path (a scheme under the schemes directory or a file the user picked in the UI; a layout’s value is validated like a tile path). Tests: a crafted .tres that writes a sentinel file, reached through a layout tile, a profile and settings.json - the sentinel never appears and the bundled font is in use; a key table per pane type (allowed, unknown, wrong type, out of range); the existing unknown-key test extended to the allowlist. The v0.8.1 font item builds its picker and fallback list on this loader rather than writing its own.
⬜ SEC The GUI’s secret and memory are not readable by the programs running in its panes - not tested (a hypothesis to prove first); the audit recovered GPTY_SECRET (16 characters) from the GUI’s /proc/<pid>/environ inside a pane, because the pane’s shell is the GUI’s child and Linux’s usual ptrace restrictions govern attaching, not reading that file. Hypothesis: marking the process non-dumpable at startup (prctl(PR_SET_DUMPABLE, 0) on Linux, ptrace(PT_DENY_ATTACH) on macOS) makes its /proc/<pid>/environ and mem root-owned, so a same-user pane process can neither read the secret nor attach, while the panes’ own shells - separate execed processes, which return to dumpable on exec - stay debuggable. Probe first, in a private sandbox: start the GUI headless with GPTY_SECRET set, and from a pane run cat /proc/$PPID/environ and strace -p $PPID; record both results before and after. Then measure what it costs: no core dumps from the GUI (crash reports, coredumpctl), the CLI’s auto-spawn path, and a developer’s debugger - if the cost is real, ship a documented opt-out; and confirm a pane’s shell and the programs it starts remain dumpable, because a user must still be able to debug their own code. Windows has an analogue (a process DACL withholding PROCESS_VM_READ from same-user children) that nobody has measured: decide at pickup, not before the probe. Outcome if it holds: with a secret set and handed only to the user’s outside clients, a program in a pane cannot recover it, which makes GPTY_SECRET a real barrier against pane programs on Linux and macOS - the one case the closed documentation item above cannot claim. Limits to state in SECURITY.md: it fails if the secret is exported from a shell rc file the panes also read, and it does nothing for the default install, which sets no secret (that case is the v0.9.0 permission item). Tests: a smoke-script step that fails if a pane can read the GUI’s environment; a unit check that the hardening call ran and its failure is a logged diagnostic, never a refusal to start. On landing, update the Known limitations entry. S to probe, S–M to ship.
⬜ SEC Pasted text can close the bracketed-paste bracket early, and the rest reaches the shell as typed input - re-read; build_paste_payload in terminal_pane.gd returns ESC[200~ + text + ESC[201~ for a child that asked for bracketed paste, with the text untouched (the closed bracketed-paste item under v0.5.4 chose that on purpose; the C0 filter only runs on the non-bracketed branch). A clipboard that contains ESC[201~ therefore ends the paste, and whatever follows - including newlines - is read as keystrokes: the classic “pastejacking” shape, reachable from any web page the user copies from. It is not a corner case: an interactive shell’s readline enables the mode by itself (the same v0.5.4 record found bash -i emits ?2004h unprompted), so most panes take this branch by default. Fix: one filter used by both branches, so they cannot drift - drop ESC and every other C0 control except newline and tab, plus DEL; in the bracketed branch that closes the early-terminator hole and removes the escape sequences a bracketed application would otherwise interpret. Tests: the existing test_paste_payload.gd pins the bracketed branch as byte-for-byte and must change with it; add a payload containing ESC[201~;whoami\n and assert the wrapped text holds neither ESC nor a second terminator; a multi-line paste still arrives with its newlines. The v0.9.0 file-drop item inserts paths through this same function and depends on this fix.
⬜ SEC A profile file can claim to be built in or plugin-provided, and consent is then looked up by the name it claims - re-read, and narrower than first reported. _profile_consented in workspace.gd takes plugin_id, revision and builtin from the profile dictionary itself, and ProfileManager.load_profiles() appends user-file dictionaries unchanged - the only place builtin is stamped is the shipped defaults. TrustedStore.is_builtin_approved compares the profile’s name and the app version only, and is_plugin_approved the plugin id and revision only; neither compares the tiles. So a user-level profiles.json entry with "builtin": true and the name of a built-in the user has already approved once at this version is treated as consented with its own tiles, skipping the dialog that would have listed them; the same holds for a plugin_id plus the approved revision of an installed plugin. It needs a prior approval, so it is not a first-contact bypass (that is why it is a medium, not a high), but the approved thing is a specific set of spawn plans and the check never looks at them. find_profile also returns the first name match (built-ins, then plugins, then user), so a name collision resolves in a fixed order; that is fine once trust no longer depends on the name. Fix: derive trust from where the profile was loaded, never from fields inside it - load_profiles() strips builtin, plugin_id and revision from user entries (the loaders that own those fields re-stamp them), and the built-in and plugin paths verify each tile’s plan key (PaneTypes.tile_plan_key) against the approved plans, not the name alone. Tests: a user profile carrying each claimed field with a different tile prompts; an unchanged built-in does not; a plugin profile whose tiles changed under the same revision prompts.
⬜ SEC A program can grow the GUI’s memory without bound by opening an escape string and never closing it - measured today, and it is the first of the audit’s two untested memory-growth concerns. TermGrid::feed hands raw bytes straight to alacritty’s processor, whose vte parser keeps an OSC string’s payload in an unbounded Vec (the 1 KiB cap exists only in vte’s no_std build). The 64 KiB cap added to parser.rs (MAX_OSC_BYTES) covers the line parser only; the engine feeds the grid its own copy of the bytes (feed_grid), so the grid is unprotected. Probe: a grid fed ESC ] 0 ; and then 256 MiB of x in 1 MiB chunks went 3.3 MB → 68 → 132 → 198 → 260 MB resident, linear with no ceiling, and a closing BEL afterwards parsed normally (growth, not a wedge). printf '\033]0;'; yes or a hostile cat file in any pane grows the single GUI process until the OS kills it, taking every pane with it; SECURITY.md lists “denial of service that grows memory without a bound” as in scope, and the per-pane flood throttle bounds the output queue, not parser state. The other concern, the title stack, is bounded: 20 000 pushes of ~1 KB titles added 4.1 MB, flat, consistent with alacritty’s 4096-entry cap - but each entry can be as long as the OSC allowed, so its real bound is this one. Measured for OSC only: DCS, APC, PM and SOS strings were not tried. Fix: lift the line parser’s string-length guard into one small component placed in front of both consumers, so there is one implementation and one test; past the budget it closes the string with a BEL, exactly as the line parser does now. Measure DCS/APC/PM/SOS the same way and extend the guard to any that grow. Tests: the guard emits its terminator at the right offset whatever the chunk boundaries (chunk at every offset of a long string), a following ESC[31mred parses afterwards, and a grid fed 8 MiB of an unterminated OSC stays within a small fixed bound (a counting allocator in the test).
⬜ IPC One malformed request drops the rest of the queued batch, and the dropped callers wait out their timeout - reproduced at the audit and re-run in isolation today. The GUI’s drain loop (Workspace._process) parses each request’s params with JSON.parse_string and guards only the null result; valid JSON that is not an object ([1,2], "x", 5) becomes params, then params.get(...) (the paneWait branch) or the typed ipc_handlers.handle(ws, method, params: Dictionary) raises a call error that aborts the whole function. The requests were already taken off the Rust queue (drain_ipc_requests), so everything after the bad one is neither handled nor answered; probe: three requests, the middle one "[1,2]", handled one and lost the third. Any client that gets through the socket checks can do it, which includes any pane until the v0.9.0 permission item lands. Fix, at both ends: the Rust server rejects params that is neither absent nor an object with a JSON-RPC invalid-params error before queueing (the IPC crate owns the request edge, and a daemon will keep it, so this is not throwaway work next to the v0.9.0 Rust-side validation item, which covers per-method shapes and can build on it), and the GDScript drain keeps a params is Dictionary guard that answers an error and continues with the next request - defence in depth, because GDScript has no try/catch and the loop must never trust its queue. Tests: a Rust server test (an array params gets an error response and the next request is served); a GUT test of the drain with a non-object between two valid requests, both answered.
⬜ IPC A program can create panes without limit through the control socket - read, not run: the newPane handler in ipc_handlers.gd checks no count, and the only IPC bounds are 16 concurrent connections and the 64 KiB request cap, so a loop of new-pane calls (from a pane client, which the v0.9.0 permission item addresses, or from any same-user process, which it does not) starts a PTY, a child process and a grid per call until the GUI or the machine runs out of processes or memory; I also found no tile-count cap on layout restore, so a layout or profile file with thousands of tiles is the same problem from a file. Fix: measure first (how many panes before the GUI stalls, and what each one costs), then put one ceiling on live panes per window as a constant in one place with a generous stated value, enforced where panes are created so new-pane, layoutLoad, profile launch and restore all share it; a refusal names the limit rather than dropping silently, and restore creates panes up to the ceiling and reports the rest. Whether workspaces get their own ceiling is decided at pickup. Tests: the call past the ceiling returns the limit error and starts no process; a layout with more tiles than the ceiling restores up to it and reports the remainder; closing a pane frees room; the ceiling is read from the one constant.
⬜ SEC The Rust code that reads attacker-controlled bytes is exercised only by hand-written cases - the unbounded OSC buffer in the grid was found by reading the code, not by a tool, and a parser that has had one such bug probably has another. Fix: a fuzz/ directory (cargo-fuzz, libFuzzer, nightly, outside the workspace members so the stable build and the gate are untouched) with targets for TermGrid::feed, the line parser’s feed, the IPC request decode (framing plus JSON), and the Markdown and vault link-resolution code in gpty-core and gpty-gdext; each seeded from the byte strings its existing tests already use. A target asserts no panic, no hang (a per-input time limit) and no allocation past the bound the OSC item introduces. CI builds the targets on every run (cargo fuzz build, so they cannot rot) and runs each for a bounded time on a schedule, not on every PR, with SHA-pinned actions as the rest of CI uses; any crash found becomes a committed regression test in the owning crate. The FFI fuzz testing row in v0.12 keeps what this does not cover: fuzzing through the GDExtension boundary itself. Tests: each target builds in CI; a deliberately re-introduced unbounded OSC string is found by the grid target within its time budget (verify once, record the time).
✅ BUILD The release workflow’s build jobs inherit their token scope instead of declaring it - re-read, and the live exposure is smaller than first reported. release.yml has no top-level permissions:; only export-release declares one (contents, id-token and attestations write), while ci.yml declared one and docs.yml declared one only on its single job. The audit could not check the repository default; checked now through the API: default workflow permissions are read and workflows cannot approve pull requests, so today the build jobs hold a read-only token and nothing is exposed - the missing block is a latent trap (a flipped default, a transferred or forked repository) rather than a live hole. Fix: permissions: contents: read at the top of release.yml, writes only on the job that needs them; the lint-workflows job is the place to keep it from regressing if it can express the rule. Also recorded, so it is not re-derived later: every third-party action is pinned to a full SHA, and each SHA resolves into its action’s tagged history (compare API), except dtolnay/rust-toolchain, which reports “diverged” - expected, since that action’s stable revision is a commit its maintainer regenerates (authored by him on 2026-09-03, “toolchain: stable”), so no pin is an impostor. Not a code change, owner action: main has no branch protection and the repository has no rulesets (API: not protected, zero rulesets), and a pushed v* tag starts a workflow that holds id-token and attestation write and creates the release; a tag ruleset limiting who can create v* tags is the cheap guard and is tracked as its own row below. Fixed 2026-10-24: GitHub’s code scanning listed four findings on release.yml - the workflow and its build-linux, build-macos, bundle-macos and build-windows jobs. permissions: contents: read is now the top of both release.yml and docs.yml; code scanning flagged release.yml alone because docs.yml’s one job carried its own block, but docs.yml needed the same top-level block: a second job added there would have inherited the repository default. export-release keeps exactly its three writes, which replace the workflow scope for that job. Guard: scripts/lint-workflows validates the YAML and refuses any workflow without a top-level permissions block, and ci.yml and scripts/ci-check both call that one script so the rule cannot drift from its enforcement. Verified: the script names release.yml and docs.yml and exits 1 on the pre-fix tree, passes on this one, and ci-check --fast is green.
⬜ BUILD No ruleset limits who can create a v* tag, which starts the release workflow - carried from the row above; not a code change. The API reports main unprotected with zero rulesets, while pushing a v* tag runs export-release, which holds contents: write, id-token: write and attestations: write and creates the release, so anyone who can push a tag can publish a release under the project’s identity. Fix: a tag ruleset restricting v* creation to maintainers - a repository setting, done in the GitHub UI or with an owner token that has admin scope; if it cannot be done now it belongs on the launch checklist rather than waiting in the queue. Verification is the API showing the ruleset active; no repository test can cover it.
⬜ BUILD Dependencies are checked for known advisories but not for source, licence or duplicate-version policy - CI runs cargo-audit on the committed Cargo.lock and nothing else; there is no deny.toml, so a git or path dependency, a licence the release cannot ship, or a yanked crate passes. Fix: add cargo-deny to the same dependency job, SHA-pinned like the other actions, with sources limited to crates.io (no git dependencies), a licence allow-list matching what the release ships (the project’s own licence and the bundled FFmpeg’s), yanked = "deny", and a duplicate-version warning rather than an error; keep cargo-audit, which covers the advisory database, and let cargo-deny own the policy. Tests: a scratch branch that adds a git dependency, and another with an unlisted licence, each fail the job (verify once, record it here); main stays green.
⬜ WIKI The vault walk follows symlinks the note reader refuses - re-read. vault::walk uses std::fs::metadata, which follows symlinks, and its comment says so on purpose: a symlinked directory is walked and only the depth cap stops a loop. Opening or saving a note, by contrast, canonicalizes the path and refuses anything that resolves outside the vault (NoteError::Outside). The two disagree: a symlink inside the notes folder to a directory elsewhere (~/.ssh, a project tree) pulls every .md file under it into the index - listing, full-text search and the link graph - although none of them can be opened or saved: the indexer reads each walked file with read_note (no containment check) and vaultSearch on the control socket answers from that index, snippets included, so text from a file outside the vault can come back in a search result. They are read into the index, never written. Fix: make the walk obey the same rule as the reader - a symlink is followed only when it canonicalizes to a path inside the vault root, outside ones are skipped (and counted for the index status), and loops are caught by a visited set of canonical directories instead of relying on the depth cap alone. Symlinks that stay inside the vault keep working, which matters for people who link folders within one. Tests: a symlinked file and a symlinked directory pointing outside (absent from the index), the same pointing inside (present, once), and a loop (terminates, each note once).
⬜ SEC unsafe is not fenced, and only the media path guards against a panic crossing the GDExtension boundary - measured by search: outside tests, unsafe appears in gpty-core (cli.rs, pty.rs: libc calls such as geteuid, kill and one block in the spawn path), gpty-ai/binary.rs (geteuid), gpty-ipc/server.rs (peer credentials and the Windows token and pipe calls) and gpty-gdext/lib.rs; gpty-media and gpty-cli have none outside tests. No crate declares that, so a later change can add one unnoticed. catch_unwind appears only in gpty-gdext/media_api.rs and the engine’s panic probe; no panic = profile setting exists, so a panic in any other entry the GUI calls with caller data aborts or unwinds into the whole process. Fix: #![cfg_attr(not(test), forbid(unsafe_code))] on gpty-media and gpty-cli (their tests set environment variables); #![deny(unsafe_op_in_unsafe_fn)] and the clippy::undocumented_unsafe_blocks lint on the rest, which the existing clippy -D warnings gate then enforces, and a // SAFETY: comment on each block stating the invariant (most are one line); then list the #[func] entries that take caller-controlled data and either wrap them like the media path or show they cannot panic. Tests: adding unsafe to a forbidden crate fails the build and an undocumented block fails clippy (verify each once by a scratch edit); a malformed input to each entry still returns an error.
⬜ SEC A store written before the owner-only write path stays world-readable until it is next saved - observed on the maintainer’s machine: layout.json is 0644, written by an older build, while concepts.json and the scrollback database written since are 0600. The scrollback database is tightened every time it opens; the JSON stores are not, so a store that is never saved again keeps its old mode, and the user:// directory itself is 0755 (the engine creates it). On a single-user machine with a private home this is harmless; on a shared machine, or with a group-readable home, the stores are readable by others (pane_env.json is the one that would hold user-typed secrets). Fix: when BasePersistenceManager reads a store, tighten it to STORE_UNIX_MODE if it is looser (the same call and constant as the write path, Unix only, never through a symlink); on startup tighten the user:// root to 0700 when this user owns it and it is not a symlink. A filesystem that refuses the change logs once and loads normally, as the history store does. Windows needs nothing (the profile’s ACLs are inherited). Tests: a 0644 store in the test user dir is 0600 after load with its contents unchanged; a symlinked store is not chmod’d through; the root directory is 0700 afterwards; a read-only filesystem logs once and the store still loads.
⬜ SEC What gets logged is unwritten policy, and agent-backend stderr is logged verbatim - gpty-ai logs each stderr line from the agent backend at warn (cli.rs, omp.rs), and those programs can print prompts, file paths or credentials in an error. The other log::*! calls I found carry ids and paths, not pane output or injected text, but nothing states that or fails if it changes. Not inspected: whether the files in the logs directory under the user data directory hold anything sensitive (AGENTS says Godot’s file logging is off by default, yet the directory exists and is 0755). Fix: write the rule down in AGENTS.md and SECURITY.md (pane output, injected or pasted text, environment values and agent stderr are never logged above debug and never written to a file by default; ids and paths are fine); bound and strip the backend stderr lines before logging; create any log file 0600 (inside the 0700 directory from the stores item); and open the existing logs once and record what they contain. Tests: a check in ci-check fails when a log::*! call formats an input or output named text, data, output or payload without an allow-comment; a backend stderr line past the cap is truncated.
✅ BUILD The Linux release’s CLI ships non-executable - measured on the v0.7.0 download: gpty is 0644 while gpty-gui is 0755, so ./gpty version fails with Permission denied (os error 13) and the bundle cannot provide a working CLI at all. The Linux packaging job copies the CLI out of actions/download-artifact, which does not preserve file modes; the GUI is exported in that same job, so it keeps +x. The job now chmod 0755 s the CLI before tarring. Verified: the defect is reproduced on the v0.7.0 tar contents and the workflow change passes the fast CI workflow lint; the next release’s tar is the end-to-end proof.
✅ GUI The plugin catalog row disappears once a plugin profile is installed - reported: after installing gpty-lazygit, the row that led to the plugin catalog was gone, so there was no in-app way back to discover another plugin. update_profile_list appended the hint only when no plugin profile was present. It is now always the last row of the Profiles section (plugin profiles above it) and still counts toward the section’s height cap. Tests: test_catalog_hint_row_is_last_with_and_without_plugin_profiles; the plugin-row test also asserts the hint follows it.
✅ BUILD CI’s dependency audit failed with a permissions error instead of naming the advisory - found 2026-10-24 on the first red run after RUSTSEC-2026-0386 (tokio 1.53.1, unsound: the AsyncFd constructors violate I/O safety) entered the advisory database on 2026-10-13. Two faults stacked: the pinned rustsec/audit-check publishes its finding as a Check Run, which needs checks: write, and the least-privilege sweep (01b26e7) left this job with only the inherited contents: read - so a clean audit never called the API (the job had been green for six weeks) and the first real finding died with “Resource not accessible by integration” one line after “Found 1 unsound”, never printing the advisory. Granting the scope would not have been enough either: a fork PR’s token is read-only, so the action can never create a check there. Fix: the job installs cargo-audit with cargo install --locked --version 0.22.2 and runs cargo audit --deny warnings directly - no token, no write scope, works on forks, and the advisory text is the failure. The version is pinned because taiki-e/install-action has no cargo-audit entry and an unpinned tool could change its policy under a green build; the cost is a couple of minutes on a job that runs in parallel anyway. scripts/ci-check uses the same flag, because plain cargo audit treats unsound as an allowed warning: the local gate passed on exactly the tree that turned CI red, which is how a red CI arrived with no local warning. Cargo.lock moves tokio 1.53.1 to 1.54.1 (patched from 1.53.3); nothing in crates/ calls AsyncFd, so the unsound path was never reachable here. Tests: the audit is clean under the new flag, and the local full suite now rejects any warning CI would reject.
⬜ SEC A workspace file can choose a restored terminal’s directory without a trust decision - found 2026-10-21 while implementing per-workspace directories. WorkspaceStore.load reads cwd, _init_workspaces/_restore_into apply it to every terminal, and _tiles_untrusted_without_consent/PaneTypes.tile_plan_key gate only the program and argv - so a crafted workspaces.json can repoint an already-approved profile at any existing absolute directory with no prompt. The directory is not itself executable, and the spawn guards are unchanged: a relative program is still refused, a bare name still resolves through the inherited PATH, and an empty PATH entry that would resolve into the cwd is refused (pty::validate_program). The exposure is the next step: a tool that trusts its cwd (git’s core.fsmonitor or hooks, an agent reading repo instructions) runs in attacker-chosen content. Reachability requires writing the user’s workspaces.json or importing a crafted one; same-UID processes are trusted, so the realistic vector is a shared or synced dotfiles copy. Decided 2026-10-24: cwd is a trust input only where the plan is already untrusted. A tile that names a program or argv the user has not approved puts the resolved directory into the Workspace Trust details and into the approved record, so an approved omp update && exec omp cannot be repointed at attacker-chosen content by editing workspaces.json; a plain shell in a file-supplied directory keeps restoring without a prompt, because a directory is not executable and asking at every launch would train the user to dismiss the one dialog this row exists to protect. The record is workspace-scoped rather than per tile because cwd sits on the workspace (workspace.gd:1003-1004), and it keys on the sanitized absolute directory actually handed to the spawn, so a relative or stale value cannot alias an approval. A directory change through the folder button re-asks only for a workspace whose tiles are already untrusted. SECURITY.md gains the accepted-risk paragraph for the plain-shell case, naming the same-UID and synced-file reachability, and the v0.8.0 pane-level override stays in the user-owned store so a directory never becomes a tile or profile field. Tests: a crafted workspace file whose cwd was never chosen prompts when a tile also names an unapproved program or argv, and the dialog shows the directory; the same file with only a plain shell restores without a prompt and starts there (the accepted case); changing the directory through the picker re-asks for an untrusted workspace and not for a plain one; a profile or plugin tile still cannot carry a cwd; the picker flow persists and restores.
⬜ SEC Re-run the audit on the fix build and cover what it did not reach - release-time work. Re-run each proof of concept against the final build and record the outcome: the two-pane control probe, a crafted resource reached through a real layout file (the link the audit did not run end to end), the paste case, the unterminated-string flood and the malformed-request batch - kept as scripts under scripts/ so the next review starts from them instead of from prose. Then extend coverage to what the audit listed as not examined: the Windows runtime including named-pipe permissions, the plugin install and update-check network paths beyond reading the code, AI backend request handling, and yanked-crate status. Finally, review what landed after the audited commit, which no one has looked at adversarially: the in-process FFmpeg decoders in gpty-media on untrusted files (the largest new memory-unsafe surface, and the one place SECURITY.md accepts an exception), the vault agent verbs and vaultWeight, and the close-confirmation path. One observation to start from, re-read on 2026-10-19: vaultSearch and vaultRead take the vault root from the caller (vault_path, checked only to be an absolute directory), so any control-socket client can index, search and read Markdown under any directory it names, not just the user’s vault; a same-user process could read those files anyway, but a prompt-injected agent confined to gPTY’s tools could not, so the review should decide whether the verbs accept only vault roots the user has registered (a user-owned list, the WorkspaceStore pattern). Fuzzing is the right tool for the first and for the grid’s feed; the FFI fuzz testing row in v0.12 covers the grid interface and should name both.
⬜ BUILD A release offers three downloads, not thirty-six files - v0.7.0 published 36 assets: the three platform archives plus every GDExtension and pinned FFmpeg library copied out of the build artifacts (dist/gdextension/*), which each archive already contains. Nothing consumed them (update_checker.gd reads a version string and nothing else), they made the Releases page look like a build-artifact dump, and they made the project’s only install signal meaningless: v0.7.0’s “73 downloads” were 7 real bundles. export-release no longer stages or uploads them, so a release is the three archives plus SHA256SUMS, and the checksum, attestation and upload lists cannot disagree about the asset set. No repository test covers a release job; the check is the next tag’s asset list (the three platform archives, SHA256SUMS, and install.sh once that row has shipped) and the same three bundles still containing their libraries.
✅ CORE Scrollback’s last line survives a pane close, and the parallel test is no longer a race - the CI run for d52e5ad failed only history::tests::pushed_lines_reach_the_store_and_a_flush_waits_for_them on the parallel cargo test --workspace (serial passed; left 50, right 51). Two faults: the writer could take an empty queue, then see closed after a push+close and break with that line still queued; and PaneHistory::drop returned without waiting for the writer, while the test polled for up to 5 s. Local scheduling hid both; CI’s parallel runner hit the break. The writer now re-drains a closed queue before breaking (once closed is observed, no later push is accepted), and Drop joins the writer so the test asserts the store directly instead of polling. Verified: 20 repeated runs, both CI test commands (serial + parallel), fmt and clippy.
⬜ DOCS The v0.7.1 changelog names the terminal selection fixes - release-time work: write docs/content/docs/changelog/v0.7.1.md and link it from CHANGELOG.md (pages are written when the release is cut), covering these user-visible changes from the 2026-10-26 fix: a bare Ctrl or Shift no longer snaps a scrolled-back pane to the bottom, so Ctrl+Shift+C on scrollback works; the selection now stays on its text when the view scrolls (wheel, PageUp/PageDown, search jump) and when output arrives, instead of holding a fixed screen row; copy reads grid rows, so a selection scrolled out of view still copies in full; wrapped lines copy joined instead of gaining a newline at each wrap; and the selection is dropped when the terminal width changes or an app switches to the alternate screen (alacritty’s rule - the old highlight silently survived and landed on different text). Evidence: term::tests::selection_* (view scroll, output at the bottom, output while scrolled back, off-screen copy, mid-drag rotation), the test_copy_routing / test_keyboard / test_gdext_ffi GUT coverage, a live-pane smoke with a real shell, and a full ./scripts/ci-check run.