Skip to content

Contributing

How to work with the codebase and submit pull requests. The authoritative copies live in the repository: CONTRIBUTING.md for the process and Makefile.md for the target reference.

This project actively uses AI assistance in development. If you are using Claude or another AI tool to help with your contribution:

  1. Disclose your use. Mention it in your pull request description (for example, “Implemented with Claude Code assistance”).
  2. Take responsibility for correctness. The PR author is responsible for verifying that all changes work correctly, pass tests, and follow the project’s standards. AI output is a draft, not a finished product.
  3. Review carefully. Before submitting, run make pre-pr, read the diff, and verify it matches what you intended.

An AI assistant working in the repository should read the root CLAUDE.md for project-wide instructions (it loads SSOOSSH.md, the product intent), client/CLAUDE.md and server/CLAUDE.md for each side’s role and layout, ./.claude/rules/ for the Go and TypeScript conventions, and AGENTS.md when several agents work in parallel. The project uses rtk to minimize token usage in bash commands.

  • Go 1.27+
  • Node.js 26+, pnpm 11+
  • golangci-lint
  • Docker, for make semgrep and for the e2e scenarios that start NATS or Postgres in containers. Without a Docker daemon those scenarios skip rather than fail.

The pam_ssoossh PAM module is a separate project, mnestor/ssoossh-pam; its build toolchain is not needed here. Its documentation is still maintained in this repository under user-docs/.

make help lists every target with a one-line description. Run it first if you are not sure what exists.

Path Contents
/cmd/ binary entrypoints (ssoossh client, ssoosshd server)
/client/ SSH client code (Go)
/server/ server code (Go)
/internal/ shared code (Go)
/frontend/ web UI (SvelteKit, TypeScript)
/docs/ design proposals, developer notes, and generated artifacts (man pages, openapi.yaml, wire-contract.json)
/user-docs/ this documentation site (Astro Starlight)
/test/ end-to-end test harness

Branches follow the pattern feat/, fix/, or chore/, then the issue number and a short description:

Terminal window
git checkout -b feat/123-add-token-refresh

Commits use Conventional Commits:

feat(client): add token refresh on certificate expiry
Tokens are now automatically refreshed 10 seconds before
expiry, reducing the likelihood of failed operations.
Fixes #42
Target When
make help First. Lists every target (it is the default goal)
make verify In the edit loop. Compiles and typechecks everything, runs unit tests. No containers, no host changes
make pre-pr Before opening a PR. Formats, autofixes, then runs every merge gate

pre-pr is fmt then lint-fix then check-generated then ci-required, in that order: the autofix steps must run before anything checks for what they fix.

make verify is the deliberate subset that keeps both in: lint, lint-tagged, lint-cross, test, check-generated. It is faster than pre-pr, still compiles the tagged suites, and still typechecks the Windows and macOS builds. It is not a merge gate: pre-pr is.

The curated set make help prints. Per-component variants (test-server, test-client, test-internal, and the matching lint-*) and the internal check halves (types-check, man-check, and friends, all reached through check-generated) are deliberately left out of both.

Target What it does
make pre-pr Format, autofix, then run every merge gate CI runs
make verify Fast subset for the edit loop – still compiles the tagged suites
make worktree Create a ready-to-use agent worktree: make worktree NAME=<name> [BASE=main] [WORKTREE_BRANCH=...]
Target What it does
make frontend Build the web UI into server/frontend/dist
make csp-check Assert the built UI’s assets are allowed by the served CSP (part of ci-required)
make build Build all Go packages
make linux Snapshot build for linux/amd64 only
make binaries Snapshot build for every release target
make server-linux-build-local Build ssoosshd for a local docker build (see Dockerfile)
make server-linux-pkcs11-build-local Build ssoosshd with PKCS#11 for a local docker build (see Dockerfile.pkcs11)
make frontend-clean Remove the built web UI
Target What it does
make test Unit tests per component, with coverage
make test-race Unit tests under -race
make cover Coverage HTML report at .coverage/coverage.html
make cover-ci Coverage exactly as codecover.yaml runs it
make cover-floors Fail if any package dropped below its floor in .coverage-floors
make frontend-test Frontend unit tests (vitest)
make a11y Accessibility sweeps over the app and the docs site
make a11y-frontend axe sweep over every frontend component
make a11y-docs axe sweep over the built documentation site

Test: tagged suites, not part of make test

