Security Policy
The threat model for a tool people run against every host they touch, and how to report what slipped through it.
Contents
- What hi does - and deliberately doesn’t
- What runs where
- What hi writes on a target
- Footprint and cleanup on the target
- Trust boundaries
- Assurance case
- Supported versions
- Reporting a vulnerability
What hi does - and deliberately doesn’t
- No network calls of its own.
hionly execs the transports you already use (ssh,docker exec,podman exec,nerdctl exec,finch exec,nomad alloc exec,kubectl exec) against a target you named. No telemetry, no update checks, nocurl/wgetin the shipped tree. - No
curl | bash. Installing isgit cloneplusscripts/install.sh, or a package built from that same script (PACKAGING.md’s Install channels).hi --updateis a release-tag checkout in a checkout you can read (ondev, a fast-forward of the branch you chose to follow). - The payload is an allow list. What goes over the wire is exactly
$_HI_PAYLOADat the top ofhi.sh(common config load.sh hi.sh, the last so a session can sayhionward) — docs, tests, CI, and editor config never leave the client. Your overlay is a second, smaller allow list,$_HI_OVERLAY_FILES(the roster is CONTRIBUTING.md’s contract), and the files your ownpluginsrows name, each listed byhi --doctor; nothing else in~/.config/say-hi/leaves the client. Asettings.<tag>.shis the one file sent under another name: joined to thesettings.shof a host carrying that tag, and to no other (SETTINGS.md). Apluginsrow is read as data: its paths are never evaluated (HI.63). - base64 is armor, not crypto. It gets the payload through the target’s login shell unmangled; confidentiality and integrity come entirely from the transport.
- hi writes nothing on the target outside its own temp directories. No login file, no history file, nothing under
$HOME, under any setting - What hi writes on a target is the whole list, and names what a prompt program or your own per-shell files write on their own account. - The transport keeps its own voice. hi does not redirect
ssh’s stderr, so the server’sBanner, thePermanently added ... to the list of known hostsline and the host-key fingerprint on a first connection reach your terminal exactly as they would without hi. Capturing them would turn trust-on-first-use into accepting a fingerprint nobody was shown. hi --updatereads the tag’s signature before checking it out, fromgit verify-tag’s output rather than its exit code, SSH signatures checked against the checkout’s own.github/allowed_signers: a failing signature (gpg or ssh, or a revoked gpg key) refuses the checkout, a good one is named with its signer, and a key nobody lists, an unsigned tag (a fork, a mirror), or nogpgis said out loud and allowed, since refusing there would strand every first install.--dry-runreports the same verdict._HI_UPDATE_SIGNED=1refuses those three as well, so only a tag signed by a key the checkout already trusts moves it.
What runs where
hi.sh runs on the client: it parses arguments, picks the backend, tars and armors the payload, and pipes it over the transport. On the target a single sh unpacks it into a temp directory and chainloads load.sh, which prints the header, writes the session’s rc files into a scratch directory of its own, and hands off to the best shell available. The target’s login files are never written; everything the target executes was generated on the client.
Three sshd shapes change that picture, and hi names each. A ForceCommand in sshd_config, or a command= on the key in authorized_keys, runs its own program whatever the client asked: hi’s bootstrap never runs, and hi says so and hands over the host’s own session - the only one such a host offers - rather than report a success it never had. (A forced program that exits non-zero and prints nothing is indistinguishable from a host with no sh, and gets the PowerShell notice instead.) A restricted login shell (rbash) forbids a / in a command name and little else; sh has no slash, so hi’s bootstrap runs unrestricted - rbash is not a boundary hi respects, and a host whose restriction matters wants ForceCommand. MaxSessions 1 fits: hi’s two calls share one connection, but the probe’s channel closes before the session’s opens. All three are tests/targets/ssh_test.sh cases.
What hi writes on a target
Default answer: one directory (two over ssh), and only for the life of the session. A session is as long as its connection unless you ask otherwise: hi --keep runs it in tmux, zellij, or screen on the target, where it and its directory last until you close it or nobody has been attached for _HI_KEEP_TIMEOUT (24h).
| what | where, in the target’s temp directory, mode 0700 | when |
|---|---|---|
| the session tree | mktemp -d <user>.hi.XXXXXX over ssh; mkdir -m 700 <user>.hi.log.<pid> in a container | every session but --plain |
| the ssh bootstrap | mktemp -d hi.boot.XXXXXX | ssh only, removed by the same remote command once the session ends |
That is everything hi’s own code writes. A prompt program drawing the prompt (INTEGRATIONS.md) - by default, one you have installed at home and the target has too - writes what it always does, under the target’s $HOME, and keeps it after the session ends:
| tool | what it keeps, by default | when |
|---|---|---|
| starship, oh-my-posh | starship’s log files under ~/.cache/starship/, oh-my-posh’s cache under ~/.cache/oh-my-posh/ | when it draws the prompt |
| powerlevel10k | gitstatusd under ~/.cache/gitstatus/ and its instant-prompt cache under ~/.cache/ | when it draws the prompt |
| tide | a _tide_* universal variable or two in the target’s fish_variables, rewritten each start | when it draws the prompt |
| zoxide, atuin, direnv, mise | zoxide’s database, atuin’s history (and a sync, where the target’s atuin is logged in), direnv’s allow list, mise’s shims and caches, each under the target’s $HOME | only once hi --plugin-on named it: the hooks group is off by default (SETTINGS.md) |
Each tool’s own settings on that target can move those paths; _HI_PROMPT_TOOL=hi brings a session back to the first table alone. What your own per-shell files start (a zoxide or atuin init, say) writes on its own account.
Your commands land in the target’s own history file, and the programs you run yourself - editors included - write there, exactly as over plain ssh; nothing hi ships touches the history file, and hi’s emacs config turns off emacs’s backups, autosaves, and lock files. hi --doctor prints any setting not at its default, so “what is this install allowed to do to a target” is one command.
Footprint and cleanup on the target
load.sh’s on-exit hook removes the whole session tree, session-rc directory included, on a clean exit and on an abrupt disconnect alike (tests/targets/ssh_disconnect_test.shverifies the latter). Over ssh the bootstrap’strap 'rm -rf $_HI_CLEANUP' exitis a backstop for the one thing the hook cannot survive: bash killed by a signal nothing can trap.- A kept session (INTEGRATIONS.md) moves the same hook into the session’s first pane, and adds one process: a timer in that pane, which ends the session once nobody has been attached for
_HI_KEEP_TIMEOUT. Nothing of hi’s runs outside the multiplexer session, and the bootstrap’s backstop leaves a tree alone while its session is running. A target that goes down under a kept session and keeps/tmpacross the reboot is left with the tree until the account’s nexthi <target>, which removes every tree of its own whose kept session’s processes are gone (HI.65). - The session tree is not added to
$PATH;hiinside a session is an alias (common/paths.sh) instead. A/tmppath on$PATHis a finding on any host that is scanned for one. - A say-hi installed on the target is neither read nor written by a session: every session runs out of the tree hi just unpacked, so the installed tree need not be writable by you or at any fixed path.
tests/targets/install_methods_test.shdrives one target per install method and asserts the install is still whole once the session is gone.
Trust boundaries
- hi’s security model is the transport’s. It adds no authentication, listens on nothing, and anyone positioned to intercept or control your ssh/container session could do so without hi in it. Backend dispatch trusts your local
~/.ssh/configand yourdocker/podman/nerdctl/finch/nomad/kubectlCLIs — the same ones you already run. A name that is noHostentry is put to each of those CLIs before it goes to ssh, andhi <TAB>lists through them,kubectl get pods -Aincluded;_HI_BACKENDS_OFFnames the ones hi is never to start, andallleaves ssh alone. - A malicious target gets what any interactive session gives it: your payload and a terminal. Treat every overlay file as public to every host you visit,
ssh_tagsincluded (hi --doctorflags a secret-shaped line in a file that rides): it names the hosts your~/.ssh/configtags, and only those, with nothing of how to reach them. Nothing a target sends back is executed on the client. The one string hi reads back and uses - the scratch directory the target made (the ssh bootstrap’s, or a container’s session tree) - reaches a command run back on that target only if absolute and built from an allow list of path characters (_hi_safe_path); otherwise ssh hands over the host’s own session and a container connect fails. Escape sequences in session output remain possible, exactly as with plainssh; hi’s own connect-failure report prints a target’s stderr as text, so a backslash sequence a target wrote stays one. - A tool your per-shell files start on a target runs under that target’s own config for it, not yours: an atuin logged in to a sync server there syncs the session’s history like any other shell’s on that box.
- What hi writes on the client. The rc lines and
settings.shthe install asked about -install.shchecks your rc files with each shell’s own syntax checker before touching them, and--uninstallremoves exactly what it wrote - plushi <TAB>’s target cache, the payload/overlay cache, the sshControlMastersocket, and an empty file per target seen holding a kept session, in a private runtime directory:$XDG_RUNTIME_DIR, or a per-uid directory hi creates withmkdir -m 700. Its name is predictable - the nexthihas to find it - so if it already exists and is not owned by you, or is a symlink, all four are skipped: completion sweeps the backends, a connect builds afresh over a fresh socket, slower and correct, and a kept session that dies is not missed. - The
ControlMastersocket is never at amktemp -uname in a shared temp directory:ControlMaster=autojoins a socket it finds at its path, and a name that was unused when printed promises nothing about the moment it is used. A connect reuses one at a stable path in the runtime directory, named by a hash of the target and your ssh options rather than either in the clear, and torn down after_HI_CTL_PERSISTidle seconds (sixty by default);scripts/doctor.sh’s probe,_HI_CTL_PERSIST=0, and a runtime directory hi cannot vouch for take a fresh socket in amktemp -dof their own, closed when done. Passed as-o, either outranks aControlMaster noin your~/.ssh/config;_HI_DISABLE_CONTROLMASTER=1passes neither, at the cost of a second authentication a connect.
What a process started from a session inherits
core.sh’s _HI_CHILD_ENV roster, and nothing else with the prefix: the tree and overlay pointers, the remote-session flag, the session rc directory, and the completion knobs targets.sh reads from its environment. Everything else hi sets — sixty-odd paths and toggles — stays a shell variable in the session shell, so a service started by hand, a sudo -E, or a cron line pasted at the prompt sees an ordinary environment. In particular the two values that name your workstation (_HI_LOCAL_USER, _HI_LOCAL_HOSTNAME) are never in a child’s environment; a shell started inside the session reads them from hi’s own rc directory instead. The mechanism and the roster are HI.47; tests/common/exports_test.sh pins both. The one tier this does not reach is a POSIX sh started inside a session (and the bash-less fallback), where $ENV sources paths.sh again and dash has no un-export.
Assurance case
The argument that secure design principles were applied against the threat model (What hi does, What runs where) and the trust boundaries above — not a claim that the tool is free of bugs.
| principle | how it holds |
|---|---|
| Least privilege, minimal surface | hi adds no authentication of its own, listens on nothing, and makes no network call of its own - it trusts the transport you already run |
| Fail loud, fail closed | every entry point (hi.sh, load.sh, common/core.sh, scripts/install.sh) runs under set -euo pipefail, each with a documented re-disable where an error must not close an interactive shell |
| Untrusted input allowlisted, not sanitized after the fact | _hi_safe_path in hi.sh checks the scratch directory a target hands back against an explicit [bracket-class] before it reaches a command run back on it; _hi_ssh_pattern_hit in common/core.sh skips an ssh-config token that isn’t a hostname shape rather than evaluate it; _hi_sanitize_var in common/core.sh strips control characters and backslashes from target-derived text |
| No secret ever needs to be in the payload | the payload is the allow list in What hi does; credentials are handled by hand, outside CI (CONTRIBUTING.md), with GitHub’s push protection as backstop and ci.yml’s secret scan (gitleaks) sweeping the full history on every PR and push to main, findings redacted from the log |
| The build and release path is defended, not just the product | every CI and release job starts with step-security/harden-runner but the arm64 Linux one, which has no agent (egress blocked to an allowlist on every Ubuntu x64 job, each list taken from a real run; audited only on macOS and Windows, which have no block mode, and on the link checker); third-party actions are pinned by SHA; dependency review fails a PR that adds a dependency with a high or critical advisory; release.yml’s gate builds only a tag signed by a key in .github/allowed_signers, on main, with green CI; the signing keys are release environment secrets no other job can read (RELEASING.md) |
What is not (yet) countered. Unless _HI_UPDATE_SIGNED=1 is set, hi --update refuses a tampered signature, not a missing or foreign one: a tag re-signed with a key .github/allowed_signers does not list, or stripped of its signature, is named in yellow and checked out. On the dev branch it fast-forwards commits, which carry no check under either setting. The allowed-signers file is trusted on first use - the copy already checked out, which a later release can change. A packaged install updates through its package manager instead (PACKAGING.md).
Supported versions
No 1.0 release yet: the supported version is the tip of main, and packages exist for the pre-1.0 versions a hand-pushed v* tag has built (RELEASING.md). Once v1.0 is tagged, this becomes a version table with the latest release supported; what a 1.x release keeps stable is CONTRIBUTING.md’s What 1.x will not break.
Reporting a vulnerability
Please don’t open a public issue for anything exploitable. Instead, report privately via GitHub private vulnerability reporting.
What happens to a report
- Acknowledgement within 14 days, usually much sooner — this is a one-maintainer project, and the private report reaches that maintainer directly.
- Coordinated disclosure. A confirmed vulnerability is fixed before it is discussed publicly, unless the reporter and maintainer agree otherwise; the aim is a fix within 60 days of the report. Reporters are kept in the loop from acknowledgement to advisory.
- Advisories are public. Every fixed vulnerability gets a GitHub Security Advisory naming the affected versions and the fix, and the fixing release’s notes reference it. None have been reported to date.
- Credit. Reporters are credited in the advisory and the release notes unless they ask not to be.
Last reviewed 2026-09.