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.
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 installruns. - 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.
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 theinitializehandshake 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.
Cargo.lockis committed. Every build resolves the same set of versions.cargo buildwill not silently bump deps.--lockedeverywhere.make test/make lint/ all release builds pass--lockedto cargo, so a CI-timeCargo.lockdrift 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 frommake denylocally. Dispatch it (gh workflow run audit.yml) after merging anything that movesCargo.toml/Cargo.lock, and before cutting a release. Configuration is checked in atdeny.tomlwith 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 listwhen 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, theUnlicense OR MITBurntSushi 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-indexare accepted. Nogit = "..."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.
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-provenanceproduces 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-blobsignsSHA256SUMSwith an ephemeral OIDC-derived key, recorded in the Rekor transparency log, published asSHA256SUMS.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.
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_COMMITnames 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/CHECKSUMScarries 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-ghosttyrebuilds 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-containerdoes 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-sysbindingsgit cloneghostty at build time and runzig build. That fetches code from a URL duringcargo build, is invisible tocargo vendorand--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.
- 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 checkandCargo.locktogether give us a full audit trail, but we don't emit a CycloneDX or SPDX SBOM artifact. If a consumer needs one, generating it fromCargo.lockis 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.ymlruns every target on a schedule;make fuzzruns one on demand (nightly, deliberately out ofmake 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 installinvokescodesign -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_serverwraps the bind inumask(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. Thespyc --mcpentry 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 withspyc --remove-agentsbefore uninstalling it.
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.
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
rootvalidation described above is relaxed.