OSC and String Sequences

CSI sequences carry numbers. String sequences carry text: window titles, URLs, clipboard contents, file paths, base64 payloads, and images. They are structurally different — unbounded length, a distinct terminator, and arbitrary bytes — and they are where the security problems live.

Four families:

FamilyIntroducerPurpose
OSC Operating System CommandESC ] (0x1b 0x5d)Title, colors, hyperlinks, clipboard, cwd, notifications
DCS Device Control StringESC P (0x1b 0x50)Sixel images, terminfo queries, ReGIS
APC Application Program CommandESC _ (0x1b 0x5f)The kitty graphics protocol
PM / SOS Privacy Message / Start of StringESC ^ / ESC XEssentially unused; consume and discard

The Terminator Problem

A string sequence ends with ST (String Terminator), which has two spellings, and OSC accepts a third:

   ST, 7-bit form:   ESC \      =  0x1b 0x5c    ← the standard, use this
   ST, 8-bit form:   0x9c                        ← a UTF-8 continuation byte; DO NOT honor it
   BEL:              0x07                        ← xterm's OSC-only shortcut, extremely common

Your OSC state must accept both ESC \ and BEL. Programs emit both, roughly evenly; printf '\033]0;title\007' and printf '\033]0;title\033\\' are equally common in the wild.

Warning: Do not honor the 8-bit 0x9c as ST. In UTF-8 it is a continuation byte, and honoring it truncates any string containing a character whose encoding includes 0x9c. Same reasoning as 8-bit C1 controls.

