core/tools: announce a remote shell instead of judging it

`ssh prod` runs no command this scan can resolve, and what is typed or piped
into the shell it opens never reaches any gate — `write_pty` and
`run_argv(stdin=…)` are both unchecked input. The tool description said nothing
about it, so the call looked like any other.

The hop is now reported even when no command follows
(`RemoteTarget.shell`), and the review announces it: `remote shell on 'prod'
(ssh with no command; its input is not checked)`. It is a soft confirm like the
other reviews (YOLO may skip it, an escalation behind a hop still may not) — the
client itself stays outside the denylist, since there is no program to name.
This commit is contained in:
2026-10-23 10:43:05 +08:00
parent e1c857fd05
commit 3cb4d490ed
5 changed files with 40 additions and 10 deletions
+10 -4
View File
@@ -275,6 +275,8 @@ class RemoteTarget:
host: str
#: ``-o BatchMode=yes``: the client never prompts, so the command runs.
batch: bool = False
#: No command followed: the client opens a shell whose input is not scanned.
shell: bool = False
@dataclass(frozen=True)
@@ -346,7 +348,8 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
``ssh`` / ``mosh`` are unwrapped the same way — the program under review is
the one after the destination (``ssh prod rm -rf /`` runs ``rm``), and the
hop is reported in :attr:`UnwrappedCommand.remote`. A client with no command
opens a remote shell, which this scan does not look into.
opens a remote shell (``remote.shell``); nothing behind it can be resolved
here.
"""
parts = list(argv)
wrappers: list[str] = []
@@ -359,6 +362,8 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
start = _skip_remote(name, parts, index + 1)
if start is not None:
remote = start.target
if start.index is None:
break # a remote shell: the client is the whole command
wrappers.append(name)
index = start.index
continue
@@ -384,12 +389,13 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
class _RemoteStart:
"""Where a remote client's own command starts, and the hop it reaches."""
index: int
#: Index of the remote command, or ``None`` when only a shell opens.
index: int | None
target: RemoteTarget
def _skip_remote(name: str, parts: list[str], start: int) -> _RemoteStart | None:
"""Where the command *name* runs elsewhere starts; ``None`` for a bare shell.
"""Where the command *name* runs elsewhere starts; ``None`` without a hop.
The destination is the first token that is not one of the client's own
options (``ssh -o BatchMode=yes -p 2222 [user@]host …``); the command follows
@@ -415,7 +421,7 @@ def _skip_remote(name: str, parts: list[str], start: int) -> _RemoteStart | None
if name == "mosh" and index < len(parts) and parts[index] == "--":
index += 1
if index >= len(parts):
return None
return _RemoteStart(index=None, target=RemoteTarget(wrapper=name, host=host, batch=batch, shell=True))
return _RemoteStart(index=index, target=RemoteTarget(wrapper=name, host=host, batch=batch))
+8 -3
View File
@@ -63,15 +63,20 @@ def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
A command on another machine (``ssh``/``mosh``) is reviewed by the program it
runs there — the client's destination and options are skipped. With
``-o BatchMode=yes`` the client cannot prompt at either end, so the command
is always confirmed, whatever program it is.
is always confirmed, whatever program it is. A client with no command opens a
remote shell, which is announced: nothing typed or piped into it can be
checked, so the human decides before it starts.
"""
scan = unwrap_command(argv)
interpreter = is_interpreter(scan.base)
findings = [f"{name} (detached run)" for name in scan.self_review]
if interpreter:
findings.append(f"interpreter {scan.base!r}")
if scan.remote is not None and scan.remote.batch:
findings.append(f"remote command on {scan.remote.host!r} (BatchMode=yes, no prompt on either end)")
remote = scan.remote
if remote is not None and remote.shell:
findings.append(f"remote shell on {remote.host!r} ({remote.wrapper} with no command; its input is not checked)")
elif remote is not None and remote.batch:
findings.append(f"remote command on {remote.host!r} (BatchMode=yes, no prompt on either end)")
findings.extend(f"{span.label} runs a command line" for span in scan.command_strings)
findings = list(dict.fromkeys(findings))
if not findings: