What an SSH Client Can Honestly Log: Enter-Time Audit and a Local JSONL

Part 10 of "Building an SSH Terminal". Part 6 is the v1.2.26 account of what a client can see. This part is the v1.2.38 addition: a local record of lines submitted with Enter. Sources: src/core/interactive_exec.rs, src/core/exec_history.rs, src/cli/ssh_session_log.rs, docs/tech/COMMAND-AUDIT.md, docs/tech/CLI-DESIGN.md.

TL;DR

Part 6 still holds. The client reconstructs a line from keystrokes. It does not see the command the shell finally runs, and another SSH client skips it entirely. v1.2.38 can append that reconstructed line to ~/.mist/logs/exec-history.jsonl, one JSON object per line, when a team audit path is actually on. A preference turns the check and the write off. mist ssh --log-session writes a different file, for that session, and only if you pass the flag. None of this is enforcement. That still belongs on the server.

The same Enter, one more side effect

In v1.2.26, when a local policy is active, Enter does not immediately send \r. The client rebuilds the line from keys sent since the last Enter and asks CmdAuditEngine. Block, confirm, or allow. If the line is not trustworthy — alternate screen, a file transfer, a password prompt, cursor motion the buffer does not model — Enter goes through. Part 6 walks through that gate. v1.2.38 does not replace it.

What it adds is a write, on that same path, into the JSONL the client already uses for structured execs. There is no second policy engine. Whether a typed line is captured at all is a small function:

/// Preference on, and at least one side active:
/// a local team policy, or server audit for this host.
/// With no team login both sides are false: no intercept, no JSONL line.
pub fn interactive_capture_active(
    preference_on: bool,
    local_policy_active: bool,
    server_audit_for_host: bool,
) -> bool {
    preference_on && (local_policy_active || server_audit_for_host)
}

The checkbox is Preferences → Activity audit → "Check and record commands typed in the terminal" (检查并记录终端里手输的命令). It defaults to on, including when an older settings file has no such field. Off means Enter is sent immediately, the rules are not consulted for this path, and output is not scanned to close a record.

On is not sufficient. The comment in the client matches the function. With no team login, both sides are false, so typed commands are not intercepted and this line is not written. The same is true when you are logged in but neither the local policy nor that host's server-audit agent is in effect. A personal session with the checkbox left on does not grow exec-history.jsonl from the keyboard.

Holding Enter is narrower than writing the line. The gate (hold \r, then block / confirm / allow) runs when the preference is on, the local audit module is enabled, and the engine has an active policy. If the host's server agent is the side that is on, the client can still record the line without holding Enter. Should that agent later report the command blocked, the same record is retagged blocked and its exit code is cleared.

One line in the file

The path is ~/.mist/logs/exec-history.jsonl. Append, one JSON object per line, same ExecLogRecord batch runs already use. The interactive tag is interactive_pty, plus one outcome: allowed, alert, confirmed, blocked, or cancelled.

A blocked or cancelled line is written at once, with no output and no exit code. An allowed line stays open until the next prompt-shaped line, or until OSC 133 D. The prompt check is a heuristic: a short line ending in $, #, % or >, with a body that looks like a path or a user@host. It is not a shell parser. Fullscreen programs, password and passphrase prompts, and multi-line paste are skipped by the caller, same as the v1.2.26 gate. Arrow keys, Tab completion and history recall still leave the local buffer empty or cleared, so those submits are not this record either. The shell still receives the key.

The write does not fail the session. If the directory cannot be created or the file cannot be opened, the append is dropped.

Other features already append to the same file (batch exec, for one). mist sop extract reads it and drafts a markdown note from recent lines. That is a local convenience. It is not the team audit trail, and it is not a promise that every keystroke on every machine landed here.

The file is not the policy

Nothing in the JSONL stops a command from running. The user can clear the checkbox. They can connect with OpenSSH. They can recall a line with ↑, which the local buffer does not model, and the client lets Enter through rather than block a line it cannot see. Part 6's server path is unchanged: sshd ForceCommand for the non-interactive command, and an optional bash DEBUG trap inside an interactive shell, which the user can remove. If a command must not run, that decision still has to happen where the process starts.

mist ssh --log-session

The CLI flag is separate and off unless you pass it. No flag means no session file and no scan of command output. The GUI checkbox does not apply.

mist ssh web-01 --log-session
mist ssh web-01 --log-session=/tmp/web-01.jsonl

The default path is ~/.mist/logs/ssh-<session>-<UTC time>.jsonl. The session piece is the saved session name, or user@host[:port] for an address you typed. Characters other than letters, digits, _ and - fold into -, and the stem is capped. The stamp is UTC YYYYMMDDTHHMMSSZ. Before raw mode, the CLI prints the path on stderr ([mist] command log: …).

The record type is the same ExecLogRecord. Tags are interactive_pty plus the outcome, and cli_ssh. The line goes to this session's file, not to exec-history.jsonl. exit_code still shows up only with OSC 133 D. Password prompts are skipped. Arrow keys, history recall and Tab clear the local line, so those Enters are not logged. The shell still gets the key.

If a team policy is loaded, mist ssh runs the same Enter check as the GUI: block, confirm, or allow. A block sends Ctrl-U, tells you on stderr, and writes a line tagged blocked. That is still the client. Another ssh binary never sees it.

Summary

FAQ

Does MistTerm always write typed commands to exec-history.jsonl?

No. From v1.2.38 a line is appended only when the preference is on and a team audit path is active: a local team policy, or server audit for that host. With no team login the file is not written for typed commands, even if the checkbox stays on.

Where does mist ssh --log-session write?

To its own file, by default ~/.mist/logs/ssh-<session>-<UTC time>.jsonl. It does not append to exec-history.jsonl. Without the flag, mist ssh creates no session log and does not scan command output.

Is a client-side JSONL enough to enforce a command policy?

No. The user can turn the preference off, use another SSH client, or submit a line the local buffer cannot reconstruct. Enforcement still belongs on the server, for example sshd ForceCommand, as described in Part 6.

Related in this series

← Part 9 · Series index