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
9 changes: 6 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
# Third-party actions in this privileged job are pinned to full
# commit SHAs so a moved tag or compromised upstream cannot inject
# code into a workflow holding contents:write + id-token:write.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
lfs: true

- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4

# Node 24 (LTS Krypton) ships npm >= 11.12, which has Trusted Publisher
# OIDC authentication. Node 22's bundled npm 10.x can sign provenance
# attestations but cannot use OIDC tokens to authenticate the publish
# itself, leading to a confusing 404 after provenance signing succeeds.
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: "pnpm"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ todo/
.docs-test-tmp/
fuzz-*.log
.claude/settings.local.json
.deepsec/
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"!.docs-test-tmp",
"!packages/just-bash/src/commands/python3/worker.js",
"!packages/just-bash/src/commands/js-exec/js-exec-worker.js",
"!packages/just-bash/src/commands/sqlite3/worker.js",
"!**/vendor"
]
}
Expand Down
36 changes: 34 additions & 2 deletions examples/website/app/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ function getTheme(isDark: boolean) {
};
}

// Strip ANSI escape sequences from a URL-supplied string before it
// reaches `term.write` (which would otherwise interpret OSC 8 hyperlinks
// as clickable <a href="javascript:..."> links — see the XSS finding).
// Order matters: OSC ends with BEL (0x07) or ESC \\, so we drop those
// first before the generic ESC catch-all stripper.
function sanitizeAgentQuery(s: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
let cleaned = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
cleaned = cleaned.replace(/\x1b\[[\d;?]*[A-Za-z@~]/g, "");
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
cleaned = cleaned.replace(/\x1b[@-_]/g, "");
// Strip remaining C0/C1 control characters (keep \t, \n, \r are
// already harmless after the OSC strip; but bash and terminal both
// mishandle them so we drop everything in 0x00–0x1F).
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
cleaned = cleaned.replace(/[\x00-\x1F\x7F]/g, "");
return cleaned;
}

// Quote a string so that bash treats it as a single argument regardless
// of contents. Single-quote everything; embedded "'" becomes "'\\''".
function bashSingleQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}

