mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
History showed what an agent run CHANGED. What it read lived in a daily aggregate with no session dimension, so the two could not be joined and nobody could answer "when my agent answered, what did it look at — and was it the fresh version or archive/retired-spec.md?". The join is one string carried through four places: hook -> spool -> hub -> run card. A run card now marks each change the run also read, lists the files it read and never touched, and says on screen why a read can be missing. The three landmines the issue asks be named here: 1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to writes on the note string would let any member with write access forge a note that collides with a teammate's run card and hang their reads off it. Fixed by adding journal.Op.Session — set only by `bdrive sync --hook`, never by --note — and joining on that. The note stays settable and stays untrusted; the join simply never reads it. Op.Session is additive JSONL and, like Mtime, is never an input to Less or Replay, so replay determinism is untouched and older ops carry "". The read half has the same hole one step further on: POST /reads takes the session id from the CLIENT, so a member could report reads under a teammate's session and paint files onto their card. Every session row is therefore pinned to the ownsDevice-validated device, and the query requires ?session= AND ?device= together — a forged row can only be found under the forger's own device, which MayActAs guarantees is never somebody else's. 2. BUCKET CARDINALITY. Putting the session in the read_stats key would take a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger loads whole at boot and full-scans on every heat request, hub-wide — so it would slow the Dashboard for projects that never ran an agent. This is the escape hatch the spec itself names, taken up front: session rows live in their own read_sessions repo, outside ReadLedger.byKey. No read_stats PK migration, no change to the resident-row count, ?by=device byte-identical. They get their own retention (session_retention_days, default 30) which DELETES rather than folds — no heat total was ever derived from them. 3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session that read a file it then deleted shows a change with no read. That is by design, and the run card says so in its footer rather than leaving it to read as a bug. Privacy ruling, written into internal/webapp/reads.go before anything serves it: a session id appears only in History responses on the op that carries it, and as a ?session= filter INPUT. It is never enumerated — no listing endpoint, no session column in /heat output, nothing new in ?by=device. Also: PendingReads now dedupes on (path, session), not path alone. Two agent sessions on one device between syncs used to collapse into one event carrying whichever session flushed last — one session's reads silently credited to another. Tests: journal round-trip + Less-ignores-Session; the forge test (`sync --note "claude-code session <someone-else's>"` leaves Session empty); a multi-device syncer test carrying the session through convergence; spool per-session dedup; hub round-trip, cross-device forge, query contract and non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts grouping incl. legacy fallback; a Playwright spec on the seeded run card.
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package store
|
|
|
|
// Round 5, from the completeness sweep: the exported surface of this package
|
|
// that no TestSec_* names. LogRead / PendingReads / ClearPendingReads and
|
|
// PutBlobBytes / PutBlobFile / PutBlobReader were on that list; the read spool
|
|
// is the one that turned out to matter.
|
|
//
|
|
// Round 4 made the volume's own state 0600 because "every local account could
|
|
// read a private project's path list, authorship and signed-in emails"
|
|
// (TestSec_Store_VolumeJournalsAreNotWorldReadable). It changed
|
|
// WriteFileAtomic's callers. The read spool is written by a different call —
|
|
// os.OpenFile(..., 0o644) in reads.go — and holds exactly the same class of
|
|
// data: every path an agent read inside the project, timestamped.
|
|
//
|
|
// Helper prefix: secdef.
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// secdefModes lists the mode of every regular file the store created under
|
|
// its directory, so a new state file cannot be added at 0644 unnoticed.
|
|
func secdefModes(t *testing.T, dir string) map[string]os.FileMode {
|
|
t.Helper()
|
|
out := map[string]os.FileMode{}
|
|
err := filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fi.Mode().IsRegular() {
|
|
rel, _ := filepath.Rel(dir, p)
|
|
out[rel] = fi.Mode().Perm()
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSec_Store_ReadSpoolIsNotWorldReadable: the agent read log is a list of
|
|
// the files a person or agent opened inside a project — which files exist,
|
|
// which ones matter, and when they were touched. On a shared machine that is
|
|
// exactly what round 4 decided the journal must not expose, and the spool sits
|
|
// beside it in the same 0755 volume directory at mode 0644.
|
|
func TestSec_Store_ReadSpoolIsNotWorldReadable(t *testing.T) {
|
|
s, _ := secpkgStore(t)
|
|
if err := s.LogRead("secret-project/acquisition-plan.md", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for rel, mode := range secdefModes(t, s.Dir()) {
|
|
if mode&0o077 == 0 {
|
|
continue
|
|
}
|
|
// The flock file is empty by design and carries nothing.
|
|
if rel == "lock" {
|
|
continue
|
|
}
|
|
found = true
|
|
t.Errorf("%s is mode %04o — every local account can read it; "+
|
|
"the read spool names the files this project's agent opened", rel, mode)
|
|
}
|
|
if !found {
|
|
// Prove the spool was actually written, so a green run means the
|
|
// guard held rather than that nothing was created.
|
|
if len(secdefModes(t, s.Dir())) == 0 {
|
|
t.Fatal("fixture wrong: LogRead created no file to check")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSec_Store_ReadSpoolSurvivesAHostilePathAsData is the injection question
|
|
// for the same file: the paths come from an agent hook firing on every Read,
|
|
// Grep and Bash, so they are whatever a filename in a synced project says.
|
|
// A newline or a brace must stay inside one JSON record and must not forge a
|
|
// second read event.
|
|
func TestSec_Store_ReadSpoolSurvivesAHostilePathAsData(t *testing.T) {
|
|
s, _ := secpkgStore(t)
|
|
hostile := []string{
|
|
"notes/real.md",
|
|
"a\n{\"path\":\"forged/by-newline.md\",\"time\":\"2030-01-01T00:00:00Z\"}\nb.md",
|
|
"quote\"break.md",
|
|
"brace}{.md",
|
|
"tab\there.md",
|
|
}
|
|
for _, p := range hostile {
|
|
if err := s.LogRead(p, ""); err != nil {
|
|
t.Fatalf("LogRead(%q): %v", p, err)
|
|
}
|
|
}
|
|
got, err := s.PendingReads()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range got {
|
|
seen[e.Path] = true
|
|
}
|
|
for _, p := range hostile {
|
|
if !seen[p] {
|
|
t.Errorf("LogRead(%q) did not round-trip through the spool: %+v", p, got)
|
|
}
|
|
}
|
|
if seen["forged/by-newline.md"] {
|
|
t.Error("a newline inside one path forged a second read event in the spool: " +
|
|
"the spool is line-delimited and the path is not encoded as data")
|
|
}
|
|
if len(got) != len(hostile) {
|
|
t.Errorf("spool holds %d events for %d logged reads: %+v", len(got), len(hostile), got)
|
|
}
|
|
}
|