Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/advisories.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: advisories

# RustSec advisories against Cargo.lock, weekly and by hand; never part of `pr`, so a new advisory blocks no merge.
# A red scheduled run emails the account that last changed the cron line.

on:
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
advisories:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: cargo deny
uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check advisories
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Gate suite
run: sh scripts/pr-check.test.sh
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ Out of scope:
- **Panes are outside it.** A helper started before the sandbox launches every pane. A pane left at Everything and Open keeps your full access to files and the network; its isolation (**Isolation…**) narrows that with a Seatbelt profile of its own, a proxy for Only these providers, or another account. An agent's own Seatbelt sandbox cannot run inside an isolated pane.
- **`open` is outside it.** Opening a file or link "the way the system would", and opening a new terminal window, go through LaunchServices, which starts those programs outside the sandbox.
- **A sandbox that fails to start does not stop the server.** The failure is written to `daemon.log`, shown by `nerd --list`, and shown in the window of every client that attaches.
- **A release is signed, and what that covers depends on the way in.** Its `.sha256` is signed with nerd's release key, kept off GitHub; install.sh carries the public halves of that key and its backup, and installs nothing whose signature does not check. An update from nerd's menu uses the install.sh built into the running nerd, so someone who can replace a release on GitHub cannot make it install theirs. `curl … | sh` downloads install.sh itself from nerdterm.dev, so it is only as safe as nerdterm/nerd's release tags and the site; it refuses a release older than the nerd already installed, but a first install takes whichever signed release GitHub calls the latest, older ones included. Homebrew pins the archive's sha256, written into the formula only after the release script checked the signature, so it is as safe as nerdterm/homebrew-tap. It cannot stop someone holding the key, a release held back or with its signature removed (nerd then reports the release has no signature yet and stays where it is), or an older nerd that never updates; the changelog shown before installing is not signed. Releases before 0.15.0 were not signed, and a nerd before 0.15.0 installs the next release without checking its signature; every update after that is checked. The installer refuses an unsigned archive given by hand unless `NERD_INSTALL_UNVERIFIED=1` is set.
- **A release is signed, and what that covers depends on the way in.** Its `.sha256` is signed with nerd's release key, kept off GitHub; install.sh carries the public halves of that key and its backup, and installs nothing whose signature does not check. An update from nerd's menu uses the install.sh built into the running nerd, so someone who can replace a release on GitHub cannot make it install theirs. `curl … | sh` downloads install.sh itself from nerdterm.dev, so it is only as safe as nerdterm/nerd's release tags and the site; it refuses a release older than the nerd already in its install folder (by default ~/.local/bin), but a first install takes whichever signed release GitHub calls the latest, older ones included. Homebrew pins the archive's sha256, written into the formula only after the release script checked the signature, so it is as safe as nerdterm/homebrew-tap. It cannot stop someone holding the key, a release held back or with its signature removed (nerd then reports the release has no signature yet and stays where it is), or an older nerd that never updates; the changelog shown before installing is not signed. Releases before 0.15.0 were not signed, and a nerd before 0.15.0 installs the next release without checking its signature; every update after that is checked. The installer refuses an unsigned archive given by hand unless `NERD_INSTALL_UNVERIFIED=1` is set, and a signed archive whose nerd does not report the version in the archive's name. The release key signs only the release commit on master: the release script refuses to build and sign any other commit, or a binary reporting another version. Building and testing run every dependency's build code as the maintainer, so the script reads its signing script, formula script and install.sh from the release commit in git before it builds and runs that text afterwards, and after the build runs only the system's own commands and the nerd it built until the key has been used: a build that rewrites those files or puts commands of its own on the maintainer's PATH cannot run them in their place. It cannot stop build code that keeps running as the maintainer from reading the terminal while the key's passphrase is typed, using the maintainer's GitHub login, changing the binary being built, or waiting for a later release; only building releases as a separate macOS user would.
- **Both ends check the other's user on every connection.** The server talks only to processes of its own user, and a client refuses a server socket another user created first, for example at a `NERD_SOCKET` in a shared directory, before it sends anything. Such a socket still keeps nerd from starting there: keep `NERD_SOCKET` in a directory only you can write.
7 changes: 4 additions & 3 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ deny = [
{ name = "tonic" },
{ name = "want" },
# mio is not banned: crossterm/signal-hook-mio use it only for stdin/tty readiness
# (SourceFd), never sockets; enforced via cackle.toml withholding its "net" api.
# (SourceFd), never sockets. cackle.toml records the withheld "net" api, but only scripts/audit.sh
# runs cackle (on Linux, by hand); what holds is the server's Seatbelt profile and tests/egress.rs.
]

[sources]
Expand All @@ -55,5 +56,5 @@ unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

[advisories]
# Unlike licenses/bans/sources, this check downloads the RustSec database over the
# network on every run: run it deliberately, never from CI or a script by default.
# Unlike licenses/bans/sources, this check downloads the RustSec database over the network:
# .github/workflows/advisories.yml runs it weekly and by hand, never in `pr` or gates.sh.
2 changes: 1 addition & 1 deletion docs/isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The hosts in the agents' `plugin.json` are a starting point: check them against

A pane's `~/.pi/agent/*.lock` is writable from every This-folder-only pane: one session can hold another pi session's lock, which only delays that session until the lock is deleted.

**This folder only** also keeps one session from planting code or instructions for another. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, and the caches package managers run code from (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot). When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working.
**This folder only** also keeps one session from planting code or instructions for another. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, and the known caches whose contents run later (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot, the system Python's bytecode, Homebrew, ccache, sccache, esbuild, Bazelisk, Coursier); another cache under `~/Library/Caches` stays writable. Seatbelt sees a folder moved in whole as one being made, so a missing `~/.<agent>/projects` folder, or a project folder in it an agent has not made yet, can still be put in place with memory or Qwen Code's workflow scripts inside. When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working.

Local sockets: with Network **Open**, a This-folder-only pane reaches no ssh agent and no Docker, colima, OrbStack, Rancher Desktop, lima or podman socket, so `git` over ssh and `docker` do not work there. With **Only these providers** or **None**, a pane reaches no Unix socket but nerd's message socket.

Expand Down
45 changes: 44 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,31 @@ pull request, `chore(master): release X.Y.Z`. Replace `N` and `X.Y.Z` below with
scripts/publish-binary.sh vX.Y.Z
```

It stops before building if the draft's target is not a commit id ("not a commit id"), the tag's commit is not
on master ("is not on master"), or it is not the release commit itself, which changes only the version and adds
to the changelog ("is not a release commit", "changes more than nerd's version", "removes lines"); and after
building, before anything is signed, if the built nerd reports another version ("the built nerd says"). Any of
these means someone moved the tag or changed the release pull request: stop and look at who did, never work around
it. Other stops are ordinary: a failing test (it prints cargo test's `failures:`), "the tree must be clean", and
network errors from git or gh; fix the cause and run it again.

Building and testing run every dependency's build code as you. Before building, the script reads the release
commit's `scripts/sign-release.sh`, `scripts/publish-formula.sh` and `install.sh` from git and later runs that text,
not the files on disk; after the build it runs only the system's own commands and the nerd it built until the key
has been used. A build that rewrites a script or puts a command of its own on your PATH cannot run in place of these
steps. What it cannot stop: build code that keeps running can read the terminal while you type the passphrase, use
your gh login without a prompt (upload to the draft, write the tap), change the binary being built, or wait for a
later release. Only building releases as a separate macOS user closes that; if a run looks wrong, see "If a release
run looks wrong".

6. Check it: `false`, then the archive, its `.sha256` and its `.sha256.sig`:

```sh
gh release view vX.Y.Z --json isDraft,assets --jq '.isDraft, .assets[].name'
```

If the formula step failed, run it alone: it reads the published checksum and checks its signature against this
checkout's install.sh, so running it again is safe.
checkout's install.sh, so running it again is safe once `git status --porcelain` prints nothing.
`scripts/publish-formula.sh --dry-run vX.Y.Z` prints the formula without writing it.

```sh
Expand All @@ -65,6 +82,32 @@ scripts/publish-formula.sh vX.Y.Z
`release not found` in step 5 means step 3 or 4 is not done yet. No macOS runners in CI: the binary is built only on
the maintainer's Mac, at the tag.

## If a release run looks wrong

Step 5 asks for the passphrase once. If it asks a second time, if the release gets an asset or nerdterm/homebrew-tap a
commit that the run did not report, or if it stops anywhere step 5 does not name, take it that code from the build is
still running as you:

- Do not type the passphrase, and do not run step 5 again.
- If the passphrase was typed, treat the key as leaked: move to the backup key as "If the main key leaks or is lost"
below says.
- Clean up from github.com on another device, or on this Mac only after what was left behind is removed and the Mac
restarted: a new `gh auth login` before that hands the new token to whatever is still running.
- Revoke GitHub CLI's token on github.com (Settings, Applications).
- Look for what was left behind: `~/.zshrc`, `~/.zprofile`, `~/Library/LaunchAgents`, `~/.cargo/bin`, `~/.local/bin`,
`/opt/homebrew/bin`, the checkout's `.git/config` and `.git/hooks`, and `~/.gitconfig` (a `core.fsmonitor` there runs
on the next `git status`).
- Put the release back to draft, then delete its assets: on github.com, or with `gh release edit vX.Y.Z --draft=true`
and `gh release delete-asset vX.Y.Z <name>` for each name step 6 lists.
- In nerdterm/homebrew-tap, revert `Formula/nerd.rb` to the last commit the release script made.

