mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
* fix(ssh): let a rejected stored passphrase ask again (#486) Saving the wrong passphrase for an encrypted key locked that key out permanently. `passphrase_submit` wrote `SetKeyPassphrase` on the "remember" checkbox alone — before the daemon had tried the secret, since `apply_keychain_write` runs ahead of `respond_active` — and `try_identity_file` treated a stored passphrase as final: a decrypt failure with one went straight to "could not decrypt identity file", with no prompt and nothing in the UI that could let go of it. The daemon now says so. `AuthPromptKind::KeyPassphrase` grows a `rejected` flag, and a stored passphrase that does not open the file falls through to the interactive prompt carrying it, so the typed answer still gets its attempt. A passphrase the user typed this time keeps the hard failure — that is a wrong answer, not stale state. The sheet renders the warning line the password sheet already had, and a rejected prompt answered without "remember" now emits `DeleteKeyPassphrase`, mirroring the password idiom exactly. The flag is a `#[serde(default)]` field on a struct variant of an externally tagged enum, which is compatible in both directions: an older peer never sets it and serde ignores fields it does not know. So `PROTOCOL_VERSION` deliberately does not move — the remote-server handshake gates on it, and a bump would turn away older servers over a field they can safely ignore. `protocol.rs`'s compat test pins both directions. Also: deleting an SSH profile now drops the key-passphrase entries no other profile still references, which is what `delete_profile_confirmed`'s own comment already claimed to do but only ever did for the password. * fix(ssh): stop replaying a stale password at keyboard-interactive (#487) `try_keyboard_interactive` answered a password-shaped round from the keychain, marked the stored password spent whether or not it had been used, and returned on the first `Failure` — so the `MAX_ROUNDS` loop never got a second pass with the stored password withheld. The same dead secret went out on every reconnect and the user was never once asked to type a different one; `ki_submit` always emitted `KeychainWrite::None`, so nothing could clear it either. `collect_ki_answers` now reports where its answers came from, and only a round that actually sent the stored password spends it — which also fixes an OTP-then-password flow that was refusing the stored password for no reason, its first round having burned the allowance on a code. On a rejection whose last round came from the keychain, and where the server still offers the method, the request is started over with the stored password withheld, so the next round reaches the prompt. That retry is bounded twice over: the restart spends the stored password, so no second restart can qualify, and the round counter it shares with the info-request loop caps the method either way. The failure text now says which of the two was turned down. Scope, honestly: the only live scenario is auth mode Auto against a server offering keyboard-interactive but not password, with a stored password for that endpoint — a profile pinned to KeyboardInteractive gets `password: None` and always prompts, and Password never tries KI. Whether the symptom shows also depends on the server: OpenSSH ends a rejected kbdint request with USERAUTH_FAILURE (symptom holds), while a device that re-issues an InfoRequest in the same request already reached the prompt. `AuthPromptKind::KeyboardInteractive` grows a `#[serde(default)]` `stored_rejected`, same both-directions compatibility as `KeyPassphrase`'s `rejected` and the same reason `PROTOCOL_VERSION` stays put. The sheet shows the warning line and, on submit, forgets the rejected password. That needed an endpoint the KI prompt does not carry, which also fixed a bug next door: `raise_routed_auth` called `from_prompt(.., None, false)`, so every routed password write was keyed to port 22 regardless of the real port and the rejected self-heal could never fire there. `PendingAuth` now carries the endpoint and the auto-supplied flag, read straight off the route's `NativeSshSpec`. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
181 lines
7.3 KiB
Bash
Executable File
181 lines
7.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# The GUI must not touch the filesystem or git directly.
|
|
#
|
|
# Once a workspace can live on a remote machine, a path held by `ui::` or
|
|
# `terminal::` is not necessarily a path on *this* box, and `std::path`'s
|
|
# fs-backed APIs quietly answer for the wrong machine:
|
|
# `canonicalize` walks the local filesystem, `is_absolute` says `false` for
|
|
# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may
|
|
# be looking at a workspace path has to go through `ui::host_ops` / the `Host`
|
|
# trait, which routes to the local disk or the far side as appropriate.
|
|
#
|
|
# This script enforces that. It is deliberately *not* the raw grep from the
|
|
# contract: run bare, that grep reports 42 hits on a clean tree — not one of them
|
|
# git, all of them modules whose paths are local by construction — and a guard
|
|
# that always fails is a guard everyone learns to ignore. Two things fix it:
|
|
#
|
|
# 1. Test bodies are cut off properly. The contract's `grep -v '#\[cfg(test)\]'`
|
|
# only removes the attribute line itself, leaving the whole test module
|
|
# behind it in scope; here the scan stops at the `#[cfg(test)] mod …` that
|
|
# opens the trailing test region.
|
|
# 2. An explicit allowlist, keyed on (file, pattern) rather than whole files, so
|
|
# a module exempted for its `.is_absolute()` still trips on a new
|
|
# `std::fs::`. Every entry carries the reason its paths cannot be remote.
|
|
#
|
|
# Adding an entry is a deliberate act: if the path could ever be a workspace
|
|
# path, the answer is `Host`, not an allowlist line.
|
|
#
|
|
# Usage: bash .github/scripts/check-host-boundary.sh
|
|
# Exit 0 = clean, 1 = violations (printed to stderr).
|
|
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/../.."
|
|
|
|
ROOTS=(src/ui src/terminal)
|
|
|
|
# The forbidden constructs, as extended-regex alternatives.
|
|
PATTERN='std::fs::|Command::new\("git"\)|\.canonicalize\(\)|\.is_absolute\(\)'
|
|
|
|
# Allowlist entries are `<path>|<literal pattern>`, one per line. `<path>|*`
|
|
# exempts a file wholesale (used only for the boundary module itself). The
|
|
# pattern side is matched as a plain substring of the offending line.
|
|
ALLOW=$(
|
|
cat <<'EOF'
|
|
# The host boundary itself: every routed filesystem call lands here by design.
|
|
src/ui/host_ops.rs|*
|
|
|
|
# Themes and presets are app-owned files under the local config dir
|
|
# (`themes_dir()`), never a workspace path — a remote workspace does not carry
|
|
# the user's color schemes with it.
|
|
src/ui/presets.rs|std::fs::
|
|
src/ui/presets.rs|.is_absolute()
|
|
src/ui/app.rs|std::fs::create_dir_all
|
|
|
|
# Reading a private key off *this* machine to hash it into a keychain account
|
|
# (`core::keychain`). The key is the client's credential; the far side never
|
|
# sees the file, only the resulting auth.
|
|
src/ui/ssh_prompt.rs|std::fs::read
|
|
src/ui/ssh_connect.rs|std::fs::read
|
|
src/ui/settings.rs|std::fs::read
|
|
|
|
# Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs
|
|
# this app's own history search. A remote pane's history is the remote shell's
|
|
# business, read over the wire, not through here.
|
|
src/terminal/history.rs|std::fs::
|
|
|
|
# Completion is already remote-aware: a remote pane is signalled by `cwd: None`,
|
|
# which disables exactly these local-filesystem candidate sources in favour of
|
|
# `remote_path_request` / `remote_path_candidates`. The `$PATH` scan is likewise
|
|
# this machine's `$PATH`, deliberately withheld from remote panes.
|
|
src/terminal/completion.rs|std::fs::read_dir
|
|
src/terminal/completion.rs|.is_absolute()
|
|
|
|
# Bundled completion specs shipped in `assets/completions`, resolved from the
|
|
# app bundle / dev manifest dir. Program data, not user or workspace data.
|
|
src/terminal/signature.rs|std::fs::read_to_string
|
|
|
|
# Opening a path scraped out of terminal output resolves it against the local
|
|
# cwd — a local-pane affordance; `resolve_existing_path` declines when there is
|
|
# no local cwd.
|
|
src/terminal/search.rs|.is_absolute()
|
|
|
|
# Clipboard-image paste stages the bytes into the OS temp dir so an agent TUI
|
|
# can be handed a path. Always `std::env::temp_dir()` on this machine.
|
|
src/terminal/view.rs|std::fs::create_dir_all
|
|
src/terminal/view.rs|std::fs::write
|
|
|
|
# The source side of a file drop. What the desktop hands over is by
|
|
# construction a path on the desktop's own machine, so reading it is a local
|
|
# read even when the tree being dropped on is remote — the destination side of
|
|
# that copy goes through `Host`, and the one `std::fs::copy` that touches a
|
|
# destination sits inside a branch already gated on `host.id().is_local()`.
|
|
src/ui/file_copy.rs|std::fs::
|
|
EOF
|
|
)
|
|
|
|
# Where the trailing `#[cfg(test)] mod …` starts, if any. Line numbers are
|
|
# preserved because only the tail is dropped.
|
|
body_of() {
|
|
local file=$1 cut
|
|
# `attr` starts unset, which awk reads as 0 — so a file whose *first* line
|
|
# is `mod something` used to match `attr == NR - 1` and cut the body at
|
|
# line 0, leaving `head -n -1` to error out and the file to be scanned as
|
|
# empty. Two files opened that way, and the guard was blind to both.
|
|
cut=$(awk '
|
|
BEGIN { attr = -1 }
|
|
/^#\[cfg\(test\)\]$/ { attr = NR }
|
|
/^mod [A-Za-z_]/ { if (attr == NR - 1) { print NR - 1; exit } }
|
|
' "$file")
|
|
if [ -n "$cut" ]; then
|
|
head -n "$((cut - 1))" "$file"
|
|
else
|
|
cat "$file"
|
|
fi
|
|
}
|
|
|
|
allowed() {
|
|
local file=$1 line=$2 entry path pat
|
|
while IFS= read -r entry; do
|
|
case "$entry" in '' | '#'*) continue ;; esac
|
|
path=${entry%%|*}
|
|
pat=${entry#*|}
|
|
[ "$path" = "$file" ] || continue
|
|
if [ "$pat" = '*' ] || [ "${line#*"$pat"}" != "$line" ]; then
|
|
return 0
|
|
fi
|
|
done <<<"$ALLOW"
|
|
return 1
|
|
}
|
|
|
|
# A guard that scans nothing reports success, which is the one failure mode worse
|
|
# than a noisy guard. Fail loudly if a root has moved out from under us.
|
|
for root in "${ROOTS[@]}"; do
|
|
if [ ! -d "$root" ]; then
|
|
echo "check-host-boundary: scan root '$root' does not exist (moved? renamed?)" >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
violations=0
|
|
scanned=0
|
|
while IFS= read -r file; do
|
|
scanned=$((scanned + 1))
|
|
while IFS= read -r hit; do
|
|
lineno=${hit%%:*}
|
|
text=${hit#*:}
|
|
# Prose, not code.
|
|
case "$(printf '%s' "$text" | sed 's/^[[:space:]]*//')" in '//'*) continue ;; esac
|
|
if allowed "$file" "$text"; then
|
|
continue
|
|
fi
|
|
printf '%s:%s:%s\n' "$file" "$lineno" "$text" >&2
|
|
violations=$((violations + 1))
|
|
done < <(body_of "$file" | grep -nE "$PATTERN" || true)
|
|
done < <(find "${ROOTS[@]}" -name '*.rs' | sort)
|
|
|
|
if [ "$scanned" -lt 10 ]; then
|
|
echo "check-host-boundary: only $scanned files scanned — the roots look wrong" >&2
|
|
exit 2
|
|
fi
|
|
|
|
if [ "$violations" -ne 0 ]; then
|
|
cat >&2 <<'EOF'
|
|
|
|
--------------------------------------------------------------------------------
|
|
The GUI reached the filesystem/git directly.
|
|
|
|
A path in `ui::` or `terminal::` may belong to a remote workspace, where these
|
|
calls answer for the wrong machine. Route it through `ui::host_ops` / `Host`
|
|
instead (`Host::read_dir`, `join`, `is_absolute`, `canonicalize`, …).
|
|
|
|
If the path genuinely cannot be remote — app config, bundled assets, the local
|
|
temp dir — add it to the allowlist in this script with the reason why.
|
|
--------------------------------------------------------------------------------
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
echo "host boundary clean: $scanned files in ${ROOTS[*]}, no direct fs/git calls"
|