mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-28 00:02:39 +00:00
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.
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"
|