## Dependency advisories

`.github/workflows/advisories.yml` runs `cargo deny check advisories` every Monday and on demand
(`gh workflow run advisories.yml`); it is never part of `pr`. A red scheduled run emails the account that last changed
its cron line. Update the dependency named; a notice with no fix (an unmaintained crate) gets an `ignore` entry with its
reason in `deny.toml`.

## The release key

Releases are signed with an ECDSA P-256 key kept off GitHub. install.sh carries the public halves of it and of a
Expand Down
13 changes: 11 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ else
# NERD_RELEASE_API lets tests point at a local stand-in; users never set it.
api="${NERD_RELEASE_API:-https://api.github.com/repos/$repo/releases/latest}"
# Into a file first: piping straight to curl would lose its exit status.
curl -fsSL "$api" -o "$work/release.json" 2>/dev/null || die "cannot read the latest release of $repo"
curl -fsSL --proto =https --proto-redir =https "$api" -o "$work/release.json" 2>/dev/null || die "cannot read the latest release of $repo"
tag=$(grep -o '"tag_name": *"[^"]*"' "$work/release.json" | head -n 1 | cut -d'"' -f4 || true)
printf '%s' "$tag" | grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+' || die "the latest release's tag is not a version: $tag"
# ceiling: a first install has no version to compare with, and this script itself comes from nerdterm.dev; an update from nerd's menu has neither limit.
Expand All @@ -56,7 +56,7 @@ else
# The three files of that tag, by name: nothing else in the release is looked at.
name="nerd-$tag-macos-arm64.tar.gz"
for n in "$name" "$name.sha256" "$name.sha256.sig"; do
curl -fsSL -o "$work/$n" "https://github.com/$repo/releases/download/$tag/$n" || die "download failed: $n"
curl -fsSL --proto =https --proto-redir =https -o "$work/$n" "https://github.com/$repo/releases/download/$tag/$n" || die "download failed: $n"
done
archive="$work/$name"
fi
Expand Down Expand Up @@ -91,6 +91,15 @@ fi
tar -xzf "$archive" -C "$work"
[ -f "$work/nerd" ] || die "the archive holds no nerd binary"
xattr -d com.apple.quarantine "$work/nerd" 2>/dev/null || true
# A signed name holds the nerd of that version and no other: a release commit moved under another tag is refused.
case $base in
nerd-v*-macos-arm64.tar.gz)
want=${base#nerd-v} want=${want%-macos-arm64.tar.gz}
got=$("$work/nerd" --version 2>/dev/null) || die "the archive's nerd does not run, nothing installed"
got=$(printf '%s\n' "$got" | awk 'NR == 1 { print $2 }')
[ "$got" = "$want" ] || die "the archive holds nerd $got, not $want, nothing installed"
;;
esac
mkdir -p "$bindir"
# Anyone who can write a folder on the way to nerd can rename it and put their own nerd in its place. Symlinks: ls on "$d/." reads the folder a link points to, the one the install writes through; the folders above that target are not checked.
d=$bindir
Expand Down
1 change: 1 addition & 0 deletions scripts/gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ sh scripts/terminals.test.sh
sh scripts/publish-formula.test.sh
sh scripts/install.test.sh
sh scripts/sign-release.test.sh
sh scripts/publish-binary.test.sh
# Cargo never deletes old builds: a target/ another toolchain built (they share nothing) or past 4 GB starts over.
if [ -f target/.rustc_info.json ] && { ! grep -qF -- "$(rustc -V)" target/.rustc_info.json || [ "$(du -sk target | cut -f1)" -gt 4194304 ]; }; then
cargo clean
Expand Down
17 changes: 15 additions & 2 deletions scripts/install.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ awk -v m="$t/main.pub" -v b="$t/backup.pub" '
/^# release keys end/ { skip = 0 }
!skip' "$here/install.sh" >"$t/install.sh"

# A release of `version` in `dir`: its archive, the shasum line, and that line signed with `key` (none for no .sig).
# A release of `version` in `dir`: its archive, the shasum line, and that line signed with `key` (none for no .sig);
# its nerd runs `body`, by default printing that version.
release() {
dir=$1 version=$2 key=$3
mkdir -p "$dir/build"
printf '#!/bin/sh\necho "nerd %s (test)"\n' "$version" >"$dir/build/nerd"
printf '#!/bin/sh\n%s\n' "${4:-echo \"nerd $version (test)\"}" >"$dir/build/nerd"
chmod 755 "$dir/build/nerd"
name="nerd-v$version-macos-arm64.tar.gz"
tar -czf "$dir/$name" -C "$dir/build" nerd
Expand Down Expand Up @@ -96,11 +97,18 @@ sign "$a.sha256" main
expect "$(A=$a run named)" none "a signed checksum naming another archive"
a=$(release "$g/down" 0.98.0 main)
expect "$(HAVE=0.99.0 A=$a run down)" 0.98.0 "an older archive given by hand"
a=$(release "$g/moved" 1.2.3 main 'echo "nerd 1.2.0 (test)"')
expect "$(A=$a run moved)" none "an archive for 1.2.3 holding nerd 1.2.0"
grep -qx 'nerd: the archive holds nerd 1.2.0, not 1.2.3, nothing installed' "$t/out" || bad "another version: $(cat "$t/out")"
a=$(release "$g/broken" 0.99.0 main 'exit 3')
expect "$(A=$a run broken)" none "an archive whose nerd does not run"
grep -qx "nerd: the archive's nerd does not run, nothing installed" "$t/out" || bad "does not run: $(cat "$t/out")"

# The download path, through a stand-in curl serving $SERVED by the last part of the url.
mkdir -p "$t/stub"
cat >"$t/stub/curl" <<'EOF'
#!/bin/sh
echo "$*" >>"$SERVED/args"
out= url=
while [ $# -gt 0 ]; do case $1 in -o) out=$2; shift ;; -*) ;; *) url=$1 ;; esac; shift; done
echo "$url" >>"$SERVED/asked"
Expand All @@ -116,6 +124,7 @@ rm -f "$s/asked"
expect "$(dl latest)" 1.2.3 "the latest release"
[ "$(grep -c 'v1\.2\.0' "$s/asked")" = 0 ] || bad "the download path fetched another release's files"
[ "$(grep -c '/releases/download/v1\.2\.3/' "$s/asked")" = 3 ] || bad "the download path did not fetch exactly the tag's three files"
grep -v -e '--proto =https --proto-redir =https' "$s/args" && bad "a download may leave https"
expect "$(HAVE=1.3.0 dl older)" 1.3.0 "a latest release older than the installed nerd"
expect "$(HAVE=1.2.3 dl same)" 1.2.3 "the installed version again"
mv "$s/nerd-v1.2.3-macos-arm64.tar.gz.sha256.sig" "$t/saved.sig"
Expand All @@ -125,6 +134,10 @@ mv "$t/saved.sig" "$s/nerd-v1.2.3-macos-arm64.tar.gz.sha256.sig"
release "$s" 1.2 main >/dev/null
printf '{"tag_name": "v1.2", "assets": []}\n' >"$s/latest"
expect "$(dl tag)" none "a tag that is not a version"
release "$s" 1.2.3 main 'echo "nerd 1.2.0 (test)"' >/dev/null
printf '{"tag_name": "v1.2.3", "assets": []}\n' >"$s/latest"
expect "$(dl moved)" none "the latest release's archive holding nerd 1.2.0"
grep -qx 'nerd: the archive holds nerd 1.2.0, not 1.2.3, nothing installed' "$t/out" || bad "download, another version: $(cat "$t/out")"

# A folder above the install folder writable by others is named once, with its fix; a normal home prints nothing about it.
a=$(release "$g/perm" 0.99.0 main)
Expand Down
Loading
Loading