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:
| Family | Introducer | Purpose |
|---|---|---|
| OSC Operating System Command | ESC ] (0x1b 0x5d) | Title, colors, hyperlinks, clipboard, cwd, notifications |
| DCS Device Control String | ESC P (0x1b 0x50) | Sixel images, terminfo queries, ReGIS |
| APC Application Program Command | ESC _ (0x1b 0x5f) | The kitty graphics protocol |
| PM / SOS Privacy Message / Start of String | ESC ^ / ESC X | Essentially 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
0x9cas ST. In UTF-8 it is a continuation byte, and honoring it truncates any string containing a character whose encoding includes0x9c. 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:
- A length cap (~4 KB is typical; xterm's is similar). Exceed it → discard and return to Ground.
ESCaborts — the global rule.ESCfollowed by anything other than\restarts parsing.- Optionally, terminate on
\nor\r(a heuristic; xterm does not, but it prevents a whole class of hang).
OSC Commands You Should Implement
Format: OSC <number> ; <payload> ST
| Ps | Name | Payload | Notes |
|---|---|---|---|
0 | Set icon name and window title | text | The most common by far |
1 | Set icon name only | text | |
2 | Set window title only | text | |
4 | Set/query a palette color | <index>;<spec> or <index>;? | rgb:RRRR/GGGG/BBBB |
7 | Report the working directory | file://host/path | How terminals open a new tab in the same directory |
8 | Hyperlink | <params>;<URI> | See below |
9 | Desktop notification (iTerm2 ext.) | text | |
10/11/12 | Default fg / bg / cursor color | spec or ? | ? queries |
52 | Clipboard | <selection>;<base64> | Security-sensitive — see below |
104 | Reset palette color(s) | <index> or empty | |
110/111/112 | Reset fg / bg / cursor color | — | |
133 | Semantic prompt marks | A/B/C/D | Shell integration: jump-to-prompt, command status |
777 | Notification (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: Hyperlinks
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; theidis 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. Ajavascript:ordata: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:
| Risk | Detail |
|---|---|
| Write | Any 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. |
| Read | Far 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.
| Sequence | Purpose |
|---|---|
DCS q <sixel data> ST | Sixel graphics — a bitmap image encoded as printable characters |
DCS + q <hex> ST | XTGETTCAP — query a terminfo capability |
DCS $ q <setting> ST | DECRQSS — request the current value of a setting (e.g. SGR) |
DCS = <n> s ST | tmux 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
-
ESCaborts 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 torm -rf ~\nand 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
- Implement OSC 8 fully, including
id=grouping and hover highlighting across a wrapped link, with a sweep for unreferenced entries. - 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. - Implement the clipboard policy with all four levels and an
Askprompt. Write the test that proves reads are denied by default. - Implement OSC 4/10/11/12 including queries, and prove theming works end to end by changing the background at runtime.
- 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.
- Write a fuzz target for string states specifically — unterminated, overlong, embedded ESC, invalid base64, nested introducers. Run for an hour.
Validation / Self-check
- Name the four string-sequence families and their introducers.
- What are the three possible OSC terminators, and which must you refuse to honor? Why?
- Why can a hyperlink URI not be stored in the
Cell? What is the alternative? - What is the
id=parameter of OSC 8 for? - Why are OSC 52 reads denied by default? What was the CVE?
- Name four bounds a string-sequence parser must enforce.
- Why must window titles be sanitized, and what attack does title reporting enable?
- Why is DCS
hook/put/unhookrather than a single dispatch? - What does OSC 133 enable, and why can the terminal not infer it on its own?
- A program emits an OSC and crashes without the terminator. What must your parser do?