Blog

Don't trust your eyes - ANSI escape injection in the skills CLI by vercel-labs

Ben Bader
July 21, 2026

In a previous post we looked at the skills CLI by vercel-labs/skills and ways for a malicious skill to overwrite an existing trusted skill by using homoglyph names or abusing weird CLI behaviors.

This post will delve into an OSC-8 escape injection we found in the skills add command, which lets a skill author write arbitrary text to the console and clickable links as if it's part of the CLI's output.

Terminal and escape sequences

When a program prints to the screen, it doesn't draw that text itself but rather hands the terminal a stream of characters and lets it paint them. Some of those characters aren't actually text, they're special sequences that instruct the terminal what to do.

These special sequences are standardized as ANSI escape codes, and let you do cool stuff: to make a word come out red you print a few extra bytes, and the terminal reads them as an instruction instead of drawing them:

printf 'boot: \x1b[31mFAILED\x1b[0m\n'

In the above example \x1b[31m switches the foreground to red and \x1b[0m switches it back, so FAILED is shown in red while the escape bytes are not.

These are called an SGR sequence (Select Graphic Rendition), and are structured thusly:

   ESC [ 1 ; 31 ; 4 m
#  └─┬─┘ └───┬────┘└┬┘
#    │       │      └ suffix ('m'=SGR, 'K'=erase)
#    │       └ params: a ';' separated list (1=bold, 31=red, 4=underline)
#    └ prefix  (\x1b[)

A handful of commands cover everything an LLM an application might need:

  • \x1b[0m - reset every attribute to default
  • \x1b[1m - bold
  • \x1b[4m - underline
  • \x1b[31m - red foreground
  • \x1b[2K - erase the entire current line
  • \r (0x0d) - move the cursor to column 0, without a new line

You're not limited to using just one - so \x1b[1;31;4m sets bold + red + underline in one shot. The combination of \r\x1b[2K lets you clear the line and start over, not unlike how progress bars redraw in place.

Some of the codes are called OSC (Operating System Command). These sequences use a similar prefix to SGR: ESC ] (a closing bracket). They have been a focus of prior research yielding several vulnerabilities, like the iTerm2 vulnerability that ended in arbitrary code execution (CVE-2019-9535).

One of the supported capabilities is the hyperlink command - OSC-8 (see https://github.com/Alhadis/OSC8-Adoption/). This command allows a program to emit text that is clickable and points at a URI that's hidden from the user, much like the color codes:

#                      target URI               link text
#                 ┌─────────┴─────────┐        ┌────┴─────┐
   ESC ]  8  ; ;  https://seal.security   BEL   Click here   ESC ] 8 ; ; BEL
# └──┬──┘└┬┘ └┬┘                         └─┬─┘              └───────┬───────┘
#   OSC   │   └ params (empty)             │                 close hyperlink
#         │                                │
#         └ command 8 (hyperlink)          └ string terminator (0x07)

The raw bytes for the above example are \x1b]8;;https://seal.security\x07Click here\x1b]8;;\x07, but the user would see the words Click here.

These are not the skills you are looking for

When skills add copies a skill into place, it walks the source tree with copyDirectory. For each entry it also dereferences any symlink it finds. If it's broken, rather than aborting the whole install, the code logs a warning and moves on (src/installer.ts from v1.5.9):

async function copyDirectory(src: string, dest: string): Promise<void> {
  ...
  if (
    err instanceof Error &&
    'code' in err &&
    (err as NodeJS.ErrnoException).code === 'ENOENT' &&
    entry.isSymbolicLink()
  ) {
    console.warn(`Skipping broken symlink: ${srcPath}`); // ← srcPath printed verbatim to stderr
  } 
    ...

Since on unix a path component may contain almost any byte, including our infamous ESC + BEL, and CR, we can craft a symlink with the bytes \r \x1b[2K \x1b]8;;... without any issues from git or tar - logging it directly to STDERR.

ANSI codes, assemble!

Most primitives are prevented at the terminal level, but we could use the OSC-8 link primitive to trick a user into opening our website. To do that we would need several / separators for the protocol and any uri path fragment.

Luckily, path.join used to concatenate path parts can be utilized by using a nested directory tree as part of our skill repository, which is being downloaded along our SKILL.md file:

skill/
└─ "\r ESC[2K ESC]8;;https:"          # scheme
    └─ "seal.security"                # host
        └─ "advisory"                 # intermediate path segment(s)
            └─ "revoke BEL ESC[1;31;4m MSG ESC[0m ESC]8;; BEL" -> /nonexistent # a BROKEN symlink 
                                        

The last element of the path is a broken symlink, so copyDirectory fails and prints Skipping broken symlink: followed by the full source path. path.join has by then reassembled every segment into one line:

Skipping broken symlink: /tmp/clone/skill/\r ESC[2K ESC]8;;https:/seal.security/advisory/revoke BEL ESC[1;31;4m MSG ESC[0m ESC]8;; BEL

Read left to right, the terminal executes it as the following instructions:

  1. \r - return the cursor to column zero
  2. ESC[2K - erase the entire line (including the Skipping broken symlink: /tmp/clone/skill/ prefix the tool had already written)
  3. ESC]8;;https: - open a hyperlink
  4. BEL - terminate the URI
  5. ESC[1;31;4m - switch to bold red underline; the display text is drawn
  6. ESC[0m - reset
  7. ESC]8;;+BEL - close the link

Thus, installing a skill from a repository with this kind of payload could display some alarming text and point you to a url once you click it:

Readers with sharp eyes might have noticed that the final url actually ends having a single-slash https:/ instead of https://. Luckily it works since macOS hands URLs to the default browser through NSURL, which accepts the noncompliant https:/host/path and normalizes it before the browser gets it.

Impact

This could theoretically run arbitrary code once a user clicks the url, as the file:/ protocol is also accepted and allows executing a .command file. However, this requires knowing the path to which the skill is being installed ahead of time, making this vector of attack unlikely compared to social engineering using a scary hyperlink.

This issue was reported to vercel-labs and deemed outside the security boundary for the project, and therefore shared here.