Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Capability tokens are no longer minted from a silently-failed CSPRNG read. If `/dev/urandom` could not be opened or read the error was discarded and the buffer kept its zero initializer, so every token became 32 zeros — a predictable credential for reattach and for TCP agents. `mint_token` now propagates the failure, which surfaces as a session-spawn error rather than a weak token. Most likely to have bitten agents run inside a container or bubblewrap sandbox with no `/dev` bound.
- A panic no longer leaves the terminal unusable. The relay client installs a panic hook that leaves the alternate screen, disables raw mode and mouse reporting, and pops kitty flags before the panic report prints, so the message lands on the normal screen instead of a shell with no echo. `SIGTERM`/`SIGHUP` are now handled the same way as a detach rather than killing the client mid-alternate-screen.
- Fixed a crash when the command bar's slash-command popup was open on a short terminal: the popup claimed one row per match (up to 8) without checking how many rows were left above the bar, underflowing the row calculation and panicking inside ratatui's buffer indexing. The popup now yields rows to the bar, and terminals below 20x12 render a "terminal too small" placeholder instead of attempting a layout the solver can't satisfy.
- Fixed the client hanging after detach until an extra keypress, which was then swallowed. The stdin reader runs as a blocking task that can't be cancelled, and dropping the tokio runtime waits for it; the client now exits directly once the terminal is restored.
- Fixed `ctrl`/`alt` chords being typed as literal characters in the chat pane, command bar, new-session dialog, and settings editor — crossterm reports `ctrl-c` as `Char('c')` with a modifier set, so it inserted a `c`. Shifted characters are still text. Search mode already had this guard.
- Tokenless IPC clients are now rejected on platforms without `SO_PEERCRED` (macOS, BSD) instead of being granted operator capabilities. The "same-uid peer is the operator" shortcut is only sound where the kernel can attest the peer's uid; elsewhere a connection is anonymous and must present a token, as TCP already did. Relatedly, the default socket path now falls back to `TMPDIR` off Linux rather than `/run/user/<uid>`, which doesn't exist there.
- A pipe's `Summarize` relay no longer stalls indefinitely against an unresponsive endpoint; the request now carries a 60s timeout, matching the bounds the orchestrator paths already set.
- Fixed Escape keypresses being swallowed when followed quickly by another key (crossterm merges them into Alt+char): the ESC prefix is now forwarded to the PTY. Most visible in vim, where Esc then `:wq` typed fast left the session in insert mode with `:wq` inserted into the buffer.
- `PageUp`/`PageDown` now scroll linkshell's captured scrollback in claude/codex panes (matching the mouse wheel) instead of being sent to the TUI, which ignored them.
- Fixed the whole UI shaking when a codex session flapped in and out of WAITING: the status panel's waiting-preview row now shrinks with a few seconds of hysteresis, breaking the resize→repaint→state-flap feedback loop.
Expand Down
45 changes: 40 additions & 5 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,10 @@ impl App {
return Err(anyhow::anyhow!("Maximum {} sessions reached", MAX_SESSIONS));
}

// Mint before touching any state: a CSPRNG failure must not leave a
// half-registered session behind (next_id bumped, pane slot claimed).
let token = crate::auth::mint_token()?;

let id = self.next_id;
self.next_id += 1;