Section titled “Test: tagged suites, not part of make test”
Target What it does
make test-hsm HSM key source tests against softhsm2 (needs softhsm2 + opensc)
make bench-hsm Signing benchmarks per CA key type (needs softhsm2 + opensc)
make test-e2e End-to-end suite (modifies host state)
make test-e2e-unlocked test-e2e without the serialising lock, for a deliberate parallel run
make test-memory-leak Memory leak repro tests
make test-resilience Resilience suite (shutdown, db loss, oidc loss)
make test-postgres The whole Go suite on a throwaway Postgres (needs docker, no tag)
make test-migration test-postgres for ./test/migration/... only: SQLite/Postgres migration parity checks
make test-load Load, soak, and concurrency suite (slow)

Each needs its tag: e2e, resilience, load, hsm, softhsm, memory_leak_test. test-hsm and bench-hsm need both hsm and softhsm. test-memory-leak is quarantined: those tests assert defects exist, so they are meant to fail.

Target What it does
make fmt Format Go and frontend sources in place
make fmt-check Fail if any Go file is not gofmt-clean
make lint-fix Auto-fix every lint finding golangci-lint can fix
make lint golangci-lint over the whole module (merge gate)
make lint-tagged golangci-lint over the build-tagged suites lint cannot see
make lint-cross golangci-lint the Windows and macOS builds lint cannot see
make frontend-lint prettier --check and eslint over the frontend
make frontend-check svelte-check the frontend against tsconfig.json
make actionlint Lint the GitHub Actions workflow files
make check-gitignore Assert the .gitignore invariants hold
make check-go-version Assert the images’ Go is new enough for go.mod

lint-fix matters more than it looks, and must run before lint: several enabled linters are mechanically fixable (godot comment full stops, the gofmt/goimports formatters, the interface{} to any rewrite) and there is no pre-commit hook in this repo. Skipping it fails the merge gate over punctuation. frontend-check is the only thing that typechecks hand-written Svelte.

Target What it does
make check-generated Assert every generated artifact is current
make types Regenerate frontend wire types from Go structs
make openapi Regenerate docs/openapi.yaml from swag annotations
make gendocs Regenerate man pages from the cobra commands
make confdocs Regenerate the config reference and defaults.yaml comments
make clidocs Regenerate the docs site’s CLI reference from the cobra commands
make makefile-docs Regenerate the target tables in Makefile.md
make wire-contract Regenerate docs/wire-contract.json from the goldens and the spec
make wire-contract-bundle Build the release’s wire-contract tarball into .wire-contract/
make third-party-licenses Regenerate THIRD-PARTY-LICENSES.md

Edit the source, never the generated file. Each gate regenerates into the tree and asserts nothing changed.

Regenerate with Gated by Source of truth
types types-check Go wire structs (see tygo.yaml)
openapi openapi-check, openapi-lint swag annotations on server/controller handlers
gendocs man-check the cobra command trees
confdocs confdocs-check the doc comments on server/config’s structs
clidocs clidocs-check the cobra command trees (the docs site’s CLI reference)
wire-contract wire-contract-check the goldens under internal/apitypes/testdata and server/controller/testdata, plus docs/openapi.yaml’s paths
makefile-docs makefile-docs-check the ##@/## annotations in the Makefile
third-party-licenses (none, not committed) the Go module cache

confdocs produces three artifacts from one walk: the OPTIONS body of docs/man/ssoosshd.yaml.5, the docs site’s reference/config/ pages with user-docs/config-sidebar.json, and the comments in server/config/defaults.yaml, the file embedded in the binary and installed as /etc/ssoossh/ssoosshd.yaml. Only the prose is generated. The values in defaults.yaml are read from the file and written back untouched, because that is the file viper loads; server/config’s golden test guards them. Adding a config field with no doc comment fails the generator rather than shipping a bare key name in two places.

The checks compare sha256 hashes rather than git diff, deliberately: git diff ignores untracked files, so a never-committed generated file would pass while reporting nothing.

gendocs covers the .1 and .8 pages generated from cobra. The .5 config-format pages (ssoossh.yaml.5, ssoosshd.yaml.5) are hand-written and outside that set.

Target What it does
make govulncheck Scan Go dependencies for known vulnerabilities
make pnpm-audit Audit frontend dependencies
make semgrep Semgrep scan of the frontend (merge gate)
make security Run every security scanner
make ci-required Every blocking check CI runs
make ci-advisory The non-blocking scanners, run the way CI runs them
make ci ci-required plus the advisory scanners
make version Print the version this tree would build as
make update Update Go dependencies and tidy