export default function TerminalComponent() {
const terminalRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -87,8 +113,14 @@ export default function TerminalComponent() {
if (agentQuery) {
// Clean the URL
window.history.replaceState({}, "", window.location.pathname);
// Execute the agent command
void inputHandler.executeCommand(`agent "${agentQuery}"`);
// Sanitize the URL-supplied query before it reaches term.write
// and the bash parser. Without this, embedded ANSI/OSC sequences
// render as clickable links (OSC 8 hyperlink XSS) and stray
// double quotes break out of the shell-quoted argument.
const sanitized = sanitizeAgentQuery(agentQuery);
// Execute the agent command, single-quoting the argument so the
// bash parser treats it as a literal string regardless of contents.
void inputHandler.executeCommand(`agent ${bashSingleQuote(sanitized)}`);
} else if (inputHandler.history.length === 0) {
// Pre-populate command if history is empty and no query param
inputHandler.setInitialCommand('agent "What is just-bash?"');
Expand Down
32 changes: 30 additions & 2 deletions examples/website/app/components/lite-terminal/LiteTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ import { InputHandler } from "./input-handler";
/** Maximum number of lines to keep in scrollback */
const MAX_SCROLLBACK_LINES = 100_000;

/**
* URL schemes considered safe for clickable terminal links.
* Specifically blocks `javascript:`, `data:`, `vbscript:`, `file:`, etc.
* which would execute attacker-controlled code or read local resources
* if assigned to an <a href>.
*/
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);

function isSafeLinkUrl(url: string): boolean {
try {
// Use a base so relative URLs (which we should NOT render as links
// anyway) don't accidentally become unsafe when resolved.
const parsed = new URL(url, "https://example.invalid/");
if (!SAFE_LINK_PROTOCOLS.has(parsed.protocol)) return false;
// If the URL was relative, the parsed origin is example.invalid —
// refuse to render that as a clickable absolute link.
if (parsed.hostname === "example.invalid" && !/^https?:|^mailto:/i.test(url)) {
return false;
}
return true;
} catch {
return false;
}
}

/**
* Lightweight terminal implementation optimized for iOS
* Drop-in compatible with xterm.js API surface used in this project
Expand Down Expand Up @@ -607,8 +632,11 @@ export class LiteTerminal {
const classes = this.getStyleClasses(style);
const inlineStyle = this.getInlineStyle(style);

// If style has a link (from OSC 8), create an anchor element
if (style.link) {
// If style has a link (from OSC 8), create an anchor element.
// Validate the URL scheme — without this, an OSC 8 sequence with
// `javascript:` (or `data:`, `vbscript:`, etc.) becomes a clickable
// anchor that fires arbitrary JS in this origin (XSS).
if (style.link && isSafeLinkUrl(style.link)) {
const link = document.createElement("a");
link.href = style.link;
link.target = "_blank";
Expand Down
84 changes: 69 additions & 15 deletions examples/website/app/components/terminal-parts/agent-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,34 @@ function sanitizeTerminalError(message: string): string {
.replace(/[A-Z]:\\[^\s'",)}\]:]+/g, "<path>");
}

// Format text for terminal: normalize newlines and convert tabs to spaces
// Strip ANSI escape sequences from model-controlled text before it
// reaches `term.write`. Without this, OSC 8 hyperlink sequences emitted
// by the LLM (or echoed from tool output / prompt-injection sources)
// render as <a href="javascript:..."> in the terminal — XSS in this
// origin. Order: drop OSC sequences (terminated by BEL or ESC \\)
// first because they carry payloads with characters that the generic
// CSI matcher would partially eat.
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const CSI_RE = /\x1b\[[\d;?]*[A-Za-z@~]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const ESC_OTHER_RE = /\x1b[@-_]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const C0_C1_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;

function stripAnsi(text: string): string {
return text
.replace(OSC_RE, "")
.replace(CSI_RE, "")
.replace(ESC_OTHER_RE, "")
.replace(C0_C1_RE, "");
}

// Format text for terminal: normalize newlines and convert tabs to spaces.
// Callers MUST run `stripAnsi` on any model-controlled content first;
// this function intentionally preserves escape sequences so that
// surrounding styling we add ourselves (\x1b[2m ... \x1b[0m) survives.
function formatForTerminal(text: string): string {
return text.replace(/\t/g, " ").replace(/\r?\n/g, "\r\n");
}
Expand Down Expand Up @@ -149,7 +176,15 @@ export function createAgentCommand(term: TerminalWriter) {
}

if (displayResult && displayResult.trim()) {
const resultLines = displayResult.split("\n").filter((l: string) => l.trim());
// Strip ANSI from each tool-output line BEFORE wrapping it in
// our `\x1b[2m ... \x1b[0m` styling. Without this, an OSC 8
// hyperlink embedded in tool output would survive the wrap
// and render as a clickable link in the terminal — XSS in
// this origin if the URL scheme is `javascript:`.
const resultLines = displayResult
.split("\n")
.map((l: string) => stripAnsi(l))
.filter((l: string) => l.trim());
const linesToShow = resultLines.slice(0, MAX_TOOL_OUTPUT_LINES);
let output = linesToShow.map((line) => `\x1b[2m${line}\x1b[0m`).join("\n");
if (resultLines.length > MAX_TOOL_OUTPUT_LINES) {
Expand Down Expand Up @@ -180,10 +215,16 @@ export function createAgentCommand(term: TerminalWriter) {
try {
const data = JSON.parse(jsonStr);

// Stream text line-by-line (complete lines only to preserve ASCII art)
// Stream text line-by-line (complete lines only to preserve ASCII art).
// stripAnsi the model-emitted delta BEFORE buffering: this
// removes OSC 8 hyperlinks and other escape sequences that
// the LLM might emit (or echo from prompt-injection sources)
// without losing the legitimate styling that `formatMarkdown`
// adds afterward.
if (data.type === "text-delta" && data.delta) {
fullText += data.delta; // Track for message history
lineBuffer += data.delta;
const safeDelta = stripAnsi(String(data.delta));
fullText += safeDelta; // Track for message history
lineBuffer += safeDelta;

// Check for complete lines to stream
const lastNewline = lineBuffer.lastIndexOf("\n");
Expand Down Expand Up @@ -214,20 +255,28 @@ export function createAgentCommand(term: TerminalWriter) {
fullText += "\n";
}
const args = data.input as Record<string, unknown>;
// stripAnsi on every model-controlled segment — these come
// from the LLM's tool-call args and could carry OSC 8
// sequences via prompt injection.
const safeToolName = stripAnsi(String(data.toolName));
if (data.toolName === "bash" && args.command) {
const cmd = String(args.command).replace(/\t/g, " ");
const cmd = stripAnsi(String(args.command)).replace(/\t/g, " ");
const lines = cmd.split("\n");
// Write each line separately for proper terminal rendering
term.write(`\x1b[36m$ ${lines[0]}\x1b[0m\r\n`);
for (let i = 1; i < lines.length; i++) {
term.write(`\x1b[36m${lines[i]}\x1b[0m\r\n`);
}
} else if (data.toolName === "readFile" && args.path) {
term.write(`\x1b[36m[readFile] ${args.path}\x1b[0m\r\n`);
term.write(
`\x1b[36m[readFile] ${stripAnsi(String(args.path))}\x1b[0m\r\n`,
);
} else if (data.toolName === "writeFile" && args.path) {
term.write(`\x1b[36m[writeFile] ${args.path}\x1b[0m\r\n`);
term.write(
`\x1b[36m[writeFile] ${stripAnsi(String(args.path))}\x1b[0m\r\n`,
);
} else {
term.write(`\x1b[36m[${data.toolName}]\x1b[0m\r\n`);
term.write(`\x1b[36m[${safeToolName}]\x1b[0m\r\n`);
}

toolCallsMap.set(data.toolCallId, {
Expand Down Expand Up @@ -262,8 +311,11 @@ export function createAgentCommand(term: TerminalWriter) {
term.write("\x1b[2m\x1b[3m"); // dim + italic
}
else if (data.type === "reasoning-delta" && data.delta) {
// Stream thinking tokens as they arrive
term.write(formatForTerminal(data.delta));
// Stream thinking tokens as they arrive — strip ANSI from
// the model-emitted delta so embedded OSC 8 hyperlinks
// can't render as clickable links inside our dim/italic
// wrapper.
term.write(formatForTerminal(stripAnsi(String(data.delta))));
resetThinkingTimer(); // Keep resetting while actively streaming
}
else if (data.type === "reasoning-end") {
Expand All @@ -273,18 +325,20 @@ export function createAgentCommand(term: TerminalWriter) {
isStreaming = false;
}
}
// Handle errors
// Handle errors. Error strings can be model-controlled
// (e.g. tool execution echoing back attacker content), so
// stripAnsi before wrapping in our \x1b[31m red styling.
else if (data.type === "error") {
const errorMsg = data.error || data.message || "Unknown error";
term.write(`\x1b[31mError: ${formatForTerminal(String(errorMsg))}\x1b[0m\r\n`);
term.write(`\x1b[31mError: ${formatForTerminal(stripAnsi(String(errorMsg)))}\x1b[0m\r\n`);
}
else if (data.type === "tool-input-error") {
const errorMsg = data.error || "Tool input error";
term.write(`\x1b[31m[Tool Error] ${formatForTerminal(String(errorMsg))}\x1b[0m\r\n`);
term.write(`\x1b[31m[Tool Error] ${formatForTerminal(stripAnsi(String(errorMsg)))}\x1b[0m\r\n`);
}
else if (data.type === "tool-output-error") {
const errorMsg = data.error || "Tool execution error";
term.write(`\x1b[31m[Tool Error] ${formatForTerminal(String(errorMsg))}\x1b[0m\r\n`);
term.write(`\x1b[31m[Tool Error] ${formatForTerminal(stripAnsi(String(errorMsg)))}\x1b[0m\r\n`);
}
else if (data.type === "tool-output-denied") {
term.write(`\x1b[33m[Tool Denied]\x1b[0m\r\n`);
Expand Down
29 changes: 27 additions & 2 deletions examples/website/app/components/terminal-parts/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ type Terminal = {
};


// Strip ANSI escape sequences from text destined for term.write so OSC
// 8 hyperlink XSS can't sneak in via user-supplied or URL-supplied
// command echoes. Defense in depth — callers that forget to sanitize
// still get protected here.
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const STRIP_OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const STRIP_CSI = /\x1b\[[\d;?]*[A-Za-z@~]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const STRIP_ESC_OTHER = /\x1b[@-_]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars
const STRIP_C0 = /[\x00-\x08\x0B-\x1F\x7F]/g;

function stripDisplayAnsi(text: string): string {
return text
.replace(STRIP_OSC, "")
.replace(STRIP_CSI, "")
.replace(STRIP_ESC_OTHER, "")
.replace(STRIP_C0, "");
}

// Find the start of the previous word
function findPrevWordBoundary(str: string, pos: number): number {
if (pos <= 0) return 0;
Expand Down Expand Up @@ -475,10 +496,14 @@ export function createInputHandler(term: Terminal, bash: Bash) {
setInitialCommand: (initialCmd: string) => {
cmd = initialCmd;
cursorPos = initialCmd.length;
term.write(initialCmd);
// Strip ANSI before echoing — defense in depth in case any caller
// forgets to sanitize. The bash parser still sees the original
// string from the cmd variable above (set on the same line),
// so we just protect the visual echo.
term.write(stripDisplayAnsi(initialCmd));
},
executeCommand: async (command: string) => {
term.write(command);
term.write(stripDisplayAnsi(command));
term.writeln("");
await executeCommand(command);
},
Expand Down
1 change: 1 addition & 0 deletions packages/just-bash/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist
src/commands/python3/worker.js
src/commands/js-exec/js-exec-worker.js
src/commands/sqlite3/worker.js
14 changes: 14 additions & 0 deletions packages/just-bash/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# just-bash

## 2.14.4

### Patch Changes

- [#206](https://github.com/vercel-labs/just-bash/pull/206) [`6ccc35f`](https://github.com/vercel-labs/just-bash/commit/6ccc35f5a9b5c6f395b145ed2ec7ee71c4862057) Thanks [@subsetpark](https://github.com/subsetpark)! - Fix awk lexer to honor POSIX statement continuation across newlines after `,`,
`{`, `&&`, `||`, `?`, `:`, `do`, `else`, `if`, and `while`. Previously, a
multi-line idiom like `printf "%s=%d\n", \n $1, $2` (comma at end-of-line
followed by indented args on the next line) failed with `Unexpected token:
NEWLINE` because the lexer emitted a NEWLINE token unconditionally. The
lexer now suppresses the NEWLINE when it immediately follows one of the
continuation-allowing tokens, matching POSIX awk.

- [#212](https://github.com/vercel-labs/just-bash/pull/212) [`733c847`](https://github.com/vercel-labs/just-bash/commit/733c84796e3abbd05a25cf67805bf4b030d0b02d) Thanks [@cramforce](https://github.com/cramforce)! - Bug fixes across network, sqlite3, xan, rg, terminal rendering, and CI

## 2.14.3

### Patch Changes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/usr/bin/env node
import{a,b}from"./chunk-RJUOOCLK.js";import"./chunk-4PRVMER6.js";import"./chunk-MO4RPBN2.js";import"./chunk-YU6OGPZR.js";import"./chunk-JDNI5HBX.js";import"./chunk-6KZRLMG3.js";import"./chunk-GTNBSMZR.js";import"./chunk-KGOUQS5A.js";export{a as awkCommand2,b as flagsForFuzzing};
import{a,b}from"./chunk-EWDHVLQL.js";import"./chunk-4PRVMER6.js";import"./chunk-MO4RPBN2.js";import"./chunk-YU6OGPZR.js";import"./chunk-JDNI5HBX.js";import"./chunk-6KZRLMG3.js";import"./chunk-GTNBSMZR.js";import"./chunk-KGOUQS5A.js";export{a as awkCommand2,b as flagsForFuzzing};
Loading
Loading