Expand Down Expand Up @@ -785,7 +789,6 @@ impl App {
// to the human and keep full operator rights (so orchestrator scripts
// run inside a linkshell shell can manage pipes / create sessions);
// AI agent sessions are confined to worker capabilities.
let token = crate::auth::mint_token();
self.tokens.insert(token.clone(), id);
let caps = if matches!(kind, SessionKind::Shell) {
crate::auth::operator_caps()
Expand Down Expand Up @@ -2650,7 +2653,10 @@ impl App {
} else {
None
}
} else if transport == Transport::Unix {
} else if transport == Transport::Unix && crate::ipc::PEER_UID_VERIFIED {
// Tokenless peers are trusted only because SO_PEERCRED already
// confirmed they run as us. Where that check is unavailable the
// connection is anonymous, so it falls through to the reject below.
if name.as_ref().map(|n| !n.is_empty()).unwrap_or(false) {
match self.spawn_headless_session(name.unwrap_or_default(), group) {
Ok(id) => {
Expand All @@ -2664,7 +2670,7 @@ impl App {
Some((None, crate::auth::operator_caps()))
}
} else {
// TCP with no token → reject
// TCP with no token, or a Unix peer we cannot attribute → reject.
None
};

Expand Down Expand Up @@ -4394,7 +4400,7 @@ impl App {
}

pub fn chat_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
use crossterm::event::{KeyCode, KeyModifiers};
match key.code {
KeyCode::Esc => {
self.chat_selection = None;
Expand Down Expand Up @@ -4458,7 +4464,14 @@ impl App {
}
KeyCode::PageUp => self.chat_scroll_up(10),
KeyCode::PageDown => self.chat_scroll_down(10),
KeyCode::Char(c) => {
// Only bare/shifted characters are text. crossterm reports Ctrl+C as
// Char('c') with CONTROL set, so an unguarded arm typed a literal
// "c" into the message — likewise "u" for Ctrl+U, "w" for Ctrl+W.
// Matching the guard already used by search mode in main.rs.
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
self.chat.input.insert(self.chat.cursor, c);
self.chat.cursor += c.len_utf8();
self.chat.history_pos = None;
Expand Down Expand Up @@ -6680,6 +6693,28 @@ mod tests {
assert_eq!(app.chat.history, vec!["first", "second"]);
}

#[test]
fn chat_ignores_control_and_alt_chords_instead_of_typing_them() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = make_app();
for c in "hi".chars() {
chat_press(&mut app, KeyCode::Char(c));
}

// crossterm reports these as Char(_) with a modifier set. Inserting them
// put a literal "c"/"u"/"b" into the message.
app.chat_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
app.chat_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.chat_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT));
assert_eq!(app.chat.input, "hi");
assert_eq!(app.chat.cursor, 2);

// Shifted characters are still ordinary text.
app.chat_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
assert_eq!(app.chat.input, "hiX");
assert_eq!(app.chat.cursor, app.chat.input.len());
}

#[test]
fn chat_slash_opens_a_filtering_palette_and_tab_completes() {
use crossterm::event::KeyCode;
Expand Down
24 changes: 19 additions & 5 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,26 @@ pub fn council_caps() -> CapSet {
[Capability::SignalState].into_iter().collect()
}

pub fn mint_token() -> String {
/// Mint a 128-bit capability token, hex-encoded.
///
/// Fails closed: if the system CSPRNG is unavailable the error is propagated
/// rather than swallowed. Swallowing it (the previous `.ok()`) left `buf` at
/// its zero initializer, so every token became 32 zeros — a fully predictable
/// credential. That is reachable in practice: a container or bubblewrap
/// sandbox with no `/dev` bound, a seccomp filter, or fd exhaustion all make
/// the open/read fail while the process otherwise runs fine.
pub fn mint_token() -> std::io::Result<String> {
let mut buf = [0u8; 16];
// /dev/urandom keeps the dep surface at zero; swap for `getrandom` if preferred.
use std::io::Read;
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.ok();
buf.iter().map(|b| format!("{:02x}", b)).collect()
std::fs::File::open("/dev/urandom")?.read_exact(&mut buf)?;
// Defence in depth: a short read can't get here (read_exact errors), but an
// all-zero buffer would be indistinguishable from the old bug, so reject it.
if buf.iter().all(|&b| b == 0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"CSPRNG returned all zero bytes",
));
}
Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
}
30 changes: 23 additions & 7 deletions src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,27 @@ pub enum Transport {
Tcp,
}

/// Whether this platform lets us verify a Unix peer's uid (SO_PEERCRED).
///
/// The tokenless "same-uid peer is the operator" shortcut is only sound when
/// this holds. On platforms where it doesn't, a connecting process is anonymous
/// and must present a token like any TCP client — otherwise anything that can
/// reach the socket is handed `operator_caps()`, which includes `InjectInput`
/// and `CreateSession`, i.e. arbitrary command execution as the user.
pub const PEER_UID_VERIFIED: bool = cfg!(target_os = "linux");

fn runtime_dir() -> std::path::PathBuf {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }))
// /run/user/<uid> is a Linux (systemd) convention. Falling back to
// it unconditionally meant the default socket path could not be
// created at all on macOS/BSD, where TMPDIR is the equivalent.
if cfg!(target_os = "linux") {
std::path::PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }))
} else {
std::env::temp_dir()
}
});
let dir = base.join("linkshell");
let _ = std::fs::create_dir_all(&dir);
Expand Down Expand Up @@ -60,17 +76,17 @@ pub fn spawn_listener(tx: mpsc::Sender<AppEvent>, config: Arc<Config>) {
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
eprintln!("[linkshell] IPC socket: {}", path);
write_last_socket(&path);
if !PEER_UID_VERIFIED {
eprintln!(
"[ipc] warning: SO_PEERCRED unavailable on this platform; \
tokenless clients will be rejected"
);
}
while let Ok((stream, _)) = listener.accept().await {
#[cfg(target_os = "linux")]
if peer_uid(&stream).ok() != Some(unsafe { libc::getuid() }) {
continue;
}
#[cfg(not(target_os = "linux"))]
{
eprintln!(
"[ipc] warning: SO_PEERCRED unavailable on this platform, skipping uid check"
);
}
let tx = tx.clone();
let (r, w) = stream.into_split();
tokio::spawn(handle_stream(
Expand Down
22 changes: 16 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async fn attach_existing(requested: Option<String>) -> anyhow::Result<()> {
"[linkshell] detached — sessions keep running; run `linkshell -r {id}` to reattach"
);
}
result
reattach::exit_after_detach(result)
}

/// Spawn a fresh detached server with a new session id, then attach the relay
Expand Down Expand Up @@ -161,7 +161,7 @@ async fn launch_and_attach(name: Option<String>) -> anyhow::Result<()> {
"[linkshell] detached — sessions keep running; run `linkshell -r {id}` to reattach"
);
}
result
reattach::exit_after_detach(result)
}