Safety valves. A program that emits an OSC and then crashes leaves your parser stuck in OscString, swallowing everything. Real terminals defend with:

  1. A length cap (~4 KB is typical; xterm's is similar). Exceed it → discard and return to Ground.
  2. ESC aborts — the global rule. ESC followed by anything other than \ restarts parsing.
  3. Optionally, terminate on \n or \r (a heuristic; xterm does not, but it prevents a whole class of hang).

OSC Commands You Should Implement

Format: OSC <number> ; <payload> ST

PsNamePayloadNotes
0Set icon name and window titletextThe most common by far
1Set icon name onlytext
2Set window title onlytext
4Set/query a palette color<index>;<spec> or <index>;?rgb:RRRR/GGGG/BBBB
7Report the working directoryfile://host/pathHow terminals open a new tab in the same directory
8Hyperlink<params>;<URI>See below
9Desktop notification (iTerm2 ext.)text
10/11/12Default fg / bg / cursor colorspec or ?? queries
52Clipboard<selection>;<base64>Security-sensitive — see below
104Reset palette color(s)<index> or empty
110/111/112Reset fg / bg / cursor color—
133Semantic prompt marksA/B/C/DShell integration: jump-to-prompt, command status
777Notification (urxvt-style)notify;title;body
printf '\033]0;My Window Title\007'                    # look at your title bar
printf '\033]11;?\033\\'; read -r -d '\' bg; echo "bg = $bg"
printf '\033]7;file://localhost/tmp\033\\'             # report cwd = /tmp; open a new tab
printf '\033]4;1;?\033\\'                              # query palette entry 1

   OSC 8 ; <params> ; <URI> ST     <link text>     OSC 8 ; ; ST
   ────────────────────────────    ───────────     ───────────
     start a link                   the text        end the link

   params are key=value pairs separated by ':', e.g.  id=xyz
printf '\033]8;;https://example.com\033\\Click me\033]8;;\033\\\n'
# In a supporting terminal, "Click me" is a clickable link.

The implementation problem

A hyperlink is a property of a range of cells, but cells store style, and style must stay small and Copy. Putting a String URI in every cell would put a heap allocation in every cell — the exact problem the workspace critique warned about.

The solution: intern.

#![allow(unused)]
fn main() {
/// Hyperlinks are stored once and referenced by a small id, so `Style` stays
/// Copy and small. Putting the URI in the cell would mean one heap allocation
/// per cell — 1,920 for an 80×24 screen.
pub struct HyperlinkTable {
    links: Vec<Hyperlink>,                 // index 0 is reserved for "no link"
    by_key: HashMap<(String, String), u16>, // (id, uri) → index, for deduplication
}

pub struct Hyperlink {
    pub id: String,      // the OSC 8 `id=` param, for grouping split links
    pub uri: String,
    pub refcount: u32,   // cells referencing it; 0 means collectable
}
}

Design points:

  • id= groups a link split across cells or lines. A link that wraps must highlight as one unit on hover; the id is how you know two ranges are the same link.
  • Refcount or periodically sweep. A long session visiting many links leaks otherwise. Sweeping when the table exceeds N entries is simpler than exact refcounting and is what most terminals do.
  • Bound the URI length (~2 KB). An unbounded URI is a memory-exhaustion vector.
  • Validate the scheme. Only allow http, https, mailto, ftp, file. A javascript: or data: URI in a terminal hyperlink is a genuine attack — and the user cannot see the URI before clicking.
#![allow(unused)]
fn main() {
#[test]
fn hyperlinks_are_interned_not_stored_per_cell() {
    let mut t = Terminal::new(3, 40);
    t.advance(b"\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\");
    let row = t.screen().row(0);
    let id = row.cell(0).style().hyperlink;
    assert_ne!(id, 0);
    // Every cell of the link shares ONE id.
    for i in 0..9 { assert_eq!(row.cell(i).style().hyperlink, id); }
    // The cell holds a u16, not a String.
    assert!(std::mem::size_of::<Style>() <= 16);
    assert_eq!(t.hyperlinks().get(id).unwrap().uri, "https://example.com");
}

#[test]
fn dangerous_uri_schemes_are_rejected() {
    // The user cannot see the URI before clicking; a javascript: or data: link
    // is a real attack surface.
    let mut t = Terminal::new(3, 40);
    t.advance(b"\x1b]8;;javascript:alert(1)\x1b\\click\x1b]8;;\x1b\\");
    assert_eq!(t.screen().row(0).cell(0).style().hyperlink, 0,
               "unsafe schemes must not produce a link");
}
}

OSC 52: Clipboard — The Dangerous One

   OSC 52 ; <selection> ; <base64 data>  ST     ← WRITE the clipboard
   OSC 52 ; <selection> ; ?              ST     ← READ the clipboard

<selection> is c (clipboard), p (primary), s, or a combination.

Why it exists: it lets a program running over ssh, inside tmux, on a remote machine, set your local clipboard. That is genuinely useful — vim yanking to the system clipboard over SSH works only because of OSC 52.

Why it is dangerous:

RiskDetail
WriteAny process that can write to your terminal can set your clipboard. cat evil.txt can put rm -rf ~ in your clipboard, and your next paste runs it.
ReadFar worse. A remote process could exfiltrate your clipboard — passwords, tokens, anything you copied.

The consensus policy, which you should implement:

#![allow(unused)]
fn main() {
pub enum ClipboardPolicy {
    /// Never touch the clipboard. Safest.
    Deny,
    /// Allow writes, deny reads. THE DEFAULT — matches xterm's and most modern
    /// terminals' shipping configuration.
    WriteOnly,
    /// Prompt the user for each operation.
    Ask,
    /// Allow everything. Do not ship this as a default.
    Allow,
}
}
  • Reads are denied by default, everywhere. xterm shipped read enabled once; it was assigned a CVE.
  • Cap the payload (~100 KB decoded). Unbounded base64 is a memory-exhaustion vector.
  • Validate the base64 strictly. Reject invalid input rather than accepting a lenient decode.
#![allow(unused)]
fn main() {
#[test]
fn clipboard_reads_are_denied_by_default() {
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b]52;c;?\x1b\\");
    assert!(t.take_replies().is_empty(),
            "OSC 52 read must not respond under the default policy");
}

#[test]
fn clipboard_writes_are_size_capped() {
    let mut t = Terminal::new(3, 20);
    let huge = base64_encode(&vec![b'A'; 10_000_000]);
    t.advance(format!("\x1b]52;c;{huge}\x1b\\").as_bytes());
    assert!(t.take_clipboard_request().is_none(), "oversized payloads are dropped");
}
}

OSC 133: Semantic Prompts (Shell Integration)

   OSC 133 ; A ST     ← prompt START
   OSC 133 ; B ST     ← prompt END / command input starts
   OSC 133 ; C ST     ← command output starts
   OSC 133 ; D ; <exit code> ST   ← command finished

The shell emits these (via PROMPT_COMMAND/precmd hooks), and the terminal records where each prompt, command, and output region lives in the grid. That enables:

  • Jump to the previous/next prompt (Ctrl+↑ in several terminals)
  • Select the output of one command
  • Mark failed commands in the gutter
  • "Copy last command output"

Cheap to implement — mark the row and column — and a genuinely nice feature. It is also the model for how terminal features should be added: the shell provides semantics the terminal cannot infer.

# Try it in bash:
PS1='\[\e]133;A\e\\\]$ \[\e]133;B\e\\\]'

DCS: Device Control Strings

   ESC P <params> <intermediates> <final>  <data...>  ST

Streamed via hook / put / unhook, because payloads can be megabytes.

SequencePurpose
DCS q <sixel data> STSixel graphics — a bitmap image encoded as printable characters
DCS + q <hex> STXTGETTCAP — query a terminfo capability
DCS $ q <setting> STDECRQSS — request the current value of a setting (e.g. SGR)
DCS = <n> s STtmux passthrough

For this curriculum: implement hook/put/unhook in the parser, and discard DCS payloads in the core with a bounded counter. Sixel is a large project of its own; getting the streaming interface right is what matters.

Note: Discarding is not the same as ignoring. Count the bytes and cap them — a DCS with no terminator would otherwise stream forever into a Vec. The whole point of the streaming interface is that the consumer enforces the bound.


APC: The kitty Graphics Protocol

   ESC _ G <key=value,...> ; <base64 payload> ST

The modern image protocol, supported by kitty, WezTerm, Ghostty, and others. Like DCS, consume and discard for now, with a bound. Know it exists and where it fits.


Parser Handling: Bounds and Safety

#![allow(unused)]
fn main() {
const MAX_OSC_LEN: usize = 4096;
const MAX_OSC_PARAMS: usize = 16;

fn osc_string(&mut self, byte: u8, perform: &mut impl Perform) {
    match byte {
        // BEL terminates (xterm's shortcut) — very common in the wild.
        0x07 => { self.osc_dispatch(perform, true); self.state = State::Ground; }

        // ESC may begin ST (ESC \). Peek at the next byte in the ESC state:
        // if it is '\', terminate; otherwise the global ESC rule restarts parsing.
        0x1b => { self.state = State::OscEscape; }

        // Overlong: DISCARD and recover. A program that emits an OSC and crashes
        // must not wedge the parser forever.
        _ if self.osc_len >= MAX_OSC_LEN => {
            self.osc_overflow = true;
            // keep consuming until a terminator, but stop accumulating
        }
        _ => { self.osc_push(byte); }
    }
}
}

The bounds checklist for string sequences:

  • OSC total length capped (~4 KB)
  • OSC parameter count capped (16)
  • Hyperlink URI capped (~2 KB) and its scheme validated
  • Clipboard payload capped (~100 KB) and reads denied by default
  • Title length capped (~1 KB) and control characters stripped
  • DCS/APC payload byte-counted and capped
  • ESC aborts from any string state
  • Overflow discards and recovers rather than truncating silently mid-parameter

Window Titles: Sanitize Them

A title goes into your window manager, your taskbar, and possibly a tmux status line. Untrusted input goes there — sshing to a compromised host is enough.

#![allow(unused)]
fn main() {
fn set_title(&mut self, raw: &[u8]) {
    const MAX_TITLE: usize = 1024;
    let s = String::from_utf8_lossy(&raw[..raw.len().min(MAX_TITLE)]);
    // Strip control characters: a newline or an escape byte in a title can
    // corrupt a status bar, a window manager, or a terminal that re-renders it.
    self.title = s.chars()
        .filter(|c| !c.is_control())
        .take(MAX_TITLE)
        .collect();
}
}

Warning: There is a classic attack chain here. Some terminals implemented "report the window title" (CSI 21 t) — which writes the title back as terminal input. Combined with title-setting, a remote host could set the title to rm -rf ~\n and then make the terminal type it into your shell. Do not implement title reporting. If you must, escape the output and never include a newline.


Experiment

CLAIM. OSC sequences reach outside the terminal grid — into the window manager, the clipboard, and the shell's own behavior.

METHOD.

# 1. Title.
printf '\033]0;EXPERIMENT\007'                  # look at the title bar / tab
printf '\033]0;%s\007' "$(date)"                # a live clock in your title

# 2. Colors — query and change.
printf '\033]11;?\033\\'; read -r -d '\' bg; echo "background = $bg"
printf '\033]11;#001122\033\\'                  # change it
printf '\033]111\033\\'                          # reset it

# 3. Hyperlink.
printf '\033]8;;https://example.com\033\\CLICK\033]8;;\033\\\n'
#    Cmd/Ctrl-click it if your terminal supports OSC 8.

# 4. Clipboard (write). Then paste somewhere to confirm.
printf '\033]52;c;%s\033\\' "$(printf 'hello from OSC 52' | base64)"

# 5. The danger, made concrete — DO NOT run this on anything you care about:
#    A file containing an OSC 52 write can silently change your clipboard.
printf '\033]52;c;%s\033\\' "$(printf 'rm -rf ~' | base64)" > /tmp/evil.txt
cat /tmp/evil.txt
#    Now check your clipboard. That is why the policy matters.

# 6. Semantic prompts.
PS1='\[\e]133;A\e\\\]$ \[\e]133;B\e\\\]'
#    In a supporting terminal, prompt navigation now works.

# 7. What does your terminal support? Record and grep:
pty-runner --record osc.cast
#    inside: run vim, then htop, then quit both
grep -o 'u001b\][0-9]*' osc.cast | sort | uniq -c

PREDICTION. Before step 5: will catting that file change your clipboard? Does your terminal warn you? Should it?


Test

#![allow(unused)]
fn main() {
#[test]
fn osc_accepts_both_terminators() {
    // BEL and ESC-backslash are both common in the wild.
    let mut a = Terminal::new(3, 20);
    let mut b = Terminal::new(3, 20);
    a.advance(b"\x1b]0;hello\x07");
    b.advance(b"\x1b]0;hello\x1b\\");
    assert_eq!(a.title(), "hello");
    assert_eq!(b.title(), a.title());
}

#[test]
fn eight_bit_st_is_not_honored() {
    // 0x9c is a UTF-8 continuation byte. Honoring it as ST truncates any string
    // containing a character whose encoding includes it.
    let mut t = Terminal::new(3, 20);
    t.advance("\x1b]0;caf\u{e9}\x1b\\".as_bytes());
    assert_eq!(t.title(), "café");
}

#[test]
fn overlong_osc_is_discarded_and_the_parser_recovers() {
    let mut t = Terminal::new(3, 20);
    let mut input = b"\x1b]0;".to_vec();
    input.extend(std::iter::repeat(b'A').take(100_000));
    input.extend_from_slice(b"\x07");
    input.extend_from_slice(b"\x1b[31mX");    // must still be parsed
    t.advance(&input);
    assert!(t.title().len() <= 1024);
    assert_eq!(t.screen().row(0).cell(0).style().fg, Color::Indexed(1),
               "the parser must recover after an overlong OSC");
}

#[test]
fn title_control_characters_are_stripped() {
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b]0;evil\ntitle\x1b[31m\x07");
    assert!(!t.title().contains('\n'));
    assert!(!t.title().contains('\x1b'));
}

#[test]
fn esc_aborts_an_unterminated_osc() {
    // A program that emits an OSC and crashes must not swallow everything after.
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b]0;no terminator\x1b[31mX");
    assert_eq!(t.screen().row(0).cell(0).grapheme(), "X");
    assert_eq!(t.screen().row(0).cell(0).style().fg, Color::Indexed(1));
}

#[test]
fn dcs_payload_is_streamed_and_bounded() {
    // hook/put/unhook, not a single buffered dispatch — a sixel image can be
    // megabytes, and buffering it before dispatch is a DoS.
    let mut t = Terminal::new(3, 20);
    let mut input = b"\x1bPq".to_vec();
    input.extend(std::iter::repeat(b'#').take(10_000_000));
    input.extend_from_slice(b"\x1b\\");
    t.advance(&input);
    assert!(t.dcs_bytes_buffered() < 1_000_000, "DCS must not buffer unboundedly");
}
}

Challenge Extensions

  1. Implement OSC 8 fully, including id= grouping and hover highlighting across a wrapped link, with a sweep for unreferenced entries.
  2. Implement OSC 133 and build "jump to previous prompt" on top of it. Then add a gutter marker for failed commands using the exit code from 133;D.
  3. Implement the clipboard policy with all four levels and an Ask prompt. Write the test that proves reads are denied by default.
  4. Implement OSC 4/10/11/12 including queries, and prove theming works end to end by changing the background at runtime.
  5. Implement sixel, or at minimum parse the header and report the image dimensions. Understanding sixel's encoding (six vertical pixels per character) is a worthwhile afternoon.
  6. Write a fuzz target for string states specifically — unterminated, overlong, embedded ESC, invalid base64, nested introducers. Run for an hour.

Validation / Self-check

  1. Name the four string-sequence families and their introducers.
  2. What are the three possible OSC terminators, and which must you refuse to honor? Why?
  3. Why can a hyperlink URI not be stored in the Cell? What is the alternative?
  4. What is the id= parameter of OSC 8 for?
  5. Why are OSC 52 reads denied by default? What was the CVE?
  6. Name four bounds a string-sequence parser must enforce.
  7. Why must window titles be sanitized, and what attack does title reporting enable?
  8. Why is DCS hook/put/unhook rather than a single dispatch?
  9. What does OSC 133 enable, and why can the terminal not infer it on its own?
  10. A program emits an OSC and crashes without the terminator. What must your parser do?

Next: Lab 6 — The Parser, Version 1.