Skip to content

Security: Tripstack-Corp/spyc

SECURITY.md

Security

This document describes spyc's actual security posture — what we do, what we don't, and why. It exists so reviewers and future maintainers can see the threat model without inferring it from CI config.

If you find something wrong, contact derek.marshall@tripstack.com.

Threat model

spyc is a single-binary terminal file manager. It runs locally as the invoking user and has no network code of its own. It is distributed both as source and as signed prebuilt binaries (GitHub Releases, a Homebrew tap, a signed apt repo, and crates.io — see INSTALL.md). The realistic threats are:

  • Supply-chain compromise of a Rust dependency — a transitive crate is yanked + republished with malicious code, or an unmaintained dep develops a CVE.
  • Release-pipeline compromise — an attacker who can run code in a release workflow, or who obtains the apt signing key, can publish a malicious binary that installs as "spyc" on every consumer machine. This is the highest-consequence threat and the reason for the keyless-signing posture below.
  • Tampered local build — someone modifies the source on a shared clone before make install runs.
  • MCP socket misuse by another local process — the per-PID Unix socket exposes tool calls to whatever process can read ~/.local/state/spyc/mcp-<pid>.sock. Filesystem permissions gate this; an attacker already running as your user can talk to any of your spyc instances.
  • MCP tool misuse by the connected agent — distinct from the above and worth stating plainly, because it is the case people assume is covered. See "MCP is not a privilege boundary" below.

spyc has no remote attack surface, no privilege boundary inside the binary, and handles no secrets of its own.

It does, however, parse a great deal of untrusted input: any file you open in the pager, ANSI/escape output from arbitrary child processes in the pty pane (libghostty-vt), git objects (gix), and images / SVG / markdown / syntax-highlighting input. A malicious file or a hostile child process is a realistic way to reach a parser bug. This is what fuzz/fuzz_targets/ exists for — see the fuzzing caveat.

MCP is not a privilege boundary against the connected agent

The MCP read tools take an optional root argument to scope a query to a worktree. That argument is validated against a set of roots spyc knows about: the trusted context root, its worktrees, and the focused column's own chain. The rationale is recorded in ROADMAP.md's decisions log: agent harnesses commonly auto-approve MCP tool calls while gating shell execution behind per-command permission prompts, so an unvalidated root would bypass a boundary the user believes exists.

The trusted anchor is spyc's launch directory, and every directory under it. is_within_allowed accepts any descendant of the context root, which is where spyc was started. Launch it from $HOME — an ordinary thing to do with a file manager — and ~/.ssh and ~/.aws are inside an allowed root for every read tool. That follows from the design being about scoping rather than confinement, and it is what separates "validated against a set of roots" sounding tight from being tight.

That set is not fixed either, and the agent can widen it. Two of its entries come from the cursor (cwd, search_root), and navigate_to sets cwd to any path the agent names — so navigate_to("/etc") followed by search_content(…, root="/etc") is allowed. open_worktree is a quieter second path to the same widening. This is deliberate: freezing the set at launch would break the ordinary case of a user navigating somewhere and then asking the agent about it. Navigation is visible (a [mcp] navigated to … flash), and it grants no reach the agent didn't already have.

So the validation is a guardrail, not a sandbox — it keeps an agent from wandering, not from going somewhere on purpose. An agent with shell access can read anything you can read, and spyc does not try to prevent it. Do not treat the MCP tool surface as a containment mechanism for an untrusted agent.

Attribution is not authorization, and no per-pane scheme would change that. A recurring proposal is to narrow the allowed set to the calling pane's own worktree, which needs the server to know which pane a call came from. Every mechanism for that is forgeable by the agent itself:

  • An env-supplied pane id (SPYC_PANE_ID, whether sent in the initialize handshake or as a tool argument) is set in an environment the agent controls, so it can claim a sibling's id before the proxy starts.
  • A socket per pane is forgeable too, just less obviously. The sockets are listable and 0600-owned by the same uid the agent runs as, so nothing stops it connecting to another pane's.

Per-pane roots would therefore stop an accident, not an attempt — and the threat this section describes is the deliberate one, a prompt-injected agent under a harness that auto-approves MCP calls. Precisely the actor who would forge an id.

spyc attributes calls anyway, because attribution is useful without being a boundary. Shell tabs carry SPYC_MCP_SOCK and SPYC_PANE_ID as agent tabs do, so an agent started from a shell is attributed too; that hands the commands run there nothing a same-uid process couldn't already find, since the sockets are listable. The spyc --mcp proxy sends its SPYC_PANE_ID in the initialize handshake, and the server binds that connection to the tab if a live one carries the id. That lets get_spyc_context describe the caller's own tab, lets report_status and a scope claim land on the caller's tab rather than whichever one has focus, and puts the binding in mcp.log. Those are ergonomics and auditability. The id is exactly as forgeable as the list above says. With several projects open in one spyc (#99), the binding also picks the project a connection's read tools answer from, and with it their allowed set. That is routing, not a boundary: a forged id reaches another of the same user's open projects, none of which is out of the agent's own shell's reach. Real containment needs OS-level isolation — a separate uid, a container — which is outside spyc's scope.

