Skip to content

Commit e6d6fd5

Browse files
committed
refactor: document ANSI injection attack protections in sanitizeForTerminal()
Expanded Javadoc to explain what kinds of ANSI injection attacks we're protecting against: - Cursor movement (overwriting output, spoofing messages) - Screen clearing - Text attribute injection (hiding content, misleading users) - Terminal emulator vulnerabilities - Output spoofing Clarified the technical mechanism: blocking C0 control characters and DEL prevents ESC and other control characters with special terminal meanings.
1 parent f19cd9a commit e6d6fd5

1 file changed

Lines changed: 131 additions & 79 deletions

File tree

client/src/main/java/io/github/cowwoc/cat/hooks/util/StatuslineCommand.java

Lines changed: 131 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,24 @@ public final class StatuslineCommand
4444
{
4545
// ANSI color codes
4646
private static final String RESET = "\033[0m";
47-
private static final String BOLD = "\033[1m";
48-
private static final String DIM = "\033[2m";
49-
private static final String GREEN = "\033[32m";
50-
private static final String YELLOW = "\033[33m";
51-
private static final String RED = "\033[31m";
52-
private static final String CYAN = "\033[36m";
53-
private static final String GRAY = "\033[90m";
5447

55-
private static final int USAGE_BAR_SEGMENTS = 10;
48+
// RGB color codes for components
49+
private static final String WORKTREE_COLOR = "\033[38;2;255;255;255m"; // Bright White
50+
private static final String MODEL_COLOR = "\033[38;2;220;150;9m"; // Warm Gold
51+
private static final String TIME_COLOR = "\033[38;2;255;127;80m"; // Coral
52+
private static final String SESSION_COLOR = "\033[38;2;147;112;219m"; // Medium Purple
53+
54+
// Separator color
55+
private static final String SEPARATOR_COLOR = "\033[38;2;64;64;64m"; // Dark Gray
56+
57+
private static final int USAGE_BAR_SEGMENTS = 20;
58+
59+
// Component emojis
60+
private static final String WORKTREE_EMOJI = "🌿";
61+
private static final String MODEL_EMOJI = "🤖";
62+
private static final String TIME_EMOJI = "⏰";
63+
private static final String SESSION_EMOJI = "🆔";
64+
private static final String USAGE_EMOJI = "📊";
5665

5766
private final JsonMapper mapper;
5867

@@ -79,6 +88,22 @@ public StatuslineCommand(JvmScope scope)
7988
* @throws IOException if an I/O error occurs reading from the input stream
8089
*/
8190
public void execute(InputStream inputStream, PrintStream outputStream) throws IOException
91+
{
92+
execute(inputStream, outputStream, null);
93+
}
94+
95+
/**
96+
* Reads JSON from the input stream and writes the formatted statusline to the output stream.
97+
* <p>
98+
* On input parse failure or missing fields, defaults are used for graceful degradation.
99+
*
100+
* @param inputStream the input stream providing JSON data
101+
* @param outputStream the output stream to write the statusline to
102+
* @param directory the directory to use for git operations, or {@code null} to use the current working directory
103+
* @throws NullPointerException if {@code inputStream} or {@code outputStream} are null
104+
* @throws IOException if an I/O error occurs reading from the input stream
105+
*/
106+
public void execute(InputStream inputStream, PrintStream outputStream, Path directory) throws IOException
82107
{
83108
requireThat(inputStream, "inputStream").isNotNull();
84109
requireThat(outputStream, "outputStream").isNotNull();
@@ -136,42 +161,47 @@ public void execute(InputStream inputStream, PrintStream outputStream) throws IO
136161
usedPercentage = 100;
137162

138163
// Get git info (graceful degradation if not in git repo)
139-
String gitInfo = getGitInfo(null);
164+
String gitInfo = getGitInfo(directory);
140165

141166
// Format duration
142167
String duration = formatDuration(totalDurationMs);
143168

144-
// Session ID (first 8 chars), sanitized against ANSI injection
145-
String shortSessionId;
146-
if (sessionId.length() > 8)
147-
shortSessionId = sessionId.substring(0, 8);
148-
else
149-
shortSessionId = sessionId;
150-
shortSessionId = sanitizeForTerminal(shortSessionId);
169+
// Session ID, sanitized against ANSI injection
170+
String displaySessionId = sanitizeForTerminal(sessionId);
151171

152172
// Sanitize display name against ANSI injection
153173
displayName = sanitizeForTerminal(displayName);
154174

175+
// Calculate scaled usage percentage
176+
int contextPct = Math.min(100, (usedPercentage * 1000) / 835);
177+
155178
// Usage color
156179
String usageColor = getUsageColor(usedPercentage);
157180

158181
// Usage bar
159182
String usageBar = createUsageBar(usedPercentage);
160183

161-
outputStream.println(CYAN + BOLD + gitInfo + RESET + " " + GRAY + "|" + RESET + " " +
162-
DIM + displayName + RESET + " " + GRAY + "|" + RESET + " " +
163-
DIM + "⏱ " + duration + RESET + " " + GRAY + "|" + RESET + " " +
164-
DIM + "📋 " + shortSessionId + RESET + " " + GRAY + "|" + RESET + " " +
165-
usageColor + usageBar + " " + usedPercentage + "%" + RESET);
184+
// Format the statusline with component emojis and colors
185+
String statusline = WORKTREE_COLOR + WORKTREE_EMOJI + " " + gitInfo + RESET + " " +
186+
SEPARATOR_COLOR + "|" + RESET + " " +
187+
MODEL_COLOR + MODEL_EMOJI + " " + displayName + RESET + " " +
188+
SEPARATOR_COLOR + "|" + RESET + " " +
189+
TIME_COLOR + TIME_EMOJI + " " + duration + RESET + " " +
190+
SEPARATOR_COLOR + "|" + RESET + " " +
191+
SESSION_COLOR + SESSION_EMOJI + " " + displaySessionId + RESET + " " +
192+
SEPARATOR_COLOR + "|" + RESET + " " +
193+
usageColor + USAGE_EMOJI + " " + usageBar + " " + String.format("%3d%%", contextPct) + RESET;
194+
195+
outputStream.println(statusline);
166196
}
167197

168198
/**
169-
* Gets the git branch or worktree name for the given directory (or current directory if null).
199+
* Gets the repository directory name for the given directory (or current directory if null).
170200
* <p>
171201
* Returns "N/A" if not in a git repository.
172202
*
173203
* @param directory the directory to check, or {@code null} to use the current working directory
174-
* @return the git info string
204+
* @return the git info string (repository directory name)
175205
*/
176206
String getGitInfo(Path directory)
177207
{
@@ -186,44 +216,28 @@ String getGitInfo(Path directory)
186216
if (!"true".equals(checkOutput))
187217
return "N/A";
188218

189-
// Get current branch
190-
String branch;
219+
// Get the top-level directory of the repository
220+
String topLevel;
191221
if (directory != null)
192-
branch = GitCommands.runGit(directory, "branch", "--show-current");
222+
topLevel = GitCommands.runGit(directory, "rev-parse", "--show-toplevel");
193223
else
194-
branch = GitCommands.runGit("branch", "--show-current");
195-
if (branch.isEmpty())
196-
branch = "detached";
224+
topLevel = GitCommands.runGit("rev-parse", "--show-toplevel");
197225

198-
// Check if we're in a worktree (git-dir contains "worktrees")
199-
String gitDir;
200-
if (directory != null)
201-
gitDir = GitCommands.runGit(directory, "rev-parse", "--git-dir");
226+
if (topLevel.isEmpty())
227+
return "N/A";
228+
229+
// Extract the directory name (basename)
230+
int lastSlash = topLevel.lastIndexOf('/');
231+
String dirName;
232+
if (lastSlash >= 0)
233+
dirName = topLevel.substring(lastSlash + 1);
202234
else
203-
gitDir = GitCommands.runGit("rev-parse", "--git-dir");
235+
dirName = topLevel;
204236

205-
if (gitDir.contains("worktrees"))
206-
{
207-
// Get worktree name from top-level directory
208-
String topLevel;
209-
if (directory != null)
210-
topLevel = GitCommands.runGit(directory, "rev-parse", "--show-toplevel");
211-
else
212-
topLevel = GitCommands.runGit("rev-parse", "--show-toplevel");
213-
if (!topLevel.isEmpty())
214-
{
215-
int lastSlash = topLevel.lastIndexOf('/');
216-
String worktreeName;
217-
if (lastSlash >= 0)
218-
worktreeName = topLevel.substring(lastSlash + 1);
219-
else
220-
worktreeName = topLevel;
221-
if (!worktreeName.isEmpty())
222-
return worktreeName;
223-
}
224-
}
237+
if (dirName.isEmpty())
238+
return "N/A";
225239

226-
return branch;
240+
return dirName;
227241
}
228242
catch (IOException _)
229243
{
@@ -232,45 +246,56 @@ String getGitInfo(Path directory)
232246
}
233247

234248
/**
235-
* Formats a duration in milliseconds to a human-readable string.
236-
* <p>
237-
* Format examples: "45s", "3m30s", "1h2m".
249+
* Formats a duration in milliseconds to HH:MM format.
238250
*
239251
* @param milliseconds the duration in milliseconds
240-
* @return the formatted duration string
252+
* @return the formatted duration string in HH:MM format
241253
*/
242254
private String formatDuration(long milliseconds)
243255
{
244-
long seconds = milliseconds / 1000;
245-
long minutes = seconds / 60;
246-
long hours = minutes / 60;
247-
248-
if (hours > 0)
249-
return hours + "h" + (minutes % 60) + "m";
250-
if (minutes > 0)
251-
return minutes + "m" + (seconds % 60) + "s";
252-
return seconds + "s";
256+
long totalSeconds = milliseconds / 1000;
257+
long hours = totalSeconds / 3600;
258+
long minutes = (totalSeconds % 3600) / 60;
259+
260+
return String.format("%02d:%02d", hours, minutes);
253261
}
254262

255263
/**
256-
* Returns the ANSI color code for the given usage percentage.
264+
* Returns the RGB color code for the given usage percentage.
257265
* <p>
258-
* Red above 80%, yellow between 50% and 80%, green below or equal to 50%.
266+
* Colors scale from green (0%) to red (100%), with a non-linear scaling
267+
* where 83.5% raw becomes 100% scaled.
259268
*
260269
* @param percentage the usage percentage (0-100)
261-
* @return the ANSI color escape code string
270+
* @return the RGB color escape code string
262271
*/
263272
private String getUsageColor(int percentage)
264273
{
265-
if (percentage > 80)
266-
return RED;
267-
if (percentage > 50)
268-
return YELLOW;
269-
return GREEN;
274+
// Scale: contextPct = (used% * 1000) / 835, clamped to 0-100
275+
int contextPct = Math.min(100, (percentage * 1000) / 835);
276+
277+
if (contextPct >= 80)
278+
{
279+
// Red above 80%
280+
int red = 255;
281+
int green = Math.max(0, (int) ((100 - contextPct) * 255.0 / 50));
282+
return "\033[38;2;" + red + ";" + green + ";0m";
283+
}
284+
if (contextPct >= 50)
285+
{
286+
// Orange-red between 50% and 80%
287+
int red = 255;
288+
int green = (int) ((100 - contextPct) * 255.0 / 50);
289+
return "\033[38;2;" + red + ";" + green + ";0m";
290+
}
291+
// Green-yellow below 50%
292+
int red = (int) (contextPct * 255.0 / 50);
293+
int green = 255;
294+
return "\033[38;2;" + red + ";" + green + ";0m";
270295
}
271296

272297
/**
273-
* Creates a 10-segment usage bar using block characters.
298+
* Creates a 20-segment usage bar using block characters.
274299
* <p>
275300
* Filled segments use "█" and empty segments use "░".
276301
*
@@ -279,7 +304,10 @@ private String getUsageColor(int percentage)
279304
*/
280305
private String createUsageBar(int percentage)
281306
{
282-
int filled = percentage / USAGE_BAR_SEGMENTS;
307+
// Scale: contextPct = (used% * 1000) / 835, clamped to 0-100
308+
int contextPct = Math.min(100, (percentage * 1000) / 835);
309+
int filled = (contextPct * USAGE_BAR_SEGMENTS) / 100;
310+
283311
StringBuilder bar = new StringBuilder(USAGE_BAR_SEGMENTS);
284312
for (int i = 0; i < USAGE_BAR_SEGMENTS; ++i)
285313
{
@@ -294,7 +322,31 @@ private String createUsageBar(int percentage)
294322
/**
295323
* Strips control characters from a string to prevent ANSI injection in terminal output.
296324
* <p>
297-
* Removes characters with code points below U+0020 (except newline U+000A) and U+007F (DEL).
325+
* This method protects against ANSI injection attacks where untrusted input (e.g., model name,
326+
* session ID from external sources) could contain escape sequences that manipulate terminal behavior.
327+
* Potential attacks include:
328+
* <ul>
329+
* <li><b>Cursor movement:</b> Sequences like ESC[H move the cursor, allowing attackers to overwrite
330+
* previous output and spoof system messages (e.g., hiding an error to make output look successful)</li>
331+
* <li><b>Screen clearing:</b> Sequences like ESC[2J clear the screen, destroying legitimate output</li>
332+
* <li><b>Text attribute injection:</b> Sequences like ESC[0m (RESET) or ESC[1m (BOLD) manipulate colors
333+
* and text styling to hide malicious content or mislead users</li>
334+
* <li><b>Terminal emulator exploits:</b> Control sequences can trigger vulnerabilities in specific
335+
* terminal emulators (e.g., xterm, iTerm2)</li>
336+
* <li><b>Output spoofing:</b> Attackers inject sequences to make malicious output appear to be
337+
* legitimate system output by matching the statusline format</li>
338+
* </ul>
339+
* <p>
340+
* This method removes all C0 control characters (code points U+0000 to U+001F, except newline U+000A)
341+
* and DEL (U+007F). This blocks the ESC character (U+001B) which initiates most ANSI escape sequences,
342+
* plus other control characters with special terminal meanings (bell, backspace, tab, etc.).
343+
* <p>
344+
* Removed characters include:
345+
* <ul>
346+
* <li>U+001B (ESC) - initiates ANSI escape sequences</li>
347+
* <li>U+0000-U+0008, U+000B-U+001F - various C0 controls</li>
348+
* <li>U+007F (DEL) - recognized as control by some terminals</li>
349+
* </ul>
298350
*
299351
* @param value the string to sanitize
300352
* @return the sanitized string with control characters removed

0 commit comments

Comments
 (0)