Harden WebCRT Enter audit so logged commands match the visible line.

Fix bare Enter re-audits, Tab expansion lag, and history mid-line edits by preferring the xterm/device row at Enter and rejecting empty-prompt snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-02 22:01:28 +08:00
parent fcef3b59ac
commit f4ce8caf3e
4 changed files with 854 additions and 77 deletions

View file

@ -499,12 +499,16 @@ def looks_like_password_prompt(text: str) -> bool:
def normalize_audit_line(line: str) -> str:
"""Normalize xterm-visible input line for audit (keep device prompt prefix)."""
s = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.", "", str(line or ""))
return s.replace("\r", "").rstrip()
# Never glue multiple PTY rows into one audit command.
s = s.replace("\r", "\n").split("\n", 1)[0]
return s.rstrip()
def finalize_audit_line(line: str) -> str:
"""Apply echoed backspaces then normalize (PTY stdout fallback only)."""
s = _strip_ansi(str(line or ""))
# Keep a single logical line — swallowing \\n used to glue command + device legend.
s = s.replace("\r", "\n").split("\n", 1)[0]
out: list[str] = []
for ch in s:
if ch in ("\b", "\x7f"):
@ -521,6 +525,56 @@ def _strip_ansi(text: str) -> str:
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.", "", str(text or ""))
# Huawei/ZTE interface-brief legends and pager crumbs often stick to the prompt line
# after ANSI cursor moves are stripped — never treat them as part of the command.
_AUDIT_CMD_CONTAMINATION = re.compile(
r"(?:"
r"\*down:"
r"|!down:"
r"|\^down:"
r"|\([a-z]{1,3}\):"
r"|PHY:\s*Physical"
r"|----\s*More\s*----"
r"|InUti/OutUti"
r"|Interface\s+PHY\b"
r"|The number of interface"
r"|Local Intf\s+Neighbor"
r")",
flags=re.I,
)
def sanitize_audit_command(line: str | None) -> str | None:
"""Clip prompt+command and drop device-output contamination."""
if line is None:
return None
s = normalize_audit_line(line)
if not s.strip():
return None
m = _AUDIT_CMD_CONTAMINATION.search(s)
if m:
s = s[: m.start()].rstrip()
if not is_auditable_command_line(s):
return None
# Collapse spaces left by mid-line overwrite deletes (``dis interface``).
prompt_m = re.match(
r"^(?:[\w.-]+(?:\([^)]+\))*[#>]|<[^>]+>|\[[^\]]+\])\s*",
s,
flags=re.I,
)
if prompt_m:
s = prompt_m.group(0) + " ".join(s[prompt_m.end() :].split())
else:
s = " ".join(s.split())
cmd = _command_tail(s)
# Guard against absurd glued blobs that still look like a prompt line.
if len(cmd) > 240 or len(s) > 300:
return None
if _AUDIT_CMD_CONTAMINATION.search(s):
return None
return s[:512]
def _is_prompt_command_line(line: str) -> bool:
"""True when line looks like ``hostname#command`` (non-empty command tail)."""
s = str(line or "").strip()
@ -569,6 +623,14 @@ def _is_device_output_line(line: str) -> bool:
return True
if re.match(r"^enter configuration commands", low):
return True
if _AUDIT_CMD_CONTAMINATION.search(s):
# Legend / pager text alone, or glued onto a prompt line.
if not _has_cli_prompt_prefix(s):
return True
# Prompt + legend glued (ANSI stripped): treat as contaminated output.
cmd = _command_tail(s)
if _AUDIT_CMD_CONTAMINATION.search(cmd):
return True
return False
@ -602,24 +664,106 @@ def _is_prompt_only_line(line: str) -> bool:
)
def extract_last_prompt_command(text: str) -> str | None:
"""Last prompt+command line in PTY transcript (tab-complete redraw aware).
def _stdout_has_inplace_edit(text: str) -> bool:
"""True when the *current* input row was rewritten with cursor CSI.
Network devices often refresh the current input with ``\\r`` after tab; the
final segment after the last carriage return is the ground truth for audit.
Only inspects the last fragment (live input line). Older history-edit CSI still
sitting in ``stdout_tail`` must not make bare Enter look like an in-place edit.
Excludes the common ``---- More ----`` wipe (``ESC[16D``).
"""
s = _strip_ansi(text)
s = str(text or "")[-4000:]
if not s:
return False
s = re.sub(r"----\s*More\s*----\x1b\[16D\s*\x1b\[16D", "", s, flags=re.I)
frags = [f for f in re.split(r"\n+", s) if f.strip()]
if not frags:
return False
frag = frags[-1]
rendered = render_pty_line(frag)
if _is_prompt_only_line(rendered) or _is_prompt_only_line(normalize_audit_line(frag)):
return False
if not _is_prompt_command_line(rendered):
return False
for m in re.finditer(r"\x1b\[([0-9]*)([DCP@])", frag):
n_s, cmd = m.group(1), m.group(2)
try:
n = int(n_s) if n_s else 1
except ValueError:
n = 1
if cmd in "CP@":
return True
if cmd == "D" and n != 16:
return True
return False
def _live_input_line(stdout_tail: str) -> str:
"""Visible text on the current input row (after last NL / CR redraw).
Huawei/ZTE often redraw the next prompt with bare ``\\r`` onto the previous
output row. Taking the last *non-empty* CR segment avoids leftover glyphs
(``Ethernet...\\r<r1>`` → ``<r1>``) and trailing CRs (``[~r1]\\r\\r`` → ``[~r1]``).
"""
s = str(stdout_tail or "")[-2000:]
frags = [f for f in re.split(r"\n+", s) if f.strip()]
if not frags:
return ""
last = frags[-1]
if "\r" in last:
parts = last.split("\r")
non_empty = [p for p in parts if p.strip()]
last = non_empty[-1] if non_empty else ""
return render_pty_line(last).strip()
def _live_input_idle(stdout_tail: str) -> bool:
"""True when the device is sitting on a bare prompt (no current command text).
Empty stdout is *not* idle — callers may only have an xterm audit_line (unit tests
/ early enter). Idle requires a positive bare-prompt observation.
"""
if not str(stdout_tail or "").strip():
return False
live = _live_input_line(stdout_tail)
return (not live) or _is_prompt_only_line(live)
def extract_last_prompt_command(text: str) -> str | None:
"""Last prompt+command line in PTY transcript (tab-complete / history-recall aware).
Network devices often refresh the current input with ``\\r`` after tab, or rewrite
the line in-place with CSI cursor moves after Up-arrow history recall. Plain ANSI
stripping would glue ``commit`` + ``ip address...``; we render CSI first.
"""
s = str(text or "")
if not s.strip():
return None
# Prefer the tail after the last in-line refresh (tab completion / prompt rewrite).
tail = s.rsplit("\r", 1)[-1]
tail_line = tail.split("\n")[-1].rstrip()
if _is_prompt_command_line(tail_line):
return finalize_audit_line(tail_line)[:512]
for frag in reversed(re.split(r"[\r\n]+", s)):
line = frag.strip()
candidates: list[tuple[int, str]] = []
for frag in re.split(r"\n+", s):
if not frag.strip("\r"):
continue
edited = 1 if re.search(r"\x1b\[[0-9]*[DCP@]", frag) else 0
rendered = render_pty_line(frag)
if rendered.strip():
candidates.append((edited, rendered))
if "\r" in frag:
sub = frag.rsplit("\r", 1)[-1]
edited_sub = 1 if re.search(r"\x1b\[[0-9]*[DCP@]", sub) else 0
candidates.append((edited_sub, render_pty_line(sub)))
# Prefer chronologically later rows; among the last few, prefer CSI-edited rows
# so a stale pre-edit recall does not win over the post-edit line.
for edited, line in reversed(candidates):
if edited and line and _is_prompt_command_line(line):
clipped = sanitize_audit_command(normalize_audit_line(line))
if clipped:
return clipped
for _edited, line in reversed(candidates):
if line and _is_prompt_command_line(line):
return finalize_audit_line(line)[:512]
clipped = sanitize_audit_command(normalize_audit_line(line))
if clipped:
return clipped
return None
@ -636,6 +780,151 @@ def _command_tail(line: str) -> str:
return s.strip()
def _token_appears(token: str, cmd: str) -> bool:
"""Whole-token match so ``ip`` does not hit the letters inside ``display``."""
t = str(token or "").replace("\t", "").strip()
if not t:
return False
parts = str(cmd or "").split()
if t in parts:
return True
if parts and (parts[-1].startswith(t) or t.startswith(parts[-1])):
return True
return False
def _looks_like_edit_fragment(typed: str, full_line: str) -> bool:
"""True when stdin bytes look like a mid-line history edit, not a full command.
Up-arrow recall + cursor edit only sends newly typed chars (``33``, ``ip``), while
the device / xterm holds the full ``ip address ... 33`` line.
"""
t = str(typed or "").replace("\t", "").strip()
if not t or not full_line:
return False
ph_cmd = _command_tail(full_line)
if not ph_cmd or ph_cmd == t:
return False
# Multi-word recalled command vs short typed fragment.
if len(ph_cmd.split()) >= 2 and (" " not in t) and len(t) <= 64:
return True
if len(t) * 2 < len(ph_cmd) and (_token_appears(t, ph_cmd) or ph_cmd.endswith(t)):
return True
return False
def render_pty_line(text: str) -> str:
"""Best-effort single-row CSI renderer for Huawei/ZTE history-recall redraws."""
raw = str(text or "").split("\n")[-1]
cells: list[str] = []
cursor = 0
i = 0
while i < len(raw):
ch = raw[i]
if ch == "\x1b" and i + 1 < len(raw):
nxt = raw[i + 1]
if nxt == "[":
j = i + 2
while j < len(raw) and raw[j] not in "ABCDEFGHJKSTfhlmnpsu":
j += 1
if j >= len(raw):
break
final = raw[j]
params_s = raw[i + 2 : j]
try:
n = int(params_s) if params_s else 1
except ValueError:
n = 1
if final == "D":
cursor = max(0, cursor - n)
elif final == "C":
cursor = min(len(cells), cursor + n)
elif final == "G":
cursor = max(0, n - 1) if n > 0 else 0
if cursor > len(cells):
cells.extend([" "] * (cursor - len(cells)))
elif final == "K":
mode = int(params_s) if params_s else 0
if mode == 0:
cells = cells[:cursor]
elif mode == 1:
for k in range(min(cursor, len(cells))):
cells[k] = " "
elif mode == 2:
cells = []
cursor = 0
elif final == "P":
del cells[cursor : cursor + n]
elif final == "@":
cells[cursor:cursor] = [" "] * n
i = j + 1
continue
if nxt == "O" and i + 2 < len(raw):
i += 3
continue
i += 2
continue
if ch == "\r":
cursor = 0
i += 1
continue
if ch in ("\b", "\x7f"):
if cursor > 0:
cursor -= 1
if cursor < len(cells):
del cells[cursor]
i += 1
continue
if ord(ch) < 32:
i += 1
continue
if cursor < len(cells):
cells[cursor] = ch
else:
if cursor > len(cells):
cells.extend([" "] * (cursor - len(cells)))
cells.append(ch)
cursor += 1
i += 1
return "".join(cells).rstrip()
def _is_cli_expansion(short_line: str, long_line: str) -> bool:
"""True when ``long_line`` looks like Tab / abbreviation expansion of ``short_line``."""
short = sanitize_audit_command(short_line) or normalize_audit_line(short_line)
long = sanitize_audit_command(long_line)
if not long:
return False
a = " ".join(_command_tail(short).split())
b = " ".join(_command_tail(long).split())
if not a or not b or a == b:
return False
# Reject glued device legends that merely startswith the short command.
if _AUDIT_CMD_CONTAMINATION.search(_command_tail(long_line) or ""):
return False
if len(b) > len(a) + 80:
return False
if b.startswith(a) and len(b) > len(a):
# Expansion should stay within CLI token charset (no *!^ legend glue).
extra = b[len(a) :]
if re.search(r"[*!^]", extra):
return False
return True
ta, tb = a.split(), b.split()
if not ta or len(ta) > len(tb):
return False
# Only allow long tokens to extend short tokens (Tab), never the reverse
# (``interface`` vs ``ip`` used to false-match via startswith both ways).
# Case-insensitive: Huawei expands ``lo`` → ``LoopBack``.
for i, tok in enumerate(ta):
other = tb[i]
if other.lower() == tok.lower() or other.lower().startswith(tok.lower()):
continue
return False
# Remaining long tokens are Tab-inserted middle/trailing words.
return len(tb) >= len(ta) and len(b) <= len(a) + 80
def _attach_prompt_prefix(typed: str, hint: str) -> str | None:
cmd = str(typed or "").strip()
if not cmd:
@ -664,45 +953,185 @@ def pick_audit_command(
source: str = "stdin",
) -> str | None:
"""Pick auditable text for one completed stdin line (actual send + optional xterm hint)."""
typed = normalize_audit_line(stdin_line).strip()
typed_raw = str(stdin_line or "")
typed_has_tab = "\t" in typed_raw
typed = normalize_audit_line(typed_raw).strip()
hint = normalize_audit_line(audit_hint) if audit_hint else ""
src = str(source or "stdin")
compact_typed = typed.replace("\t", "").strip()
def _out(cmd: str | None) -> str | None:
if not cmd:
return None
cleaned = sanitize_audit_command(cmd)
if cleaned:
return cleaned
# early/post_login may lack a prompt prefix — still strip legend glue.
s = normalize_audit_line(cmd)
m = _AUDIT_CMD_CONTAMINATION.search(s)
if m:
s = s[: m.start()].rstrip()
if not s or _AUDIT_CMD_CONTAMINATION.search(s):
return None
if src in ("post_login", "early_stdin") or (
src == "stdin" and not _has_cli_prompt_prefix(s) and not _is_prompt_only_line(s)
):
return s[:512]
return None
if typed and _is_device_output_line(typed):
return None
if src == "post_login" and typed:
return typed
return _out(typed.replace("\t", " ").strip() or typed)
# No keystroke payload and no auditable xterm snapshot → only scrape stdout when
# the device just did an in-place history edit (CSI). Bare Enter must not re-audit.
if not compact_typed and not (hint and is_auditable_command_line(hint)):
if src != "early_stdin":
if _stdout_has_inplace_edit(stdout_tail):
echoed = extract_last_prompt_command(stdout_tail)
if echoed:
return _out(echoed)
return None
# Empty Enter while the live row is a bare prompt: never trust a stale xterm hint
# (walk-up into the previous command echo). Applies even when callers skip the
# resolve_audit_commands idle gate.
if not compact_typed and src == "stdin" and str(stdout_tail or "").strip():
live = _live_input_line(stdout_tail)
if ((not live) or _is_prompt_only_line(live)) and not _stdout_has_inplace_edit(
stdout_tail
):
return None
def _from_device_echo() -> str | None:
def _usable(full: str) -> bool:
if not full or not is_auditable_command_line(full):
return False
ph_cmd = _command_tail(full) or full
if typed_has_tab or not compact_typed:
return True
if ph_cmd.startswith(compact_typed) or compact_typed.startswith(ph_cmd):
return True
if _token_appears(compact_typed, ph_cmd):
return True
# History fragment: only accept echo that already contains the typed token.
if _looks_like_edit_fragment(compact_typed, full):
return _token_appears(compact_typed, ph_cmd)
return False
# Prefer live stdout (CSI-rendered history line) over possibly stale prompt_hint.
if stdout_tail:
ext = extract_last_prompt_command(stdout_tail)
if ext and _usable(ext):
return ext
if prompt_hint:
ph = sanitize_audit_command(prompt_hint) or (
normalize_audit_line(prompt_hint)
if is_auditable_command_line(prompt_hint)
else ""
)
if ph and _usable(ph):
return ph
return None
def _prefer_expanded(base: str) -> str:
"""Reconcile xterm hint with live device echo (Tab / history mid-line edits)."""
echoed = _from_device_echo()
if not echoed:
return base
base_n = sanitize_audit_command(base) or normalize_audit_line(base)
echo_n = sanitize_audit_command(echoed) or echoed
bc = " ".join(_command_tail(base_n).split())
ec = " ".join(_command_tail(echo_n).split())
if not ec or ec == bc:
return base_n
# In-place history edit on the device: always trust the CSI-rendered echo.
if _stdout_has_inplace_edit(stdout_tail):
return echo_n
# Tab completion only: accept longer real expansions. Never re-inflate a
# shorter post-delete snapshot back into a longer stale echo without Tab.
if typed_has_tab and _is_cli_expansion(base_n, echo_n):
return echo_n
# Tab: prefer the longer device expansion when token heads match
# (``interface lo`` → ``interface LoopBack 1``).
if typed_has_tab:
bt, et = bc.split(), ec.split()
if bt and et and bt[0].lower() == et[0].lower() and len(ec) > len(bc):
return echo_n
bt, et = bc.split(), ec.split()
if bt and et and bt[0].lower() == et[0].lower():
if len(et) < len(bt):
return echo_n
if len(et) == len(bt) and et != bt:
return echo_n
return base_n
return base_n
# Tab completion: device echo is authoritative (xterm snapshot may still show
# the pre-expansion fragment ``interface lo`` when Enter is delayed after Tab).
if typed_has_tab:
echoed = _from_device_echo()
if echoed:
return _out(echoed)
if hint and is_auditable_command_line(hint):
return _out(hint)
# Never persist literal Tab into audit_log.
typed = compact_typed
# xterm visible row at Enter is usually authoritative — but history mid-line
# deletes may leave a stale longer snapshot in audit_line while device echo
# is already shorter.
if hint and is_auditable_command_line(hint):
return _out(_prefer_expanded(hint))
if src == "early_stdin" and typed and not _is_prompt_only_line(typed):
return typed
if hint and is_auditable_command_line(hint):
if not typed:
return hint
hint_cmd = _command_tail(hint) or hint
if typed == hint_cmd or hint_cmd.startswith(typed):
return hint
# Material disagreement — record bytes actually sent, not xterm hint.
return typed if typed else hint
echoed = _from_device_echo()
if echoed:
return _out(echoed)
return _out(typed)
if typed and is_auditable_command_line(typed):
return typed
return _out(_prefer_expanded(typed))
# History/arrow edits: stdin may be only the newly typed fragment ("33", "ip").
# Never glue that onto the prompt as "[*r1]33" — prefer device-rendered full line.
for candidate in (
sanitize_audit_command(hint) if hint else None,
_from_device_echo(),
sanitize_audit_command(prompt_hint) if prompt_hint else None,
):
if not candidate or not is_auditable_command_line(candidate):
continue
if not _looks_like_edit_fragment(typed, candidate):
continue
# Require the candidate to already reflect the typed edit (token-level).
if compact_typed and not _token_appears(compact_typed, _command_tail(candidate)):
continue
return _out(candidate)
for prefix_src in (hint, prompt_hint):
if prefix_src and _looks_like_edit_fragment(typed, prefix_src):
continue
enriched = _attach_prompt_prefix(typed, prefix_src)
if enriched and is_auditable_command_line(enriched):
return enriched
# Reject enrichment that clearly dropped the recalled command body.
if prompt_hint and _looks_like_edit_fragment(typed, prompt_hint):
continue
return _out(_prefer_expanded(enriched))
if stdout_tail:
ext = extract_last_prompt_command(stdout_tail)
if ext and is_auditable_command_line(ext):
ext_cmd = _command_tail(ext) or ext
if typed and (typed in ext_cmd or ext_cmd.endswith(typed)):
return ext
echoed = _from_device_echo()
if echoed:
return _out(echoed)
if src == "stdin" and typed and not _is_prompt_only_line(typed):
return typed
# Last resort: still avoid publishing bare fragments when we have a full echo.
if prompt_hint and _looks_like_edit_fragment(typed, prompt_hint):
ph = sanitize_audit_command(prompt_hint)
if ph:
return _out(ph)
return _out(typed)
return None
@ -722,8 +1151,35 @@ def resolve_audit_commands(
return []
if not buf_lines:
if audit_line and is_auditable_command_line(normalize_audit_line(audit_line)):
return [normalize_audit_line(audit_line)]
# History mid-line CSI edits can complete Enter with empty stdin.
hint = str(audit_line or "").strip()
has_stdout = bool(str(stdout_tail or "").strip())
live = _live_input_line(stdout_tail) if has_stdout else ""
inplace = _stdout_has_inplace_edit(stdout_tail)
# Bare Enter / empty stdin: never re-audit from a stale xterm audit_line that
# walked up into the previous command echo. Only proceed when the *live*
# device row still shows a command (history recall) or an in-place CSI edit.
if has_stdout and not inplace:
if (not live) or _is_prompt_only_line(live) or not is_auditable_command_line(live):
return []
if hint and is_auditable_command_line(normalize_audit_line(hint)):
cmd = pick_audit_command(
"",
hint,
prompt_hint=prompt_hint,
stdout_tail=stdout_tail,
source=src,
)
return [cmd] if cmd else []
if inplace:
cmd = pick_audit_command(
"",
None,
prompt_hint=prompt_hint,
stdout_tail=stdout_tail,
source=src,
)
return [cmd] if cmd else []
return []
hints: list[str | None] = [None] * len(buf_lines)
@ -737,10 +1193,14 @@ def resolve_audit_commands(
if merged:
if len(merged) == len(buf_lines):
hints = list(merged)
else:
start = max(0, len(buf_lines) - len(merged))
elif len(merged) < len(buf_lines):
start = len(buf_lines) - len(merged)
for j, h in enumerate(merged):
hints[start + j] = h
else:
# Duplicate Enter can produce more audit_line snapshots than completed
# stdin lines — keep the trailing hints (most recent).
hints = list(merged[-len(buf_lines) :])
out: list[str] = []
for i, typed in enumerate(buf_lines):

View file

@ -393,17 +393,37 @@ class WebcrtSession:
audit_lines: list[str] | None = None,
) -> None:
"""Extract completed command lines from stdin and emit webcrt.command audits."""
need_echo_settle = False
with self._cmd_buf_lock:
self._cmd_buf, buf_lines = feed_command_line_buffer(self._cmd_buf, text)
redacted = bool(self._password_mode)
if redacted and (buf_lines or audit_line or audit_lines):
self._password_mode = False
if "\r" in text or "\n" in text:
from .webcrt_channel import resolve_audit_commands
# Tab completion / abbrev expansion often lands in stdout just after Enter.
# History-arrow edits only send a short fragment ("33"/"ip") — wait for
# the device CSI redraw of the full line before picking the audit text.
from .webcrt_channel import _looks_like_edit_fragment
if redacted and (buf_lines or audit_line or audit_lines):
lines = ["***"] * max(1, len(buf_lines))
else:
need_echo_settle = any("\t" in str(x) for x in buf_lines) or ("\t" in str(text))
if not need_echo_settle and self._last_prompt_line:
need_echo_settle = any(
_looks_like_edit_fragment(str(x), self._last_prompt_line) for x in buf_lines
)
else:
return
if need_echo_settle and str(source or "stdin") == "stdin" and not redacted:
# Let device redraw / execute-echo update _stdout_tail before we pick the line.
time.sleep(0.12)
with self._cmd_buf_lock:
from .webcrt_channel import resolve_audit_commands
if redacted and (buf_lines or audit_line or audit_lines):
lines = ["***"] * max(1, len(buf_lines) or 1)
else:
try:
lines = resolve_audit_commands(
buf_lines,
audit_line=audit_line,
@ -412,9 +432,12 @@ class WebcrtSession:
stdout_tail=self._stdout_tail,
source=str(source or "stdin"),
)
self._last_prompt_line = ""
else:
lines = []
except Exception:
_log.exception(
"webcrt resolve_audit_commands failed session=%s", self.session_id
)
lines = []
self._last_prompt_line = ""
for cmd in lines:
if not str(cmd).strip():
continue