Supply-chain controls (what we do)

  • Cargo.lock is committed. Every build resolves the same set of versions. cargo build will not silently bump deps.
  • --locked everywhere. make test / make lint / all release builds pass --locked to cargo, so a CI-time Cargo.lock drift fails loudly. The Makefile and the GitHub Actions CI (.github/workflows/ci.yml) both enforce this.
  • cargo deny check (advisories, licenses, sources, bans) runs weekly and on demand, in .github/workflows/audit.yml — not on every CI build. It re-fetches the RUSTSEC database on every invocation, which is what makes the weekly run worth having (it surfaces new advisories against unchanged deps) and what made it unfit for the per-commit path, where it is network-dependent and has segfaulted mid-run, failing PRs that touched no dependencies. The gap that buys: a PR introducing a banned, wrongly-licensed or duplicate dependency is not caught at PR time. It surfaces on the next scheduled run, or from make deny locally. Dispatch it (gh workflow run audit.yml) after merging anything that moves Cargo.toml / Cargo.lock, and before cutting a release. Configuration is checked in at deny.toml with documented reasons for every advisory ignore — none are silent.
  • License allow-list. Only the licenses present in our actual dep graph are allowed; the list is reviewed against cargo deny list when deps change. Adding a dep with a license outside that set fails the audit run above (not the PR — same gap); you read it, decide, and either add to the allow-list (with a reason) or pick a different dep. Several deps are multi-licensed and offer a copyleft option (self_cell, r-efi, the Unlicense OR MIT BurntSushi crates); cargo-deny selects an allowed alternative, so no copyleft obligation attaches.
  • Source allow-list. Only crates from https://github.com/rust-lang/crates.io-index are accepted. No git = "..." deps, no patched forks.
  • MSRV pinned via rust-toolchain.toml. A new stable release cannot tighten lints, change behaviour, or drop features behind our back.
  • Pre-commit hook (optional, install via make install-hooks) runs the same gate as CI before each commit so drift surfaces in seconds locally instead of ~10 min later in pipelines.

Release pipeline, signing, and what you can verify

Pushing a v* tag runs .github/workflows/release.yml. Operator mechanics live in docs/RELEASE_ENGINEERING.md §8–9; this section covers only the security-relevant chain.

Build. macOS universal (arm64 + x86_64 lipo) on macos-latest; static musl x86_64 and aarch64 on ubuntu-latest via cargo-zigbuild with a pinned Zig. The workflows call the same make targets used locally, so CI and local builds don't drift. SHA256SUMS is generated over all three tarballs.

Signing is keyless — no stored secret is involved.

  • Build provenance — actions/attest-build-provenance produces a SLSA attestation binding each tarball to the workflow, repo, and commit that produced it, via GitHub's OIDC identity.
  • cosign-signed checksums — cosign sign-blob signs SHA256SUMS with an ephemeral OIDC-derived key, recorded in the Rekor transparency log, published as SHA256SUMS.cosign.bundle.
  • crates.io Trusted Publishing — the publish job exchanges a GitHub OIDC token for a crates.io token scoped to this repo and workflow file, valid only for that step and auto-revoked afterwards. No registry token is stored.

What a consumer can verify (commands in INSTALL.md):

sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify spyc-<tag>-<platform>.tar.gz --repo Tripstack-Corp/spyc
cosign verify-blob SHA256SUMS --bundle SHA256SUMS.cosign.bundle \
  --certificate-identity-regexp '...' --certificate-oidc-issuer '...'

Attestation verification is the strong check: it proves the artifact was built by this repo's release workflow, not merely that a hash matches a file someone published alongside it.

Actions permissions. Every workflow defaults to contents: read. Only the release publish job widens it — contents: write (create the release), id-token: write (OIDC for attestation and cosign), attestations: write, and actions: write (to dispatch the homebrew and apt workflows, because a release created by GITHUB_TOKEN does not fire on: release). The crates.io job holds id-token: write and nothing else. apt.yml holds contents: write solely to push the index to gh-pages.

The apt signing key is the one real stored secret. A dedicated RSA-4096 key signs the apt Release file; its public half is published as KEY.gpg and pinned by clients via signed-by. The private key and passphrase live in the apt-publish protected Environment (APT_GPG_PRIVATE_KEY, APT_GPG_PASSPHRASE), scoped by deployment policy so only that job, on a v* tag or main, can read them. The key is backed up off-repo in the owner's password manager together with a revocation certificate. The apt push itself uses the built-in org-owned GITHUB_TOKEN, not a personal or cross-repo token. The Homebrew tap bump uses an org GitHub App credential in the separate homebrew-tap Environment.

On key compromise. The keyless channels need no rotation — there is no long-lived key to steal, and a malicious artifact would carry an attestation naming the workflow that actually built it. For apt: revoke using the stored revocation certificate, generate a new RSA-4096 key, replace both apt-publish secrets, and republish KEY.gpg. Every existing apt client must then re-import the new key, because signed-by pins the old one — an unavoidable break, and the reason the apt key is the most valuable secret in the project. This procedure is stated here but has never been rehearsed.

