This file is the executable companion to plan-01-gittuf-forge.md. Read that first for why; this is what and done-when. Work through the milestones below in order: find the first one whose acceptance tests don't pass, work until they do, move on.
- Module path:
github.com/git-pkgs/silo - Go: 1.26 (
go 1.26in go.mod,toolchainunset) - Licence: MIT.
LICENSEfile fromhttps://raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt, copyright line2026 Andrew Nesbitt. proxy integration later stays separate-process over HTTP. - Push-rejection error format: multi-line over sideband-64k band 2, one fact per line. Exact shape:
Followed by report-status
silo: rejected <refname> rule '<rule-name>' requires <threshold> of: <principal>, <principal>, ... you pushed as: <user> (<key-fingerprint>) — <in set|not in principal set> approvals on record: <n>/<threshold> policy: <base-url>/<owner>/<repo>/policy#<rule-name>ng <refname> policyso git renders! [remote rejected] ... (policy). - Test layout:
internal/<pkg>/<pkg>_test.gofor unit tests,testdata/testscript/*.txtarfor end-to-end viarogpeppe/go-internal/testscript. Fixtures (captured wire bytes, keys) undertestdata/fixtures/.
A milestone is not done until all of the following pass. Add a Makefile with these as targets during the receive-pack milestone and keep it green.
Tests and coverage:
go test -race -shuffle=on ./...passes.go test -cover ./internal/...reports ≥ 80% per package;internal/receiveandinternal/gittuf≥ 90%. Enforce withgo test -coverprofile=cover.out ./internal/... && go tool cover -func=cover.out | tail -1and a check script.- Every exported func in
internal/receive,internal/gittuf,internal/signerhas at least one direct test; table-driven where there's more than one case.
Lint and static analysis:
golangci-lint run --enable gocritic,gocognit,gocyclo,maintidx,dupl,mnd,unparam,ireturn,goconst,errcheck ./...clean.govulncheck ./...clean.deadcode ./...clean.gosec -quiet ./...clean, or each finding suppressed with a// #nosec Gxxx -- <reason>comment that names the reason.
Benchmarks:
- Each package that sits in the request path has at least one
Benchmark*withb.ReportAllocs(). Minimum set once gittuf verification is wired:BenchmarkReceive_SingleRef(replay the fixture),BenchmarkPktlineRead,BenchmarkVerifyRef(warm policy),BenchmarkWitness. go test -bench . -benchmem -run ^$ ./internal/... | tee bench.txtis committed at the end of each milestone so regressions are visible. No hard thresholds yet, but a >2× regression in ns/op or allocs/op between milestones is a finding to investigate, not ignore.
Security review:
go run github.com/git-pkgs/capcheck@latest ./cmd/siloafter each milestone; any new capability (network, exec, filesystem outside$SILO_DATA) gets a one-line justification appended under Findings. The baseline once SSH push works is: net (listeners), filesystem ($SILO_DATA), no exec, no cgo.- Checklist, recorded under Findings as each item becomes applicable:
internal/receive: everyio.Readerfrom the wire is wrapped in a limit; noio.ReadAllon client streams; pkt-line length field validated before allocating.internal/ssh: exec command parsing rejects anything that isn't exactlygit-upload-packorgit-receive-packfollowed by a single quoted path; the path is cleaned and confined underrepos/.internal/hooks:flockis acquired before any gittuf state is read and released on every return path;recover()wrapsVerifyRefandInvokeHooksForStage; the forge key file is0600and refused otherwise.
- Fuzz targets listed per milestone run for at least 1 minute locally (
go test -fuzz=Fuzz -fuzztime=60s) with no crashes before the milestone closes; corpus additions are committed.
Documentation:
- All user-facing documentation (README,
docs/*, godoc, cobraLongstrings) describes the system as it currently exists, written as if for the first time. Never reference implementation history, prior approaches, milestones, decisions that were reversed, or "we used to / now we / this was changed because". A reader should not be able to tell the docs were written incrementally. If a design choice needs justifying, state the constraint it satisfies, not the path that led to it. Process notes belong in Findings orGITTUF-NOTES.md, never inREADME.mdordocs/. README.mdexists oncesilo serveexists and is kept current. It covers, in this order: one-paragraph what-and-why (the gittuf-native forge thesis, two sentences), install (go installand binary), quick start (the smallest sequence of commands that gets from nothing to a verified push:silo keygen,silo serve,silo admin user create,gittuf trust init,git push,gittuf verify-ref), the CLI command list, and configuration (flags andSILO_*env). Keep it under 200 lines; anything longer goes indocs/. Follow the writing rules in~/.claude/CLAUDE.md(no emoji, no banned words, flowing prose over bullet farms, no marketing headers).docs/holds the longer pieces, one file per topic, written as they become true:docs/architecture.mdoncesilo serveexists: the receive pipeline diagram in ASCII, the disk layout, the package map. Condensed fromplan-01-gittuf-forge.md; that file is design-time and can drift, this one tracks the code.docs/trust-model.mdonce gittuf verification is wired: owner-signs-root, witness vs authoriser, whatgittuf verifyproves and what it doesn't, the import-genesis caveat. Written for someone evaluating whether to run silo, not for a contributor.docs/errors.mdonce gittuf verification is wired: every push-rejection message silo can emit, what it means, what to do about it. The multi-line format is the first entry.docs/api.mdonce/api/v1exists: the surface with request/response examples; mark which endpoints are Gitea-compatible.docs/deploy.mdonce anon-read caching exists: single-binary, systemd unit, the one-writer/N-reader topology, reverse proxy config, what to back up ($SILO_DATA).
- Every
cmd/silosubcommand has a usefulShortandLongin its cobra definition;silo <cmd> --helpis the source of truth anddocs/cli.mdis generated from it (cobra/doc.GenMarkdownTree), not hand-written. - Godoc: every exported type and func in
internal/receive,internal/gittuf,internal/signerhas a doc comment.go doc ./internal/receiveshould read as a usable reference.
End-to-end smoke:
testscript_test.goat the repo root drives everytestdata/testscript/*.txtaragainst a freshly builtsilobinary (built intot.TempDir(), exposed viatestscript.Params.Cmds).go test ./...therefore runs unit and smoke together.- A
make smoketarget builds the binary and runs only the txtar suite, for quick iteration. - The smoke suite must pass with
-count=3to catch order-dependence and port races (use:0listeners and surface the bound port via a status file the txtarwaitforreads).
git init
curl -sL https://raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt > LICENSE # then set the copyright line
go mod init github.com/git-pkgs/silo
mkdir -p cmd/silo internal testdata/testscript testdata/fixtures
Pinned direct deps (do not bump without re-running R1-R4 and updating their citations):
github.com/go-git/go-git/v5—v5.19.1github.com/gittuf/gittuf—6f382ee5c02943dee0195af6bee751a2d24a6533(2026-06-14), sinceexperimental/is unstable by name.github.com/gliderlabs/ssh,modernc.org/sqlite,github.com/spf13/cobra,github.com/rogpeppe/go-internal— latest tagged at the time their milestone adds them.
The gittuf CLI is needed for the verification txtar. Build it from the pinned module into the testscript Cmds map rather than relying on ambient PATH:
gittufBin := buildOnce(t, "github.com/gittuf/gittuf") // go build -o tmpdir from the pinned go.sumDo not add a dep before the milestone that needs it.
These hold for the whole run.
- Milestone ordering is strict. Do not start the next milestone until the current one's "Done when" line is satisfied, even if parts of the next look trivial.
- Commit freely as work progresses; small commits are fine. Never push. Never rewrite history (no amend, no rebase, no force).
- Do not add: web frameworks or routers (stdlib
net/http.ServeMuxonly), config file parsing (flags +SILO_*env only for now), apkg/directory, ORMs or query builders (write SQL), codegen beyondcobra/doc, JS/CSS toolchain, telemetry. - Do add, once
silo serveexists: aDockerfile(multi-stage,FROM golang:1.26 AS build→FROM gcr.io/distroless/static, the binary and nothing else,USER nonroot,VOLUME /data,ENTRYPOINT ["/silo","serve","--data","/data"]) and.github/workflows/ci.ymlrunninggo test -race -shuffle=on ./..., the lint set,govulncheck, andmake smokeon push and PR. Keep both current as milestones land. - Stuck protocol: when gittuf or go-git can't do what a milestone assumes, implement the smallest shim that makes that milestone's acceptance tests pass, record the shape and size of the shim in
GITTUF-NOTES.md(or Findings for go-git), and move on. Do not redesign the milestone. Do not open upstream issues or PRs. - SSH host key: generate ed25519 into
$SILO_DATA/host_ed25519on firstserveif absent,0600, refuse to start if perms are wider. - testscript scaffolding (
testscript_test.goat repo root):Setup: setGIT_CONFIG_NOSYSTEM=1,GIT_AUTHOR_NAME=test,GIT_AUTHOR_EMAIL=test@test,GIT_COMMITTER_*likewise; allocate two:0TCP listeners, exportSILO_HTTP/SILO_HTTP_PORT/SILO_SSH/SILO_SSH_PORT, close the listeners (silo reopens them);SILO_DATA=$WORK/data; generate ed25519 keypairsalice,bob,malloryinto$WORK(private +.pub).Cmds:silo(built once into a temp dir),gittuf(built from the pinned module),waitfor <addr>(dial tcp every 50ms, fail after 5s),silo-test-seed <bare-path>(init bare, add one commit withREADME.md).- Background
exec ... &processes are tracked and killed int.Cleanup.
- Extraction candidates: when a package under
internal/turns out to have no silo-specific imports and a clean boundary, append it toEXTRACT.mdwith the proposedgit-pkgs/*name, the public surface, and who else would use it. Do not actually extract;internal/is fine.
Resolve these before writing any code and record the answers under "Findings" at the bottom. Each is a yes/no with a file:line citation.
- R1: Does
github.com/gittuf/gittuf/experimental/gittuf.LoadRepositoryopen a bare repo (no worktree) and doVerifyRef/RecordRSLEntryForReferencework against it? Checkpkg/gitinterface/repository.gofor worktree assumptions. If no, note the minimum shim needed. - R2: Can a gittuf delegation reference a principal by name with key material resolved from a separate signed metadata blob, or is the key always embedded by value in root? Check
internal/tuf/v02/for the principal/key types. - R3: Does go-git
plumbing/transport/server.ReceivePackexpose a hook between packfile-unpack and ref-update? Checkgo-git/v5/plumbing/transport/server/server.go. Expected answer: no, which is why receive-pack is owned below. - R4: Does go-git
plumbing/protocol/packp.AdvRefslet arbitrary capability strings be set, sobundle-urican be advertised? Checkpackp/advrefs.goandcapability/capability.go.
Goal: a ReceivePack(ctx, repo, r io.Reader, w io.Writer, hooks Hooks) error that speaks git's receive-pack protocol over arbitrary streams, with a callback between unpack and ref application.
Deliverables:
internal/receive/receive.go— the entry point and theHooksinterface.internal/receive/proto.go— pkt-line read/write, ref-update command parsing, report-status encoding. Reusego-git/v5/plumbing/format/pktlineandplumbing/protocol/packptypes where they fit; do not reusetransport/server.internal/receive/unpack.go— stream the packfile fromrinto the repo'sstorer.EncodedObjectStorerviago-git/v5/plumbing/format/packfile.UpdateObjectStorage. EnforceMaxPackBytes(default 512 MiB) andMaxObjects(default 100 000) read from aLimitsstruct; exceeding either aborts withng * unpack-limit.internal/receive/errors.go—RejectionError{Ref, Rule, Threshold, Principals, Pusher, PusherKey, Approvals, PolicyURL}with aSideband() []stringmethod producing the multi-line format above, and aStatus() stringreturningpolicyfor the report-status reason.testdata/fixtures/push-single-ref.bin— bytes of a realgit pushof one commit to one ref, captured withGIT_TRACE_PACKET=1against a throwawaygit daemon, for replay in tests.
Wire sequence the implementation must follow:
- Write ref advertisement: for each ref in the repo, one pkt-line
<oid> <refname>, first line carries\0<capabilities>where capabilities includereport-status,side-band-64k,delete-refs,quiet,agent=silo/<ver>. If repo is empty, advertise<zero-oid> capabilities^{}with the cap list. Flush-pkt. - Read command list: pkt-lines of
<old-oid> SP <new-oid> SP <refname>, first line carries\0<client-caps>. Stop at flush-pkt. Build[]RefUpdate{Name, Old, New plumbing.Hash}. - If any
Newis non-zero, a packfile follows onr. Unpack it into storage. If all updates are deletes, no pack. - Call
hooks.PreReceive(ctx, repo, updates). If it returns aRejectionError, write itsSideband()lines on band 2, then report-status withunpack okandng <ref> <reason>for each ref, and return nil (the protocol completed; the push was refused). - On
PreReceivesuccess, apply each ref update viarepo.Storer.SetReference(orRemoveReferencefor deletes). All-or-nothing: if any set fails, roll back the ones already applied and reportngfor all. - Call
hooks.PostReceive(ctx, repo, updates). Errors here are logged, not surfaced to the client. - Write report-status:
unpack ok, thenok <ref>per applied update, flush-pkt. All wrapped in sideband band 1 ifside-band-64kwas negotiated.
Hooks interface:
type RefUpdate struct {
Name plumbing.ReferenceName
Old, New plumbing.Hash
}
type Hooks interface {
PreReceive(ctx context.Context, repo *git.Repository, updates []RefUpdate) error
PostReceive(ctx context.Context, repo *git.Repository, updates []RefUpdate)
}Acceptance (go test ./internal/receive/...):
TestReceive_SingleRef: in-memory go-git repo, replaypush-single-ref.bin, assert ref exists at expected OID and report-status bytes match golden.TestReceive_PreReceiveReject: hook returns aRejectionError; assert ref unchanged, sideband lines match the resolved format, report-status isng refs/heads/main policy.TestReceive_Delete: push a delete (zero new-oid, no pack); assert ref removed.TestReceive_PackTooLarge: setMaxPackBytes=1; assert abort withunpack-limitand no objects written.TestReceive_Atomic: two ref updates, second fails inSetReference(simulate via a storer wrapper); assert first is rolled back.FuzzCommandList: fuzz the pkt-line command parser; must not panic.
Done when: tests above pass, go vet ./... clean, a real git push against ReceivePack wired to a net.Pipe succeeds end to end (covered by the SSH-push smoke test below).
Goal: silo serve listens on HTTP, anonymous git clone http://.../owner/repo.git of a manually-placed bare repo works.
Deliverables:
cmd/silo/main.go,cmd/silo/serve.go— cobra root andservesubcommand. Flags:--data(default./silo-data),--http(default:8080).internal/config/config.go—Config{DataDir, HTTPAddr, SSHAddr, BaseURL string}loaded from flags and env (SILO_*).internal/gitstore/gitstore.go—Open(dataDir) *Store;(*Store).Repo(owner, name) (*git.Repository, error)opening<dataDir>/repos/<owner>/<name>.gitas bare viago-git/v5.PlainOpenWithOptions(..., {DetectDotGit: false});(*Store).Init(owner, name)creating a bare repo.internal/http/git/git.go—Handler(store) http.Handlerserving:GET /:owner/:repo.git/info/refs?service=git-upload-pack→ smart advertisement viago-git/v5/plumbing/transport/server.NewServer(loader).NewUploadPackSession(...)andAdvertisedReferencesContext.POST /:owner/:repo.git/git-upload-pack→UploadPackon the same session.- 404 on
git-receive-pack(HTTP is anon-read-only). Thetransport.Loadermaps endpoint path →gitstore.Repo.
Dockerfile,.github/workflows/ci.yml,README.md,docs/architecture.md,Makefile— see Conventions and Quality bar.
Acceptance (testdata/testscript/01_clone.txtar):
# 01_clone: anonymous HTTP clone of a pre-seeded bare repo
exec silo-test-seed $SILO_DATA/repos/alice/demo.git # helper: git init --bare + one commit
exec silo serve --data $SILO_DATA --http $SILO_HTTP &
waitfor $SILO_HTTP
exec git clone http://$SILO_HTTP/alice/demo.git out
exists out/README.md
# receive-pack over HTTP must refuse
! exec git -C out push origin HEAD:refs/heads/x
stderr '404'
Plus TestLoader unit test: unknown repo → transport.ErrRepositoryNotFound.
Done when: 01_clone.txtar passes via testscript.Run, silo serve exits cleanly on SIGINT.
Goal: silo admin user create, register an SSH key, git push over SSH lands via the owned receive-pack with a no-op hook.
Deliverables:
internal/store/store.go—Open(dataDir) (*Store, error)opening<dataDir>/silo.db(modernc.org/sqlite), schema applied on first open. Tables:users(id, name, created_at),ssh_keys(id, user_id, fingerprint, pubkey, created_at),tokens(id, user_id, hash, created_at),repos(id, owner, name, created_at),repo_members(repo_id, user_id, role),jobs(id, repo_id, kind, state, payload, attempts, updated_at). CRUD funcs:CreateUser,AddSSHKey,UserBySSHFingerprint,CreateRepo,RepoByPath.cmd/silo/admin.go—silo admin user create <name> --ssh-key <path>;silo admin repo create <owner>/<name>.internal/ssh/ssh.go—Serve(ctx, addr, store, gitstore, hooks)usinggliderlabs/ssh.PublicKeyHandlercomputes SHA256 fingerprint, looks upUserBySSHFingerprint, stashes user inssh.Context.Handlerparses the exec command (git-upload-pack '<path>'orgit-receive-pack '<path>'), resolves the repo, and:- upload-pack → go-git
UploadPacksession overs/s.Stderr(). - receive-pack →
receive.ReceivePack(ctx, repo, s, s, hooks)with the resolved user attached to ctx.
- upload-pack → go-git
internal/receive/context.go—WithPusher(ctx, Pusher{User, KeyFingerprint}) / PusherFrom(ctx).- A no-op
Hooksimplementation incmd/silo/serve.gofor now:PreReceivereturns nil,PostReceiveno-op.
Acceptance (testdata/testscript/02_push.txtar):
# 02_push: SSH push lands, HTTP clone sees it
exec silo admin user create alice --ssh-key $WORK/alice.pub --data $SILO_DATA
exec silo admin repo create alice/demo --data $SILO_DATA
exec silo serve --data $SILO_DATA --http $SILO_HTTP --ssh $SILO_SSH &
waitfor $SILO_SSH
env GIT_SSH_COMMAND='ssh -i $WORK/alice -o StrictHostKeyChecking=no -p $SILO_SSH_PORT'
exec git init repo
exec git -C repo commit --allow-empty -m one
exec git -C repo remote add origin ssh://git@localhost:$SILO_SSH_PORT/alice/demo.git
exec git -C repo push origin main
stdout 'main -> main'
exec git clone http://$SILO_HTTP/alice/demo.git out
exec git -C out log --oneline
stdout 'one'
# unknown key rejected
env GIT_SSH_COMMAND='ssh -i $WORK/mallory -o StrictHostKeyChecking=no -p $SILO_SSH_PORT'
! exec git -C repo push origin main:x
Unit: TestUserBySSHFingerprint round-trip; TestSSHExecParse for the git-receive-pack 'owner/repo.git' quoting variants git emits.
Done when: 02_push.txtar passes, silo.db schema matches the table list above (TestSchema asserts via pragma table_list).
Goal: repo creation produces an unsigned policy skeleton; pushes are refused until the owner signs root; thereafter receive verifies policy and witnesses the RSL. The push-rejection error format is exercised end to end.
Deliverables:
internal/signer/signer.go—type Signer interface { Sign(data []byte) ([]byte, error); PublicKey() crypto.PublicKey; ID() string }andLoad(cfg) (Signer, error). One backend for now:ed25519reading<dataDir>/forge.key.cmd/silo/keygen.gowrites a new key there;cmd/silo/pubkey.goprints it in SSH authorized_keys format and as a gittuf principal JSON.internal/gittuf/gittuf.go— wrapper holding a*expgittuf.Repositoryper bare repo (whereexpgittufisgithub.com/gittuf/gittuf/experimental/gittuf). Funcs:InitSkeleton(repo, ownerKey, forgeKey)→ writesrefs/gittuf/policy-stagingwith root namingownerKeyas root + authorising ongit:refs/heads/*andgit:refs/tags/*, andforgeKeyas a separate witness principal not in any authorising set. Usesexpgittufpolicy builders; if those require a signed root to write anything, write the staged metadata as a plain blob and document the format here.HasSignedRoot(repo) bool→ true iffrefs/gittuf/policyexists andexpgittuf.HasPolicy()is true.Verify(ctx, repo, updates, pusher) (*Verdict, error)→ for each update, callexpgittuf.VerifyRef; on failure, build areceive.RejectionErrorpopulated fromListRules/ListPrincipalsfor the matching rule.Verdict{Ref, Rule string; Principals []string; Threshold int; Met bool}per update.Witness(ctx, repo, updates, pusher, signer)→ for each update,RecordRSLEntryForReferencethenRecordRSLAnnotationwith messagesilo: pushed by <pusher.User> via <pusher.KeyFingerprint>, signed bysigner. Skip ifisDuplicateEntryreports the client already recorded it; in that case annotate only.
internal/hooks/builtin.go— areceive.Hooksimpl:PreReceivetakesflockon<repo>/silo.lock, checksHasSignedRoot(if false, reject with a fixed "repo not initialised: rungittuf trust initand push refs/gittuf/policy" message), runsgittuf.Verify, runsexpgittuf.InvokeHooksForStage(PrePush).PostReceiverunsgittuf.Witnessthen releases the lock. Lock is also released on anyPreReceivereturn.cmd/silo/admin.go—repo createnow callsgittuf.InitSkeletonaftergitstore.Init.docs/trust-model.md,docs/errors.md— see Quality bar.
Acceptance (testdata/testscript/03_gittuf.txtar):
# 03_gittuf: policy gates pushes, RSL is witnessed
exec silo keygen --data $SILO_DATA
exec silo admin user create alice --ssh-key $WORK/alice.pub --data $SILO_DATA
exec silo admin repo create alice/demo --data $SILO_DATA
exec silo serve --data $SILO_DATA --http $SILO_HTTP --ssh $SILO_SSH &
waitfor $SILO_SSH
env GIT_SSH_COMMAND='ssh -i $WORK/alice -o StrictHostKeyChecking=no -p $SILO_SSH_PORT'
# push before root is signed: refused with the init message
exec git init repo
exec git -C repo commit --allow-empty -m one
exec git -C repo remote add origin ssh://git@localhost:$SILO_SSH_PORT/alice/demo.git
! exec git -C repo push origin main
stderr 'repo not initialised'
stderr 'gittuf trust init'
# owner signs root locally and pushes policy
exec gittuf -C repo trust init --key $WORK/alice
exec gittuf -C repo policy init --key $WORK/alice
exec git -C repo push origin 'refs/gittuf/*:refs/gittuf/*'
# now a normal push works and is witnessed
exec git -C repo push origin main
exec git clone http://$SILO_HTTP/alice/demo.git verify
exec git -C verify for-each-ref refs/gittuf/reference-state-log
stdout 'refs/gittuf/reference-state-log'
exec gittuf -C verify verify-ref main
# bob is not in policy: push to main rejected with the multi-line format
exec silo admin user create bob --ssh-key $WORK/bob.pub --data $SILO_DATA
env GIT_SSH_COMMAND='ssh -i $WORK/bob -o StrictHostKeyChecking=no -p $SILO_SSH_PORT'
exec git -C repo commit --allow-empty -m two
! exec git -C repo push origin main
stderr 'silo: rejected refs/heads/main'
stderr 'requires 1 of: alice'
stderr 'you pushed as: bob'
stderr '/alice/demo/policy#'
# concurrent pushes to two branches: RSL stays linear
# (see TestConcurrentRSL unit test; testscript can't easily race)
Unit:
TestRejectionError_Sideband: golden test of the multi-line format.TestConcurrentRSL: two goroutines call thePreReceive/ref-apply/PostReceivesequence on the same repo for different branches; assert the RSL ref afterwards is a single linear chain of length 2 (walk parents, no fork).TestWitnessSkipsDuplicate: pre-seed an RSL entry for the update as if a gittuf-aware client pushed it; assertWitnessadds an annotation only, not a second entry.TestInitSkeleton: assertrefs/gittuf/policy-stagingexists,HasSignedRootis false, and the staged root names the owner key and not the forge key in therefs/heads/*authorising set.
Done when: 03_gittuf.txtar passes against a gittuf binary built from the pinned module, gittuf verify-ref main in the cloned verify dir exits 0, and the four unit tests pass. Record under Findings whether expgittuf needed a worktree (R1) and what shim, if any, was added.
Two wrinkles are expected here. Treat them as findings to record, not failures to redesign around.
The skeleton→owner-init handoff: silo stages a policy naming the forge key as witness, but gittuf trust init run locally by the owner doesn't read that staging ref and will produce a root that doesn't mention the forge key. The txtar will reveal whether gittuf verify-ref then accepts silo's witness annotations or not. If not, the options are (a) the owner adds the forge principal explicitly (gittuf trust add-...; document the extra command in README and docs/trust-model.md), or (b) witness annotations are verified out-of-band against silo pubkey rather than via policy. Do (a) and record in GITTUF-NOTES.md what "witness" maps to in gittuf's actual schema versus this spec's use of the word; this is likely the first substantive entry there.
The refs/RSL crash window: the receive sequence applies refs then calls Witness, both under the same flock, but a crash between them leaves the ref tip ahead of the RSL. Git can't make two ref updates atomic, so this window exists regardless of ordering. Keep the current order (refs then RSL); add a startup check in serve that walks each repo and logs a warning if any ref tip isn't covered by the latest RSL entry for that ref; and state the window and its detection in docs/trust-model.md ("a crash during push can leave a ref ahead of its RSL entry; silo logs this on next start and the next successful push to that ref closes the gap; a verifying client sees it as an unrecorded tip").
silo is likely the first project to embed experimental/gittuf as a library on the server side of receive-pack. Friction, bugs, and gaps found here are upstreamable. Throughout all milestones, append to GITTUF-NOTES.md whenever you hit one of:
- API friction: something
experimental/gittufexposes that's awkward for this use (needs a worktree, assumes a remote, takes a CLI-shaped string where a typed value would fit, etc). Note what you did to work around it. - Missing surface: something needed that's in
internal/or not exposed at all. Note whether you vendored, reimplemented, or shelled out. - Bugs: panics, incorrect verification results, RSL corruption, anything where gittuf's behaviour didn't match its docs or the design doc. Include a minimal reproduction.
- Opportunities: things gittuf could add that would make silo (or any forge integration) simpler. e.g. "a
Verdicttype returned fromVerifyRefinstead of justerror", "bare-repoRecordRSLEntrywithout the commit-signing path". - Protocol questions: places where the spec/design doc and gittuf's actual model disagree and it's not clear which is right.
One entry per finding, with the gittuf file:line that's relevant and the silo file:line where it bit. Don't fix gittuf in place; record and work around. The file is for sending upstream later, so write each entry so it makes sense to someone who hasn't read silo's code.
(Append R1-R4 answers here with file:line citations before writing any code, and any deviations from this spec discovered during implementation. gittuf-specific observations go in GITTUF-NOTES.md, not here.)
R1: yes, with caveats. pkg/gitinterface/repository.go:56 LoadRepository resolves gitDir via git rev-parse --git-dir, which works on bare repos. But: (a) it requires the git binary on PATH (repository.go:58 exec.LookPath), and every operation in pkg/gitinterface shells out to git (blob.go:14, commit.go:50, log.go:22, etc.) — silo is not pure-Go while it links gittuf; (b) LoadRepository does os.Chdir into the repo and back (repository.go:72,75), which is process-global and races under concurrent loads; (c) IsBare() at repository.go:48 returns !strings.HasSuffix(gitDirPath, ".git"), which misreports repos/owner/name.git as non-bare. Shim: serialise LoadRepository calls behind a package-level mutex in internal/gittuf, and don't rely on IsBare(). The system-git requirement is accepted for now and noted in docs/architecture.md. See GITTUF-NOTES.md for the upstream-facing entries.
R2: no. internal/tuf/tuf.go:84 Principal interface has Keys() []*signerverifier.SSLibKey; v02 Person (internal/tuf/v02/tuf.go:33) embeds PublicKeys map[string]*Key by value. AssociatedIdentities carries provider strings (Sigstore etc.) but key material is inline. Forge key rotation requires each repo's root holders to re-sign. The witness-only default keeps the blast radius small; documented in docs/trust-model.md.
R3: no. go-git/v5@v5.19.1 plumbing/transport/server/server.go:238-264 rpSession.ReceivePack calls writePackfile then immediately updateReferences with no callback between (and a //TODO: Implement 'atomic' update comment at :252). Confirmed: silo owns receive-pack.
R4: yes. plumbing/protocol/packp/capability/list.go:127-129 — validate for an unknown Capability only checks for empty arg strings, then Add stores it. advrefs.go:36 Capabilities *capability.List is exported. caps.Add(capability.Capability("bundle-uri"), uri) will encode into the advertisement.
receive-pack milestone deviations:
Advertise(repo, w)is split fromReceivePack(ctx, repo, r, w, hooks, limits)rather than one function doing both, so the same code serves SSH (caller does both on one conn) and smart-HTTP (two separate handlers). Spec step 1 belongs toAdvertise.- No
proto.go: pkt-line, command parsing, and report-status encoding are taken wholesale frompackp.{AdvRefs,ReferenceUpdateRequest,ReportStatus}andsideband.Muxer, leaving nothing for a separate file. - No
testdata/fixtures/push-single-ref.bin: tests generate wire bytes in-process viapackp.ReferenceUpdateRequest.Encode+packfile.NewEncoder, which exercises the sameDecodepath and avoids a binary blob in the repo. Real-git interop is covered by02_push.txtarin the SSH milestone. TestReceive_AtomicexercisesapplyUpdatesagainst a minimalstorer.ReferenceStorermock rather than a wrapped*git.Repository, sincestorage.Storerembeds eight interfaces and wrapping it for one method is more code than the function under test.packp.ReferenceUpdateRequest.Decodealways setsPackfileto the remaining reader (updreq_decode.go:198), soReceivePackgatesunpackon whether any command has a non-zeroNewrather than onPackfile != nil.- Security checklist for
internal/receive: pkt-line length validated by go-git'spktline.Scanner(rejects len > 65520);capReaderbounds packfile bytes; object count read from the 12-byte header before parsing; noio.ReadAllon the client stream (packfile streams throughpackfile.UpdateObjectStorage). .golangci.ymlexcludesgoconst,dupl,mnd,ireturnfrom_test.gofiles: test tables and interface mocks trip these without indicating a real problem. Production code is held to the full set.- capcheck deferred to the next milestone since there is no
cmd/silobinary yet.
serve-and-HTTP-clone milestone deviations:
-
testscript scaffolding builds the
silobinary once at repo root and prepends its dir to PATH inSetup, rather than usingtestscript.RunMain: the RunMain re-exec runs from$WORKwhere there is nogo.mod, sogo buildinside it fails.gittufwill be built the same way for the verification milestone. -
01_clone.txtarasserts'only git-upload-pack'for the HTTP push refusal, since git first issuesGET info/refs?service=git-receive-packand that handler's message is what surfaces. SSH-push milestone deviations: -
unpackno longer closes its reader: over SSHreq.Packfileis the session itself, and closing it before writing report-status disconnected the client (send-pack: unexpected disconnect while reading sideband packet). The session lifecycle is the caller's. -
internal/sshunit coverage is 33.3%.parseExec,loader.Load, andpublicKeyHandlerare at 100%;Serve/handler/serveUploadPackrequire a livegssh.Session(15-method interface) and are exercised by02_push.txtaragainst a realgit push/git cloneover SSH instead, which covers receive-pack, upload-pack via clone of the pushed repo, unknown-key rejection, and bad-exec rejection. MockingSessionwould duplicate that with weaker evidence. Total internal coverage is 77.4%. -
testscript
envsplits on spaces;GIT_SSH_COMMANDis set withenv 'GIT_SSH_COMMAND=ssh -i '$WORK'/alice ...'(single-quoted segments around the literal parts). -
Security checklist for
internal/ssh:parseExecrejects anything butgit-upload-pack/git-receive-packfollowed by exactly oneowner/namepath (no.., no nested/);gitstore.Pathindependently validates names against[A-Za-z0-9][A-Za-z0-9._-]*so traversal is refused at both layers. -
capcheck: new capability EXEC, transitive via
modernc.org/sqlite→modernc.org/libc(the cgo-free libc shim has exec syscall stubs). silo's own code does not callexec.Command. Baseline updated to 11 capabilities.
gittuf-verification milestone deviations:
-
InitSkeletonis not implemented. Everyexperimental/gittufpolicy builder requires adsse.SignerVerifierand signs as it goes; there is no way to write unsigned staging metadata through the API. Per the predicted wrinkle, took option (a):repo createmakes the bare repo only and the "repo not initialised" rejection tells the owner to run the gittuf CLI locally. The README anddocs/trust-model.mddocument the owner's command sequence (which differs from this file's draft txtar; the actual gittuf workflow istrust init→trust add-policy-key→policy init --policy-name targets→policy add-person→policy add-rule --authorize <person-ID>→policy stage→policy apply). Recorded in GITTUF-NOTES. -
Witnessis a no-op.RecordRSLEntryForReferencereads its signer from the bare repo's git config (user.signingKey/gpg.format) rather than accepting adsse.SignerVerifier. Writing the forge key path into every bare repo's config is the shim; deferred since the witness role is not policy-required and verification passes without it. Recorded in GITTUF-NOTES as friction with a proposedWithSigneroption. -
Server-side
VerifyReffailed on bare repos withrepository does not existinside gittuf'sverifyCommitSignature, traced topkg/gitinterface/repository.go:39GetGoGitRepository()callinggit.PlainOpenWithOptions(gitDirPath, {DetectDotGit: true}): with detect on, go-git looks for a.gitentry inside the (already-resolved) git dir, finds none in a bare layout, and errors. Confirmed by direct probe:DetectDotGit:falseopens the same path;truefails. Upstream fix is one flag flip. silo workaround atinternal/gitstore/gitstore.go: write.gitcontaininggitdir: .inside each bare repo so go-git's gitfile resolver dereferences it back to the bare dir. With that in place, server-sideVerifyRefpasses and bob's unauthorised push is rejected with the full multi-line policy format. Recorded in GITTUF-NOTES with the exact reproduction and fix. -
Builtin.PreReceiveapplies all proposed updates (gittuf refs first, then others) before callingVerifyRef, since verification reads the on-disk policy/RSL/ref state via shelled git. On verify failure all updates roll back.receive.ReceivePack's own apply step then re-sets refs to the same values, which is a no-op. The flock spans PreReceive through PostReceive. -
internal/ssh.HooksFactoryreturns a fresh*Builtinper push sinceBuiltinholds the per-push lock handle. -
02_push.txtar restructured: with gittuf hooks active a plain push to main is correctly refused, so the SSH-transport milestone now proves itself by pushing to
refs/gittuf/policy-staging(allowed without policy) and asserting the "not initialised" rejection on main. The full happy-path push lives in 03_gittuf. -
Unit coverage for
internal/gittuf(20.7%) andinternal/hooks(53.8%):Open/HasSignedRoot/VerifyRef/RuleForandbuildRejectionneed a real gittuf-initialised repo with signed policy and RSL, which is exactly what03_gittuf.txtarconstructs end to end with real keys and the gittuf CLI. Reproducing that in unit tests would duplicate the txtar with weaker fidelity. -
capcheck baseline at 13 capabilities after pulling in gittuf (sigstore-go, lua sandbox, etc. transitively). New since SSH milestone: CGO (false positive from a transitive dep's build tag, silo binary is
CGO_ENABLED=0) and EXEC paths via gittuf'sgitinterfaceshelling to git, which is now genuinely reachable from silo's hooks. -
capcheck invocation is
go run github.com/git-pkgs/capcheck/cmd/capcheck@latest(binary lives undercmd/). Baseline at this milestone (capcheck.lock.json): NETWORK (HTTP listener), FILES ($SILO_DATA), READ_SYSTEM_STATE/OPERATING_SYSTEM (os/signal, env), REFLECT/UNSAFE_POINTER/RUNTIME/SYSTEM_CALLS (transitive via go-git and stdlib net), ARBITRARY_EXECUTION and MODIFY_SYSTEM_STATE (transitive via go-git's ssh transport which can exec ssh-agent; not reachable from silo's code paths yet). No exec from silo itself; no cgo.