semgrep is the only merge gate of the three scanners; govulncheck and pnpm-audit are advisory, because either can surface a dependency you cannot fix in the same PR. semgrep is frontend-only on purpose: gosec inside golangci-lint covers the Go side. Details: dependency scanning.

Deliberately not in ci-required: test-e2e (modifies host state), test-load (weekly), and the client-matrix macOS/Windows legs (they need those operating systems).

Three things hide code from the obvious command

Section titled “Three things hide code from the obvious command”

Why verify and pre-pr are targets rather than habits. Each of these lets a plain command report success over code it never looked at.

What hides Hidden from How it bit Covered by
Build tags lint passes none; test builds none A suite behind e2e, resilience, load, hsm, softhsm, or natsintegration can fail to compile outright while both report success lint-tagged
GOOS lint runs with the host’s A G115 overflow bug sat in client/config/policy_windows.go, on a value an admin sets through Group Policy lint-cross
cgo golangci-lint cannot see into a cgo file Every symbol referenced only from one reports as unused. The devcontainer defaults CGO_ENABLED=0 Explicit CGO_ENABLED=1 per recipe

cover-floors ratchets per package, not on one module number, because a module total moves for unrelated reasons and hides exactly the regression it exists to catch. Raising a floor is routine; lowering one is a deliberate edit to .coverage-floors that shows up in review.

Go

  • Interfaces are defined in the package that implements them, not the package that uses them.
  • Errors are wrapped with context: return fmt.Errorf("operation: %w", err).
  • context.Context is the first parameter for I/O operations.
  • No global state; pass dependencies through structs.
  • No init() functions; wire startup explicitly. In tests that need package-wide setup, use TestMain.
  • slog for structured logging; slog is the only global allowed.
  • Imports in three groups: standard lib, external, local, separated by blank lines.
  • Every function has a comment explaining what it does.
  • Tests are colocated: email.go becomes email_test.go.

TypeScript/Svelte

  • No {@html}, innerHTML, or eval. Svelte’s auto-escaping protects against XSS.
  • Descriptive test names: “should [action] when [condition]”.
  • No external mock frameworks; use interfaces to inject test doubles.

Coverage

  • Aim for over 90% coverage in each package.
  • Coverage is reported unfiltered: what make test, make cover, CI and Codecov show is the same number. There is no exclusion list.
  • Not all code can be tested (cgo entry points, crypto/rand failures, defensive branches that cannot be reached). Explain those in place with a comment starting not covered:, so a reader looking at the coverage report finds the reason next to the code. Grep for not covered: to see every such block.
  • That marker is for code a test genuinely cannot reach. Code that is merely awkward to reach is a gap: write the test.

Known coverage gaps are tracked in docs/dev/testing-needs.md, each with the bug that exposed it. Add to it when you find a gap you are not closing in the same change, and delete entries as they are closed rather than marking them done.

  1. Create a branch off main: git checkout -b feat/<issue>-your-feature.
  2. Make your changes. Write tests as you go.
  3. Run the gate locally: make pre-pr.
  4. Commit with conventional commits. Keep one concern per commit.
  5. Open a PR against main. The description should explain why the change matters, not just what changed.
  6. Wait for CI.
  7. Respond to feedback. Add commits rather than amending, so the review history is clear.

Expect the first pre-pr run to take a while: ci-required builds the web UI, runs the whole unit suite with coverage, and runs the frontend lint, typecheck, and semgrep scan.

Workflow Blocking Notes
lint yes Go lint: the host build and the Windows and macOS builds. Plus frontend lint and svelte-check, actionlint, .gitignore invariants
codecover yes Unit suite plus the Codecov upload
build yes On PRs: generated-artifact staleness plus a single-target snapshot build. The full signed multi-platform pipeline runs on tags, weekly, and manual dispatch
e2e yes Three tiers plus the multi-signer job. SQLite, except that tier 1 runs both backends, the multi-signer job runs against a Postgres service, and the weekly extended run covers both
client-matrix yes macOS and Windows client and agent tests
resilience yes Resilience, migration parity, frontend tests, and the axe sweep over both the app and this site. The load job is weekly, not per-PR
hsm when it runs The HSM key source against a real SoftHSM2 token. Unlike the others it filters in on: rather than in a changes job, so on an unrelated PR it does not start at all rather than reporting as skipped
security partly semgrep blocks; govulncheck and pnpm audit report to a PR comment
docs-site no Builds and deploys this site on a push to main. Skipped while the repository is private

