diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt index a89a23f9fd..b080eb0625 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt @@ -169,34 +169,6 @@ class BossStatusCommand : CliktCommand(name = "status") { }, ) } - - private fun formatHumanStatus(rawJson: String): String = - buildString { - appendLine("BOSS Console Status") - appendLine("-------------------") - try { - val element = Json.parseToJsonElement(rawJson).jsonObject - appendLine(" Running: ${element["running"]?.jsonPrimitive?.contentOrNull ?: "true"}") - appendLine(" Version: ${element["version"]?.jsonPrimitive?.contentOrNull ?: "unknown"}") - val osName = element["os"]?.jsonPrimitive?.contentOrNull ?: "unknown" - val osArch = element["arch"]?.jsonPrimitive?.contentOrNull ?: "" - appendLine(" OS: $osName ($osArch)") - val project = element["activeProject"]?.jsonPrimitive?.contentOrNull - if (!project.isNullOrBlank()) { - appendLine(" Active Project: $project") - } - val mem = element["memory"]?.jsonObject - if (mem != null) { - val used = mem["usedMb"]?.jsonPrimitive?.contentOrNull ?: "?" - val max = mem["maxMb"]?.jsonPrimitive?.contentOrNull ?: "?" - val heapPct = mem["heapPercent"]?.jsonPrimitive?.contentOrNull ?: "?" - appendLine(" JVM Memory: ${used}MB / ${max}MB ($heapPct%)") - } - healthSummaryOf(element)?.let { appendLine(" Health: $it") } - } catch (_: Exception) { - appendLine(rawJson) - } - } } /** @@ -530,95 +502,6 @@ class BossMcpCommand : CliktCommand(name = "mcp") { content.ifEmpty { "{}" } } } - - private fun formatHumanToolsList( - rawJson: String, - filterQuery: String?, - ): String = - buildString { - if (filterQuery.isNullOrBlank()) { - appendLine("Available MCP Tools in BOSS Console") - appendLine("===================================") - } else { - appendLine("Available MCP Tools in BOSS Console (filtered by: '$filterQuery')") - appendLine("================================================================") - } - try { - val array = Json.parseToJsonElement(rawJson).jsonArray - val filtered = - if (filterQuery.isNullOrBlank()) { - array - } else { - array.filter { item -> - val obj = item.jsonObject - val name = obj["name"]?.jsonPrimitive?.contentOrNull.orEmpty() - val desc = obj["description"]?.jsonPrimitive?.contentOrNull.orEmpty() - val pluginId = obj["pluginId"]?.jsonPrimitive?.contentOrNull.orEmpty() - name.contains(filterQuery, ignoreCase = true) || - desc.contains(filterQuery, ignoreCase = true) || - pluginId.contains(filterQuery, ignoreCase = true) - } - } - - if (filtered.isEmpty()) { - if (filterQuery.isNullOrBlank()) { - appendLine("No MCP tools currently registered.") - } else { - appendLine("No MCP tools matching '$filterQuery'.") - } - } else { - for (item in filtered) { - val obj = item.jsonObject - val name = obj["name"]?.jsonPrimitive?.contentOrNull ?: "unknown" - val desc = obj["description"]?.jsonPrimitive?.contentOrNull ?: "" - val pluginId = obj["pluginId"]?.jsonPrimitive?.contentOrNull ?: "" - appendLine("• $name ($pluginId)") - if (desc.isNotBlank()) { - appendLine(" $desc") - } - } - appendLine() - if (filterQuery.isNullOrBlank()) { - appendLine("Total tools: ${array.size}") - } else { - appendLine("Matching tools: ${filtered.size} (of ${array.size} total)") - } - } - } catch (_: Exception) { - appendLine(rawJson) - } - } - - private fun formatHumanToolDetail(obj: JsonObject): String = - buildString { - val name = obj["name"]?.jsonPrimitive?.contentOrNull ?: "unknown" - val pluginId = obj["pluginId"]?.jsonPrimitive?.contentOrNull ?: "unknown" - val desc = obj["description"]?.jsonPrimitive?.contentOrNull ?: "No description provided." - val requiresAdmin = obj["requiresAdmin"]?.jsonPrimitive?.booleanOrNull ?: false - val perms = - obj["requiredPermissions"] - ?.jsonArray - ?.mapNotNull { it.jsonPrimitive.contentOrNull } - ?: emptyList() - - appendLine("MCP Tool: $name") - appendLine("Plugin: $pluginId") - if (requiresAdmin) { - appendLine("Access: Requires Administrator") - } else if (perms.isNotEmpty()) { - appendLine("Access: Permissions: [${perms.joinToString(", ")}]") - } else { - appendLine("Access: Standard") - } - appendLine() - appendLine("Description:") - appendLine(desc.prependIndent(" ")) - obj["inputSchema"]?.let { - appendLine() - appendLine("Input schema:") - appendLine(it.toString().prependIndent(" ")) - } - } } /** diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt index 8341f9206d..876e9207a6 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt @@ -126,7 +126,7 @@ private fun JsonObject.uncheckedAreas(): List = areaNames("unchecked") private fun JsonObject.partialAreas(): List = areaNames("partial") private fun JsonObject.areaNames(key: String): List = - (get(key) as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + (get(key) as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull }.map(TerminalText::safe) /** A field on one line. Fault messages can contain line breaks, and the report prints one line per item. */ private fun JsonObject.text(key: String): String? = @@ -136,5 +136,6 @@ private fun JsonObject.text(key: String): String? = ?.map(String::trim) ?.filter(String::isNotEmpty) ?.joinToString(" ") + ?.let(TerminalText::safe) private fun problemCount(count: Int): String = if (count == 1) "1 problem" else "$count problems" diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HumanReports.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HumanReports.kt new file mode 100644 index 0000000000..80d2a1fdca --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HumanReports.kt @@ -0,0 +1,145 @@ +package ai.rever.boss.cli + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/* + * The human renderings of `boss status` and `boss mcp list|describe`. + * + * Every string read out of a response here came from outside BOSS - a plugin's tool name, plugin id + * and description, the active project's name - and is printed to an operator who is deciding what to + * trust, so each goes through [TerminalText]. Printed raw, a newline in a description forges a second + * tool in the list and an ESC sequence acts on the terminal. JSON output escapes control characters + * already and does not pass through here. + */ + +/** A string field, made safe to print on one line, or null when it is absent. */ +private fun JsonObject.safeText(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull?.let(TerminalText::safe) + +internal fun formatHumanStatus(rawJson: String): String = + buildString { + appendLine("BOSS Console Status") + appendLine("-------------------") + try { + val element = Json.parseToJsonElement(rawJson).jsonObject + appendLine(" Running: ${element.safeText("running") ?: "true"}") + appendLine(" Version: ${element.safeText("version") ?: "unknown"}") + val osName = element.safeText("os") ?: "unknown" + val osArch = element.safeText("arch") ?: "" + appendLine(" OS: $osName ($osArch)") + val project = element.safeText("activeProject") + if (!project.isNullOrBlank()) { + appendLine(" Active Project: $project") + } + val mem = element["memory"]?.jsonObject + if (mem != null) { + val used = mem.safeText("usedMb") ?: "?" + val max = mem.safeText("maxMb") ?: "?" + val heapPct = mem.safeText("heapPercent") ?: "?" + appendLine(" JVM Memory: ${used}MB / ${max}MB ($heapPct%)") + } + healthSummaryOf(element)?.let { appendLine(" Health: ${TerminalText.safe(it)}") } + } catch (_: Exception) { + appendLine(TerminalText.safeIndented(rawJson, indent = " ")) + } + } + +internal fun formatHumanToolsList( + rawJson: String, + filterQuery: String?, +): String = + buildString { + val shownQuery = filterQuery?.let(TerminalText::safe) + if (filterQuery.isNullOrBlank()) { + appendLine("Available MCP Tools in BOSS Console") + appendLine("===================================") + } else { + appendLine("Available MCP Tools in BOSS Console (filtered by: '$shownQuery')") + appendLine("================================================================") + } + try { + val array = Json.parseToJsonElement(rawJson).jsonArray + val filtered = + if (filterQuery.isNullOrBlank()) { + array + } else { + array.filter { item -> + val obj = item.jsonObject + val name = obj["name"]?.jsonPrimitive?.contentOrNull.orEmpty() + val desc = obj["description"]?.jsonPrimitive?.contentOrNull.orEmpty() + val pluginId = obj["pluginId"]?.jsonPrimitive?.contentOrNull.orEmpty() + name.contains(filterQuery, ignoreCase = true) || + desc.contains(filterQuery, ignoreCase = true) || + pluginId.contains(filterQuery, ignoreCase = true) + } + } + + if (filtered.isEmpty()) { + if (filterQuery.isNullOrBlank()) { + appendLine("No MCP tools currently registered.") + } else { + appendLine("No MCP tools matching '$shownQuery'.") + } + } else { + for (item in filtered) { + val obj = item.jsonObject + val name = obj.safeText("name") ?: "unknown" + val desc = obj["description"]?.jsonPrimitive?.contentOrNull ?: "" + val pluginId = obj.safeText("pluginId") ?: "" + appendLine("• $name ($pluginId)") + if (desc.isNotBlank()) { + // Every line indented: only the first used to be, so a later line of a + // description could start with a bullet and read as another tool. + appendLine(TerminalText.safeIndented(desc, indent = " ")) + } + } + appendLine() + if (filterQuery.isNullOrBlank()) { + appendLine("Total tools: ${array.size}") + } else { + appendLine("Matching tools: ${filtered.size} (of ${array.size} total)") + } + } + } catch (_: Exception) { + appendLine(TerminalText.safeIndented(rawJson, indent = " ")) + } + } + +internal fun formatHumanToolDetail(obj: JsonObject): String = + buildString { + val name = obj.safeText("name") ?: "unknown" + val pluginId = obj.safeText("pluginId") ?: "unknown" + val desc = obj["description"]?.jsonPrimitive?.contentOrNull ?: "No description provided." + val requiresAdmin = obj["requiresAdmin"]?.jsonPrimitive?.booleanOrNull ?: false + val perms = + obj["requiredPermissions"] + ?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?.map(TerminalText::safe) + ?: emptyList() + + appendLine("MCP Tool: $name") + appendLine("Plugin: $pluginId") + if (requiresAdmin) { + appendLine("Access: Requires Administrator") + } else if (perms.isNotEmpty()) { + appendLine("Access: Permissions: [${perms.joinToString(", ")}]") + } else { + appendLine("Access: Standard") + } + appendLine() + appendLine("Description:") + appendLine(TerminalText.safeIndented(desc, indent = " ")) + obj["inputSchema"]?.let { + appendLine() + appendLine("Input schema:") + // Compact JSON has no raw line breaks, but its serializer leaves DEL, C1 and bidi + // characters alone. + appendLine(TerminalText.safe(it.toString()).prependIndent(" ")) + } + } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/McpLedgerCli.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/McpLedgerCli.kt index 7613336c14..74bc1eb67c 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/McpLedgerCli.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/McpLedgerCli.kt @@ -49,6 +49,9 @@ internal data class McpLedgerQuery( val toMillis: Long? = null, ) +/** An exception's message as the operator's terminal may safely see it; it can quote ledger content. */ +private fun reasonOf(e: Exception): String = TerminalText.safe(e.message.orEmpty()) + /** * The read side of the durable MCP operation ledger, for `boss mcp ledger verify|tail|search`. * @@ -87,9 +90,10 @@ internal object McpLedgerCli { requireLedger(ledgerFile) McpOperationLedger(ledgerFile = ledgerFile).verifyChain() } catch (e: McpLedgerReadException) { - return McpLedgerOutcome.Failed("Error: ${e.message}") + return McpLedgerOutcome.Failed("Error: ${reasonOf(e)}") } catch (e: Exception) { - return McpLedgerOutcome.Failed("Error: cannot read ${ledgerFile.absolutePath}: ${e.message}") + val where = TerminalText.safe(ledgerFile.absolutePath) + return McpLedgerOutcome.Failed("Error: cannot read $where: ${reasonOf(e)}") } val text = if (json) { @@ -125,9 +129,9 @@ internal object McpLedgerCli { }, ) } catch (e: McpLedgerReadException) { - McpLedgerOutcome.Failed("Error: ${e.message}") + McpLedgerOutcome.Failed("Error: ${reasonOf(e)}") } catch (e: Exception) { - McpLedgerOutcome.Failed("Error: cannot read the MCP operation ledger: ${e.message}") + McpLedgerOutcome.Failed("Error: cannot read the MCP operation ledger: ${reasonOf(e)}") } /** Matching records newest first, capped at [limit], with the total number of matches. */ @@ -150,9 +154,9 @@ internal object McpLedgerCli { }, ) } catch (e: McpLedgerReadException) { - McpLedgerOutcome.Failed("Error: ${e.message}") + McpLedgerOutcome.Failed("Error: ${reasonOf(e)}") } catch (e: Exception) { - McpLedgerOutcome.Failed("Error: cannot read the MCP operation ledger: ${e.message}") + McpLedgerOutcome.Failed("Error: cannot read the MCP operation ledger: ${reasonOf(e)}") } /** @@ -236,13 +240,17 @@ internal object McpLedgerFormat { /** ISO-8601 in UTC, so a timestamp in a report cannot be misread as local time. */ fun timestamp(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis).toString() + /** [text] made safe to print; see [TerminalText.safe]. */ + fun terminalSafe(text: String): String = TerminalText.safe(text) + fun verification( ledgerFile: File, verification: McpLedgerVerification, ): String = buildString { - appendLine("Ledger: ${ledgerFile.absolutePath}") - appendLine("Files: ${verification.files.size} (${verification.files.joinToString(", ")})") + appendLine("Ledger: ${terminalSafe(ledgerFile.absolutePath)}") + val files = verification.files.joinToString(", ") { terminalSafe(it) } + appendLine("Files: ${verification.files.size} ($files)") appendLine( "Records: ${verification.totalRecords} " + "(${verification.chainedRecords} chained, " + @@ -252,21 +260,23 @@ internal object McpLedgerFormat { if (broken != null) { appendLine( "Chain: BROKEN - first break at record ${broken.recordIndex} " + - "of ${verification.totalRecords}, ${broken.fileName} line ${broken.lineNumber}", + "of ${verification.totalRecords}, ${terminalSafe(broken.fileName)} line ${broken.lineNumber}", ) - appendLine(" ${broken.recordId} at ${timestamp(broken.timestamp)}") + appendLine(" ${terminalSafe(broken.recordId)} at ${timestamp(broken.timestamp)}") appendLine(" reason: ${reasonText(broken.reason)}") - appendLine(" expected ${broken.expectedHash}") - appendLine(" found ${broken.foundHash}") + appendLine(" expected ${terminalSafe(broken.expectedHash)}") + appendLine(" found ${terminalSafe(broken.foundHash)}") } else if (verification.coverageGaps.isNotEmpty()) { + val gaps = verification.coverageGaps.joinToString(", ") { terminalSafe(it) } appendLine( - "Chain: INCOMPLETE - ${verification.coverageGaps.joinToString(", ")} missing, " + + "Chain: INCOMPLETE - $gaps missing, " + "so the chain could not be followed across that gap", ) } else if (verification.verdict == "intact") { + val oldest = verification.oldestVerifiableFile?.let(::terminalSafe) appendLine( "Chain: intact - oldest verifiable record is " + - "${verification.oldestVerifiableFile} line ${verification.oldestVerifiableLine}", + "$oldest line ${verification.oldestVerifiableLine}", ) } else { val explanation = @@ -306,7 +316,7 @@ internal object McpLedgerFormat { if (entries.isEmpty()) { return if (totalMatches == 0) "No matching ledger records." else "No records to show." } - val toolWidth = entries.maxOf { it.record.toolName.length }.coerceAtMost(40) + val toolWidth = entries.maxOf { terminalSafe(it.record.toolName).length }.coerceAtMost(40) val policyWidth = entries.maxOf { policyOf(it.record).length } val body = entries.joinToString("\n") { entry -> @@ -314,7 +324,7 @@ internal object McpLedgerFormat { buildString { append(timestamp(record.timestamp)) append(" ") - append(record.toolName.padEnd(toolWidth)) + append(terminalSafe(record.toolName).padEnd(toolWidth)) append(" ") append(policyOf(record).padEnd(policyWidth)) append(" ") @@ -322,8 +332,8 @@ internal object McpLedgerFormat { append("ms ") append(if (record.isError) "error" else "ok ") append(" hash ") - append(record.hash?.take(12) ?: "unverifiable") - record.errorSnippet?.let { append("\n error: ").append(it) } + append(record.hash?.take(12)?.let(::terminalSafe) ?: "unverifiable") + record.errorSnippet?.let { append("\n error: ").append(terminalSafe(it)) } } } val footer = diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/TerminalText.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/TerminalText.kt new file mode 100644 index 0000000000..5f18808993 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/TerminalText.kt @@ -0,0 +1,73 @@ +package ai.rever.boss.cli + +/** + * Text that came from somewhere other than the operator, made safe to print to their terminal. + * + * The CLI's human reports are read by an operator deciding what to trust: an audit ledger, a tool + * catalogue, a health report. Every string in them that originates outside BOSS - a tool's error + * text, a plugin's tool name or description, a project directory name - is attacker-influenceable, + * and printed raw it does two things. A newline starts a second, perfectly formatted "record" or + * "tool" that was never there, and an ESC sequence acts on the terminal itself: clearing the screen + * over what the operator was about to read, retitling the window, or (on terminals that honour + * OSC 52) writing the clipboard. JSON output is unaffected, because its serializer already escapes + * control characters; this is for the human renderings. + */ +internal object TerminalText { + private const val HEX_RADIX = 16 + private const val HEX_WIDTH = 4 + private const val ESCAPE_HEADROOM = 16 + + /** + * [text] with every character that could act on a terminal, or on the reader, written out as a + * visible `\uXXXX` escape instead, and line breaks and tabs as `\n`, `\r` and `\t`. + * + * Escaped rather than dropped, so the operator can see that something was there. Covered are the + * C0 and C1 controls including DEL, and the Unicode characters that reorder or hide text: the + * bidi embeddings, overrides and isolates (which can make one name read as another), zero-width + * characters and the line and paragraph separators. Ordinary text, including accents, CJK and + * emoji, is returned unchanged. + */ + fun safe(text: String): String = escape(text, keepLineBreaks = false) + + /** + * [text] split into lines, each escaped as [safe] does, but with real line breaks kept as the + * split points. For a value that is legitimately several lines, such as a tool's description. + * Only the caller can say how each line is then presented, so it must indent or prefix every + * one of them; [safeIndented] does that. + */ + fun safeLines(text: String): List = escape(text.replace("\r\n", "\n"), keepLineBreaks = true).split('\n') + + /** [text] as [safeLines], with [indent] in front of every line, so no line can pass for a heading. */ + fun safeIndented( + text: String, + indent: String, + ): String = safeLines(text).joinToString("\n") { indent + it } + + private fun escape( + text: String, + keepLineBreaks: Boolean, + ): String { + if (text.none { it in "\n\r\t" || needsEscaping(it) }) return text + return buildString(text.length + ESCAPE_HEADROOM) { + for (ch in text) { + when { + ch == '\n' && keepLineBreaks -> append('\n') + ch == '\n' -> append("\\n") + ch == '\r' -> append("\\r") + ch == '\t' -> append("\\t") + needsEscaping(ch) -> append("\\u").append(ch.code.toString(HEX_RADIX).padStart(HEX_WIDTH, '0')) + else -> append(ch) + } + } + } + } + + private fun needsEscaping(ch: Char): Boolean = + ch.code < 0x20 || + ch.code in 0x7f..0x9f || + ch.code in 0x200b..0x200f || + ch.code in 0x2028..0x202e || + ch.code in 0x2066..0x2069 || + ch.code == 0x061c || + ch.code == 0xfeff +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HumanReportsTerminalSafetyTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HumanReportsTerminalSafetyTest.kt new file mode 100644 index 0000000000..0979f809cc --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HumanReportsTerminalSafetyTest.kt @@ -0,0 +1,119 @@ +package ai.rever.boss.cli + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The human renderings of `boss status`, `boss doctor` and `boss mcp list|describe` print strings + * that come from outside BOSS - a plugin's tool name and description, the active project's name, a + * health finding quoting a plugin - to an operator who is deciding what to trust. Printed raw, a + * newline forges a second "tool" or "problem" that was never there and an ESC sequence acts on the + * terminal. JSON output escapes control characters already and is not covered here. + */ +class HumanReportsTerminalSafetyTest { + private val esc = 27.toChar() + private val bel = 7.toChar() + + private fun assertNoTerminalControl(text: String) { + val offenders = text.filter { (it.code < 0x20 && it != '\n') || it.code == 0x7f || it.code in 0x80..0x9f } + val codes = offenders.map { it.code } + assertTrue(offenders.isEmpty(), "control characters would reach the operator's terminal: $codes\n$text") + } + + private fun tool( + name: String, + pluginId: String, + description: String, + ): JsonObject = + buildJsonObject { + put("name", name) + put("pluginId", pluginId) + put("description", description) + } + + private fun toolsJson(vararg tools: JsonObject): String = buildJsonArray { tools.forEach { add(it) } }.toString() + + @Test + fun `a tool description cannot forge a second entry in the tool list`() { + val forged = "Does helpful things.\n• git_status (boss-workspace)\n Reads git status." + val hostile = tool("helper", "evil-plugin", forged) + val json = toolsJson(tool("read_file", "boss-workspace", "Reads a file."), hostile) + + val text = formatHumanToolsList(json, filterQuery = null) + + val entries = text.lines().filter { it.startsWith("• ") } + assertEquals(listOf("• read_file (boss-workspace)", "• helper (evil-plugin)"), entries, text) + } + + @Test + fun `a tool list neutralises escape sequences in names, plugin ids and descriptions`() { + val json = toolsJson(tool("run$esc[2J", "plugin$esc]0;pwned$bel", "desc$esc[31m red")) + + val text = formatHumanToolsList(json, filterQuery = null) + + assertNoTerminalControl(text) + assertTrue(text.contains("\\u001b"), "the escape must stay visible to the operator:\n$text") + } + + @Test + fun `an unparseable tool list response is not printed raw`() { + val text = formatHumanToolsList("not json $esc[2J\nsecond line", filterQuery = null) + + assertNoTerminalControl(text) + } + + @Test + fun `tool detail neutralises the name, plugin id and description`() { + val obj = tool("run$esc[2J", "plugin$esc[1m", "first\n$esc[2Jsecond\nAccess: Standard") + + val text = formatHumanToolDetail(obj) + + assertNoTerminalControl(text) + val accessLines = text.lines().filter { it.startsWith("Access:") } + assertEquals(1, accessLines.size, "a description must not forge a second Access line:\n$text") + } + + @Test + fun `status neutralises the active project name and version`() { + val json = + buildJsonObject { + put("version", "1.0$esc[2J") + put("activeProject", "/home/me/proj$esc]0;pwned${bel}\nHealth: OK") + }.toString() + + val text = formatHumanStatus(json) + + assertNoTerminalControl(text) + val healthLines = text.lines().filter { it.startsWith("Health:") } + assertTrue(healthLines.isEmpty(), "a project name must not forge a Health line:\n$text") + } + + @Test + fun `doctor neutralises finding text and area names`() { + val health = + buildJsonObject { + putJsonArray("findings") { + add( + buildJsonObject { + put("severity", "warn") + put("summary", "plugin evil$esc[2J failed") + put("remedy", "reload$esc]0;pwned$bel") + }, + ) + } + putJsonArray("unchecked") { add(JsonPrimitive("area$esc[31m")) } + } + + val text = formatDoctorReport(health) + + assertNoTerminalControl(text) + assertTrue(text.contains("\\u001b"), text) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/McpLedgerCliTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/McpLedgerCliTest.kt index 0ebc312fd9..e975f9d8d3 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/McpLedgerCliTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/McpLedgerCliTest.kt @@ -24,6 +24,12 @@ import kotlin.test.assertTrue * covered by the argument conventions `BossMcpCliTest` already exercises. */ class McpLedgerCliTest { + private val esc = 27.toChar() + private val bel = 7.toChar() + private val csi = 0x9b.toChar() + private val del = 0x7f.toChar() + private val rlo = 0x202e.toChar() + private val tempFiles = mutableListOf() private fun createTempLedgerFile(): File { @@ -298,6 +304,93 @@ class McpLedgerCliTest { assertTrue(failure.contains("record 1 of 2"), "the break must be located: $failure") } + private fun recordWithError( + ledger: McpOperationLedger, + toolName: String, + errorSnippet: String, + ) { + ledger.record( + toolName = toolName, + providerId = "provider", + policyApplied = McpPolicyAction.ALLOW, + approvalDisposition = McpApprovalDisposition.AUTO_ALLOWED, + durationMs = 7L, + isError = true, + errorSnippet = errorSnippet, + rawArgs = emptyMap(), + ) + } + + private fun assertNoTerminalControl(text: String) { + val offenders = text.filter { (it.code < 0x20 && it != '\n') || it.code == 0x7f || it.code in 0x80..0x9f } + val codes = offenders.map { it.code } + assertTrue(offenders.isEmpty(), "control characters would reach the operator's terminal: $codes") + } + + /** + * The ledger is an audit trail, and `tail`/`search` are how an operator reads it. An error + * snippet is text a tool produced, so a newline in it used to start a second, perfectly + * formatted "record" line that was never written by the ledger - a forged audit row in the + * one view an operator trusts - and an ESC sequence in it reached the terminal unfiltered. + */ + @Test + fun `tail cannot be made to print a forged record row or a terminal escape sequence`() { + val file = createTempLedgerFile() + val ledger = McpOperationLedger(ledgerFile = file) + val forgedRow = "2099-01-01T00:00:00Z approved_tool ALLOW/AUTO_ALLOWED 1ms ok hash 000000000000" + recordWithError(ledger, "read_file", "denied\n$forgedRow$esc[2J$esc]0;pwned$bel") + + val text = okText(McpLedgerCli.tail(file.absolutePath, lines = 10, query = McpLedgerQuery(), json = false)) + + assertNoTerminalControl(text) + assertTrue(text.lines().none { it.startsWith("2099-") }, "a forged row must not start its own line:\n$text") + assertTrue(text.contains("\\u001b"), "the escape must stay visible to the operator, not vanish:\n$text") + assertEquals(1, text.lines().count { it.startsWith(" error: ") }, "one record, one error line:\n$text") + } + + @Test + fun `tail neutralises control and direction-override characters in a tool name`() { + val file = createTempLedgerFile() + val ledger = McpOperationLedger(ledgerFile = file) + record(ledger, "safe$esc[31m${rlo}tool") + + val text = okText(McpLedgerCli.tail(file.absolutePath, lines = 10, query = McpLedgerQuery(), json = false)) + + assertNoTerminalControl(text) + assertTrue(text.none { it == rlo }, "a bidi override can visually reorder the tool name:\n$text") + } + + @Test + fun `terminalSafe leaves ordinary text alone and escapes only what could act on a terminal`() { + val ordinary = "read_file: café 日本語 🚀 (path=/tmp/a b) [ok]" + assertEquals(ordinary, McpLedgerFormat.terminalSafe(ordinary)) + + assertEquals("a\\nb\\tc\\r", McpLedgerFormat.terminalSafe("a\nb\tc\r")) + assertEquals("\\u001b[2J", McpLedgerFormat.terminalSafe("$esc[2J")) + assertEquals("\\u009b31m", McpLedgerFormat.terminalSafe("${csi}31m"), "the C1 CSI is one-byte ESC[") + assertEquals("\\u007f", McpLedgerFormat.terminalSafe("$del")) + assertEquals("\\u202e", McpLedgerFormat.terminalSafe("$rlo")) + } + + @Test + fun `verify neutralises control characters read back from a tampered ledger`() { + val file = createTempLedgerFile() + val ledger = McpOperationLedger(ledgerFile = file) + record(ledger, "tool_1") + record(ledger, "tool_2") + + // The break report prints the record id straight out of the file it just found altered, + // which is exactly the file an attacker who altered it controls. + val lines = file.readLines().toMutableList() + lines[0] = Regex("\"id\":\"([^\"]+)\"").replace(lines[0]) { "\"id\":\"${it.groupValues[1]}\\u001b[2J\\n\"" } + file.writeText(lines.joinToString("\n") + "\n") + + val failure = failureMessage(McpLedgerCli.verify(file.absolutePath, json = false)) + + assertTrue(failure.contains("BROKEN"), failure) + assertNoTerminalControl(failure) + } + @Test fun `verify refuses to report an absent ledger as intact`() { val file = File(createTempLedgerFile().parentFile, "absent.jsonl") diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/TerminalTextTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/TerminalTextTest.kt new file mode 100644 index 0000000000..ba3d7697ed --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/TerminalTextTest.kt @@ -0,0 +1,46 @@ +package ai.rever.boss.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TerminalTextTest { + private val esc = 27.toChar() + private val bel = 7.toChar() + private val csi = 0x9b.toChar() + private val rlo = 0x202e.toChar() + private val zwsp = 0x200b.toChar() + private val bom = 0xfeff.toChar() + + @Test + fun `ordinary text passes through untouched`() { + val ordinary = "git_status (boss-workspace) - café 日本語 🚀 [ok]" + assertEquals(ordinary, TerminalText.safe(ordinary)) + } + + @Test + fun `safe writes escapes and line breaks out visibly`() { + assertEquals("a\\nb\\r\\tc", TerminalText.safe("a\nb\r\tc")) + assertEquals("\\u001b[2J\\u0007", TerminalText.safe("$esc[2J$bel")) + assertEquals("\\u009b31m", TerminalText.safe("${csi}31m"), "the single-byte CSI is an ESC sequence too") + assertEquals("\\u202etool", TerminalText.safe("${rlo}tool"), "a bidi override can reorder a name") + assertEquals("\\u200bx\\ufeff", TerminalText.safe("${zwsp}x$bom")) + } + + @Test + fun `a multi-line value keeps its lines but every one is indented, so none can pass for a heading`() { + val description = "Reads a file.\n• git_status (boss-workspace)\r\n Runs git." + + val rendered = TerminalText.safeIndented(description, indent = " ") + + assertEquals(3, rendered.lines().size) + assertTrue(rendered.lines().all { it.startsWith(" ") }, rendered) + val bulletLines = rendered.lines().filter { it.startsWith("•") } + assertTrue(bulletLines.isEmpty(), "the injected bullet must not start a line:\n$rendered") + } + + @Test + fun `safeLines still neutralises controls inside a line`() { + assertEquals(listOf("one", "\\u001b[31mtwo"), TerminalText.safeLines("one\n$esc[31mtwo")) + } +}