Closes #6.
What was wrong
data/audit.log had six independent writers — server/web/audit.go,
forge/identity, forge/session, forge/access, server/hook,
server/ssh/admin.go and core/lfs/lockstore.go — with four different column
layouts between them. Every one treated failure as best-effort, and two of them
did not escape their fields, so a key comment or an LFS lock path containing a
tab or a newline could forge a column or a whole record.
What this does
- One writer.
internal/auditrenders every record astimestamp, action, actor, subject, detail; every field is escaped and capped at 4 KiB, and onewrite(2)per line underO_APPENDkeeps concurrent processes from interleaving.secure.OpenAppendkeeps the file at0600, including an upgrade from a log created0644. - Fail closed, before the change. The line is written before the mutation:
roster/key/role changes (inside the transaction, before
Commit), ACL changes, repository deletion, session create/revoke, forced LFS unlock, admin commands, and every push override (lock, approval, release, history). A failed write means the change did not happen, and the command says so. - Loud where refusal is not possible. Ordinary pushes and web actions write
nothing they could roll back, so a failure is reported at error level (or on the
pusher’s terminal from
post-receive) instead of making a working push look rejected. - Startup check. Opening an instance verifies the log is a regular file at
0600, owned by the service account, and appendable, and refuses to start otherwise. A symlink is refused: it lets someone outside the service decide whether privileged actions are recorded. - No knob. There is no setting that turns the log off or back to best-effort, per the decision on the issue.
Log format change
This is a breaking change for anyone parsing the file, and the file is append-only, so old lines stay as they are:
before: 2026-10-27T00:00:00Z a force-push happened
after: 2026-10-27T00:00:00Z push.force keenan vole refs/heads/vole aaaaaaaa -> bbbbbbbb
docs/operations.md#audit-log documents the columns, the fail-closed cases, and
how to ship the log off the host.
Verification
gofmt,go vet ./...,go test -race ./...clean../vole workflow checkand./vole workflow run verifypass.
Tests, each failing before its fix
internal/audit: column count, empty-field rendering, tab/newline/control injection, truncation at a rune boundary,0600on create and on upgrade, missing sink and missing action refused, write failure returned, 8 concurrent writers keeping 200 lines whole,Checkrefusing a symlink and a directory.forge/access,forge/identity,forge/session,core/lfs,server/web: field injection and unwritable-sink refusals with the underlying state unchanged.server/hook: an unwritable log refuses a lock override rather than granting it.server:vole-admin account createfails withaudit_unavailablewhen the log is a directory, and the account is absent;forge.Openrefuses a symlinked or non-file audit path.