Most workflows are behind a path filter, so a docs-only PR will show several checks as skipped. Skipped satisfies branch protection; it is not a failure.

  • check-generated – you changed a Go wire type, a swag annotation, or a cobra command without running make types, make openapi, or make gendocs and committing the result.
  • frontend-check – pnpm build does not typecheck, so a Svelte or TypeScript type error only shows up here. Run make frontend-check locally.
  • semgrep – frontend only, and a merge gate rather than advisory. make semgrep reproduces it exactly (same pinned image, same rule packs).
  • client-matrix – a macOS or Windows path. Nothing local reproduces it; read the job log. If your change touches client/ or internal/crypto/ssh/agent/, CI runs those legs for you and they are the ones most likely to surprise you: path handling, agent sockets, file modes, and keychain behavior all differ per platform. See cross-platform-testing.md for the differences that have actually broken a build.
  • One concern per PR. If you are adding a feature, do not also refactor unrelated code.
  • Focused scope. Aim for PRs that can be reviewed in 10 to 15 minutes.
  • Avoid merge commits. Rebase before pushing if CI added new commits.
  • Schema changes are single-owner. If your PR edits server/model/ or server/resources/migrations/, no other PR can land in parallel until yours is merged. Coordinate with the maintainer first.
Requirement Needed by Why
cgo on -tags=hsm builds: test-hsm and server-linux-pkcs11-build-local The HSM key source reaches libpkcs11 through crypto11, and it is compiled only under the hsm build tag. The default build is cgo-free. Several recipes (build, test-server, lint, gendocs among them) still set CGO_ENABLED=1 from before that split; it is harmless
cgo off lint-cross, test-client There is no cross-compiling cgo toolchain, which is also why lint-cross scopes to a package list rather than ./...
Node, pnpm frontend, frontend-*, fmt, pnpm-audit The web UI toolchain
Frontend bundle Anything embedding or testing the UI server/frontend embeds it. A stale bundle makes a browser test assert against whenever the UI was last built, presenting as a selector timeout indistinguishable from a wrong selector
Docker semgrep, and the e2e scenarios that need NATS or Postgres Pinned images, same as CI. Without a daemon those e2e scenarios skip
Host state + sudo test-e2e tier 3 Creates and unlocks a local account, runs sshd as root
softhsm2 + opensc test-hsm A real PKCS#11 token
goreleaser linux, binaries, all Snapshot builds

Testing plans and design records live in the repository under docs/dev/.

Document What it covers
e2e-testing-plan.md The end-to-end merge gate: login, browser approval, certificate, ssh
cross-platform-testing.md Testing the client across macOS, Linux, and Windows
testing-strategy-assessment.md What further test investment would and would not buy
test-coverage-gap-map.md Where coverage is thin and why
testing-needs.md Known coverage gaps, each with the evidence it is real. A worklist
mutation-testing-findings.md What mutation testing surfaced
multi-instance-safety-plan.md Design record for multi-instance safety (implemented)
signer-split-deferred.md Design record for the split signer process (implemented)

Architecture and design records for the shipped system are on this site under Internals. For larger features, add a design document to docs/proposals/ before implementing.

The site is an Astro Starlight project in user-docs/. Pages are Markdown under user-docs/src/content/docs/, and the site is served under a /ssoossh base path, so internal links are written as absolute paths with that prefix and a trailing slash.

Terminal window
cd user-docs
npm install
npm run dev # local preview with hot reload
npm run build # what CI builds

The configuration reference and the CLI reference are generated. Do not edit them by hand:

  • make confdocs regenerates the reference/config/ pages (and the sidebar entries that order them) from the doc comments on server/config’s structs, alongside the man page and the annotated defaults.yaml.
  • make clidocs regenerates the reference/cli/ pages (and cli-sidebar.json) from the cobra command trees, the same trees make gendocs turns into man pages, so the two can never disagree.
  • make openapi regenerates docs/openapi.yaml, which backs the HTTP API reference.

If you find a bug, check the existing issues to avoid duplicates, then provide: steps to reproduce, expected versus actual behavior, your environment (OS, Go version), and the error output or stack trace. Open an issue to discuss architectural questions before coding.