The vendored libghostty-vt archives

spyc-vt-sys ships prebuilt static archives of libghostty-vt — one per target, built from a spyc-owned pinned ghostty commit and committed to the repository. This is a deliberate trade and it is worth stating plainly, because a binary blob in a dependency tree is the shape of a supply-chain problem.

What we do about it:

  • The pin is ours. spyc-vt-sys::pin::GHOSTTY_COMMIT names an exact commit, never a branch, never a moving tag. It moves only on a deliberate bump that re-runs the engine harness in full.
  • Every archive is checksummed and verified on every build. vendor/CHECKSUMS carries a SHA-256 per archive, and the build script recomputes and compares before emitting a single link flag. A mismatch fails the build with both hashes printed. Verified by tampering with a byte, not by assuming.
  • Reproducible by anyone. make vendor-ghostty rebuilds all five on Linux from the pinned source with a pinned Zig version (ziglang.org's build of it) and rewrites the checksums, so a third party can confirm the committed bytes are what the pinned source produces (make vendor-ghostty-container does the same from a Mac). CI does exactly that before every release, and the release is not published unless the bytes match.
  • The alternative was worse. The published libghostty-vt-sys bindings git clone ghostty at build time and run zig build. That fetches code from a URL during cargo build, is invisible to cargo vendor and --offline, and is not covered by any checksum. Vendored archives are at least pinned, hashed and reviewable in-tree.

What we do not claim: cargo audit and cargo deny cannot see inside a static archive, so an advisory against ghostty would not surface through them. Ghostty's own security posture is upstream's; ours is the pin, the checksum and the bump gate. deny.toml records the same.

Known caveats (what we don't do)

  • No reproducible builds. Two builds of the same source on two machines may differ in timestamps, paths, and rustc-version fingerprints. Bit-for-bit reproducibility is non-trivial for Rust binaries and we don't claim it. Build-provenance attestation is what we offer instead: it proves where a binary came from rather than letting you rebuild it yourself.
  • No SBOM published. cargo deny check and Cargo.lock together give us a full audit trail, but we don't emit a CycloneDX or SPDX SBOM artifact. If a consumer needs one, generating it from Cargo.lock is a one-shot script away.
  • No commit signing requirement. The repo does not require signed commits. A compromised dev account could push unsigned commits indistinguishable from real ones, bounded by branch protection (PR-only merge into main, required status checks, restricted write access). Note this is the weakest link in the release chain: signing proves an artifact came from the workflow, not that the source the workflow built was authored by who you think.
  • Fuzz targets run weekly, not per-PR. fuzz/fuzz_targets/ covers the DSL parser, path/percent expansion, highlighting, markdown rendering, word wrap, and the archive layer — member names and the container parsers themselves. .github/workflows/fuzz.yml runs every target on a schedule; make fuzz runs one on demand (nightly, deliberately out of make check). A regression therefore surfaces within the week rather than at the next release, but a PR can still merge without any target having seen its code.
  • macOS binaries are ad-hoc signed only. make install invokes codesign -s -, which keeps entitlements stable across rebuilds and silences some Gatekeeper-on-translocation noise. It does not prove origin and would not survive notarization. A real Developer ID requires Apple Developer enrollment and is out of scope; the provenance attestation is the origin proof instead.
  • The MCP socket is owner-only, and that is the whole boundary. start_socket_server wraps the bind in umask(0o077) so the socket lands 0700, and refuses to serve at all rather than fall back to a world-writable /tmp. But anyone running as your user on the same machine can still open it and exercise the MCP tool surface: we rely on user-process isolation, not on stricter ACLs.
  • Wiring an agent puts spyc in every session of it, not only those spyc launches: saying yes at its first launch (or spyc --install-agents) adds spyc's status hooks and MCP entry to the agent's own configuration. The hooks run on every turn, and do nothing without the socket a spyc pane sets. The spyc --mcp entry starts in every session, and serves no tools to one spyc didn't launch, wherever it runs. A codex session on codex's shared daemon is the exception: spyc can't tell who started it, and it reaches the spyc rooted at its directory, unattributed. The entries outlive spyc, so remove them with spyc --remove-agents before uninstalling it.

Reporting a vulnerability

Email derek.marshall@tripstack.com. Internal contact, no formal SLA; expect a same-day response during business hours.

If the issue is in a dependency we use rather than spyc itself, please also report it upstream — we'll coordinate on a fix and update deny.toml as needed.

When to revisit this document

Update this document when any of the following change:

  • A new dependency is added with a license outside deny.toml's allow-list (and the allow-list is widened).
  • A new advisory is ignored in deny.toml (the reason should also be expanded here under "known caveats" if it's load-bearing).
  • spyc gains a network attack surface (HTTP client, RPC server, remote config source).
  • A distribution channel is added or removed, or the signing chain changes (new key, a channel that stores a credential, a move away from keyless).
  • The MCP tool surface gains a tool that writes outside spyc's own process, or the root validation described above is relaxed.

There aren't any published security advisories