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: def normalize_audit_line(line: str) -> str:
"""Normalize xterm-visible input line for audit (keep device prompt prefix).""" """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 "")) 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: def finalize_audit_line(line: str) -> str:
"""Apply echoed backspaces then normalize (PTY stdout fallback only).""" """Apply echoed backspaces then normalize (PTY stdout fallback only)."""
s = _strip_ansi(str(line or "")) 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] = [] out: list[str] = []
for ch in s: for ch in s:
if ch in ("\b", "\x7f"): 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 "")) 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: def _is_prompt_command_line(line: str) -> bool:
"""True when line looks like ``hostname#command`` (non-empty command tail).""" """True when line looks like ``hostname#command`` (non-empty command tail)."""
s = str(line or "").strip() s = str(line or "").strip()
@ -569,6 +623,14 @@ def _is_device_output_line(line: str) -> bool:
return True return True
if re.match(r"^enter configuration commands", low): if re.match(r"^enter configuration commands", low):
return True 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 return False
@ -602,24 +664,106 @@ def _is_prompt_only_line(line: str) -> bool:
) )
def extract_last_prompt_command(text: str) -> str | None: def _stdout_has_inplace_edit(text: str) -> bool:
"""Last prompt+command line in PTY transcript (tab-complete redraw aware). """True when the *current* input row was rewritten with cursor CSI.
Network devices often refresh the current input with ``\\r`` after tab; the Only inspects the last fragment (live input line). Older history-edit CSI still
final segment after the last carriage return is the ground truth for audit. 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(): if not s.strip():
return None return None
# Prefer the tail after the last in-line refresh (tab completion / prompt rewrite).
tail = s.rsplit("\r", 1)[-1] candidates: list[tuple[int, str]] = []
tail_line = tail.split("\n")[-1].rstrip() for frag in re.split(r"\n+", s):
if _is_prompt_command_line(tail_line): if not frag.strip("\r"):
return finalize_audit_line(tail_line)[:512] continue
for frag in reversed(re.split(r"[\r\n]+", s)): edited = 1 if re.search(r"\x1b\[[0-9]*[DCP@]", frag) else 0
line = frag.strip() 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): 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 return None
@ -636,6 +780,151 @@ def _command_tail(line: str) -> str:
return s.strip() 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: def _attach_prompt_prefix(typed: str, hint: str) -> str | None:
cmd = str(typed or "").strip() cmd = str(typed or "").strip()
if not cmd: if not cmd:
@ -664,45 +953,185 @@ def pick_audit_command(
source: str = "stdin", source: str = "stdin",
) -> str | None: ) -> str | None:
"""Pick auditable text for one completed stdin line (actual send + optional xterm hint).""" """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 "" hint = normalize_audit_line(audit_hint) if audit_hint else ""
src = str(source or "stdin") 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): if typed and _is_device_output_line(typed):
return None return None
if src == "post_login" and typed: if src == "post_login" and typed:
return typed return _out(typed.replace("\t", " ").strip() or typed)
if src == "early_stdin" and typed and not _is_prompt_only_line(typed): # No keystroke payload and no auditable xterm snapshot → only scrape stdout when
return typed # 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
if hint and is_auditable_command_line(hint): # Empty Enter while the live row is a bare prompt: never trust a stale xterm hint
if not typed: # (walk-up into the previous command echo). Applies even when callers skip the
return hint # resolve_audit_commands idle gate.
hint_cmd = _command_tail(hint) or hint if not compact_typed and src == "stdin" and str(stdout_tail or "").strip():
if typed == hint_cmd or hint_cmd.startswith(typed): live = _live_input_line(stdout_tail)
return hint if ((not live) or _is_prompt_only_line(live)) and not _stdout_has_inplace_edit(
# Material disagreement — record bytes actually sent, not xterm hint. stdout_tail
return typed if typed else hint ):
return None
if typed and is_auditable_command_line(typed): def _from_device_echo() -> str | None:
return typed def _usable(full: str) -> bool:
if not full or not is_auditable_command_line(full):
for prefix_src in (hint, prompt_hint): return False
enriched = _attach_prompt_prefix(typed, prefix_src) ph_cmd = _command_tail(full) or full
if enriched and is_auditable_command_line(enriched): if typed_has_tab or not compact_typed:
return enriched 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: if stdout_tail:
ext = extract_last_prompt_command(stdout_tail) ext = extract_last_prompt_command(stdout_tail)
if ext and is_auditable_command_line(ext): if ext and _usable(ext):
ext_cmd = _command_tail(ext) or ext
if typed and (typed in ext_cmd or ext_cmd.endswith(typed)):
return 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):
echoed = _from_device_echo()
if echoed:
return _out(echoed)
return _out(typed)
if typed and is_auditable_command_line(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):
# 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))
echoed = _from_device_echo()
if echoed:
return _out(echoed)
if src == "stdin" and typed and not _is_prompt_only_line(typed): 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 return None
@ -722,8 +1151,35 @@ def resolve_audit_commands(
return [] return []
if not buf_lines: if not buf_lines:
if audit_line and is_auditable_command_line(normalize_audit_line(audit_line)): # History mid-line CSI edits can complete Enter with empty stdin.
return [normalize_audit_line(audit_line)] 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 [] return []
hints: list[str | None] = [None] * len(buf_lines) hints: list[str | None] = [None] * len(buf_lines)
@ -737,10 +1193,14 @@ def resolve_audit_commands(
if merged: if merged:
if len(merged) == len(buf_lines): if len(merged) == len(buf_lines):
hints = list(merged) hints = list(merged)
else: elif len(merged) < len(buf_lines):
start = max(0, len(buf_lines) - len(merged)) start = len(buf_lines) - len(merged)
for j, h in enumerate(merged): for j, h in enumerate(merged):
hints[start + j] = h 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] = [] out: list[str] = []
for i, typed in enumerate(buf_lines): for i, typed in enumerate(buf_lines):

View file

@ -393,17 +393,37 @@ class WebcrtSession:
audit_lines: list[str] | None = None, audit_lines: list[str] | None = None,
) -> None: ) -> None:
"""Extract completed command lines from stdin and emit webcrt.command audits.""" """Extract completed command lines from stdin and emit webcrt.command audits."""
need_echo_settle = False
with self._cmd_buf_lock: with self._cmd_buf_lock:
self._cmd_buf, buf_lines = feed_command_line_buffer(self._cmd_buf, text) self._cmd_buf, buf_lines = feed_command_line_buffer(self._cmd_buf, text)
redacted = bool(self._password_mode) redacted = bool(self._password_mode)
if redacted and (buf_lines or audit_line or audit_lines): if redacted and (buf_lines or audit_line or audit_lines):
self._password_mode = False self._password_mode = False
if "\r" in text or "\n" in text: if "\r" in text or "\n" in text:
# 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
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 from .webcrt_channel import resolve_audit_commands
if redacted and (buf_lines or audit_line or audit_lines): if redacted and (buf_lines or audit_line or audit_lines):
lines = ["***"] * max(1, len(buf_lines)) lines = ["***"] * max(1, len(buf_lines) or 1)
else: else:
try:
lines = resolve_audit_commands( lines = resolve_audit_commands(
buf_lines, buf_lines,
audit_line=audit_line, audit_line=audit_line,
@ -412,9 +432,12 @@ class WebcrtSession:
stdout_tail=self._stdout_tail, stdout_tail=self._stdout_tail,
source=str(source or "stdin"), source=str(source or "stdin"),
) )
self._last_prompt_line = "" except Exception:
else: _log.exception(
"webcrt resolve_audit_commands failed session=%s", self.session_id
)
lines = [] lines = []
self._last_prompt_line = ""
for cmd in lines: for cmd in lines:
if not str(cmd).strip(): if not str(cmd).strip():
continue continue

View file

@ -169,14 +169,265 @@ class ResolveAuditCommandsTests(unittest.TestCase):
self.assertIn("show ll n b", out[1]) self.assertIn("show ll n b", out[1])
self.assertIn("show intf", out[2]) self.assertIn("show intf", out[2])
def test_hint_disagreement_records_actual_stdin(self) -> None: def test_hint_wins_over_corrupt_stdin(self) -> None:
cmd = pick_audit_command( cmd = pick_audit_command(
"how ll n b", "how ll n b",
"AL5458#show ll n b", "AL5458#show ll n b",
prompt_hint="AL5458#", prompt_hint="AL5458#",
source="stdin", source="stdin",
) )
self.assertEqual(cmd, "how ll n b") self.assertEqual(cmd, "AL5458#show ll n b")
def test_tab_in_stdin_uses_prompt_hint(self) -> None:
cmd = pick_audit_command(
"dis ip int\tbr",
None,
prompt_hint="[~r1]display ip interface brief",
source="stdin",
)
self.assertEqual(cmd, "[~r1]display ip interface brief")
def test_tab_completion_hint_beats_stdin(self) -> None:
out = resolve_audit_commands(
["dis ip int\tbr"],
audit_lines=["[~r1]display ip interface brief"],
source="stdin",
)
self.assertEqual(out, ["[~r1]display ip interface brief"])
def test_insert_edit_hint_beats_stdin(self) -> None:
cmd = pick_audit_command(
"dislay version",
"[~r1]display version",
source="stdin",
)
self.assertEqual(cmd, "[~r1]display version")
def test_device_echo_expands_stale_tab_hint(self) -> None:
"""xterm may still show mid-Tab text while device already redrew the full command."""
cmd = pick_audit_command(
"dis inter\t",
"[~r1]dis interface br",
prompt_hint="[~r1]dis interface brief",
source="stdin",
)
self.assertEqual(cmd, "[~r1]dis interface brief")
def test_is_cli_expansion_abbrev_tokens(self) -> None:
from netx_api.webcrt_channel import _is_cli_expansion
self.assertTrue(_is_cli_expansion("[~r1]dis interface br", "[~r1]dis interface brief"))
self.assertTrue(_is_cli_expansion("<r1>dis ip int", "<r1>display ip interface brief"))
self.assertFalse(_is_cli_expansion("[~r1]dis arp all", "[~r1]dis interface brief"))
def test_rejects_glued_interface_legend(self) -> None:
from netx_api.webcrt_channel import _is_cli_expansion, sanitize_audit_command
dirty = (
"[~r1]display ip interface brief"
"*down: administratively down"
"!down: FIB overload down"
"^down: standby"
"(l): loopback"
)
self.assertEqual(sanitize_audit_command(dirty), "[~r1]display ip interface brief")
cmd = pick_audit_command(
"dis ip int\t",
dirty,
prompt_hint=dirty,
stdout_tail=dirty + "\nInterface PHY\n",
source="stdin",
)
self.assertEqual(cmd, "[~r1]display ip interface brief")
self.assertFalse(_is_cli_expansion("[~r1]display ip interface brief", dirty))
def test_history_recall_csi_render(self) -> None:
from netx_api.webcrt_channel import extract_last_prompt_command, render_pty_line
raw = "[~r1-LoopBack1]commit\x1b[6D \x1b[6Dip address 10.1.1.1 33"
self.assertEqual(render_pty_line(raw), "[~r1-LoopBack1]ip address 10.1.1.1 33")
self.assertEqual(
extract_last_prompt_command(raw + "\n"),
"[~r1-LoopBack1]ip address 10.1.1.1 33",
)
def test_history_edit_fragment_not_glued_to_prompt(self) -> None:
"""Up-arrow edit only types '33' — must not audit as '[*r1-LoopBack1]33'."""
cmd = pick_audit_command(
"33",
None,
prompt_hint="[*r1-LoopBack1]ip address 10.1.1.1 25",
stdout_tail="[*r1-LoopBack1]ip address 10.1.1.1 25\x1b[2D33\n",
source="stdin",
)
self.assertEqual(cmd, "[*r1-LoopBack1]ip address 10.1.1.1 33")
# Even with no usable stdout, never publish bare prompt+fragment.
cmd2 = pick_audit_command(
"33",
None,
prompt_hint="[*r1-LoopBack1]ip address 10.1.1.1 25",
source="stdin",
)
self.assertEqual(cmd2, "[*r1-LoopBack1]ip address 10.1.1.1 25")
def test_history_insert_fragment_ip(self) -> None:
# Real Huawei redraws include a trailing space before CSI left.
stdout = (
"[~r1]display interface brief "
"\x1b[24D \x1b[24D"
"display ip interface brief"
)
cmd = pick_audit_command(
"ip",
None,
prompt_hint="[~r1]display interface brief",
stdout_tail=stdout,
source="stdin",
)
self.assertEqual(cmd, "[~r1]display ip interface brief")
def test_history_midline_delete_prefers_device_echo(self) -> None:
"""Up-arrow then delete middle ``ip`` — must not keep stale longer audit_line."""
frag = (
"<r1>dis ip interface brief "
+ ("\x1b[1D" * 18)
+ " interface brief \x1b[18D\x1b[1D interface brief \x1b[18D\x1b[17C"
)
# Stale xterm snapshot still has ``ip``; device echo already deleted it.
cmd = pick_audit_command(
"",
"<r1>dis ip interface brief",
prompt_hint="<r1>dis ip interface brief",
stdout_tail=frag + "\n",
source="stdin",
)
self.assertEqual(cmd, "<r1>dis interface brief")
out = resolve_audit_commands(
[],
audit_line="<r1>dis ip interface brief",
prompt_hint="<r1>dis ip interface brief",
stdout_tail=frag + "\n",
source="stdin",
)
self.assertEqual(out, ["<r1>dis interface brief"])
def test_bare_enter_does_not_reaudit_previous_command(self) -> None:
"""Empty Enter after a prior command must not invent another audit from stdout."""
out = resolve_audit_commands(
[],
audit_line=None,
prompt_hint="<r1>dis interface",
stdout_tail="<r1>dis interface \nEthernet1/0/0 current state : UP\n<r1>",
source="stdin",
)
self.assertEqual(out, [])
out2 = resolve_audit_commands(
[],
audit_line="<r1>", # prompt-only from xterm
prompt_hint="<r1>dis interface",
stdout_tail="<r1>dis interface \n",
source="stdin",
)
self.assertEqual(out2, [])
# Stale audit_line harvested from previous command row on screen.
out3 = resolve_audit_commands(
[],
audit_line="<r1>dis interface brief",
prompt_hint="<r1>dis interface brief",
stdout_tail=(
"<r1>dis interface brief \n"
"PHY: Physical\n"
"*down: administratively down\n"
"<r1>"
),
source="stdin",
)
self.assertEqual(out3, [])
# Prompt redrawn with CR onto the previous output row (common on Huawei).
out4 = resolve_audit_commands(
[],
audit_line="<r1>dis interface brief",
prompt_hint="<r1>dis interface brief",
stdout_tail="Ethernet1/0/0 UP\r<r1>",
source="stdin",
)
self.assertEqual(out4, [])
# Leftover glyphs after CR must not look like a live command row.
out5 = resolve_audit_commands(
[],
audit_line="<r1>dis interface brief",
stdout_tail="Interface PHY Protocol\r<r1>",
source="stdin",
)
self.assertEqual(out5, [])
# Trailing CR after bare prompt must still look idle (not empty → stale hint).
out6 = resolve_audit_commands(
[],
audit_line="<r1>sys",
stdout_tail=(
"---- More ----\x1b[16D \x1b[16D<r1>sys\r\r\n"
"Enter system view, return user view with return command.\r\r\n"
"[~r1]\r\r\n"
),
source="stdin",
)
self.assertEqual(out6, [])
# pick() itself must also refuse stale hint on bare prompt.
self.assertIsNone(
pick_audit_command(
"",
"<r1>sys",
stdout_tail="[~r1]\r\r\n",
source="stdin",
)
)
def test_tab_interface_loopback_from_session_transcript(self) -> None:
"""Tab ``inter``→``interface LoopBack 1`` must audit the expanded line."""
stdout = (
"[~r1]inter\t\r\r\n"
"[~r1]interface lo\t\r\r\n"
"[~r1]interface LoopBack 1\r\r\n"
"[~r1-LoopBack1]\r\r\n"
)
# Incomplete xterm snapshot must not win over device expansion.
out = resolve_audit_commands(
["inter\tlo\t1"],
audit_line="[~r1]interface lo",
stdout_tail=stdout,
source="stdin",
)
self.assertEqual(out, ["[~r1]interface LoopBack 1"])
# Extra audit_line from duplicate Enter must not IndexError / drop the line.
out2 = resolve_audit_commands(
["inter\tlo\t1"],
audit_lines=["[~r1]interface lo", "[~r1]interface LoopBack 1"],
stdout_tail=stdout,
source="stdin",
)
self.assertEqual(out2, ["[~r1]interface LoopBack 1"])
def test_history_trailing_delete_without_audit_line(self) -> None:
"""Delete trailing ``brief`` via CSI — audit even if xterm snapshot is missing."""
frag = (
"<r1>dis ip interface brief "
+ "".join("\x1b[1D \x1b[1D" for _ in range(6))
)
out = resolve_audit_commands(
[],
audit_line=None,
stdout_tail=frag + "\n",
source="stdin",
)
self.assertEqual(out, ["<r1>dis ip interface"])
# Stale longer audit_line must not win over CSI-shortened echo.
out2 = resolve_audit_commands(
[],
audit_line="<r1>dis ip interface brief",
stdout_tail=frag + "\n",
source="stdin",
)
self.assertEqual(out2, ["<r1>dis ip interface"])
def test_early_stdin_plain_command(self) -> None: def test_early_stdin_plain_command(self) -> None:
out = resolve_audit_commands( out = resolve_audit_commands(

View file

@ -92,7 +92,13 @@ function stripAnsi(text: string): string {
/** Visible xterm row on Enter — keep device prompt prefix, drop ANSI only. */ /** Visible xterm row on Enter — keep device prompt prefix, drop ANSI only. */
export function normalizeAuditLine(line: string): string { export function normalizeAuditLine(line: string): string {
return stripAnsi(line).replace(/\r/g, "").replace(/\s+$/, ""); let s = stripAnsi(line).replace(/\r/g, "\n").split("\n")[0] ?? "";
// Clip Huawei/ZTE legend glue that ANSI stripping can append to the prompt row.
s = s.replace(
/(?:\*down:|!down:|\^down:|\([a-z]{1,3}\):|PHY:\s*Physical|----\s*More\s*----|InUti\/OutUti|Local Intf\s+Neighbor).*$/i,
"",
);
return s.replace(/\s+$/, "");
} }
/** True when the row is only a device prompt (empty Enter — not auditable). */ /** True when the row is only a device prompt (empty Enter — not auditable). */
@ -110,13 +116,25 @@ export function hasCliPromptPrefix(line: string): boolean {
/** Device error/warning lines must never be audited as operator commands. */ /** Device error/warning lines must never be audited as operator commands. */
export function isDeviceOutputLine(line: string): boolean { export function isDeviceOutputLine(line: string): boolean {
const raw = stripAnsi(line).replace(/\r/g, "").trim();
const s = normalizeAuditLine(line).trim(); const s = normalizeAuditLine(line).trim();
if (!s) return false; if (!s) {
// Pure legend row (clipped to empty) counts as device output.
return /(?:\*down:|!down:|\^down:|PHY:\s*Physical|----\s*More\s*----)/i.test(raw);
}
const low = s.toLowerCase(); const low = s.toLowerCase();
if (low.startsWith("%error") || low.startsWith("%warning")) return true; if (low.startsWith("%error") || low.startsWith("%warning")) return true;
if (low.includes("invalid input detected")) return true; if (low.includes("invalid input detected")) return true;
if (/^\^+\s*$/.test(s)) return true; if (/^\^+\s*$/.test(s)) return true;
if (low.startsWith("enter configuration commands")) return true; if (low.startsWith("enter configuration commands")) return true;
if (
!CLI_PROMPT_PREFIX.test(s) &&
/(?:\*down:|!down:|\^down:|\([a-z]{1,3}\):|PHY:\s*Physical|----\s*More\s*----|InUti\/OutUti|Local Intf\s+Neighbor)/i.test(
raw,
)
) {
return true;
}
return false; return false;
} }
@ -129,27 +147,29 @@ export function isAuditableCommandLine(line: string): boolean {
function currentCommandLine(term: Terminal): string { function currentCommandLine(term: Terminal): string {
const buf = term.buffer.active; const buf = term.buffer.active;
const y = buf.cursorY; const y = buf.cursorY;
const rows: string[] = []; const curText = normalizeAuditLine(buf.getLine(y)?.translateToString(true) ?? "");
let sawPrompt = false;
for (let i = y; i >= Math.max(0, y - 5); i -= 1) { // Sitting on a bare prompt / blank row after output — never walk up into the
const text = normalizeAuditLine(buf.getLine(i)?.translateToString(true) ?? ""); // previous command echo (that caused bare Enter to re-audit the last command).
if (!text.trim()) { if (!curText.trim() || isPromptOnlyLine(curText)) {
if (rows.length) break; return curText;
continue;
} }
if (isDeviceOutputLine(text)) break;
// Prompt+command already on the cursor row.
if (hasCliPromptPrefix(curText)) {
return curText.trimEnd();
}
// Soft-wrap continuation: join upward until the prompt row; abort on device output.
const rows: string[] = [curText];
for (let i = y - 1; i >= Math.max(0, y - 5); i -= 1) {
const text = normalizeAuditLine(buf.getLine(i)?.translateToString(true) ?? "");
if (!text.trim() || isDeviceOutputLine(text) || isPromptOnlyLine(text)) break;
rows.unshift(text); rows.unshift(text);
if (hasCliPromptPrefix(text)) { if (hasCliPromptPrefix(text)) {
sawPrompt = true; return rows.join("").trimEnd();
break;
} }
} }
if (!sawPrompt && rows.length === 0) {
const cur = buf.getLine(y);
return cur ? normalizeAuditLine(cur.translateToString(true)) : "";
}
return rows.join("").trimEnd(); return rows.join("").trimEnd();
} }
@ -313,6 +333,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const findInputRef = useRef<HTMLInputElement | null>(null); const findInputRef = useRef<HTMLInputElement | null>(null);
/** Suppress duplicate Enter frames (keydown + xterm onData both fire). */ /** Suppress duplicate Enter frames (keydown + xterm onData both fire). */
const enterHandledAtRef = useRef(0); const enterHandledAtRef = useRef(0);
/** Last Tab keystroke — Enter soon after must wait for device completion redraw. */
const lastTabAtRef = useRef(0);
useEffect(() => { useEffect(() => {
onStatusRef.current = onStatus; onStatusRef.current = onStatus;
@ -383,6 +405,17 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}; };
const sendEnterStdin = (term: Terminal, explicitAuditLine?: string) => { const sendEnterStdin = (term: Terminal, explicitAuditLine?: string) => {
const recentTab = performance.now() - lastTabAtRef.current < 450;
if (recentTab) {
// Re-read xterm after device Tab redraw; ignore any stale snapshot.
// Mark Enter handled now so onData duplicate frame is suppressed while we wait.
enterHandledAtRef.current = performance.now();
window.setTimeout(() => {
enterHandledAtRef.current = performance.now();
sendStdinWithAudit("\r", auditLineForEnter(term));
}, 100);
return;
}
enterHandledAtRef.current = performance.now(); enterHandledAtRef.current = performance.now();
sendStdinWithAudit("\r", explicitAuditLine ?? auditLineForEnter(term)); sendStdinWithAudit("\r", explicitAuditLine ?? auditLineForEnter(term));
}; };
@ -759,17 +792,27 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const dataDisposable = term.onData((data) => { const dataDisposable = term.onData((data) => {
const normalized = data.replace(/\x7f/g, "\x08"); const normalized = data.replace(/\x7f/g, "\x08");
if (isDuplicateEnterFrame(normalized)) return; if (normalized.includes("\t")) {
// Capture visible line before Enter moves the cursor to the next row. lastTabAtRef.current = performance.now();
const auditLine =
normalized.includes("\r") || normalized.includes("\n")
? auditLineForEnter(term)
: undefined;
if (normalized.includes("\r") || normalized.includes("\n")) {
enterHandledAtRef.current = performance.now();
} }
if (isDuplicateEnterFrame(normalized)) return;
const isEnter = normalized.includes("\r") || normalized.includes("\n");
if (isEnter) {
enterHandledAtRef.current = performance.now();
const recentTab = performance.now() - lastTabAtRef.current < 450;
// After Tab, wait briefly so Huawei/ZTE redraw lands in xterm before we snapshot.
if (recentTab && /^[\r\n]+$/.test(normalized)) {
window.setTimeout(() => {
sendStdinWithAudit("\r", auditLineForEnter(term));
}, 100);
return;
}
}
const auditLine = isEnter ? auditLineForEnter(term) : undefined;
// Large pastes from xterm arrive as one onData blob. // Large pastes from xterm arrive as one onData blob.
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) { if (normalized.length > 32 || isEnter) {
sendStdinThrottled(normalized, auditLine); sendStdinThrottled(normalized, auditLine);
} else { } else {
sendStdinWithAudit(normalized, auditLine); sendStdinWithAudit(normalized, auditLine);
@ -841,7 +884,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.stopPropagation(); e.stopPropagation();
maybeFocus(); maybeFocus();
if (e.key === "Enter") { if (e.key === "Enter") {
sendEnterStdin(term, auditLineForEnter(term)); sendEnterStdin(term);
return; return;
} }
sendStdinWithAudit(data); sendStdinWithAudit(data);