Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Close the approval-forgery hole: check every write, not just additions

362932289cfbecbca4f9937def07b21671bc1aee · Keenan Nemetz · 17d ago · parent 1eeca4e6

Close the approval-forgery hole: check every write, not just additions

The check that an approval is written by the principal it names used
`--diff-filter=A`, so it only looked at newly added files. An approval that already
existed could simply be edited: approve commit A, then rewrite approvals/<me>.json
to name commit B and land B with the approver's name on it. A test now demonstrates
that exact forgery before the fix and refuses it after.

The check is now driven by the change status of every path: any non-deletion on
`approvals/<name>.json` requires the actor to be `<name>`, and the first push (no
old commit to diff, so every approval in the tree is new) is checked the same way.
Deletion is limited to the owner, because removing someone else's approval forges
nothing but erases their statement; an admin may still do it, and that is written to
the audit log rather than happening silently.

Renames land in the same net: without rename detection a move appears as an add,
which the check sees, and the destination path is what the check reads.

Also worth recording what the enforcement actually rests on, since the file name is
the only name that carries authority: the SSH handshake resolves the presented key
to a user through data/trusted_keys/<user>.keys, that mapping is one-to-one (a key
already owned by another user is refused at add time and fatal at load), and the
resolved name reaches the hook as VOLE_ACTOR, which the client cannot set because
the exec surface is a two-entry whitelist. `author` fields in meta.json remain
display-only and decide nothing.

The remaining limitation is honest: this is enforcement at push time, not proof at
read time. A clone shows that an approval file exists, not that the server saw its
owner write it. Signed attestations in Phase 7 are what would make it verifiable
offline.

internal/gitx/gitx.go +18−7

@@ -377,19 +377,30 @@ func (r *Repo) DiffNameStatus(from, to string) ([]string, error) {
377 377 return lines, nil
378 378 }
379 379
380 // AddedPaths lists the paths added between two commits.
381 func (r *Repo) AddedPaths(old, new string) ([]string, error) {
382 out, errs, err := r.run(nil, "diff-tree", "-r", "--no-commit-id", "--diff-filter=A", "--name-only", old, new)
380 // DiffStatus returns the change status of each path between two commits, as the
381 // single letter git uses: A added, M modified, D deleted, T type changed, R
382 // renamed, C copied. Callers that guard a path must treat every status except D
383 // as a write, or a check that only looks at additions can be walked past by
384 // editing a file that already exists.
385 func (r *Repo) DiffStatus(old, new string) (map[string]string, error) {
386 out, errs, err := r.run(nil, "diff-tree", "-r", "--no-commit-id", "--name-status", old, new)
383 387 if err != nil {
384 388 return nil, fmt.Errorf("diff-tree %s..%s: %w: %s", old, new, err, errs)
385 389 }
386 var paths []string
390 status := map[string]string{}
387 391 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
388 if line != "" {
389 paths = append(paths, line)
392 if line == "" {
393 continue
394 }
395 parts := strings.SplitN(line, "\t", 2)
396 if len(parts) != 2 {
397 continue
390 398 }
399 // Rename and copy entries carry two paths; the destination is the one that
400 // matters for a write check.
401 status[strings.TrimSpace(parts[1])] = strings.TrimSpace(parts[0])[:1]
391 402 }
392 return paths, nil
403 return status, nil
393 404 }
394 405
395 406 // CommitParents returns a commit's parents, in order.