/// Spawn `linkshell --server` as a daemon: new session (setsid) so it has no
Expand Down Expand Up @@ -310,7 +310,7 @@ async fn run_server() -> anyhow::Result<()> {
std::env::var("LINKSHELL_SESSION_ID").unwrap_or_else(|_| reattach::new_session_id());
let session_name = std::env::var("LINKSHELL_SESSION_NAME").unwrap_or_default();
let ipc_socket_path = ipc::socket_path(&config);
let reattach_token = auth::mint_token();
let reattach_token = auth::mint_token()?;
let reattach_socket_path = reattach::reattach_socket_from_ipc(&ipc_socket_path);
reattach::write_session_entry(&reattach::SessionEntry {
id: session_id.clone(),
Expand Down Expand Up @@ -958,7 +958,10 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) {
KeyCode::Backspace => {
app.new_session_backspace();
}
KeyCode::Char(c) => {
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.new_session_input(c);
}
_ => {}
Expand Down Expand Up @@ -1010,7 +1013,10 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) {
KeyCode::End => {
app.command_cursor_end();
}
KeyCode::Char(c) => {
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.command_input_char(c);
}
_ => {}
Expand Down Expand Up @@ -1214,7 +1220,11 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) {
}
app.mode = AppMode::CommandResult;
}
KeyCode::Char(c) if app.settings_state.editing => {
KeyCode::Char(c)
if app.settings_state.editing
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
let cursor = app.settings_state.edit_cursor;
app.settings_state.edit_buf.insert(cursor, c);
app.settings_state.edit_cursor += c.len_utf8();
Expand Down
13 changes: 11 additions & 2 deletions src/pipe.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};

use tokio::sync::mpsc;
use tokio::task::JoinHandle;
Expand All @@ -8,6 +8,9 @@ use crate::config::Config;
use crate::events::AppEvent;
use crate::session::Session;

/// Wall-clock bound on a pipe's summarize request, covering connect + response.
const SUMMARIZE_TIMEOUT: Duration = Duration::from_secs(60);

#[derive(Debug, Clone)]
pub enum ExtractMode {
LastBlock,
Expand Down Expand Up @@ -146,7 +149,13 @@ async fn summarize_for_relay(
max_tokens: u32,
config: &Config,
) -> anyhow::Result<String> {
let client = reqwest::Client::new();
// reqwest has no default timeout, so an unresponsive endpoint would wedge
// this relay indefinitely. The orchestrator paths already set one
// per-request; this is a short summarize of a bounded amount of text, so it
// gets a tighter bound.
let client = reqwest::Client::builder()
.timeout(SUMMARIZE_TIMEOUT)
.build()?;
let auth = crate::config::AnthropicAuth::from_env().ok_or_else(|| {
anyhow::anyhow!("no Anthropic credentials (set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)")
})?;
Expand Down
4 changes: 2 additions & 2 deletions src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,8 @@ mod tests {

#[test]
fn minted_tokens_are_32_hex_chars_and_unique() {
let a = crate::auth::mint_token();
let b = crate::auth::mint_token();
let a = crate::auth::mint_token().unwrap();
let b = crate::auth::mint_token().unwrap();
assert_eq!(a.len(), 32);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b);
Expand Down
Loading
Loading